Compare commits

...
Author SHA1 Message Date
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
257 changed files with 24797 additions and 5633 deletions
+11
View File
@@ -73,3 +73,14 @@ loop/loop.log.bak
# Separate repos nested in tree
web/
._*
# Resilience harness reports (generated, contains session cookies)
scripts/resilience/reports/
# Codex / pnpm / python caches / editor backups
.codex
.pnpm-store/
**/__pycache__/
*.bak
.claude/scheduled_tasks.lock
+52
View File
@@ -1,5 +1,57 @@
# Changelog
## v1.7.52-alpha (2026-05-05)
- Tailscale now launches the local installed web UI on port `8240` and starts `tailscaled` before `tailscale web`, fixing unreachable installs after container creation.
- Grafana install/start/restart now repairs missing rootless host listeners on port `3000`, matching the existing SearXNG, Uptime Kuma, and Gitea recovery path.
- Debian 13/Trixie ISO and disk-install paths now force security updates from `trixie-security` during image/install creation so rebuilt release media includes patched base packages.
- Broad `.198` lifecycle audit passes with the current qualified app set; known absent blockers remain `electrumx`, `photoprism`, `dwn`, and `ollama`.
## v1.7.49-alpha (2026-04-30)
- Bitcoin Knots/Core UI now reports connection, reconnecting, syncing, and error states from a backend status bridge instead of showing a stale "Unable to connect" message while the node is warming up.
- ElectrumX UI now exposes indexed height, local Bitcoin height, known headers, status, and progress source so indexing/waiting states are readable during long initial sync.
- Added container doctor timer and smoke/lifecycle test coverage for Bitcoin Knots/Core, ElectrumX, Mempool, BTCPay/NBXplorer, and UI surface availability.
- Bitcoin Core and Bitcoin Knots are mutually exclusive variants, with a real Bitcoin Core manifest and corrected install conflict handling.
- IndeeHub now launches only on direct web UI port `7778`; the broken `/app/indeedhub/` path proxy was removed, and port `7777` remains the Nostr relay.
- BTCPay/NBXplorer Postgres environment formatting fixed so installs do not carry malformed connection strings.
## v1.7.48-alpha (2026-04-29)
- archipelago.service no longer fails to start with "Failed to set up mount namespacing: /run/containers: No such file or directory" on nodes where /run/containers wasn't pre-created. ExecStartPre now creates it. Existing nodes need a one-time `systemctl edit archipelago` to add the mkdir; ISO installs from this version forward have the fix baked in.
## v1.7.47-alpha (2026-04-29)
- Bitcoin Knots/Core sync is now significantly faster. The container now uses every available core for script verification (was capped at 2) and has 8GB of memory instead of 4GB so its 4GB UTXO cache has headroom for the mempool and peer connections. Existing nodes pick up the new limits on next install/update; freshly-installed nodes start at full speed.
- ElectrumX initial indexing is faster too. Its container memory bumped from 1GB to 2GB and its internal cache is now 2GB (default was 1.2GB).
## v1.7.46-alpha (2026-04-29)
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
## v1.7.45-alpha (2026-04-29)
- Bitcoin RPC auth is durable. The dashboard reliably connects across container restart, image update, and reboot. Was failing on registry-pulled images that shipped a stale baked-in password.
- 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/),
+10 -4
View File
@@ -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
```
+159 -139
View File
@@ -1,7 +1,7 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "git.tx1138.com/lfg2025",
"registry": "146.59.87.168:3000/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
@@ -11,240 +11,260 @@
},
"apps": [
{
"id": "bitcoin-knots", "title": "Bitcoin Knots", "version": "28.1.0",
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Run a full Bitcoin node. Validate and relay blocks and transactions.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/bitcoin-knots:latest",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core", "title": "Bitcoin Core", "version": "28.4",
"description": "Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks.",
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4",
"description": "Reference Bitcoin node implementation. Alternative to Bitcoin Knots; uninstall Knots before switching.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors", "category": "money", "tier": "optional",
"dockerImage": "bitcoin/bitcoin:28.4",
"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",
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network Daemon. Fast Bitcoin payments through Lightning.",
"icon": "/assets/img/app-icons/lnd.svg",
"author": "Lightning Labs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/lnd:v0.18.4-beta",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server", "title": "BTCPay Server", "version": "1.13.7",
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "1.13.7",
"description": "Self-hosted Bitcoin payment processor.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation", "category": "commerce", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/btcpayserver:1.13.7",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/btcpayserver:1.13.7",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool", "title": "Mempool Explorer", "version": "3.0.0",
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Self-hosted Bitcoin blockchain and mempool visualizer.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/mempool-frontend:v3.0.0",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0",
"repoUrl": "https://github.com/mempool/mempool",
"requires": ["bitcoin-knots", "electrumx"]
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx", "title": "ElectrumX", "version": "1.18.0",
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum protocol server. Index the blockchain for fast wallet lookups.",
"icon": "/assets/img/app-icons/electrumx.webp",
"author": "Luke Childs", "category": "money", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/electrumx:v1.18.0",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": ["bitcoin-knots"]
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub", "title": "IndeeHub", "version": "1.0.0",
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming with Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/indeedhub:1.0.0",
"author": "IndeeHub",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights", "title": "BotFights", "version": "1.1.0",
"id": "botfights",
"title": "BotFights",
"version": "1.1.0",
"description": "Bot arena + 2-player arcade fighter with controller support and Adventure Mode.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights", "category": "community",
"dockerImage": "git.tx1138.com/lfg2025/botfights:1.1.0",
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.1.0",
"repoUrl": "https://botfights.net"
},
{
"id": "gitea", "title": "Gitea", "version": "1.23",
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with container registry, CI/CD, issue tracking.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea", "category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"author": "Gitea",
"category": "development",
"dockerImage": "146.59.87.168:3000/lfg2025/gitea:1.23",
"repoUrl": "https://gitea.com"
},
{
"id": "filebrowser", "title": "File Browser", "version": "2.27.0",
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Web-based file manager.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser", "category": "data", "tier": "core",
"dockerImage": "git.tx1138.com/lfg2025/filebrowser:v2.27.0",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser"
},
{
"id": "vaultwarden", "title": "Vaultwarden", "version": "1.30.0",
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/vaultwarden:1.30.0-alpine",
"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"
},
{
"id": "searxng", "title": "SearXNG", "version": "2024.1.0",
"id": "searxng",
"title": "SearXNG",
"version": "2024.1.0",
"description": "Privacy-respecting metasearch engine.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/searxng:latest",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng"
},
{
"id": "nostr-rs-relay", "title": "Nostr Relay", "version": "0.9.0",
"description": "Your own Nostr relay. Store events locally, relay for friends.",
"icon": "/assets/img/app-icons/nostr-rs-relay.svg",
"author": "scsiblade", "category": "nostr",
"dockerImage": "git.tx1138.com/lfg2025/nostr-rs-relay:0.9.0",
"repoUrl": "https://sr.ht/~gheartsfield/nostr-rs-relay/"
},
{
"id": "fedimint", "title": "Fedimint", "version": "0.10.0",
"id": "fedimint",
"title": "Fedimint",
"version": "0.10.0",
"description": "Federated Bitcoin mint with privacy through federated guardians.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint", "category": "money",
"dockerImage": "git.tx1138.com/lfg2025/fedimintd:v0.10.0",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "ollama", "title": "Ollama", "version": "0.5.4",
"description": "Run AI models locally. Private and on your hardware.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "nextcloud", "title": "Nextcloud", "version": "28",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server"
},
{
"id": "jellyfin", "title": "Jellyfin", "version": "10.8.13",
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/jellyfin:10.8.13",
"author": "Jellyfin",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin"
},
{
"id": "immich", "title": "Immich", "version": "1.90.0",
"id": "immich",
"title": "Immich",
"version": "1.90.0",
"description": "High-performance photo and video backup with ML.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich", "category": "data",
"dockerImage": "git.tx1138.com/lfg2025/immich-server:release",
"author": "Immich",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant", "title": "Home Assistant", "version": "2024.1",
"id": "homeassistant",
"title": "Home Assistant",
"version": "2024.1",
"description": "Open-source home automation.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant", "category": "home",
"dockerImage": "git.tx1138.com/lfg2025/home-assistant:2024.1",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2024.1",
"repoUrl": "https://github.com/home-assistant/core"
},
{
"id": "grafana", "title": "Grafana", "version": "10.2.0",
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs", "category": "data", "tier": "recommended",
"dockerImage": "git.tx1138.com/lfg2025/grafana:10.2.0",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana"
},
{
"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",
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale"
},
{
"id": "uptime-kuma", "title": "Uptime Kuma", "version": "1.23.0",
"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",
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma"
},
{
"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",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism"
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "28",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:28",
"repoUrl": "https://github.com/nextcloud/server"
}
]
}
+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: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
+53 -31
View File
@@ -2,60 +2,82 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol.
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
container:
image: bitcoin/bitcoin:28.4
image_signature: cosign://...
pull_policy: verify-signature
image: 146.59.87.168:3000/lfg2025/bitcoin:28.4
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
data_uid: "100101:100101"
dependencies:
- storage: 500Gi # Minimum disk space for mainnet
- storage: 500Gi
resources:
cpu_limit: 0 # 0 = unlimited; bitcoind uses -par=auto across all cores
memory_limit: 4Gi # matches container-specs.sh bitcoin-knots large-disk dbcache=4096
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
+83
View File
@@ -0,0 +1,83 @@
app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
container:
image: 146.59.87.168:3000/lfg2025/bitcoin-knots:latest
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
custom_args:
# Sync-speed flags: -par=0 uses every core (was capped at 2 by
# --cpus=2, now removed for bitcoin/electrumx). -dbcache sized to
# the IBD sweet spot - 4GB on full nodes, 1GB on pruned. Container
# --memory=8g (config.rs::get_memory_limit) leaves headroom for
# mempool + connections.
- >-
BITCOIND="$(command -v bitcoind || true)";
if [ -z "$BITCOIND" ]; then
BITCOIND="$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)";
fi;
if [ -z "$BITCOIND" ]; then
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
fi
derived_env:
- key: DISK_GB
template: "{{DISK_GB}}"
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
data_uid: "100101:100101"
dependencies:
- storage: 500Gi
resources:
cpu_limit: 0
memory_limit: 4Gi
disk_limit: 500Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE]
readonly_root: false
network_policy: isolated
ports:
- host: 8332
container: 8332
protocol: tcp
- host: 8333
container: 8333
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/bitcoin
target: /home/bitcoin/.bitcoin
options: [rw]
environment:
- BITCOIN_RPC_USER=archipelago
health_check:
type: tcp
endpoint: localhost:8332
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
testnet_support: false
pruning_support: true
+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
+49 -34
View File
@@ -1,66 +1,81 @@
app:
id: btcpay-server
name: BTCPay Server
version: 1.12.0
version: 1.13.7
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: git.tx1138.com/lfg2025/btcpayserver:1.13.7
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BTCPAY_BTCRPCPASSWORD
secret_file: bitcoin-rpc-password
- key: BTCPAY_DB_PASS
secret_file: btcpay-db-password
derived_env:
- key: BTCPAY_HOST
template: "{{HOST_IP}}:23000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
- app_id: lnd
version: ">=0.18.0"
- app_id: archy-btcpay-db
version: ">=15.17"
- app_id: archy-nbxplorer
version: ">=2.6.0"
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 20Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
capabilities: []
readonly_root: false
network_policy: isolated
apparmor_profile: btcpay
ports:
- host: 80
container: 80
- host: 23000
container: 49392
protocol: tcp
- host: 443
container: 443
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/btcpay
target: /datadir
options: [rw]
environment:
- BTCPAY_NETWORK=mainnet
- BTCPAY_CHAIN=btc
- BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332
- BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true
- ASPNETCORE_URLS=http://0.0.0.0:49392
- BTCPAY_PROTOCOL=http
- BTCPAY_CHAINS=btc
- BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838
- BTCPAY_BTCRPCURL=http://bitcoin-knots:8332
- BTCPAY_BTCRPCUSER=archipelago
- BTCPAY_POSTGRES=Username=btcpay;Password=${BTCPAY_DB_PASS};Host=archy-btcpay-db;Port=5432;Database=btcpay
health_check:
type: http
endpoint: http://localhost
path: /health
endpoint: http://localhost:49392
path: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
lightning_integration:
payment_processing: true
payment_processing: false
invoice_management: true
interfaces:
main:
name: Web UI
description: BTCPay Server dashboard
type: ui
port: 23000
protocol: http
path: /
+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
+62
View File
@@ -0,0 +1,62 @@
app:
id: electrumx
name: ElectrumX
version: 1.18.0
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
image: git.tx1138.com/lfg2025/electrumx:v1.18.0
pull_policy: if-not-present
network: archy-net
data_uid: "1000:1000"
entrypoint: ["sh", "-lc"]
custom_args:
- >-
export DAEMON_URL="http://archipelago:${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: 2
memory_limit: 2Gi
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
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
+30 -24
View File
@@ -3,56 +3,62 @@ app:
name: Fedimint
version: 0.10.0
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: fedimint/fedimintd:v0.10.0
image_signature: cosign://...
image: git.tx1138.com/lfg2025/fedimintd:v0.10.0
pull_policy: if-not-present
network: archy-net
derived_env:
- key: FM_P2P_URL
template: fedimint://{{HOST_MDNS}}:8173
- key: FM_API_URL
template: ws://{{HOST_MDNS}}:8174
secret_env:
- key: FM_BITCOIND_PASSWORD
secret_file: bitcoin-rpc-password
data_uid: "1000:1000"
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
version: ">=26.0"
- storage: 20Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 20Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: fedimint
ports:
- host: 8173
container: 8173
protocol: tcp # P2P
protocol: tcp
- host: 8174
container: 8174
protocol: tcp # API
protocol: tcp
- host: 8175
container: 8175
protocol: tcp # Built-in Guardian UI
protocol: tcp
volumes:
- type: bind
source: /var/lib/archipelago/fedimint
target: /fedimint
target: /data
options: [rw]
environment:
- FM_DATA_DIR=/fedimint
- FM_BITCOIND_URL=http://bitcoin-core:8332
- FM_BITCOIND_USERNAME=${BITCOIN_RPC_USER}
- FM_BITCOIND_PASSWORD=${BITCOIN_RPC_PASSWORD}
- FM_DATA_DIR=/data
- FM_BITCOIND_URL=http://host.archipelago:8332
- FM_BITCOIND_USERNAME=archipelago
- FM_BITCOIN_NETWORK=bitcoin
- FM_BIND_P2P=0.0.0.0:8173
- FM_BIND_API=0.0.0.0:8174
- FM_BIND_UI=0.0.0.0:8175
health_check:
type: http
endpoint: http://localhost:8175
@@ -60,7 +66,7 @@ app:
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
+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
+2 -3
View File
@@ -29,9 +29,8 @@ environment:
GITEA__repository__ENABLE_PUSH_CREATE_USER: "true"
GITEA__repository__ENABLE_PUSH_CREATE_ORG: "true"
# Gitea hardcodes X-Frame-Options: SAMEORIGIN which blocks iframe embedding.
# Container binds to internal_port (3001), nginx proxies public port (3000)
# stripping the X-Frame-Options header so the app works in Archipelago's iframe.
# Gitea hardcodes X-Frame-Options: SAMEORIGIN, so Archipelago opens it in a
# new tab on host port 3001 instead of embedding it in an iframe.
nginx_proxy:
listen: 3000
proxy_pass: "http://127.0.0.1:3001"
+3 -3
View File
@@ -27,7 +27,7 @@ app:
apparmor_profile: grafana
ports:
- host: 3001
- host: 3000
container: 3000
protocol: tcp # Web UI
@@ -40,12 +40,12 @@ app:
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}
- GF_SERVER_ROOT_URL=http://localhost:3001
- GF_SERVER_ROOT_URL=http://localhost:3000
- GF_INSTALL_PLUGINS=
health_check:
type: http
endpoint: http://localhost:3001
endpoint: http://localhost:3000
path: /api/health
interval: 30s
timeout: 5s
+12 -5
View File
@@ -6,8 +6,9 @@ app:
category: media
container:
image: git.tx1138.com/lfg2025/indeedhub:latest
image: 146.59.87.168:3000/lfg2025/indeedhub:latest
pull_policy: always # Pull from registry; falls back to local build
network: indeedhub-net
dependencies:
- storage: 1Gi
@@ -27,9 +28,9 @@ app:
apparmor_profile: default
ports:
- host: 7777
container: 3000
protocol: tcp # Web UI (Next.js)
- host: 7778
container: 7777
protocol: tcp # Web UI. Port 7777 on the host is reserved for Nostr relay.
volumes:
- type: tmpfs
@@ -38,6 +39,12 @@ app:
- type: tmpfs
target: /app/.next/cache
options: [rw,noexec,nosuid,size=128m]
- type: tmpfs
target: /run
options: [rw,nosuid,nodev,size=16m]
- type: tmpfs
target: /var/cache/nginx
options: [rw,nosuid,nodev,size=32m]
environment:
- NODE_ENV=production
@@ -57,7 +64,7 @@ app:
name: Web UI
description: Stream Bitcoin documentaries with Nostr identity
type: ui
port: 7777
port: 7778
protocol: http
path: /
+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
+27 -29
View File
@@ -1,67 +1,65 @@
app:
id: lnd
name: Lightning Network Daemon
version: 0.18.0
version: 0.18.4
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: lightninglabs/lnd:v0.18.0
image_signature: cosign://...
pull_policy: verify-signature
image: git.tx1138.com/lfg2025/lnd:v0.18.4-beta
pull_policy: if-not-present
network: archy-net
secret_env:
- key: BITCOIND_RPCPASS
secret_file: bitcoin-rpc-password
data_uid: "100000:100000"
dependencies:
- app_id: bitcoin-core
version: ">=26.0"
resources:
cpu_limit: 2
memory_limit: 1Gi
disk_limit: 10Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW]
readonly_root: false
network_policy: isolated
apparmor_profile: lnd
ports:
- host: 9735
container: 9735
protocol: tcp # P2P
protocol: tcp
- host: 10009
container: 10009
protocol: tcp # gRPC
protocol: tcp
- host: 8080
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: /
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: read-only
sync_required: true
pruning_support: false
+2 -1
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
@@ -34,7 +35,7 @@ app:
volumes:
- type: bind
source: /var/lib/archipelago/nostr-relay
target: /app/db
target: /usr/src/app/db
options: [rw]
environment:
-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 @@
# 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
+2 -3
View File
@@ -1,12 +1,11 @@
app:
id: searxng
name: SearXNG
version: 2024.1.0
version: latest
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
image: searxng/searxng:2024.1.0
image_signature: cosign://...
image: 146.59.87.168:3000/lfg2025/searxng:latest
pull_policy: if-not-present
dependencies:
+2 -1
View File
@@ -80,13 +80,14 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.36-alpha"
version = "1.7.52-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.36-alpha"
version = "1.7.52-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"
+9 -4
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?,
);
@@ -125,8 +130,7 @@ impl ApiHandler {
/// 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
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" };
@@ -141,7 +145,7 @@ impl ApiHandler {
}
if upstreams.is_empty() {
upstreams.push(
"http://23.182.128.160:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
"http://146.59.87.168:3000/lfg2025/app-catalog/raw/branch/main/catalog.json"
.to_string(),
);
upstreams.push(
@@ -316,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") => {
@@ -425,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
+18 -5
View File
@@ -1,5 +1,6 @@
use super::build_response;
use crate::api::rpc::RpcHandler;
use crate::bitcoin_status;
use crate::electrs_status;
use anyhow::Result;
use hyper::{Response, StatusCode};
@@ -76,11 +77,23 @@ impl ApiHandler {
pub(super) async fn handle_electrs_status() -> Result<Response<hyper::Body>> {
let status = electrs_status::get_electrs_sync_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
pub(super) async fn handle_bitcoin_status() -> Result<Response<hyper::Body>> {
let status = bitcoin_status::get_bitcoin_status().await;
let body = serde_json::to_vec(&status).unwrap_or_default();
Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
pub(super) async fn handle_lnd_connect_info(
+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));
}
}
+262 -70
View File
@@ -1,4 +1,5 @@
use super::package::validate_app_id;
use super::transitional::Op;
use super::RpcHandler;
use anyhow::{Context, Result};
@@ -7,8 +8,13 @@ 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)")
// 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 +51,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 +63,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 +70,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 +95,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 +153,7 @@ impl RpcHandler {
.unwrap_or(false);
orchestrator
.remove_container(app_id, preserve_data)
.remove(app_id, preserve_data)
.await
.context("Failed to remove container")?;
@@ -137,12 +171,25 @@ impl RpcHandler {
.package_data
.iter()
.map(|(id, pkg)| {
// Keep this mapping in sync with the UI's
// ContainerStatus.state union in
// neode-ui/src/api/container-client.ts. The UI maps
// transitional variants to single-button labels
// (Stopping… / Starting… / Restarting…).
let state = match &pkg.state {
crate::data_model::PackageState::Running => "running",
crate::data_model::PackageState::Stopped => "stopped",
crate::data_model::PackageState::Exited => "exited",
crate::data_model::PackageState::Starting => "created",
_ => "unknown",
crate::data_model::PackageState::Starting => "starting",
crate::data_model::PackageState::Stopping => "stopping",
crate::data_model::PackageState::Restarting => "restarting",
crate::data_model::PackageState::Installing => "installing",
crate::data_model::PackageState::Installed => "installed",
crate::data_model::PackageState::Updating => "updating",
crate::data_model::PackageState::Removing => "removing",
crate::data_model::PackageState::CreatingBackup => "creating-backup",
crate::data_model::PackageState::RestoringBackup => "restoring-backup",
crate::data_model::PackageState::BackingUp => "backing-up",
};
let lan = pkg
.installed
@@ -163,9 +210,9 @@ impl RpcHandler {
return Ok(serde_json::json!(containers));
}
// Fallback: scanner hasn't run yet, query podman directly
// Fallback: scanner hasn't run yet, query the orchestrator directly.
if let Some(orchestrator) = &self.orchestrator {
if let Ok(containers) = orchestrator.list_containers().await {
if let Ok(containers) = orchestrator.list().await {
if !containers.is_empty() {
return Ok(serde_json::to_value(containers)?);
}
@@ -242,9 +289,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 +301,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 +341,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 +354,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,43 +371,63 @@ impl RpcHandler {
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let orchestrator = self.orchestrator.as_ref().ok_or_else(|| {
anyhow::anyhow!("Container orchestrator not available (dev mode required)")
})?;
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?;
// If app_id is provided, get health for that app
// If app_id is provided, get health for that app.
if let Some(params) = params {
if let Some(app_id) = params.get("app_id").and_then(|v| v.as_str()) {
let health = orchestrator
.get_health_status(app_id)
.await
.context("Failed to get container health")?;
return Ok(serde_json::json!({ app_id: health }));
let mut last_err: Option<anyhow::Error> = None;
for candidate in status_app_id_candidates(app_id) {
match orchestrator.health(&candidate).await {
Ok(health) => return Ok(serde_json::json!({ app_id: health })),
Err(e) => last_err = Some(e),
}
}
for name in status_container_name_candidates(app_id) {
if let Some(health) = inspect_container_health_value(&name).await {
return Ok(serde_json::json!({ app_id: health }));
}
}
if let Some(e) = last_err {
return Err(e.context("Failed to get container health"));
}
return Err(anyhow::anyhow!("Failed to get container health"));
}
}
// Otherwise, get health for all containers
// Otherwise, get health for all containers.
let containers = orchestrator
.list_containers()
.list()
.await
.context("Failed to list containers")?;
let mut health_map = serde_json::Map::new();
for container in containers {
if let Some(app_id) = container.name.strip_prefix("archipelago-") {
if let Some(app_id) = app_id.strip_suffix("-dev") {
match orchestrator.get_health_status(app_id).await {
Ok(health) => {
health_map
.insert(app_id.to_string(), serde_json::Value::String(health));
}
Err(_) => {
health_map.insert(
app_id.to_string(),
serde_json::Value::String("unknown".to_string()),
);
}
}
// Map the runtime container name back to the app_id the orchestrator
// knows about. Dev orchestrator uses `archipelago-<id>-dev`; Prod
// uses bare `<id>` (or `archy-<id>` for UIs — health() accepts the
// app_id either way since UI_APP_IDS is centralised).
let app_id_candidate = container
.name
.strip_prefix("archipelago-")
.and_then(|s| s.strip_suffix("-dev"))
.or_else(|| container.name.strip_prefix("archy-"))
.unwrap_or(container.name.as_str());
match orchestrator.health(app_id_candidate).await {
Ok(health) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String(health),
);
}
Err(_) => {
health_map.insert(
app_id_candidate.to_string(),
serde_json::Value::String("unknown".to_string()),
);
}
}
}
@@ -351,3 +435,111 @@ impl RpcHandler {
Ok(serde_json::Value::Object(health_map))
}
}
fn status_app_id_candidates(app_id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match app_id {
"bitcoin-knots" => {
push("bitcoin-knots");
push("bitcoin-core");
push("bitcoin");
}
"bitcoin-core" | "bitcoin" => {
push("bitcoin-core");
push("bitcoin-knots");
push("bitcoin");
}
"electrs" | "mempool-electrs" => {
push("electrs");
push("mempool-electrs");
push("electrumx");
}
"mempool" | "mempool-web" => {
push("mempool");
push("archy-mempool-web");
}
"immich" => {
push("immich");
push("immich_server");
}
_ => push(app_id),
}
out
}
fn status_container_name_candidates(app_id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match app_id {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => push("bitcoin-knots"),
"bitcoin-ui" => push("archy-bitcoin-ui"),
"lnd-ui" => push("archy-lnd-ui"),
"electrs-ui" => push("archy-electrs-ui"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" | "archy-mempool-web" => push("mempool"),
"immich" => push("immich_server"),
_ => {}
}
push(app_id);
if let Some(stripped) = app_id.strip_prefix("archy-") {
push(stripped);
} else {
push(&format!("archy-{}", app_id));
}
out
}
async fn inspect_container_state_value(name: &str) -> Option<serde_json::Value> {
let out = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{.State.Status}} {{.State.Running}}",
])
.output()
.await
.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";
Some(serde_json::json!({
"name": name,
"status": status,
"state": status,
"running": running,
}))
}
async fn inspect_container_health_value(name: &str) -> Option<String> {
let v = inspect_container_state_value(name).await?;
match v.get("state").and_then(|s| s.as_str()).unwrap_or("unknown") {
"running" => Some("healthy".to_string()),
"created" => Some("starting".to_string()),
"paused" => Some("paused".to_string()),
"exited" | "stopped" => Some("unhealthy".to_string()),
other => Some(format!("unknown:{other}")),
}
}
+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)
+16 -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,23 @@ impl RpcHandler {
"container-install" => self.handle_container_install(params).await,
"container-start" => self.handle_container_start(params).await,
"container-stop" => self.handle_container_stop(params).await,
"container-restart" => self.handle_container_restart(params).await,
"container-remove" => self.handle_container_remove(params).await,
"container-list" => self.handle_container_list().await,
"container-status" => self.handle_container_status(params).await,
"container-logs" => self.handle_container_logs(params).await,
"container-health" => self.handle_container_health(params).await,
// Package management (for docker-compose apps)
"package.install" => self.handle_package_install(params).await,
// Package management (for docker-compose apps).
// install/uninstall/update return immediately with a
// transitional status; the actual work runs in a background
// tokio::spawn so the HTTP request doesn't block for minutes.
"package.install" => self.clone().spawn_package_install(params).await,
"package.start" => self.handle_package_start(params).await,
"package.stop" => self.handle_package_stop(params).await,
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.handle_package_uninstall(params).await,
"package.update" => self.handle_package_update(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
// Bundled app management (for pre-loaded container images)
@@ -525,6 +531,11 @@ impl RpcHandler {
Ok(serde_json::json!({ "message": "Hello from Archipelago!" }))
}
async fn handle_server_get_state(&self) -> Result<serde_json::Value> {
let (data, rev) = self.state_manager.get_snapshot().await;
Ok(serde_json::json!({ "data": data, "rev": rev }))
}
pub(super) async fn handle_health(&self) -> Result<serde_json::Value> {
let recovery_complete = crate::crash_recovery::is_recovery_complete();
let uptime = crate::crash_recovery::uptime_seconds();
@@ -403,7 +403,10 @@ impl RpcHandler {
});
let own_fips_npub = match own_fips_npub {
Some(n) => Some(n),
None => crate::fips::service::read_upstream_npub().await.ok().flatten(),
None => crate::fips::service::read_upstream_npub()
.await
.ok()
.flatten(),
};
let state = federation::build_local_state(
@@ -461,8 +464,7 @@ impl RpcHandler {
// the entry causes sync loops where the node syncs with itself
// forever. Drop it quietly — no useful recovery path.
let (own_data, _) = self.state_manager.get_snapshot().await;
let own_did_result =
identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_did_result = identity::did_key_from_pubkey_hex(&own_data.server_info.pubkey).ok();
let own_onion_trim = own_data
.server_info
.tor_address
@@ -568,11 +570,7 @@ impl RpcHandler {
let new_peer_did = did.to_string();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
if let Err(e) = crate::federation::sync_with_peer_by_did(
&data_dir,
&new_peer_did,
)
.await
if let Err(e) = crate::federation::sync_with_peer_by_did(&data_dir, &new_peer_did).await
{
tracing::debug!(
peer_did = %new_peer_did,
+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");
+4 -14
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};
#[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()
@@ -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);
@@ -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()
+39 -5
View File
@@ -4,7 +4,11 @@ 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";
// Shared LND response types used by multiple submodules
#[derive(Debug, serde::Deserialize)]
@@ -17,15 +21,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))
@@ -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);
}
+44 -11
View File
@@ -31,6 +31,7 @@ mod streaming;
mod system;
mod tor;
mod totp;
mod transitional;
mod transport;
mod update;
mod vpn;
@@ -39,7 +40,7 @@ mod webhooks;
use crate::auth::AuthManager;
use crate::config::Config;
use crate::container::DevContainerOrchestrator;
use crate::container::{ContainerOrchestrator, DevContainerOrchestrator};
use crate::monitoring::MetricsStore;
use crate::port_allocator::PortAllocator;
use crate::rate_limit::{EndpointRateLimiter, LoginRateLimiter};
@@ -62,7 +63,14 @@ pub(crate) const DEV_DEFAULT_PASSWORD: &str = "password123";
pub struct RpcHandler {
config: Config,
auth_manager: AuthManager,
orchestrator: Option<Arc<DevContainerOrchestrator>>,
/// Shared lifecycle orchestrator (Dev or Prod). Always `Some` in a normal
/// build — the only reason it is `Option` is so tests that don't exercise
/// container RPCs can skip constructing one.
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
/// Concrete handle to the dev orchestrator, when we're in dev mode. Used by
/// `container-install { manifest_path }` which takes an ad-hoc manifest
/// path and is not part of the shared trait.
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
state_manager: Arc<StateManager>,
pub(crate) metrics_store: Arc<MetricsStore>,
port_allocator: Arc<tokio::sync::Mutex<PortAllocator>>,
@@ -79,6 +87,15 @@ pub struct RpcHandler {
/// Our own Ed25519 pubkey hex — needed by ContentRef senders for cap scoping
/// and by ContentRef receivers to request caps scoped to themselves.
pub(crate) self_pubkey_hex: Arc<tokio::sync::RwLock<Option<String>>>,
/// Kick the package scanner to run immediately (bypassing the 60s interval).
/// Used by install/update success paths so the fresh manifest (with populated
/// `interfaces.main.ui`) lands before we flip state to Running — closes the
/// "Launch button is missing for up to 60s after install" UX gap.
pub(crate) scan_kick: Arc<tokio::sync::Notify>,
/// Monotonic counter incremented by the scan loop after each completed scan.
/// Install/update success paths subscribe to this to know when a kicked scan
/// has actually finished before flipping to the terminal state.
pub(crate) scan_tick: Arc<tokio::sync::watch::Sender<u64>>,
}
impl RpcHandler {
@@ -87,15 +104,10 @@ impl RpcHandler {
state_manager: Arc<StateManager>,
metrics_store: Arc<MetricsStore>,
session_store: SessionStore,
orchestrator: Option<Arc<dyn ContainerOrchestrator>>,
dev_orchestrator: Option<Arc<DevContainerOrchestrator>>,
) -> Result<Self> {
let auth_manager = AuthManager::new(config.data_dir.clone());
let orchestrator = if config.dev_mode {
Some(Arc::new(
DevContainerOrchestrator::new(config.clone()).await?,
))
} else {
None
};
let port_allocator = Arc::new(tokio::sync::Mutex::new(
PortAllocator::new(&config.data_dir).await?,
));
@@ -129,6 +141,7 @@ impl RpcHandler {
config,
auth_manager,
orchestrator,
dev_orchestrator,
state_manager,
metrics_store,
port_allocator,
@@ -140,6 +153,8 @@ impl RpcHandler {
transport_router: Arc::new(tokio::sync::RwLock::new(None)),
blob_store: Arc::new(tokio::sync::RwLock::new(None)),
self_pubkey_hex: Arc::new(tokio::sync::RwLock::new(None)),
scan_kick: Arc::new(tokio::sync::Notify::new()),
scan_tick: Arc::new(tokio::sync::watch::channel(0u64).0),
})
}
@@ -180,6 +195,21 @@ impl RpcHandler {
Arc::clone(&self.mesh_service)
}
/// Shared Notify handle the package-scanner loop waits on (in addition to
/// its periodic tick). Install/update success paths call `notify_one()` to
/// trigger an immediate scan so the fresh manifest lands before we flip to
/// the terminal Running state.
pub fn scan_kick(&self) -> Arc<tokio::sync::Notify> {
Arc::clone(&self.scan_kick)
}
/// Sender half of the scan-completion watch channel. The scanner bumps this
/// counter after every finished scan; install/update wait for an advance
/// after kicking so they know the fresh manifest has landed.
pub fn scan_tick(&self) -> Arc<tokio::sync::watch::Sender<u64>> {
Arc::clone(&self.scan_tick)
}
fn cookie_suffix_for_request(&self, headers: &hyper::header::HeaderMap) -> &'static str {
// Only set Secure flag when the original request was over HTTPS.
// Nginx sends X-Forwarded-Proto: https for HTTPS connections.
@@ -197,7 +227,10 @@ impl RpcHandler {
""
}
pub async fn handle(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
pub async fn handle(
self: Arc<Self>,
req: Request<hyper::Body>,
) -> Result<Response<hyper::Body>> {
// Extract session cookie before consuming the request
let (parts, body) = req.into_parts();
let session_token = session::extract_session_cookie(&parts.headers);
@@ -376,7 +409,7 @@ impl RpcHandler {
// Route to handler (track latency for metrics)
let rpc_start = std::time::Instant::now();
let result = self.dispatch(&rpc_req.method, params, &session_token).await;
let result = Self::dispatch(&self, &rpc_req.method, params, &session_token).await;
// Record RPC latency for monitoring
let elapsed_ms = rpc_start.elapsed().as_secs_f64() * 1000.0;
@@ -0,0 +1,442 @@
//! 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)?;
// 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;
}
+156 -51
View File
@@ -9,7 +9,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 +29,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"
)
}
@@ -70,19 +70,30 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETGID".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Bitcoin and Lightning need file ownership ops + NET_BIND_SERVICE for port binding
// LND additionally needs NET_RAW for TLS certificate generation (netlinkrib interface enumeration)
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "lnd" | "fedimint" | "fedimint-gateway" => {
vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
]
}
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
// RPC port. NO NET_RAW — bitcoind never opens raw sockets and
// dropping it removes a class of intra-pod spoofing capability.
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// LND additionally needs NET_RAW for TLS certificate generation
// (netlink interface enumeration during `lnd --tlscertpath` first run).
// Fedimint inherits the same set because the gateway also enumerates
// network interfaces on startup.
"lnd" | "fedimint" | "fedimint-gateway" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
"--cap-add=NET_RAW".to_string(),
],
// Vaultwarden needs file ownership + NET_BIND_SERVICE (binds port 80 internally)
"vaultwarden" => vec![
"--cap-add=CHOWN".to_string(),
@@ -174,7 +185,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
("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",
),
@@ -194,7 +205,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" => ("wget -q -O /dev/null http://localhost:8080/ || exit 1", "30s", "3"),
"photoprism" => (
"curl -sf http://localhost:2342/api/v1/status || exit 1",
"60s",
@@ -210,11 +226,7 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"portainer" => (
"curl -sf http://localhost:9000/api/status || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
"fedimint-gateway" => ("curl -sf http://localhost:8176/ || exit 1", "60s", "3"),
@@ -243,13 +255,19 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
/// Get per-app memory limit.
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
match app_id {
// Heavy apps
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "4g",
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
// headroom for mempool + connection buffers + script-verifier
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: bumped from 1g to 2g so its CACHE_MB has somewhere
// to live during initial blockchain indexing. CACHE_MB=2048 in
// env vars below requires this much.
"electrumx" | "mempool-electrs" | "electrs" => "2g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
"lnd" => "512m",
"electrumx" | "mempool-electrs" | "electrs" => "1g",
"nextcloud" => "1g",
"immich_server" | "immich" => "1g",
"btcpay-server" | "btcpayserver" => "1g",
@@ -291,16 +309,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 +384,15 @@ pub(super) fn all_container_names(package_id: &str) -> Vec<String> {
"penpot-exporter".into(),
"penpot-frontend".into(),
],
"indeedhub" => vec![
"indeedhub-postgres".into(),
"indeedhub-redis".into(),
"indeedhub-minio".into(),
"indeedhub-relay".into(),
"indeedhub-api".into(),
"indeedhub-ffmpeg".into(),
"indeedhub".into(),
],
"nostr-vpn" => vec![
"nostr-vpn".into(),
"archy-nostr-vpn".into(),
@@ -393,16 +427,34 @@ pub(super) async fn get_containers_for_app(package_id: &str) -> Result<Vec<Strin
Ok(result)
}
#[cfg(test)]
mod tests {
use super::all_container_names;
#[test]
fn bitcoin_variant_container_names_are_precise() {
let core = all_container_names("bitcoin-core");
assert!(core.contains(&"bitcoin-core".to_string()));
assert!(!core.contains(&"bitcoin-knots".to_string()));
let knots = all_container_names("bitcoin-knots");
assert!(knots.contains(&"bitcoin-knots".to_string()));
assert!(!knots.contains(&"bitcoin-core".to_string()));
}
}
/// Get data directories to clean for an app.
/// Caller must validate package_id before calling.
pub(super) fn get_data_dirs_for_app(package_id: &str) -> Vec<String> {
let base = "/var/lib/archipelago";
match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => {
vec![format!("{}/bitcoin", base), format!("{}/bitcoin-ui", base)]
}
"mempool" | "mempool-web" => vec![
format!("{}/mempool", base),
format!("{}/mysql-mempool", base),
format!("{}/electrumx", base),
format!("{}/mempool-electrs", base),
],
"fedimint" => vec![
format!("{}/fedimint", base),
@@ -483,7 +535,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(),
@@ -510,9 +598,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 +612,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 +625,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 +634,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 +656,19 @@ pub(super) async fn get_app_config(
vec!["/var/lib/archipelago/electrumx:/data".to_string()],
vec![
format!(
"DAEMON_URL=http://{}:{}@host.containers.internal:8332/",
"DAEMON_URL=http://{}:{}@bitcoin-knots:8332/",
rpc_user, rpc_pass
),
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB, container now
// gets 2g (config.rs::get_memory_limit) so 2048 fits.
"CACHE_MB=2048".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
],
None,
None,
@@ -587,7 +681,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,
@@ -729,27 +823,38 @@ pub(super) async fn get_app_config(
vec!["9000:9000".to_string()],
vec![
"/var/lib/archipelago/portainer:/data".to_string(),
"/var/run/podman/podman.sock:/var/run/docker.sock".to_string(),
"/run/user/1000/podman/podman.sock:/var/run/docker.sock".to_string(),
],
vec![],
None,
None,
),
"uptime-kuma" => (
vec!["3001:3001".to_string()],
vec!["3002:3001".to_string()],
vec!["/var/lib/archipelago/uptime-kuma:/app/data".to_string()],
vec!["TZ=UTC".to_string()],
None,
None,
Some(vec![
"--".to_string(),
"node".to_string(),
"server/server.js".to_string(),
]),
),
"tailscale" => (
vec!["8240:8240".to_string()],
vec!["/var/lib/archipelago/tailscale:/var/lib/tailscale".to_string()],
vec!["TS_STATE_DIR=/var/lib/tailscale".to_string()],
Some(
"sh -c 'tailscale web --listen 0.0.0.0:8240 & exec tailscaled'".to_string(),
),
// Don't use custom_command (Option<String>) — install.rs passes
// it as a SINGLE arg to podman, which then treats the whole
// "sh -c 'tailscale web …'" string as the executable name and
// fails: "executable file `sh -c 'tailscale web …'` not found".
// custom_args (Option<Vec<String>>) splits properly.
None,
Some(vec![
"sh".to_string(),
"-c".to_string(),
"tailscaled --tun=userspace-networking & sleep 2; tailscale web --listen 0.0.0.0:8240 & wait".to_string(),
]),
),
"fedimint" => (
vec![
@@ -768,13 +873,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 +903,7 @@ pub(super) async fn get_app_config(
"--network".to_string(),
"bitcoin".to_string(),
"--bitcoind-url".to_string(),
format!("http://{}:8332", host_ip),
"http://bitcoin-knots:8332".to_string(),
"--bitcoind-username".to_string(),
rpc_user.to_string(),
"--bitcoind-password".to_string(),
@@ -909,8 +1014,8 @@ pub(super) async fn get_app_config(
None,
)
}
// Gitea binds to 3001 internally. Nginx on port 3000 strips X-Frame-Options
// so Gitea works in Archipelago's iframe. See nginx-gitea-iframe.conf.
// Gitea listens on container port 3000 and is launched directly on
// host port 3001 because it blocks iframe embedding.
"gitea" => (
vec!["3001:3000".to_string(), "2222:22".to_string()],
vec![
@@ -1,5 +1,7 @@
use super::config::get_containers_for_app;
use anyhow::Result;
use crate::data_model::{PackageDataEntry, PackageState};
use anyhow::{Context, Result};
use std::collections::HashMap;
use tracing::info;
/// Names of container variants that represent a running Bitcoin node
@@ -8,6 +10,13 @@ const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
/// Names of container variants that represent a running Electrum indexer
const ELECTRUM_NAMES: &[&str] = &["electrumx", "mempool-electrs", "electrs"];
fn requires_unpruned_bitcoin(package_id: &str) -> bool {
matches!(
package_id,
"electrumx" | "mempool-electrs" | "electrs" | "mempool" | "mempool-web"
)
}
/// Snapshot of which dependency services are currently running.
pub(super) struct RunningDeps {
pub has_bitcoin: bool,
@@ -15,13 +24,43 @@ pub(super) struct RunningDeps {
pub has_lnd: bool,
}
pub(super) fn detect_running_deps_from_package_data(
packages: &HashMap<String, PackageDataEntry>,
) -> RunningDeps {
let is_running = |names: &[&str]| {
names.iter().any(|name| {
packages
.get(*name)
.map(|pkg| pkg.state == PackageState::Running)
.unwrap_or(false)
})
};
RunningDeps {
has_bitcoin: is_running(BITCOIN_NAMES),
has_electrumx: is_running(ELECTRUM_NAMES),
has_lnd: is_running(&["lnd"]),
}
}
/// Query podman for currently running containers and return dependency status.
pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
let dep_check = tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.await
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
let dep_check = tokio::time::timeout(
std::time::Duration::from_secs(30),
tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output(),
)
.await
.map_err(|_| anyhow::anyhow!("Timed out checking running containers"))?
.map_err(|e| anyhow::anyhow!("Failed to check running containers: {}", e))?;
if !dep_check.status.success() {
anyhow::bail!(
"Failed to check running containers: {}",
String::from_utf8_lossy(&dep_check.stderr).trim()
);
}
let running = String::from_utf8_lossy(&dep_check.stdout);
let is_running = |names: &[&str]| {
@@ -76,6 +115,65 @@ pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result
}
}
/// ElectrumX and Mempool's Electrum backend need historical blocks from an
/// unpruned node while building their indexes. A pruned Bitcoin node can be
/// running and RPC-reachable but still leave them stuck with closed ports.
pub(super) async fn check_bitcoin_pruning_compatibility(package_id: &str) -> Result<()> {
if !requires_unpruned_bitcoin(package_id) {
return Ok(());
}
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "package-install-prune-check",
"method": "getblockchaininfo",
"params": [],
});
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.context("building Bitcoin RPC client")?;
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("checking Bitcoin pruning status")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC response")?;
if !status.is_success() {
anyhow::bail!(
"Bitcoin RPC returned {} while checking pruning status",
status
);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC error while checking pruning status: {}", error);
}
let Some(result) = json.get("result") else {
anyhow::bail!("Bitcoin RPC response missing result while checking pruning status");
};
if result
.get("pruned")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
anyhow::bail!(
"{} requires an unpruned Bitcoin node while indexing. Current Bitcoin is pruned; use a full node with enough disk for txindex/full block history, then reinstall/restart {}.",
package_id,
package_id
);
}
Ok(())
}
/// Log informational messages about optional dependencies.
pub(super) fn log_optional_dep_info(package_id: &str, deps: &RunningDeps) {
if matches!(package_id, "btcpay-server" | "btcpayserver") && !deps.has_lnd {
@@ -129,6 +227,18 @@ pub(super) fn startup_order(package_id: &str) -> &'static [&'static str] {
"mempool",
],
"immich" => &["immich_postgres", "immich_redis", "immich_server"],
"indeedhub" => &[
"indeedhub-postgres",
"indeedhub-redis",
"indeedhub-minio",
"indeedhub-relay",
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub",
],
"btcpay-server" | "btcpayserver" | "btcpay" => {
&["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"]
}
"penpot" | "penpot-frontend" => &[
"penpot-postgres",
"penpot-valkey",
@@ -211,3 +321,24 @@ pub(super) fn configure_fedimint_lnd(
]);
}
}
#[cfg(test)]
mod tests {
use super::requires_unpruned_bitcoin;
#[test]
fn unpruned_bitcoin_required_for_electrum_indexers_and_mempool() {
for package_id in [
"electrumx",
"mempool-electrs",
"electrs",
"mempool",
"mempool-web",
] {
assert!(requires_unpruned_bitcoin(package_id), "{package_id}");
}
for package_id in ["bitcoin-knots", "btcpay-server", "lnd", "fedimint"] {
assert!(!requires_unpruned_bitcoin(package_id), "{package_id}");
}
}
}
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;
}
@@ -52,9 +94,12 @@ impl RpcHandler {
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
let existing_phase = entry.install_progress.as_ref().and_then(|p| p.phase);
entry.install_progress = Some(InstallProgress {
size: total,
downloaded,
phase: existing_phase,
message: None,
});
state_manager.update_data(data).await;
}
@@ -69,7 +114,11 @@ fn create_installing_entry(package_id: &str) -> PackageDataEntry {
static_files: StaticFiles {
license: String::new(),
instructions: String::new(),
icon: format!("/assets/img/app-icons/{}.png", package_id),
// Empty icon: hardcoding `<id>.png` is wrong for apps that use
// `.svg` or `.webp` assets and produces a broken-image flicker.
// The frontend's `icon` computed falls through to the curated
// map which has correct extensions for known apps.
icon: String::new(),
},
manifest: Manifest {
id: package_id.to_string(),
+688 -131
View File
@@ -3,7 +3,10 @@ use super::dependencies::ordered_containers_for_start;
use super::install::install_log;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::data_model::PackageState;
use anyhow::{Context, Result};
use std::sync::Arc;
use tracing::warn;
/// Per-container graceful shutdown timeout in seconds.
/// Bitcoin Core needs 600s to flush UTXO set, LND 330s for channel state,
@@ -25,6 +28,12 @@ pub fn stop_timeout_secs(container_name: &str) -> &'static str {
impl RpcHandler {
/// Start a package: start all containers in dependency order.
///
/// Returns immediately with `{ "status": "starting" }` after flipping
/// the package state to `Starting` in the StateManager. The actual
/// podman-start sequence + post-start exit verification runs in a
/// background task. On success the state becomes `Running`; on error
/// it reverts to the pre-transition state.
pub(in crate::api::rpc) async fn handle_package_start(
&self,
params: Option<serde_json::Value>,
@@ -42,83 +51,60 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
// Clear user-stopped flag — user explicitly started this app
// Clear user-stopped flag — user explicitly started this app.
// Must happen BEFORE the spawn (ordering contract with crash recovery).
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
for name in &to_start {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
let package_id_owned = package_id.to_string();
let companion_app_id = package_id_owned.clone();
let orchestrator = self.orchestrator.clone();
let state_manager = Arc::clone(&self.state_manager);
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Starting).await;
install_log(&format!(
"START: {} (containers: {:?})",
package_id, to_start
package_id_owned, to_start
))
.await;
let mut errors = Vec::new();
for (i, name) in to_start.iter().enumerate() {
// Brief delay between dependent containers to allow initialization
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to start {}: {}", name, stderr);
install_log(&format!("START FAIL: {}{}", name, stderr)).await;
errors.push(format!("{}: {}", name, stderr));
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")));
}
// Verify containers actually reached running state (podman start can
// succeed even if the container exits immediately after)
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for name in &to_start {
let status = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "5", name])
.output()
tokio::spawn(async move {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_start(orchestrator.as_ref(), &to_start).await
} else {
do_package_start(&to_start).await
};
match result {
Ok(()) => {
reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await;
let log_text = logs
.map(|o| {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
combined.chars().take(200).collect::<String>()
})
.unwrap_or_default();
tracing::error!("Container {} exited after start: {}", name, log_text);
install_log(&format!("START EXITED: {}{}", name, log_text)).await;
errors.push(format!("{}: exited after start", name));
}
Err(e) => {
tracing::error!("package.start {} failed: {:#}", package_id_owned, e);
install_log(&format!("START FAIL: {}{:#}", package_id_owned, e)).await;
if let Some(prev) = pre_state {
set_package_state(&state_manager, &package_id_owned, prev).await;
} else {
warn!(
"package.start {}: no pre-state recorded; relying on next scan",
package_id_owned
);
}
}
}
}
});
if !errors.is_empty() {
return Err(anyhow::anyhow!(
"Containers exited after start: {}",
errors.join("; ")
));
}
Ok(serde_json::Value::Null)
Ok(serde_json::json!({ "status": "starting" }))
}
/// Stop a package: mark as user-stopped and stop all containers.
///
/// Returns immediately with `{ "status": "stopping" }`. podman stop
/// (up to 600s for bitcoin-core) runs in the background.
pub(in crate::api::rpc) async fn handle_package_stop(
&self,
params: Option<serde_json::Value>,
@@ -136,43 +122,55 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
install_log(&format!(
"STOP: {} (containers: {:?})",
package_id, containers
))
.await;
// Mark as user-stopped so health monitor and crash recovery don't auto-restart
// Mark as user-stopped BEFORE the spawn so health monitor and
// crash recovery don't auto-restart mid-flight. Ordering is
// load-bearing — see runtime.rs:145-148 original note.
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, package_id).await;
for name in &containers {
crate::crash_recovery::mark_user_stopped(&self.config.data_dir, name).await;
}
let mut errors = Vec::new();
for name in &containers {
tracing::info!(
"Stopping container: {} (timeout: {}s)",
name,
stop_timeout_secs(name)
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to stop {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
}
}
let package_id_owned = package_id.to_string();
let to_stop = containers.clone();
let orchestrator = self.orchestrator.clone();
let state_manager = Arc::clone(&self.state_manager);
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Stopping).await;
if !errors.is_empty() {
return Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")));
}
Ok(serde_json::Value::Null)
install_log(&format!(
"STOP: {} (containers: {:?})",
package_id_owned, containers
))
.await;
tokio::spawn(async move {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_stop(orchestrator.as_ref(), &to_stop).await
} else {
do_package_stop(&containers).await
};
match result {
Ok(()) => {
set_package_state(&state_manager, &package_id_owned, PackageState::Stopped)
.await;
}
Err(e) => {
tracing::error!("package.stop {} failed: {:#}", package_id_owned, e);
install_log(&format!("STOP FAIL: {}{:#}", package_id_owned, e)).await;
if let Some(prev) = pre_state {
set_package_state(&state_manager, &package_id_owned, prev).await;
}
}
}
});
Ok(serde_json::json!({ "status": "stopping" }))
}
/// Restart a package: restart all containers.
///
/// Returns immediately with `{ "status": "restarting" }`. The restart
/// (up to 600s per container for bitcoin-core) runs in the background.
pub(in crate::api::rpc) async fn handle_package_restart(
&self,
params: Option<serde_json::Value>,
@@ -190,55 +188,51 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
// Restart does not mark user-stopped; user wants the app to keep
// running. Clear any lingering marker so downstream layers don't
// interpret the brief podman stop as user intent.
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, package_id).await;
for name in &containers {
crate::crash_recovery::clear_user_stopped(&self.config.data_dir, name).await;
}
let package_id_owned = package_id.to_string();
let companion_app_id = package_id_owned.clone();
let to_restart = ordered_containers_for_start(package_id).await?;
let state_manager = Arc::clone(&self.state_manager);
let orchestrator = self.orchestrator.clone();
let pre_state =
flip_package_state(&state_manager, &package_id_owned, PackageState::Restarting).await;
install_log(&format!(
"RESTART: {} (containers: {:?})",
package_id, containers
package_id_owned, containers
))
.await;
let mut errors = Vec::new();
for name in &containers {
tracing::info!("Restarting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
.await
.context(format!("Failed to exec podman restart {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::warn!(
"podman restart {} failed: {}, trying stop+start",
name,
stderr
);
// Fallback: stop then start (handles rootless podman loopback issues)
let _ = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
let start_err = String::from_utf8_lossy(&start_out.stderr)
.trim()
.to_string();
tracing::error!("stop+start {} also failed: {}", name, start_err);
errors.push(format!("{}: {}", name, start_err));
} else {
tracing::info!("Restarted {} via stop+start fallback", name);
tokio::spawn(async move {
let result = if let Some(orchestrator) = orchestrator.as_ref() {
do_orchestrator_package_restart(orchestrator.as_ref(), &to_restart).await
} else {
do_package_restart(&containers).await
};
match result {
Ok(()) => {
reconcile_companions_for(&companion_app_id).await;
set_package_state(&state_manager, &package_id_owned, PackageState::Running)
.await;
}
Err(e) => {
tracing::error!("package.restart {} failed: {:#}", package_id_owned, e);
install_log(&format!("RESTART FAIL: {}{:#}", package_id_owned, e)).await;
if let Some(prev) = pre_state {
set_package_state(&state_manager, &package_id_owned, prev).await;
}
}
}
}
});
if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
}
Ok(serde_json::Value::Null)
Ok(serde_json::json!({ "status": "restarting" }))
}
/// Uninstall a package: stop and remove all related containers, clean data.
@@ -257,6 +251,20 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.unwrap_or(false);
// Disable + remove Quadlet companion units BEFORE the rm loop.
// Otherwise systemd's Restart=always will respawn each companion
// within ~10s of `podman rm`, leaving them orphaned post-uninstall.
crate::container::companion::remove_for(package_id).await;
// Keep the production reconciler from recreating an app immediately
// after uninstall. The reconciler owns a manifest map independent of
// podman state, so a raw `podman rm` alone is not enough.
if let Some(orchestrator) = &self.orchestrator {
for app_id in orchestrator_uninstall_app_ids(package_id) {
let _ = orchestrator.remove(&app_id, preserve_data).await;
}
}
let containers_to_remove = get_containers_for_app(package_id).await?;
if containers_to_remove.is_empty() {
tracing::warn!("Uninstall {}: no containers found", package_id);
@@ -342,7 +350,8 @@ impl RpcHandler {
}
}
self.set_uninstall_stage(package_id, "Cleaning up volumes").await;
self.set_uninstall_stage(package_id, "Cleaning up volumes")
.await;
// Clean up dangling volumes associated with removed containers
let _ = tokio::process::Command::new("podman")
.args(["volume", "prune", "-f"])
@@ -371,7 +380,8 @@ impl RpcHandler {
// Clean data directories unless preserve_data
if !preserve_data {
self.set_uninstall_stage(package_id, "Removing app data").await;
self.set_uninstall_stage(package_id, "Removing app data")
.await;
let data_dirs = get_data_dirs_for_app(package_id);
for dir in &data_dirs {
tracing::info!("Uninstall {}: removing data {}", package_id, dir);
@@ -579,3 +589,550 @@ impl RpcHandler {
Ok(serde_json::json!({ "status": "stopped", "app_id": app_id }))
}
}
// ---------------------------------------------------------------------------
// Background workers for async package lifecycle RPCs.
//
// Extracted from the pre-async RPC handlers so the transitional state is
// visible to the UI immediately. Each worker is pure IO over podman + the
// crash_recovery helpers — no StateManager access here so we don't need
// a handler reference. The caller does state flipping before/after.
// ---------------------------------------------------------------------------
/// Start containers in dependency order. Includes the post-start 3s wait +
/// exit-check verification from the original synchronous handler (critical
/// for catching "podman start succeeded but container immediately exited"
/// failure modes).
async fn do_package_start(to_start: &[String]) -> Result<()> {
let mut errors = Vec::new();
for (i, name) in to_start.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
repair_before_package_start(name).await;
tracing::info!("Starting container: {}", name);
let out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await
.context(format!("Failed to exec podman start {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
tracing::error!("Failed to start {}: {}", name, stderr);
cleanup_start_conflict(name, &stderr).await;
install_log(&format!("START FAIL: {}{}", name, stderr)).await;
errors.push(format!("{}: {}", name, stderr));
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")));
}
// Post-start exit verification (podman start can succeed even if the
// container exits immediately after).
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
for name in to_start {
let status = tokio::process::Command::new("podman")
.args(["inspect", name, "--format", "{{.State.Status}}"])
.output()
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
let logs = tokio::process::Command::new("podman")
.args(["logs", "--tail", "5", name])
.output()
.await;
let log_text = logs
.map(|o| {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
combined.chars().take(200).collect::<String>()
})
.unwrap_or_default();
tracing::error!("Container {} exited after start: {}", name, log_text);
install_log(&format!("START EXITED: {}{}", name, log_text)).await;
errors.push(format!("{}: exited after start", name));
}
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!(
"Containers exited after start: {}",
errors.join("; ")
));
}
for name in to_start {
ensure_runtime_host_port_listener(name).await?;
}
Ok(())
}
async fn do_orchestrator_package_start(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
to_start: &[String],
) -> Result<()> {
let mut errors = Vec::new();
for (i, name) in to_start.iter().enumerate() {
if i > 0 {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
match orchestrator.start(name).await {
Ok(()) => wait_after_orchestrator_start(name).await,
Err(e) if is_unknown_app_id_error(&e) => {
do_package_start(&[name.clone()]).await?;
}
Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator start failed");
install_log(&format!("START FAIL: {}{:#}", name, e)).await;
errors.push(format!("{}: {:#}", name, e));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Start failed: {}", errors.join("; ")))
}
}
async fn wait_after_orchestrator_start(name: &str) {
let delay = match name {
"archy-btcpay-db" => 5,
"archy-nbxplorer" => 8,
_ => 0,
};
if delay > 0 {
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
}
}
async fn do_orchestrator_package_stop(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
containers: &[String],
) -> Result<()> {
let mut errors = Vec::new();
for name in containers.iter().rev() {
match orchestrator.stop(name).await {
Ok(()) => {}
Err(e) if is_unknown_app_id_error(&e) => {
if let Err(e) = do_package_stop(&[name.clone()]).await {
errors.push(format!("{}: {:#}", name, e));
}
}
Err(e) => {
tracing::error!(container = %name, error = %e, "orchestrator stop failed");
errors.push(format!("{}: {:#}", name, e));
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")))
}
}
async fn do_orchestrator_package_restart(
orchestrator: &dyn crate::container::traits::ContainerOrchestrator,
to_restart: &[String],
) -> Result<()> {
do_orchestrator_package_stop(orchestrator, to_restart).await?;
do_orchestrator_package_start(orchestrator, to_restart).await
}
/// Stop all containers with their per-container graceful-shutdown timeout.
async fn do_package_stop(containers: &[String]) -> Result<()> {
let mut errors = Vec::new();
for name in containers {
tracing::info!(
"Stopping container: {} (timeout: {}s)",
name,
stop_timeout_secs(name)
);
let out = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await
.context(format!("Failed to exec podman stop {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if is_missing_companion_ok(name, &stderr) {
tracing::debug!(container = %name, "companion already absent during stop");
continue;
}
tracing::error!("Failed to stop {}: {}", name, stderr);
errors.push(format!("{}: {}", name, stderr));
}
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Stop failed: {}", errors.join("; ")));
}
Ok(())
}
/// Restart via `podman restart`, falling back to stop+start when restart
/// fails (rootless podman loopback issues).
async fn do_package_restart(containers: &[String]) -> Result<()> {
let mut errors = Vec::new();
for name in containers {
tracing::info!("Restarting container: {}", name);
repair_before_package_start(name).await;
let out = tokio::process::Command::new("podman")
.args(["restart", "-t", stop_timeout_secs(name), name])
.output()
.await
.context(format!("Failed to exec podman restart {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
if is_missing_companion_ok(name, &stderr) {
tracing::debug!(container = %name, "companion absent during restart; reconcile will recreate it");
continue;
}
tracing::warn!(
"podman restart {} failed: {}, trying stop+start",
name,
stderr
);
// Fallback: stop then start
let _ = tokio::process::Command::new("podman")
.args(["stop", "-t", stop_timeout_secs(name), name])
.output()
.await;
let start_out = tokio::process::Command::new("podman")
.args(["start", name])
.output()
.await
.context(format!("Failed to exec podman start {}", name))?;
if !start_out.status.success() {
let start_err = String::from_utf8_lossy(&start_out.stderr)
.trim()
.to_string();
cleanup_start_conflict(name, &start_err).await;
if is_missing_companion_ok(name, &start_err) {
tracing::debug!(container = %name, "companion absent during restart fallback; reconcile will recreate it");
continue;
}
tracing::error!("stop+start {} also failed: {}", name, start_err);
errors.push(format!("{}: {}", name, start_err));
} else {
tracing::info!("Restarted {} via stop+start fallback", name);
}
}
ensure_runtime_host_port_listener(name).await?;
}
if !errors.is_empty() {
return Err(anyhow::anyhow!("Restart failed: {}", errors.join("; ")));
}
Ok(())
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
async fn repair_before_package_start(container_name: &str) {
match container_name {
"btcpay-server" | "archy-nbxplorer" => repair_btcpay_dirs().await,
"indeedhub-postgres" | "indeedhub-redis" | "indeedhub-minio" | "indeedhub-relay"
| "indeedhub-api" | "indeedhub-ffmpeg" | "indeedhub" => repair_indeedhub_network().await,
"grafana" => cleanup_stale_pasta_port("3000").await,
"gitea" => cleanup_gitea_stale_ports().await,
_ => {}
}
}
async fn ensure_runtime_host_port_listener(container_name: &str) -> Result<()> {
let Some(port) = runtime_required_host_port(container_name) else {
return Ok(());
};
if wait_for_runtime_host_port(port, 10).await {
return Ok(());
}
install_log(&format!(
"START REPAIR: {} — host port {} missing after start; restarting container",
container_name, port
))
.await;
let output = tokio::process::Command::new("podman")
.args(["restart", container_name])
.output()
.await
.context("failed to restart container after missing host port")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow::anyhow!(
"Container {} host port {} was not listening and restart failed: {}",
container_name,
port,
stderr.trim()
));
}
if wait_for_runtime_host_port(port, 60).await {
install_log(&format!(
"START REPAIR OK: {} — host port {} is listening after restart",
container_name, port
))
.await;
return Ok(());
}
Err(anyhow::anyhow!(
"Container {} is running but host port {} is not listening",
container_name,
port
))
}
fn runtime_required_host_port(container_name: &str) -> Option<u16> {
match container_name {
"grafana" => Some(3000),
"searxng" => Some(8888),
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
_ => None,
}
}
async fn wait_for_runtime_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
if tokio::net::TcpStream::connect(("127.0.0.1", port))
.await
.is_ok()
{
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
}
async fn repair_btcpay_dirs() {
let _ = tokio::process::Command::new("sudo")
.args([
"mkdir",
"-p",
"/var/lib/archipelago/btcpay/Main",
"/var/lib/archipelago/nbxplorer/Main",
])
.output()
.await;
for dir in [
"/var/lib/archipelago/btcpay",
"/var/lib/archipelago/nbxplorer",
] {
let _ = tokio::process::Command::new("sudo")
.args(["chown", "-R", "1000:1000", dir])
.output()
.await;
}
repair_btcpay_database_password().await;
}
async fn repair_btcpay_database_password() {
let Ok(db_pass) =
tokio::fs::read_to_string("/var/lib/archipelago/secrets/btcpay-db-password").await
else {
return;
};
let db_pass = db_pass.trim();
if db_pass.is_empty() {
return;
}
let _ = tokio::process::Command::new("podman")
.args(["start", "archy-btcpay-db"])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let escaped = db_pass.replace('\'', "''");
let sql = format!("ALTER USER btcpay WITH PASSWORD '{}';", escaped);
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"psql",
"-U",
"btcpay",
"-d",
"btcpay",
"-c",
&sql,
])
.output()
.await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"archy-btcpay-db",
"createdb",
"-U",
"btcpay",
"nbxplorer",
])
.output()
.await;
}
async fn repair_indeedhub_network() {
super::stacks::repair_indeedhub_network_aliases().await;
}
async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
if !stderr.contains("address already in use") && !stderr.contains("pasta failed") {
return;
}
if container_name == "gitea" {
cleanup_gitea_stale_ports().await;
return;
}
if container_name != "grafana" {
return;
}
cleanup_stale_pasta_port("3000").await;
}
async fn cleanup_stale_pasta_port(port: &str) {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
async fn cleanup_gitea_stale_ports() {
for port in ["3001", "2222", "3000"] {
let kill_listener = format!(
"ss -ltnp 'sport = :{}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | xargs -r kill 2>/dev/null || true",
port
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
let pattern = format!("rootlessport.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
matches!(
name,
"archy-bitcoin-ui" | "archy-lnd-ui" | "archy-electrs-ui"
) && stderr.contains("no container with name or ID")
}
/// Flip the primary package entry's state and return the pre-transition
/// state for revert on error. Mirrors `transitional::flip_to_transitional`
/// but lives here because the package path keys by `package_id` (which may
/// differ from the container name used by orchestrator-level entries).
async fn flip_package_state(
state_manager: &crate::state::StateManager,
package_id: &str,
transitional: 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 = transitional;
state_manager.update_data(data).await;
}
prev
}
/// Write the package entry's final state. No-op if the entry has since
/// been removed (uninstall race).
async fn set_package_state(
state_manager: &crate::state::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;
}
}
}
pub(super) async fn reconcile_companions_for(package_id: &str) {
let app_ids = match package_id {
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".to_string(), "bitcoin-ui".to_string()],
"bitcoin-knots" => vec!["bitcoin-knots".to_string(), "bitcoin-ui".to_string()],
"lnd" => vec!["lnd".to_string(), "lnd-ui".to_string()],
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx".to_string(), "electrs-ui".to_string()]
}
_ => return,
};
for (companion, err) in crate::container::companion::reconcile(&app_ids).await {
tracing::warn!(companion = %companion, error = %err, "companion reconcile failed");
}
}
pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
match package_id {
"bitcoin" | "bitcoin-core" => vec!["bitcoin-core".into(), "bitcoin-ui".into()],
"bitcoin-knots" => vec!["bitcoin-knots".into(), "bitcoin-ui".into()],
"lnd" => vec!["lnd".into(), "lnd-ui".into()],
"electrumx" | "electrs" | "mempool-electrs" => {
vec!["electrumx".into(), "electrs-ui".into()]
}
"mempool" | "mempool-web" => vec![
"mempool-api".into(),
"archy-mempool-web".into(),
"archy-mempool-db".into(),
],
"btcpay-server" | "btcpayserver" | "btcpay" => vec![
"btcpay-server".into(),
"archy-nbxplorer".into(),
"archy-btcpay-db".into(),
],
"fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()],
_ => vec![package_id.to_string()],
}
}
File diff suppressed because it is too large Load Diff
+273 -49
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,7 +11,7 @@ use super::runtime::stop_timeout_secs;
use super::validation::validate_app_id;
use crate::api::rpc::RpcHandler;
use crate::container::image_versions;
use crate::data_model::PackageState;
use crate::data_model::{InstallPhase, PackageState};
use anyhow::{Context, Result};
use tokio::io::{AsyncBufReadExt, BufReader};
use tracing::{error, info, warn};
@@ -34,15 +34,10 @@ impl RpcHandler {
let pinned = image_versions::pinned_image_for_app(package_id)
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
// Reject if already updating
{
let (data, _) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get(package_id) {
if entry.state == PackageState::Updating {
return Err(anyhow::anyhow!("{} is already updating", package_id));
}
}
}
// Note: the `already updating` guard lives in `spawn_package_update`
// (the async wrapper that dispatch actually routes to). By the time
// this inner function runs, the wrapper has already flipped state to
// `Updating`, so duplicating the check here would be a false positive.
install_log(&format!("UPDATE: {}{}", package_id, pinned)).await;
@@ -56,6 +51,64 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
// Preferred path: for single-container apps managed by manifests, route
// updates through the orchestrator's upgrade lifecycle instead of the
// legacy shell/CLI flow. Keep stack-style packages on legacy for now.
if should_try_orchestrator_update(package_id, self.orchestrator.is_some()) {
let orchestrator_app_id = orchestrator_update_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
install_log(&format!(
"UPDATE ORCH: {} — attempting orchestrator upgrade as {}",
package_id, orchestrator_app_id
))
.await;
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
if health != "healthy" {
warn!(
"Update {}: orchestrator upgrade completed with health={} (expected healthy)",
package_id, health
);
}
}
install_log(&format!(
"UPDATE ORCH OK: {} (app={})",
package_id, orchestrator_app_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({
"status": "updated",
"package_id": package_id,
}));
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Update {}: orchestrator has no manifest mapping yet, falling back to legacy updater",
package_id
);
install_log(&format!(
"UPDATE ORCH SKIP: {} — unknown app_id, using legacy flow",
package_id
))
.await;
}
Err(e) => {
install_log(&format!("UPDATE ORCH FAIL: {}{}", package_id, e)).await;
self.clear_install_progress(package_id).await;
self.clear_update_state(package_id).await;
return Err(e.context(format!("Orchestrator update {} failed", package_id)));
}
}
}
}
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
@@ -101,6 +154,11 @@ impl RpcHandler {
containers: &[String],
images_to_pull: &[(String, String)],
) -> Result<()> {
// Phase: Preparing — about to stop the running container(s) so
// we can swap images. Fast.
self.set_install_phase(package_id, InstallPhase::Preparing)
.await;
// 1. Graceful stop all containers (reverse order for dependencies)
info!(
"Update {}: stopping {} containers",
@@ -130,6 +188,10 @@ impl RpcHandler {
}
}
// Phase: PullingImage — about to fetch each pinned image in turn.
self.set_install_phase(package_id, InstallPhase::PullingImage)
.await;
// 2. Pull new images with progress
info!(
"Update {}: pulling {} images",
@@ -173,38 +235,23 @@ impl RpcHandler {
}
}
// 4. Recreate via reconcile script (single source of truth for container specs)
info!("Update {}: recreating containers via reconcile", package_id);
// Phase: CreatingContainer — about to recreate each container.
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
.await;
// 4. Recreate containers (orchestrator-first, reconcile fallback)
info!("Update {}: recreating containers", package_id);
for name in containers {
let out = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await
.context(format!("Failed to reconcile {}", name))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
error!(
"Update {}: reconcile {} failed:\nstdout: {}\nstderr: {}",
package_id,
name,
stdout.trim(),
stderr.trim()
);
return Err(anyhow::anyhow!(
"Reconcile failed for {}: {}",
name,
stderr.trim()
));
}
self.recreate_container_for_update(package_id, name).await?;
// Brief delay between containers for dependency initialization
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
// Phase: WaitingHealthy — reconcile has started every container,
// now verifying each reached running state.
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
// 5. Verify containers reached running state
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
for name in containers {
@@ -226,12 +273,62 @@ impl RpcHandler {
Ok(())
}
async fn recreate_container_for_update(
&self,
package_id: &str,
container_name: &str,
) -> Result<()> {
let Some(orchestrator) = self.orchestrator.as_ref() else {
return Err(anyhow::anyhow!(
"Cannot recreate {} during update {}: orchestrator unavailable",
container_name,
package_id
));
};
let mut attempted = Vec::new();
for app_id in candidate_app_ids_for_container(container_name) {
attempted.push(app_id.clone());
match orchestrator.install(&app_id).await {
Ok(created_name) => {
install_log(&format!(
"UPDATE ORCH RECREATE OK: {} — container={} app_id={} created={}",
package_id, container_name, app_id, created_name
))
.await;
return Ok(());
}
Err(e) if is_unknown_app_id_error(&e) => {
continue;
}
Err(e) => {
return Err(e.context(format!(
"orchestrator recreate failed for update {} (container={}, app_id={})",
package_id, container_name, app_id
)));
}
}
}
Err(anyhow::anyhow!(
"No manifest mapping found while recreating {} during update {} (attempted app_ids: {})",
container_name,
package_id,
attempted.join(", ")
))
}
/// Pull a single image with progress broadcasting (reuses install progress pattern).
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
self.set_install_progress(package_id, 0, 0).await;
let mut child = tokio::process::Command::new("podman")
.args(["pull", image])
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if archipelago_container::image_uses_insecure_registry(image) {
cmd.arg("--tls-verify=false");
}
let mut child = cmd
.arg(image)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
@@ -296,15 +393,16 @@ impl RpcHandler {
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
warn!("Rollback: could not restart {}: {}", name, stderr.trim());
// Container was already removed — try reconcile to recreate with old image
let _ = tokio::process::Command::new("bash")
.args([
"/opt/archipelago/scripts/reconcile-containers.sh",
&format!("--container={}", name),
"--force",
])
.output()
.await;
// Container was already removed (forward path ran `podman rm`).
// Recreate via orchestrator-first path with legacy fallback.
if let Err(recreate_err) =
self.recreate_container_for_update(package_id, name).await
{
error!(
"Rollback: failed to recreate {} during rollback of {}: {}",
name, package_id, recreate_err
);
}
}
Err(e) => {
error!("Rollback: failed to restart {}: {}", name, e);
@@ -325,3 +423,129 @@ impl RpcHandler {
self.state_manager.update_data(data).await;
}
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
fn orchestrator_update_app_id(package_id: &str) -> &str {
match package_id {
"bitcoin-knots" => "bitcoin-core",
"electrs" | "mempool-electrs" => "electrumx",
_ => package_id,
}
}
fn uses_legacy_update_flow(package_id: &str) -> bool {
matches!(
package_id,
// Multi-container stacks still updated via the stack-aware path.
"immich" | "penpot" | "penpot-frontend" | "indeedhub"
)
}
fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("unknown app_id"))
}
fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
let mut out = Vec::new();
let mut push = |s: &str| {
if !out.iter().any(|e: &String| e == s) {
out.push(s.to_string());
}
};
match container_name {
"bitcoin-knots" | "bitcoin-core" => {
push("bitcoin-core");
push("bitcoin-knots");
}
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
push("archy-mempool-web");
push("mempool");
}
_ => {}
}
push(container_name);
if let Some(stripped) = container_name.strip_prefix("archy-") {
push(stripped);
}
out
}
#[cfg(test)]
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
};
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
assert!(uses_legacy_update_flow(app), "{app} should stay legacy");
}
}
#[test]
fn orchestrator_flow_for_single_apps() {
for app in [
"lnd",
"bitcoin-core",
"searxng",
"grafana",
"btcpay-server",
"mempool",
"fedimint",
] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
);
assert!(
should_try_orchestrator_update(app, true),
"{app} should use orchestrator when available"
);
}
}
#[test]
fn no_orchestrator_means_no_orchestrator_flow() {
assert!(!should_try_orchestrator_update("lnd", false));
assert!(!should_try_orchestrator_update("btcpay-server", false));
}
#[test]
fn container_name_candidates_cover_common_aliases() {
assert_eq!(
candidate_app_ids_for_container("bitcoin-knots"),
vec!["bitcoin-core", "bitcoin-knots"]
);
assert_eq!(
candidate_app_ids_for_container("archy-bitcoin-ui"),
vec!["bitcoin-ui", "archy-bitcoin-ui"]
);
assert_eq!(
candidate_app_ids_for_container("mempool"),
vec!["archy-mempool-web", "mempool"]
);
assert_eq!(
candidate_app_ids_for_container("archy-mempool-db"),
vec!["archy-mempool-db", "mempool-db"]
);
}
#[test]
fn update_aliases_map_to_manifest_app_ids() {
assert_eq!(orchestrator_update_app_id("bitcoin-knots"), "bitcoin-core");
assert_eq!(orchestrator_update_app_id("electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("mempool-electrs"), "electrumx");
assert_eq!(orchestrator_update_app_id("fedimint"), "fedimint");
}
}
+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);
+1 -1
View File
@@ -355,7 +355,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"vaultwarden" => 8343,
"indeedhub" => 7777,
"indeedhub" => 7778,
_ => 0,
}
}
@@ -0,0 +1,172 @@
//! Async lifecycle helper for container Stop/Start/Restart RPCs.
//!
//! The `ContainerOrchestrator` trait is intentionally synchronous — blocking
//! calls keep the reconciler, boot flow, chaos harness, and unit tests
//! deterministic. But the RPC layer must return to the UI in <1s so the
//! dashboard can render a transitional "Stopping…" / "Starting…" label while
//! the underlying `podman stop` (up to 600s for bitcoin-core) runs in the
//! background.
//!
//! `RpcHandler::spawn_transitional` bridges the two: it
//! 1. flips the package state in `StateManager` to the appropriate
//! transitional variant (`Stopping` / `Starting` / `Restarting`),
//! which fans out to WebSocket clients immediately.
//! 2. `tokio::spawn`s the actual orchestrator call.
//! 3. on success, writes the final state (`Stopped` / `Running`).
//! 4. on error, reverts to the pre-transition state and logs via
//! `install_log()` so the incident shows up in
//! `/var/log/archipelago/container-installs.log`.
//!
//! The server.rs package-scan loop must also be taught to preserve
//! transitional states — see `server.rs:scan_and_update_packages`'s merge
//! logic and the companion `merge_preserving_transitional` helper.
use super::package::install_log;
use super::RpcHandler;
use crate::container::ContainerOrchestrator;
use crate::data_model::PackageState;
use crate::state::StateManager;
use anyhow::Result;
use std::sync::Arc;
use tracing::{error, info, warn};
/// The three transitional lifecycle operations that run asynchronously from
/// the RPC handler. `Install` and `Remove` are intentionally NOT here — they
/// already have their own progress-tracking paths (`install_progress`,
/// `uninstall_stage`) with multi-step UI feedback.
#[derive(Debug, Clone, Copy)]
pub(super) enum Op {
Stop,
Start,
Restart,
}
impl Op {
/// The `PackageState` to set on the entry while the operation is in
/// flight. The package-scan merge loop must preserve this variant and
/// refuse to overwrite it with whatever podman reports (see
/// `merge_preserving_transitional` in server.rs).
fn transitional_state(self) -> PackageState {
match self {
Op::Stop => PackageState::Stopping,
Op::Start => PackageState::Starting,
Op::Restart => PackageState::Restarting,
}
}
/// The `PackageState` to set on success. On error the caller reverts to
/// the pre-transition state rather than using these.
fn final_state_on_success(self) -> PackageState {
match self {
Op::Stop => PackageState::Stopped,
Op::Start => PackageState::Running,
Op::Restart => PackageState::Running,
}
}
/// Prefix used in `install_log` entries so post-mortem readers can grep
/// the operation that failed.
fn log_prefix(self) -> &'static str {
match self {
Op::Stop => "STOP",
Op::Start => "START",
Op::Restart => "RESTART",
}
}
/// Call the orchestrator for this op. Kept in one place so the spawned
/// task doesn't repeat the match four times.
async fn dispatch(self, orch: &dyn ContainerOrchestrator, app_id: &str) -> Result<()> {
match self {
Op::Stop => orch.stop(app_id).await,
Op::Start => orch.start(app_id).await,
Op::Restart => orch.restart(app_id).await,
}
}
}
impl RpcHandler {
/// Flip the package state to `op.transitional_state()`, spawn a background
/// task that runs `op.dispatch()`, and return immediately. The spawned
/// task writes the final state on completion or reverts to the
/// pre-transition state on failure.
///
/// If no package entry exists for `app_id` (e.g. Start on a container
/// that was never installed), no pre-state is recorded and the spawn
/// still runs — the post-success path will no-op the state write and
/// the next scan will pick up the newly-created entry with the correct
/// state. This keeps the helper usable for stacks that lazily create
/// their entries.
pub(super) async fn spawn_transitional(&self, op: Op, app_id: String) -> Result<()> {
let orchestrator = self
.orchestrator
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Container orchestrator not available"))?
.clone();
let state_manager = Arc::clone(&self.state_manager);
// Snapshot pre-transition state (for revert on error) and flip to
// transitional variant. Done BEFORE the spawn so the WebSocket push
// beats the RPC response — the UI should see "Stopping…" the moment
// it gets the RPC ok, not on the next scan.
let pre_state =
flip_to_transitional(&state_manager, &app_id, op.transitional_state()).await;
let log_prefix = op.log_prefix();
let app_id_log = app_id.clone();
install_log(&format!("{}: {}", log_prefix, app_id_log)).await;
tokio::spawn(async move {
match op.dispatch(orchestrator.as_ref(), &app_id).await {
Ok(()) => {
info!("{} complete: {}", log_prefix, app_id);
set_state(&state_manager, &app_id, op.final_state_on_success()).await;
}
Err(e) => {
error!("{} failed for {}: {:#}", log_prefix, app_id, e);
install_log(&format!("{} FAIL: {}{:#}", log_prefix, app_id, e)).await;
// Revert to pre-transition state if we had one; otherwise
// leave the entry untouched so the next scan reconciles.
if let Some(prev) = pre_state {
set_state(&state_manager, &app_id, prev).await;
} else {
warn!(
"{}: no pre-transition state recorded for {}; leaving entry to next scan",
log_prefix, app_id
);
}
}
}
});
Ok(())
}
}
/// Flip the entry's state to `transitional` and return the previous state.
/// Returns `None` if there is no entry for `app_id`.
async fn flip_to_transitional(
state_manager: &StateManager,
app_id: &str,
transitional: PackageState,
) -> Option<PackageState> {
let (mut data, _) = state_manager.get_snapshot().await;
let prev = data.package_data.get(app_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(app_id) {
entry.state = transitional;
state_manager.update_data(data).await;
}
prev
}
/// Set the entry's state to `new_state`. No-ops if the entry has since been
/// removed (e.g. uninstall ran concurrently).
async fn set_state(state_manager: &StateManager, app_id: &str, new_state: PackageState) {
let (mut data, _) = state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
entry.state = new_state;
state_manager.update_data(data).await;
}
}
}
+3 -6
View File
@@ -162,10 +162,8 @@ impl RpcHandler {
// progress bar after navigation instead of showing the fake
// creep again. An RPC poll every ~1s during download drives a
// real progress indicator that survives route changes.
let downloaded = update::DOWNLOAD_BYTES
.load(std::sync::atomic::Ordering::Relaxed);
let total = update::DOWNLOAD_TOTAL
.load(std::sync::atomic::Ordering::Relaxed);
let downloaded = update::DOWNLOAD_BYTES.load(std::sync::atomic::Ordering::Relaxed);
let total = update::DOWNLOAD_TOTAL.load(std::sync::atomic::Ordering::Relaxed);
let active = total > 0 && downloaded < total;
let completed = total > 0 && downloaded >= total;
@@ -175,8 +173,7 @@ impl RpcHandler {
// read timeout). The UI uses this to surface a Cancel button
// with explanatory copy.
let stalled = if active {
let last_at = update::DOWNLOAD_PROGRESS_AT
.load(std::sync::atomic::Ordering::Relaxed);
let last_at = update::DOWNLOAD_PROGRESS_AT.load(std::sync::atomic::Ordering::Relaxed);
if last_at > 0 {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+26 -6
View File
@@ -185,12 +185,32 @@ impl AuthManager {
}
}
}
// Fallback: user.json
Ok(self
.get_user()
.await?
.map(|u| u.onboarding_complete)
.unwrap_or(false))
// Fallback: user.json. A node that has a password set AND
// setup_complete=true has been through onboarding by
// definition — you can't reach the password-set step any
// other way. The separate `onboarding_complete` flag can drift
// out of sync (e.g. the completion RPC never reached disk, or
// the node was seeded from a backup pre-dating the flag), so
// auto-heal by inferring from setup_complete + password_hash.
// Without this, a fully-onboarded node whose `onboarding_complete`
// is stuck false will force its user back through the intro
// wizard on every cleared browser cache.
if let Some(u) = self.get_user().await? {
if u.onboarding_complete {
return Ok(true);
}
if u.setup_complete && !u.password_hash.is_empty() {
// Persist the healed state so subsequent calls skip this
// inference. Ignore write errors — returning true is
// still correct even if we can't persist.
let healed = OnboardingState { complete: true };
if let Ok(json) = serde_json::to_string_pretty(&healed) {
let _ = fs::write(&onboarding_file, json).await;
}
return Ok(true);
}
}
Ok(false)
}
/// Check if 2FA is enabled for the user.
+6 -3
View File
@@ -18,12 +18,12 @@ use base64::Engine;
/// Convert a byte to an HSL triple biased toward readable foregrounds on
/// dark backgrounds (saturation 6085%, lightness 5270%).
fn hue_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
let hue = (seed as u32) * 360 / 256;
format!("hsl({}, 72%, 60%)", hue)
}
fn accent_color(seed: u8) -> String {
let hue = (seed as u16) * 360 / 256;
let hue = (seed as u32) * 360 / 256;
format!("hsl({}, 80%, 68%)", hue)
}
@@ -37,7 +37,10 @@ fn encode_svg(svg: &str) -> String {
/// avatar rather than an error.
fn seed_bytes(pubkey_hex: &str) -> [u8; 8] {
let mut out = [0u8; 8];
let clean: String = pubkey_hex.chars().filter(|c| c.is_ascii_hexdigit()).collect();
let clean: String = pubkey_hex
.chars()
.filter(|c| c.is_ascii_hexdigit())
.collect();
for (i, byte) in out.iter_mut().enumerate() {
let lo = i * 2;
if clean.len() >= lo + 2 {
+185
View File
@@ -0,0 +1,185 @@
//! Cached Bitcoin node status for browser UIs.
//!
//! The bitcoin-ui should not poll Bitcoin RPC directly for display state.
//! During container restarts, reindexing, and IBD, direct browser RPC polling
//! turns short RPC gaps into visible UI failures. This module owns the RPC
//! polling loop, caches the last successful snapshot, and serves stale-but-known
//! state while the node is reconnecting.
use anyhow::{Context, Result};
use serde::Serialize;
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tracing::{debug, warn};
const CACHE_REFRESH_SECS: u64 = 5;
#[derive(Debug, Clone, Serialize)]
pub struct BitcoinNodeStatus {
pub ok: bool,
pub stale: bool,
pub updated_at_ms: u64,
pub error: Option<String>,
pub blockchain_info: Option<serde_json::Value>,
pub network_info: Option<serde_json::Value>,
pub index_info: Option<serde_json::Value>,
pub zmq_notifications: Option<serde_json::Value>,
}
impl Default for BitcoinNodeStatus {
fn default() -> Self {
Self {
ok: false,
stale: false,
updated_at_ms: 0,
error: Some("Connecting to Bitcoin node...".to_string()),
blockchain_info: None,
network_info: None,
index_info: None,
zmq_notifications: None,
}
}
}
static STATUS_CACHE: OnceLock<RwLock<BitcoinNodeStatus>> = OnceLock::new();
fn cache() -> &'static RwLock<BitcoinNodeStatus> {
STATUS_CACHE.get_or_init(|| RwLock::new(BitcoinNodeStatus::default()))
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
fn transient_error(err_msg: &str) -> bool {
let lower = err_msg.to_lowercase();
lower.contains("connect")
|| lower.contains("reset")
|| lower.contains("refused")
|| lower.contains("timed out")
|| lower.contains("timeout")
|| lower.contains("broken pipe")
|| lower.contains("eof")
|| lower.contains("500 internal server error")
}
pub fn spawn_status_cache() {
tokio::spawn(async {
loop {
let fresh = fetch_bitcoin_status().await;
let mut cached = cache().write().await;
match fresh {
Ok(mut status) => {
status.ok = true;
status.stale = false;
status.error = None;
*cached = status;
}
Err(e) => {
let err_msg = e.to_string();
if transient_error(&err_msg) {
debug!("Bitcoin status: transient RPC failure: {}", err_msg);
} else {
warn!("Bitcoin status: RPC failure: {}", err_msg);
}
if cached.blockchain_info.is_some() {
cached.ok = false;
cached.stale = true;
cached.error = Some(format!(
"Bitcoin node is reconnecting; showing last known state: {}",
err_msg
));
} else {
*cached = BitcoinNodeStatus {
ok: false,
stale: false,
updated_at_ms: now_ms(),
error: Some(format!("Connecting to Bitcoin node: {}", err_msg)),
..BitcoinNodeStatus::default()
};
}
}
}
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
}
});
}
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
cache().read().await.clone()
}
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(8))
.build()
.context("build Bitcoin status HTTP client")?;
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
.await
.context("getblockchaininfo")?;
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
.await
.context("getnetworkinfo")
.ok();
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
.await
.context("getindexinfo")
.ok();
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
.await
.context("getzmqnotifications")
.ok();
Ok(BitcoinNodeStatus {
ok: true,
stale: false,
updated_at_ms: now_ms(),
error: None,
blockchain_info: Some(blockchain_info),
network_info,
index_info,
zmq_notifications,
})
}
async fn bitcoin_rpc_call(
client: &reqwest::Client,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value> {
let (rpc_user, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "bitcoin-status",
"method": method,
"params": params,
});
let resp = client
.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(rpc_user, Some(rpc_pass))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await
.context("Bitcoin RPC request failed")?;
let status = resp.status();
let json: serde_json::Value = resp.json().await.context("decode Bitcoin RPC JSON")?;
if !status.is_success() {
anyhow::bail!("Bitcoin RPC returned {}: {}", status, json);
}
if let Some(error) = json.get("error").filter(|e| !e.is_null()) {
anyhow::bail!("Bitcoin RPC {} error: {}", method, error);
}
json.get("result")
.cloned()
.context("missing Bitcoin RPC result")
}
+431 -30
View File
@@ -1,18 +1,21 @@
//! Bootstrap host-side doctor artifacts on every archipelago startup.
//! Bootstrap host-side artifacts on every archipelago startup.
//!
//! The update pipeline swaps the archipelago binary but does not touch
//! scripts or systemd units — those are installed once by the ISO builder.
//! Without this module, changes to `container-doctor.sh` or the doctor
//! service/timer never reach boxes installed before the change.
//! scripts, systemd units, or nginx configuration — those are installed
//! once by the ISO builder. Without this module, changes to
//! `container-doctor.sh`, the doctor service/timer, or the nginx config
//! never reach boxes installed before the change.
//!
//! On startup we compare three embedded files against their on-disk
//! copies and rewrite any that differ, then enable the doctor timer if
//! it isn't already. Idempotent: no-ops on boxes that match the
//! embedded version. All work is best-effort — failures are logged but
//! never abort the backend.
//! Two things are synced on startup:
//! 1. Doctor artifacts (container-doctor.sh + service + timer).
//! 2. An nginx `location /api/app-catalog` proxy block — required for
//! the App Store catalog proxy to actually reach the backend.
//!
//! Idempotent: no-ops on boxes that are already in sync. All work is
//! best-effort — failures are logged but never abort the backend.
use anyhow::{Context, Result};
use std::path::Path;
use std::path::{Path, PathBuf};
use tokio::fs;
use tracing::{debug, info, warn};
@@ -21,21 +24,330 @@ use crate::update::host_sudo;
const DOCTOR_SH: &str = include_str!("../../../scripts/container-doctor.sh");
const DOCTOR_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
const DOCTOR_TIMER: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
/// `/api/app-catalog` proxy. Kept in sync with the canonical block in
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap the doctor must not prevent the backend from serving.
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
match run_service_override_repair().await {
Ok(true) => info!("Removed stale Archipelago dev-mode service override"),
Ok(false) => debug!("No stale Archipelago dev-mode service override found"),
Err(e) => warn!("Service override repair failed (non-fatal): {:#}", e),
}
match run_runtime_assets().await {
Ok(changed) if changed => info!("Runtime assets synchronized from OTA payload"),
Ok(_) => debug!("No OTA runtime payload to synchronize"),
Err(e) => warn!("Runtime asset bootstrap failed (non-fatal): {:#}", e),
}
match run().await {
Ok(changed) if changed => info!("Doctor artifacts synchronized with binary"),
Ok(_) => debug!("Doctor artifacts already in sync"),
Err(e) => warn!("Doctor bootstrap failed (non-fatal): {:#}", e),
}
match run_nginx().await {
Ok(true) => info!("Patched nginx config to proxy /api/app-catalog"),
Ok(false) => debug!("Nginx already has /api/app-catalog block"),
Err(e) => warn!("Nginx bootstrap failed (non-fatal): {:#}", e),
}
match run_bitcoin_rpc_repair().await {
Ok(true) => info!("Repaired Bitcoin RPC bind settings and restarted Bitcoin containers"),
Ok(false) => debug!("Bitcoin RPC bind settings already usable"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match tighten_secrets_dir().await {
Ok(n) if n > 0 => info!(tightened = n, "Tightened mode on secret files"),
Ok(_) => debug!("Secrets directory already at expected mode"),
Err(e) => warn!("Secrets dir tightening failed (non-fatal): {:#}", e),
}
// Podman probing MUST be the last bootstrap stage. We used to delete
// transient runroot state here when `podman info` failed, but live nodes
// can still have rootlessport/conmon processes holding that state. Removing
// it automatically makes failures worse: containers lose `.containerenv`,
// ports stay bound, and later starts fail. Report the fault instead; repair
// must be deliberate/operator-driven.
match heal_podman_state().await {
Ok(PodmanHealOutcome::Healthy) => debug!("podman runtime state healthy"),
Ok(PodmanHealOutcome::Unhealthy) => warn!(
"podman runtime state is unhealthy at startup — skipping automatic runroot cleanup"
),
Err(e) => warn!(
"podman self-heal failed (non-fatal, will retry next boot): {:#}",
e
),
}
}
#[derive(Debug, PartialEq, Eq)]
enum PodmanHealOutcome {
Healthy,
Unhealthy,
}
async fn heal_podman_state() -> Result<PodmanHealOutcome> {
if probe_podman_ok().await {
return Ok(PodmanHealOutcome::Healthy);
}
Ok(PodmanHealOutcome::Unhealthy)
}
/// True iff `podman info` returns 0 within 5s. Any timeout, spawn
/// failure, or non-zero exit reads as "wedged" and triggers cleanup.
async fn probe_podman_ok() -> bool {
use std::time::Duration;
let probe = tokio::time::timeout(
Duration::from_secs(5),
tokio::process::Command::new("podman")
.arg("info")
.arg("--format=json")
.output(),
)
.await;
match probe {
Ok(Ok(out)) => out.status.success(),
Ok(Err(_)) | Err(_) => false,
}
}
/// Make sure /var/lib/archipelago/secrets/ stays 0700 owned by archipelago,
/// and every file inside is 0600. The parent dir mode is the load-bearing
/// boundary against host-side reads from other UIDs (rootless container
/// escapes get mapped to UID >= 100000 and can't traverse a 0700/uid=1000
/// directory). The per-file 0600 sweep is defense-in-depth in case some
/// installer wrote a 0644 file.
async fn tighten_secrets_dir() -> Result<u32> {
let dir = Path::new("/var/lib/archipelago/secrets");
if !dir.exists() {
return Ok(0);
}
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
.await
.with_context(|| format!("chmod 0700 {}", dir.display()))?;
let mut entries = fs::read_dir(dir)
.await
.with_context(|| format!("read_dir {}", dir.display()))?;
let mut tightened = 0u32;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let meta = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
if !meta.is_file() {
continue;
}
if meta.permissions().mode() & 0o777 != 0o600 {
fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
.await
.with_context(|| format!("chmod 0600 {}", path.display()))?;
tightened += 1;
}
}
Ok(tightened)
}
async fn run_service_override_repair() -> Result<bool> {
let override_path = Path::new("/etc/systemd/system/archipelago.service.d/override.conf");
let Ok(content) = fs::read_to_string(override_path).await else {
return Ok(false);
};
if !content.contains("ARCHIPELAGO_DEV_MODE=true") {
return Ok(false);
}
let only_dev_mode_override = content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.all(|line| line == "[Service]" || line == "Environment=ARCHIPELAGO_DEV_MODE=true");
if !only_dev_mode_override {
warn!(
path = %override_path.display(),
"Archipelago service override contains ARCHIPELAGO_DEV_MODE=true plus other settings; leaving it untouched"
);
return Ok(false);
}
let path_s = override_path.to_string_lossy().to_string();
let status = host_sudo(&["rm", "-f", &path_s])
.await
.with_context(|| format!("remove {}", override_path.display()))?;
if !status.success() {
anyhow::bail!("remove {} exited with {}", override_path.display(), status);
}
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
Ok(true)
}
async fn run_runtime_assets() -> Result<bool> {
// The v1.7.50 OTA bridge puts scripts/apps/docker assets inside the
// frontend tarball because older binaries only know how to apply the
// backend binary and frontend archive. Once the new backend starts, it
// promotes that payload into /opt so app installs use the matching specs.
let runtime_dir = Path::new(RUNTIME_ASSETS_DIR);
if !runtime_dir.exists() {
return Ok(false);
}
let mut changed = false;
for (relative, dest) in [
("apps", "/opt/archipelago/apps"),
("scripts", "/opt/archipelago/scripts"),
("docker", "/opt/archipelago/docker"),
] {
let src = runtime_dir.join(relative);
if src.exists() {
replace_dir_from_runtime(&src, dest).await?;
if relative == "scripts" {
let _ = host_sudo(&[
"find", dest, "-type", "f", "-name", "*.sh", "-exec", "chmod", "755", "{}", "+",
])
.await;
let image_versions = format!("{}/image-versions.sh", dest);
if Path::new(&image_versions).exists() {
let _ =
host_sudo(&["cp", &image_versions, "/opt/archipelago/image-versions.sh"])
.await;
}
}
changed = true;
}
}
let configs = runtime_dir.join("image-recipe/configs");
for unit in ["archipelago-doctor.service", "archipelago-doctor.timer"] {
let src = configs.join(unit);
if src.exists() {
let src_s = src.to_string_lossy().to_string();
let dest = format!("/etc/systemd/system/{}", unit);
let status = host_sudo(&["install", "-m", "644", &src_s, &dest])
.await
.with_context(|| format!("install {}", unit))?;
if !status.success() {
anyhow::bail!("install {} exited with {}", unit, status);
}
changed = true;
}
}
if changed {
let _ = host_sudo(&["systemctl", "daemon-reload"]).await;
}
Ok(changed)
}
async fn replace_dir_from_runtime(src: &Path, dest: &str) -> Result<()> {
let tmp = format!("{}.new.{}", dest, chrono::Utc::now().timestamp_millis());
let src_dot = path_dot(src);
let mkdir = host_sudo(&["mkdir", "-p", &tmp])
.await
.with_context(|| format!("mkdir {}", tmp))?;
if !mkdir.success() {
anyhow::bail!("mkdir {} exited with {}", tmp, mkdir);
}
let copy = host_sudo(&["cp", "-a", &src_dot, &tmp])
.await
.with_context(|| format!("copy runtime {} -> {}", src.display(), tmp))?;
if !copy.success() {
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
anyhow::bail!("copy runtime {} exited with {}", src.display(), copy);
}
let _ = host_sudo(&["mkdir", "-p", dest]).await;
let cleanup = host_sudo(&[
"find",
dest,
"-mindepth",
"1",
"-maxdepth",
"1",
"-exec",
"rm",
"-rf",
"{}",
"+",
])
.await
.with_context(|| format!("clean {}", dest))?;
if !cleanup.success() {
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
anyhow::bail!("clean {} exited with {}", dest, cleanup);
}
let tmp_dot = format!("{}/.", tmp);
let promote = host_sudo(&["cp", "-a", &tmp_dot, dest])
.await
.with_context(|| format!("promote {} -> {}", tmp, dest))?;
let _ = host_sudo(&["rm", "-rf", &tmp]).await;
if !promote.success() {
anyhow::bail!("promote {} exited with {}", dest, promote);
}
Ok(())
}
fn path_dot(path: &Path) -> String {
let mut p = PathBuf::from(path);
p.push(".");
p.to_string_lossy().to_string()
}
async fn run_bitcoin_rpc_repair() -> Result<bool> {
// Older installs can have a container-owned bitcoin.conf with only rpcauth
// and printtoconsole. In that state bitcoind is healthy internally, but the
// host-network bitcoin-ui proxy to 127.0.0.1:8332 gets connection resets.
// Repair it at startup so OTA fixes existing nodes without a manual
// uninstall/reinstall.
let script = r#"
set -eu
conf=/var/lib/archipelago/bitcoin/bitcoin.conf
[ -f "$conf" ] || exit 0
changed=0
ensure_line() {
line="$1"
key="${line%%=*}"
if ! grep -q "^${key}=" "$conf"; then
printf '%s\n' "$line" >> "$conf"
changed=1
fi
}
ensure_line server=1
ensure_line rpcbind=0.0.0.0
ensure_line rpcallowip=0.0.0.0/0
ensure_line rpcport=8332
ensure_line listen=1
[ "$changed" -eq 0 ] && exit 0
exit 2
"#;
let status = host_sudo(&["sh", "-lc", script])
.await
.context("repair bitcoin.conf RPC bind settings")?;
match status.code() {
Some(0) => Ok(false),
Some(2) => {
for name in ["bitcoin-knots", "bitcoin-core", "archy-bitcoin-ui"] {
let _ = tokio::process::Command::new("podman")
.args(["restart", name])
.status()
.await;
}
Ok(true)
}
_ => {
warn!("Bitcoin RPC repair helper exited with {}", status);
Ok(false)
}
}
}
async fn run() -> Result<bool> {
@@ -87,21 +399,14 @@ async fn run() -> Result<bool> {
let timer_changed = write_root_if_needed(DOCTOR_TIMER_PATH, DOCTOR_TIMER).await?;
changed = changed || service_changed || timer_changed;
// 3. Reload + enable. Only when we actually touched units, or when the
// timer isn't enabled yet (catches fresh upgrades of boxes that predate
// the doctor entirely).
let timer_enabled = is_timer_enabled().await;
if service_changed || timer_changed || !timer_enabled {
// 3. Reload if units changed. Do not enable/start the timer here: lifecycle
// qualification and explicit app operations need deterministic Podman
// ownership, and the doctor can race those flows. Operators can enable it
// separately when they want periodic host repair.
if service_changed || timer_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("daemon-reload failed: {:#}", e);
}
if let Err(e) = host_sudo(&["systemctl", "enable", "--now", "archipelago-doctor.timer"])
.await
{
warn!("enable archipelago-doctor.timer failed: {:#}", e);
} else if !timer_enabled {
info!("Enabled archipelago-doctor.timer");
}
}
Ok(changed)
@@ -142,11 +447,107 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
async fn is_timer_enabled() -> bool {
tokio::process::Command::new("systemctl")
.args(["is-enabled", "--quiet", "archipelago-doctor.timer"])
.status()
/// Patch the nginx site config to add a `/api/app-catalog` proxy block if
/// it's missing. The original ISO shipped individual per-endpoint `location`
/// blocks and no catch-all `/api/`, so `/api/app-catalog` silently fell
/// through to the SPA `index.html` and the frontend got HTML instead of
/// JSON. We anchor the insert to the DWN comment that already sits right
/// after the `/api/blob` block, so the new block lands in both the HTTP
/// and HTTPS server blocks.
///
/// Validates via `nginx -t` before reloading. On failure the patch is
/// rolled back from a backup written just before the write.
async fn run_nginx() -> Result<bool> {
// Skip on dev symlinks — we don't want to touch `/etc/nginx` on laptops.
let home_archy = Path::new("/home/archipelago/archy");
if fs::symlink_metadata(home_archy)
.await
.map(|s| s.success())
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
{
return Ok(false);
}
if !Path::new(NGINX_CONF_PATH).exists() {
debug!("{} missing — skipping nginx bootstrap", NGINX_CONF_PATH);
return Ok(false);
}
let content = fs::read_to_string(NGINX_CONF_PATH)
.await
.with_context(|| format!("read {}", NGINX_CONF_PATH))?;
if content.contains("location /api/app-catalog") {
return Ok(false);
}
// The DWN comment sits at the same indent right after the `/api/blob`
// block in both server blocks — a stable anchor that existed on every
// ISO shipped to date. If it's absent (config got heavily customized),
// we bail rather than guess where to splice.
let anchor = " # DWN endpoints — peer access over Tor (no auth)";
if !content.contains(anchor) {
warn!("nginx conf missing DWN anchor — skipping /api/app-catalog patch");
return Ok(false);
}
let replacement = format!("{}{}", NGINX_APP_CATALOG_BLOCK, anchor);
let patched = content.replace(anchor, &replacement);
// Write patched config via a user-owned tmp + sudo mv, after stashing
// a backup so we can revert if `nginx -t` hates what we produced.
let pid = std::process::id();
let tmp = format!("/tmp/archipelago-nginx-{}.conf", pid);
fs::write(&tmp, &patched)
.await
.with_context(|| format!("write {}", tmp))?;
let backup = format!("/tmp/archipelago-nginx-backup-{}.conf", pid);
if let Err(e) = host_sudo(&["cp", NGINX_CONF_PATH, &backup]).await {
let _ = fs::remove_file(&tmp).await;
return Err(e.context("backup nginx conf"));
}
let mv = host_sudo(&["mv", &tmp, NGINX_CONF_PATH]).await;
match mv {
Ok(s) if s.success() => {}
Ok(s) => {
let _ = fs::remove_file(&tmp).await;
anyhow::bail!("sudo mv nginx conf exited with {}", s);
}
Err(e) => {
let _ = fs::remove_file(&tmp).await;
return Err(e.context("mv tmp -> nginx conf"));
}
}
// Validate.
let test = host_sudo(&["nginx", "-t"]).await;
let valid = matches!(&test, Ok(s) if s.success());
if !valid {
warn!("nginx -t failed after patch — reverting");
let _ = host_sudo(&["mv", &backup, NGINX_CONF_PATH]).await;
if let Err(e) = test {
return Err(e.context("nginx -t"));
}
anyhow::bail!("nginx config invalid after patch — reverted");
}
// Reload nginx so the new block takes effect immediately. Reload (not
// restart) keeps in-flight connections alive.
if let Err(e) = host_sudo(&["systemctl", "reload", "nginx"]).await {
warn!("nginx reload failed (non-fatal): {:#}", e);
}
let _ = host_sudo(&["rm", "-f", &backup]).await;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
let outcome = PodmanHealOutcome::Unhealthy;
assert_ne!(outcome, PodmanHealOutcome::Healthy);
}
}
+58 -3
View File
@@ -62,6 +62,14 @@ pub struct Config {
/// Tor SOCKS5 proxy (e.g. 127.0.0.1:9050). When set, ALL Nostr traffic routes through Tor.
#[serde(default)]
pub nostr_tor_proxy: Option<String>,
/// Phase 3.2 of v1.7.52: route orchestrator-managed backend installs
/// through Quadlet (`.container` units in ~/.config/containers/systemd
/// + systemctl --user start) instead of `podman create + start`. Default
/// off so the legacy path stays the production path until the harness
/// at tests/lifecycle/run-20x.sh has gone green against the new path
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
#[serde(default)]
pub use_quadlet_backends: bool,
}
impl Config {
@@ -132,9 +140,12 @@ impl Config {
config.log_level = level;
}
// Dev mode configuration
if let Ok(dev_mode) = std::env::var("ARCHIPELAGO_DEV_MODE") {
config.dev_mode = dev_mode.parse().unwrap_or(false);
// Production binaries must not be switched into dev orchestration by
// host environment. Several live nodes carried a stale systemd
// ARCHIPELAGO_DEV_MODE override, which rewrote production volume
// mounts into /tmp and prevented real installs from starting.
if std::env::var("ARCHIPELAGO_DEV_MODE").is_ok() {
tracing::warn!("Ignoring ARCHIPELAGO_DEV_MODE in production config");
}
if let Ok(runtime) = std::env::var("ARCHIPELAGO_CONTAINER_RUNTIME") {
@@ -171,6 +182,17 @@ impl Config {
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
// route backend installs through the Quadlet path without requiring a
// config.json edit + archipelago.service restart (which would trigger
// FM3 cgroup cascade until 3.5 ships). Anything else (or unset) leaves
// the config.json value untouched.
if let Ok(v) = std::env::var("ARCHIPELAGO_USE_QUADLET_BACKENDS") {
if parse_truthy_env(&v) {
config.use_quadlet_backends = true;
}
}
// Host IP for container env vars (detect if not set)
if let Ok(ip) = std::env::var("ARCHIPELAGO_HOST_IP") {
config.host_ip = ip;
@@ -218,10 +240,23 @@ impl Default for Config {
"wss://relay.nostr.info".into(),
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
use_quadlet_backends: false,
}
}
}
/// Recognise the canonical "the user meant true" forms for boolean env
/// vars: 1, true, yes, on (case-insensitive, surrounding whitespace
/// trimmed). Anything else — including the typo'd "ture" or the empty
/// string — counts as false. Centralised so future env flags stay
/// consistent with each other.
fn parse_truthy_env(raw: &str) -> bool {
matches!(
raw.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -425,4 +460,24 @@ mod tests {
assert!(!config.nostr_relays.is_empty());
assert!(config.nostr_relays.iter().all(|r| r.starts_with("wss://")));
}
#[test]
fn parse_truthy_env_recognises_canonical_forms() {
for t in ["1", "true", "TRUE", "yes", "Yes", "on", "ON", " true "] {
assert!(parse_truthy_env(t), "{t:?} should parse truthy");
}
for f in ["", "0", "false", "no", "off", "ture", "anything else", " "] {
assert!(!parse_truthy_env(f), "{f:?} should NOT parse truthy");
}
}
#[test]
fn test_config_use_quadlet_backends_defaults_off() {
// Phase 3.2 of v1.7.52 — the new path stays gated until the 20×
// harness goes green on .228 and .198. Flipping this default
// ahead of that would route every backend install through code
// we haven't fleet-validated yet.
let config = Config::default();
assert!(!config.use_quadlet_backends);
}
}
@@ -0,0 +1,298 @@
//! bitcoin-ui nginx.conf renderer.
//!
//! Step 7 of the rust-orchestrator migration. Replaces the old
//! `sed -i __BITCOIN_RPC_AUTH__` approach from `first-boot-containers.sh`
//! (which destructively overwrote its own template, broke on rotation,
//! and had no story for dual Knots/Core UIs) with a binary-embedded
//! template rendered at install/reconcile time and atomic-written to
//! disk.
//!
//! The manifest bind-mounts the rendered file read-only into the
//! container at `/etc/nginx/conf.d/default.conf`. On every reconcile
//! pass we re-render and compare — if the rendered bytes would differ
//! from what's on disk (password rotated, template changed via OTA),
//! we rewrite atomically and the reconciler restarts the container.
//!
//! Source of truth:
//! * RPC user: hardcoded `archipelago` (matches the image's `bitcoin.conf`).
//! * RPC password: `/var/lib/archipelago/secrets/bitcoin-rpc-password`,
//! plaintext, written by the seed-derived credential setup.
//!
//! Both Knots and Core back-ends expose RPC on 127.0.0.1:8332 with the
//! same auth shape, so one template serves both.
use anyhow::{Context, Result};
use base64::Engine;
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs;
/// The nginx.conf template. Embedded at compile time so it can never
/// drift from the code that renders it, and ships atomically with OTA.
///
/// `{{BITCOIN_RPC_AUTH}}` is the only placeholder — replaced with a
/// `base64(user:password)` blob at render time.
pub(crate) const TEMPLATE: &str = include_str!("bitcoin_ui_nginx.conf.template");
/// The single placeholder in `TEMPLATE`.
const PLACEHOLDER: &str = "{{BITCOIN_RPC_AUTH}}";
/// Hardcoded RPC user. Matches the user written into `bitcoin.conf` by
/// the bitcoin-core/bitcoin-knots bootstrap, and the legacy
/// `BITCOIN_RPC_USER="archipelago"` from `first-boot-containers.sh`.
const RPC_USER: &str = "archipelago";
/// Default path to the plaintext RPC password secret.
///
/// Written by the seed-derived credential flow; same file the bash
/// scripts read today at `first-boot-containers.sh:277` and `:1225`.
pub const DEFAULT_SECRET_PATH: &str = "/var/lib/archipelago/secrets/bitcoin-rpc-password";
/// Default output path for the rendered nginx.conf.
///
/// The manifest bind-mounts this file read-only into the bitcoin-ui
/// container at `/etc/nginx/conf.d/default.conf`.
pub const DEFAULT_RENDERED_PATH: &str = "/var/lib/archipelago/bitcoin-ui/nginx.conf";
/// Parameters for rendering. Injectable so tests can hit a tmpdir
/// instead of `/var/lib/archipelago`.
#[derive(Debug, Clone)]
pub struct RenderPaths {
/// Path to read the plaintext RPC password from.
pub secret_path: PathBuf,
/// Path to write the rendered nginx.conf to.
pub rendered_path: PathBuf,
}
impl Default for RenderPaths {
fn default() -> Self {
Self {
secret_path: PathBuf::from(DEFAULT_SECRET_PATH),
rendered_path: PathBuf::from(DEFAULT_RENDERED_PATH),
}
}
}
/// Outcome of a render pass. `Written` if the rendered bytes differed
/// from the current on-disk contents and we rewrote; `Unchanged` if
/// they matched and we left the file alone.
///
/// The caller (reconciler / install path) decides whether to restart
/// the bitcoin-ui container based on this.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RenderOutcome {
Written,
Unchanged,
}
/// Render the bitcoin-ui nginx.conf and atomic-write it to disk if it
/// differs from what's already there.
///
/// Idempotent: safe to call on every reconcile pass. Does a byte
/// comparison before writing so an unchanged password + template is a
/// no-op (no inode churn, no container restart cascade).
///
/// Errors if the secret file is missing or empty. Upstream callers
/// treat that as "bitcoin-ui isn't installable yet" rather than fatal
/// — the RPC password comes into being during bitcoin-core's own
/// bootstrap, which may not have happened yet on a fresh node.
pub async fn render(paths: &RenderPaths) -> Result<RenderOutcome> {
let password = read_password(&paths.secret_path).await?;
let auth_b64 = encode_basic_auth(RPC_USER, &password);
let rendered = TEMPLATE.replace(PLACEHOLDER, &auth_b64);
// Compare against existing. read-to-string fails on ENOENT (first
// install) — treat as "different".
let existing = fs::read_to_string(&paths.rendered_path).await.ok();
if existing.as_deref() == Some(rendered.as_str()) {
return Ok(RenderOutcome::Unchanged);
}
// Atomic write: write to sibling tmp + rename. Keeps the bind-
// mounted file pointing at a fully-formed config at all times.
let parent = paths
.rendered_path
.parent()
.ok_or_else(|| anyhow::anyhow!("rendered_path has no parent directory"))?;
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
let tmp = unique_tmp_path(&paths.rendered_path);
fs::write(&tmp, &rendered)
.await
.with_context(|| format!("writing tmp {}", tmp.display()))?;
fs::rename(&tmp, &paths.rendered_path)
.await
.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.rendered_path.display()
)
})?;
tracing::info!(
path = %paths.rendered_path.display(),
auth_hash = %short_hash(&auth_b64),
"bitcoin-ui nginx.conf rendered"
);
Ok(RenderOutcome::Written)
}
fn unique_tmp_path(dest: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
dest.with_extension(format!("tmp.{ts}.{n}"))
}
/// Read the plaintext RPC password from disk. Trims trailing newlines
/// (common from `echo "$PASS" > file`) but rejects an empty result.
async fn read_password(path: &Path) -> Result<String> {
let raw = fs::read_to_string(path)
.await
.with_context(|| format!("reading bitcoin RPC password from {}", path.display()))?;
let trimmed = raw.trim().to_string();
if trimmed.is_empty() {
anyhow::bail!(
"bitcoin RPC password file {} is empty — bitcoin-core bootstrap hasn't written it yet",
path.display()
);
}
Ok(trimmed)
}
/// `base64("user:password")` — the value nginx puts after `Basic ` in
/// the upstream `Authorization` header.
fn encode_basic_auth(user: &str, password: &str) -> String {
let raw = format!("{user}:{password}");
base64::engine::general_purpose::STANDARD.encode(raw.as_bytes())
}
/// Short hash of the auth value for logging — we never want the
/// plaintext or full base64 in logs (it's a credential), but a stable
/// fingerprint helps correlate rotations.
fn short_hash(s: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(s.as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..4])
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn paths_in(dir: &Path, password: &str) -> RenderPaths {
let secret = dir.join("bitcoin-rpc-password");
std::fs::write(&secret, password).unwrap();
RenderPaths {
secret_path: secret,
rendered_path: dir.join("nginx.conf"),
}
}
#[tokio::test]
async fn render_writes_file_with_substitution() {
let tmp = TempDir::new().unwrap();
let paths = paths_in(tmp.path(), "hunter2");
let outcome = render(&paths).await.unwrap();
assert_eq!(outcome, RenderOutcome::Written);
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
// archipelago:hunter2 -> "YXJjaGlwZWxhZ286aHVudGVyMg=="
assert!(
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
"base64 auth not found in rendered config:\n{contents}"
);
assert!(
!contents.contains(PLACEHOLDER),
"placeholder left in output"
);
}
#[tokio::test]
async fn render_is_idempotent_when_password_unchanged() {
let tmp = TempDir::new().unwrap();
let paths = paths_in(tmp.path(), "hunter2");
let first = render(&paths).await.unwrap();
assert_eq!(first, RenderOutcome::Written);
let second = render(&paths).await.unwrap();
assert_eq!(second, RenderOutcome::Unchanged);
}
#[tokio::test]
async fn render_rewrites_on_password_rotation() {
let tmp = TempDir::new().unwrap();
let paths = paths_in(tmp.path(), "old-pass");
render(&paths).await.unwrap();
// Rotate.
std::fs::write(&paths.secret_path, "new-pass").unwrap();
let outcome = render(&paths).await.unwrap();
assert_eq!(outcome, RenderOutcome::Written);
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
// archipelago:new-pass -> "YXJjaGlwZWxhZ286bmV3LXBhc3M="
assert!(contents.contains("YXJjaGlwZWxhZ286bmV3LXBhc3M="));
}
#[tokio::test]
async fn render_trims_trailing_newline_from_secret() {
// Matches `echo "$PASS" > file` behaviour.
let tmp = TempDir::new().unwrap();
let paths = paths_in(tmp.path(), "hunter2\n");
render(&paths).await.unwrap();
let contents = std::fs::read_to_string(&paths.rendered_path).unwrap();
assert!(
contents.contains("YXJjaGlwZWxhZ286aHVudGVyMg=="),
"trailing newline should be stripped before encoding"
);
}
#[tokio::test]
async fn render_errors_on_empty_password() {
let tmp = TempDir::new().unwrap();
let paths = paths_in(tmp.path(), "");
let err = render(&paths).await.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("empty"), "unexpected error: {msg}");
}
#[tokio::test]
async fn render_errors_when_secret_missing() {
let tmp = TempDir::new().unwrap();
let paths = RenderPaths {
secret_path: tmp.path().join("does-not-exist"),
rendered_path: tmp.path().join("nginx.conf"),
};
let err = render(&paths).await.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("reading bitcoin RPC password"),
"unexpected error: {msg}"
);
}
#[test]
fn template_contains_exactly_one_placeholder() {
// Safety net: if someone adds a second placeholder to the
// template without updating the renderer, we want a test to
// fail loudly rather than ship a half-substituted config.
let count = TEMPLATE.matches(PLACEHOLDER).count();
assert_eq!(count, 1, "template must contain exactly one {PLACEHOLDER}");
}
#[test]
fn template_proxies_bitcoin_rpc_on_8332() {
// Lock in the core shape so a bad template edit doesn't ship.
assert!(TEMPLATE.contains("proxy_pass http://127.0.0.1:8332/"));
assert!(TEMPLATE.contains("location /bitcoin-rpc/"));
assert!(TEMPLATE.contains("listen 8334"));
}
}
@@ -9,11 +9,19 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Authorization "Basic __BITCOIN_RPC_AUTH__";
proxy_set_header Authorization "Basic {{BITCOIN_RPC_AUTH}}";
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
if ($request_method = OPTIONS) { return 204; }
}
location /bitcoin-status {
proxy_pass http://127.0.0.1:5678/bitcoin-status;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Cache-Control "no-store";
}
location / { try_files $uri $uri/ /index.html; }
}
@@ -0,0 +1,379 @@
//! BootReconciler — the long-running task that keeps the prod orchestrator's
//! desired-state view in lockstep with what podman actually has.
//!
//! Step 5 of the rust-orchestrator migration. Spawned once from `main.rs`
//! (Step 6) after the initial `adopt_existing()` pass. Every `interval` it
//! calls `ProdContainerOrchestrator::reconcile_existing()`, which repairs
//! containers that already exist without installing every catalog manifest.
//!
//! Per answered design Q3, `interval` defaults to 30 seconds.
//!
//! Shutdown is signalled via `Arc<Notify>`. The reconciler finishes its
//! current `reconcile_all` call before exiting — we don't interrupt an
//! in-flight pull or build.
//!
//! See `docs/rust-orchestrator-migration.md` §269-352.
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Notify;
use tokio::time::{self, Instant};
use crate::container::prod_orchestrator::{ProdContainerOrchestrator, ReconcileReport};
/// Default reconciler cadence (answered design Q3).
pub const DEFAULT_INTERVAL: Duration = Duration::from_secs(30);
pub struct BootReconciler {
orchestrator: Arc<ProdContainerOrchestrator>,
interval: Duration,
shutdown: Arc<Notify>,
/// Run the companion-unit repair stage each tick. Default true.
/// Tests disable this — companion reconcile shells out to
/// `systemctl --user` and `podman`, which both block real time
/// and would race the paused-clock test fixtures.
companion_stage: bool,
}
impl BootReconciler {
pub fn new(
orchestrator: Arc<ProdContainerOrchestrator>,
interval: Duration,
shutdown: Arc<Notify>,
) -> Self {
Self {
orchestrator,
interval,
shutdown,
companion_stage: true,
}
}
/// Disable the companion-unit reconcile stage. Used by unit tests
/// that exercise loop cadence without the real systemd / podman
/// surface. Production must not call this.
#[cfg(test)]
pub fn without_companion_stage(mut self) -> Self {
self.companion_stage = false;
self
}
/// Run the reconcile loop until `shutdown` is notified.
///
/// Does one reconcile immediately, then sleeps `interval` between
/// subsequent passes. A `shutdown.notify_one()` call unblocks the sleep
/// and the task returns after the *next* pass completes.
///
/// Each pass is two stages:
/// 1. App reconcile: `reconcile_all()` keeps every loaded manifest's
/// container running.
/// 2. Companion reconcile: any expected Quadlet companion unit that
/// is missing or inactive is repaired (writes the unit, daemon-
/// reloads, starts the service). This is the safety net for the
/// "someone deleted my unit file" / "systemd lost the service"
/// failure modes.
///
/// Never panics: per-app failures are absorbed into `ReconcileReport`
/// by the orchestrator, and companion failures are logged but never
/// propagated.
pub async fn run_forever(self) {
// Initial pass: no delay.
self.tick().await;
loop {
let deadline = Instant::now() + self.interval;
tokio::select! {
_ = time::sleep_until(deadline) => {
self.tick().await;
}
_ = self.shutdown.notified() => {
tracing::info!("boot reconciler: shutdown requested, exiting loop");
break;
}
}
}
}
async fn tick(&self) {
let report = self.orchestrator.reconcile_existing().await;
Self::log_report(&report);
if !self.companion_stage {
return;
}
let installed = self.orchestrator.manifest_ids().await;
for (companion, err) in crate::container::companion::reconcile(&installed).await {
tracing::warn!(
companion = %companion,
error = %err,
"companion reconcile failed"
);
}
}
fn log_report(report: &ReconcileReport) {
for (app_id, action) in &report.actions {
tracing::debug!(app_id = %app_id, action = ?action, "reconcile action");
}
for (app_id, err) in &report.failures {
tracing::warn!(app_id = %app_id, error = %err, "reconcile failure");
}
if report.failures.is_empty() {
tracing::debug!(count = report.actions.len(), "reconcile pass complete");
} else {
tracing::warn!(
ok = report.actions.len(),
failed = report.failures.len(),
"reconcile pass completed with failures"
);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::container::prod_orchestrator::ProdContainerOrchestrator;
use anyhow::Result;
use archipelago_container::{
AppManifest, BuildConfig, ContainerRuntime as ContainerRuntimeTrait, ContainerState,
ContainerStatus,
};
use async_trait::async_trait;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex as StdMutex;
/// Instrumented runtime that counts reconcile-loop side effects so tests
/// can tell exactly how many passes have fired. All containers are
/// reported as already Running so `reconcile_all` will NoOp — we are only
/// measuring loop cadence, not install behavior.
#[derive(Default)]
struct CountingRuntime {
/// Number of times get_container_status has been called. Each
/// reconcile_all pass hits this once per manifest, so with one
/// manifest this equals the number of reconcile passes.
status_calls: StdMutex<u32>,
running: StdMutex<HashMap<String, ContainerState>>,
}
impl CountingRuntime {
fn new_with(names: &[&str]) -> Self {
let me = Self::default();
let mut m = me.running.lock().unwrap();
for n in names {
m.insert((*n).to_string(), ContainerState::Running);
}
drop(m);
me
}
fn status_call_count(&self) -> u32 {
*self.status_calls.lock().unwrap()
}
}
#[async_trait]
impl ContainerRuntimeTrait for CountingRuntime {
async fn pull_image(&self, _: &str, _: Option<&str>) -> Result<()> {
Ok(())
}
async fn create_container(&self, _: &AppManifest, name: &str, _: u16) -> Result<String> {
Ok(name.to_string())
}
async fn start_container(&self, _: &str) -> Result<()> {
Ok(())
}
async fn stop_container(&self, _: &str) -> Result<()> {
Ok(())
}
async fn remove_container(&self, _: &str) -> Result<()> {
Ok(())
}
async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
*self.status_calls.lock().unwrap() += 1;
let state = self
.running
.lock()
.unwrap()
.get(name)
.cloned()
.ok_or_else(|| anyhow::anyhow!("not found: {name}"))?;
Ok(ContainerStatus {
id: format!("id-{name}"),
name: name.to_string(),
state,
health: None,
exit_code: None,
started_at: None,
image: "test".into(),
created: "now".into(),
ports: vec![],
lan_address: None,
})
}
async fn get_container_logs(&self, _: &str, _: u32) -> Result<Vec<String>> {
Ok(vec![])
}
async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
Ok(vec![])
}
async fn image_exists(&self, _: &str) -> Result<bool> {
Ok(true)
}
async fn build_image(&self, _: &BuildConfig) -> Result<()> {
Ok(())
}
}
fn pull_manifest(id: &str, image: &str) -> AppManifest {
let yaml = format!(
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {image}\n"
);
AppManifest::parse(&yaml).unwrap()
}
async fn orch_with_one_running_manifest(
rt: Arc<CountingRuntime>,
) -> Arc<ProdContainerOrchestrator> {
let mut orch =
ProdContainerOrchestrator::with_runtime(rt, PathBuf::from("/nonexistent-for-tests"));
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
)
.await;
orch
}
#[tokio::test(start_paused = true)]
async fn initial_pass_fires_immediately() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
// Yield so the spawned task gets CPU to run its initial reconcile.
tokio::task::yield_now().await;
tokio::task::yield_now().await;
// We expect exactly one reconcile pass to have run by now (the initial),
// NOT a second one (the 30s sleep hasn't elapsed in paused time).
assert_eq!(rt.status_call_count(), 1, "initial pass should fire once");
shutdown.notify_one();
// Under paused clock the select! is blocked on sleep_until; the notify
// will unblock it. Advance wall-clock a hair so the notify gets polled.
tokio::task::yield_now().await;
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
#[tokio::test(start_paused = true)]
async fn second_pass_fires_after_interval() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(rt.status_call_count(), 1);
// Fast-forward past one interval; the sleep_until should fire.
tokio::time::advance(Duration::from_secs(31)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
assert_eq!(
rt.status_call_count(),
2,
"a second reconcile pass should fire after one interval"
);
shutdown.notify_one();
tokio::task::yield_now().await;
let _ = tokio::time::timeout(Duration::from_secs(1), handle).await;
}
#[tokio::test(start_paused = true)]
async fn shutdown_terminates_loop() {
let rt = Arc::new(CountingRuntime::new_with(&["bitcoin-knots"]));
let orch = orch_with_one_running_manifest(rt.clone()).await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
shutdown.notify_one();
// The select! should wake on Notified and return. Use a real timeout
// with advancing the paused clock to make sure the task exits.
tokio::time::advance(Duration::from_millis(10)).await;
let result = tokio::time::timeout(Duration::from_secs(5), handle).await;
assert!(result.is_ok(), "reconciler did not exit after shutdown");
}
#[tokio::test(start_paused = true)]
async fn failure_in_one_pass_does_not_stop_loop() {
// Manifest references a container the runtime does not have AND
// cannot create (no install path — install_fresh will also fail to
// pull, since CountingRuntime::pull_image returns Ok but the
// manifest's referenced container stays uncreated). In practice
// reconcile_all will observe the missing container, install_fresh
// will run, and the next pass will see a new state. We care about
// "loop keeps ticking even when the report has actions".
let rt = Arc::new(CountingRuntime::default());
let mut orch = ProdContainerOrchestrator::with_runtime(
rt.clone(),
PathBuf::from("/nonexistent-for-tests"),
);
let tmp = tempfile::tempdir().unwrap().keep();
orch.set_data_dir(tmp);
let orch = Arc::new(orch);
orch.insert_manifest_for_test(
pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"),
PathBuf::from("/tmp/bk"),
)
.await;
let shutdown = Arc::new(Notify::new());
let reconciler =
BootReconciler::new(orch.clone(), Duration::from_secs(30), shutdown.clone())
.without_companion_stage();
let handle = tokio::spawn(reconciler.run_forever());
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let first = rt.status_call_count();
assert!(first >= 1, "initial pass should have touched the runtime");
// Advance one interval — second pass should fire regardless of what
// the first pass did.
tokio::time::advance(Duration::from_secs(31)).await;
tokio::task::yield_now().await;
tokio::task::yield_now().await;
let second = rt.status_call_count();
assert!(
second > first,
"loop should have fired a second pass after the interval"
);
shutdown.notify_one();
tokio::time::advance(Duration::from_millis(10)).await;
let _ = tokio::time::timeout(Duration::from_secs(5), handle).await;
}
}
+392
View File
@@ -0,0 +1,392 @@
//! Companion UI container lifecycle, entirely Quadlet-managed.
//!
//! A "companion" is a small nginx-based container that exposes a
//! browser-friendly UI on top of a headless backend service:
//!
//! | Backend | Companion | Purpose |
//! |------------------|--------------------|--------------------------|
//! | bitcoin-knots | archy-bitcoin-ui | RPC viewer |
//! | bitcoin-core | archy-bitcoin-ui | RPC viewer |
//! | lnd | archy-lnd-ui | wallet/channel UI |
//! | electrumx | archy-electrs-ui | indexer status UI |
//!
//! Lifecycle: `install` writes a Quadlet `.container` unit to
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
//! generated `.service`. systemd owns supervision from that point on
//! — archipelago can crash, restart, or be uninstalled without
//! touching the companion.
//!
//! This replaces the old `tokio::spawn { podman run }` block in
//! `install.rs` (~165 lines of fire-and-forget shellouts) with a
//! single declarative call.
use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
use tokio::process::Command;
use tracing::{info, warn};
use crate::container::quadlet::{self, BindMount, NetworkMode, QuadletUnit};
use archipelago_container::image_uses_insecure_registry;
const COMPANION_REGISTRY: &str = "146.59.87.168:3000/lfg2025";
/// Static description of one companion. The full list per backend
/// app_id lives in `companions_for`.
#[derive(Debug, Clone)]
pub struct CompanionSpec {
/// Container + unit name (e.g. "archy-bitcoin-ui").
pub name: &'static str,
/// Image base name in the lfg2025 registry namespace
/// (e.g. "bitcoin-ui" → "146.59.87.168:3000/lfg2025/bitcoin-ui:latest").
pub image_base: &'static str,
/// Filesystem locations to look for a local Dockerfile (build wins
/// over registry pull). Searched in order; first hit wins.
pub build_dir_candidates: &'static [&'static str],
/// Optional pre-start hook that renders config files referenced
/// by `bind_mounts`. Returns Ok(()) on success; bind-mount must
/// be present at start time or the companion will 502.
pub pre_start: Option<PreStartHook>,
/// Bind mounts. Always read-only — companions don't write to
/// host paths.
pub bind_mounts: &'static [(&'static str, &'static str)],
/// Host-to-container TCP ports for non-host-network companions.
pub ports: &'static [(u16, u16)],
/// Whether the companion must share the host network namespace.
pub host_network: bool,
}
pub type PreStartHook = fn() -> futures_util::future::BoxFuture<'static, Result<()>>;
/// Companions to install when `package_id` lands. Empty for apps
/// without a companion UI.
pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
match package_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => BITCOIN_UI,
"lnd" => LND_UI,
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
_ => &[],
}
}
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-bitcoin-ui",
image_base: "bitcoin-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/bitcoin-ui",
"/home/archipelago/archy/docker/bitcoin-ui",
"/home/archipelago/Projects/archy/docker/bitcoin-ui",
],
pre_start: Some(render_bitcoin_ui),
bind_mounts: &[(
"/var/lib/archipelago/bitcoin-ui/nginx.conf",
"/etc/nginx/conf.d/default.conf",
)],
ports: &[],
host_network: true,
}];
const LND_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-lnd-ui",
image_base: "lnd-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/lnd-ui",
"/home/archipelago/archy/docker/lnd-ui",
"/home/archipelago/Projects/archy/docker/lnd-ui",
],
pre_start: None,
bind_mounts: &[],
ports: &[(18083, 80)],
host_network: false,
}];
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-electrs-ui",
image_base: "electrs-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/electrs-ui",
"/home/archipelago/archy/docker/electrs-ui",
"/home/archipelago/Projects/archy/docker/electrs-ui",
],
pre_start: None,
bind_mounts: &[],
ports: &[],
host_network: true,
}];
fn render_bitcoin_ui() -> futures_util::future::BoxFuture<'static, Result<()>> {
Box::pin(async {
let paths = crate::container::bitcoin_ui::RenderPaths::default();
crate::container::bitcoin_ui::render(&paths)
.await
.map(|_| ())
.context("render bitcoin-ui nginx.conf")
})
}
/// Provision and start every companion for `package_id`. Each
/// companion is independent — a failure in one is logged but does
/// not abort the others.
pub async fn install_for(package_id: &str) -> Vec<(String, anyhow::Error)> {
let mut failures = Vec::new();
for spec in companions_for(package_id) {
if let Err(e) = install_one(spec).await {
warn!(companion = spec.name, error = %e, "companion install failed");
failures.push((spec.name.to_string(), e));
}
}
failures
}
/// Stop and remove every companion for `package_id`. Best effort:
/// errors are logged but do not abort the sequence.
pub async fn remove_for(package_id: &str) {
let dir = match quadlet::unit_dir().await {
Ok(d) => d,
Err(e) => {
warn!("companion remove: cannot resolve quadlet dir: {e:#}");
return;
}
};
for spec in companions_for(package_id) {
if let Err(e) = quadlet::disable_remove(spec.name, &dir).await {
warn!(companion = spec.name, error = %e, "companion remove failed");
}
}
}
/// Provision one companion: pre-start hook → image present → write
/// quadlet → daemon-reload → start.
pub async fn install_one(spec: &CompanionSpec) -> Result<()> {
if let Some(hook) = spec.pre_start {
hook().await.with_context(|| {
format!(
"pre-start hook failed for {} — companion will not start",
spec.name
)
})?;
}
let image = ensure_image_present(spec).await?;
let unit = build_unit(spec, &image);
let dir = quadlet::unit_dir().await?;
let changed = quadlet::write_if_changed(&unit, &dir).await?;
if changed {
info!(companion = spec.name, "wrote quadlet unit");
quadlet::daemon_reload_user().await?;
}
// Start is idempotent — if already running, systemctl returns 0.
quadlet::enable_now(&unit.service_name()).await?;
info!(companion = spec.name, "companion started");
Ok(())
}
/// Build companion image locally if a Dockerfile exists, otherwise
/// pull from the lfg2025 registry. Returns the image ref the quadlet
/// should reference (`localhost/<base>:latest` for build, registry
/// URL for pull).
async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
let local_image = format!("localhost/{}:latest", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
// Prefer local build — companions can carry build-time customizations
// (e.g. nginx.conf templates baked in). Search known candidates.
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
if image_exists(&local_image).await {
return Ok(local_image);
}
info!(companion = spec.name, "building locally from {dir}");
let out = Command::new("podman")
.args(["build", "-t", &local_image, dir])
.output()
.await
.context("spawn podman build")?;
if out.status.success() {
return Ok(local_image);
}
warn!(
companion = spec.name,
"local build failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
// Fall through to registry pull rather than fail outright.
break;
}
}
// Registry pull. Use insecure flag only for whitelisted hosts.
let mut cmd = Command::new("podman");
cmd.arg("pull");
if image_uses_insecure_registry(&registry_image) {
cmd.arg("--tls-verify=false");
}
cmd.arg(&registry_image);
let out = cmd.output().await.context("spawn podman pull")?;
if !out.status.success() {
anyhow::bail!(
"no local Dockerfile and registry pull failed for {}: {}",
spec.name,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(registry_image)
}
async fn image_exists(image: &str) -> bool {
Command::new("podman")
.args(["image", "exists", image])
.status()
.await
.is_ok_and(|status| status.success())
}
fn build_unit(spec: &CompanionSpec, image: &str) -> QuadletUnit {
QuadletUnit {
name: spec.name.into(),
description: format!("Archipelago companion UI: {}", spec.name),
image: image.into(),
network: if spec.host_network {
NetworkMode::Host
} else {
NetworkMode::Bridge("bridge".into())
},
// Run as root inside the container so nginx can chown its
// worker dirs. Rootless podman maps this to a high host UID,
// so it is unprivileged on the host.
user: Some("0:0".into()),
memory_mb: Some(128),
cap_drop_all: true,
cap_add: vec![
"CHOWN".into(),
"DAC_OVERRIDE".into(),
"NET_BIND_SERVICE".into(),
"SETUID".into(),
"SETGID".into(),
],
bind_mounts: spec
.bind_mounts
.iter()
.map(|(host, container)| BindMount {
host: PathBuf::from(*host),
container: PathBuf::from(*container),
read_only: true,
})
.collect(),
ports: spec
.ports
.iter()
.map(|(host, container)| (*host, *container, "tcp".into()))
.collect(),
extra_podman_args: vec![],
depends_on: vec![],
// Companions don't use the backend-manifest extension fields;
// the renderer skips empty/false directives so the rendered
// bytes are unchanged from before quadlet.rs grew the new fields.
..QuadletUnit::default()
}
}
/// Is a user systemd manager reachable? In production archipelago.service
/// inherits XDG_RUNTIME_DIR from systemd; in unit tests / CI sandboxes it
/// is unset, in which case `systemctl --user` would fail and write to
/// HOME would be an unwanted side effect. The reconciler skips its
/// companion stage when this is false.
fn user_systemd_available() -> bool {
std::env::var_os("XDG_RUNTIME_DIR")
.map(|v| !v.is_empty())
.unwrap_or(false)
}
/// Reconcile companion presence: every expected companion for the
/// given installed apps must have its quadlet unit on disk and its
/// service active. Returns a list of (companion, error) for anything
/// that needed correction and failed.
///
/// Called from `boot_reconciler` so a deleted unit file or a stopped
/// service is repaired within one tick. No-ops if the user systemd
/// manager is not reachable (CI / test environments).
pub async fn reconcile(installed_apps: &[String]) -> Vec<(String, anyhow::Error)> {
if !user_systemd_available() {
return Vec::new();
}
let mut failures = Vec::new();
for app_id in installed_apps {
for spec in companions_for(app_id) {
match needs_repair(spec).await {
Ok(false) => {}
Ok(true) => {
info!(
companion = spec.name,
"reconcile: companion not active, repairing"
);
if let Err(e) = install_one(spec).await {
failures.push((spec.name.to_string(), e));
}
}
Err(e) => {
warn!(companion = spec.name, error = %e, "reconcile probe failed");
failures.push((spec.name.to_string(), e));
}
}
}
}
failures
}
/// Does this companion need install_one to be re-run? Returns true if
/// the unit file is missing OR the service is not active.
async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
let dir = quadlet::unit_dir().await?;
let unit_path = dir.join(format!("{}.container", spec.name));
if !fs::try_exists(&unit_path).await.unwrap_or(false) {
return Ok(true);
}
let svc = format!("{}.service", spec.name);
Ok(!quadlet::is_active(&svc).await)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn companions_for_known_apps_returns_expected_set() {
assert_eq!(companions_for("bitcoin-knots").len(), 1);
assert_eq!(companions_for("bitcoin-core").len(), 1);
assert_eq!(companions_for("bitcoin").len(), 1);
assert_eq!(companions_for("lnd").len(), 1);
assert_eq!(companions_for("electrumx").len(), 1);
assert_eq!(companions_for("electrs").len(), 1);
assert_eq!(companions_for("mempool-electrs").len(), 1);
assert_eq!(companions_for("nextcloud").len(), 0);
assert_eq!(companions_for("not-a-real-app").len(), 0);
}
#[test]
fn build_unit_uses_host_network_and_drops_caps() {
let spec = &BITCOIN_UI[0];
let u = build_unit(spec, "localhost/bitcoin-ui:latest");
assert_eq!(u.name, "archy-bitcoin-ui");
assert!(matches!(u.network, NetworkMode::Host));
assert!(u.cap_drop_all);
assert!(u.cap_add.iter().any(|c| c == "NET_BIND_SERVICE"));
assert_eq!(u.user.as_deref(), Some("0:0"));
assert_eq!(u.memory_mb, Some(128));
assert_eq!(u.bind_mounts.len(), 1);
assert_eq!(
u.bind_mounts[0].container,
PathBuf::from("/etc/nginx/conf.d/default.conf")
);
assert!(u.bind_mounts[0].read_only);
}
#[test]
fn lnd_ui_uses_port_mapping_not_host_port_80() {
let spec = &LND_UI[0];
let u = build_unit(spec, "localhost/lnd-ui:latest");
assert_eq!(u.name, "archy-lnd-ui");
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
}
}
@@ -1,12 +1,15 @@
use anyhow::{Context, Result};
use archipelago_container::{
AppManifest, BitcoinSimulationMode, BitcoinSimulator,
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager,
ContainerRuntime as ContainerRuntimeTrait, ContainerStatus, PortManager, ResolvedSource,
};
use async_trait::async_trait;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::config::{BitcoinSimulation, Config, ContainerRuntime};
use crate::container::data_manager::DevDataManager;
use crate::container::traits::ContainerOrchestrator;
pub struct DevContainerOrchestrator {
runtime: Arc<dyn ContainerRuntimeTrait>,
@@ -103,14 +106,27 @@ impl DevContainerOrchestrator {
volume.source = dev_path.to_string_lossy().to_string();
}
// Pull image
self.runtime
.pull_image(
&manifest.app.container.image,
manifest.app.container.image_signature.as_deref(),
)
.await
.context("Failed to pull image")?;
// Resolve pull-or-build. Dev orchestrator currently only supports pull;
// Build support lands in Step 2 of the rust-orchestrator migration.
match manifest.app.container.resolve().ok_or_else(|| {
anyhow::anyhow!("manifest container config invalid (neither image nor build)")
})? {
ResolvedSource::Pull {
image,
image_signature,
..
} => {
self.runtime
.pull_image(&image, image_signature.as_deref())
.await
.context("Failed to pull image")?;
}
ResolvedSource::Build(_) => {
anyhow::bail!(
"dev orchestrator does not yet support local image builds (see rust-orchestrator-migration.md Step 2)"
);
}
}
// Create container with port offset
let port_offset = if self.config.dev_mode {
@@ -242,4 +258,153 @@ impl DevContainerOrchestrator {
archipelago_container::ContainerState::Unknown(_) => Ok("unknown".to_string()),
}
}
/// Load a manifest for `app_id` from the dev-mode apps directory.
///
/// Used by the trait-level `install(app_id)` entry point.
///
/// Search order intentionally mirrors production/operator reality:
/// 1) `$ARCHIPELAGO_APPS_DIR` (explicit override)
/// 2) `/opt/archipelago/apps` (image-recipe canonical path)
/// 3) `/home/archipelago/Projects/archy/apps` (repo-local fallback on dev nodes)
/// 4) `<data_dir>/apps` (legacy dev layout)
async fn load_manifest_for(&self, app_id: &str) -> Result<AppManifest> {
let candidates = candidate_manifest_paths(app_id, &self.config.data_dir);
let mut last_err: Option<anyhow::Error> = None;
for path in candidates {
let content = match tokio::fs::read_to_string(&path).await {
Ok(c) => c,
Err(e) => {
last_err = Some(e.into());
continue;
}
};
let manifest: AppManifest = serde_yaml::from_str(&content)
.with_context(|| format!("parsing manifest {}", path.display()))?;
return Ok(manifest);
}
let msg = format!(
"manifest for {} not found in any search path (set ARCHIPELAGO_APPS_DIR or install /opt/archipelago/apps)",
app_id
);
Err(match last_err {
Some(e) => e.context(msg),
None => anyhow::anyhow!(msg),
})
}
}
fn candidate_manifest_paths(app_id: &str, data_dir: &Path) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = Vec::new();
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
let v = v.trim();
if !v.is_empty() {
roots.push(PathBuf::from(v));
}
}
roots.push(PathBuf::from("/opt/archipelago/apps"));
roots.push(PathBuf::from("/home/archipelago/Projects/archy/apps"));
roots.push(data_dir.join("apps"));
let mut deduped: Vec<PathBuf> = Vec::new();
for root in roots {
if !deduped.iter().any(|p| p == &root) {
deduped.push(root);
}
}
deduped
.into_iter()
.map(|root| root.join(app_id).join("manifest.yml"))
.collect()
}
// ---------------------------------------------------------------------------
// Trait impl (Step 4): expose the shared ContainerOrchestrator surface.
// Forwards to the inherent methods, which internally apply the `-dev` suffix
// and the port offset. The trait keeps the RPC layer mode-agnostic; Dev's
// install_container (manifest_path-based) stays as an inherent method for the
// ad-hoc dev-mode RPC and is not exposed on the trait.
// ---------------------------------------------------------------------------
#[async_trait]
impl ContainerOrchestrator for DevContainerOrchestrator {
async fn install(&self, app_id: &str) -> Result<String> {
let manifest = self.load_manifest_for(app_id).await?;
let name = self.install_container(&manifest, "").await?;
Ok(name)
}
async fn start(&self, app_id: &str) -> Result<()> {
self.start_container(app_id).await
}
async fn stop(&self, app_id: &str) -> Result<()> {
self.stop_container(app_id).await
}
async fn restart(&self, app_id: &str) -> Result<()> {
let _ = self.stop_container(app_id).await;
self.start_container(app_id).await
}
async fn remove(&self, app_id: &str, preserve_data: bool) -> Result<()> {
self.remove_container(app_id, preserve_data).await
}
async fn upgrade(&self, app_id: &str) -> Result<()> {
// Dev upgrade: stop, remove (preserving data), re-install from the loaded manifest.
let _ = self.stop_container(app_id).await;
let _ = self.remove_container(app_id, true).await;
let manifest = self.load_manifest_for(app_id).await?;
self.install_container(&manifest, "").await?;
self.start_container(app_id).await
}
async fn status(&self, app_id: &str) -> Result<ContainerStatus> {
self.get_container_status(app_id).await
}
async fn list(&self) -> Result<Vec<ContainerStatus>> {
self.list_containers().await
}
async fn logs(&self, app_id: &str, lines: u32) -> Result<Vec<String>> {
self.get_container_logs(app_id, lines).await
}
async fn health(&self, app_id: &str) -> Result<String> {
self.get_health_status(app_id).await
}
}
#[cfg(test)]
mod tests {
use super::candidate_manifest_paths;
use std::path::PathBuf;
#[test]
fn candidate_manifest_paths_include_expected_fallbacks() {
let app_id = "bitcoin-ui";
let paths = candidate_manifest_paths(app_id, &PathBuf::from("/var/lib/archipelago"));
let as_strings: Vec<String> = paths
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect();
assert!(as_strings
.iter()
.any(|p| p == "/opt/archipelago/apps/bitcoin-ui/manifest.yml"));
assert!(as_strings
.iter()
.any(|p| p == "/home/archipelago/Projects/archy/apps/bitcoin-ui/manifest.yml"));
assert!(as_strings
.iter()
.any(|p| p == "/var/lib/archipelago/apps/bitcoin-ui/manifest.yml"));
}
}
@@ -44,11 +44,6 @@ impl DockerPackageScanner {
"nbxplorer",
"mempool-db",
"mempool-api",
"penpot-postgres",
"penpot-backend",
"penpot-exporter",
"penpot-valkey",
"penpot-mailcatch",
"immich_postgres",
"immich_redis",
"endurain-db",
@@ -68,10 +63,14 @@ impl DockerPackageScanner {
"indeedhub-build_ffmpeg-worker_1",
];
// First pass: collect UI containers
// First pass: collect running UI containers. Custom UI-backed apps must
// not advertise a launch URL unless their companion is actually alive.
let mut ui_containers: HashMap<String, String> = HashMap::new();
for container in &containers {
if container.name.ends_with("-ui") {
if !matches!(container.state, ContainerState::Running) {
continue;
}
// Map fedimint-ui -> fedimint, lnd-ui -> lnd (normalize archy- prefix for lookup)
let parent_app = container
.name
@@ -81,10 +80,10 @@ impl DockerPackageScanner {
.strip_prefix("archy-")
.unwrap_or(parent_app)
.to_string();
if !container.ports.is_empty() {
if let Some(ui_address) = extract_lan_address(&container.ports) {
ui_containers.insert(canonical_id, ui_address);
}
let ui_address = extract_lan_address(&container.ports)
.or_else(|| companion_lan_address(&canonical_id));
if let Some(ui_address) = ui_address {
ui_containers.insert(canonical_id, ui_address);
}
}
}
@@ -138,12 +137,6 @@ impl DockerPackageScanner {
// Apps with separate UI containers (e.g. archy-bitcoin-ui, archy-lnd-ui)
debug!("Using UI container for {}: {}", app_id, ui_address);
Some(ui_address.clone())
} else if app_id == "bitcoin-knots" {
Some("http://localhost:8334".to_string())
} else if app_id == "lnd" {
Some("http://localhost:8081".to_string())
} else if app_id == "electrumx" || app_id == "mempool-electrs" || app_id == "electrs" {
Some("http://localhost:50002".to_string())
} else {
// Dynamic: use actual port bindings from container, fall back to static map
extract_lan_address(&container.ports)
@@ -297,9 +290,16 @@ fn get_app_tier(app_id: &str) -> &'static str {
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => AppMetadata {
"bitcoin-core" => AppMetadata {
title: "Bitcoin Core".to_string(),
description: "Reference Bitcoin node implementation".to_string(),
icon: "/assets/img/app-icons/bitcoin-core.svg".to_string(),
repo: "https://github.com/bitcoin/bitcoin".to_string(),
tier: "",
},
"bitcoin" | "bitcoin-knots" => AppMetadata {
title: "Bitcoin Knots".to_string(),
description: "Full Bitcoin node implementation".to_string(),
description: "Enhanced Bitcoin node implementation".to_string(),
icon: "/assets/img/app-icons/bitcoin-knots.webp".to_string(),
repo: "https://github.com/bitcoinknots/bitcoin".to_string(),
tier: "",
@@ -409,13 +409,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/cryptpad/cryptpad".to_string(),
tier: "",
},
"penpot" | "penpot-frontend" => AppMetadata {
title: "Penpot".to_string(),
description: "Open-source design and prototyping".to_string(),
icon: "/assets/img/app-icons/penpot.webp".to_string(),
repo: "https://github.com/penpot/penpot".to_string(),
tier: "",
},
"nextcloud" => AppMetadata {
title: "Nextcloud".to_string(),
description: "Self-hosted cloud storage and file management".to_string(),
@@ -493,13 +486,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"nostr-rs-relay" => AppMetadata {
title: "Nostr Relay".to_string(),
description: "Run your own Nostr relay for sovereign event storage".to_string(),
icon: "/assets/img/app-icons/nostr-rs-relay.svg".to_string(),
repo: "https://sr.ht/~gheartsfield/nostr-rs-relay/".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
@@ -645,6 +631,14 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
None
}
fn companion_lan_address(app_id: &str) -> Option<String> {
match app_id {
"bitcoin" | "bitcoin-knots" | "bitcoin-core" => Some("http://localhost:8334".to_string()),
"electrumx" | "mempool-electrs" | "electrs" => Some("http://localhost:50002".to_string()),
_ => None,
}
}
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),
@@ -0,0 +1,118 @@
//! filebrowser config bootstrap helper.
//!
//! Mirrors the legacy first-boot behavior that writes
//! `/var/lib/archipelago/filebrowser-data/.filebrowser.json` before
//! starting the container with `--config /data/.filebrowser.json`.
use anyhow::{Context, Result};
use std::path::PathBuf;
use tokio::fs;
pub const DEFAULT_SRV_ROOT: &str = "/var/lib/archipelago/filebrowser";
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/filebrowser-data";
pub const DEFAULT_CONFIG_PATH: &str = "/var/lib/archipelago/filebrowser-data/.filebrowser.json";
const DEFAULT_CONFIG_JSON: &str =
"{\"port\":80,\"baseURL\":\"\",\"address\":\"0.0.0.0\",\"database\":\"/data/filebrowser.db\",\"root\":\"/srv\",\"log\":\"stdout\"}\n";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
pub srv_root: PathBuf,
pub data_dir: PathBuf,
pub config_path: PathBuf,
}
impl Default for EnsurePaths {
fn default() -> Self {
Self {
srv_root: PathBuf::from(DEFAULT_SRV_ROOT),
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
config_path: PathBuf::from(DEFAULT_CONFIG_PATH),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnsureOutcome {
Written,
Unchanged,
}
pub async fn ensure_config(paths: &EnsurePaths) -> Result<EnsureOutcome> {
fs::create_dir_all(&paths.srv_root)
.await
.with_context(|| format!("creating {}", paths.srv_root.display()))?;
fs::create_dir_all(&paths.data_dir)
.await
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
for d in ["Documents", "Photos", "Music", "Downloads", "Builds"] {
fs::create_dir_all(paths.srv_root.join(d))
.await
.with_context(|| format!("creating {}/{}", paths.srv_root.display(), d))?;
}
if paths.config_path.exists() {
return Ok(EnsureOutcome::Unchanged);
}
let parent = paths
.config_path
.parent()
.ok_or_else(|| anyhow::anyhow!("config_path has no parent directory"))?;
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
let tmp = paths.config_path.with_extension("tmp");
fs::write(&tmp, DEFAULT_CONFIG_JSON)
.await
.with_context(|| format!("writing tmp {}", tmp.display()))?;
fs::rename(&tmp, &paths.config_path)
.await
.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.config_path.display()
)
})?;
Ok(EnsureOutcome::Written)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ensure_config_creates_dirs_and_file() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
srv_root: tmp.path().join("filebrowser"),
data_dir: tmp.path().join("filebrowser-data"),
config_path: tmp.path().join("filebrowser-data/.filebrowser.json"),
};
let out = ensure_config(&paths).await.unwrap();
assert_eq!(out, EnsureOutcome::Written);
assert!(paths.config_path.exists());
assert!(paths.srv_root.join("Documents").exists());
assert!(paths.srv_root.join("Photos").exists());
}
#[tokio::test]
async fn ensure_config_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
srv_root: tmp.path().join("filebrowser"),
data_dir: tmp.path().join("filebrowser-data"),
config_path: tmp.path().join("filebrowser-data/.filebrowser.json"),
};
let first = ensure_config(&paths).await.unwrap();
assert_eq!(first, EnsureOutcome::Written);
let second = ensure_config(&paths).await.unwrap();
assert_eq!(second, EnsureOutcome::Unchanged);
}
}
@@ -1,8 +1,8 @@
//! Parser for image-versions.sh — single source of truth for pinned container images.
//!
//! Reads the deployed file at /opt/archipelago/image-versions.sh (or the repo-local
//! scripts/image-versions.sh as fallback) and exposes lookup functions so the container
//! scanner can compare running images against pinned targets.
//! Reads the deployed file at /opt/archipelago/scripts/image-versions.sh (the canonical
//! location installed by the image-recipe) with fallbacks for older layouts and the
//! repo-local scripts/image-versions.sh for development runs from the repo root.
use std::collections::HashMap;
use std::path::Path;
@@ -18,9 +18,15 @@ struct CacheEntry {
images: HashMap<String, String>,
}
/// File search order — production path first, then repo-local for dev.
/// File search order — canonical production path first, older layout second,
/// repo-local for dev last. The canonical deployed path is
/// /opt/archipelago/scripts/image-versions.sh; earlier builds put it directly
/// in /opt/archipelago/, so that path is kept as a fallback for not-yet-updated
/// nodes. The repo-relative entry matches `cargo run` from the repo root.
const PATHS: &[&str] = &[
"/opt/archipelago/scripts/image-versions.sh",
"/opt/archipelago/image-versions.sh",
"/home/archipelago/Projects/archy/scripts/image-versions.sh",
"scripts/image-versions.sh",
];
@@ -102,8 +108,11 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
}
}
// Keep only *_IMAGE entries
vars.retain(|k, _| k.ends_with("_IMAGE"));
// Keep only *_IMAGE entries whose value looks like a container image
// reference (contains a `:` tag separator and at least one `/` path
// component). Rejects placeholder values like "something" so a
// hand-edit typo in image-versions.sh never gets treated as an image.
vars.retain(|k, v| k.ends_with("_IMAGE") && v.contains(':') && v.contains('/'));
vars
}
@@ -134,12 +143,15 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"lnd" => Some("LND_IMAGE"),
"electrumx" => Some("ELECTRUMX_IMAGE"),
"electrs" | "mempool-electrs" => Some("ELECTRUMX_IMAGE"),
"bitcoin-ui" | "archy-bitcoin-ui" => Some("BITCOIN_UI_IMAGE"),
"lnd-ui" | "archy-lnd-ui" => Some("LND_UI_IMAGE"),
"electrs-ui" | "archy-electrs-ui" => Some("ELECTRS_UI_IMAGE"),
// Mempool stack (primary = web)
"mempool" | "mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
// BTCPay stack (primary = server)
"btcpay" | "btcpay-server" | "btcpayserver" => Some("BTCPAY_IMAGE"),
"btcpay" | "btcpay-server" | "btcpayserver" | "archy-btcpay-ui" => Some("BTCPAY_IMAGE"),
// Apps
"homeassistant" | "home-assistant" => Some("HOMEASSISTANT_IMAGE"),
+425
View File
@@ -0,0 +1,425 @@
//! lnd config bootstrap helper.
use anyhow::{Context, Result};
use base64::Engine;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tokio::fs;
use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
pub const WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
pub data_dir: PathBuf,
pub conf_path: PathBuf,
}
impl Default for EnsurePaths {
fn default() -> Self {
Self {
data_dir: PathBuf::from(DEFAULT_DATA_DIR),
conf_path: PathBuf::from(DEFAULT_CONF_PATH),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EnsureOutcome {
Written,
Unchanged,
}
pub async fn ensure_config(paths: &EnsurePaths, rpc_pass: &str) -> Result<EnsureOutcome> {
fs::create_dir_all(&paths.data_dir)
.await
.with_context(|| format!("creating {}", paths.data_dir.display()))?;
if paths.conf_path.exists() {
let existing = fs::read_to_string(&paths.conf_path)
.await
.with_context(|| format!("reading {}", paths.conf_path.display()))?;
if has_required_lnd_flags(&existing) {
return Ok(EnsureOutcome::Unchanged);
}
}
let conf = format!(
"debuglevel=info\n\
maxpendingchannels=10\n\
alias=Archipelago Node\n\
color=#f7931a\n\
listen=0.0.0.0:9735\n\
rpclisten=0.0.0.0:10009\n\
restlisten=0.0.0.0:8080\n\
bitcoin.active=true\n\
bitcoin.mainnet=true\n\
bitcoin.node=bitcoind\n\
bitcoind.rpchost=bitcoin-knots:8332\n\
bitcoind.rpcuser=archipelago\n\
bitcoind.rpcpass={}\n\
bitcoind.rpcpolling=true\n\
bitcoind.estimatemode=ECONOMICAL\n",
rpc_pass
);
write_config_atomically(paths, &conf).await?;
Ok(EnsureOutcome::Written)
}
pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
}
tokio::process::Command::new("sudo")
.args(["test", "-f", path])
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
match fs::read(path).await {
Ok(bytes) => Ok(bytes),
Err(direct_err) => {
let out = tokio::process::Command::new("sudo")
.args(["cat", path])
.output()
.await
.with_context(|| format!("reading {path} via sudo"))?;
if out.status.success() {
Ok(out.stdout)
} else {
anyhow::bail!(
"reading {path} failed (direct: {direct_err}; sudo: {})",
String::from_utf8_lossy(&out.stderr).trim()
)
}
}
}
}
async fn unlock_existing_wallet() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
.await
.context("writing lncli password")?;
}
let out = child
.wait_with_output()
.await
.context("waiting for lncli")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let msg = format!("{stderr}{stdout}");
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
return Ok(());
}
last_err = Some(msg);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"lncli wallet unlock failed: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
#[derive(Debug, Deserialize)]
struct GenSeedResponse {
cipher_seed_mnemonic: Vec<String>,
}
#[derive(Debug)]
enum UnlockerResponse<T> {
Value(T),
WalletAlreadyExists,
}
#[derive(Debug, Serialize)]
struct InitWalletRequest {
wallet_password: String,
cipher_seed_mnemonic: Vec<String>,
}
async fn init_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let seed: GenSeedResponse = match get_lnd_unlocker_json(&client, "/v1/genseed")
.await
.context("generating LND wallet seed")?
{
UnlockerResponse::Value(seed) => seed,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
return Ok(());
}
};
if seed.cipher_seed_mnemonic.is_empty() {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
};
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/initwallet",
serde_json::to_value(req)?,
)
.await
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
}
Ok(())
}
async fn get_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let mut last_err = None;
for _ in 0..60 {
match client.get(&url).send().await {
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
Ok(value) => return Ok(value),
Err(e) => last_err = Some(e.to_string()),
},
Err(e) => last_err = Some(e.to_string()),
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"LND REST {path} unavailable: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
async fn post_lnd_unlocker_json<T: for<'de> Deserialize<'de>>(
client: &reqwest::Client,
path: &str,
body: serde_json::Value,
) -> Result<UnlockerResponse<T>> {
let url = format!("https://127.0.0.1:8080{path}");
let mut last_err = None;
for _ in 0..60 {
match client.post(&url).json(&body).send().await {
Ok(resp) => match decode_lnd_unlocker_response(resp, path).await {
Ok(value) => return Ok(value),
Err(e) => last_err = Some(e.to_string()),
},
Err(e) => last_err = Some(e.to_string()),
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!(
"LND REST {path} unavailable: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
}
async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
resp: reqwest::Response,
path: &str,
) -> Result<UnlockerResponse<T>> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
let value = serde_json::from_str(&text)
.with_context(|| format!("parsing LND REST response from {path}"))?;
return Ok(UnlockerResponse::Value(value));
}
if text.contains("wallet already exists") {
return Ok(UnlockerResponse::WalletAlreadyExists);
}
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
};
let Ok(client) = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
else {
return false;
};
client
.get("https://127.0.0.1:8080/v1/getinfo")
.header("Grpc-Metadata-macaroon", hex::encode(macaroon))
.send()
.await
.map(|resp| resp.status().is_success())
.unwrap_or(false)
}
async fn wait_for_admin_macaroon(admin_macaroon: &str) -> Result<()> {
for _ in 0..60 {
if file_exists_as_root(admin_macaroon).await {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND admin macaroon not created after wallet init")
}
async fn write_config_atomically(paths: &EnsurePaths, conf: &str) -> Result<()> {
let tmp = paths.conf_path.with_extension("tmp");
match fs::write(&tmp, conf).await {
Ok(()) => {
fs::rename(&tmp, &paths.conf_path).await.with_context(|| {
format!(
"renaming {} -> {}",
tmp.display(),
paths.conf_path.display()
)
})?;
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
let script = format!(
"set -eu\ncat > '{}' <<'LNDCONF'\n{}LNDCONF\n",
shell_quote(&paths.conf_path.to_string_lossy()),
conf
);
let status = host_sudo(&["sh", "-lc", &script])
.await
.context("writing lnd.conf via sudo")?;
if !status.success() {
anyhow::bail!("writing lnd.conf via sudo exited with {status}");
}
Ok(())
}
Err(e) => Err(e).with_context(|| format!("writing tmp {}", tmp.display())),
}
}
fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
fn has_required_lnd_flags(conf: &str) -> bool {
[
"bitcoin.active=true",
"bitcoin.mainnet=true",
"bitcoin.node=bitcoind",
"bitcoind.rpchost=bitcoin-knots:8332",
]
.iter()
.all(|needle| conf.lines().any(|line| line.trim() == *needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn ensure_config_writes_required_bitcoin_network_flags() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
let out = ensure_config(&paths, "secret").await.unwrap();
assert_eq!(out, EnsureOutcome::Written);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.active=true"));
assert!(conf.contains("bitcoin.mainnet=true"));
assert!(conf.contains("bitcoin.node=bitcoind"));
assert!(conf.contains("bitcoind.rpchost=bitcoin-knots:8332"));
assert!(conf.contains("bitcoind.rpcpass=secret"));
}
#[tokio::test]
async fn ensure_config_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
assert_eq!(
ensure_config(&paths, "first").await.unwrap(),
EnsureOutcome::Written
);
assert_eq!(
ensure_config(&paths, "second").await.unwrap(),
EnsureOutcome::Unchanged
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoind.rpcpass=first"));
}
#[tokio::test]
async fn ensure_config_repairs_incomplete_existing_config() {
let tmp = tempfile::TempDir::new().unwrap();
let paths = EnsurePaths {
data_dir: tmp.path().join("lnd"),
conf_path: tmp.path().join("lnd/lnd.conf"),
};
fs::create_dir_all(&paths.data_dir).await.unwrap();
fs::write(&paths.conf_path, "debuglevel=info\n")
.await
.unwrap();
assert_eq!(
ensure_config(&paths, "repaired").await.unwrap(),
EnsureOutcome::Written
);
let conf = fs::read_to_string(&paths.conf_path).await.unwrap();
assert!(conf.contains("bitcoin.mainnet=true"));
assert!(conf.contains("bitcoind.rpcpass=repaired"));
}
#[test]
fn wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
}
}
+11
View File
@@ -1,8 +1,19 @@
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
pub mod data_manager;
pub mod dev_orchestrator;
pub mod docker_packages;
pub mod filebrowser;
pub mod image_versions;
pub mod lnd;
pub mod prod_orchestrator;
pub mod quadlet;
pub mod registry;
pub mod traits;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};
pub use dev_orchestrator::DevContainerOrchestrator;
pub use docker_packages::DockerPackageScanner;
pub use prod_orchestrator::ProdContainerOrchestrator;
pub use traits::ContainerOrchestrator;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+73 -25
View File
@@ -8,14 +8,15 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, info};
const REGISTRY_FILE: &str = "config/registries.json";
const OVH_REGISTRY_URL: &str = "146.59.87.168:3000/lfg2025";
const TX1138_REGISTRY_URL: &str = "git.tx1138.com/lfg2025";
/// A single container registry.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Registry {
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "23.182.128.160:3000/lfg2025").
/// Registry URL (e.g., "git.tx1138.com/lfg2025" or "146.59.87.168:3000/lfg2025").
pub url: String,
/// Human-readable name.
pub name: String,
@@ -44,26 +45,19 @@ impl Default for RegistryConfig {
Self {
registries: vec![
Registry {
url: "23.182.128.160:3000/lfg2025".to_string(),
name: "Server 1 (VPS)".to_string(),
url: OVH_REGISTRY_URL.to_string(),
name: "Server 1 (OVH)".to_string(),
tls_verify: false,
enabled: true,
priority: 0,
},
Registry {
url: "git.tx1138.com/lfg2025".to_string(),
url: TX1138_REGISTRY_URL.to_string(),
name: "Server 2 (tx1138)".to_string(),
tls_verify: true,
enabled: true,
priority: 10,
},
Registry {
url: "146.59.87.168:3000/lfg2025".to_string(),
name: "Server 3 (OVH)".to_string(),
tls_verify: false,
enabled: true,
priority: 20,
},
],
}
}
@@ -78,15 +72,14 @@ impl RegistryConfig {
}
/// Rewrite an image reference to use a specific registry.
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "23.182.128.160:3000/lfg2025"
/// becomes "23.182.128.160:3000/lfg2025/bitcoin-knots:latest".
/// E.g., "git.tx1138.com/lfg2025/bitcoin-knots:latest" with registry "146.59.87.168:3000/lfg2025"
/// becomes "146.59.87.168:3000/lfg2025/bitcoin-knots:latest".
pub fn rewrite_image(&self, image: &str, registry: &Registry) -> String {
// Extract the image name (last component after the org/namespace)
// Handles: "registry/org/image:tag" -> "image:tag"
let image_name = extract_image_name(image);
format!("{}/{}", registry.url, image_name)
}
}
/// Extract the image name from a full image reference.
@@ -114,6 +107,19 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
let mut config: RegistryConfig =
serde_json::from_str(&content).unwrap_or_else(|_| RegistryConfig::default());
// One-time migration: the Hetzner VPS at 23.182.128.160 was
// decommissioned 2026-04-23. Existing nodes have it baked into
// their saved registry list (was the original Server 1). Strip it
// on load so every container pull doesn't pay a connection-refused
// timeout against a dead host. Exception to the usual "explicit
// removals stick" rule: the user never chose to add this — it
// was a default.
let before = config.registries.len();
config
.registries
.retain(|r| !r.url.contains("23.182.128.160"));
let mut changed = config.registries.len() != before;
// Migrate: any default registry URL that isn't already in the
// saved list gets appended at the end (so existing priority order
// is preserved for anything the operator already configured).
@@ -126,22 +132,65 @@ pub async fn load_registries(data_dir: &Path) -> Result<RegistryConfig> {
.map(|r| r.priority)
.max()
.unwrap_or(0);
let mut added = false;
for (i, def) in defaults.registries.iter().enumerate() {
if !known.contains(&def.url) {
let mut cloned = def.clone();
cloned.priority = max_priority.saturating_add(10 + i as u32);
config.registries.push(cloned);
added = true;
changed = true;
}
}
if added {
let before_order: Vec<(String, bool, u32)> = config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect();
force_ovh_registry_primary(&mut config);
changed = changed
|| before_order
!= config
.registries
.iter()
.map(|r| (r.url.clone(), r.enabled, r.priority))
.collect::<Vec<_>>();
if changed {
// Persist so the next load doesn't have to re-merge.
let _ = save_registries(data_dir, &config).await;
}
Ok(config)
}
fn force_ovh_registry_primary(config: &mut RegistryConfig) {
let defaults = RegistryConfig::default();
for def in defaults.registries {
if !config.registries.iter().any(|r| r.url == def.url) {
config.registries.push(def);
}
}
for registry in config.registries.iter_mut() {
match registry.url.as_str() {
OVH_REGISTRY_URL => {
registry.name = "Server 1 (OVH)".to_string();
registry.tls_verify = false;
registry.enabled = true;
registry.priority = 0;
}
TX1138_REGISTRY_URL => {
registry.name = "Server 2 (tx1138)".to_string();
registry.tls_verify = true;
registry.enabled = true;
registry.priority = 10;
}
_ => {
if registry.priority <= 10 {
registry.priority = registry.priority.saturating_add(20);
}
}
}
}
}
/// Save registry config to disk.
pub async fn save_registries(data_dir: &Path, config: &RegistryConfig) -> Result<()> {
let dir = data_dir.join("config");
@@ -178,12 +227,12 @@ mod tests {
#[test]
fn test_rewrite_image() {
let config = RegistryConfig::default();
// Default primary is now VPS (index 0). A tx1138-hardcoded image
// rewrites to VPS when asked for the primary mirror.
// Default primary is now the OVH VPS (index 0). A tx1138-hardcoded
// image rewrites to OVH when asked for the primary mirror.
let primary = &config.registries[0];
assert_eq!(
config.rewrite_image("git.tx1138.com/lfg2025/bitcoin-knots:latest", primary),
"23.182.128.160:3000/lfg2025/bitcoin-knots:latest"
"146.59.87.168:3000/lfg2025/bitcoin-knots:latest"
);
}
@@ -191,16 +240,15 @@ mod tests {
fn test_active_registries_sorted() {
let config = RegistryConfig::default();
let active = config.active_registries();
assert_eq!(active.len(), 3);
assert_eq!(active.len(), 2);
assert!(active[0].priority <= active[1].priority);
assert!(active[1].priority <= active[2].priority);
}
#[tokio::test]
async fn test_load_default() {
let tmp = TempDir::new().unwrap();
let config = load_registries(tmp.path()).await.unwrap();
assert_eq!(config.registries.len(), 3);
assert_eq!(config.registries.len(), 2);
}
#[tokio::test]
@@ -216,6 +264,6 @@ mod tests {
});
save_registries(tmp.path(), &config).await.unwrap();
let loaded = load_registries(tmp.path()).await.unwrap();
assert_eq!(loaded.registries.len(), 4);
assert_eq!(loaded.registries.len(), 3);
}
}
+56
View File
@@ -0,0 +1,56 @@
//! Orchestrator trait — the shared surface the RPC layer talks to.
//!
//! Step 4 of the rust-orchestrator migration. Unifies the container lifecycle
//! surface of `DevContainerOrchestrator` and `ProdContainerOrchestrator` so
//! `RpcHandler` can hold `Arc<dyn ContainerOrchestrator>` and stop caring
//! which mode it is in.
//!
//! The trait takes `app_id: &str` everywhere (never a manifest path). Dev and
//! Prod both resolve app_id → manifest internally. The legacy
//! `container-install { manifest_path }` RPC shape is preserved as a concrete
//! `install_container_from_path` method on `DevContainerOrchestrator` only,
//! since that ad-hoc workflow is a dev convenience and has no prod meaning.
//!
//! See `docs/rust-orchestrator-migration.md`.
use anyhow::Result;
use archipelago_container::ContainerStatus;
use async_trait::async_trait;
/// Lifecycle + query operations every orchestrator exposes to the RPC layer.
#[async_trait]
pub trait ContainerOrchestrator: Send + Sync {
/// Build-or-pull the image, create the container, and start it. Returns the
/// podman container name that was created. Assumes the app_id corresponds
/// to a manifest the orchestrator already knows about.
async fn install(&self, app_id: &str) -> Result<String>;
/// Start an already-created container.
async fn start(&self, app_id: &str) -> Result<()>;
/// Stop a running container. No-op on Prod if already stopped.
async fn stop(&self, app_id: &str) -> Result<()>;
/// Stop-then-start. Best-effort: ignores stop failure.
async fn restart(&self, app_id: &str) -> Result<()>;
/// Remove the container. `preserve_data = true` keeps the volumes; `false`
/// is honored on a best-effort basis (Dev cleans, Prod leaves the volume
/// management to the data layer).
async fn remove(&self, app_id: &str, preserve_data: bool) -> Result<()>;
/// Pull/rebuild the image and recreate the container from scratch.
async fn upgrade(&self, app_id: &str) -> Result<()>;
/// Current state of a single container.
async fn status(&self, app_id: &str) -> Result<ContainerStatus>;
/// All containers this orchestrator knows about.
async fn list(&self) -> Result<Vec<ContainerStatus>>;
/// Tail the container's stdout+stderr.
async fn logs(&self, app_id: &str, lines: u32) -> Result<Vec<String>>;
/// Coarse health summary: "healthy", "unhealthy", "starting", "paused", "unknown".
async fn health(&self, app_id: &str) -> Result<String>;
}
+29
View File
@@ -412,6 +412,19 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
};
}
let names: Vec<String> = names
.into_iter()
.filter(|n| should_auto_start_stopped_container(n))
.collect();
if names.is_empty() {
return RecoveryReport {
total: 0,
recovered: 0,
failed: Vec::new(),
};
}
// Sort by startup tier: databases first, then core, then dependent services, then apps
let mut records: Vec<RunningContainerRecord> = names
.iter()
@@ -430,6 +443,13 @@ pub async fn start_stopped_containers(data_dir: &Path) -> RecoveryReport {
recover_containers(&records).await
}
fn should_auto_start_stopped_container(name: &str) -> bool {
// Keep generic boot recovery narrow. The Rust manifest reconciler owns
// managed app stacks; starting every exited Podman container here races
// it and resurrects legacy/orphan helper containers.
matches!(name, "filebrowser" | "nostr-rs-relay")
}
/// Simple tier ordering for boot recovery (mirrors health_monitor tiers).
fn container_boot_tier(name: &str) -> u8 {
let id = name.strip_prefix("archy-").unwrap_or(name);
@@ -603,4 +623,13 @@ mod tests {
let result = check_for_crash(tmp.path()).await.unwrap();
assert!(result.is_none());
}
#[test]
fn generic_boot_recovery_skips_manifest_owned_and_legacy_stacks() {
assert!(should_auto_start_stopped_container("filebrowser"));
assert!(should_auto_start_stopped_container("nostr-rs-relay"));
assert!(!should_auto_start_stopped_container("bitcoin-knots"));
assert!(!should_auto_start_stopped_container("lnd"));
assert!(!should_auto_start_stopped_container("indeedhub-postgres"));
}
}
+20 -8
View File
@@ -129,9 +129,21 @@ pub fn is_revoked(vc: &VerifiableCredential) -> bool {
mod tests {
use super::*;
/// Create a tempdir with a dummy `identity/node_key` so that the
/// credential store's encrypt/decrypt path can derive a key.
/// Returns the tempdir guard (drop it to clean up).
fn test_dir_with_node_key() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let identity_dir = dir.path().join("identity");
std::fs::create_dir_all(&identity_dir).unwrap();
// 32 bytes of deterministic test material; never a real key.
std::fs::write(identity_dir.join("node_key"), [0xAB; 32]).unwrap();
dir
}
#[tokio::test]
async fn test_issue_credential_w3c_format() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
@@ -163,7 +175,7 @@ mod tests {
#[tokio::test]
async fn test_issue_credential_serializes_as_jsonld() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
@@ -185,7 +197,7 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
issue_credential(
dir.path(),
"did:key:a",
@@ -205,7 +217,7 @@ mod tests {
#[tokio::test]
async fn test_issue_credential_sign_fn_failure_propagates() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
let result = issue_credential(
dir.path(),
"did:key:issuer",
@@ -257,7 +269,7 @@ mod tests {
#[tokio::test]
async fn test_revoke_credential() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
let vc = issue_credential(
dir.path(),
"did:key:issuer",
@@ -288,7 +300,7 @@ mod tests {
#[tokio::test]
async fn test_revoke_nonexistent_credential_fails() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
let result = revoke_credential(dir.path(), "urn:uuid:does-not-exist").await;
assert!(result.is_err());
assert!(result
@@ -299,7 +311,7 @@ mod tests {
#[tokio::test]
async fn test_list_credentials_no_filter() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
issue_credential(
dir.path(),
"did:key:a",
@@ -329,7 +341,7 @@ mod tests {
#[tokio::test]
async fn test_list_credentials_filter_by_did() {
let dir = tempfile::tempdir().unwrap();
let dir = test_dir_with_node_key();
issue_credential(
dir.path(),
"did:key:alice",
+43 -1
View File
@@ -143,7 +143,11 @@ pub struct PackageDataEntry {
/// pipeline so the UI can show real progress instead of a generic
/// "Uninstalling…" spinner. Cleared after the package entry is
/// removed.
#[serde(rename = "uninstall-stage", skip_serializing_if = "Option::is_none", default)]
#[serde(
rename = "uninstall-stage",
skip_serializing_if = "Option::is_none",
default
)]
pub uninstall_stage: Option<String>,
/// Pinned image version from image-versions.sh when it differs from running version
#[serde(rename = "available-update", skip_serializing_if = "Option::is_none")]
@@ -245,6 +249,44 @@ pub enum ServiceStatus {
pub struct InstallProgress {
pub size: u64,
pub downloaded: u64,
/// High-level pipeline phase. Preferred by the UI over the byte
/// counters (podman pull doesn't emit parseable progress on a piped
/// stderr, so `size`/`downloaded` are often 0). Each phase maps to
/// a fixed UI percentage and a descriptive label.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<InstallPhase>,
/// Optional explicit message — used to surface install failures so
/// the UI can keep the app card visible with an error description
/// instead of silently removing the entry on fail. UI's PHASE_INFO
/// label takes precedence when phase is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
/// Phases of the install / update pipeline, surfaced to the UI so users
/// see where the pipeline is rather than a stuck 0% bar.
///
/// Ordered so each variant's index roughly corresponds to pipeline time.
/// Serialized as kebab-case: "preparing", "pulling-image", …
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum InstallPhase {
/// Validating params, checking deps, writing dynamic configs.
Preparing,
/// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks).
PullingImage,
/// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer,
/// `podman run` has returned; container is transitioning to running.
StartingContainer,
/// Post-start loop waiting up to 60s for `State.Status == running`.
WaitingHealthy,
/// Running post-install hooks (chain init, wallet setup, …).
PostInstall,
/// Pipeline finished successfully. Terminal phase, UI clears entry.
Done,
}
/// WebSocket message sent to clients
+187 -43
View File
@@ -30,9 +30,11 @@ async fn bitcoin_rpc_auth() -> String {
#[derive(Debug, Clone, Serialize)]
pub struct ElectrsSyncStatus {
pub indexed_height: u64,
pub bitcoin_height: u64,
pub network_height: u64,
pub progress_pct: f64,
pub status: String,
pub stale: bool,
pub error: Option<String>,
/// Index data size in human-readable format (e.g. "11.2 GB")
pub index_size: Option<String>,
@@ -44,9 +46,11 @@ impl Default for ElectrsSyncStatus {
fn default() -> Self {
Self {
indexed_height: 0,
bitcoin_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "starting".to_string(),
stale: false,
error: None,
index_size: None,
tor_onion: None,
@@ -64,15 +68,31 @@ fn cache() -> &'static RwLock<ElectrsSyncStatus> {
/// Spawn background task that refreshes ElectrumX status every CACHE_REFRESH_SECS.
pub fn spawn_status_cache() {
tokio::spawn(async {
// Initial delay — let services start up before first query
tokio::time::sleep(Duration::from_secs(5)).await;
let mut interval = tokio::time::interval(Duration::from_secs(CACHE_REFRESH_SECS));
loop {
interval.tick().await;
let fresh = fetch_electrs_sync_status().await;
let mut fresh = fetch_electrs_sync_status().await;
let mut cached = cache().write().await;
if fresh.indexed_height == 0
&& cached.indexed_height > 0
&& matches!(fresh.status.as_str(), "indexing" | "waiting")
{
fresh.indexed_height = cached.indexed_height;
if fresh.network_height == 0 {
fresh.network_height = cached.network_height;
}
if fresh.bitcoin_height == 0 {
fresh.bitcoin_height = cached.bitcoin_height;
}
if fresh.progress_pct <= 0.0 {
fresh.progress_pct = cached.progress_pct;
}
fresh.stale = true;
fresh.error = Some(fresh.error.unwrap_or_else(|| {
"ElectrumX is reconnecting; showing last known indexed height.".to_string()
}));
}
*cached = fresh;
drop(cached);
tokio::time::sleep(Duration::from_secs(CACHE_REFRESH_SECS)).await;
}
});
}
@@ -187,13 +207,69 @@ async fn electrumx_indexed_height() -> Result<u64> {
Ok(height)
}
/// Fetch Bitcoin network height via JSON-RPC.
async fn bitcoin_network_height() -> Result<u64> {
fn parse_electrumx_height_from_logs(logs: &str) -> Option<u64> {
let mut height = None;
for line in logs.lines() {
if let Some(idx) = line.find("BlockProcessor:our height:") {
let rest = &line[idx + "BlockProcessor:our height:".len()..];
if let Some(parsed) = parse_first_u64_token(rest) {
height = Some(parsed);
}
continue;
}
if let Some(idx) = line.find("DB:height:") {
let rest = &line[idx + "DB:height:".len()..];
if let Some(parsed) = parse_first_u64_token(rest) {
height = Some(parsed);
}
}
}
height
}
fn parse_first_u64_token(input: &str) -> Option<u64> {
let token: String = input
.trim_start()
.chars()
.take_while(|c| c.is_ascii_digit() || *c == ',')
.filter(|c| *c != ',')
.collect();
if token.is_empty() {
None
} else {
token.parse().ok()
}
}
async fn electrumx_log_indexed_height() -> Result<u64> {
let output = tokio::process::Command::new("podman")
.args(["logs", "--tail", "500", "electrumx"])
.output()
.await
.context("Failed to read ElectrumX logs")?;
if !output.status.success() {
anyhow::bail!(
"podman logs electrumx failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let logs = String::from_utf8_lossy(&output.stdout);
parse_electrumx_height_from_logs(&logs).context("No ElectrumX indexed height in logs")
}
/// Fetch Bitcoin local block height and best-known network header height via JSON-RPC.
async fn bitcoin_chain_heights() -> Result<(u64, u64)> {
let client = reqwest::Client::new();
let body = serde_json::json!({
"jsonrpc": "1.0",
"id": "electrs-status",
"method": "getblockcount",
"method": "getblockchaininfo",
"params": []
});
let resp = client
@@ -211,11 +287,18 @@ async fn bitcoin_network_height() -> Result<u64> {
}
let json: serde_json::Value = resp.json().await?;
let height = json
let result = json
.get("result")
.and_then(|r| r.as_u64())
.context("Missing result in Bitcoin RPC")?;
Ok(height)
let blocks = result
.get("blocks")
.and_then(|h| h.as_u64())
.context("Missing blocks in Bitcoin RPC")?;
let headers = result
.get("headers")
.and_then(|h| h.as_u64())
.unwrap_or(blocks);
Ok((blocks, headers.max(blocks)))
}
/// Fetch fresh ElectrumX sync status (called by background cache task).
@@ -260,15 +343,22 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
onion
};
let network_height = match bitcoin_network_height().await {
Ok(h) => h,
let (bitcoin_blocks, network_height) = match bitcoin_chain_heights().await {
Ok(heights) => heights,
Err(e) => {
warn!("ElectrumX status: Bitcoin RPC failed: {}", e);
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
tracing::debug!("ElectrumX status: Bitcoin RPC transient: {}", err_msg);
} else {
warn!("ElectrumX status: Bitcoin RPC failed: {}", err_msg);
}
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: 0,
network_height: 0,
progress_pct: 0.0,
status: "waiting".to_string(),
stale: false,
error: Some("Waiting for Bitcoin node...".to_string()),
index_size,
tor_onion,
@@ -278,21 +368,25 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
let indexed_height = match electrumx_indexed_height().await {
Ok(h) => h,
Err(e) => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
// ElectrumX is starting up or busy — estimate from data size
let progress_pct = if data_bytes > 0 {
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
} else {
0.0
};
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
return ElectrsSyncStatus {
Err(e) => match electrumx_log_indexed_height().await {
Ok(h) if h > 0 => h,
_ => {
let err_msg = e.to_string();
if is_transient_error(&err_msg) {
// ElectrumX is starting up or busy — estimate from data size
let progress_pct = if data_bytes > 0 {
((data_bytes as f64 / ESTIMATED_FULL_INDEX_BYTES) * 100.0).min(99.0)
} else {
0.0
};
let size_str = index_size.clone().unwrap_or_else(|| "0 MB".to_string());
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: bitcoin_blocks,
network_height,
progress_pct,
status: "indexing".to_string(),
stale: false,
error: Some(format!(
"Building index ({} / ~130 GB estimated). Electrum RPC will be available when complete.",
size_str
@@ -300,40 +394,90 @@ async fn fetch_electrs_sync_status() -> ElectrsSyncStatus {
index_size,
tor_onion,
};
}
// Genuine unexpected error
warn!("ElectrumX status: unexpected error: {}", err_msg);
return ElectrsSyncStatus {
indexed_height: 0,
bitcoin_height: bitcoin_blocks,
network_height,
progress_pct: 0.0,
status: "error".to_string(),
stale: false,
error: Some(format!("ElectrumX: {}", err_msg)),
index_size,
tor_onion,
};
}
// Genuine unexpected error
warn!("ElectrumX status: unexpected error: {}", err_msg);
return ElectrsSyncStatus {
indexed_height: 0,
network_height,
progress_pct: 0.0,
status: "error".to_string(),
error: Some(format!("ElectrumX: {}", err_msg)),
index_size,
tor_onion,
};
}
},
};
let progress_pct = if network_height > 0 {
(indexed_height as f64 / network_height as f64) * 100.0
let observed_header_height = network_height.max(indexed_height);
let bitcoin_catching_up = bitcoin_blocks > 0 && bitcoin_blocks < observed_header_height;
let electrum_waiting_on_bitcoin =
bitcoin_catching_up && indexed_height >= bitcoin_blocks.saturating_sub(1);
let sync_target_height = if bitcoin_blocks > 0 {
bitcoin_blocks
} else {
observed_header_height
};
let progress_pct = if electrum_waiting_on_bitcoin && observed_header_height > 0 {
((bitcoin_blocks as f64 / observed_header_height as f64) * 100.0).min(99.9)
} else if sync_target_height > 0 {
((indexed_height as f64 / sync_target_height as f64) * 100.0).min(100.0)
} else {
0.0
};
let status = if indexed_height >= network_height.saturating_sub(1) {
let status = if sync_target_height == 0 {
"waiting"
} else if electrum_waiting_on_bitcoin {
"waiting"
} else if indexed_height >= sync_target_height.saturating_sub(1) {
"synced"
} else {
"syncing"
};
let error = if electrum_waiting_on_bitcoin {
Some(format!(
"ElectrumX is indexed to {:}; waiting for the local Bitcoin node to catch up from {:} to known header {:}.",
indexed_height, bitcoin_blocks, observed_header_height
))
} else if status == "syncing" && bitcoin_blocks < observed_header_height {
Some(format!(
"Indexing local Bitcoin node height {:} of {:}. Bitcoin node is still catching up to known header {:}.",
indexed_height, bitcoin_blocks, observed_header_height
))
} else {
None
};
ElectrsSyncStatus {
indexed_height,
network_height,
bitcoin_height: bitcoin_blocks,
network_height: observed_header_height,
progress_pct,
status: status.to_string(),
error: None,
stale: false,
error,
index_size,
tor_onion,
}
}
#[cfg(test)]
mod tests {
use super::parse_electrumx_height_from_logs;
#[test]
fn parses_latest_electrumx_progress_height_from_logs() {
let logs = r#"
INFO:DB:height: 228,238
INFO:BlockProcessor:our height: 228,248 daemon: 731,568 UTXOs 1MB hist 1MB
INFO:BlockProcessor:our height: 232,117 daemon: 732,108 UTXOs 281MB hist 83MB
"#;
assert_eq!(parse_electrumx_height_from_logs(logs), Some(232_117));
}
}
+2 -5
View File
@@ -80,8 +80,7 @@ pub async fn record_peer_transport(
let mut modified = false;
for node in nodes.iter_mut() {
let did_match = did.is_some_and(|d| d == node.did);
let onion_match = onion_target
.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
let onion_match = onion_target.is_some_and(|t| node.onion.trim_end_matches(".onion") == t);
if did_match || onion_match {
node.last_transport = Some(transport.to_string());
node.last_transport_at = Some(now.clone());
@@ -182,9 +181,7 @@ pub async fn update_node_state(data_dir: &Path, did: &str, state: NodeStateSnaps
// routing over FIPS on the very next sync. Refresh if the peer
// rotated their FIPS key, too.
if let Some(ref npub) = state.own_fips_npub {
if !npub.is_empty()
&& node.fips_npub.as_deref().map(str::trim) != Some(npub.trim())
{
if !npub.is_empty() && node.fips_npub.as_deref().map(str::trim) != Some(npub.trim()) {
node.fips_npub = Some(npub.clone());
}
}
+10 -17
View File
@@ -8,9 +8,7 @@ use anyhow::{Context, Result};
use std::path::Path;
use super::storage::update_node_state;
use super::types::{
AppStatus, FederatedNode, FederationPeerHint, NodeStateSnapshot, TrustLevel,
};
use super::types::{AppStatus, FederatedNode, FederationPeerHint, NodeStateSnapshot, TrustLevel};
use crate::fips::dial::PeerRequest;
/// Sync state with a single federated peer. Tries FIPS first; falls back
@@ -68,9 +66,7 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await
{
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
tracing::warn!(
peer_did = %peer.did,
error = %e,
@@ -87,10 +83,7 @@ pub async fn sync_with_peer(
/// call sync_with_peer. Used by transitive-discovery code paths where
/// the caller only knows the peer's DID (e.g. the peer-joined RPC's
/// follow-up task).
pub async fn sync_with_peer_by_did(
data_dir: &Path,
peer_did: &str,
) -> Result<NodeStateSnapshot> {
pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<NodeStateSnapshot> {
let nodes = super::storage::load_nodes(data_dir).await?;
let peer = nodes
.into_iter()
@@ -98,8 +91,7 @@ pub async fn sync_with_peer_by_did(
.ok_or_else(|| anyhow::anyhow!("Unknown federation peer: {}", peer_did))?;
let identity_dir = data_dir.join("identity");
let node_identity =
crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let node_identity = crate::identity::NodeIdentity::load_or_create(&identity_dir).await?;
let local_pubkey_hex = node_identity.pubkey_hex();
let local_did = crate::identity::did_key_from_pubkey_hex(&local_pubkey_hex)?;
@@ -258,7 +250,11 @@ pub async fn deploy_to_peer(
.context("Failed to reach federated peer for deploy")?;
if !resp.status().is_success() {
anyhow::bail!("Remote node returned HTTP {} (via {})", resp.status(), transport);
anyhow::bail!(
"Remote node returned HTTP {} (via {})",
resp.status(),
transport
);
}
let result: serde_json::Value = resp.json().await.context("Invalid response from peer")?;
@@ -355,10 +351,7 @@ mod tests {
last_transport_at: None,
},
];
let state = build_local_state(
vec![],
0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers,
);
let state = build_local_state(vec![], 0.0, 0, 0, 0, 0, 0, true, None, None, None, &peers);
assert_eq!(state.federated_peers.len(), 1);
assert_eq!(state.federated_peers[0].did, "did:key:zTrusted");
assert_eq!(
+3 -8
View File
@@ -70,8 +70,8 @@ pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("read {}", path.display()))?;
let anchors: Vec<SeedAnchor> = serde_json::from_slice(&bytes)
.with_context(|| format!("parse {}", path.display()))?;
let anchors: Vec<SeedAnchor> =
serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))?;
Ok(anchors)
}
@@ -125,12 +125,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args([
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
.output()
.await;
let result = match out {
+3 -1
View File
@@ -13,7 +13,9 @@ use anyhow::{Context, Result};
use std::path::Path;
use tokio::process::Command;
use super::{DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT};
use super::{
DAEMON_CONFIG_PATH, DAEMON_KEY_PATH, DAEMON_PUB_PATH, DEFAULT_TCP_PORT, DEFAULT_UDP_PORT,
};
/// Write the FIPS daemon config based on the local npub and default
/// transports. Overwrites any existing file — callers are expected to
+8 -21
View File
@@ -109,7 +109,7 @@ fn encode_query(id: u16, npub: &str) -> Result<Vec<u8>> {
encode_label(&mut out, npub)?;
encode_label(&mut out, FIPS_DNS_SUFFIX)?;
out.push(0); // root
// QTYPE + QCLASS
// QTYPE + QCLASS
out.extend_from_slice(&QTYPE_AAAA.to_be_bytes());
out.extend_from_slice(&QCLASS_IN.to_be_bytes());
Ok(out)
@@ -247,11 +247,7 @@ pub struct PeerRequest<'a> {
}
impl<'a> PeerRequest<'a> {
pub fn new(
fips_npub: Option<&'a str>,
onion_host: &'a str,
path: &'a str,
) -> Self {
pub fn new(fips_npub: Option<&'a str>, onion_host: &'a str, path: &'a str) -> Self {
Self {
fips_npub,
onion_host,
@@ -312,9 +308,7 @@ impl<'a> PeerRequest<'a> {
}
/// GET with optional header-based auth.
pub async fn send_get(
&self,
) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
pub async fn send_get(&self) -> Result<(reqwest::Response, crate::transport::TransportKind)> {
use crate::settings::transport::TransportPref;
let pref = self.preference().await;
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
@@ -392,19 +386,14 @@ impl<'a> PeerRequest<'a> {
}
}
async fn send_tor_post_json<B: serde::Serialize>(
&self,
body: &B,
) -> Result<reqwest::Response> {
async fn send_tor_post_json<B: serde::Serialize>(&self, body: &B) -> Result<reqwest::Response> {
let url = self.tor_url();
let client = self.tor_client()?;
let mut rb = client.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
rb.send()
.await
.with_context(|| format!("Tor POST {}", url))
rb.send().await.with_context(|| format!("Tor POST {}", url))
}
async fn send_tor_get(&self) -> Result<reqwest::Response> {
@@ -414,9 +403,7 @@ impl<'a> PeerRequest<'a> {
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
rb.send()
.await
.with_context(|| format!("Tor GET {}", url))
rb.send().await.with_context(|| format!("Tor GET {}", url))
}
fn tor_url(&self) -> String {
@@ -449,7 +436,7 @@ mod tests {
assert_eq!(&q[0..2], &[0x12, 0x34]);
assert_eq!(&q[2..4], &[0x01, 0x00]); // flags RD=1
assert_eq!(&q[4..6], &[0x00, 0x01]); // QDCOUNT=1
// Tail: QTYPE=28, QCLASS=1
// Tail: QTYPE=28, QCLASS=1
assert_eq!(&q[q.len() - 4..], &[0x00, 0x1C, 0x00, 0x01]);
}
@@ -471,7 +458,7 @@ mod tests {
r.extend_from_slice(&1u16.to_be_bytes()); // ANCOUNT
r.extend_from_slice(&0u16.to_be_bytes()); // NSCOUNT
r.extend_from_slice(&0u16.to_be_bytes()); // ARCOUNT
// Question: 1 label "a" + "fips"
// Question: 1 label "a" + "fips"
r.extend_from_slice(b"\x01a\x04fips\x00");
r.extend_from_slice(&QTYPE_AAAA.to_be_bytes());
r.extend_from_slice(&QCLASS_IN.to_be_bytes());
+1 -3
View File
@@ -24,9 +24,7 @@ pub const FIPS_IFACE: &str = "fips0";
/// - Link-local (`fe80::/10`) and non-ULA addresses are ignored — we
/// only want the mesh-routable ULA that `<npub>.fips` DNS resolves to.
pub fn fips0_ula() -> Option<Ipv6Addr> {
addresses_on(FIPS_IFACE)
.into_iter()
.find(|a| is_ula(a))
addresses_on(FIPS_IFACE).into_iter().find(|a| is_ula(a))
}
/// List every IPv6 address bound to a given interface from
+5 -3
View File
@@ -122,8 +122,7 @@ impl FipsStatus {
};
let service_state = service::unit_state(SERVICE_UNIT).await;
let upstream_service_state = service::unit_state(UPSTREAM_SERVICE_UNIT).await;
let service_active =
service_state == "active" || upstream_service_state == "active";
let service_active = service_state == "active" || upstream_service_state == "active";
let key_present = crate::identity::fips_key_exists(&identity_dir);
// Prefer the seed-derived npub; otherwise read the daemon's own
@@ -180,7 +179,10 @@ mod tests {
// anchor is the only candidate.
let status = FipsStatus::query(dir.path()).await;
assert!(!status.key_present, "no key before onboarding");
assert!(status.npub.is_none());
// `npub` falls back to whatever an already-running local fips
// daemon advertises, so on a dev machine or node with fips
// installed this field can be Some(...) even when the test
// data_dir is empty. We only assert that key_present is false.
// `installed`, `service_state`, `version` depend on the host and are
// not asserted here — query() must return cleanly regardless.
}
+4 -5
View File
@@ -150,11 +150,10 @@ pub async fn peer_connectivity_summary(anchor_candidates: &[String]) -> (u32, bo
Ok(o) if o.status.success() => o.stdout,
_ => return (0, false),
};
let parsed: serde_json::Value =
match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return (0, false),
};
let parsed: serde_json::Value = match serde_json::from_slice(&peers_json) {
Ok(v) => v,
Err(_) => return (0, false),
};
let peers = parsed
.get("peers")
.and_then(|p| p.as_array())
+121 -8
View File
@@ -4,7 +4,7 @@
// handles "created" state containers, resets dependent counters when deps recover,
// and sends WebSocket notifications to the UI on failure.
use crate::data_model::{Notification, NotificationLevel};
use crate::data_model::{Notification, NotificationLevel, PackageState};
use crate::state::StateManager;
use crate::webhooks::{self, WebhookEvent};
use serde::{Deserialize, Serialize};
@@ -67,14 +67,14 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
let id = name.strip_prefix("archy-").unwrap_or(name);
match id {
// Bitcoin-dependent chain
"lnd" => &["bitcoin-knots"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin-knots"],
"nbxplorer" => &["bitcoin-knots"],
"lnd" => &["bitcoin"],
"electrumx" | "mempool-electrs" | "electrs" => &["bitcoin"],
"nbxplorer" => &["bitcoin"],
"btcpay-server" => &["btcpay-db", "nbxplorer"],
"mempool-api" => &["mempool-db", "electrumx"],
"mempool-web" => &["mempool-api"],
"fedimint" => &["bitcoin-knots"],
"fedimint-gateway" => &["bitcoin-knots", "fedimint"],
"fedimint" => &["bitcoin"],
"fedimint-gateway" => &["bitcoin", "fedimint"],
// IndeedHub stack
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
@@ -88,7 +88,7 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
"penpot-frontend" => &["penpot-backend"],
// UI containers
"bitcoin-ui" => &["bitcoin-knots"],
"bitcoin-ui" => &["bitcoin"],
"lnd-ui" => &["lnd"],
"electrs-ui" => &["electrumx"],
@@ -103,6 +103,16 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
return true;
}
for dep in deps {
if *dep == "bitcoin" {
let bitcoin_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
matches!(c_id, "bitcoin" | "bitcoin-knots" | "bitcoin-core") && c.state == "running"
});
if !bitcoin_running {
return false;
}
continue;
}
// Check both plain name and archy- prefixed name
let dep_running = containers.iter().any(|c| {
let c_id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
@@ -115,6 +125,24 @@ fn deps_are_running(name: &str, containers: &[ContainerHealth]) -> bool {
true
}
fn conflicting_bitcoin_variant(name: &str) -> Option<&'static str> {
match name.strip_prefix("archy-").unwrap_or(name) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
}
}
fn has_running_bitcoin_conflict(name: &str, containers: &[ContainerHealth]) -> bool {
let Some(conflict) = conflicting_bitcoin_variant(name) else {
return false;
};
containers.iter().any(|c| {
let id = c.name.strip_prefix("archy-").unwrap_or(&c.name);
id == conflict && c.state == "running"
})
}
/// Track restart attempts per container with exponential backoff and stability reset.
struct RestartTracker {
attempts: HashMap<String, u32>,
@@ -539,6 +567,30 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
debug!("Skipping uninstalled container: {}", container.name);
continue;
}
if matches!(
pkg.state,
PackageState::Starting | PackageState::Stopping | PackageState::Restarting
) {
debug!(
"Skipping container during package lifecycle transition: {} ({:?})",
container.name, pkg.state
);
continue;
}
} else {
// Orphan: container exists in podman but archipelago has
// no package_data entry for it. Common after a variant
// switch (bitcoin-core ↔ bitcoin-knots) where the
// uninstall removed the package entry but the prior
// variant's container survived in stopped state. Without
// this guard the health monitor pages every minute with
// "Auto-restart failed (attempt N/10)" for an app the
// user can no longer see in the dashboard.
debug!(
"Skipping orphan container (not in package_data): {}",
container.name
);
continue;
}
if container.healthy {
@@ -636,6 +688,14 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
continue;
}
if has_running_bitcoin_conflict(&container.name, &containers) {
debug!(
"Skipping auto-restart for {} because the other Bitcoin implementation is running",
container.name
);
continue;
}
// When transitioning to a higher tier, wait briefly for previous tier to stabilize
if let Some(prev) = prev_tier {
if tier > prev {
@@ -902,7 +962,7 @@ mod tests {
#[test]
fn test_container_dependencies() {
assert!(container_dependencies("lnd").contains(&"bitcoin-knots"));
assert!(container_dependencies("lnd").contains(&"bitcoin"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-postgres"));
assert!(container_dependencies("indeedhub-api").contains(&"indeedhub-redis"));
assert!(container_dependencies("mempool-api").contains(&"mempool-db"));
@@ -943,6 +1003,59 @@ mod tests {
assert!(!deps_are_running("indeedhub-api", &partial));
}
#[test]
fn test_bitcoin_dependency_accepts_core_or_knots() {
let core = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
healthy: true,
}];
assert!(deps_are_running("lnd", &core));
let knots = vec![ContainerHealth {
name: "bitcoin-knots".into(),
app_id: "bitcoin-knots".into(),
state: "running".into(),
healthy: true,
}];
assert!(deps_are_running("fedimint", &knots));
let stopped = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
healthy: false,
}];
assert!(!deps_are_running("electrumx", &stopped));
}
#[test]
fn test_bitcoin_conflict_detection() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "running".into(),
healthy: true,
}];
assert!(has_running_bitcoin_conflict("bitcoin-knots", &containers));
assert!(!has_running_bitcoin_conflict("bitcoin-core", &containers));
assert!(!has_running_bitcoin_conflict("lnd", &containers));
}
#[test]
fn test_bitcoin_conflict_ignores_stopped_sibling() {
let containers = vec![ContainerHealth {
name: "bitcoin-core".into(),
app_id: "bitcoin-core".into(),
state: "stopped".into(),
healthy: false,
}];
assert!(!has_running_bitcoin_conflict("bitcoin-knots", &containers));
}
#[test]
fn test_container_tier_core() {
assert_eq!(container_tier("bitcoin-knots"), StartupTier::CoreInfra);
+12 -20
View File
@@ -111,11 +111,7 @@ pub struct ProfilePublishOutcome {
/// (trailing slash, case). nostr-sdk canonicalises URLs internally and
/// we compare on the surface strings, so be liberal about what matches.
fn relay_url_matches(a: &str, b: &str) -> bool {
let norm = |s: &str| {
s.trim_end_matches('/')
.trim()
.to_ascii_lowercase()
};
let norm = |s: &str| s.trim_end_matches('/').trim().to_ascii_lowercase();
norm(a) == norm(b)
}
@@ -262,8 +258,8 @@ impl IdentityManager {
derivation_index: Some(0),
};
let json = serde_json::to_string_pretty(&identity_file)
.context("Failed to serialize identity")?;
let json =
serde_json::to_string_pretty(&identity_file).context("Failed to serialize identity")?;
fs::write(&file_path, json.as_bytes())
.await
.context("Failed to write identity file")?;
@@ -701,11 +697,8 @@ impl IdentityManager {
let event_id = output.id().to_hex();
// `Output` has `success: HashSet<RelayUrl>` + `failed: HashMap<RelayUrl, String>`.
// Normalise to string comparisons (RelayUrl trims trailing slashes etc.).
let success_strs: std::collections::HashSet<String> = output
.success
.iter()
.map(|u| u.to_string())
.collect();
let success_strs: std::collections::HashSet<String> =
output.success.iter().map(|u| u.to_string()).collect();
let failed_strs: std::collections::HashMap<String, String> = output
.failed
.iter()
@@ -714,14 +707,11 @@ impl IdentityManager {
let mut accepted: Vec<String> = Vec::new();
let mut rejected: Vec<(String, String)> = Vec::new();
for url in relay_urls {
let match_url = success_strs
.iter()
.any(|s| relay_url_matches(s, url));
let match_url = success_strs.iter().any(|s| relay_url_matches(s, url));
if match_url {
accepted.push(url.clone());
} else if let Some((_, reason)) = failed_strs
.iter()
.find(|(s, _)| relay_url_matches(s, url))
} else if let Some((_, reason)) =
failed_strs.iter().find(|(s, _)| relay_url_matches(s, url))
{
rejected.push((url.clone(), reason.clone()));
} else {
@@ -885,11 +875,13 @@ mod tests {
async fn test_create_nostr_key_npub_format() {
let dir = tempdir().unwrap();
let mgr = IdentityManager::new(dir.path()).await.unwrap();
// `create()` auto-provisions a Nostr key for every identity, so the
// returned record should already have a valid bech32 npub.
let record = mgr
.create("Nostr".to_string(), IdentityPurpose::Personal)
.create("Personal".to_string(), IdentityPurpose::Personal)
.await
.unwrap();
let npub = mgr.create_nostr_key(&record.id).await.unwrap();
let npub = record.nostr_npub.expect("nostr npub should be populated");
assert!(
npub.starts_with("npub1"),
"npub should start with npub1, got {}",
+138 -40
View File
@@ -19,7 +19,9 @@
use anyhow::{Context, Result};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::signal;
use tokio::sync::Notify;
use tracing::info;
mod api;
@@ -27,6 +29,7 @@ mod auth;
mod avatar;
mod backup;
mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod config;
@@ -69,6 +72,10 @@ mod wallet;
mod webhooks;
use config::Config;
use container::{
BootReconciler, ContainerOrchestrator, DevContainerOrchestrator, ProdContainerOrchestrator,
RECONCILER_DEFAULT_INTERVAL,
};
use server::Server;
#[tokio::main]
@@ -86,6 +93,33 @@ async fn main() -> Result<()> {
info!("Starting Archipelago Bitcoin Node OS");
// Self-heal web-ui permissions. The OTA updater in <=v1.7.38 left
// /opt/archipelago/web-ui as drwx------ (700) after the atomic
// swap — nginx (www-data) then returned 500/403 on every request
// until someone shelled in and chmod'd it. Check on every boot
// and repair if needed so a node auto-recovers after the next
// service restart that follows a broken OTA.
tokio::spawn(async move {
use std::os::unix::fs::PermissionsExt;
let web_ui = std::path::Path::new("/opt/archipelago/web-ui");
if let Ok(meta) = tokio::fs::metadata(web_ui).await {
let mode = meta.permissions().mode() & 0o777;
if mode & 0o005 != 0o005 {
tracing::warn!("web-ui perms {:o} not world-readable — self-healing", mode);
let _ = tokio::process::Command::new("sudo")
.args([
"-n",
"chmod",
"-R",
"u=rwX,go=rX",
"/opt/archipelago/web-ui",
])
.status()
.await;
}
}
});
// Load configuration
let config = Config::load().await?;
info!("📁 Data directory: {}", config.data_dir.display());
@@ -103,49 +137,94 @@ async fn main() -> Result<()> {
// Write PID marker early so we can detect crashes on next startup
crash_recovery::write_pid_marker(&config.data_dir).await?;
// Crash recovery runs in background so health endpoint is available immediately
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
// Check if previous instance shut down cleanly
match crash_recovery::check_for_crash(&data_dir).await {
Ok(Some(containers)) => {
info!(
"🔧 Recovering {} containers from previous crash...",
containers.len()
);
let report = crash_recovery::recover_containers(&containers).await;
info!(
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
report.recovered, report.total, report.failed
);
}
Ok(None) => {}
Err(e) => {
tracing::warn!("Crash recovery check failed: {}", e);
}
}
// Run crash recovery before starting the manifest reconciler. Both paths
// mutate Podman; running them concurrently can corrupt transient runtime
// state and leave netavark/conmon unable to start containers.
match crash_recovery::check_for_crash(&config.data_dir).await {
Ok(Some(containers)) => {
info!(
"🔧 Recovering {} containers from previous crash...",
containers.len()
);
let report = crash_recovery::recover_containers(&containers).await;
info!(
"🔧 Recovery complete: {}/{} containers restarted (failed: {:?})",
report.recovered, report.total, report.failed
);
}
Ok(None) => {}
Err(e) => {
tracing::warn!("Crash recovery check failed: {}", e);
}
}
// Start any stopped containers (handles clean reboot)
// Skips user-stopped containers, uses tier ordering
let boot_report = crash_recovery::start_stopped_containers(&data_dir).await;
if boot_report.total > 0 {
// Start any stopped containers (handles clean reboot). This remains
// synchronous for the same reason: no concurrent reconciler during Podman
// startup/recovery operations.
let boot_report = crash_recovery::start_stopped_containers(&config.data_dir).await;
if boot_report.total > 0 {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
);
}
crash_recovery::mark_recovery_complete();
// Construct the container orchestrator once. In prod mode we load the
// on-disk app manifests, do an initial adoption pass, and spawn the
// BootReconciler loop (Step 5/6 of the rust-orchestrator migration).
// Dev mode uses the in-memory DevContainerOrchestrator and has no
// reconciler (manifests are pushed via RPC, not discovered from disk).
let shutdown_notify = Arc::new(Notify::new());
let (orchestrator, dev_orchestrator): (
Option<Arc<dyn ContainerOrchestrator>>,
Option<Arc<DevContainerOrchestrator>>,
) = if config.dev_mode {
let dev = Arc::new(DevContainerOrchestrator::new(config.clone()).await?);
let trait_obj: Arc<dyn ContainerOrchestrator> = dev.clone();
(Some(trait_obj), Some(dev))
} else {
let prod = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
// Best-effort manifest load; a missing /opt/archipelago/apps is
// logged inside load_manifests and not fatal.
match prod.load_manifests().await {
Ok(n) => info!("📦 Loaded {n} app manifest(s) from disk"),
Err(e) => {
tracing::error!(error = %e, "prod orchestrator: load_manifests failed at startup");
}
}
// Adoption pass: link existing podman containers back to their
// manifests so the reconciler doesn't recreate them.
match prod.adopt_existing().await {
Ok(report) => {
info!(
"🔄 Boot startup: {}/{} containers started (failed: {:?})",
boot_report.recovered, boot_report.total, boot_report.failed
"🔗 Adopted {} existing container(s): {:?}",
report.adopted.len(),
report.adopted
);
}
// Signal to health monitor that boot recovery is done
crash_recovery::mark_recovery_complete();
// Boot reconciliation disabled — the reconciler creates ALL containers
// from specs, which is wrong on unbundled installs where only user-chosen
// apps should exist. The health monitor handles restarting existing
// containers. Run reconcile-containers.sh manually when needed.
// crash_recovery::run_boot_reconciliation().await;
});
}
Err(e) => {
tracing::warn!(error = %e, "prod orchestrator: adopt_existing failed (non-fatal)");
}
}
// Spawn the boot reconciler loop. Runs an initial reconcile
// immediately, then re-checks every RECONCILER_DEFAULT_INTERVAL
// until shutdown_notify fires.
{
let reconciler = BootReconciler::new(
prod.clone(),
RECONCILER_DEFAULT_INTERVAL,
shutdown_notify.clone(),
);
tokio::spawn(reconciler.run_forever());
info!(
"🔄 Boot reconciler started (interval: {:?})",
RECONCILER_DEFAULT_INTERVAL
);
}
let trait_obj: Arc<dyn ContainerOrchestrator> = prod;
(Some(trait_obj), None)
};
// Ensure a default user exists so login works after install/onboarding.
// In production, the default password is "password123" (shown during install).
@@ -155,7 +234,7 @@ async fn main() -> Result<()> {
// "Create Password" form instead of login form.
// Create server
let server = Server::new(config.clone()).await?;
let server = Server::new(config.clone(), orchestrator, dev_orchestrator).await?;
// Start server
let addr: SocketAddr = format!("{}:{}", config.bind_host, config.bind_port)
@@ -166,6 +245,19 @@ async fn main() -> Result<()> {
// on a 30s poll of fips0 — so a post-onboarding fips.install brings it
// online without needing an archipelago restart.
// Post-OTA verification: if apply_update() wrote a pending-verify
// marker right before the restart, probe the frontend now and auto-
// rollback if it's broken. This is the guardrail that stops fleet-
// wide breakage when an OTA lands a subtly-bad release (v1.7.38/39
// tarball-perms → nginx 500 was the trigger). Runs concurrently
// with normal startup — doesn't delay the server coming up.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
update::verify_pending_update(&data_dir).await;
});
}
// Spawn background update scheduler
let update_data_dir = config.data_dir.clone();
tokio::spawn(async move {
@@ -193,6 +285,7 @@ async fn main() -> Result<()> {
// Spawn ElectrumX status cache (refreshes every 15s, serves cached data to avoid race conditions)
electrs_status::spawn_status_cache();
bitcoin_status::spawn_status_cache();
let startup_ms = startup_start.elapsed().as_millis();
info!(
@@ -218,6 +311,7 @@ async fn main() -> Result<()> {
// Graceful shutdown: wait for SIGTERM or SIGINT
let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
.context("Failed to register SIGTERM handler")?;
let shutdown_notify_for_signal = shutdown_notify.clone();
let shutdown = async move {
tokio::select! {
_ = signal::ctrl_c() => {
@@ -227,6 +321,10 @@ async fn main() -> Result<()> {
info!("Received SIGTERM, initiating graceful shutdown...");
}
}
// Signal the boot reconciler (and any other subscribers) to stop.
// `notify_one` stores a permit if no task is currently parked on
// `notified()`, so we don't race the reconciler's reconcile_all pass.
shutdown_notify_for_signal.notify_one();
};
server.serve_with_shutdown(addr, shutdown).await?;
+6 -3
View File
@@ -457,8 +457,9 @@ mod tests {
let key = SigningKey::generate(&mut OsRng);
let wire = build_block_header_announcement(
890412,
"0000000000000000000abc",
"0000000000000000000aab",
// Block hashes must be 32 bytes (64 hex chars). Use realistic-shaped placeholders.
"0000000000000000000abc00000000000000000000000000000000000000abcd",
"0000000000000000000aab0000000000000000000000000000000000000aabcd",
1710633600,
"did:key:z6MkTest",
&key,
@@ -469,7 +470,9 @@ mod tests {
assert_eq!(wire[0], 0x02);
let envelope = TypedEnvelope::from_wire(&wire).unwrap();
assert_eq!(envelope.t, MeshMessageType::BlockHeader as u8);
assert!(envelope.sig.is_some());
// Block header announcements are intentionally unsigned to save 64 bytes
// on the 160-byte LoRa payload (see builder comment).
assert!(envelope.sig.is_none());
}
#[test]
+4 -1
View File
@@ -52,7 +52,10 @@ impl PendingMessage {
return true; // Can't parse = treat as expired
};
let age = chrono::Utc::now().signed_duration_since(created);
age.num_seconds() as u64 > self.ttl_secs
// Use `>=` so a ttl_secs=0 message is expired immediately (used by
// tests and by callers that want a fire-and-forget behavior when
// the relay can't deliver on first try).
age.num_seconds() as u64 >= self.ttl_secs
}
/// Check if this message can be relayed further.

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