Compare commits

...
124 Commits
Author SHA1 Message Date
archipelago 6d5f3ffb85 fix: select NPM admin port regardless of binding order
Demo images / Build & push demo images (push) Failing after 34s
2026-09-30 18:24:35 -04:00
archipelago d1bc1273d4 fix: replace cached container doctor before ISO first boot 2026-09-30 17:52:30 -04:00
archipelago 96fb5a4f19 fix: prevent stale snapshots resurrecting orphaned dashboards
Demo images / Build & push demo images (push) Failing after 36s
2026-09-30 17:45:18 -04:00
archipelago f91c1f33db fix: keep apps running when network diagnostics fail 2026-09-30 17:34:11 -04:00
archipelago 02b840f2d1 chore: prepare 1.8.22-alpha release candidate
Demo images / Build & push demo images (push) Failing after 36s
2026-09-30 16:45:43 -04:00
archipelago f992780957 fix: probe Angor IPv4 health endpoint inside the actual image 2026-09-30 16:40:16 -04:00
archipelago c82c1eee98 fix: prevent NPM tunnel collisions and false app health restarts 2026-09-30 16:30:40 -04:00
archipelago 2992443d5d docs: record verified NPM tunnel port conflict and node repair 2026-09-30 16:12:04 -04:00
archipelago 259c353147 Clear completed candidate deployment instructions 2026-09-30 13:46:50 -04:00
archipelago 1724ea05d1 Show readiness reason first and record live dashboard acceptance
Demo images / Build & push demo images (push) Failing after 45s
2026-09-30 13:46:16 -04:00
archipelago c1e20a71ae Check companion dashboards and omit headless UI waiting messages
Demo images / Build & push demo images (push) Failing after 37s
2026-09-30 13:28:37 -04:00
archipelago bf56956790 Record UI acceptance and remaining normal-startup release gate 2026-09-30 12:57:35 -04:00
archipelago 2f1a3ade07 Promote runtime manifests before starting app reconciliation 2026-09-30 12:50:46 -04:00
archipelago ef8254272c Name waiting apps, align card actions, and update Angor icon
Demo images / Build & push demo images (push) Failing after 39s
2026-09-30 12:43:53 -04:00
archipelago d50be13232 Normalize Mempool frontend aliases in restored app inventory 2026-09-30 12:41:15 -04:00
archipelago 439b55a236 Use manifest names for new services and document release acceptance
Demo images / Build & push demo images (push) Failing after 38s
2026-09-30 12:20:29 -04:00
archipelago 5ab65f7581 Preserve apostrophes in Quadlet commands and record funded acceptance 2026-09-30 12:08:35 -04:00
archipelago 169bf77de6 Add headless Angor services and shared-index install guard
Demo images / Build & push demo images (push) Failing after 43s
2026-09-30 11:52:19 -04:00
archipelago 7c4169867c docs: consolidate release scope and record migration recovery checks 2026-09-30 10:52:41 -04:00
archipelago acf544500f fix(apps): preserve state across runtime repairs and restore Gitea SSH 2026-09-30 10:46:38 -04:00
archipelago 7d767c8cb0 fix(catalog): gate network migration manifests on backup support 2026-09-30 10:08:56 -04:00
archipelago eb3ccfa00b Merge branch 'fix/gitea-portainer-20260930' 2026-09-30 09:57:49 -04:00
archipelago eda28c4cd6 fix(portainer): repair same-node Git routing with recoverable network migration 2026-09-30 09:57:25 -04:00
archipelago d69e845216 Merge remote-tracking branch 'origin/main'
Demo images / Build & push demo images (push) Failing after 1m10s
2026-09-30 09:32:20 -04:00
archipelago dc962c53b0 docs: record live lifecycle acceptance and next release blockers 2026-09-30 09:31:18 -04:00
archipelago 6ac26f637c fix(apps): preserve lifecycle state and wait for usable launch endpoints 2026-09-30 09:10:30 -04:00
archipelago 27d81e956d fix(installer): ship all app build contexts and refresh GitWorkshop dependencies 2026-09-30 09:09:21 -04:00
archipelago eb39391223 fix(ui): keep Bitcoin version choices readable in kiosk 2026-09-30 09:09:21 -04:00
chaum b02ba4100d Merge pull request 'fix(files): save purchased files atomically with rootless ownership' (#162) from fix/filebrowser-purchase-filing into main 2026-09-30 12:58:36 +00:00
chaum 3daea6623b Merge pull request 'fix(ecash): prevent paid-download replay and read failures after charging' (#161) from fix/ecash-paid-download-v2-keyset into main 2026-09-30 12:58:33 +00:00
archipelago d42f448e31 docs: close verified 1.8.21 OTA and ISO publication 2026-09-30 07:46:05 -04:00
archipelago 1566f1bb00 docs: record tested paid-download PRs for next release 2026-09-30 07:34:18 -04:00
archipelago 0677924a64 Merge current main and make purchase filing atomic under concurrent writes 2026-09-30 07:26:51 -04:00
archipelago 971d477795 Merge current main and harden paid-download delivery 2026-09-30 07:25:47 -04:00
archipelago f12042f194 docs: track X250 kiosk Bitcoin version selector regression 2026-09-30 07:01:57 -04:00
archipelago e7cf336665 chore: publish release v1.8.21-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-09-30 05:55:13 -04:00
archipelago 8ca20de82e release: prepare signed 1.8.21-alpha OTA 2026-09-30 05:51:16 -04:00
archipelago 1fa654cb6a docs: record 1.8.21 artifact and two-node release acceptance 2026-09-30 05:39:26 -04:00
archipelago c993d9dd0d fix(lnd): require observed Bitcoin lifecycle change before dependency restart 2026-09-30 05:16:17 -04:00
archipelago 33d2b3ce60 fix(containers): preserve graceful shutdown through Quadlet and prepare 1.8.21 2026-09-30 04:59:30 -04:00
archipelago c7ce35bd43 chore: publish release v1.8.20-alpha
Demo images / Build & push demo images (push) Failing after 1m31s
2026-09-30 04:32:43 -04:00
archipelago ad1d71a462 Prepare signed v1.8.20-alpha release and record operator acceptance 2026-09-30 04:27:02 -04:00
ssmithxandClaude Opus 5.5 33477f284b fix(files): file purchased content into FileBrowser folders again
Every paid download logged "filing into filebrowser/Music/... failed
(non-fatal): Permission denied". The purchase played in-app but never
appeared in Files. FileBrowser's folders belong to its rootless container
range (host uid 100000, mode 755). This service is host uid 1000, outside
that range, so it can read them but not create files in them.

New container::filebrowser::save_new_file:
- Writes directly when the folder allows it.
- Otherwise writes through `podman unshare`, where that uid range is
  ours: to a temp file, then chowned to the folder's owner, set to 0644,
  and hard-linked into place. FileBrowser never sees a partial file and an
  existing file is never replaced. A missing folder is created and given
  its parent's owner. No sudo.
- Keeps the "name (2).ext" de-duplication the RPC did inline.

Checked the unshare script on amishparadise in a scratch folder owned
like FileBrowser's: new folder + file OK, owner/mode right, no clobber,
no temp file left, and the service can read the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 22:36:31 +00:00
ssmithxandClaude Opus 5.5 03e38d1ca3 test: regression tests for the paid-download fixes
- mint_client: a stub mint shows swap() sends the full v2 keyset id when
  given a cashuB short id, and leaves complete v1/v2 ids unchanged.
- fips::dial: the single-delivery decisions are now small functions
  (fips_answer_is_final, fips_retryable). Tests cover them and, against a
  silent local peer, check that a single-delivery request isn't resent
  after a timeout while an ordinary one still is.
- content_server: an unreadable paid file returns Unavailable before the
  payment gate runs, and a readable one still returns 402. Also covers
  ensure_readable's grant/reopen behaviour. The podman grant is replaced
  by a refusal under cfg(test) so results don't depend on the host.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:12:16 +00:00
archipelago bded929812 Record validated OTA candidate and remaining release gates 2026-09-29 15:47:22 -04:00
archipelago 3612458e86 Handle empty recorded calls in install regression assertion 2026-09-29 15:38:49 -04:00
archipelago 8d9fad1749 Require Bitcoin version and pruning selection from the App Store 2026-09-29 15:37:05 -04:00
archipelago d25ed492c9 Keep Bitcoin pruning explanation below desktop install controls 2026-09-29 15:27:00 -04:00
archipelago 1f9abefc35 Isolate backend tests from live node wallets and services 2026-09-29 15:15:51 -04:00
archipelago b634f41a1c Complete paid-file caching and deliver LND waiting UI to existing nodes 2026-09-29 14:59:08 -04:00
archipelago 0f85f588fb Fix Cashu file redemption and Bitcoin-dependent wallet readiness 2026-09-29 14:42:44 -04:00
ssmithxandClaude Opus 5.5 e5fc99d66c fix(content): never charge for a file the seller can't serve or replay a spent token
After the keyset-id fix, a Minibits paid download still failed and the
buyer lost the sats. What happened, 2026-09-29, amishparadise:

1. The seller redeemed the token, then failed to read the file. It was a
   FileBrowser upload owned by the container subuid (100999) with mode
   0640. The handler mapped that Err to 404.
2. The buyer's FIPS dial treats 404 as "fall back to Tor" and resent the
   request with the same, now spent, token. The seller answered 402, and
   the buyer showed "seller doesn't accept your Cashu mint".

Fixes:
- serve_content checks the file is readable before the paid gate. If it
  isn't, it grants read with `podman unshare chmod a+r`, which matches
  the other shared files. If that also fails it returns Unavailable (503)
  without taking payment.
- The content handler returns 500 on internal errors and logs them,
  instead of a silent 404.
- New PeerRequest::single_delivery(), used for the paid download: the
  FIPS answer is final, FIPS retries only when it never connected, and
  there's no Tor replay once the request may have been delivered.
- The buyer shows the seller's error text for non-402 failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 18:42:20 +00:00
ssmithxandClaude Opus 5.5 8b74803290 fix(ecash): repair short v2 keyset ids on every swap, not just receive
Paid cloud downloads paid with Minibits ecash were always rejected. The
buyer sends a cashuB token, which carries NUT-02 v2 keyset ids in their
8-byte short form. Minibits rotated its active keyset to a v2 id, and the
seller's verify_and_receive_payment called MintClient::swap directly,
skipping the short->full id repair that only receive_token applied. The
mint answered 422 ("ID length invalid"). The buyer then showed the
misleading "seller doesn't accept your Cashu mint" hint.

Move the repair into swap() so every caller is covered: payment verify,
streaming gate, send change, and cross-mint swaps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 15:47:23 +00:00
archipelago 540639d2c1 chore: publish release v1.8.19-alpha 2026-09-28 13:21:51 -04:00
archipelago 562871b1ce chore: prepare signed release v1.8.19-alpha 2026-09-28 13:18:34 -04:00
archipelago cca3f8bfcd docs: include AIUI packaging fix in v1.8.19 release notes
Demo images / Build & push demo images (push) Failing after 44s
2026-09-28 13:13:31 -04:00
archipelago 89c08be712 fix(aiui): match host color scheme for iframe transparency
Demo images / Build & push demo images (push) Failing after 56s
2026-09-28 12:37:40 -04:00
archipelago b4ecf86c13 chore: stage v1.8.19-alpha version bump 2026-09-28 12:33:59 -04:00
archipelago b14fe78306 fix(aiui): expose host wallpaper and package fresh production builds 2026-09-28 12:32:18 -04:00
archipelago 3f0c1038c3 docs: add v1.8.19 release notes to settings 2026-09-28 12:23:57 -04:00
archipelago 1fbefce6df docs: add v1.8.19-alpha release notes 2026-09-28 12:23:05 -04:00
archipelago 63cb68451a fix(aiui): keep embedded chat background transparent 2026-09-22 05:04:59 -04:00
archipelago 17cfebbe26 chore: publish release v1.8.18-alpha 2026-09-20 11:49:39 -04:00
archipelago 379fb930fc chore: prepare release v1.8.18-alpha
Demo images / Build & push demo images (push) Failing after 43s
2026-09-20 11:45:35 -04:00
archipelago 1bebdeac0f Prepare v1.8.18-alpha release notes 2026-09-18 06:36:43 -04:00
archipelago f458591132 Document Minibits description customization limits
Demo images / Build & push demo images (push) Failing after 45s
2026-09-15 16:13:14 -04:00
archipelago 6155539254 Confirm Primal automatic-comment cause and successful workaround 2026-09-15 16:11:09 -04:00
archipelago 76e0f1f3b6 Trace Primal Spark auto-comment failure against Framework address 2026-09-15 16:09:16 -04:00
archipelago ba6ce2cdb6 Record live LNURL comment limit investigation 2026-09-15 16:06:21 -04:00
archipelago 8212049f57 Shorten ecash backup copy and stack card actions 2026-09-15 16:03:52 -04:00
archipelago 5814f47659 docs: verify Framework Cashu address and preserved proofs 2026-09-15 15:58:16 -04:00
archipelago 94f5e892c3 docs: track authenticated Cashu address setup and remaining verification 2026-09-15 15:47:48 -04:00
archipelago a3b6467047 fix(ecash): guide unseeded wallets through Lightning address setup 2026-09-15 15:45:32 -04:00
archipelago 66db6497ec docs: record successful Framework reboot verification 2026-09-15 15:27:13 -04:00
archipelago 81be17f09f docs: record Framework live evidence and staged fix validation 2026-09-15 15:15:34 -04:00
archipelago 4237fb5e79 fix(wallet): prioritize LND boot and reject unavailable balances 2026-09-15 15:09:08 -04:00
archipelago 4302138b4f docs: make Framework LND incident a persistent investigation blocker 2026-09-15 14:56:01 -04:00
archipelago 3b9b74dae5 chore: publish release v1.8.17-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-09-15 12:56:18 -04:00
archipelago 4021c1f496 chore: prepare release v1.8.17-alpha 2026-09-15 12:53:06 -04:00
archipelago 5f8de584bc docs: add v1.8.17-alpha release notes
Demo images / Build & push demo images (push) Failing after 42s
2026-09-15 12:33:24 -04:00
chaum 38de1b3310 Merge pull request 'fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn' (#160) from fix/minibits-already-redeemed into main 2026-09-15 16:32:53 +00:00
archipelago abfbccc906 fix(ecash): preserve retryable claims and resume relay backlogs 2026-09-15 12:31:49 -04:00
ssmithxandClaude Sonnet 5 9d4e74e094 docs: redact node hostname from the Minibits incident writeup
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:19:23 +00:00
ssmithxandClaude Sonnet 5 db355b759c fix(ecash): stop replayed Minibits claims retrying forever, reduce relay churn
claim_and_redeem retried every redeem failure indefinitely, including a
terminal one: mint error 11001 "Token Already Spent" (a claim replayed by a
relay-watermark edge case, or already redeemed by an earlier run). On
archy-x250-pa3 this pinned pending_claims at 1 forever and hammered
mint.minibits.cash's swap endpoint every ~6s, with the UI permanently
showing "a payment arrived but couldn't be redeemed yet".

- mint_client: expose the NUT error-code-11001 message as
  ALREADY_REDEEMED_MSG so callers can recognize it without duplicating the
  string.
- minibits: drop (not retry) a redeem failure that matches
  is_already_redeemed — the value was already swept, so retrying can never
  succeed.
- fetch_relay_dms: query the primary relay.minibits.cash alone first,
  falling back to the public relay.damus.io/nos.lol only if it's
  unreachable, and page past a 200-DM backlog instead of silently
  stranding older DMs behind an un-advanced watermark.

This fix already existed on feat/minibits-lnurl-receive (4e410d7, 489995c,
2026-09-09) but that branch was never merged into main, which has its own
independently-diverged minibits.rs — so the bug shipped again in
1.8.16-alpha. Ported directly onto main's current implementation this time.

Immediate unblock on archy-x250-pa3: cleared the one poisoned
pending_claims entry from wallet/minibits.json by hand (already-redeemed,
zero value at risk) and restarted archipelago.service; confirmed via
journalctl that polling is quiet again.

See docs/incident-2026-09-15-minibits-already-redeemed.md for the full
writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:12:00 +00:00
archipelago 31d77f01ac chore: publish release v1.8.16-alpha
Demo images / Build & push demo images (push) Failing after 34s
2026-09-15 04:02:57 -04:00
archipelago 1b0ed281b2 chore: prepare release v1.8.16-alpha 2026-09-15 03:59:43 -04:00
archipelago 9c6580f5c0 fix: prevent stale catalog updates and redundant container recreation
Demo images / Build & push demo images (push) Failing after 40s
2026-09-15 03:40:21 -04:00
archipelago 83abb0485d fix: publish signed mempool DNS recovery catalog 2026-09-15 03:13:02 -04:00
archipelago b35409ca74 fix: recover mempool frontend after backend address changes 2026-09-15 02:49:06 -04:00
archipelago 700d39c425 fix: keep release credentials out of public remote URLs 2026-09-14 10:47:58 -04:00
archipelago 4272c47ee5 chore: sign app catalog for v1.8.15-alpha 2026-09-13 13:21:24 -04:00
archipelago c7cb043485 chore: publish release v1.8.15-alpha 2026-09-13 05:09:56 -04:00
archipelago 4dfe79290e chore: sign release v1.8.15-alpha manifest 2026-09-13 04:42:17 -04:00
archipelago d3e3df6d24 docs: add release validation note 2026-09-13 04:32:58 -04:00
archipelago 969570e38b chore: prepare release v1.8.15-alpha
Demo images / Build & push demo images (push) Successful in 3m29s
2026-09-13 04:32:16 -04:00
archipelago b73d646db5 docs: prepare 1.8.15 alpha notes
Demo images / Build & push demo images (push) Successful in 3m22s
2026-09-13 03:06:53 -04:00
archipelago 8c37ff412c fix: present Cuprate as one launchable app
Demo images / Build & push demo images (push) Successful in 3m28s
2026-09-13 03:06:22 -04:00
archipelago 06bf359535 chore: publish release v1.8.14-alpha 2026-09-13 02:56:29 -04:00
archipelago a4f3415f0f chore: prepare release v1.8.14-alpha
Demo images / Build & push demo images (push) Successful in 3m25s
2026-09-13 02:53:11 -04:00
archipelago c9c9ebe6d4 docs: sync whats new for 1.8.14 alpha
Demo images / Build & push demo images (push) Successful in 3m18s
2026-09-13 02:34:45 -04:00
archipelago 100993445b docs: prepare 1.8.14 alpha release notes 2026-09-13 02:33:30 -04:00
archipelago a4f80e7ec1 test: update GitWorkshop launcher deep-link expectation
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:48:36 -04:00
archipelago 4ad34d3a0a test: validate full Archipelago ngit promotion path 2026-09-13 01:45:22 -04:00
archipelago c9bae926a5 test: satisfy strict indexed access
Demo images / Build & push demo images (push) Successful in 3m14s
2026-09-13 01:41:11 -04:00
archipelago cb3f7e8720 Merge PR #157: Cuprate disk gate and companion dashboard
Demo images / Build & push demo images (push) Successful in 3m19s
2026-09-13 01:37:46 -04:00
archipelago eb98ebb682 Merge PR #158: preserve Bitcoin Core Tor service naming 2026-09-13 01:37:15 -04:00
archipelago 00682e6420 test: expect Cuprate launches through companion UI 2026-09-12 16:17:47 -04:00
archipelago 95cdc3daea fix: retain source port in Cuprate generated ports 2026-09-12 16:15:28 -04:00
archipelago 1d05f2c27a style: format generated app launch ports 2026-09-12 16:15:01 -04:00
archipelago b3f16d07a6 style(cuprate-ui): anchor desktop details to right edge 2026-09-12 16:14:55 -04:00
archipelago 14d2b37e99 style(cuprate-ui): right-align desktop header cards 2026-09-12 16:14:55 -04:00
archipelago f5b255ee68 style(cuprate-ui): improve mobile dashboard layout 2026-09-12 16:14:55 -04:00
archipelago 6e8d90fb5f style(cuprate-ui): match bitcoin status cards 2026-09-12 16:14:55 -04:00
archipelago 66c4b0d375 feat(cuprate-ui): add bitcoin-style dashboard tabs 2026-09-12 16:14:55 -04:00
archipelago 0f74ebfbbe feat(cuprate-ui): use app icon and shared dashboard background 2026-09-12 16:14:55 -04:00
archipelago ee11863ada refactor(cuprate-ui): align dashboard with bitcoin UI style 2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 86052d9552 refactor(cuprate): one CUPRATE_MIN_DISK_GB, manifest matches it (review)
450 existed as two independent Rust constants (RPC gates vs boot
reconciler) linked only by a "keep in lockstep" comment — updating one
would reopen the disk-fill hole. Move it to crate::constants as the
single source of truth both paths import.

Also raise apps/cuprate/manifest.yml storage dependency and disk_limit
from 300Gi to 450Gi so manifest-driven surfaces (store size, pre-checks)
show the number the gate actually enforces — a user provisioning to the
displayed 300 was refused at an unexplained 450. Catalog regenerated
(cuprate entry re-embedded; still unsigned pending sign-catalog.sh).
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 047ef98987 fix(cuprate-ui): target_height 0 means synced, not stuck (review)
Monero's get_info returns target_height == 0 when the node is FULLY
SYNCED — the field is the height being caught up to, not the chain tip.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago c681472e15 fix(cuprate): gate package.restart and package.update too (review)
Restart and update are stop + recreate — a fresh start by another name —
but only start carried the disk gate, so on a disk that shrank below the
floor after install, either action silently resumed the unprunable
Monero sync: the exact failure the gate exists to close.

Both now call check_cuprate_disk_compatibility after validate_app_id and
BEFORE any state mutation (user-stopped clear / Restarting / Updating
flip), matching handle_package_start's fail-clean contract.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago 7c0ba14a00 feat(neode-ui): launch cuprate tiles on the Cuprate UI companion
cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
2026-09-12 16:14:55 -04:00
ssmithxandarchipelago eacd74e1db feat(cuprate-ui): companion dashboard for the Cuprate Monero node
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.

No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.

Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
2026-09-12 16:14:29 -04:00
ssmithxandarchipelago 34b68001d1 fix(cuprate): refuse to run on disks too small for the Monero chain
Cuprate has no pruning — verified against upstream main
(binaries/cuprated/src/config.rs): the 'pruning' crate is Monero's p2p
protocol pruning, not on-disk. Unlike the bitcoin apps, which branch on
DISK_GB in their entrypoint and self-prune, a disk-constrained cuprate
can only sync until the filesystem fills and take Archipelago down.

Translate the bitcoin disk-awareness into the only form cuprate can
honor — refuse rather than prune:
- install (sync + async RPC paths) and package.start fail with an
  actionable message below CUPRATE_MIN_DISK_GB (450 GB total: chain
  ~250 GiB + headroom; allows 500 GB-class, refuses the 250 GB VPS)
- boot reconcile skips an already-installed cuprate on a shrunken disk,
  recorded as Left("cuprate-insufficient-disk") before ensure_running
  so desired-state recovery can never undo it (same shape as
  requires-archival-bitcoin)
- df failure fail-opens at install (never block on an unreadable disk),
  fail-closes at boot (never start a doomed sync)

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-12 16:14:02 -04:00
ssmithxandClaude Sonnet 5 dc7b598558 fix(tor): un-alias bitcoin-core's hidden-service name; add regression tests
read_tor_address("bitcoin-core") was resolving through tor_service_name to
the shared "bitcoin" alias, but enrollment (install.rs auto-enroll and the
tor.create-service RPC) always names HiddenServiceDir/tor-hostnames entries
using the raw package_id verbatim — never canonicalized. On a real node
that's hidden_service_bitcoin-core, which the aliased lookup never found,
so the per-app UI Tor badge stayed empty even after the previous commit
made bitcoin-core auto-enrollable.

Give bitcoin-core its own identity-mapped arm instead of folding it into
the legacy bitcoin/bitcoin-knots/bitcoind alias, and pin all three lookup
tables (known_service_port, is_protocol_service, tor_service_name) with
regression tests so this alias-drift class of bug can't recur silently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 16:26:59 +00:00
ssmithxandClaude Sonnet 5 69f3a355c7 fix(tor): recognize bitcoin-core in Tor auto-enrollment tables
apps/bitcoin-core/manifest.yml uses id "bitcoin-core", but
known_service_port/is_protocol_service (tor/mod.rs) and
tor_service_name (docker_packages.rs) only matched "bitcoin" and
"bitcoin-knots", so the app silently never got auto-enrolled for a
P2P (8333) hidden service at install time, and the UI's Tor address
lookup for it always returned None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WxfWiFfnBkdSxwKUuV2tNy
2026-09-10 15:28:17 +00:00
183 changed files with 15260 additions and 5053 deletions
+31
View File
@@ -0,0 +1,31 @@
# Blocking incident: Framework LND startup and false zero balance
Before starting or resuming work in this repository, read
`docs/incident-framework-lnd-startup.md` and check its status.
The user explicitly required this incident to take priority over later work.
While its status is OPEN:
- Surface this blocker at session start, including when the user asks for unrelated work.
- Prioritize investigation and repair on the actual Framework node. If access is
unavailable, ask for it and continue useful offline investigation; defer unrelated
work unless the user explicitly overrides this priority.
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
does not resolve the incident. Do not mark it fixed until the Framework's startup,
Receive flow, and balance behavior are verified on the node, including a controlled
reboot with access and recovery arrangements in place.
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
Never run wallet wipe/recreation as an automatic investigation or recovery step.
- Record evidence, changes, validation, and remaining work in the incident document.
This priority comes from the user's explicit instruction on 2026-09-15. It remains
in effect across sessions until the documented acceptance criteria are met or the
user explicitly changes it.
## Unit tests on a live node
Run backend unit tests through `scripts/test-backend-isolated.sh`. Do not run
unrestricted `cargo test` on a node with installed apps: older mocked-runtime
tests still reached real service commands. The runner isolates wallet data,
service buses, container storage, networking, and process IDs. Compilation with
`cargo test --no-run` is safe. Keep separately authorized live checks explicit.
+86 -1
View File
@@ -1,6 +1,91 @@
# Changelog
## Unreleased
## v1.8.22-alpha (2026-09-30)
- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.
- Network diagnostic failures no longer stop all apps or rebuild shared container networking.
- Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.
- Fixed companion dashboard builds still referencing a retired image registry.
- Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.
- Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.
- Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.
- Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.
- Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.
- Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.
- Named the app in compact readiness messages and kept app-card actions aligned at the bottom.
- Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.
- Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.
- Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.
- Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.
- Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.
- Preserved Gitea configuration and SSH operation during fresh setup and upgrades.
- Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.
- Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.
- Prevented manifest command arguments containing apostrophes from being corrupted in generated services.
## v1.8.21-alpha (2026-09-30)
- Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.
- Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.
- Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.
- Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20.
## v1.8.20-alpha (2026-09-29)
- Fixed Cashu file payments rejected despite a shared mint, and preserved the payment amount when mint fees reduce change.
- Payment failures now report whether a refund actually succeeded; missing files and unsupported payment methods are rejected before charging.
- Improved saving paid files into Files and reopening purchases without paying again.
- Bitcoin Core and Knots installation offers optional pruning on larger disks, using the same settings as automatic pruning.
- Fixed false missing-port checks that unnecessarily restarted Bitcoin and LND; recovery now respects managed shutdown timeouts.
- LND explains when it is waiting for Bitcoin installation, startup, or sync, without treating normal synchronization as a restart-worthy failure.
- Bitcoin startup messages explain block-index loading without exposing raw RPC errors, and Lightning keeps known balances clearly marked during outages.
- Changed the public transaction-explorer default to mempool.space while preserving local explorers and custom choices.
## v1.8.19-alpha (2026-09-28)
- Fixed the embedded AIUI chat page painting a second background and dark scrim over Archy’s dashboard background.
- Embedded AIUI now stays transparent so the dashboard background appears once.
- AIUI background fixes are now included reliably in OTA updates and fresh installations.
## v1.8.18-alpha (2026-09-18)
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
## v1.8.17-alpha (2026-09-15)
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
- Release validation was rerun on the corrected tree before OTA and ISO publication.
## v1.8.14-alpha (2026-09-13)
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
## v1.8.13-alpha (2026-09-12)
+3 -2
View File
@@ -46,13 +46,14 @@ interface RateBucket {
const rateBuckets = new Map<string, RateBucket>()
// Clean up stale buckets every 5 minutes
// Vite imports this module during builds too; cleanup must not keep the
// process alive once compilation has finished.
setInterval(() => {
const now = Date.now()
for (const [key, bucket] of rateBuckets) {
if (now > bucket.resetAt) rateBuckets.delete(key)
}
}, 5 * 60_000)
}, 5 * 60_000).unref()
function getClientIp(req: IncomingMessage): string {
return req.socket.remoteAddress ?? 'unknown'
+1
View File
@@ -33,6 +33,7 @@ const PWA_CACHE_VERSION = '2'
// Only embedded when explicitly requested via ?embedded param
const _embeddedFlag = new URLSearchParams(window.location.search).has('embedded')
;(window as unknown as Record<string, unknown>).__AIUI_EMBEDDED__ = _embeddedFlag
document.documentElement.classList.toggle('aiui-embedded', _embeddedFlag)
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
+5 -5
View File
@@ -2,13 +2,13 @@
<div
class="h-full flex flex-col relative overflow-hidden transition-colors duration-300"
:class="[]"
:style="isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: isEmbedded
? { background: 'transparent' }
:style="isEmbedded
? { background: 'transparent' }
: isDark
? { background: '#000 url(' + bgImageUrl + ') center center / cover no-repeat fixed' }
: { backgroundColor: '#f5f4f1' }"
>
<div v-if="isDark" class="absolute inset-0 pointer-events-none bg-black/20" />
<div v-if="isDark && !isEmbedded" class="absolute inset-0 pointer-events-none bg-black/20" />
<!-- Desktop layout -->
<div
+15 -6
View File
@@ -57,12 +57,8 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
/* Every page paints its own explicit background (bg-[#0a0a0a] / bg-[#faf9f6])
EXCEPT the embedded Chat page, which intentionally goes transparent so
Archy's own dark chrome can show behind it (Chat.vue's iframe host). With
no background-color here, "transparent" fell through to the browser's
default white canvas instead. Match the theme's own dark/light default so
nothing above this ever needs to guess. */
/* Standalone canvas fallback. Embedded mode overrides this below so
Archy's wallpaper remains visible through the iframe. */
background-color: #0a0a0a;
}
@@ -70,6 +66,19 @@ html.light body {
background-color: #faf9f6;
}
/* The host owns the wallpaper when AIUI is embedded. The document canvas
must be transparent too, otherwise it hides the host behind ChatPage. */
html.aiui-embedded {
/* Match Archy's dark canvas scheme. Browsers otherwise give an iframe
with a different scheme an opaque canvas despite transparent CSS. */
color-scheme: dark;
}
html.aiui-embedded,
html.aiui-embedded body {
background: transparent;
}
/* ===== DARK MODE GLASSMORPHISM — from Archy ===== */
@layer components {
+31 -2
View File
@@ -378,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -644,6 +644,35 @@
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "angor-indexer",
"title": "Angor Indexer",
"version": "1.0.1",
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
"author": "Angor / Archipelago",
"requires": [
"Mempool API",
"Unpruned Bitcoin"
],
"category": "money",
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/block-core/angor"
},
{
"id": "angor-relay",
"title": "Angor Relay",
"version": "1.1.2",
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
"dockerImage": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
"author": "Angor / Archipelago",
"requires": [],
"category": "nostr",
"tier": "optional",
"icon": "/assets/img/app-icons/angor-green.png",
"repoUrl": "https://github.com/hoytech/strfry"
}
]
}
+57
View File
@@ -0,0 +1,57 @@
# Angor Indexer
Headless mainnet API endpoint for Angor. The service reuses this node's Mempool
backend and Electrum index instead of creating a second blockchain database.
An unpruned, fully synced Bitcoin node is required. Installing against a pruned
node must show the existing archival-node requirement; it must never silently
unprune or replace its Bitcoin data.
## Connect Angor
Install **Angor Indexer** in the store. Its API appears under **Services**.
In Angor settings, use `http://<node-address>:8998/` as the custom indexer origin.
The `/health` endpoint reports readiness against Mempool's indexed block height;
it returns 503 while that backend is unavailable. Index building may take time.
Browser clients require a reachable HTTPS origin with a trusted certificate.
Configure your HTTPS reverse proxy to forward to port 8998, then use that HTTPS
origin in Angor. Do not disable browser TLS checks. The API supports both
`/api/v1/` and `/api/` paths, transaction broadcast, and CORS without cookies.
This endpoint intentionally exposes public blockchain queries and transaction
broadcast through the app gate without dashboard-cookie login. It has no Bitcoin
RPC password, wallet keys, or persistent wallet data. The backend stays on the
managed container network; its private port does not become publicly exposed.
You can change network access using the node's normal access controls.
## Relay
A relay is optional. Angor can continue using its configured external relays.
Install **Angor Relay** separately to host project metadata locally, then add
`ws://<node-address>:8091/` in Angor, or a trusted `wss://` proxy origin for browser
clients. Its storage and configuration are separate from the node's internal
relay; installing or uninstalling it does not change the internal relay.
## Packaging
Build the pinned image with:
```
podman build -t source.archipelago-foundation.org/chaum/angor-indexer:1.0.1 apps/angor-indexer/container
```
The image runs as UID 101 with a read-only root filesystem and no capabilities.
Only temporary nginx state is writable. Runtime DNS is read from resolv.conf so
Mempool recreation does not require editing IP addresses or restarting this app.
No app-specific Rust installer is required.
Source documentation: [Angor's official deployment guide](https://github.com/block-core/angor/blob/869dd43cf38332dd7128a284a6bf4c1cac44c1a7/docker/DEPLOY-INDEXER-AND-RELAY.md).
The app icon is based on [Angor’s dark-mode app icon](https://angor.io/images/app-icon-dark-mode.png), retrieved 2026-09-30. At the operator’s request, the outer corners use the same green as the background. The built-in imagegen edit preserved the black mark and filled the square green; the project asset is `neode-ui/public/assets/img/app-icons/angor-green.png`.
Tests and release acceptance are recorded in the next-release checklist. The
health probe establishes backend availability, not a guarantee that every
address query is indexed at the latest Bitcoin tip.
Install Mempool Explorer first. The declarative `install_prerequisites` check
refuses a new adapter installation if its Mempool API component is absent, before
creating an installed-app record. It does not install or resync Bitcoin for you.
+6
View File
@@ -0,0 +1,6 @@
FROM docker.io/library/nginx:1.31.3-alpine@sha256:1d40e3eb3bf4f138de1d67193f2aa5309fcaf343eb5ffadbf5e9439de1eb1ebb
COPY nginx.conf /etc/angor-nginx.conf.template
COPY entrypoint.sh /usr/local/bin/angor-indexer
USER 101:101
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/angor-indexer"]
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
set -eu
# Resolve through the container runtime's DNS, including after dependency
# recreation. Never bake a container IP into the indexer endpoint.
DNS_RESOLVER=$(awk '/^nameserver[[:space:]]/ {print $2; exit}' /etc/resolv.conf)
case "$DNS_RESOLVER" in
''|*[!0-9a-fA-F.:]*) echo 'Container DNS resolver is unavailable' >&2; exit 1 ;;
esac
case "$DNS_RESOLVER" in *:*) DNS_RESOLVER="[$DNS_RESOLVER]" ;; esac
export DNS_RESOLVER
envsubst '${DNS_RESOLVER}' < /etc/angor-nginx.conf.template > /tmp/nginx.conf
exec nginx -c /tmp/nginx.conf -g 'daemon off;'
+63
View File
@@ -0,0 +1,63 @@
worker_processes 1;
pid /tmp/nginx.pid;
error_log /dev/stderr warn;
events { worker_connections 512; }
http {
access_log off;
server_tokens off;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
resolver ${DNS_RESOLVER} valid=10s ipv6=off;
upstream mempool_backend {
zone mempool_backend 64k;
server mempool-api:8999 resolve;
}
server {
listen 8080;
client_max_body_size 4m;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_send_timeout 30s;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
proxy_set_header Authorization "";
proxy_set_header Cookie "";
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin '*' always;
add_header Access-Control-Allow-Methods 'GET, HEAD, POST, OPTIONS' always;
add_header Access-Control-Allow-Headers 'Content-Type' always;
add_header Cache-Control 'no-store' always;
if ($request_method = OPTIONS) { return 204; }
# Mempool's backend uses /api/v1. Match its frontend's shorter /api
# surface too, without doubling already-versioned Angor URLs.
rewrite ^/api/(?!v1/)(.*)$ /api/v1/$1 last;
location = / {
default_type application/json;
return 200 '{"service":"Angor Indexer","network":"mainnet","api":"/api/v1","health":"/health"}\n';
}
# Readiness checks the indexing backend, not this gateway's process.
location = /health {
limit_except GET { deny all; }
proxy_pass http://mempool_backend/api/v1/blocks/tip/height;
proxy_intercept_errors on;
error_page 500 502 503 504 =503 @waiting;
}
location @waiting {
default_type application/json;
return 503 '{"status":"waiting","message":"Waiting for Bitcoin and Mempool indexing"}\n';
}
location ~ ^/api/(v1/)?tx$ {
limit_except GET POST { deny all; }
proxy_pass http://mempool_backend;
}
location /api/ {
limit_except GET { deny all; }
proxy_pass http://mempool_backend;
}
location / { return 404; }
}
}
+68
View File
@@ -0,0 +1,68 @@
app:
id: angor-indexer
name: Angor Indexer
version: 1.0.1
description: Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool
and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s
address as the custom indexer in Angor settings. A relay is optional and installed
separately.
category: money
install_prerequisites:
- mempool-api
upstream:
kind: github
repo: block-core/angor
container:
image: source.archipelago-foundation.org/chaum/angor-indexer:1.0.1
pull_policy: if-not-present
network: archy-net
dependencies:
- app_id: mempool-api
version: '>=3.0.0'
- bitcoin:archival
resources:
cpu_limit: 1
memory_limit: 128Mi
disk_limit: 128Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 101
network_policy: isolated
ports:
- host: 8998
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: open
auth_rationale: Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.
interfaces:
main:
name: Angor Indexer API
description: Use this origin as Angor’s custom mainnet indexer URL. HTTPS is
required for browser clients.
type: api
port: 8998
protocol: http
path: /
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 8s
retries: 3
bitcoin_integration:
rpc_access: none
sync_required: true
pruning_support: false
metadata:
icon: /assets/img/app-icons/angor-green.png
tier: optional
repo: https://github.com/block-core/angor
features:
- Angor mainnet API
- Reuses existing Mempool indexing
- No separate blockchain database
- Optional independent relay
+21
View File
@@ -0,0 +1,21 @@
# Angor Relay
Optional standalone strfry relay for Angor's public project metadata. See
[Angor Indexer setup](../angor-indexer/README.md) for client URLs and HTTPS/WSS.
The gate exposes port 8091 for Nostr clients. strfry validates event signatures;
this is a public relay, not a private messaging archive. It mounts only
`/var/lib/archipelago/angor-relay` and its separate configuration directory.
It never opens, reconfigures or shares the node's internal strfry database.
The configuration is seeded only when absent, preserving operator changes.
Stop the service before making a consistent backup of its event database.
Ordinary start/restart/recreation preserves both mounts. Use the standard app
lifecycle; do not manually recreate a systemd-managed container.
## Image provenance
Mirrored from `docker.io/dockurr/strfry:1.1.2`, upstream manifest digest
`sha256:e81d238db13507f6ef24c49d47cd0b0ea58ff207961f10581fa2a7c901054df4`.
The public Angor policy is supplied by this app's own configuration; it does not
reuse the internal relay's event whitelist.
+223
View File
@@ -0,0 +1,223 @@
app:
id: angor-relay
name: Angor Relay
version: 1.1.2
upstream:
kind: github
repo: hoytech/strfry
description: Optional dedicated Nostr relay for Angor project metadata. Separate
storage and access settings keep the node’s internal relay private. Add this service’s
address to Angor’s relay settings; use WSS for browser clients.
container:
image: source.archipelago-foundation.org/chaum/angor-relay:1.1.2
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
seccomp_profile: default
network_policy: isolated
apparmor_profile: nostr-relay
ports:
- host: 8091
container: 7777
protocol: tcp
bind: 127.0.0.1
auth: open
auth_rationale: Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.
volumes:
- type: bind
source: /var/lib/archipelago/angor-relay
target: /app/strfry-db
options:
- rw
- type: bind
source: /var/lib/archipelago/angor-relay-config/angor-relay.conf
target: /etc/strfry.conf
options:
- ro
files:
- path: /var/lib/archipelago/angor-relay-config/angor-relay.conf
overwrite: false
content: |
##
## Default strfry config
##
# Directory that contains the strfry LMDB database (restart required)
db = "./strfry-db/"
dbParams {
# Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)
maxreaders = 256
# Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)
mapsize = 10995116277760
# Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)
noReadAhead = false
}
events {
# Maximum size of normalised JSON, in bytes
maxEventSize = 65536
# Events newer than this will be rejected
rejectEventsNewerThanSeconds = 900
# Events older than this will be rejected
rejectEventsOlderThanSeconds = 94608000
# Ephemeral events older than this will be rejected
rejectEphemeralEventsOlderThanSeconds = 60
# Ephemeral events will be deleted from the DB when older than this
ephemeralEventsLifetimeSeconds = 300
# Maximum number of tags allowed
maxNumTags = 2000
# Maximum size for tag values, in bytes
maxTagValSize = 1024
}
relay {
# Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)
bind = "0.0.0.0"
# Port to open for the nostr websocket protocol (restart required)
port = 7777
# Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)
nofiles = 0
# HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)
realIpHeader = ""
info {
# NIP-11: Name of this server. Short/descriptive (< 30 characters)
name = "Angor Relay"
# NIP-11: Detailed information about relay, free-form
description = "Dedicated public relay for Angor project metadata."
# NIP-11: Administrative nostr pubkey, for contact purposes
pubkey = ""
# NIP-11: Alternative administrative contact (email, website, etc)
contact = ""
# NIP-11: URL pointing to an image to be used as an icon for the relay
icon = ""
# List of supported lists as JSON array, or empty string to use default. Example: "[1,2]"
nips = ""
}
# Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)
maxWebsocketPayloadSize = 131072
# Maximum number of filters allowed in a REQ
maxReqFilterSize = 200
# Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)
autoPingSeconds = 55
# If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)
enableTcpKeepalive = false
# How much uninterrupted CPU time a REQ query should get during its DB scan
queryTimesliceBudgetMicroseconds = 10000
# Maximum records that can be returned per filter
maxFilterLimit = 500
# Maximum number of subscriptions (concurrent REQs) a connection can have open at any time
maxSubsPerConnection = 20
writePolicy {
# If non-empty, path to an executable script that implements the writePolicy plugin logic
plugin = ""
}
compression {
# Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)
enabled = true
# Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)
slidingWindow = true
}
logging {
# Dump all incoming messages
dumpInAll = false
# Dump all incoming EVENT messages
dumpInEvents = false
# Dump all incoming REQ/CLOSE messages
dumpInReqs = false
# Log performance metrics for initial REQ database scans
dbScanPerf = false
# Log reason for invalid event rejection? Can be disabled to silence excessive logging
invalidEvents = true
}
numThreads {
# Ingester threads: route incoming requests, validate events/sigs (restart required)
ingester = 3
# reqWorker threads: Handle initial DB scan for events (restart required)
reqWorker = 3
# reqMonitor threads: Handle filtering of new events (restart required)
reqMonitor = 3
# negentropy threads: Handle negentropy protocol messages (restart required)
negentropy = 2
}
negentropy {
# Support negentropy protocol messages
enabled = true
# Maximum records that sync will process before returning an error
maxSyncEvents = 1000000
}
}
health_check:
type: http
endpoint: http://127.0.0.1:7777
path: /health
interval: 30s
timeout: 5s
retries: 3
nostr_integration:
relay_type: public
monetization_enabled: false
category: nostr
interfaces:
main:
name: Angor Relay
description: Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your
HTTPS domain.
type: api
port: 8091
protocol: http
path: /
metadata:
icon: /assets/img/app-icons/angor-green.png
tier: optional
repo: https://github.com/hoytech/strfry
features:
- Angor project metadata
- Separate from the node relay
- Persistent Nostr event storage
+5 -3
View File
@@ -1,7 +1,7 @@
app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
@@ -12,7 +12,7 @@ app:
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
pull_policy: if-not-present
network: archy-net
@@ -45,7 +45,9 @@ app:
# first, but nginx binds 0.0.0.0:8080 (IPv4) only -> localhost probe gets
# "connection refused" -> perpetual unhealthy -> health_monitor restart loop.
endpoint: http://127.0.0.1:8080
path: /
# Probe the backend through nginx: a static page can be healthy while
# every API/WebSocket request is stuck on a dead backend address.
path: /api/v1/backend-info
interval: 30s
timeout: 5s
retries: 3
+1 -1
View File
@@ -54,7 +54,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+1 -1
View File
@@ -60,7 +60,7 @@ app:
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
if [ "${BITCOIN_PRUNE:-0}" = "1" ] || [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -conf="$RPC_CONF" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;
+67
View File
@@ -0,0 +1,67 @@
app:
id: cuprate-ui
name: Cuprate UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for the Cuprate Monero node. Runs nginx
inside a container, serves a static status dashboard, and proxies
/cuprate-rpc/ to the cuprate restricted RPC on 127.0.0.1:18090 (the
published host port for the container's 18089). No credentials are
injected — the restricted RPC is Monero's own safe-for-public subset — so
the nginx.conf is baked into the image and there is no rendered-config
bind-mount like bitcoin-ui's.
container:
build:
context: /opt/archipelago/docker/cuprate-ui
dockerfile: Dockerfile
tag: localhost/cuprate-ui:local
dependencies:
- app_id: cuprate
resources:
memory_limit: 64Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 18091 directly on the host IP.
# Declared so the APP GATE can see this port. Host networking means Podman
# publishes nothing (quadlet skips PublishPort in host mode), so `bind:` here
# is a statement of where the container's own nginx listens — 127.0.0.1 —
# not a publish instruction. Without this declaration the gate would have no
# idea the port existed: neither protected nor listed as unprotected.
ports:
- host: 18091
container: 18091
protocol: tcp
bind: 127.0.0.1
auth: gated
# First-party companion UI: its nginx forwards the node session cookie
# to the daemon's authenticated endpoints; without passthrough the gate
# strips it and every data call 401s while the page shell renders.
session_passthrough: true
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:18091
path: /
interval: 30s
timeout: 5s
retries: 3
metadata:
icon: /assets/img/app-icons/cuprate.svg
category: money
tier: optional
author: Archipelago
repo: https://github.com/Cuprate/cuprate
+23 -7
View File
@@ -36,12 +36,26 @@ app:
data_uid: "1000:1000"
dependencies:
# Monero mainnet is ~250GiB unpruned as of 2026 and growing a few GB a
# month; cuprated's pruning support is not confirmed stable yet (the
# `pruning` crate exists in the workspace but nothing in this config
# surface toggles it), so this sizes for a full unpruned chain plus
# headroom rather than assuming pruning is available.
- storage: 300Gi
# Monero mainnet is ~250GiB unpruned as of 2026 and growing ~60GiB/year.
# Verified against upstream main (binaries/cuprated/src/config.rs, 2026-09):
# cuprated has NO on-disk pruning setting of any kind — the `pruning`
# crate in its workspace is Monero's p2p *protocol* pruning, not a
# smaller chain — so unlike bitcoin-knots this app CANNOT self-prune
# when disk is scarce (see the DISK_GB branch in
# apps/bitcoin-knots/manifest.yml). Left running on a too-small disk it
# syncs until the filesystem fills and takes Archipelago down. The
# disk-scarce equivalent is enforced in Rust instead: install, start,
# restart and update refuse, and boot reconcile skips, on any node under
# CUPRATE_MIN_DISK_GB (450GB — chain + headroom; refuses the 250GB VPS
# class, allows 500GB-class disks). If upstream ever ships a prune flag,
# replace that gate with the bitcoin-style entrypoint branch.
#
# 450Gi, not the chain size (~250GiB): every manifest-driven surface
# (store size display, install pre-checks, docs) must show the number the
# Rust gate actually enforces, or a user provisioned to the displayed
# value gets refused at a different, unexplained one. Single source of
# truth is crate::constants::CUPRATE_MIN_DISK_GB — keep in lockstep.
- storage: 450Gi
resources:
cpu_limit: 0
@@ -51,7 +65,9 @@ app:
# CPU and ~595GB/24h of block I/O on a fully-synced node. 10Gi leaves
# headroom above the 8GiB cache for the process itself.
memory_limit: 10Gi
disk_limit: 300Gi
# Matches the storage dependency above (= the enforced disk floor),
# not the raw chain size — see the CUPRATE_MIN_DISK_GB note.
disk_limit: 450Gi
security:
# FROM scratch, no package manager/shell, ownership fixed at build time
+15 -8
View File
@@ -15,6 +15,9 @@ app:
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
pull_policy: if-not-present
# Preserve repositories, database, keys and configuration during runtime repairs.
backup_before_runtime_change: true
dependencies:
# Source history, LFS objects, release artifacts and OCI layers all share
# this persistent store. 500Mi was only suitable for an empty demo node.
@@ -25,7 +28,7 @@ app:
disk_limit: 50Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE, SYS_CHROOT]
readonly_root: false
no_new_privileges: false
network_policy: bridge
@@ -62,6 +65,17 @@ app:
target: /etc/gitea
options: [rw]
# Seed a fresh installation with the same origin advertised by the app gate.
# Existing app.ini (including custom HTTPS/domain settings) is never replaced.
files:
- path: /var/lib/archipelago/gitea/data/gitea/conf/app.ini
overwrite: false
content: |
[server]
DOMAIN = {{HOST_IP}}
SSH_DOMAIN = {{HOST_IP}}
ROOT_URL = http://{{HOST_IP}}:3001/
environment:
- GITEA__database__DB_TYPE=sqlite3
- GITEA__server__SSH_PORT=2222
@@ -106,10 +120,3 @@ app:
- Issue tracking and pull requests
- CI/CD via Gitea Actions
- Lightweight SQLite deployment
nginx_proxy:
listen: 3000
proxy_pass: http://127.0.0.1:3001
extra_headers:
- proxy_hide_header X-Frame-Options
- proxy_hide_header Content-Security-Policy
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: mempool
name: Mempool Explorer
version: 3.0.0
version: 3.3.1-archy1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
@@ -11,7 +11,7 @@ app:
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image: source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1
image_signature: cosign://...
pull_policy: if-not-present
+4 -2
View File
@@ -64,9 +64,11 @@ app:
environment: []
# Probe the admin API inside the container, independent of optional
# tunnel listeners. This also verifies the Node backend is ready.
health_check:
type: tcp
endpoint: localhost:81
type: http
endpoint: http://127.0.0.1:81/api/
interval: 30s
timeout: 5s
retries: 3
+8
View File
@@ -14,8 +14,16 @@ app:
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present
# Portainer fetches Git sources and images from services on this same node.
# Rootless pasta copies the host LAN address into its namespace, so a LAN
# URL points back at Portainer itself. Give it a private address with the
# supported rootless slirp backend; public app URLs still traverse the gate.
network: slirp4netns
data_uid: "1000:1000"
# Snapshot state before an upgrade recreates this app with new networking.
backup_before_runtime_change: true
dependencies:
- storage: 1Gi
+2 -2
View File
@@ -67,13 +67,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.13-alpha"
version = "1.8.22-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.13-alpha"
version = "1.8.22-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+23 -1
View File
@@ -162,11 +162,33 @@ impl ApiHandler {
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
Ok(content_server::ServeResult::Unavailable) => Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"The seller's node can't read this file right now. This request did not redeem an ecash payment."}"#,
),
)),
Ok(content_server::ServeResult::RangeNotSatisfiable(total)) => Ok(Response::builder()
.status(StatusCode::RANGE_NOT_SATISFIABLE)
.header("Content-Range", format!("bytes */{total}"))
.body(hyper::Body::empty())
.unwrap()),
Ok(content_server::ServeResult::NotFound) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
)),
// Not a 404: a paid request may already have been charged by the
// time this fails, and "not found" hid the real error entirely.
Err(e) => {
tracing::error!("Serving content {content_id} failed: {e:#}");
Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"text/plain",
hyper::Body::from("Failed to serve content"),
))
}
}
}
+13
View File
@@ -138,6 +138,19 @@ impl ApiHandler {
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
if suffix == "/archy-status" {
return Ok(Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-store")
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
.body(hyper::Body::from(
rpc.handle_lnd_readiness().await.to_string(),
))?);
}
let url = format!("{LND_REST_BASE_URL}{suffix}");
// LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the
+136 -118
View File
@@ -22,9 +22,9 @@ const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-cata
/// Best-effort reclaim of an ecash payment token that was minted but the sale
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer
/// doesn't lose the value. For Fedimint the spender can reissue its own
/// un-redeemed notes; for Cashu the proofs are received back. Fails silently if
/// the seller already claimed the token (then the value is genuinely gone).
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) {
/// un-redeemed notes; for Cashu the proofs are received back. Report the actual
/// recovered amount, or explicitly say when a refund could not be confirmed.
async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &str) -> String {
let res = match backend {
"fedimint" => crate::wallet::fedimint_client::reissue_into_any(data_dir, token)
.await
@@ -32,16 +32,81 @@ async fn reclaim_spent_ecash(data_dir: &std::path::Path, token: &str, backend: &
_ => ecash::receive_token(data_dir, token).await,
};
match res {
Ok(sats) => tracing::info!(
"paid download: reclaimed {sats} sats of unspent {backend} ecash after a failed sale"
),
Err(e) => tracing::warn!(
"paid download: could not reclaim {backend} ecash (the peer may have already \
claimed it): {e:#}"
),
Ok(sats) => {
tracing::info!("paid download: reclaimed {sats} sats after failed sale");
format!("Refunded {sats} sats to your wallet.")
}
Err(e) => {
tracing::warn!("paid download: refund not confirmed: {e}");
"Your refund could not be confirmed. The seller may have received the payment. Do not pay again until this is checked.".to_string()
}
}
}
/// Only pass through the peer's bounded, printable explanation; refund status
/// is always determined locally and must never come from the peer's wording.
fn seller_error_message(status: reqwest::StatusCode, body: &str) -> String {
let reason = serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str()).map(str::to_owned));
match reason {
Some(reason) if !reason.trim().is_empty() => {
let clean: String = reason
.chars()
.filter(|c| !c.is_control())
.take(240)
.collect();
format!("Seller response ({status}): {clean}")
}
_ => format!("Peer returned an error ({status})."),
}
}
/// Keep first purchases and cached repeats compatible with both existing clients.
fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json::Value {
use base64::Engine;
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
serde_json::json!({
"data": data, "data_base64": data,
"size": bytes.len(), "size_bytes": bytes.len(),
"mime_type": mime, "paid_sats": paid_sats, "owned": true,
})
}
/// File purchases through an atomic no-clobber write in Files' own namespace.
async fn file_purchase_in_files(
data_dir: &std::path::Path,
filename: &str,
mime: &str,
bytes: &[u8],
) -> Result<String> {
let folder = if mime.starts_with("image/") || mime.starts_with("video/") {
"Photos"
} else if mime.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let root = data_dir.join("filebrowser");
anyhow::ensure!(
tokio::fs::metadata(&root).await?.is_dir(),
"Files storage is unavailable"
);
let name = std::path::Path::new(filename)
.file_name()
.and_then(|n| n.to_str())
.filter(|n| !n.is_empty())
.unwrap_or("download");
let path =
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
Ok(format!(
"{folder}/{}",
path.file_name()
.and_then(|n| n.to_str())
.context("Invalid Files name")?
))
}
impl RpcHandler {
/// List content I'm sharing.
pub(super) async fn handle_content_list_mine(&self) -> Result<serde_json::Value> {
@@ -463,17 +528,10 @@ impl RpcHandler {
crate::content_owned::read_owned(&self.config.data_dir, &o.onion, &o.content_id)
.await
{
use base64::Engine;
return Ok(serde_json::json!({
"owned": true,
"already_owned": true,
"filename": o.filename,
"mime_type": mime,
"size_bytes": bytes.len(),
"paid_sats": 0,
"data_base64":
base64::engine::general_purpose::STANDARD.encode(&bytes),
}));
let mut result = paid_content_response(&bytes, &mime, 0);
result["already_owned"] = serde_json::json!(true);
result["filename"] = serde_json::json!(o.filename);
return Ok(result);
}
// Cache record exists but bytes are gone — fall through and
// repurchase rather than stranding the user.
@@ -545,31 +603,30 @@ impl RpcHandler {
let path = format!("/content/{}", content_id);
// Surface a real reason instead of the generic sanitized error (#30):
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
// here means the peer is genuinely unreachable on both transports.
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable.
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Your ecash was refunded to your wallet. Please try again."
}));
}
};
// A bearer token must not be replayed after an ambiguous delivery.
// A transport error can mean the seller received it without replying.
let (response, transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str.clone())
.single_delivery()
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
// The token was already minted/spent — reclaim it so the buyer
// doesn't lose the value when the seller was simply unreachable.
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The purchase could not be completed. {refund}")
}));
}
};
// Record which transport actually reached the peer (B14).
if let Err(e) = crate::federation::record_peer_transport(
&self.config.data_dir,
@@ -583,25 +640,17 @@ impl RpcHandler {
}
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected by the seller. Surface the most likely cause
// per backend — for ecash both sides must share a redemption network
// (a Cashu mint, or a Fedimint federation).
// A 402 can mean mint validation, network failure, underpayment,
// or an unaccepted mint. Do not invent a mint-mismatch diagnosis.
let body = response.text().await.unwrap_or_default();
tracing::warn!(
"paid download: seller {onion} rejected {used_backend} payment of {price_sats} sats: {body}"
);
// Seller couldn't redeem the token — reclaim it so the buyer keeps
// their funds (the spent-but-unredeemed-notes case the user hit).
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
let hint = match used_backend {
"fedimint" => "the seller isn't in the same Fedimint federation as you",
_ => "the seller doesn't accept your Cashu mint",
};
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!(
"Payment rejected by the seller — {hint}. Your ecash was refunded to \
your wallet. Try the other ecash type, or use a shared mint/federation."
)
"error": format!("The seller could not verify the payment. {refund}")
}));
}
@@ -609,9 +658,9 @@ impl RpcHandler {
let status = response.status();
let body = response.text().await.unwrap_or_default();
tracing::warn!("paid download: seller {onion} returned {status}: {body}");
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
let refund = reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({status}). Your ecash was refunded to your wallet.")
"error": format!("{} {refund}", seller_error_message(status, &body))
}));
}
@@ -625,10 +674,17 @@ impl RpcHandler {
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "application/octet-stream".to_string());
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
let bytes = match response.bytes().await {
Ok(bytes) => bytes,
Err(error) => {
tracing::warn!("paid download: response body failed: {error}");
let refund =
reclaim_spent_ecash(&self.config.data_dir, &token_str, used_backend).await;
return Ok(serde_json::json!({
"error": format!("The file transfer was interrupted after payment was sent. {refund}")
}));
}
};
// Persist the purchase so it "stays unlocked" for this buyer: cache the
// bytes + metadata keyed by (onion, content_id). The gallery then renders
@@ -658,63 +714,21 @@ impl RpcHandler {
tracing::warn!("paid download: failed to cache purchased content (non-fatal): {e:#}");
}
// Auto-file the purchase into the user's Files area (2026-07-22):
// Photos for images/video, Music for audio, Documents otherwise —
// same buckets the Cloud view uses. The in-app viewer still plays
// from the purchase cache; this makes the file ALSO show up where
// files live, on every device, without relying on a browser
// download. Best-effort: never fail a paid download over it.
{
let folder = if mime_type.starts_with("image/") || mime_type.starts_with("video/") {
"Photos"
} else if mime_type.starts_with("audio/") {
"Music"
} else {
"Documents"
};
let base = std::path::Path::new(&filename)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("download")
.to_string();
let dir = self.config.data_dir.join("filebrowser").join(folder);
if let Err(e) = tokio::fs::create_dir_all(&dir).await {
tracing::warn!("paid download: cannot create {}: {e}", dir.display());
} else {
// Don't clobber an existing file of the same name: "x.jpg"
// → "x (2).jpg" etc.
let mut target = dir.join(&base);
let (stem, ext) = match base.rsplit_once('.') {
Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")),
_ => (base.clone(), String::new()),
};
let mut n = 2;
while target.exists() {
target = dir.join(format!("{stem} ({n}){ext}"));
n += 1;
}
match tokio::fs::write(&target, &bytes).await {
Ok(()) => tracing::info!("paid download: filed into {}", target.display()),
Err(e) => tracing::warn!(
"paid download: filing into {} failed (non-fatal): {e}",
target.display()
),
}
}
// The durable purchased-content cache above is primary. A Files copy
// remains optional: a stopped FileBrowser must not undo a paid download.
let filed =
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
match filed {
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
Err(error) => tracing::warn!(
"paid download: optional Files copy failed; purchase cache retained: {error}"
),
}
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
tracing::info!("paid download: received {} bytes from {onion} (paid {price_sats} sats via {used_backend})", bytes.len());
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
"paid_sats": price_sats,
"ecash_backend": used_backend,
"mime_type": mime_type,
"owned": true,
}))
let mut result = paid_content_response(&bytes, &mime_type, price_sats);
result["ecash_backend"] = serde_json::json!(used_backend);
Ok(result)
}
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
@@ -1387,3 +1401,7 @@ impl RpcHandler {
}
}
}
#[cfg(test)]
#[path = "content_tests.rs"]
mod tests;
@@ -0,0 +1,73 @@
use super::*;
#[test]
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
use base64::Engine;
for paid in [0, 1] {
let response = paid_content_response(&[0, 255, 123], "application/octet-stream", paid);
assert_eq!(response["data"], response["data_base64"]);
assert_eq!(
base64::engine::general_purpose::STANDARD
.decode(response["data"].as_str().unwrap())
.unwrap(),
[0, 255, 123]
);
assert_eq!(response["size"], 3);
assert_eq!(response["size_bytes"], 3);
assert_eq!(response["paid_sats"], paid);
assert_eq!(response["owned"], true);
}
}
#[tokio::test]
async fn files_copy_routes_media_and_sanitizes_the_filename() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::create_dir(dir.path().join("filebrowser"))
.await
.unwrap();
for (mime, folder) in [
("image/png", "Photos"),
("video/mp4", "Photos"),
("audio/mpeg", "Music"),
("text/plain", "Documents"),
] {
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
.await
.unwrap();
assert!(relative.starts_with(&format!("{folder}/name #?")));
assert_eq!(
tokio::fs::read(dir.path().join("filebrowser").join(relative))
.await
.unwrap(),
b"paid"
);
}
}
#[tokio::test]
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
let dir = tempfile::tempdir().unwrap();
assert!(
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
.await
.is_err()
);
assert!(!dir.path().join("filebrowser").exists());
}
#[test]
fn seller_errors_are_bounded_printable_and_identified_as_peer_text() {
let status = reqwest::StatusCode::SERVICE_UNAVAILABLE;
let message = seller_error_message(status, r#"{"error":"Cannot read file\n\u0000"}"#);
assert!(message.starts_with("Seller response (503"));
assert!(message.ends_with("Cannot read file"));
assert!(!message.contains('\n') && !message.contains('\0'));
let body = serde_json::json!({"error": "é".repeat(1000)}).to_string();
assert!(seller_error_message(status, &body).chars().count() < 300);
for body in ["not JSON", r#"{"error": 7}"#, r#"{"error":" "}"#] {
assert_eq!(
seller_error_message(status, body),
"Peer returned an error (503 Service Unavailable)."
);
}
}
+213 -51
View File
@@ -73,7 +73,86 @@ struct LndChannelBalanceResponse {
pending_open_local_balance: Option<LndAmount>,
}
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
macaroon_hex: &str,
) -> Result<T> {
client
.get(url)
.header("Grpc-Metadata-macaroon", macaroon_hex)
.send()
.await
.context("LND is unavailable; balance could not be checked")?
.error_for_status()
.context("LND is not ready; balance could not be checked")?
.json()
.await
.context("LND returned invalid wallet data")
}
fn checked_balances(
wallet: LndBalanceResponse,
channels: LndChannelBalanceResponse,
) -> Result<(i64, i64, i64)> {
fn sats(value: Option<String>) -> Result<i64> {
let value = value.context("LND omitted a balance; balance is unavailable")?;
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
Ok(amount)
}
Ok((
sats(wallet.total_balance)?,
sats(channels.local_balance.and_then(|a| a.sat))?,
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
))
}
fn bitcoin_wait_state(
installed: bool,
running: bool,
fresh: bool,
ibd: Option<bool>,
) -> (&'static str, &'static str) {
if !installed {
("waiting_install", "Waiting for Bitcoin to be installed")
} else if !running {
("waiting_start", "Waiting for Bitcoin to start")
} else if !fresh || ibd.is_none() {
("waiting_start", "Waiting for Bitcoin to start")
} else if ibd == Some(true) {
("waiting_sync", "Waiting for Bitcoin to sync")
} else {
("bitcoin_ready", "Bitcoin is ready")
}
}
impl RpcHandler {
pub(crate) async fn handle_lnd_readiness(&self) -> serde_json::Value {
let (data, _) = self.state_manager.get_snapshot().await;
if !data.server_info.status_info.containers_scanned {
return serde_json::json!({"state":"checking", "message":"Checking Bitcoin availability"});
}
let nodes: Vec<_> = ["bitcoin-core", "bitcoin-knots", "bitcoin"]
.iter()
.filter_map(|id| data.package_data.get(*id))
.collect();
let installed = !nodes.is_empty();
let running = nodes
.iter()
.any(|p| p.state == crate::data_model::PackageState::Running);
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
let ibd = bitcoin
.blockchain_info
.as_ref()
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool());
let (state, message) =
bitcoin_wait_state(installed, running, bitcoin.ok && !bitcoin.stale, ibd);
serde_json::json!({"state": state, "message": message})
}
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
@@ -85,45 +164,26 @@ impl RpcHandler {
.build()
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?
.json()
.await
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
local_balance: None,
pending_open_local_balance: None,
}),
Err(_) => LndChannelBalanceResponse {
local_balance: None,
pending_open_local_balance: None,
},
};
let wallet_balance: LndBalanceResponse = match client
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
total_balance: None,
}),
Err(_) => LndBalanceResponse {
total_balance: None,
},
};
let get_info: LndGetInfoResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
&macaroon_hex,
)
.await?;
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
&macaroon_hex,
)
.await?;
let wallet_balance: LndBalanceResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
&macaroon_hex,
)
.await?;
let (balance_sats, channel_balance_sats, pending_open_balance) =
checked_balances(wallet_balance, channel_balance)?;
let (identity_pubkey, uris) = map_identity(&get_info);
@@ -135,18 +195,9 @@ impl RpcHandler {
num_peers: get_info.num_peers.unwrap_or(0),
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
block_height: get_info.block_height.unwrap_or(0),
balance_sats: wallet_balance
.total_balance
.and_then(|s| s.parse().ok())
.unwrap_or(0),
channel_balance_sats: channel_balance
.local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
pending_open_balance: channel_balance
.pending_open_local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
balance_sats,
channel_balance_sats,
pending_open_balance,
};
Ok(serde_json::to_value(info)?)
@@ -268,6 +319,76 @@ impl RpcHandler {
mod tests {
use super::*;
#[test]
fn unavailable_balances_are_not_zero() {
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
assert!(checked_balances(
serde_json::from_str(body).unwrap(),
serde_json::from_str(body).unwrap(),
)
.is_err());
}
for value in ["bad", "-1", "9223372036854775808"] {
let wallet = LndBalanceResponse {
total_balance: Some(value.into()),
};
let channels = serde_json::from_str(
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
)
.unwrap();
assert!(checked_balances(wallet, channels).is_err());
}
}
#[test]
fn verified_zero_and_nonzero_balances_survive() {
for expected in [0, 42] {
let wallet = LndBalanceResponse {
total_balance: Some(expected.to_string()),
};
let channels = serde_json::from_value(serde_json::json!({
"local_balance":{"sat":expected.to_string()},
"pending_open_local_balance":{"sat":"0"}
}))
.unwrap();
assert_eq!(
checked_balances(wallet, channels).unwrap(),
(expected, expected, 0)
);
}
}
#[tokio::test]
async fn locked_wallet_http_response_is_not_successful_getinfo() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
stream.read(&mut buf).await.unwrap();
let body =
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
stream.write_all(format!(
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
).as_bytes()).await.unwrap();
});
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
assert!(get_lnd_json::<LndGetInfoResponse>(
&client,
&format!("http://{addr}/v1/getinfo"),
"test"
)
.await
.is_err());
server.await.unwrap();
}
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
@@ -341,3 +462,44 @@ mod tests {
assert!(!is_valid_identity_pubkey(&"g".repeat(66)));
}
}
#[cfg(test)]
mod dependency_readiness_tests {
use super::bitcoin_wait_state;
#[test]
fn waiting_states_cover_install_start_sync_outage_and_recovery() {
assert_eq!(
bitcoin_wait_state(false, false, false, None).0,
"waiting_install"
);
assert_eq!(
bitcoin_wait_state(true, false, false, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, false, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(true)).0,
"waiting_sync"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(false)).0,
"bitcoin_ready"
);
// Previously synced cached information must not hide a current outage.
assert_eq!(
bitcoin_wait_state(true, true, false, Some(false)).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, None).0,
"waiting_start"
);
assert_eq!(
bitcoin_wait_state(true, true, true, Some(false)).0,
"bitcoin_ready"
);
}
}
+62 -1
View File
@@ -133,12 +133,36 @@ async fn stream_lnd_transactions(sm: &crate::state::StateManager) -> Result<()>
/// RPC-unreachable and locked-wallet states are deliberately NOT handled
/// here — container-down is crash-recovery's job, and unlocking needs the
/// operator.
fn bitcoin_ready_for_lnd_watchdog(status: &crate::bitcoin_status::BitcoinNodeStatus) -> bool {
status.ok
&& !status.stale
&& status.age_ms < 30_000
&& status
.blockchain_info
.as_ref()
.and_then(|v| v.get("initialblockdownload"))
.and_then(|v| v.as_bool())
== Some(false)
}
pub(crate) fn spawn_lnd_health_watchdog() {
tokio::spawn(async move {
let mut bad_minutes: u32 = 0;
let mut last_restart: Option<tokio::time::Instant> = None;
let mut last_height: Option<u64> = None;
loop {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
// Initial Bitcoin sync, warmup, and outages are dependencies to
// wait for, never evidence that LND is wedged. Do not accumulate
// restart pressure during a days-long initial block download.
let bitcoin = crate::bitcoin_status::get_bitcoin_status().await;
if !bitcoin_ready_for_lnd_watchdog(&bitcoin)
|| crate::app_ops::lifecycle_op_in_flight("lnd")
{
bad_minutes = 0;
last_height = None;
continue;
}
let Ok(bytes) = read_lnd_admin_macaroon().await else {
bad_minutes = 0; // no LND on this node (or not set up yet)
continue;
@@ -161,6 +185,10 @@ pub(crate) fn spawn_lnd_health_watchdog() {
bad_minutes = 0; // down/locked — not the wedge signature
continue;
};
if !resp.status().is_success() {
bad_minutes = 0;
continue;
}
let Ok(info) = resp.json::<serde_json::Value>().await else {
bad_minutes = 0;
continue;
@@ -182,7 +210,12 @@ pub(crate) fn spawn_lnd_health_watchdog() {
.get("num_pending_channels")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let wedged = !synced || (channels > 0 && peers == 0);
let height = info.get("block_height").and_then(|v| v.as_u64());
let progressing = height
.zip(last_height)
.is_some_and(|(now, before)| now > before);
last_height = height;
let wedged = !progressing && (!synced || (channels > 0 && peers == 0));
if !wedged {
bad_minutes = 0;
continue;
@@ -239,3 +272,31 @@ impl RpcHandler {
Ok((client, macaroon_hex))
}
}
#[cfg(test)]
mod watchdog_dependency_tests {
use super::bitcoin_ready_for_lnd_watchdog;
use crate::bitcoin_status::BitcoinNodeStatus;
use serde_json::json;
#[test]
fn initial_sync_warmup_outage_stale_and_unknown_never_trigger_lnd_restart() {
let mut status = BitcoinNodeStatus::default();
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.blockchain_info = Some(json!({"initialblockdownload":true}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.blockchain_info = Some(json!({"initialblockdownload":false}));
assert!(bitcoin_ready_for_lnd_watchdog(&status));
status.stale = true;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.stale = false;
status.ok = false;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.ok = true;
status.age_ms = 30_000;
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
status.age_ms = 0;
status.blockchain_info = Some(json!({}));
assert!(!bitcoin_ready_for_lnd_watchdog(&status));
}
}
@@ -55,6 +55,7 @@ impl RpcHandler {
.to_string();
super::validation::validate_app_id(&package_id)?;
super::dependencies::check_bitcoin_pruning_compatibility(&package_id).await?;
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle (prevents double-click
// queuing two installs on the same package).
@@ -88,6 +89,15 @@ impl RpcHandler {
match handler.handle_package_install(params).await {
Ok(_) => {
info!("package.install {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::clear_user_uninstalled(&handler.config.data_dir, id)
.await;
}
crate::crash_recovery::mark_installed(
&handler.config.data_dir,
&package_id_spawn,
)
.await;
// 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
@@ -183,17 +193,20 @@ impl RpcHandler {
// phase is cleared (None) so no stale InstallPhase
// lingers on the card.
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;
}
handler
.state_manager
.mutate_data(|data| {
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),
});
}
})
.await;
}
}
});
@@ -251,6 +264,11 @@ impl RpcHandler {
match handler.handle_package_uninstall(params).await {
Ok(_) => {
info!("package.uninstall {}: complete", package_id_spawn);
for id in [&package_id_spawn, &format!("archy-{}", package_id_spawn)] {
crate::crash_recovery::mark_user_uninstalled(&handler.config.data_dir, id)
.await;
crate::crash_recovery::clear_installed(&handler.config.data_dir, id).await;
}
// Inner handler already removed the package entry on
// success. Nothing more to do here.
}
@@ -294,6 +312,12 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?
.to_string();
super::validation::validate_app_id(&package_id)?;
// Update is stop → pull → remove → recreate, i.e. a fresh start by
// another name: on a disk that shrank since install it would resume
// cuprate's unprunable sync unchecked. Same gate as install and
// start, run BEFORE the Updating flip so a refusal leaves the app
// cleanly in its previous state.
super::dependencies::check_cuprate_disk_compatibility(&package_id).await?;
// Reject if already in a transitional lifecycle.
{
@@ -323,7 +347,7 @@ impl RpcHandler {
let package_id_spawn = package_id.clone();
tokio::spawn(async move {
match handler.handle_package_update(params).await {
Ok(_) => {
Ok(result) => {
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
@@ -338,7 +362,11 @@ impl RpcHandler {
set_package_state(
&handler.state_manager,
&package_id_spawn,
PackageState::Running,
if result.get("status").and_then(|v| v.as_str()) == Some("up-to-date") {
pre_state.clone().unwrap_or(PackageState::Running)
} else {
PackageState::Running
},
)
.await;
}
@@ -371,52 +399,56 @@ impl RpcHandler {
/// 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;
state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| PackageDataEntry {
ui_ready: None,
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.ui_ready = Some(false);
entry.state = PackageState::Installing;
})
.await;
}
/// True when the failed install still has a real footprint: any container
@@ -474,20 +506,23 @@ async fn remove_entry_with_notification(
id_prefix: &str,
message: &str,
) {
let (mut data, _) = handler.state_manager.get_snapshot().await;
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
handler.state_manager.update_data(data).await;
handler
.state_manager
.mutate_data(|data| {
data.package_data.remove(package_id);
data.notifications.push(crate::data_model::Notification {
id: format!("{id_prefix}-{package_id}"),
level: crate::data_model::NotificationLevel::Error,
title: format!("Could not install {package_id}"),
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
app_id: Some(package_id.to_string()),
});
while data.notifications.len() > 20 {
data.notifications.remove(0);
}
})
.await;
}
/// Flip an existing entry's state and return the pre-flip value (or None if
@@ -497,18 +532,23 @@ async fn flip_package_state(
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
state_manager
.mutate_data(|data| {
let prev = data.package_data.get(package_id).map(|e| e.state.clone());
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
} else {
warn!(
"flip_package_state: no entry for {} — cannot flip",
package_id
);
}
prev
})
.await
}
/// Set state unconditionally (no-op if entry no longer exists).
@@ -517,13 +557,18 @@ async fn set_package_state(
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;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
/// Set state and clear the uninstall_stage label. Used when an uninstall
@@ -534,12 +579,17 @@ async fn set_package_state_and_clear_uninstall_stage(
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;
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
entry.uninstall_stage = None;
}
})
.await
}
/// Kick the container scanner to run immediately and wait for it to finish
@@ -22,6 +22,18 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
/// hardcoded id list below — a new app just declares the dependency instead
/// of needing a code change here.
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
// Registry-only apps need the same guard as OTA-bundled manifests. Honor
// the verified catalog's effective manifest before the disk fallback.
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
.into_iter()
.find(|(id, _)| id == package_id)
{
if let Some(manifest) =
crate::container::app_catalog::catalog_manifest_overlay(package_id, value)
{
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
}
}
for apps_dir in manifest_apps_dirs() {
let path = apps_dir.join(package_id).join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
@@ -670,6 +682,50 @@ async fn detect_disk_gb() -> u64 {
.unwrap_or(u64::MAX)
}
/// Smallest disk (GB, total) a cuprate node can live on. The value and its
/// rationale live in ONE place — `crate::constants::CUPRATE_MIN_DISK_GB` —
/// shared with the boot reconciler so install/start and boot can never
/// disagree about where cuprate may run.
use crate::constants::CUPRATE_MIN_DISK_GB;
/// The bitcoin apps pick `-prune` automatically when disk is scarce, because
/// bitcoind supports pruning. Cuprate CANNOT: upstream has no pruning config
/// at all (the `pruning` crate in its workspace is Monero's p2p *protocol*
/// pruning, not on-disk pruning), so the disk-scarce equivalent is to refuse
/// to run cuprate at all rather than let it sync until the filesystem fills —
/// which took Archipelago itself down on nodes with too little disk.
fn cuprate_insufficient_disk_message(disk_gb: u64) -> String {
format!(
"Cuprate needs a disk of at least {} GB and this node has {} GB. \
A Monero node cannot run pruned — upstream cuprate has no pruning \
support — so the chain (~250 GB and growing) would fill the disk and \
take Archipelago down with it. Attach a larger disk (or move \
/var/lib/archipelago to one) and try again. Bitcoin apps CAN run \
pruned on smaller disks; Monero currently cannot.",
CUPRATE_MIN_DISK_GB, disk_gb
)
}
/// Pure decision half of the cuprate disk gate — testable without df.
pub(super) fn cuprate_disk_gate(disk_gb: u64) -> Option<String> {
(disk_gb < CUPRATE_MIN_DISK_GB).then(|| cuprate_insufficient_disk_message(disk_gb))
}
/// Install/start-time pre-check: refuse cuprate on disks too small to hold
/// the Monero chain. Mirrors `check_bitcoin_pruning_compatibility`'s
/// fail-open-on-unknown-disk behaviour (`detect_disk_gb` returns u64::MAX
/// when df fails, so an unreadable disk never blocks an install).
pub(super) async fn check_cuprate_disk_compatibility(package_id: &str) -> Result<()> {
if package_id != "cuprate" {
return Ok(());
}
let disk_gb = detect_disk_gb().await;
if let Some(message) = cuprate_disk_gate(disk_gb) {
anyhow::bail!(message);
}
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 {
@@ -873,9 +929,9 @@ pub(super) fn configure_fedimint_lnd(
#[cfg(test)]
mod tests {
use super::{
bitcoin_is_warming_up, dependency_list_declares_archival_bitcoin,
bitcoin_is_warming_up, cuprate_disk_gate, dependency_list_declares_archival_bitcoin,
manifest_declares_archival_bitcoin, order_present_containers, requires_unpruned_bitcoin,
startup_order, BITCOIN_WARMUP_BUDGET,
startup_order, BITCOIN_WARMUP_BUDGET, CUPRATE_MIN_DISK_GB,
};
use archipelago_container::Dependency;
@@ -1011,12 +1067,51 @@ mod tests {
// edit to `requires_unpruned_bitcoin`.
assert!(manifest_declares_archival_bitcoin("electrumx"));
assert!(manifest_declares_archival_bitcoin("mempool"));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../apps/angor-indexer/manifest.yml"
)))
.unwrap();
assert!(dependency_list_declares_archival_bitcoin(
&angor.app.dependencies
));
// An app whose manifest exists but never declares the marker.
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
// An id with no manifest on disk at all.
assert!(!manifest_declares_archival_bitcoin("does-not-exist"));
}
#[test]
fn cuprate_disk_gate_refuses_disks_too_small_for_the_monero_chain() {
// 250 GB VPS class: the ~250 GiB chain does not fit, full stop.
assert!(cuprate_disk_gate(0).is_some());
assert!(cuprate_disk_gate(250).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB - 1).is_some());
assert!(cuprate_disk_gate(CUPRATE_MIN_DISK_GB).is_none());
assert!(cuprate_disk_gate(1000).is_none());
// df failure reads as u64::MAX — an unreadable disk must not block.
assert!(cuprate_disk_gate(u64::MAX).is_none());
}
#[test]
fn cuprate_disk_gate_message_names_the_fix_not_just_the_problem() {
let msg = cuprate_disk_gate(250).expect("250 GB must be refused");
assert!(msg.contains("cannot run pruned"), "{msg}");
assert!(msg.contains("larger disk"), "{msg}");
assert!(msg.contains("250 GB"), "{msg}");
}
#[tokio::test]
async fn cuprate_disk_gate_only_applies_to_cuprate() {
// Every other package passes regardless of disk — including the
// bitcoin apps, which self-prune via their manifest entrypoint.
for package_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
super::check_cuprate_disk_compatibility(package_id)
.await
.expect("non-cuprate installs must not be gated here");
}
}
mod dep_wait {
use super::super::{wait_for_install_deps, DepProbe, DependencyGateError, RunningDeps};
use std::sync::atomic::{AtomicU32, Ordering};
+23 -136
View File
@@ -3,10 +3,10 @@ use super::config::{
is_readonly_compatible, is_valid_docker_image,
};
use super::dependencies::{
check_bitcoin_pruning_compatibility, configure_fedimint_lnd, detect_existing_containers,
detect_running_deps, detect_running_deps_from_package_data, log_optional_dep_info,
needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps, DEP_WAIT_INTERVAL,
DEP_WAIT_MAX_ATTEMPTS,
check_bitcoin_pruning_compatibility, check_cuprate_disk_compatibility, configure_fedimint_lnd,
detect_existing_containers, detect_running_deps, detect_running_deps_from_package_data,
log_optional_dep_info, needs_archy_net, wait_for_install_deps, DepProbe, RunningDeps,
DEP_WAIT_INTERVAL, DEP_WAIT_MAX_ATTEMPTS,
};
use super::progress::parse_pull_progress;
use super::validation::validate_app_id;
@@ -326,6 +326,10 @@ impl RpcHandler {
// an older version pins it so install_fresh resolves that image and the
// update badge stays suppressed. See docs/bitcoin-multi-version-design.md.
if matches!(package_id, "bitcoin-core" | "bitcoin-knots") {
if let Some(value) = params.get("prune") {
let prune = value.as_bool().context("prune must be a boolean")?;
crate::settings::bitcoin_storage::save(&self.config.data_dir, prune).await?;
}
if let Some(version) = params.get("version").and_then(|v| v.as_str()) {
persist_install_version_selection(package_id, version).await;
}
@@ -374,6 +378,7 @@ impl RpcHandler {
// failing instantly.
let deps = self.gate_install_deps(package_id).await?;
check_bitcoin_pruning_compatibility(package_id).await?;
check_cuprate_disk_compatibility(package_id).await?;
log_optional_dep_info(package_id, &deps);
if matches!(package_id, "bitcoin" | "bitcoin-core" | "bitcoin-knots") {
// Materialise the RPC password file before any install path
@@ -540,7 +545,7 @@ impl RpcHandler {
// Keep legacy install flow as default while migration is in progress.
if orchestrator_managed {
let orchestrator_app_id = orchestrator_install_app_id(package_id);
self.set_install_phase(package_id, InstallPhase::CreatingContainer)
self.set_install_phase(package_id, InstallPhase::PreparingApp)
.await;
install_log(&format!(
"INSTALL ORCH: {} — attempting orchestrator install as {}",
@@ -568,6 +573,9 @@ impl RpcHandler {
"message": format!("Package {} installed and started", package_id)
}));
}
Err(e) if e.downcast_ref::<crate::container::prod_orchestrator::InstallPrerequisiteError>().is_some() => {
return Err(super::dependencies::DependencyGateError(e.to_string()).into());
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
@@ -1694,32 +1702,10 @@ autopilot.active=false\n",
patch_indeedhub_nostr_provider().await;
}
// Gitea: keep it on its native host port (3001). The UI opens Gitea
// in a new tab on that direct port so absolute asset URLs must be
// rooted at the host port rather than Archipelago's /app/gitea/ path.
if package_id == "gitea" {
let _ = tokio::fs::remove_file("/etc/nginx/conf.d/gitea-iframe.conf").await;
// Set ROOT_URL to the direct launch route so links/assets stay
// anchored under the same origin Gitea is launched from.
let host_ip = &self.config.host_ip;
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
&format!("grep -q ROOT_URL /data/gitea/conf/app.ini && sed -i 's|ROOT_URL.*|ROOT_URL = http://{}:3001/|' /data/gitea/conf/app.ini || true", host_ip)])
.output()
.await;
// Also ensure X_FRAME_OPTIONS is empty so Gitea doesn't send the header
let _ = tokio::process::Command::new("podman")
.args(["exec", "gitea", "sh", "-c",
"grep -q X_FRAME_OPTIONS /data/gitea/conf/app.ini && sed -i 's|X_FRAME_OPTIONS.*|X_FRAME_OPTIONS =|' /data/gitea/conf/app.ini || sed -i '/^\\[security\\]/a X_FRAME_OPTIONS =' /data/gitea/conf/app.ini"])
.output()
.await;
info!(
"Gitea: ROOT_URL set to http://{}:3001/, X_FRAME_OPTIONS cleared",
host_ip
);
}
// Gitea owns its public URL and security settings in app.ini, including
// values chosen in its first-run setup. Do not rewrite operator values
// or claim success from best-effort grep/sed commands. The app gate
// fronts its declared HTTP port and handles frame headers separately.
if package_id == "nextcloud" {
let host_ip = &self.config.host_ip;
@@ -2048,25 +2034,8 @@ fn parse_setup_token(lines: &[&str]) -> Option<String> {
}
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
"homeassistant" | "home-assistant" => cleanup_stale_pasta_port("8123").await,
"searxng" => cleanup_stale_pasta_port("8888").await,
"uptime-kuma" => cleanup_stale_pasta_port("3002").await,
"gitea" => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"portainer" => cleanup_stale_pasta_port("9000").await,
_ => {}
}
// Never kill by port: another app or the management gate may own it.
crate::container::ghost_reaper::reap_for_app(package_id).await;
}
fn install_command_tail(
@@ -2191,93 +2160,11 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
return true;
}
match package_id {
"grafana"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3000").await;
true
}
"homeassistant" | "home-assistant"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8123").await;
true
}
"searxng"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8888").await;
true
}
"uptime-kuma"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("3002").await;
true
}
"gitea" if stderr.contains("pasta failed") || stderr.contains("address already in use") => {
cleanup_stale_pasta_port("3001").await;
cleanup_stale_pasta_port("2222").await;
cleanup_stale_pasta_port("3000").await;
true
}
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
}
"nextcloud"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("8085").await;
true
}
"portainer"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("9000").await;
true
}
_ => false,
if stderr.contains("pasta failed") || stderr.contains("address already in use") {
crate::container::ghost_reaper::reap_for_app(package_id).await;
return true;
}
}
async fn cleanup_stale_pasta_port(port: &str) {
// NEVER kill our own process. The daemon holds catalog app ports over
// IPv6 (the mesh app-port relay), so a blunt `fuser -k <port>/tcp` would
// terminate archipelago itself mid-install — installs failed and apps
// vanished on a test node 2026-07-27. Kill every listener on the port
// EXCEPT our PID (and our process group), leaving the relay/daemon alive.
let self_pid = std::process::id();
let kill_listener = format!(
"ss -ltnp 'sport = :{port}' 2>/dev/null | sed -n 's/.*pid=\\([0-9]*\\).*/\\1/p' | \
while read p; do [ \"$p\" = \"{self_pid}\" ] || kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &kill_listener])
.output()
.await;
// sudo fuser -k, but exclude our own PID: fuser prints the PIDs holding
// the port; kill each except self. (`fuser -k` has no exclusion flag.)
let fuser_kill = format!(
"for p in $(sudo fuser {port}/tcp 2>/dev/null); do [ \"$p\" = \"{self_pid}\" ] || sudo kill \"$p\" 2>/dev/null; done || true",
);
let _ = tokio::process::Command::new("sh")
.args(["-c", &fuser_kill])
.output()
.await;
let pattern = format!("pasta.*{}", port);
let _ = tokio::process::Command::new("pkill")
.args(["-f", &pattern])
.output()
.await;
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
false
}
async fn repair_nextcloud_permissions() {
@@ -14,20 +14,23 @@ impl RpcHandler {
/// 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
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.state = PackageState::Installing;
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;
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
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,
});
})
.await;
}
/// Set the install pipeline phase and broadcast. This is the
@@ -35,76 +38,86 @@ impl RpcHandler {
/// 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;
self.state_manager
.mutate_data(|data| {
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.ui_ready = Some(false);
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,
});
})
.await;
}
/// Set a user-facing install status message (e.g. "Waiting for Bitcoin
/// to start…") without disturbing the current phase/byte counters.
pub(super) async fn set_install_message(&self, package_id: &str, message: &str) {
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));
if entry.state != PackageState::Updating {
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
let entry = data
.package_data
.entry(package_id.to_string())
.or_insert_with(|| create_installing_entry(package_id));
if entry.state != PackageState::Updating {
entry.ui_ready = Some(false);
entry.state = PackageState::Installing;
}
let (size, downloaded, phase) = entry
.install_progress
.as_ref()
.map(|p| (p.size, p.downloaded, p.phase))
.unwrap_or((0, 0, None));
entry.install_progress = Some(InstallProgress {
size,
downloaded,
phase,
message: Some(message.to_string()),
});
})
.await;
}
/// Clear install progress after pull completes or fails.
pub(super) async fn clear_install_progress(&self, package_id: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.install_progress = None;
}
})
.await;
}
/// Set the uninstall stage label so the UI can show what's happening
/// instead of a generic spinner. Each call broadcasts a state change
/// — call sparingly (one per pipeline phase, not per container).
pub(super) async fn set_uninstall_stage(&self, package_id: &str, stage: &str) {
let (mut data, _rev) = self.state_manager.get_snapshot().await;
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
self.state_manager.update_data(data).await;
self.state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
entry.uninstall_stage = Some(stage.to_string());
entry.state = crate::data_model::PackageState::Removing;
}
})
.await;
}
/// Update install progress (static method for use in async closures).
@@ -114,25 +127,28 @@ impl RpcHandler {
downloaded: u64,
total: u64,
) {
let (mut data, _rev) = state_manager.get_snapshot().await;
let entry = data
.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;
state_manager
.mutate_data(|data| {
let entry = data
.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,
});
})
.await;
}
}
/// Create a minimal PackageDataEntry for a package being installed.
fn create_installing_entry(package_id: &str) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state: PackageState::Installing,
health: None,
exit_code: None,
+57 -64
View File
@@ -60,6 +60,12 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// A cuprate node that starts on a too-small disk fills it and takes
// Archipelago down with it (no upstream pruning — see
// dependencies::check_cuprate_disk_compatibility). Fail the start
// before clearing user-stopped or flipping state, so the app stays
// cleanly stopped and the error carries the actionable message.
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let to_start = if self.orchestrator.is_some() && uses_single_orchestrator_app(package_id) {
vec![orchestrator_app_id(package_id).to_string()]
@@ -251,6 +257,11 @@ impl RpcHandler {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Restart is stop + recreate, so on a disk that shrank below the cuprate
// minimum after install it resumes the doomed unprunable sync just like
// start would — same gate, same "fail before clearing user-stopped /
// flipping state" contract (see handle_package_start).
super::dependencies::check_cuprate_disk_compatibility(package_id).await?;
let single_orchestrator_app =
self.orchestrator.is_some() && uses_single_orchestrator_app(package_id);
@@ -1420,10 +1431,9 @@ async fn repair_before_package_start(container_name: &str) {
// published port and the data-dir file locks, so the replacement either
// fails to bind (`address already in use`) or starts and dies on the
// lock — and `Restart=always` loops it there forever. Ordered before
// the port cleanup below: killing the owner is what actually frees the
// port, and the port sweep alone cannot tell a ghost from a live app.
// starting the replacement. A port sweep cannot distinguish a ghost
// from the dashboard gate or another live app and must never kill it.
crate::container::ghost_reaper::reap_for_app(container_name).await;
cleanup_runtime_host_ports(container_name).await;
}
async fn wait_before_package_start(container_name: &str) {
@@ -1568,7 +1578,6 @@ async fn repair_netbird_network() {
async fn repair_nginx_proxy_manager_container() {
repair_nginx_proxy_manager_dirs().await;
if !nginx_proxy_manager_has_legacy_admin_port().await {
cleanup_nginx_proxy_manager_ports().await;
return;
}
@@ -1577,7 +1586,7 @@ async fn repair_nginx_proxy_manager_container() {
)
.await;
let _ = podman_control(&["rm", "-f", "nginx-proxy-manager"]).await;
cleanup_nginx_proxy_manager_ports().await;
crate::container::ghost_reaper::reap_for_app("nginx-proxy-manager").await;
if let Err(err) = recreate_nginx_proxy_manager_container().await {
tracing::warn!(error = %err, "failed to recreate stale nginx-proxy-manager container");
}
@@ -1801,6 +1810,9 @@ fn manifest_host_ports(container_name: &str) -> Vec<u16> {
pub(super) fn manifest_apps_dirs() -> Vec<std::path::PathBuf> {
let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(Path::new(&manifest_dir).join("../../apps"));
}
@@ -2021,51 +2033,10 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
return;
}
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
return;
}
}
async fn cleanup_runtime_host_ports(container_name: &str) {
let ports = runtime_host_ports(container_name);
if !ports.is_empty() {
cleanup_ports(&ports).await;
}
}
async fn cleanup_nginx_proxy_manager_ports() {
cleanup_ports(&[8081, 8084, 8444]).await;
}
async fn cleanup_ports(ports: &[u16]) {
for port in ports {
cleanup_stale_pasta_port(&port.to_string()).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;
// Only reap processes proven to belong to an absent container. The app
// gate shares the app's port on other addresses and lives in this daemon;
// killing port owners (or matching argv with pkill) kills the dashboard.
crate::container::ghost_reaper::reap_for_app(container_name).await;
}
pub(super) fn is_missing_companion_ok(name: &str, stderr: &str) -> bool {
@@ -2084,13 +2055,16 @@ async fn flip_package_state(
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
state_manager
.mutate_data(|data| {
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.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
}
/// Write the package entry's final state. No-op if the entry has since
@@ -2100,13 +2074,18 @@ async fn set_package_state(
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;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(package_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
pub(super) async fn reconcile_companions_for(package_id: &str) {
@@ -2174,6 +2153,20 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec<String> {
mod tests {
use super::*;
#[tokio::test]
async fn port_conflict_cleanup_preserves_live_host_listener() {
// The previous ss|kill sweep terminated the daemon's app gate on a
// restart. Keep a real listening socket owned by this test process.
let listener = tokio::net::TcpListener::bind("127.0.0.2:2342")
.await
.unwrap();
let addr = listener.local_addr().unwrap();
cleanup_start_conflict("photoprism", "address already in use").await;
let client = tokio::net::TcpStream::connect(addr).await.unwrap();
let _connection = listener.accept().await.unwrap();
drop(client);
}
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// Regression (.228 gate 2026-07-08): podman 5.x `inspect` on a missing
@@ -153,8 +153,18 @@ impl RpcHandler {
let default = app_catalog::catalog_default_version(app_id);
let cfg = version_config::read(app_id);
let installed = installed_version(app_id).await;
let bitcoin_prune = if matches!(app_id, "bitcoin-core" | "bitcoin-knots") {
Some(
crate::settings::bitcoin_storage::load(&self.config.data_dir)
.await?
.prune,
)
} else {
None
};
Ok(serde_json::json!({
"bitcoinPrune": bitcoin_prune,
"id": app_id,
"supportsVersions": supports_versions(app_id),
"default": default,
+232 -25
View File
@@ -19,7 +19,7 @@ use tracing::{error, info, warn};
const PODMAN_UPDATE_PULL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
impl RpcHandler {
/// Update a package to the version pinned in image-versions.sh.
/// Update a package to the freshly verified catalog target.
/// This is a manual operation — the user clicks "Update" in the UI.
pub(in crate::api::rpc) async fn handle_package_update(
&self,
@@ -32,6 +32,21 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// An Update click must not act on an hourly cache that predates the
// button. Fetch and verify first; failure leaves running containers alone.
crate::container::app_catalog::refresh_catalog(&self.config.data_dir)
.await
.context(
"Cannot check the signed app catalog; update cancelled before changing containers",
)?;
if let Some(orch) = &self.orchestrator {
// Reload even when bytes did not change: a previous reload may have
// failed after the cache was written, or another refresher wrote it.
orch.reload_manifests()
.await
.context("Cannot load current app manifests; update cancelled")?;
}
// Resolve the target image. Prefer the remote app catalog (decoupled
// from the binary OTA), falling back to the image-versions.sh pin. This
// is OPTIONAL for orchestrator-managed apps: the orchestrator resolves
@@ -42,6 +57,22 @@ impl RpcHandler {
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id));
let targets = pinned
.as_ref()
.map(|target| self.resolve_images_to_pull(package_id, target));
if let Some(targets) = &targets {
let installed = inspect_update_images(package_id).await?;
if !update_targets_need_change(targets, &installed)? {
install_log(&format!(
"UPDATE SKIP: {} — target versions already installed",
package_id
))
.await;
self.clear_install_progress(package_id).await;
return Ok(serde_json::json!({"status": "up-to-date", "package_id": 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
@@ -80,6 +111,12 @@ impl RpcHandler {
if let Some(orchestrator) = self.orchestrator.as_ref() {
match orchestrator.upgrade(orchestrator_app_id).await {
Ok(()) => {
if let Some(targets) = &targets {
verify_update_targets(
targets,
&inspect_update_images(package_id).await?,
)?;
}
self.set_install_phase(package_id, InstallPhase::WaitingHealthy)
.await;
if let Ok(health) = orchestrator.health(orchestrator_app_id).await {
@@ -133,7 +170,8 @@ impl RpcHandler {
};
// Resolve images to pull — either a stack or single container
let images_to_pull = self.resolve_images_to_pull(package_id, &pinned);
let images_to_pull =
targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned));
// Get all containers for this app
let containers = get_containers_for_app(package_id).await?;
@@ -324,15 +362,22 @@ impl RpcHandler {
.await;
if let Ok(o) = status {
let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
if state == "exited" {
warn!(
"Update {}: container {} exited after recreate",
package_id, name
);
}
anyhow::ensure!(
o.status.success() && state == "running",
"Update {}: container {} is not running after recreate",
package_id,
name
);
} else {
anyhow::bail!(
"Update {}: cannot inspect recreated container {}",
package_id,
name
);
}
}
verify_update_targets(images_to_pull, &inspect_update_images(package_id).await?)?;
Ok(())
}
@@ -514,6 +559,98 @@ impl RpcHandler {
}
}
async fn inspect_update_images(package_id: &str) -> Result<Vec<(String, String)>> {
let containers = get_containers_for_app(package_id).await?;
anyhow::ensure!(
!containers.is_empty(),
"No containers found for {}",
package_id
);
let mut command = tokio::process::Command::new("podman");
command.arg("inspect").args(&containers).kill_on_drop(true);
let output = tokio::time::timeout(std::time::Duration::from_secs(30), command.output())
.await
.context("Timed out checking installed images")??;
anyhow::ensure!(
output.status.success(),
"Cannot inspect installed images; update cancelled"
);
let inspected: Vec<serde_json::Value> = serde_json::from_slice(&output.stdout)?;
inspected
.iter()
.map(|entry| {
let name = entry
.get("Name")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted Name"))?;
let image = entry
.get("ImageName")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Container inspection omitted ImageName"))?;
Ok((name.trim_start_matches('/').to_string(), image.to_string()))
})
.collect()
}
fn installed_image_for_target<'a>(
app_id: &str,
installed: &'a [(String, String)],
) -> Option<&'a str> {
installed
.iter()
.find(|(name, _)| {
candidate_app_ids_for_container(name)
.iter()
.any(|id| id == app_id)
})
.map(|(_, image)| image.as_str())
}
/// A successful recreate is not proof that it used the downloaded image.
fn verify_update_targets(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<()> {
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed).ok_or_else(|| {
anyhow::anyhow!("Update {}: target container missing after recreate", app_id)
})?;
anyhow::ensure!(
image_versions::extract_version_from_image(target)
== image_versions::extract_version_from_image(running)
|| image_versions::compare_image_versions(target, running)
== Some(std::cmp::Ordering::Equal),
"Update {}: recreated container did not reach target version {}",
app_id,
image_versions::extract_version_from_image(target)
);
}
Ok(())
}
/// Check every stack component, not just the version shown on its tile. A
/// newer backend must still update when its frontend version is unchanged.
/// A stale target for any component cancels before pulling or stopping anything.
fn update_targets_need_change(
targets: &[(String, String)],
installed: &[(String, String)],
) -> Result<bool> {
use std::cmp::Ordering;
let mut changed = false;
for (app_id, target) in targets {
let running = installed_image_for_target(app_id, installed);
match running.and_then(|image| image_versions::compare_image_versions(target, image)) {
Some(Ordering::Less) => anyhow::bail!(
"Catalog target for {} is older than the installed image; refusing downgrade",
app_id
),
Some(Ordering::Equal) => {}
Some(Ordering::Greater) | None => changed = true,
}
}
Ok(changed)
}
fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool) -> bool {
orchestrator_available && !uses_legacy_update_flow(package_id)
}
@@ -526,11 +663,14 @@ fn orchestrator_update_app_id(package_id: &str) -> &str {
}
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"
)
// A primary container already at its target does not mean its backend or
// database is current. Route every mapped stack through the component flow.
!image_versions::containers_for_stack(package_id).is_empty()
|| 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 {
@@ -554,7 +694,12 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
"archy-bitcoin-ui" => push("bitcoin-ui"),
"archy-lnd-ui" => push("lnd-ui"),
"archy-electrs-ui" => push("electrs-ui"),
"mempool" => {
"mysql-mempool" => push("archy-mempool-db"),
"btcpay" | "btcpayserver" | "archy-btcpay" => push("btcpay-server"),
"homeassistant" | "archy-homeassistant" => push("home-assistant"),
"fedimintd" => push("fedimint"),
"electrs" | "mempool-electrs" => push("electrumx"),
"mempool" | "mempool-web" => {
push("archy-mempool-web");
push("mempool");
}
@@ -572,27 +717,89 @@ fn candidate_app_ids_for_container(container_name: &str) -> Vec<String> {
mod tests {
use super::{
candidate_app_ids_for_container, orchestrator_update_app_id,
should_try_orchestrator_update, uses_legacy_update_flow,
should_try_orchestrator_update, update_targets_need_change, uses_legacy_update_flow,
verify_update_targets,
};
#[test]
fn mempool_update_preflight_rejects_stale_catalog_without_reinstalling() {
let installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into(),
)];
let stale = vec![(
"archy-mempool-web".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(update_targets_need_change(&stale, &installed).is_err());
let current = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &installed).unwrap());
let legacy = vec![(
"mempool-web".into(),
"r.test/old/mempool-frontend:v3.3.1-archy1".into(),
)];
assert!(!update_targets_need_change(&current, &legacy).unwrap());
let newer = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy2".into(),
)];
assert!(update_targets_need_change(&newer, &installed).unwrap());
}
#[test]
fn stack_update_checks_backend_even_when_frontend_matches() {
let installed = vec![
("mempool".into(), "r.test/team/web:3.3.1-archy1".into()),
("mempool-api".into(), "r.test/team/api:3.3.1".into()),
];
let mut targets = vec![
(
"archy-mempool-web".into(),
"r.test/team/web:3.3.1-archy1".into(),
),
("mempool-api".into(), "r.test/team/api:3.3.2".into()),
];
assert!(update_targets_need_change(&targets, &installed).unwrap());
targets[0].1 = "r.test/team/web:3.3.1".into();
assert!(update_targets_need_change(&targets, &installed).is_err());
}
#[test]
fn update_completion_requires_the_target_version_to_be_installed() {
let targets = vec![(
"archy-mempool-web".into(),
"r.test/chaum/mempool-frontend:v3.3.1-archy1".into(),
)];
let mut installed = vec![(
"mempool".into(),
"r.test/lfg2025/mempool-frontend:v3.3.1".into(),
)];
assert!(verify_update_targets(&targets, &installed).is_err());
assert!(verify_update_targets(&targets, &[]).is_err());
installed[0].1 = "r.test/lfg2025/mempool-frontend:v3.3.1-archy1".into();
assert!(verify_update_targets(&targets, &installed).is_ok());
}
#[test]
fn legacy_flow_for_stack_apps() {
for app in ["immich", "penpot", "indeedhub"] {
for app in [
"immich",
"penpot",
"indeedhub",
"mempool",
"btcpay-server",
"netbird",
] {
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",
] {
for app in ["lnd", "bitcoin-core", "searxng", "grafana", "fedimint"] {
assert!(
!uses_legacy_update_flow(app),
"{app} should be orchestrator-first"
+19 -2
View File
@@ -377,6 +377,23 @@ async fn write_staged_torrc(content: &str, staging: &str) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod known_service_tests {
use super::{is_protocol_service, known_service_port};
#[test]
fn bitcoin_core_is_a_protocol_service_on_the_p2p_port() {
// Regression: apps/bitcoin-core/manifest.yml uses id "bitcoin-core",
// distinct from the legacy "bitcoin"/"bitcoin-knots" ids. Missing
// here means auto-enrollment silently skips it (known_service_port
// returns 0) and, separately, regenerate_torrc falls back to the
// web-app HiddenServicePort-80 default instead of forwarding 8333
// straight through.
assert_eq!(known_service_port("bitcoin-core"), 8333);
assert!(is_protocol_service("bitcoin-core"));
}
}
#[cfg(test)]
mod torrc_tests {
use super::app_hidden_service_port_line;
@@ -594,7 +611,7 @@ fn is_valid_v3_onion(s: &str) -> bool {
pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
match name {
"archipelago" => 80,
"bitcoin" | "bitcoin-knots" => 8333,
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => 8333,
"electrs" | "electrumx" => 50001,
"lnd" => 8080,
"btcpay" | "btcpay-server" | "btcpayserver" => 23000,
@@ -619,7 +636,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
pub(in crate::api::rpc) fn is_protocol_service(name: &str) -> bool {
matches!(
name,
"bitcoin" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
"bitcoin" | "bitcoin-core" | "bitcoin-knots" | "electrs" | "electrumx" | "lnd"
)
}
+22 -14
View File
@@ -150,23 +150,31 @@ async fn flip_to_transitional(
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
state_manager
.mutate_data(|data| {
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.ui_ready = Some(false);
entry.state = transitional;
}
prev
})
.await
}
/// 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;
}
}
state_manager
.mutate_data(|data| {
if let Some(entry) = data.package_data.get_mut(app_id) {
if entry.state != new_state {
if new_state != PackageState::Running {
entry.ui_ready = Some(false);
}
entry.state = new_state;
}
}
})
.await
}
+3
View File
@@ -114,6 +114,9 @@ impl PortMap {
/// there.
fn apps_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(root) = std::env::var_os("ARCHIPELAGO_APPS_DIR") {
dirs.push(root.into());
}
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
dirs.push(PathBuf::from(manifest_dir).join("../../apps"));
}
+48 -3
View File
@@ -144,6 +144,34 @@ pub fn shared_status() -> Arc<RwLock<GateStatus>> {
.clone()
}
static REFRESH_KICK: std::sync::LazyLock<tokio::sync::Notify> =
std::sync::LazyLock::new(tokio::sync::Notify::new);
static REFRESH_REV: std::sync::LazyLock<tokio::sync::watch::Sender<u64>> =
std::sync::LazyLock::new(|| tokio::sync::watch::channel(0).0);
/// Installation must not wait for the minute sweep before becoming reachable.
/// Wait for a completed sweep, bounded if shutdown/startup prevents one.
pub async fn refresh_now() {
let mut completed = REFRESH_REV.subscribe();
REFRESH_KICK.notify_one();
let _ = tokio::time::timeout(std::time::Duration::from_secs(3), completed.changed()).await;
}
pub fn port_claimed(status: &GateStatus, port: u16) -> bool {
let mut external = false;
let mut tor = false;
for (claimed_port, address) in &status.claimed {
if *claimed_port != port {
continue;
}
if let Ok(ip) = address.parse::<IpAddr>() {
tor |= ip == GATE_TOR_UPSTREAM;
external |= !ip.is_loopback();
}
}
external && tor
}
/// Run the gate. Returns only on shutdown.
pub async fn run(
gate: Arc<AppGate>,
@@ -162,11 +190,12 @@ pub async fn run(
loop {
tokio::select! {
_ = interval.tick() => {
sweep(&gate, &status, &mut held, &shutdown_rx).await;
}
_ = interval.tick() => {}
_ = REFRESH_KICK.notified() => {}
_ = shutdown_rx.changed() => return,
}
sweep(&gate, &status, &mut held, &shutdown_rx).await;
REFRESH_REV.send_modify(|revision| *revision = revision.wrapping_add(1));
}
}
@@ -461,3 +490,19 @@ mod tests {
assert!(!status.is_fully_enforced());
}
}
#[cfg(test)]
mod readiness_tests {
use super::*;
#[test]
fn readiness_requires_external_and_tor_claims_for_the_same_port() {
let mut status = GateStatus::default();
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "127.0.0.2".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3002, "192.0.2.10".into()));
assert!(!port_claimed(&status, 3001));
status.claimed.push((3001, "192.0.2.10".into()));
assert!(port_claimed(&status, 3001));
}
}
+1
View File
@@ -322,6 +322,7 @@ async fn eval_rpc_handler() -> (Arc<RpcHandler>, tempfile::TempDir) {
fn installed_entry(app_id: &str) -> crate::data_model::PackageDataEntry {
use crate::data_model::{Description, Manifest, PackageDataEntry, PackageState, StaticFiles};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
+1
View File
@@ -1069,6 +1069,7 @@ mod tests {
Description, Manifest, PackageDataEntry, PackageState, StaticFiles,
};
PackageDataEntry {
ui_ready: None,
state: PackageState::Running,
health: None,
exit_code: None,
+23 -1
View File
@@ -100,7 +100,11 @@ fn friendly_transient_error(has_cached_state: bool, err_msg: &str) -> String {
.trim()
.trim_end_matches('.');
let lower = detail.to_lowercase();
let state = if lower.contains("verifying blocks") {
let state = if lower.contains("loading block index") {
Some("loading its block index. This can take a while after installation or restart")
} else if lower.contains("replaying blocks") {
Some("checking saved blocks before startup completes")
} else if lower.contains("verifying blocks") {
Some("verifying blocks after restart")
} else if lower.contains("connection reset") {
Some("starting up and not yet accepting RPC connections")
@@ -340,3 +344,21 @@ mod tests {
assert!(msg.len() < 260);
}
}
#[cfg(test)]
mod startup_message_tests {
#[test]
fn loading_block_index_is_explained_without_rpc_error_dump() {
for cached in [false, true] {
let message = super::friendly_transient_error(
cached,
r#"getblockchaininfo: Bitcoin RPC returned 500 Internal Server Error: {"error":{"code":-28,"message":"Loading block index…"}}"#,
);
assert!(message.contains("loading its block index"));
for raw in ["500", "-28", "Detail:", "getblockchaininfo", "{", "RPC"] {
assert!(!message.contains(raw));
}
assert_eq!(message.contains("last known state"), cached);
}
}
}
+38 -12
View File
@@ -138,6 +138,44 @@ const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
/// Finish manifest promotion before constructing the orchestrator or starting
/// catalog refresh/reconciliation. Replacing the app tree in the background
/// could let a reload observe its temporary empty state and forget disk-only apps.
pub async fn ensure_runtime_assets_ready() {
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),
}
// Repair the narrowly recognized legacy NPM tunnel override before app
// reconciliation. The embedded script ships in both OTA and ISO binaries.
// It preserves native wallet services and refuses unknown custom routing.
match tokio::process::Command::new("python3")
.arg("-c")
.arg(include_str!("../../../scripts/repair-npm-tunnel.py"))
.output()
.await
{
Ok(output) if output.status.success() => {
if !output.stdout.is_empty() {
info!("{}", String::from_utf8_lossy(&output.stdout).trim());
}
}
Ok(output) => warn!(
"NPM tunnel migration needs attention: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
Err(error) => warn!("NPM tunnel migration could not run: {error}"),
}
match run_apps_dir_repair().await {
Ok(true) => {
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
}
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
}
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -146,11 +184,6 @@ pub async fn ensure_doctor_installed() {
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"),
@@ -168,13 +201,6 @@ pub async fn ensure_doctor_installed() {
Ok(false) => debug!("No stale bitcoin.conf found"),
Err(e) => warn!("Bitcoin RPC repair failed (non-fatal): {:#}", e),
}
match run_apps_dir_repair().await {
Ok(true) => {
info!("Populated /opt/archipelago/apps from installer copy at /etc/archipelago/apps")
}
Ok(false) => debug!("/opt/archipelago/apps already populated (or no installer copy)"),
Err(e) => warn!("Apps dir repair failed (non-fatal): {:#}", e),
}
match run_tor_helper_sync().await {
Ok(true) => info!("tor-helper.sh synchronized with binary"),
Ok(false) => debug!("tor-helper.sh already current"),
+16
View File
@@ -9,3 +9,19 @@ pub const DWN_HEALTH_URL: &str = "http://127.0.0.1:3100/health";
/// Tor SOCKS5 proxy for outbound onion connections.
pub const TOR_SOCKS_PROXY: &str = "socks5h://127.0.0.1:9050";
/// Smallest disk (GB, total) a cuprate node may be installed, started,
/// restarted, updated, or boot-reconciled onto. Cuprate has no on-disk
/// pruning (verified against upstream `cuprated/src/config.rs` — the
/// `pruning` crate is Monero's p2p protocol pruning), so unlike the bitcoin
/// apps it cannot self-shrink on a scarce disk; below this line the ~250 GiB
/// Monero chain simply does not fit and running it would fill the filesystem
/// and take Archipelago down. 450 = chain + growth/headroom: allows
/// 500 GB-class disks, refuses the 250 GB VPS class.
///
/// SINGLE SOURCE OF TRUTH — the RPC gates
/// (`api::rpc::package::dependencies`) and the boot reconciler
/// (`container::prod_orchestrator`) both read this; a drift between them
/// would silently reopen the disk-fill failure the gate exists to close.
/// Keep `apps/cuprate/manifest.yml` (storage dependency + comments) aligned.
pub const CUPRATE_MIN_DISK_GB: u64 = 450;
+53 -2
View File
@@ -102,6 +102,31 @@ pub struct AppCatalogEntry {
/// `docs/registry-manifest-design.md`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub manifest: Option<serde_json::Value>,
/// Backward-compatible catalog rollout: old daemons ignore these and keep
/// the base manifest. New daemons choose only variants they can safely apply.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub manifest_variants: Vec<CatalogManifestVariant>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogManifestVariant {
pub requires: Vec<String>,
pub manifest: serde_json::Value,
}
fn selected_manifest(entry: AppCatalogEntry) -> Option<serde_json::Value> {
// Never let an unknown future requirement become an unsafe partial match.
for variant in entry.manifest_variants.into_iter().rev() {
if !variant.requires.is_empty()
&& variant
.requires
.iter()
.all(|capability| capability == "runtime-migration-backup-v1")
{
return Some(variant.manifest);
}
}
entry.manifest
}
/// One selectable version in an app's `versions[]` list. The catalog carries a
@@ -234,7 +259,7 @@ pub fn catalog_manifest_values() -> Vec<(String, serde_json::Value)> {
load_catalog()
.apps
.into_iter()
.filter_map(|(id, e)| e.manifest.map(|m| (id, m)))
.filter_map(|(id, e)| selected_manifest(e).map(|m| (id, m)))
.collect()
}
@@ -400,7 +425,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
if let Some(catalog_image) = catalog_primary_image(app_id) {
// Catalog covers this app with a concrete image -> authoritative.
return crate::container::image_versions::available_update_for_images(
return crate::container::image_versions::available_catalog_update_for_images(
&catalog_image,
running_image,
);
@@ -557,6 +582,32 @@ fn write_cache(data_dir: &Path, body: &str) -> anyhow::Result<bool> {
mod tests {
use super::*;
#[test]
fn catalog_migration_variant_is_compatible_with_old_and_future_daemons() {
let raw = serde_json::json!({
"version": "2.45.0", "manifest": {"app": {"id": "portainer", "container": {}}},
"manifest_variants": [{"requires": ["runtime-migration-backup-v1"],
"manifest": {"app": {"id": "portainer", "container": {"network": "slirp4netns"}, "backup_before_runtime_change": true}}}]
});
#[derive(Deserialize)]
struct OldEntry {
manifest: serde_json::Value,
}
let old: OldEntry = serde_json::from_value(raw.clone()).unwrap();
assert!(old.manifest["app"]["container"].get("network").is_none());
let current: AppCatalogEntry = serde_json::from_value(raw.clone()).unwrap();
let chosen = selected_manifest(current).unwrap();
assert_eq!(chosen["app"]["container"]["network"], "slirp4netns");
assert_eq!(chosen["app"]["backup_before_runtime_change"], true);
let mut future = raw;
future["manifest_variants"][0]["requires"]
.as_array_mut()
.unwrap()
.push(serde_json::json!("unknown-next-capability"));
let chosen = selected_manifest(serde_json::from_value(future).unwrap()).unwrap();
assert!(chosen["app"]["container"].get("network").is_none());
}
#[test]
fn parses_and_ignores_unknown_fields() {
let json = r#"{
+83 -11
View File
@@ -10,6 +10,7 @@
//! | lnd | archy-lnd-ui | wallet/channel UI |
//! | electrumx | archy-electrs-ui | indexer status UI |
//! | fedimint | archy-fedimint-ui | wait/proxy Guardian UI |
//! | cuprate | archy-cuprate-ui | Monero node status UI |
//!
//! Lifecycle: `install` writes a Quadlet `.container` unit to
//! `~/.config/containers/systemd/`, daemon-reloads, then starts the
@@ -97,14 +98,25 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
"lnd" => LND_UI,
"electrumx" | "electrs" | "mempool-electrs" => ELECTRS_UI,
"fedimint" | "fedimintd" => FEDIMINT_UI,
"cuprate" => CUPRATE_UI,
_ => &[],
}
}
/// Missing companion UIs are provisioned here, never by snapshot recovery.
/// A stale running-container snapshot must not resurrect an orphaned UI.
pub fn is_companion_app(app_id: &str) -> bool {
ALL_COMPANIONS
.iter()
.flat_map(|specs| specs.iter())
.any(|spec| spec.image_base == app_id)
}
/// Every companion this build knows how to provision. Kept beside
/// `companions_for` — a new companion must be added to both, or the reaper
/// will not recognise it as one of ours and will leave it running forever.
const ALL_COMPANIONS: &[&[CompanionSpec]] = &[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI];
const ALL_COMPANIONS: &[&[CompanionSpec]] =
&[BITCOIN_UI, LND_UI, ELECTRS_UI, FEDIMINT_UI, CUPRATE_UI];
const BITCOIN_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-bitcoin-ui",
@@ -172,6 +184,24 @@ const FEDIMINT_UI: &[CompanionSpec] = &[CompanionSpec {
host_network: true,
}];
const CUPRATE_UI: &[CompanionSpec] = &[CompanionSpec {
name: "archy-cuprate-ui",
image_base: "cuprate-ui",
build_dir_candidates: &[
"/opt/archipelago/docker/cuprate-ui",
"/home/archipelago/archy/docker/cuprate-ui",
"/home/archipelago/Projects/archy/docker/cuprate-ui",
],
// No pre-start hook and no bind mounts: unlike bitcoin-ui there is no
// secret to inject. Cuprate's restricted RPC (the only thing this UI
// proxies) is unauthenticated by design — Monero's safe-for-public
// subset — so the nginx.conf is baked into the image.
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();
@@ -292,7 +322,7 @@ async fn image_id(image_ref: &str) -> Option<String> {
/// 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 mut local_image = format!("localhost/{}:latest", spec.image_base);
let local_image_compat = format!("localhost/{}:local", spec.image_base);
let registry_image = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
@@ -301,11 +331,13 @@ async fn ensure_image_present(spec: &CompanionSpec) -> Result<String> {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// `:local` is a deliberate manual override — never auto-rebuild it.
// Older installers and self-update create :local themselves. It
// must receive source updates too; treating it as a permanent
// manual override silently kept the old LND UI after an OTA.
if image_exists(&local_image_compat).await {
return Ok(local_image_compat);
local_image = local_image_compat.clone();
}
// Reuse the auto-built `:latest` only when the build context has NOT
// Reuse either local tag only when the build context has NOT
// changed since it was built. Without this staleness check an
// already-present image is reused forever, so edits to the baked-in
// context (Dockerfile, nginx.conf, …) never reach the node — this is
@@ -828,20 +860,43 @@ async fn needs_repair(spec: &CompanionSpec) -> Result<bool> {
if !matches_known_shape {
return Ok(true);
}
if on_disk.contains(&local_image) && !on_disk.contains(&local_image_compat) {
if let Some(image) = managed_local_image(spec, &on_disk) {
for dir in spec.build_dir_candidates {
let dockerfile = PathBuf::from(dir).join("Dockerfile");
if fs::try_exists(&dockerfile).await.unwrap_or(false) {
// Conservative on any timeout/error inside: reuse the cache.
return Ok(context_is_newer_than_image(dir, &local_image).await);
return Ok(context_is_newer_than_image(dir, &image).await);
}
}
}
Ok(false)
}
fn managed_local_image(spec: &CompanionSpec, unit: &str) -> Option<String> {
["latest", "local"]
.iter()
.map(|tag| format!("localhost/{}:{tag}", spec.image_base))
.find(|image| build_unit(spec, image).render() == unit)
}
#[cfg(test)]
mod tests {
#[test]
fn legacy_installer_local_tag_is_checked_for_source_updates_like_latest() {
for spec in ALL_COMPANIONS.iter().flat_map(|group| group.iter()) {
for tag in ["local", "latest"] {
let image = format!("localhost/{}:{tag}", spec.image_base);
let unit = build_unit(spec, &image).render();
assert_eq!(managed_local_image(spec, &unit), Some(image));
}
let registry = format!("{}/{}:latest", COMPANION_REGISTRY, spec.image_base);
assert_eq!(
managed_local_image(spec, &build_unit(spec, &registry).render()),
None
);
}
}
use super::*;
fn names(specs: &[&'static CompanionSpec]) -> Vec<&'static str> {
@@ -869,6 +924,7 @@ mod tests {
"mempool-electrs",
"fedimint",
"fedimintd",
"cuprate",
];
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
.iter()
@@ -893,6 +949,7 @@ mod tests {
names(&orphan_companions(&[])),
vec![
"archy-bitcoin-ui",
"archy-cuprate-ui",
"archy-electrs-ui",
"archy-fedimint-ui",
"archy-lnd-ui"
@@ -906,7 +963,10 @@ mod tests {
// electrumx installed, fedimint and lnd not — yet all four companions
// were running because the reconciler was fed the manifest list.
let orphans = orphan_companions(&ids(&["bitcoin-knots", "electrumx"]));
assert_eq!(names(&orphans), vec!["archy-fedimint-ui", "archy-lnd-ui"]);
assert_eq!(
names(&orphans),
vec!["archy-cuprate-ui", "archy-fedimint-ui", "archy-lnd-ui"]
);
}
#[test]
@@ -926,12 +986,18 @@ mod tests {
#[test]
fn apps_without_companions_orphan_everything_and_panic_nothing() {
let orphans = orphan_companions(&ids(&["nextcloud", "not-a-real-app"]));
assert_eq!(orphans.len(), 4);
assert_eq!(orphans.len(), 5);
}
#[test]
fn every_backend_installed_leaves_no_orphans() {
let orphans = orphan_companions(&ids(&["bitcoin-knots", "lnd", "electrumx", "fedimint"]));
let orphans = orphan_companions(&ids(&[
"bitcoin-knots",
"lnd",
"electrumx",
"fedimint",
"cuprate",
]));
assert!(
names(&orphans).is_empty(),
"unexpected orphans: {:?}",
@@ -970,7 +1036,12 @@ mod tests {
let due = due_after_grace(orphans, &names_seen, &mut since, start + ORPHAN_GRACE);
assert_eq!(
names(&due),
vec!["archy-electrs-ui", "archy-fedimint-ui", "archy-lnd-ui"]
vec![
"archy-cuprate-ui",
"archy-electrs-ui",
"archy-fedimint-ui",
"archy-lnd-ui"
]
);
}
@@ -1024,6 +1095,7 @@ mod tests {
assert_eq!(companions_for("mempool-electrs").len(), 1);
assert_eq!(companions_for("fedimint").len(), 1);
assert_eq!(companions_for("fedimintd").len(), 1);
assert_eq!(companions_for("cuprate").len(), 1);
assert_eq!(companions_for("nextcloud").len(), 0);
assert_eq!(companions_for("not-a-real-app").len(), 0);
}
+486 -34
View File
@@ -3,8 +3,9 @@
use anyhow::Result;
use archipelago_container::{
ContainerRuntime as ContainerRuntimeTrait, ContainerState, PodmanClient,
ContainerRuntime as ContainerRuntimeTrait, ContainerState, ContainerStatus, PodmanClient,
};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::sync::Arc;
use tracing::{debug, info};
@@ -15,6 +16,16 @@ use crate::data_model::{
PackageDataEntry, PackageState, ServiceStatus, StaticFiles,
};
/// One displayed package for each known container/manifest alias. Keep the
/// stopped-app restoration path in agreement with live-container discovery.
fn canonical_package_id(name: &str) -> &str {
match name.strip_prefix("archy-").unwrap_or(name) {
"immich_server" => "immich",
"mempool-web" | "mempool-frontend" => "mempool",
name => name,
}
}
pub struct DockerPackageScanner {
runtime: Arc<dyn ContainerRuntimeTrait>,
}
@@ -25,8 +36,15 @@ impl DockerPackageScanner {
}
/// Scan Docker containers and convert to package data
pub async fn scan_containers(&self) -> Result<HashMap<String, PackageDataEntry>> {
let containers = self.runtime.list_containers().await?;
pub async fn scan_containers(
&self,
data_dir: &std::path::Path,
cached: &HashMap<String, PackageDataEntry>,
) -> Result<HashMap<String, PackageDataEntry>> {
let mut containers = self.runtime.list_containers().await?;
let installed = crate::crash_recovery::load_installed_apps(data_dir).await;
let uninstalled = crate::crash_recovery::load_user_uninstalled(data_dir).await;
restore_absent_installed(&mut containers, &installed, &uninstalled);
debug!("Found {} containers", containers.len());
@@ -91,24 +109,8 @@ impl DockerPackageScanner {
debug!("Found {} UI containers", ui_containers.len());
for container in containers {
// Extract app ID from container name
// Support both archy-* containers (docker-compose) and plain names (manual)
let app_id = if container.name.starts_with("archy-") {
container
.name
.strip_prefix("archy-")
.unwrap_or(&container.name)
.to_string()
} else {
// Use the container name as-is for manually started containers
container.name.clone()
};
// Normalize multi-container app IDs to their canonical names
let app_id = match app_id.as_str() {
"immich_server" => "immich".to_string(),
_ => app_id,
};
// Use the same alias mapping as stopped-app restoration.
let app_id = canonical_package_id(&container.name).to_owned();
// Skip backend services (databases, APIs, etc.)
if excluded_services.contains(&app_id.as_str()) {
@@ -139,6 +141,18 @@ impl DockerPackageScanner {
continue;
}
if container.id.is_empty() {
if let Some(previous) = cached.get(&app_id) {
let mut held = previous.clone();
held.state = PackageState::Stopped;
held.ui_ready = Some(false);
held.health = None;
held.exit_code = None;
packages.insert(app_id.clone(), held);
continue;
}
}
// Get metadata for this app
let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
@@ -158,13 +172,11 @@ impl DockerPackageScanner {
} else {
// Prefer the known web UI port over arbitrary first binding
// (for example Gitea exposes SSH on 2222 before web on 3001).
let candidate = if uses_allocated_launch_port(&app_id) {
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
} else {
PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports))
};
let candidate = package_launch_candidate(
&app_id,
&container.ports,
PodmanClient::lan_address_for(&app_id),
);
reachable_lan_address(&app_id, candidate).await
};
@@ -179,14 +191,22 @@ impl DockerPackageScanner {
let tor_address = read_tor_address(&app_id).await;
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
let running_version = if container.id.is_empty() {
String::new() // Absence cannot establish the installed image version.
} else {
image_versions::extract_version_from_image(&container.image)
};
// Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline.
let available_update =
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
let available_update = if container.id.is_empty() {
None
} else {
crate::container::app_catalog::available_update_for_app(&app_id, &container.image)
};
let package = PackageDataEntry {
ui_ready: Some(false),
state: package_state.clone(),
health: container.health.clone(),
exit_code: if package_state == PackageState::Exited {
@@ -283,10 +303,237 @@ impl DockerPackageScanner {
);
}
let probes: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.state != PackageState::Running {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.clone()?;
Some((id.clone(), url))
})
.collect();
let mut results = futures_util::stream::iter(
probes
.into_iter()
.map(|(id, url)| async move { (id, launch_http_ready(&url).await) }),
)
.buffer_unordered(8);
while let Some((id, ready)) = results.next().await {
if let Some(pkg) = packages.get_mut(&id) {
pkg.ui_ready = Some(ready);
}
}
// HTTP on loopback can precede the LAN/Tor listener after install.
let port_map = crate::appgate::identity::build_port_map();
let gated: Vec<_> = packages
.iter()
.filter_map(|(id, pkg)| {
if pkg.ui_ready != Some(true) {
return None;
}
let url = pkg
.installed
.as_ref()?
.interface_addresses
.get("main")?
.lan_address
.as_deref()?;
let port = launch_url_port(url)?;
port_map
.gated(port)
.filter(|gate| gate.declared)
.map(|_| (id.clone(), port))
})
.collect();
if !gated.is_empty() {
use crate::appgate::listener::{port_claimed, refresh_now, shared_status};
let status = shared_status();
let needs_refresh = {
let current = status.read().await;
gated.iter().any(|(_, port)| !port_claimed(&current, *port))
};
if needs_refresh {
refresh_now().await;
}
let current = status.read().await;
for (id, port) in gated {
if !port_claimed(&current, port) {
packages.get_mut(&id).unwrap().ui_ready = Some(false);
}
}
}
Ok(packages)
}
}
/// Quadlet removes containers during ordinary stops/restarts. Rebuild installed
/// entries even on the daemon's first scan; a runtime absence is not uninstall.
fn restore_absent_installed(
containers: &mut Vec<ContainerStatus>,
installed: &std::collections::HashSet<String>,
uninstalled: &std::collections::HashSet<String>,
) {
let mut present: std::collections::HashSet<String> = containers
.iter()
.map(|c| canonical_package_id(&c.name).to_owned())
.collect();
let removed: std::collections::HashSet<_> = uninstalled
.iter()
.map(|id| canonical_package_id(id))
.collect();
for name in installed {
let id = canonical_package_id(name);
if removed.contains(id) || !present.insert(id.to_owned()) {
continue;
}
containers.push(ContainerStatus {
id: String::new(),
name: id.to_owned(),
state: ContainerState::Stopped,
health: None,
exit_code: None,
started_at: None,
image: String::new(),
created: String::new(),
ports: Vec::new(),
lan_address: None,
});
}
}
/// Probe the actual loopback upstream, not the app gate's login page. A bound
/// TCP socket alone can still reset requests or serve a startup 503.
async fn launch_http_ready(candidate: &str) -> bool {
let Ok(mut url) = reqwest::Url::parse(candidate) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") {
return false;
}
if url.set_host(Some("127.0.0.1")).is_err() {
return false;
}
static CLIENT: std::sync::OnceLock<reqwest::Client> = std::sync::OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.redirect(reqwest::redirect::Policy::none())
// Self-signed local app certificates are normal. This client only
// contacts loopback and never sends credentials or follows redirects.
.danger_accept_invalid_certs(true)
.build()
.expect("local readiness client")
});
match client.get(url).send().await {
Ok(response) => matches!(response.status().as_u16(), 200..=399 | 401 | 403),
Err(_) => false,
}
}
#[cfg(test)]
mod lifecycle_regression_tests {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
.into_iter()
.map(str::to_owned)
.collect();
let removed = ["removed".to_owned()].into_iter().collect();
let mut containers = Vec::new();
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert!(containers
.iter()
.all(|c| c.state == ContainerState::Stopped));
containers[0].state = ContainerState::Running;
restore_absent_installed(&mut containers, &installed, &removed);
assert_eq!(containers.len(), 2);
assert_eq!(containers[0].state, ContainerState::Running);
}
#[test]
fn mempool_frontend_inventory_alias_does_not_create_a_second_package() {
let installed = ["mempool", "archy-mempool-web", "mempool-web"]
.into_iter()
.map(str::to_owned)
.collect();
let mut containers = Vec::new();
restore_absent_installed(&mut containers, &installed, &Default::default());
assert_eq!(containers.len(), 1);
assert_eq!(containers[0].name, "mempool");
containers[0].id = "live-frontend".into();
containers[0].state = ContainerState::Running;
restore_absent_installed(&mut containers, &installed, &Default::default());
assert_eq!(containers.len(), 1);
assert_eq!(containers[0].id, "live-frontend");
assert_eq!(containers[0].state, ContainerState::Running);
assert_eq!(canonical_package_id("archy-mempool-web"), "mempool");
assert_eq!(canonical_package_id("mempool-api"), "mempool-api");
containers.clear();
restore_absent_installed(
&mut containers,
&installed,
&["mempool".into()].into_iter().collect(),
);
assert!(containers.is_empty());
}
#[tokio::test]
async fn readiness_rejects_startup_errors_and_accepts_auth_and_redirects() {
for (status, expected) in [
(200, true),
(302, true),
(401, true),
(403, true),
(404, false),
(500, false),
(502, false),
(503, false),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
let n = stream.read(&mut buf).await.unwrap();
assert!(String::from_utf8_lossy(&buf[..n]).starts_with("GET /start HTTP/1.1"));
stream.write_all(format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
});
assert_eq!(
launch_http_ready(&format!("http://localhost:{port}/start")).await,
expected,
"status {status}"
);
task.await.unwrap();
}
}
#[tokio::test]
async fn readiness_rejects_tcp_accept_without_http() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let task = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
drop(stream);
});
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
task.await.unwrap();
assert!(!launch_http_ready(&format!("http://localhost:{port}/")).await);
assert!(!launch_http_ready("file:///tmp/test").await);
}
}
struct AppMetadata {
title: String,
description: String,
@@ -657,9 +904,19 @@ fn apply_dynamic_metadata(app_id: &str, meta: &mut AppMetadata) {
/// Map app_id to Tor hidden service directory name.
/// "archipelago" is the main web UI (nginx port 80).
/// Supports container names from deploy (archy-*, btcpay-server, etc.).
///
/// This must match what enrollment actually names the hidden service dir
/// with — both the install-time auto-enroll (`install.rs`) and the manual
/// `tor.create-service` RPC write `HiddenServiceDir` using the raw
/// `package_id`/`name` verbatim, with no canonicalization. So `bitcoin-core`
/// gets its own identity arm rather than folding into the "bitcoin" alias:
/// aliasing it here without also canonicalizing the write side would point
/// this lookup at `hidden_service_bitcoin`, which never gets created — the
/// on-disk dir is always `hidden_service_bitcoin-core` for this app id.
fn tor_service_name(app_id: &str) -> Option<&'static str> {
match app_id {
"archipelago" => Some("archipelago"),
"bitcoin-core" => Some("bitcoin-core"),
"bitcoin" | "bitcoin-knots" | "bitcoind" => Some("bitcoin"),
"electrumx" | "electrs" | "electrum" => Some("electrumx"),
"lnd" | "lnd-ui" => Some("lnd"),
@@ -721,14 +978,20 @@ fn extract_lan_address(ports: &[String]) -> Option<String> {
let mut first_candidate = None;
for port_str in ports {
// Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp"
let Some(public_part) = port_str.split("->").next() else {
let Some((public_part, _)) = port_str.split_once("->") else {
continue;
};
let Some(port_part) = public_part.split(':').nth(1) else {
let Some((_, port_part)) = public_part.rsplit_once(':') else {
continue;
};
// Extract just the first port if it's a range (e.g., "18443-18444" -> "18443")
let host_port = port_part.split('-').next().unwrap_or(port_part);
let Ok(host_port) = host_port.parse::<u16>() else {
continue;
};
if host_port == 0 {
continue;
}
let candidate = format!("http://localhost:{}", host_port);
if first_candidate.is_none() {
first_candidate = Some(candidate.clone());
@@ -846,6 +1109,46 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
}
}
/// Companion dashboards remain usable while their backend is syncing. Never
/// probe a Bitcoin RPC or Electrum protocol socket as dashboard readiness.
fn package_launch_candidate(
app_id: &str,
ports: &[String],
known: Option<String>,
) -> Option<String> {
if let Some(companion) = companion_lan_address(app_id) {
return Some(companion);
}
if app_id == "nginx-proxy-manager" {
// 80/443 serve users' proxy hosts; only container port 81 serves the
// admin UI. Podman's binding order is unstable across recreation.
// Resolve its actual host allocation rather than guessing the first
// HTTP port or hardcoding the default host port 8081.
let admin_ports: Vec<String> = ports
.iter()
.filter(|port| {
port.split_once("->")
.is_some_and(|(_, target)| target == "81/tcp")
})
.cloned()
.collect();
// With published bindings, a missing admin mapping is not evidence
// that some unrelated service on the default host port is this UI.
return extract_lan_address(&admin_ports).or_else(|| {
if ports.is_empty() {
known
} else {
None
}
});
}
if uses_allocated_launch_port(app_id) {
extract_lan_address(ports).or(known)
} else {
known.or_else(|| extract_lan_address(ports))
}
}
fn uses_allocated_launch_port(app_id: &str) -> bool {
matches!(
app_id,
@@ -906,9 +1209,158 @@ mod launch_url_port_tests {
}
}
#[cfg(test)]
mod tor_service_name_tests {
use super::tor_service_name;
#[test]
fn bitcoin_core_resolves_to_its_own_hidden_service_dir() {
// Regression: enrollment (install.rs, tor.create-service) writes
// HiddenServiceDir/tor-hostnames entries using the raw package_id
// verbatim, never canonicalized. Aliasing "bitcoin-core" to the
// shared "bitcoin" name here would point reads at a directory
// enrollment never creates.
assert_eq!(tor_service_name("bitcoin-core"), Some("bitcoin-core"));
}
#[test]
fn legacy_bitcoin_ids_share_the_bitcoin_alias() {
assert_eq!(tor_service_name("bitcoin"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoin-knots"), Some("bitcoin"));
assert_eq!(tor_service_name("bitcoind"), Some("bitcoin"));
}
}
#[cfg(test)]
mod extract_lan_address_tests {
use super::extract_lan_address;
use super::{extract_lan_address, package_launch_candidate};
#[test]
fn companion_dashboard_wins_over_backend_protocol_ports() {
for id in ["bitcoin", "bitcoin-core", "bitcoin-knots"] {
assert_eq!(
package_launch_candidate(
id,
&["127.0.0.1:8332->8332/tcp".into()],
Some("http://localhost:8332".into())
)
.as_deref(),
Some("http://localhost:8334")
);
}
for id in ["electrumx", "electrs", "mempool-electrs"] {
assert_eq!(
package_launch_candidate(
id,
&["127.0.0.1:50001->50001/tcp".into()],
Some("http://localhost:50001".into())
)
.as_deref(),
Some("http://localhost:50002")
);
}
assert_eq!(
package_launch_candidate(
"filebrowser",
&["127.0.0.1:19080->80/tcp".into()],
Some("http://localhost:8080".into())
)
.as_deref(),
Some("http://localhost:19080")
);
}
#[test]
fn npm_admin_launch_is_independent_of_proxy_binding_order() {
let mappings = [
"10.77.0.2:18081->80/tcp",
"10.77.0.2:18443->443/tcp",
"127.0.0.1:8081->81/tcp",
];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let ports: Vec<String> = order.iter().map(|&i| mappings[i].into()).collect();
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081")
);
}
}
#[test]
fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() {
for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] {
let ports = vec![
"10.77.0.2:18081->80/tcp".into(),
format!("{binding}:28081->81/tcp"),
];
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&ports,
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:28081")
);
}
let proxies = vec![
"10.77.0.2:18081->80/tcp".into(),
"10.77.0.2:18443->443/tcp".into(),
];
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &proxies, None),
None
);
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&proxies,
Some("http://localhost:8081/".into())
),
None
);
}
#[test]
fn npm_without_port_information_uses_declared_admin_url() {
assert_eq!(
package_launch_candidate(
"nginx-proxy-manager",
&[],
Some("http://localhost:8081/".into())
)
.as_deref(),
Some("http://localhost:8081/")
);
}
#[test]
fn malformed_published_ports_do_not_become_launch_urls() {
for port in [
"81/tcp",
"127.0.0.1:bad->81/tcp",
"[::1]:0->81/tcp",
"[::]:65536->81/tcp",
"127.0.0.1:8081->81/udp",
] {
assert_eq!(
package_launch_candidate("nginx-proxy-manager", &[port.into()], None),
None
);
}
}
#[test]
fn skips_ssh_port_when_web_port_is_published() {
+420 -1
View File
@@ -5,7 +5,7 @@
//! starting the container with `--config /data/.filebrowser.json`.
use anyhow::{Context, Result};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::update::host_sudo;
@@ -117,6 +117,197 @@ fn shell_quote(s: &str) -> String {
s.replace('\'', "'\\''")
}
/// Save a complete purchase without overwriting any existing directory entry.
/// Both host and rootless-namespace paths publish with a no-clobber hard link.
pub async fn save_new_file(dir: &Path, name: &str, bytes: &[u8]) -> Result<PathBuf> {
save_new_file_with(dir, name, bytes, write_via_userns).await
}
fn validate_filename(name: &str) -> Result<()> {
anyhow::ensure!(
!name.is_empty()
&& name != "."
&& name != ".."
&& !name.contains(['/', '\\', '\0'])
&& name.len() <= 255,
"Invalid purchased filename"
);
Ok(())
}
async fn save_new_file_with<F, Fut>(
dir: &Path,
name: &str,
bytes: &[u8],
fallback: F,
) -> Result<PathBuf>
where
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<PathBuf>>,
{
validate_filename(name)?;
// Never follow a user-created destination directory symlink.
match fs::symlink_metadata(dir).await {
Ok(meta) => anyhow::ensure!(meta.is_dir(), "Files destination is not a directory"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
save_after_direct_result(
write_direct(dir, name, bytes).await,
dir,
name,
bytes,
fallback,
)
.await
}
async fn save_after_direct_result<F, Fut>(
result: std::io::Result<PathBuf>,
dir: &Path,
name: &str,
bytes: &[u8],
fallback: F,
) -> Result<PathBuf>
where
F: FnOnce(PathBuf, String, Vec<u8>) -> Fut,
Fut: std::future::Future<Output = Result<PathBuf>>,
{
match result {
Ok(path) => Ok(path),
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
fallback(dir.to_owned(), name.to_owned(), bytes.to_vec())
.await
.context("Saving purchase in Files user namespace")
}
Err(error) => Err(error).context("Saving purchase in Files"),
}
}
fn numbered_name(name: &str, attempt: usize) -> String {
if attempt == 1 {
return name.to_owned();
}
match name.rsplit_once('.') {
Some((stem, extension)) if !stem.is_empty() => format!("{stem} ({attempt}).{extension}"),
_ => format!("{name} ({attempt})"),
}
}
struct PendingFile(PathBuf);
impl Drop for PendingFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
async fn write_direct(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result<PathBuf> {
use std::os::unix::fs::PermissionsExt;
use tokio::io::AsyncWriteExt;
fs::create_dir_all(dir).await?;
let temp_path = dir.join(format!(".archy-saving-{}", uuid::Uuid::new_v4()));
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp_path)
.await?;
let temp = PendingFile(temp_path);
file.write_all(bytes).await?;
file.set_permissions(std::fs::Permissions::from_mode(0o644))
.await?;
file.sync_all().await?;
for attempt in 1..=100 {
let target = dir.join(numbered_name(name, attempt));
match fs::hard_link(&temp.0, &target).await {
Ok(()) => return Ok(target),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error),
}
}
Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"Too many existing copies; purchase cache retained",
))
}
// Positional arguments carry all user-controlled text. mktemp prevents temp-name
// collisions; ln -T refuses files, symlinks and directories, including races.
const WRITE_VIA_USERNS: &str = r#"set -eu
dir=$1
name=$2
expected=$3
[ ! -L "$dir" ] || exit 1
if [ ! -d "$dir" ]; then
mkdir -p -- "$dir"
chown --reference="$(dirname -- "$dir")" -- "$dir"
fi
tmp=$(mktemp "$dir/.archy-saving.XXXXXXXXXX")
trap 'rm -f -- "$tmp"' EXIT HUP INT TERM
cat > "$tmp"
[ "$(wc -c < "$tmp")" -eq "$expected" ] || exit 1
chown --reference="$dir" -- "$tmp"
chmod 0644 -- "$tmp"
sync -f -- "$tmp"
stem=$name
ext=
case "$name" in
*.*) prefix=${name%.*}; if [ -n "$prefix" ]; then stem=$prefix; ext=.${name##*.}; fi ;;
esac
n=1
while [ "$n" -le 100 ]; do
candidate=$name
if [ "$n" -gt 1 ]; then candidate="$stem ($n)$ext"; fi
dst="$dir/$candidate"
if ln -T -- "$tmp" "$dst" 2>/dev/null; then
printf '%s' "$candidate"
exit 0
fi
# A conflict may be a dangling symlink; never follow it or overwrite it.
if [ ! -e "$dst" ] && [ ! -L "$dst" ]; then exit 1; fi
n=$((n + 1))
done
exit 1
"#;
async fn write_via_userns(dir: PathBuf, name: String, bytes: Vec<u8>) -> Result<PathBuf> {
use tokio::io::AsyncWriteExt;
let mut child = tokio::process::Command::new("podman")
.args(["unshare", "sh", "-c", WRITE_VIA_USERNS, "sh"])
.arg(&dir)
.arg(&name)
.arg(bytes.len().to_string())
.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Starting Files namespace writer")?;
let mut stdin = child.stdin.take().context("Files writer stdin missing")?;
let operation = async {
let fed = stdin.write_all(&bytes).await;
drop(stdin);
let output = child.wait_with_output().await?;
anyhow::ensure!(
output.status.success(),
"Files namespace writer failed: {}",
output.status
);
fed.context("Sending purchase bytes to Files")?;
let chosen =
String::from_utf8(output.stdout).context("Files writer returned an invalid name")?;
validate_filename(&chosen)?;
anyhow::ensure!(
(1..=100).any(|n| numbered_name(&name, n) == chosen),
"Files writer returned an unexpected name"
);
Ok(dir.join(chosen))
};
tokio::time::timeout(std::time::Duration::from_secs(120), operation)
.await
.context("Files namespace writer timed out")?
}
#[cfg(test)]
mod tests {
use super::*;
@@ -152,3 +343,231 @@ mod tests {
assert_eq!(second, EnsureOutcome::Unchanged);
}
}
#[cfg(test)]
mod purchase_write_tests {
use super::*;
use std::{
collections::HashSet,
os::unix::fs::{symlink, PermissionsExt},
};
fn no_temps(dir: &Path) {
assert!(std::fs::read_dir(dir).unwrap().all(|e| !e
.unwrap()
.file_name()
.to_string_lossy()
.starts_with(".archy-saving")));
}
#[tokio::test]
async fn direct_write_uses_complete_bytes_and_preserves_originals() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("song.mp3"), b"original")
.await
.unwrap();
let target = save_new_file(dir.path(), "song.mp3", b"new").await.unwrap();
assert_eq!(target.file_name().unwrap(), "song (2).mp3");
assert_eq!(fs::read(target).await.unwrap(), b"new");
assert_eq!(
fs::read(dir.path().join("song.mp3")).await.unwrap(),
b"original"
);
no_temps(dir.path());
}
#[tokio::test]
async fn simultaneous_saves_publish_unique_complete_files() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for n in 0..24u8 {
let dir = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
let bytes = vec![n; 32768];
let path = save_new_file(&dir, "same.bin", &bytes).await.unwrap();
assert_eq!(fs::read(&path).await.unwrap(), bytes);
path
}));
}
let mut paths = HashSet::new();
for task in tasks {
assert!(paths.insert(task.await.unwrap()));
}
assert_eq!(paths.len(), 24);
no_temps(dir.path());
}
#[tokio::test]
async fn existing_directories_and_dangling_symlinks_are_conflicts() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("name")).await.unwrap();
symlink("missing", dir.path().join("name (2)")).unwrap();
let path = save_new_file(dir.path(), "name", b"new").await.unwrap();
assert_eq!(path.file_name().unwrap(), "name (3)");
assert!(dir.path().join("name").is_dir());
assert!(fs::symlink_metadata(dir.path().join("name (2)"))
.await
.unwrap()
.is_symlink());
no_temps(dir.path());
}
#[tokio::test]
async fn invalid_names_and_symlink_destination_are_refused() {
let dir = tempfile::tempdir().unwrap();
for name in [
"",
".",
"..",
"../escape",
"/absolute",
"a/b",
"a\\b",
"a\0b",
] {
assert!(save_new_file(dir.path(), name, b"bytes").await.is_err());
}
let outside = tempfile::tempdir().unwrap();
symlink(outside.path(), dir.path().join("Music")).unwrap();
assert!(save_new_file(&dir.path().join("Music"), "song", b"bytes")
.await
.is_err());
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[tokio::test]
async fn collision_limit_preserves_all_files_and_cleans_temporary_data() {
let dir = tempfile::tempdir().unwrap();
for n in 1..=100 {
fs::write(dir.path().join(numbered_name("a.txt", n)), b"keep")
.await
.unwrap();
}
assert!(save_new_file(dir.path(), "a.txt", b"new").await.is_err());
for n in 1..=100 {
assert_eq!(
fs::read(dir.path().join(numbered_name("a.txt", n)))
.await
.unwrap(),
b"keep"
);
}
no_temps(dir.path());
}
#[tokio::test]
async fn permission_fallback_is_exercised_without_skipping_as_root() {
let dir = tempfile::tempdir().unwrap();
let result = save_after_direct_result(
Err(std::io::ErrorKind::PermissionDenied.into()),
dir.path(),
"a",
b"abc",
|dir, name, bytes| async move {
assert_eq!(bytes, b"abc");
Ok(dir.join(name))
},
)
.await
.unwrap();
assert_eq!(result, dir.path().join("a"));
assert!(save_after_direct_result(
Err(std::io::ErrorKind::PermissionDenied.into()),
dir.path(),
"a",
b"abc",
|_, _, _| async { anyhow::bail!("namespace unavailable") }
)
.await
.unwrap_err()
.to_string()
.contains("namespace"));
assert!(save_after_direct_result(
Err(std::io::ErrorKind::StorageFull.into()),
dir.path(),
"a",
b"abc",
|_, _, _| async { panic!("disk full must not trigger permission fallback") }
)
.await
.is_err());
}
async fn run_script(
dir: &Path,
name: &str,
bytes: &[u8],
expected: usize,
) -> std::process::Output {
use tokio::io::AsyncWriteExt;
let mut child = tokio::process::Command::new("sh")
.args(["-c", WRITE_VIA_USERNS, "sh"])
.arg(dir)
.arg(name)
.arg(expected.to_string())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let mut input = child.stdin.take().unwrap();
input.write_all(bytes).await.unwrap();
drop(input);
child.wait_with_output().await.unwrap()
}
#[tokio::test]
async fn namespace_script_preserves_names_bytes_modes_and_existing_entries() {
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("Music");
let name = "song ' $() ; #.mp3";
for n in 1..=2 {
let output = run_script(&folder, name, b"abc", 3).await;
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let chosen = String::from_utf8(output.stdout).unwrap();
assert_eq!(chosen, numbered_name(name, n));
let path = folder.join(chosen);
assert_eq!(fs::read(&path).await.unwrap(), b"abc");
assert_eq!(
fs::metadata(path).await.unwrap().permissions().mode() & 0o777,
0o644
);
}
no_temps(&folder);
}
#[tokio::test]
async fn namespace_script_refuses_truncated_input_and_cleans_up() {
let dir = tempfile::tempdir().unwrap();
let output = run_script(dir.path(), "never.bin", b"partial", 100).await;
assert!(!output.status.success());
assert!(!dir.path().join("never.bin").exists());
no_temps(dir.path());
}
#[tokio::test]
async fn namespace_script_does_not_link_inside_existing_directory() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir(dir.path().join("name")).await.unwrap();
symlink("missing", dir.path().join("name (2)")).unwrap();
let output = run_script(dir.path(), "name", b"abc", 3).await;
assert!(output.status.success());
assert_eq!(output.stdout, b"name (3)");
assert_eq!(
std::fs::read_dir(dir.path().join("name")).unwrap().count(),
0
);
no_temps(dir.path());
}
#[test]
fn names_keep_extensions_and_dotfiles() {
assert_eq!(numbered_name("a.tar.gz", 2), "a.tar (2).gz");
assert_eq!(numbered_name(".hidden", 2), ".hidden (2)");
assert_eq!(numbered_name("README", 2), "README (2)");
}
}
+131 -34
View File
@@ -100,6 +100,12 @@ fn parse_image_versions(content: &str) -> HashMap<String, String> {
// Match VAR="value" or VAR=value
if let Some((key, val)) = parse_assignment(line) {
// Read a self-default assignment without evaluating shell code.
let default_prefix = format!("${{{key}:-");
let val = val
.strip_prefix(&default_prefix)
.and_then(|v| v.strip_suffix('}'))
.unwrap_or(val);
let expanded = val.replace("$ARCHY_REGISTRY", &registry);
if key == "ARCHY_REGISTRY" {
registry = expanded.clone();
@@ -146,6 +152,7 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"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"),
"cuprate-ui" | "archy-cuprate-ui" => Some("CUPRATE_UI_IMAGE"),
// Mempool stack (primary = web)
"mempool" | "mempool-web" | "archy-mempool-web" => Some("MEMPOOL_WEB_IMAGE"),
@@ -204,48 +211,71 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
}
pub fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(&pinned);
if image_without_registry_or_tag(pinned) != image_without_registry_or_tag(running_image) {
return None;
}
available_catalog_update_for_images(pinned, running_image)
}
/// A signed catalog binds the image to an app id, so a publisher namespace
/// migration must not hide a real upgrade. Baseline pins still require the
/// same repository via `available_update_for_images` above.
pub fn available_catalog_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
let pinned_version = extract_version_from_image(pinned);
if is_floating_tag(&pinned_version) {
return None;
}
let running_version = extract_version_from_image(running_image);
if pinned_version == running_version {
return None;
}
let pinned_repo = image_without_registry_or_tag(&pinned);
let running_repo = image_without_registry_or_tag(running_image);
if pinned_repo != running_repo {
return None;
}
// Never advertise a LOWER version as an update.
//
// Everything upstream of here is a version claim that can go stale: the
// signed catalog, a legacy catalog entry with no manifest, the
// image-versions.sh baseline pin. When one lags behind what a node is
// actually running, a bare `pinned != running` check turns that staleness
// into an "Update" button that rolls the node BACKWARDS — and a rollback
// to a version withdrawn for a vulnerability is precisely the case where
// that must not happen. Observed with BTCPay: 2.4.2 installed, a stale
// 2.3.9 pin, and the UI offering "update" to the exploited release.
//
// Only suppress when both tags parse as comparable version numbers, so
// apps with opaque tags (RELEASE.2024-11-07T00-52-20Z, 14-vectorchord0.4.3)
// keep the previous behaviour rather than silently losing updates.
if let (Some(p), Some(r)) = (
parse_version_parts(&pinned_version),
parse_version_parts(&running_version),
if matches!(
compare_image_versions(pinned, running_image),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
) {
if p < r {
return None;
}
return None;
}
Some(pinned_version)
}
/// Compare explicit image tags, ignoring registry and namespace. `None` means
/// unknown ordering (including floating tags), never permission to downgrade.
/// Archipelago's `-archyN` is a downstream patch revision ABOVE the upstream
/// release, not a SemVer prerelease below it.
pub fn compare_image_versions(target: &str, running: &str) -> Option<std::cmp::Ordering> {
use std::cmp::Ordering;
let target = extract_version_from_image(target);
let running = extract_version_from_image(running);
if is_floating_tag(&target) || is_floating_tag(&running) {
return None;
}
let target = target.strip_prefix('v').unwrap_or(&target);
let running = running.strip_prefix('v').unwrap_or(&running);
if target == running {
return Some(Ordering::Equal);
}
let mut target_core = parse_version_parts(target)?;
let mut running_core = parse_version_parts(running)?;
while target_core.last() == Some(&0) {
target_core.pop();
}
while running_core.last() == Some(&0) {
running_core.pop();
}
match target_core.cmp(&running_core) {
Ordering::Equal => {
fn patch_revision(tag: &str) -> Option<u64> {
if let Some((base, revision)) = tag.rsplit_once("-archy") {
if base.chars().all(|c| c.is_ascii_digit() || c == '.') {
return revision.parse().ok();
}
}
tag.chars()
.all(|c| c.is_ascii_digit() || c == '.')
.then_some(0)
}
Some(patch_revision(target)?.cmp(&patch_revision(running)?))
}
order => Some(order),
}
}
/// Numeric components of a version tag, for ordering comparisons only.
///
/// Accepts a leading `v` and a trailing pre-release suffix (`v0.18.4-beta`),
@@ -422,6 +452,57 @@ mod tests {
);
}
#[test]
fn downstream_patch_is_newer_than_upstream_and_orders_revisions() {
let upstream = "registry.test/team/mempool-frontend:v3.3.1";
let patch1 = "registry.test/team/mempool-frontend:v3.3.1-archy1";
let patch2 = "registry.test/team/mempool-frontend:v3.3.1-archy2";
assert_eq!(available_update_for_images(upstream, patch1), None);
assert_eq!(available_update_for_images(patch1, patch2), None);
assert_eq!(
available_update_for_images(patch1, upstream),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_update_for_images(patch2, patch1),
Some("v3.3.1-archy2".into())
);
}
#[test]
fn catalog_namespace_migration_does_not_hide_patch_or_offer_reinstall() {
let old = "registry.test/lfg2025/mempool-frontend:v3.3.1";
let patched = "registry.test/chaum/mempool-frontend:v3.3.1-archy1";
assert_eq!(
available_catalog_update_for_images(patched, old),
Some("v3.3.1-archy1".into())
);
assert_eq!(
available_catalog_update_for_images(
patched,
"registry.test/lfg2025/mempool-frontend:v3.3.1-archy1"
),
None
);
assert_eq!(available_update_for_images(patched, old), None);
}
#[test]
fn equivalent_version_spelling_does_not_offer_update() {
assert_eq!(
available_update_for_images("r.test/team/app:v3.3.1", "r.test/team/app:3.3.1"),
None
);
assert_eq!(
available_update_for_images("r.test/team/app:3.3.0", "r.test/team/app:3.3"),
None
);
assert_eq!(
compare_image_versions("r.test/team/app:latest", "r.test/team/app:latest"),
None
);
}
#[test]
fn test_parse_image_versions() {
let content = r#"
@@ -444,6 +525,22 @@ NOT_AN_IMAGE="something"
assert!(!parsed.contains_key("ARCHY_REGISTRY"));
}
#[test]
fn shipped_image_pins_expand_shell_defaults_to_concrete_refs() {
let images = parse_image_versions(include_str!("../../../../scripts/image-versions.sh"));
assert_eq!(
images["MEMPOOL_WEB_IMAGE"],
"source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1"
);
assert_eq!(
images["MEMPOOL_BACKEND_IMAGE"],
"source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
);
assert!(images
.values()
.all(|v| !v.contains('$') && !v.contains('}')));
}
#[test]
fn test_image_var_mapping() {
assert_eq!(image_var_for_app("lnd"), Some("LND_IMAGE"));
+110 -120
View File
@@ -89,136 +89,84 @@ bitcoind.estimatemode=ECONOMICAL\n"
Ok(EnsureOutcome::Written)
}
/// Bitcoin can accept TCP while returning RPC_IN_WARMUP for many minutes.
/// Unlocking LND then triggers its short chain-backend timeout and a restart loop.
/// Leave the wallet intact and locked; the next reconciliation retries readiness.
async fn bitcoin_rpc_ready() -> bool {
let (user, password) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
let client = match reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.build()
{
Ok(client) => client,
Err(_) => return false,
};
let response = client.post(crate::constants::BITCOIN_RPC_URL)
.basic_auth(user, Some(password))
.json(&serde_json::json!({"jsonrpc":"1.0","id":"lnd-readiness","method":"getblockchaininfo","params":[]}))
.send().await;
match response {
Ok(response) if response.status().is_success() => response
.json::<serde_json::Value>()
.await
.is_ok_and(|value| bitcoin_readiness_response(&value)),
_ => false,
}
}
fn bitcoin_readiness_response(value: &serde_json::Value) -> bool {
value.get("error").is_none_or(|e| e.is_null())
&& value
.pointer("/result/blocks")
.and_then(|v| v.as_u64())
.is_some()
&& value
.pointer("/result/initialblockdownload")
.and_then(|v| v.as_bool())
.is_some()
}
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 {
// GetInfo can wait for Bitcoin sync even though the wallet is already
// unlocked. State RPC stays available during that normal startup phase.
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()?;
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
return Ok(());
}
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
match unlock_existing_wallet().await? {
true => {
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
false => {
// Every candidate password was actively rejected: this wallet was
// created with a password this node no longer has, so it can never
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
// locked with an unknown password is already inaccessible, so wipe +
// recreate it on the per-node secret to self-heal at boot.
recreate_wallet_destructively().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
if !bitcoin_rpc_ready().await {
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet unlock");
return Ok(());
}
unlock_existing_wallet_no_wipe().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
if !bitcoin_rpc_ready().await {
tracing::debug!("[lnd] waiting for Bitcoin RPC readiness before wallet initialization");
return Ok(());
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
/// LND data subdirectories holding wallet + channel + graph state. Removing them
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
/// so deletion is destructive — only done once the wallet is already unrecoverable.
const LND_STATE_DIRS: &[&str] = &[
"/var/lib/archipelago/lnd/data/chain",
"/var/lib/archipelago/lnd/data/graph",
];
/// Podman container name for the core LND app (see `compute_container_name`:
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
const LND_CONTAINER: &str = "lnd";
/// Canonical on-host admin macaroon — same path the RPC layer reads.
const LND_ADMIN_MACAROON: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
/// Archipelago data dir (default; not overridden in prod). Holds the
/// `user-stopped.json` that gates health-monitor auto-restart.
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
/// candidate password can open the existing wallet.
async fn recreate_wallet_destructively() -> Result<()> {
tracing::warn!(
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
);
// The health monitor restarts any container it sees stopped; mark LND
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
// Always cleared below so LND auto-recovers normally afterwards.
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
let result = wipe_and_reinit_wallet().await;
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
result
}
async fn wipe_and_reinit_wallet() -> Result<()> {
podman_user_scoped(&["stop", LND_CONTAINER])
.await
.context("stopping lnd before wallet wipe")?;
for dir in LND_STATE_DIRS {
let status = host_sudo(&["rm", "-rf", dir])
.await
.with_context(|| format!("removing {dir}"))?;
if !status.success() {
anyhow::bail!("removing {dir} exited with {status}");
}
}
podman_user_scoped(&["start", LND_CONTAINER])
.await
.context("restarting lnd after wallet wipe")?;
wait_for_wallet_state("NON_EXISTING").await?;
init_wallet_via_rest().await
}
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
/// how the orchestrator/health-monitor manage rootless containers (keeps the
/// container out of the archipelago service's cgroup).
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
let out = tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args(args)
.output()
.await
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
async fn wait_for_wallet_state(target: &str) -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
for _ in 0..120 {
if wallet_state(&client).await.as_deref() == Some(target) {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND did not reach state {target} after wallet wipe")
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
@@ -366,6 +314,9 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
// exactly the nodes least able to afford it. Waiting longer costs nothing —
// a wrong password still exits on the first pass via `all_rejected`.
for _ in 0..UNLOCK_NOT_READY_ATTEMPTS {
if wallet_is_unlocked(wallet_state(&client).await.as_deref()) {
return Ok(true);
}
let mut all_rejected = true;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
@@ -390,14 +341,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
)
}
/// Unlock an existing wallet WITHOUT the destructive fallback.
///
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
/// password can open — correct for a boot path that must self-heal, and exactly
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
/// still wants. Rotation calls this instead, so there is no code path from
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
/// as an error the caller reports, never as a wipe.
/// Unlock the existing wallet, preserving its identity and channel data when
/// passwords are unavailable or rejected. Used by boot and credential rotation.
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
match unlock_existing_wallet().await? {
true => Ok(()),
@@ -408,6 +353,10 @@ pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
}
}
fn wallet_is_unlocked(state: Option<&str>) -> bool {
matches!(state, Some("UNLOCKED" | "RPC_ACTIVE" | "SERVER_ACTIVE"))
}
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
@@ -538,7 +487,7 @@ async fn init_wallet_via_rest() -> Result<()> {
{
UnlockerResponse::Value(seed) => seed,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
unlock_existing_wallet_no_wipe().await?;
return Ok(());
}
};
@@ -569,7 +518,7 @@ async fn init_wallet_via_rest() -> Result<()> {
.await;
}
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
unlock_existing_wallet_no_wipe().await?;
}
}
@@ -1203,3 +1152,44 @@ mod tests {
.is_empty());
}
}
#[cfg(test)]
mod bitcoin_readiness_tests {
use super::bitcoin_readiness_response;
use serde_json::json;
#[test]
fn only_usable_bitcoin_rpc_allows_wallet_unlock() {
for response in [
json!({}),
json!({"error":{"code":-28,"message":"Loading block index"},"result":null}),
json!({"result":{"blocks":null}}),
] {
assert!(!bitcoin_readiness_response(&response));
}
// Initial sync is supported by LND. Loading the database is not.
for ibd in [true, false] {
assert!(bitcoin_readiness_response(
&json!({"result":{"blocks":100,"initialblockdownload":ibd},"error":null})
));
}
}
}
#[cfg(test)]
mod syncing_wallet_state_tests {
#[test]
fn an_unlocked_wallet_waiting_for_chain_sync_is_never_unlocked_again() {
for state in ["UNLOCKED", "RPC_ACTIVE", "SERVER_ACTIVE"] {
assert!(super::wallet_is_unlocked(Some(state)));
}
for state in [
None,
Some("LOCKED"),
Some("NON_EXISTING"),
Some("WAITING_TO_START"),
Some("unknown"),
] {
assert!(!super::wallet_is_unlocked(state));
}
}
}
@@ -0,0 +1,254 @@
//! Consistent, private snapshots for declaratively opted-in runtime migrations.
use anyhow::{bail, Context, Result};
use archipelago_container::AppManifest;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
pub fn enabled(manifest: &AppManifest) -> Result<bool> {
match manifest.app.extensions.get("backup_before_runtime_change") {
None => Ok(false),
Some(value) => value
.as_bool()
.context("backup_before_runtime_change must be boolean"),
}
}
fn relative_sources(manifest: &AppManifest, data_dir: &Path) -> Result<Vec<PathBuf>> {
let mut sources = Vec::new();
for volume in &manifest.app.volumes {
if volume.options.iter().any(|v| v == "ro") || volume.volume_type == "tmpfs" {
continue;
}
// A runtime socket is a connection, not application state.
if volume.source == "/run/user/1000/podman/podman.sock" {
continue;
}
if volume.volume_type != "bind" {
bail!("runtime migration backup requires bind-mounted persistent state");
}
let path = Path::new(&volume.source);
let relative = path
.strip_prefix(data_dir)
.context("runtime migration state must be inside the node data directory")?;
if relative.starts_with("migration-backups") {
bail!("migration backup cannot include its own archive directory");
}
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
bail!("invalid runtime migration state path");
}
sources.push(relative.to_path_buf());
}
sources.sort();
sources.dedup();
let mut roots: Vec<PathBuf> = Vec::new();
for source in sources {
if !roots.iter().any(|root| source.starts_with(root)) {
roots.push(source);
}
}
if roots.is_empty() {
bail!("runtime migration backup has no persistent state mounts");
}
Ok(roots)
}
/// Caller must gracefully stop the app before this function, and resume the old
/// service if it fails. No source files are changed or deleted by this operation.
pub async fn snapshot(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
) -> Result<PathBuf> {
let mut command = tokio::process::Command::new("podman");
command.args(["unshare", "tar"]);
snapshot_with_command(manifest, data_dir, previous_unit, command).await
}
async fn snapshot_with_command(
manifest: &AppManifest,
data_dir: &Path,
previous_unit: Option<&[u8]>,
mut command: tokio::process::Command,
) -> Result<PathBuf> {
let sources = relative_sources(manifest, data_dir)?;
let canonical_root = tokio::fs::canonicalize(data_dir).await?;
for source in &sources {
let path = data_dir.join(source);
if tokio::fs::symlink_metadata(&path)
.await?
.file_type()
.is_symlink()
{
bail!("runtime migration state mount is a symlink; explicit backup required");
}
let canonical = tokio::fs::canonicalize(&path).await?;
if !canonical.starts_with(&canonical_root) {
bail!("runtime migration state path resolves outside node data directory");
}
}
let root = data_dir.join("migration-backups");
tokio::fs::create_dir_all(&root).await?;
tokio::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).await?;
let dir = root.join(uuid::Uuid::new_v4().to_string());
tokio::fs::create_dir(&dir).await?;
tokio::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).await?;
if let Some(unit) = previous_unit {
let path = dir.join("previous.container");
tokio::fs::write(&path, unit).await?;
tokio::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&path).await?.sync_all().await?;
}
let partial = dir.join("state.tar.partial");
let archive = dir.join("state.tar");
let output = command
.args([
"--create",
"--numeric-owner",
"--acls",
"--xattrs",
"--file",
])
.arg(&partial)
.arg("--directory")
.arg(data_dir)
.arg("--")
.args(&sources)
.output()
.await
.context("start rootless migration snapshot")?;
if !output.status.success() {
// No tar stderr in public logs: it can contain private filenames.
let _ = tokio::fs::remove_file(&partial).await;
bail!("persistent-state snapshot failed; original state was left intact");
}
tokio::fs::set_permissions(&partial, std::fs::Permissions::from_mode(0o600)).await?;
tokio::fs::File::open(&partial).await?.sync_all().await?;
tokio::fs::rename(&partial, &archive).await?;
let metadata = serde_json::json!({"app": manifest.app.id, "version": manifest.app.version,
"network": manifest.app.container.network, "capabilities": manifest.app.security.capabilities, "sources": sources});
tokio::fs::write(
dir.join("metadata.json"),
serde_json::to_vec_pretty(&metadata)?,
)
.await?;
tokio::fs::File::open(&dir).await?.sync_all().await?;
Ok(archive)
}
#[cfg(test)]
mod tests {
use super::*;
fn portainer() -> AppManifest {
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap()
}
#[tokio::test]
async fn stopped_state_archive_round_trips_database_compose_and_old_unit() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"fixture database")
.await
.unwrap();
tokio::fs::write(state.join("compose/stack.yml"), b"services: {}\n")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
let archive = snapshot_with_command(
&m,
dir.path(),
Some(b"old unit"),
tokio::process::Command::new("tar"),
)
.await
.unwrap();
assert_eq!(
std::fs::metadata(&archive).unwrap().permissions().mode() & 0o777,
0o600
);
assert_eq!(
tokio::fs::read(archive.parent().unwrap().join("previous.container"))
.await
.unwrap(),
b"old unit"
);
let restored = tempfile::tempdir().unwrap();
assert!(tokio::process::Command::new("tar")
.arg("-xf")
.arg(archive)
.arg("-C")
.arg(restored.path())
.status()
.await
.unwrap()
.success());
assert_eq!(
tokio::fs::read(restored.path().join("portainer/portainer.db"))
.await
.unwrap(),
b"fixture database"
);
assert_eq!(
tokio::fs::read(restored.path().join("portainer/compose/stack.yml"))
.await
.unwrap(),
b"services: {}\n"
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"fixture database"
);
}
#[tokio::test]
async fn failed_snapshot_never_publishes_archive_or_changes_original_state() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("portainer");
tokio::fs::create_dir_all(state.join("compose"))
.await
.unwrap();
tokio::fs::write(state.join("portainer.db"), b"unchanged")
.await
.unwrap();
let mut m = portainer();
m.app.volumes[0].source = state.display().to_string();
m.app.volumes[1].source = state.join("compose").display().to_string();
assert!(
snapshot_with_command(&m, dir.path(), None, tokio::process::Command::new("false"))
.await
.is_err()
);
assert_eq!(
tokio::fs::read(state.join("portainer.db")).await.unwrap(),
b"unchanged"
);
for entry in std::fs::read_dir(dir.path().join("migration-backups")).unwrap() {
assert!(!entry.unwrap().path().join("state.tar").exists());
}
}
#[test]
fn backup_covers_all_portainer_state_once_and_excludes_runtime_socket() {
let m = portainer();
assert!(enabled(&m).unwrap());
assert_eq!(
relative_sources(&m, Path::new("/var/lib/archipelago")).unwrap(),
vec![PathBuf::from("portainer")]
);
}
#[test]
fn backup_refuses_unknown_state_locations_instead_of_silently_omitting_them() {
let mut m = portainer();
m.app.volumes[0].source = "/other/operator/state".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
m.app.volumes[0].source = "/var/lib/archipelago/../secret".into();
assert!(relative_sources(&m, Path::new("/var/lib/archipelago")).is_err());
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod hooks;
pub mod image_policy;
pub mod image_versions;
pub mod lnd;
pub mod migration_backup;
pub mod prod_orchestrator;
pub mod quadlet;
pub mod registry;
File diff suppressed because it is too large Load Diff
+329 -7
View File
@@ -184,6 +184,7 @@ pub struct QuadletUnit {
pub no_new_privileges: bool,
pub cpu_quota: Option<u32>,
pub restart_policy: RestartPolicy,
pub stop_grace_secs: Option<u64>,
}
impl QuadletUnit {
@@ -216,6 +217,10 @@ impl QuadletUnit {
let _ = writeln!(s, "[Container]");
let _ = writeln!(s, "ContainerName={}", self.name);
let _ = writeln!(s, "Image={}", self.image);
let grace = self
.stop_grace_secs
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(&self.name));
let _ = writeln!(s, "StopTimeout={grace}");
// Pull=never: companions are pre-pulled or built. A missing image
// must surface as a unit start failure, not a silent retry storm.
let _ = writeln!(s, "Pull=never");
@@ -350,6 +355,15 @@ impl QuadletUnit {
// the unit stuck in deactivating. Health/status remains app-level state,
// not a systemd start gate.
let _ = writeln!(s, "TimeoutStartSec=0");
let _ = writeln!(s, "TimeoutStopSec={}", grace.saturating_add(15));
// Stop explicitly before Quadlet's generated `podman rm -f`. The
// existing container may still carry Podman's old 10-second default;
// StopTimeout alone only protects containers created after migration.
let _ = writeln!(s, "ExecStop=");
let _ = writeln!(
s,
"ExecStop=/usr/bin/podman stop --ignore --time={grace} --cidfile=%t/%N.cid"
);
// Restart policy + 10s backoff. RestartSec keeps a crash-loop
// from saturating the journal. Companions: Always. Backends:
// OnFailure (clean stops stay stopped).
@@ -376,7 +390,10 @@ fn shell_join(parts: &[String]) -> String {
.iter()
.map(|p| {
let p = p.replace(['\r', '\n'], " ").replace('%', "%%");
if p.is_empty() || p.chars().any(|c| c.is_whitespace() || "\"\\$`".contains(c)) {
if p.is_empty()
|| p.chars()
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = p
.replace('\\', "\\\\")
.replace('"', "\\\"")
@@ -396,7 +413,7 @@ fn quote_environment(env: &str) -> String {
if env.is_empty()
|| env
.chars()
.any(|c| c.is_whitespace() || "\"\\$`".contains(c))
.any(|c| c.is_whitespace() || "'\"\\$`".contains(c))
{
let escaped = env
.replace('\\', "\\\\")
@@ -525,6 +542,9 @@ impl QuadletUnit {
// Always, not OnFailure: with quadlet's `--rm`, OnFailure left a
// cleanly-exited app deleted and unrestarted. See RestartPolicy.
restart_policy: RestartPolicy::Always,
stop_grace_secs: Some(super::prod_orchestrator::resolve_stop_grace_secs(
manifest, name,
)),
}
}
}
@@ -676,6 +696,13 @@ pub async fn unit_exists(name: &str) -> bool {
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
pub async fn unit_dir() -> Result<PathBuf> {
#[cfg(test)]
{
static TEST_UNITS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
return Ok(TEST_UNITS
.get_or_init(|| tempfile::tempdir().unwrap().keep())
.clone());
}
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.ok_or_else(|| anyhow!("HOME not set; cannot locate quadlet unit dir"))?;
@@ -785,7 +812,11 @@ pub async fn stop_service(service: &str) -> Result<()> {
/// corruption — so the orchestrator passes the per-app grace here. Never waits
/// less than `QUADLET_STOP_TIMEOUT`.
pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Result<()> {
let timeout = timeout.max(QUADLET_STOP_TIMEOUT);
let name = service.strip_suffix(".service").unwrap_or(service);
let body = fs::read_to_string(unit_dir().await?.join(format!("{name}.container")))
.await
.unwrap_or_default();
let timeout = timeout.max(stop_wait_timeout(name, &body));
match systemctl_user_status(&["stop", service], timeout).await {
Ok(status) if status.success() => Ok(()),
Ok(status) => Err(anyhow!("systemctl --user stop {service} exited {status}")),
@@ -806,10 +837,29 @@ pub async fn stop_service_with_timeout(service: &str, timeout: Duration) -> Resu
}
}
/// The command waiter must outlive both the container grace and systemd's
/// stop deadline. Restart/repair callers must not kill Bitcoin at 45 seconds.
fn stop_wait_timeout(name: &str, unit_body: &str) -> Duration {
Duration::from_secs(stop_grace_from_unit(name, unit_body).saturating_add(30))
.max(QUADLET_STOP_TIMEOUT)
}
fn stop_grace_from_unit(name: &str, unit_body: &str) -> u64 {
directive_values(unit_body, "StopTimeout=")
.last()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or_else(|| archipelago_container::runtime::stop_grace_secs_for(name))
}
async fn systemctl_user_status(
args: &[&str],
timeout: Duration,
) -> Result<std::process::ExitStatus> {
#[cfg(test)]
{
use std::os::unix::process::ExitStatusExt;
return Ok(std::process::ExitStatus::from_raw(0));
}
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
@@ -856,6 +906,10 @@ async fn wait_not_deactivating(service: &str, timeout: Duration) -> bool {
}
async fn systemctl_user_output(args: &[&str], timeout: Duration) -> Result<std::process::Output> {
#[cfg(test)]
{
anyhow::bail!("Unit tests have no real user service manager");
}
let mut cmd = Command::new("systemctl");
cmd.arg("--user").args(args);
cmd.kill_on_drop(true);
@@ -887,12 +941,77 @@ pub fn health_cmd_changed(old_body: &str, new_body: &str) -> bool {
!= directive_values(new_body, "HealthRetries=")
}
/// A unit rewrite and a successful systemd restart are separate operations.
/// Keep the restart obligation across errors or a management-daemon restart.
pub struct RestartObligation {
marker: PathBuf,
pending: bool,
}
impl RestartObligation {
pub async fn prepare(unit_path: &Path, newly_required: bool) -> Result<Self> {
let marker = unit_path.with_extension("restart-pending");
if newly_required {
// Contents contain no manifest environment or credentials. sync_all
// makes the obligation durable before the subsequent unit rename.
let file = tokio::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(false)
.open(&marker)
.await
.context("record pending Quadlet restart")?;
file.sync_all().await?;
if let Some(parent) = marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
let pending = tokio::fs::try_exists(&marker).await?;
Ok(Self { marker, pending })
}
pub fn is_pending(&self) -> bool {
self.pending
}
/// Call only after systemd accepted the replacement service successfully.
pub async fn complete(self) -> Result<()> {
if self.pending {
tokio::fs::remove_file(&self.marker)
.await
.context("clear completed Quadlet restart")?;
if let Some(parent) = self.marker.parent() {
tokio::fs::File::open(parent).await?.sync_all().await?;
}
}
Ok(())
}
}
pub fn publish_ports_changed(old_body: &str, new_body: &str) -> bool {
let old_ports = directive_values(old_body, "PublishPort=");
let new_ports = directive_values(new_body, "PublishPort=");
old_ports != new_ports
}
pub fn security_changed(old_body: &str, new_body: &str) -> bool {
[
"AddCapability=",
"DropCapability=",
"NoNewPrivileges=",
"ReadOnly=",
"User=",
]
.iter()
.any(|directive| {
let mut old = directive_values(old_body, directive);
let mut new = directive_values(new_body, directive);
old.sort();
new.sort();
old != new
})
}
pub fn network_aliases_changed(old_body: &str, new_body: &str) -> bool {
let old_network = directive_values(old_body, "Network=");
let new_network = directive_values(new_body, "Network=");
@@ -923,6 +1042,10 @@ fn directive_values(unit_body: &str, prefix: &str) -> Vec<String> {
/// that systemd no longer knows about.
pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
let svc = format!("{unit_name}.service");
let path = dir.join(format!("{unit_name}.container"));
let body = fs::read_to_string(&path).await.unwrap_or_default();
let timeout = stop_wait_timeout(unit_name, &body);
let grace = stop_grace_from_unit(unit_name, &body).to_string();
// Stop first; ignore failure (unit may already be down). BOUNDED — on
// rootless podman a generated unit can wedge in "deactivating" while
// `podman rm -f` hangs underneath it, and an unbounded `systemctl stop`
@@ -930,13 +1053,12 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
// the package entry is stranded in `Removing` (a ghost in My Apps that also
// blocks reinstall). If the graceful stop times out, escalate to
// SIGKILL + reset-failed so teardown always proceeds.
if systemctl_user_status(&["stop", &svc], QUADLET_STOP_TIMEOUT)
if systemctl_user_status(&["stop", &svc], timeout)
.await
.is_err()
{
let _ = kill_and_reset_service(&svc).await;
}
let path = dir.join(format!("{unit_name}.container"));
if fs::try_exists(&path).await.unwrap_or(false) {
match fs::remove_file(&path).await {
Ok(()) => {}
@@ -949,9 +1071,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
// Bounded so a hung podman store can't re-introduce the stall this function
// exists to avoid.
let _ = tokio::time::timeout(
QUADLET_STOP_TIMEOUT,
timeout,
Command::new("podman")
.args(["rm", "-f", unit_name])
.args(["rm", "-f", "--ignore", "--time", &grace, unit_name])
.status(),
)
.await;
@@ -960,6 +1082,9 @@ pub async fn disable_remove(unit_name: &str, dir: &Path) -> Result<()> {
/// Is the quadlet-generated service currently active?
pub async fn is_active(service: &str) -> bool {
if cfg!(test) {
return false;
}
Command::new("systemctl")
.args(["--user", "is-active", "--quiet", service])
.status()
@@ -973,6 +1098,118 @@ mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn shutdown_grace_covers_container_systemd_and_caller() {
for (name, grace) in [
("bitcoin-core", 600),
("bitcoin-knots", 600),
("lnd", 330),
("electrumx", 300),
("other", 30),
] {
let unit = QuadletUnit {
name: name.into(),
..Default::default()
};
let body = unit.render();
assert!(body.contains(&format!("StopTimeout={grace}\n")));
assert!(body.contains(&format!("TimeoutStopSec={}\n", grace + 15)));
assert!(body.contains(&format!("podman stop --ignore --time={grace} --cidfile=")));
assert_eq!(
stop_wait_timeout(name, &body),
Duration::from_secs(grace + 30)
);
// Legacy units have no StopTimeout directive yet.
assert_eq!(stop_wait_timeout(name, ""), Duration::from_secs(grace + 30));
}
}
#[test]
fn custom_stop_grace_survives_render_and_restart_budget() {
let manifest: AppManifest = serde_yaml::from_str(
r#"
app:
id: custom-db
name: Custom database
version: 1.0.0
stop_grace_secs: 900
container:
image: example/db:1
"#,
)
.unwrap();
let unit = QuadletUnit::from_manifest(&manifest, "custom-db");
assert_eq!(unit.stop_grace_secs, Some(900));
assert_eq!(
stop_wait_timeout("custom-db", &unit.render()),
Duration::from_secs(930)
);
assert_eq!(
stop_wait_timeout("lnd", "StopTimeout=invalid"),
Duration::from_secs(360)
);
}
#[test]
fn stop_grace_migration_does_not_request_an_execution_restart() {
let unit = sample_unit();
let new = unit.render();
let old = new
.lines()
.filter(|line| {
!line.starts_with("StopTimeout=")
&& !line.starts_with("TimeoutStopSec=")
&& !line.starts_with("ExecStop=")
})
.collect::<Vec<_>>()
.join("\n");
assert!(!exec_changed(&old, &new));
assert!(!publish_ports_changed(&old, &new));
assert!(!network_aliases_changed(&old, &new));
assert!(!health_cmd_changed(&old, &new));
}
#[test]
fn actual_quadlet_generator_stops_before_forced_removal() {
let generator = Path::new("/usr/lib/systemd/system-generators/podman-system-generator");
if !generator.exists() {
eprintln!(
"Quadlet generator unavailable; run this regression on the Linux release host"
);
return;
}
let dir = tempdir().unwrap();
let unit = QuadletUnit {
name: "grace-test".into(),
image: "localhost/test:latest".into(),
stop_grace_secs: Some(600),
..Default::default()
};
std::fs::write(dir.path().join("grace-test.container"), unit.render()).unwrap();
let output = std::process::Command::new(generator)
.args(["--user", "--dryrun"])
.env("QUADLET_UNIT_DIRS", dir.path())
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let generated = String::from_utf8_lossy(&output.stdout).to_string()
+ &String::from_utf8_lossy(&output.stderr);
let stop = generated
.find("ExecStop=/usr/bin/podman stop --ignore --time=600")
.unwrap();
let remove = generated.find("ExecStop=/usr/bin/podman rm ").unwrap();
assert!(
stop < remove,
"Legacy container must stop gracefully before removal"
);
assert!(generated.contains("--stop-timeout 600"));
assert!(generated.contains("TimeoutStopSec=615"));
}
#[test]
fn render_emits_secret_env_by_reference_never_value() {
let u = QuadletUnit {
@@ -1160,6 +1397,18 @@ mod tests {
);
}
#[test]
fn apostrophes_survive_quadlet_argument_and_environment_parsing() {
// A whitespace-free Node script reproduced this in a real Quadlet:
// unquoted apostrophes were consumed by the parser, changing JS strings
// into identifiers and preventing the app from starting.
assert_eq!(
shell_join(&["require('http')".into()]),
"\"require('http')\""
);
assert_eq!(quote_environment("NAME=O'Brien"), "\"NAME=O'Brien\"");
}
#[test]
fn quote_environment_quotes_values_with_spaces() {
assert_eq!(
@@ -1372,6 +1621,26 @@ app:
assert!(!s.contains("Network=host"));
}
#[test]
fn portainer_catalog_network_repairs_same_node_routing_without_exposing_backend() {
let manifest = AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml"))
.expect("shipped Portainer manifest must parse");
let new = QuadletUnit::from_manifest(&manifest, "portainer").render();
assert!(new.contains("Network=slirp4netns\n"));
assert!(!new.contains("NetworkAlias="));
assert!(new.contains("PublishPort=127.0.0.1:9000:9000/tcp"));
assert!(!new.contains("PublishPort=0.0.0.0"));
// The upgrade changes networking only: retain both state mounts and the
// existing rootless socket, without an app.ini or repository rewrite.
assert!(new.contains("Volume=/var/lib/archipelago/portainer:/data"));
assert!(new.contains("Volume=/var/lib/archipelago/portainer/compose:/data/compose"));
assert!(new.contains("Volume=/run/user/1000/podman/podman.sock:/var/run/docker.sock"));
let old = new.replace("Network=slirp4netns\n", "");
assert!(network_aliases_changed(&old, &new));
assert!(!network_aliases_changed(&new, &new));
assert!(!publish_ports_changed(&old, &new));
}
#[test]
fn from_manifest_slirp4netns_omits_network_alias() {
let yaml = r#"
@@ -1722,6 +1991,59 @@ app:
assert!(!network_aliases_changed(new, new));
}
#[tokio::test]
async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
let dir = tempfile::tempdir().unwrap();
let unit = dir.path().join("portainer.container");
tokio::fs::write(&unit, "[Container]\n").await.unwrap();
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
assert!(pending.is_pending());
tokio::fs::write(&unit, "[Container]\nNetwork=slirp4netns\n")
.await
.unwrap();
// Simulate systemctl failure or daemon interruption after unit rewrite.
drop(pending);
let retry = RestartObligation::prepare(&unit, false).await.unwrap();
assert!(
retry.is_pending(),
"matching unit must not discard failed restart"
);
retry.complete().await.unwrap();
assert!(!RestartObligation::prepare(&unit, false)
.await
.unwrap()
.is_pending());
}
#[tokio::test]
async fn pending_runtime_change_errors_are_not_reported_as_success() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing/app.container");
assert!(RestartObligation::prepare(&missing, true).await.is_err());
let unit = dir.path().join("app.container");
let pending = RestartObligation::prepare(&unit, true).await.unwrap();
tokio::fs::remove_file(unit.with_extension("restart-pending"))
.await
.unwrap();
assert!(pending.complete().await.is_err());
}
#[test]
fn gitea_ssh_sandbox_capability_is_applied_as_a_runtime_change() {
let manifest =
AppManifest::parse(include_str!("../../../../apps/gitea/manifest.yml")).unwrap();
manifest.validate().unwrap();
let new = QuadletUnit::from_manifest(&manifest, "gitea").render();
assert!(new.contains("AddCapability=SYS_CHROOT\n"));
let old = new.replace("AddCapability=SYS_CHROOT\n", "");
assert!(security_changed(&old, &new));
assert!(!security_changed(&new, &new));
assert!(!security_changed(
"AddCapability=CHOWN\nAddCapability=SETUID\n",
"AddCapability=SETUID\nAddCapability=CHOWN\n"
));
}
#[test]
fn network_aliases_changed_detects_network_mode_drift() {
let old = "[Container]\nNetwork=slirp4netns\n";
+495 -60
View File
@@ -238,6 +238,11 @@ pub enum ServeResult {
Forbidden,
/// Content not found.
NotFound,
/// The catalog entry and file exist but this node can't read the file.
/// Returned before any payment is taken.
Unavailable,
/// Requested byte range cannot be served; no payment was taken.
RangeNotSatisfiable(u64),
}
/// Serve a content item by ID with access control and optional range request.
@@ -252,6 +257,39 @@ pub async fn serve_content(
range: Option<ByteRange>,
owner_session: bool,
) -> Result<ServeResult> {
serve_content_with(
data_dir,
id,
payment_token,
invoice_hash,
peer_did,
range,
owner_session,
|path, range, mime| prepare_content(data_dir, path, range, mime),
|token, amount| async move { verify_payment_token(data_dir, &token, amount).await },
)
.await
}
// Inject only the read and payment boundaries, so tests can prove ordering
// without mint access, file-permission assumptions or privileged commands.
async fn serve_content_with<R, RF, V, VF>(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
owner_session: bool,
read: R,
verify: V,
) -> Result<ServeResult>
where
R: FnOnce(PathBuf, Option<ByteRange>, String) -> RF,
RF: std::future::Future<Output = Result<ServeResult>>,
V: FnOnce(String, u64) -> VF,
VF: std::future::Future<Output = bool>,
{
let catalog = load_catalog(data_dir).await?;
let item = match catalog.items.iter().find(|i| i.id == id) {
Some(i) => i,
@@ -296,6 +334,47 @@ pub async fn serve_content(
}
}
let file_path = content_file_path(data_dir, item);
if !file_path.exists() {
// The catalog entry survived (it's a separate JSON file) but its
// backing file is gone — most likely lost in an unrelated data-dir
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
// outlived a filebrowser reinstall that wiped the files themselves).
// Leaving the entry in place would keep advertising it as available
// to every peer forever, each hitting the exact same dead end this
// one just did. Prune it so it stops being offered.
warn!(
content_id = %id,
filename = %item.filename,
"content catalog entry's file is missing on disk — pruning the stale entry"
);
prune_missing_content_entry(data_dir, id).await;
return Ok(ServeResult::NotFound);
}
// Refuse unauthorized viewers before opening or reading any bytes.
if !owner_session && matches!(item.access, AccessControl::PeersOnly) && !is_known_peer {
return Ok(ServeResult::Forbidden);
}
if !owner_session {
if let AccessControl::Paid { price_sats, .. } = &item.access {
if payment_token.is_none() && invoice_hash.is_none() {
return Ok(ServeResult::PaymentRequired(*price_sats));
}
}
}
// Finish all file I/O before consuming bearer payment. Merely opening then
// reopening after charging still lost payments on read errors or deletion.
let prepared = match read(file_path, range, item.mime_type.clone()).await {
Ok(result @ (ServeResult::Ok(..) | ServeResult::Partial { .. })) => result,
Ok(other) => return Ok(other),
Err(error) => {
warn!(content_id = %id, "Cannot prepare shared content: {error:#}");
return Ok(ServeResult::Unavailable);
}
};
// Check access control
if !owner_session {
match &item.access {
@@ -307,9 +386,13 @@ pub async fn serve_content(
// Each path only counts when the sharer accepts that method.
let mut authorized = false;
if let Some(token) = payment_token {
if (method_accepted(&item.access, "ecash")
|| method_accepted(&item.access, "fedimint"))
&& verify_payment_token(data_dir, token, *price_sats).await
let method = if token.trim().starts_with("cashu") {
"ecash"
} else {
"fedimint"
};
if method_accepted(&item.access, method)
&& verify(token.to_owned(), *price_sats).await
{
authorized = true;
}
@@ -336,73 +419,127 @@ pub async fn serve_content(
}
}
let file_path = content_file_path(data_dir, item);
if !file_path.exists() {
// The catalog entry survived (it's a separate JSON file) but its
// backing file is gone — most likely lost in an unrelated data-dir
// reset (a shared filebrowser file, 2026-07-01: two catalog entries
// outlived a filebrowser reinstall that wiped the files themselves).
// Leaving the entry in place would keep advertising it as available
// to every peer forever, each hitting the exact same dead end this
// one just did. Prune it so it stops being offered.
warn!(
content_id = %id,
filename = %item.filename,
"content catalog entry's file is missing on disk — pruning the stale entry"
);
prune_missing_content_entry(data_dir, id).await;
return Ok(ServeResult::NotFound);
}
Ok(prepared)
}
let metadata = fs::metadata(&file_path)
async fn prepare_content(
data_dir: &Path,
path: PathBuf,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = match fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(&path)
.await
.context("Failed to read file metadata")?;
let total_size = metadata.len();
// Handle range request for streaming
if let Some(range) = range {
let start = range.start.min(total_size.saturating_sub(1));
let end = range
.end
.map(|e| e.min(total_size - 1))
.unwrap_or(total_size - 1);
if start > end || start >= total_size {
return Ok(ServeResult::NotFound);
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
let bytes = read_filebrowser_via_userns(data_dir, &path).await?;
return slice_prepared_content(bytes, range, mime);
}
let len = (end - start + 1) as usize;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
let mut file = tokio::fs::File::open(&file_path)
Err(error) => return Err(error).context("Opening shared content"),
};
let metadata = file.metadata().await?;
anyhow::ensure!(metadata.is_file(), "Shared content is not a regular file");
let total = metadata.len();
if let Some(range) = range {
let Some((start, end)) = checked_range(&range, total) else {
return Ok(ServeResult::RangeNotSatisfiable(total));
};
file.seek(std::io::SeekFrom::Start(start)).await?;
let len = usize::try_from(end - start + 1).context("Content range is too large")?;
let mut bytes = vec![0; len];
file.read_exact(&mut bytes)
.await
.context("Failed to open content file")?;
file.seek(std::io::SeekFrom::Start(start))
.await
.context("Failed to seek")?;
let mut buf = vec![0u8; len];
file.read_exact(&mut buf)
.await
.context("Failed to read range")?;
debug!(
"Serving content '{}' range {}-{}/{} ({} bytes)",
id, start, end, total_size, len
);
.context("Reading shared content range")?;
return Ok(ServeResult::Partial {
bytes: buf,
mime_type: item.mime_type.clone(),
bytes,
mime_type: mime,
start,
end,
total: total_size,
total,
});
}
let bytes = fs::read(&file_path)
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.await
.context("Failed to read content file")?;
.context("Reading shared content")?;
Ok(ServeResult::Ok(bytes, mime))
}
debug!("Serving content '{}' ({} bytes)", id, bytes.len());
Ok(ServeResult::Ok(bytes, item.mime_type.clone()))
fn checked_range(range: &ByteRange, total: u64) -> Option<(u64, u64)> {
let last = total.checked_sub(1)?;
let end = range.end.unwrap_or(last).min(last);
(range.start <= end && range.start < total).then_some((range.start, end))
}
fn slice_prepared_content(
bytes: Vec<u8>,
range: Option<ByteRange>,
mime: String,
) -> Result<ServeResult> {
let total = bytes.len() as u64;
match range {
None => Ok(ServeResult::Ok(bytes, mime)),
Some(range) => match checked_range(&range, total) {
Some((start, end)) => Ok(ServeResult::Partial {
bytes: bytes[start as usize..=end as usize].to_vec(),
mime_type: mime,
start,
end,
total,
}),
None => Ok(ServeResult::RangeNotSatisfiable(total)),
},
}
}
/// Read only an explicitly shared, regular file within FileBrowser storage.
/// Do not change its mode or grant world-readable access to paid/private data.
async fn filebrowser_read_path(data_dir: &Path, path: &Path) -> Result<PathBuf> {
let root = fs::canonicalize(data_dir.join("filebrowser")).await?;
let target = fs::canonicalize(path).await?;
anyhow::ensure!(
target.starts_with(&root) && target != root,
"Shared file is outside Files storage"
);
anyhow::ensure!(
fs::metadata(&target).await?.is_file(),
"Shared content is not a regular file"
);
Ok(target)
}
async fn read_filebrowser_via_userns(data_dir: &Path, path: &Path) -> Result<Vec<u8>> {
let path = filebrowser_read_path(data_dir, path).await?;
// Tests exercise the boundary explicitly; they never launch the host Podman.
#[cfg(test)]
{
let _ = path;
anyhow::bail!("Files namespace read disabled in unit tests")
}
#[cfg(not(test))]
{
let output = tokio::time::timeout(
std::time::Duration::from_secs(900),
tokio::process::Command::new("podman")
.args(["unshare", "cat", "--"])
.arg(path)
.kill_on_drop(true)
.output(),
)
.await
.context("Files namespace read timed out")??;
anyhow::ensure!(
output.status.success(),
"Files namespace read failed: {}",
output.status
);
Ok(output.stdout)
}
}
/// Result of attempting to serve a preview.
@@ -573,7 +710,7 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
}
/// Verify a payment token covers the required amount.
/// Accepts both cashuA tokens (real Cashu) and legacy cashuSend_ format.
/// Accepts real Cashu tokens and Fedimint notes.
/// Swaps proofs at the mint to verify they're unspent before accepting.
async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64) -> bool {
match crate::wallet::ecash::verify_and_receive_payment(data_dir, token, required_sats).await {
@@ -725,3 +862,301 @@ mod prune_missing_content_tests {
assert_eq!(reloaded.items[0].id, "present-item");
}
}
#[cfg(test)]
mod paid_read_order_tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
async fn fixture(bytes: &[u8]) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("content/files"))
.await
.unwrap();
fs::write(dir.path().join("content/files/test.bin"), bytes)
.await
.unwrap();
save_catalog(
dir.path(),
&ContentCatalog {
items: vec![ContentItem {
id: "paid".into(),
filename: "test.bin".into(),
mime_type: "application/octet-stream".into(),
size_bytes: bytes.len() as u64,
description: String::new(),
access: AccessControl::Paid {
price_sats: 10,
accepted: vec!["ecash".into()],
},
availability: Availability::AllPeers,
added_at: "2026-09-30".into(),
}],
},
)
.await
.unwrap();
dir
}
#[tokio::test]
async fn all_read_failures_precede_redemption_even_as_root() {
for kind in [
std::io::ErrorKind::PermissionDenied,
std::io::ErrorKind::UnexpectedEof,
std::io::ErrorKind::NotFound,
std::io::ErrorKind::Other,
] {
let dir = fixture(b"abc").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|_, _, _| async move { Err(std::io::Error::from(kind).into()) },
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
true
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
assert_eq!(charged.load(Ordering::SeqCst), 0);
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 1);
}
}
#[tokio::test]
async fn deletion_during_payment_cannot_lose_prepared_bytes() {
let dir = fixture(b"original").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, amount| {
assert_eq!(amount, 10);
async {
fs::remove_file(dir.path().join("content/files/test.bin"))
.await
.unwrap();
true
}
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"original"));
}
#[tokio::test]
async fn empty_out_of_bounds_and_reversed_ranges_never_charge() {
for (bytes, start, end) in [
(b"".as_slice(), 0, None),
(b"abc".as_slice(), 3, None),
(b"abc".as_slice(), 2, Some(1)),
] {
let dir = fixture(bytes).await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange { start, end }),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("invalid range reached payment") },
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::RangeNotSatisfiable(n) if n == bytes.len() as u64)
);
}
}
#[tokio::test]
async fn prepared_range_survives_file_change_while_payment_is_verified() {
let dir = fixture(b"abcdef").await;
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
Some(ByteRange {
start: 2,
end: Some(999),
}),
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
fs::write(dir.path().join("content/files/test.bin"), b"x")
.await
.unwrap();
true
},
)
.await
.unwrap();
assert!(
matches!(result, ServeResult::Partial { bytes, start: 2, end: 5, total: 6, .. } if bytes == b"cdef")
);
}
#[tokio::test]
async fn payment_denial_never_returns_prepared_content() {
let dir = fixture(b"secret").await;
let charged = AtomicUsize::new(0);
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async {
charged.fetch_add(1, Ordering::SeqCst);
false
},
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
assert_eq!(charged.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn missing_payment_and_peer_restrictions_precede_file_reads() {
let dir = fixture(b"secret").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::PaymentRequired(10)));
let mut catalog = load_catalog(dir.path()).await.unwrap();
catalog.items[0].access = AccessControl::PeersOnly;
save_catalog(dir.path(), &catalog).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
false,
|_, _, _| async { panic!("unauthorized file read") },
|_, _| async { panic!("unexpected payment") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Forbidden));
}
#[tokio::test]
async fn owner_reads_paid_content_without_redemption() {
let dir = fixture(b"own file").await;
let result = serve_content_with(
dir.path(),
"paid",
None,
None,
None,
None,
true,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("owner charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Ok(bytes, _) if bytes == b"own file"));
}
#[tokio::test]
async fn directory_in_place_of_file_does_not_charge() {
let dir = fixture(b"abc").await;
let path = dir.path().join("content/files/test.bin");
fs::remove_file(&path).await.unwrap();
fs::create_dir(&path).await.unwrap();
let result = serve_content_with(
dir.path(),
"paid",
Some("cashuBtest"),
None,
None,
None,
false,
|path, range, mime| prepare_content(dir.path(), path, range, mime),
|_, _| async { panic!("directory charged") },
)
.await
.unwrap();
assert!(matches!(result, ServeResult::Unavailable));
}
#[tokio::test]
async fn files_namespace_read_is_scoped_to_regular_files_and_keeps_mode() {
use std::os::unix::fs::{symlink, PermissionsExt};
let dir = fixture(b"outside").await;
let root = dir.path().join("filebrowser");
fs::create_dir(&root).await.unwrap();
let inside = root.join("song");
fs::write(&inside, b"song").await.unwrap();
fs::set_permissions(&inside, std::fs::Permissions::from_mode(0o640))
.await
.unwrap();
assert_eq!(
filebrowser_read_path(dir.path(), &inside).await.unwrap(),
inside
);
assert_eq!(
fs::metadata(&inside).await.unwrap().permissions().mode() & 0o777,
0o640
);
let outside = dir.path().join("content/files/test.bin");
symlink(&outside, root.join("escape")).unwrap();
for path in [outside, root.join("escape"), root.clone()] {
assert!(filebrowser_read_path(dir.path(), &path).await.is_err());
}
}
#[test]
fn user_namespace_bytes_use_the_same_range_rules() {
assert!(matches!(
slice_prepared_content(
vec![],
Some(ByteRange {
start: 0,
end: None
}),
"x".into()
)
.unwrap(),
ServeResult::RangeNotSatisfiable(0)
));
assert!(
matches!(slice_prepared_content(b"abc".to_vec(), Some(ByteRange { start: 1, end: None }), "x".into()).unwrap(), ServeResult::Partial { bytes, start: 1, end: 2, total: 3, .. } if bytes == b"bc")
);
}
}
+38 -1
View File
@@ -194,6 +194,7 @@ pub async fn clear_user_stopped(data_dir: &Path, name: &str) {
// Installation is a decision, not a runtime observation, so it gets a record
// of its own that no amount of downtime erodes.
const INSTALLED_APPS_FILE: &str = "installed-apps.json";
static INSTALLED_APPS_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// Load the durable set of installed app ids / container names.
pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<String> {
@@ -220,12 +221,23 @@ pub async fn load_installed_apps_if_recorded(
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) {
let _ = fs::write(&path, json).await;
let tmp = path.with_extension("json.tmp");
let result = async {
fs::write(&tmp, json).await?;
fs::File::open(&tmp).await?.sync_all().await?;
fs::rename(&tmp, &path).await?;
fs::File::open(data_dir).await?.sync_all().await
}
.await;
if let Err(error) = result {
warn!(%error, "could not persist installed apps");
}
}
}
/// Record that an app is installed. Called when an install succeeds.
pub async fn mark_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await;
if installed.insert(name.to_string()) {
save_installed_apps(data_dir, &installed).await;
@@ -235,6 +247,7 @@ pub async fn mark_installed(data_dir: &Path, name: &str) {
/// Forget an app. Called on uninstall, beside `mark_user_uninstalled` — the
/// two must move together or a reinstall-after-uninstall leaves a stale claim.
pub async fn clear_installed(data_dir: &Path, name: &str) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
let mut installed = load_installed_apps(data_dir).await;
if installed.remove(name) {
save_installed_apps(data_dir, &installed).await;
@@ -252,6 +265,7 @@ pub async fn clear_installed(data_dir: &Path, name: &str) {
/// need it. Runs on every boot, so an app installed before the upgrade is
/// still picked up whenever it is next seen alive.
pub async fn backfill_installed_apps(data_dir: &Path, present_container_names: &[String]) {
let _guard = INSTALLED_APPS_LOCK.lock().await;
if present_container_names.is_empty() {
return;
}
@@ -1497,3 +1511,26 @@ mod tests {
);
}
}
#[cfg(test)]
mod installed_concurrency_tests {
use super::*;
#[tokio::test]
async fn concurrent_install_records_are_not_lost() {
let dir = tempfile::tempdir().unwrap();
let mut tasks = Vec::new();
for i in 0..24 {
let path = dir.path().to_owned();
tasks.push(tokio::spawn(async move {
mark_installed(&path, &format!("app-{i}")).await;
}));
}
for task in tasks {
task.await.unwrap();
}
assert_eq!(load_installed_apps(dir.path()).await.len(), 24);
clear_installed(dir.path(), "app-3").await;
assert_eq!(load_installed_apps(dir.path()).await.len(), 23);
assert!(!dir.path().join("installed-apps.json.tmp").exists());
}
}
+6
View File
@@ -146,6 +146,10 @@ pub enum PackageState {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PackageDataEntry {
/// Whether the app's HTTP upstream answered this scan (independent of
/// container health and blockchain sync). Missing on older nodes.
#[serde(rename = "ui-ready", default, skip_serializing_if = "Option::is_none")]
pub ui_ready: Option<bool>,
pub state: PackageState,
/// Container health: "healthy", "unhealthy", "starting", or null
#[serde(skip_serializing_if = "Option::is_none")]
@@ -297,6 +301,8 @@ pub enum InstallPhase {
/// `podman pull` in progress (the longest phase — up to several
/// minutes for large images on slow networks).
PullingImage,
/// Orchestrator owns download/build and startup as one operation.
PreparingApp,
/// Creating data directories, writing app-specific configs
/// (bitcoin.conf, lnd.conf, searxng settings.yml, chown).
CreatingContainer,
+40 -3
View File
@@ -5,8 +5,45 @@
//! are reachable over the mesh; ports of apps that aren't installed have
//! no listener, so allowing them is inert.
#[rustfmt::skip]
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
2283,
2342,
3000,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8090,
8091,
8096,
8123,
8175,
8176,
8187,
8240,
8334,
8336,
8337,
8888,
8998,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
18091,
23000,
32838,
50002,
];
+268 -7
View File
@@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// Is this FIPS answer the final one, or should the request go again over
/// Tor? A single-delivery request already reached the peer, so any answer
/// is final: a Tor replay would carry the same (possibly spent) payload.
fn fips_answer_is_final(
pref: crate::settings::transport::TransportPref,
single_delivery: bool,
status: reqwest::StatusCode,
) -> bool {
pref == crate::settings::transport::TransportPref::Fips
|| single_delivery
|| !fips_should_fall_back(status)
}
/// May a failed FIPS attempt be sent again? Only a failed connect proves the
/// peer never saw it; a timeout can land after the request was delivered.
fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool {
e.is_connect() || (!single_delivery && e.is_timeout())
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -113,7 +132,21 @@ pub fn client() -> reqwest::Client {
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
client_with_delivery_policy(timeout, false)
}
fn delivery_redirect_policy(single: bool) -> reqwest::redirect::Policy {
if single {
reqwest::redirect::Policy::none()
} else {
reqwest::redirect::Policy::default()
}
}
fn client_with_delivery_policy(timeout: Duration, single: bool) -> reqwest::Client {
reqwest::Client::builder()
.no_proxy()
.redirect(delivery_redirect_policy(single))
.timeout(timeout)
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
@@ -130,10 +163,18 @@ pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
send_with_retry_if(rb, |e| e.is_connect() || e.is_timeout()).await
}
/// [`send_with_retry`], retrying only on errors `retryable` accepts.
async fn send_with_retry_if(
rb: reqwest::RequestBuilder,
retryable: impl Fn(&reqwest::Error) -> bool,
) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
Err(e) if retryable(&e) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
@@ -350,6 +391,9 @@ pub struct PeerRequest<'a> {
/// the per-peer FIPS/Tor badge reflects reality. Opt-in because not
/// every caller has a data dir in scope.
pub record_data_dir: Option<std::path::PathBuf>,
/// The request carries something that must reach the peer at most once
/// (a bearer ecash token). See [`PeerRequest::single_delivery`].
pub single_delivery: bool,
}
impl<'a> PeerRequest<'a> {
@@ -363,9 +407,25 @@ impl<'a> PeerRequest<'a> {
fips_timeout: None,
service: None,
record_data_dir: None,
single_delivery: false,
}
}
/// Never send this request twice. A paid download carries a bearer ecash
/// token that the seller redeems on first sight; replaying it over Tor
/// after FIPS already delivered it hands the seller a spent token, so the
/// buyer is charged and gets a 402 instead of the file (2026-09-29: FIPS
/// answered 404 after the seller redeemed, the Tor retry got 402).
///
/// With this set, whatever FIPS answers is final, the FIPS retry fires
/// only when the first attempt never connected, and Tor is used only when
/// FIPS could not have delivered the request. An attempt that may have
/// been delivered but timed out is an error, not a fallback.
pub fn single_delivery(mut self) -> Self {
self.single_delivery = true;
self
}
/// Record the transport that serves this request into federation storage
/// (matched by this request's onion host). Best-effort, off the hot path.
pub fn record_transport(mut self, data_dir: impl Into<std::path::PathBuf>) -> Self {
@@ -442,7 +502,7 @@ impl<'a> PeerRequest<'a> {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -481,7 +541,7 @@ impl<'a> PeerRequest<'a> {
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
if fips_answer_is_final(pref, self.single_delivery, resp.status()) {
telemetry::record_fips_ok();
self.spawn_record(crate::transport::TransportKind::Fips);
return Ok((resp, crate::transport::TransportKind::Fips));
@@ -551,13 +611,21 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let mut rb = c.post(&url).json(body);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
Ok(Ok(r)) => Ok(Some(r)),
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS POST failed after possible delivery; not replaying: {e}"
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS POST exceeded its budget after possible delivery; not replaying"
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -612,13 +680,28 @@ impl<'a> PeerRequest<'a> {
} else {
budget
};
let c = client_with_timeout(per_attempt);
let c = client_with_delivery_policy(per_attempt, self.single_delivery);
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match tokio::time::timeout(budget, send_with_retry(rb)).await {
let single = self.single_delivery;
let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e));
match tokio::time::timeout(budget, attempt).await {
Ok(Ok(r)) => Ok(Some(r)),
// Anything but a failed connect may have reached the peer.
Ok(Err(e)) if single && !e.is_connect() => Err(anyhow::anyhow!(
"FIPS GET {} failed after the request may have been delivered \
(not retrying over Tor): {}",
self.path,
e
)),
Err(_) if single => Err(anyhow::anyhow!(
"FIPS GET {} exceeded its {:?} budget after the request may have \
been delivered (not retrying over Tor)",
self.path,
budget
)),
Ok(Err(e)) => {
telemetry::record_fallback(FallbackReason::ConnectFail);
tracing::info!(
@@ -676,6 +759,7 @@ impl<'a> PeerRequest<'a> {
.context("Invalid Tor SOCKS proxy URL")?;
reqwest::Client::builder()
.proxy(proxy)
.redirect(delivery_redirect_policy(self.single_delivery))
.timeout(self.timeout)
.build()
.context("Build Tor HTTP client")
@@ -759,4 +843,181 @@ mod tests {
let err = decode_response(0xAABB, &r, "x").unwrap_err();
assert!(err.to_string().contains("no AAAA"));
}
#[test]
fn a_single_delivery_answer_is_final_whatever_its_status() {
use crate::settings::transport::TransportPref;
use reqwest::StatusCode;
// Regression (2026-09-29): the seller redeemed a paid download's
// token, answered 404, and the Tor fallback replayed the spent token.
for status in [
StatusCode::NOT_FOUND,
StatusCode::INTERNAL_SERVER_ERROR,
StatusCode::SERVICE_UNAVAILABLE,
StatusCode::OK,
] {
assert!(fips_answer_is_final(TransportPref::Auto, true, status));
}
// Everything else keeps the existing fallback rules.
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::NOT_FOUND
));
assert!(!fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::BAD_GATEWAY
));
assert!(fips_answer_is_final(
TransportPref::Auto,
false,
StatusCode::PAYMENT_REQUIRED
));
assert!(fips_answer_is_final(
TransportPref::Fips,
false,
StatusCode::NOT_FOUND
));
}
/// A listener that accepts connections and never answers, counting them.
async fn silent_peer() -> (String, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let counter = seen.clone();
tokio::spawn(async move {
let mut held = Vec::new();
while let Ok((stream, _)) = listener.accept().await {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
held.push(stream); // keep it open, never reply
}
});
(format!("http://{addr}/content/x"), seen)
}
#[tokio::test]
async fn a_single_delivery_request_is_not_resent_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e))
.await
.expect_err("peer never answers");
assert!(err.is_timeout());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn an_ordinary_request_is_still_retried_once_after_a_timeout() {
let (url, seen) = silent_peer().await;
let c = client_with_timeout(Duration::from_millis(300));
let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await;
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[tokio::test]
async fn a_single_delivery_request_still_retries_a_refused_connect() {
// Nothing listening: the peer provably never saw the request.
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
let c = client_with_timeout(Duration::from_millis(500));
let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| {
fips_retryable(true, e)
})
.await
.expect_err("nothing listening");
assert!(err.is_connect());
assert!(fips_retryable(true, &err));
}
}
#[cfg(test)]
mod delivery_redirect_tests {
use super::*;
use hyper::{
service::{make_service_fn, service_fn},
Body, Response, Server,
};
use std::{
convert::Infallible,
sync::{
atomic::{AtomicUsize, Ordering},
Arc,
},
};
#[tokio::test]
async fn paid_bearer_request_does_not_follow_redirects_but_normal_get_does() {
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let server = Server::bind(&([127, 0, 0, 1], 0).into());
let address = server.local_addr();
let service = make_service_fn(move |_| {
let counter = counter.clone();
async move {
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
let counter = counter.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
let response = if request.uri().path() == "/first" {
Response::builder()
.status(302)
.header("Location", "/replay")
.body(Body::empty())
.unwrap()
} else {
Response::new(Body::from("replayed"))
};
Ok::<_, Infallible>(response)
}
}))
}
});
let task = tokio::spawn(server.serve(service));
let url = format!("http://{address}/first");
let response = client_with_delivery_policy(Duration::from_secs(2), true)
.get(&url)
.header("X-Payment-Token", "dummy-test-token")
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::FOUND);
assert_eq!(seen.load(Ordering::SeqCst), 1);
let response = client_with_delivery_policy(Duration::from_secs(2), false)
.get(url)
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::OK);
assert_eq!(seen.load(Ordering::SeqCst), 3);
task.abort();
}
#[tokio::test]
async fn paid_request_is_not_resent_when_peer_disconnects_after_reading_it() {
use tokio::io::AsyncReadExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let seen = Arc::new(AtomicUsize::new(0));
let counter = seen.clone();
let task = tokio::spawn(async move {
while let Ok((mut stream, _)) = listener.accept().await {
let mut buf = [0; 4096];
let _ = stream.read(&mut buf).await;
counter.fetch_add(1, Ordering::SeqCst);
drop(stream);
}
});
let c = client_with_delivery_policy(Duration::from_secs(2), true);
let error = send_with_retry_if(c.get(format!("http://{address}/")), |e| {
fips_retryable(true, e)
})
.await
.unwrap_err();
assert!(!error.is_connect());
assert_eq!(seen.load(Ordering::SeqCst), 1);
task.abort();
}
}
+66 -9
View File
@@ -53,8 +53,8 @@ fn container_tier(name: &str) -> StartupTier {
| "indeedhub-api" => StartupTier::DependentService,
// Tier 4: Frontend/UI
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "penpot-frontend"
| "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
"mempool-web" | "bitcoin-ui" | "lnd-ui" | "electrs-ui" | "cuprate-ui"
| "penpot-frontend" | "penpot-exporter" | "indeedhub" => StartupTier::Frontend,
// Tier 3: Application layer (everything else)
_ => StartupTier::Application,
@@ -501,12 +501,12 @@ async fn check_containers() -> Vec<ContainerHealth> {
out
}
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<std::net::SocketAddr> {
let Some(ports) = c.get("Ports").and_then(|v| v.as_array()) else {
return Vec::new();
};
let mut out: Vec<u16> = ports
let mut out: Vec<std::net::SocketAddr> = ports
.iter()
.filter(|p| {
p.get("protocol")
@@ -515,9 +515,19 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
.eq_ignore_ascii_case("tcp")
})
.filter_map(|p| {
p.get("host_port")
.and_then(|v| v.as_u64())
.and_then(|port| u16::try_from(port).ok())
let port = p.get("host_port")?.as_u64()?;
let port = u16::try_from(port).ok().filter(|port| *port != 0)?;
let bind = p.get("host_ip").and_then(|v| v.as_str()).unwrap_or("");
// Wildcard listeners are reachable through the corresponding
// loopback family. Explicit binds must be probed at that address:
// probing a WireGuard-only port on 127.0.0.1 creates false failures
// and endlessly restarts an otherwise healthy app.
let address: std::net::IpAddr = match bind {
"" | "0.0.0.0" => "127.0.0.1".parse().ok()?,
"::" => "::1".parse().ok()?,
explicit => explicit.parse().ok()?,
};
Some(std::net::SocketAddr::new(address, port))
})
.collect();
out.sort_unstable();
@@ -525,11 +535,11 @@ fn host_tcp_ports_from_container(c: &serde_json::Value) -> Vec<u16> {
out
}
async fn host_ports_ready(ports: &[u16]) -> bool {
async fn host_ports_ready(ports: &[std::net::SocketAddr]) -> bool {
for port in ports {
let ready = tokio::time::timeout(
std::time::Duration::from_secs(2),
tokio::net::TcpStream::connect(("127.0.0.1", *port)),
tokio::net::TcpStream::connect(*port),
)
.await
.is_ok_and(|r| r.is_ok());
@@ -1662,4 +1672,51 @@ mod tests {
"Prefetcher:catching up to daemon height 953,480"
));
}
#[test]
fn published_port_probes_preserve_explicit_bind_addresses() {
let c = serde_json::json!({"Ports": [
{"host_ip":"127.0.0.1","host_port":8081,"protocol":"tcp"},
{"host_ip":"10.77.0.2","host_port":18081,"protocol":"tcp"},
{"host_ip":"10.77.0.2","host_port":18443,"protocol":"tcp"},
{"host_ip":"::1","host_port":8082,"protocol":"tcp"}
]});
let targets = host_tcp_ports_from_container(&c);
for target in [
"127.0.0.1:8081",
"10.77.0.2:18081",
"10.77.0.2:18443",
"[::1]:8082",
] {
assert!(targets.contains(&target.parse().unwrap()));
}
assert!(!targets.contains(&"127.0.0.1:18081".parse().unwrap()));
}
#[test]
fn published_port_probes_normalize_wildcards_and_ignore_invalid_entries() {
let c = serde_json::json!({"Ports": [
{"host_ip":"0.0.0.0","host_port":8080},
{"host_ip":"","host_port":8080},
{"host_ip":"::","host_port":8080},
{"host_ip":"10.0.0.1","host_port":53,"protocol":"udp"},
{"host_ip":"bad","host_port":8080},
{"host_port":0}, {"host_port":65536}, {"container_port":80}
]});
let targets = host_tcp_ports_from_container(&c);
assert_eq!(targets.len(), 2);
assert!(targets.contains(&"127.0.0.1:8080".parse().unwrap()));
assert!(targets.contains(&"[::1]:8080".parse().unwrap()));
}
#[tokio::test]
async fn health_probe_reaches_non_default_loopback_and_detects_closed_port() {
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await.unwrap();
let address = listener.local_addr().unwrap();
assert!(host_ports_ready(&[address]).await);
// Same port, wrong local address reproduces the former false failure.
let wrong = std::net::SocketAddr::new("127.0.0.1".parse().unwrap(), address.port());
assert!(!host_ports_ready(&[wrong]).await);
drop(listener);
assert!(!host_ports_ready(&[address]).await);
}
}
+4
View File
@@ -256,6 +256,10 @@ async fn main() -> Result<()> {
boot_report.recovered, boot_report.total, boot_report.failed
);
}
// Disk manifests must be stable before the initial load and all later
// catalog reloads. Do not move this into the background doctor bootstrap.
bootstrap::ensure_runtime_assets_ready().await;
// 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).
+86 -18
View File
@@ -1765,12 +1765,17 @@ fn merge_preserving_transitional(
};
crate::data_model::PackageDataEntry {
state,
state: state.clone(),
// install_progress and uninstall_stage are also owned by the
// initiating op (same reason as state) — keep them.
install_progress: existing.install_progress.clone(),
uninstall_stage: existing.uninstall_stage.clone(),
// Everything else comes from the fresh scan.
ui_ready: if state == crate::data_model::PackageState::Running {
fresh.ui_ready
} else {
Some(false)
},
health: fresh.health.clone(),
exit_code: fresh.exit_code,
static_files: fresh.static_files.clone(),
@@ -1809,7 +1814,10 @@ async fn scan_and_update_packages(
absence_tracker: &mut HashMap<String, u32>,
transitional_since: &mut HashMap<String, Instant>,
) -> Result<()> {
let mut packages = scanner.scan_containers().await?;
let (before_scan, _) = state.get_snapshot().await;
let mut packages = scanner
.scan_containers(data_dir, &before_scan.package_data)
.await?;
let user_stopped = crate::crash_recovery::load_user_stopped(data_dir).await;
for (id, pkg) in packages.iter_mut() {
if pkg.state == crate::data_model::PackageState::Exited && user_stopped.contains(id) {
@@ -1870,11 +1878,14 @@ async fn scan_and_update_packages(
// once at load ~2). Better to keep saying "scanning…" than to say "empty".
if packages.is_empty() && (!first_scan || !installed_registry.is_empty()) {
if tor_changed || update_changed {
let mut data = current_data;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
state
.mutate_data(|data| {
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address =
tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.updated = update_available;
})
.await;
}
return Ok(());
}
@@ -1899,6 +1910,13 @@ async fn scan_and_update_packages(
// died without cleanup and let the scan override it.
let now = Instant::now();
for (id, pkg) in &packages {
if user_uninstalled.contains(id)
|| user_uninstalled.contains(&format!("archy-{id}"))
|| (before_scan.package_data.contains_key(id)
&& !current_data.package_data.contains_key(id))
{
continue;
}
absence_tracker.remove(id);
let existing = merged.get(id);
let overwrite = match existing {
@@ -2054,22 +2072,40 @@ async fn scan_and_update_packages(
}
if changed || tor_changed || first_scan || update_changed {
let mut data = current_data;
data.package_data = merged;
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
state.update_data(data).await;
debug!(
"📦 State changed (packages={}, tor={}, first_scan={}, update={}), broadcasting update",
changed, tor_changed, first_scan, update_changed
);
state
.mutate_data(|data| {
// A lifecycle operation may have started/finished while this scan
// awaited probes or disk I/O. Never overwrite that newer entry or
// resurrect one that an uninstall removed in the meantime.
apply_scanned_packages(&mut data.package_data, &current_data.package_data, &merged);
data.server_info.tor_address = tor_addr.clone();
data.server_info.node_address = tor_addr.as_ref().map(|t| identity.node_address(t));
data.server_info.status_info.containers_scanned = true;
data.server_info.status_info.updated = update_available;
})
.await;
}
Ok(())
}
fn apply_scanned_packages(
latest: &mut HashMap<String, crate::data_model::PackageDataEntry>,
base: &HashMap<String, crate::data_model::PackageDataEntry>,
scanned: &HashMap<String, crate::data_model::PackageDataEntry>,
) {
for (id, fresh) in scanned {
if latest.get(id) == base.get(id) {
latest.insert(id.clone(), fresh.clone());
}
}
for id in base.keys() {
if !scanned.contains_key(id) && latest.get(id) == base.get(id) {
latest.remove(id);
}
}
}
async fn normalize_reachable_package_health(
packages: &mut HashMap<String, crate::data_model::PackageDataEntry>,
) {
@@ -2268,6 +2304,7 @@ mod merge_tests {
fn make_entry(state: PackageState, health: Option<&str>) -> PackageDataEntry {
PackageDataEntry {
ui_ready: None,
state,
health: health.map(|s| s.to_string()),
exit_code: None,
@@ -2280,6 +2317,37 @@ mod merge_tests {
}
}
#[test]
fn stale_scan_cannot_remove_new_installs_or_overwrite_lifecycle_changes() {
let running = make_entry(PackageState::Running, Some("healthy"));
let restarting = make_entry(PackageState::Restarting, None);
let base = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
let mut latest = [
("restart".into(), restarting.clone()),
("new".into(), running.clone()),
]
.into_iter()
.collect();
let scanned = [
("restart".into(), running.clone()),
("uninstalled".into(), running.clone()),
]
.into_iter()
.collect();
apply_scanned_packages(&mut latest, &base, &scanned);
assert_eq!(latest.get("restart"), Some(&restarting));
assert_eq!(latest.get("new"), Some(&running));
assert!(!latest.contains_key("uninstalled"));
apply_scanned_packages(&mut latest, &base, &HashMap::new());
assert_eq!(latest.get("restart"), Some(&restarting));
assert!(latest.contains_key("new"));
}
#[test]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
@@ -0,0 +1,51 @@
//! Install-time pruning preference, shared by Bitcoin Core and Knots.
//! Missing preference preserves the existing disk-based automatic selection.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Default, Serialize, Deserialize)]
pub struct BitcoinStorage {
pub prune: bool,
}
pub async fn load(data_dir: &Path) -> Result<BitcoinStorage> {
match tokio::fs::read(data_dir.join("settings/bitcoin-storage.json")).await {
Ok(bytes) => serde_json::from_slice(&bytes).context("Invalid Bitcoin storage settings"),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BitcoinStorage::default()),
Err(e) => Err(e.into()),
}
}
pub async fn save(data_dir: &Path, prune: bool) -> Result<()> {
let dir = data_dir.join("settings");
tokio::fs::create_dir_all(&dir).await?;
let path = dir.join("bitcoin-storage.json");
let temporary = dir.join("bitcoin-storage.json.tmp");
tokio::fs::write(&temporary, serde_json::to_vec(&BitcoinStorage { prune })?).await?;
tokio::fs::rename(temporary, path).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn missing_setting_keeps_auto_and_explicit_pruning_survives_reload() {
let dir = tempfile::tempdir().unwrap();
assert!(!load(dir.path()).await.unwrap().prune);
save(dir.path(), true).await.unwrap();
assert!(load(dir.path()).await.unwrap().prune);
save(dir.path(), false).await.unwrap();
assert!(!load(dir.path()).await.unwrap().prune);
}
#[tokio::test]
async fn corrupt_setting_is_not_silently_changed_to_archival() {
let dir = tempfile::tempdir().unwrap();
save(dir.path(), true).await.unwrap();
tokio::fs::write(dir.path().join("settings/bitcoin-storage.json"), "broken")
.await
.unwrap();
assert!(load(dir.path()).await.is_err());
}
}
+2
View File
@@ -7,3 +7,5 @@
pub mod ai_permissions;
pub mod session_policy;
pub mod transport;
pub mod bitcoin_storage;
+41
View File
@@ -54,6 +54,21 @@ impl StateManager {
let _ = self.broadcast_tx.send(message);
}
/// Apply a small state change while holding the write lock. A lifecycle
/// task must not replace the entire model from an earlier snapshot.
pub async fn mutate_data<T>(&self, change: impl FnOnce(&mut DataModel) -> T) -> T {
let mut data = self.data.write().await;
let result = change(&mut data);
let mut rev = self.revision.write().await;
*rev += 1;
let _ = self.broadcast_tx.send(WebSocketMessage {
rev: *rev,
data: Some(data.clone()),
patch: None,
});
result
}
/// Get a WebSocket message with the current state
pub async fn get_initial_message(&self) -> WebSocketMessage {
let (data, rev) = self.get_snapshot().await;
@@ -190,3 +205,29 @@ mod tests {
assert_eq!(rev, 1);
}
}
#[cfg(test)]
mod atomic_mutation_tests {
use super::*;
#[tokio::test]
async fn concurrent_updates_preserve_independent_entries() {
let state = Arc::new(StateManager::new());
let mut tasks = Vec::new();
for i in 0..24 {
let state = state.clone();
tasks.push(tokio::spawn(async move {
state
.mutate_data(|data| {
data.peer_health.insert(format!("peer-{i}"), true);
})
.await;
}));
}
for task in tasks {
task.await.unwrap();
}
let (data, revision) = state.get_snapshot().await;
assert_eq!(data.peer_health.len(), 24);
assert_eq!(revision, 24);
}
}
+65 -20
View File
@@ -1481,6 +1481,21 @@ pub async fn cancel_download(data_dir: &Path) -> Result<()> {
/// service unit that inherits systemd's default protections (i.e. none
/// of ours), escaping the namespace.
pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus> {
#[cfg(test)]
{
anyhow::ensure!(
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
"Host-operation tests require scripts/test-backend-isolated.sh"
);
let (program, args) = args.split_first().context("Missing test command")?;
// Run inside the test namespace, never escape through sudo/systemd-run.
return tokio::process::Command::new(program)
.args(args)
.status()
.await
.context("isolated test command failed");
}
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
@@ -1505,6 +1520,21 @@ pub(crate) async fn host_sudo(args: &[&str]) -> Result<std::process::ExitStatus>
/// Same mechanism as `host_sudo` but captures stdout — for read-only probes
/// (e.g. `stat`) where the answer is in the output, not the exit status.
pub(crate) async fn host_sudo_output(args: &[&str]) -> Result<std::process::Output> {
#[cfg(test)]
{
anyhow::ensure!(
std::env::var("ARCHY_TEST_ISOLATED").as_deref() == Ok("1"),
"Host-operation tests require scripts/test-backend-isolated.sh"
);
let (program, args) = args.split_first().context("Missing test command")?;
// Run inside the test namespace, never escape through sudo/systemd-run.
return tokio::process::Command::new(program)
.args(args)
.output()
.await
.context("isolated test command failed");
}
let mut full: Vec<&str> = vec![
"systemd-run",
"--wait",
@@ -2159,20 +2189,25 @@ async fn apply_per_app_auto_updates(
}
}
/// After a catalog refresh that changed the cached bytes, rebuild the
/// orchestrator's manifest map so registry-shipped manifest changes take
/// effect now instead of at the next service restart.
async fn reload_manifests_if_changed(
refresh: crate::container::app_catalog::CatalogRefresh,
/// Reload after every successful refresh, including unchanged bytes: the cache
/// may have been written before a previous reload failed. Auto-updates only run
/// when the catalog and the orchestrator's manifests are ready together.
async fn reload_catalog_manifests(
_refresh: crate::container::app_catalog::CatalogRefresh,
orchestrator: &Option<std::sync::Arc<dyn crate::container::traits::ContainerOrchestrator>>,
) {
if !refresh.changed {
return;
}
let Some(orch) = orchestrator else { return };
) -> bool {
let Some(orch) = orchestrator else {
return false;
};
match orch.reload_manifests().await {
Ok(n) => info!("Update scheduler: catalog changed, reloaded {n} manifest(s)"),
Err(e) => warn!("Update scheduler: manifest reload after catalog change failed: {e}"),
Ok(n) => {
info!("Update scheduler: refreshed catalog, reloaded {n} manifest(s)");
true
}
Err(e) => {
warn!("Update scheduler: manifest reload failed; skipping auto-updates: {e}");
false
}
}
}
@@ -2188,7 +2223,9 @@ pub async fn run_update_scheduler(
// Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Ok(refresh) => {
reload_catalog_manifests(refresh, &orchestrator).await;
}
Err(e) => debug!(
"Update scheduler: initial app-catalog refresh failed: {}",
e
@@ -2204,14 +2241,22 @@ pub async fn run_update_scheduler(
// previously cached catalog stays in place (origin-always-wins).
// A changed catalog also reloads the orchestrator's manifest overlay so
// catalog-shipped manifest fixes apply without a service restart.
match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_manifests_if_changed(refresh, &orchestrator).await,
Err(e) => debug!("Update scheduler: app-catalog refresh failed: {}", e),
}
let catalog_ready = match crate::container::app_catalog::refresh_catalog(&data_dir).await {
Ok(refresh) => reload_catalog_manifests(refresh, &orchestrator).await,
Err(e) => {
debug!(
"Update scheduler: app-catalog refresh failed; skipping auto-updates: {}",
e
);
false
}
};
// Per-app auto-update-to-latest (multi-version support). Runs every tick
// regardless of the binary-OTA schedule below; opt-in + pin-respecting.
apply_per_app_auto_updates(&orchestrator).await;
// Per-app updates require fresh, loaded manifests; a failed refresh
// may still show cached badges but must not trigger container changes.
if catalog_ready {
apply_per_app_auto_updates(&orchestrator).await;
}
let state = match load_state(&data_dir).await {
Ok(s) => s,
+75 -62
View File
@@ -775,7 +775,9 @@ pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) ->
let mut all_target: Vec<u64> = send_denoms.clone();
all_target.extend(&change_denoms);
let swap_result = client.swap(&selected_proofs, &all_target).await?;
let swap_result = client
.swap_at_least(&selected_proofs, &all_target, amount_sats)
.await?;
// Mark original proofs as spent
wallet.mark_spent(&indices);
@@ -1192,7 +1194,11 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Verify all mints in the token are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted.mints.iter().any(|m| m == mint_url) {
if !accepted
.mints
.iter()
.any(|m| m.trim_end_matches('/') == mint_url.trim_end_matches('/'))
{
anyhow::bail!("Mint '{}' is not in accepted mints list", mint_url);
}
}
@@ -1205,6 +1211,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// for the log. Remember the last one so a total failure can tell the user
// *why* instead of just "nothing was received".
let mut last_reason: Option<String> = None;
let mut all_already_redeemed = true;
// Swap proofs at each mint
for entry in &token.token {
@@ -1216,7 +1223,8 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
received_total += amount;
}
Err(e) => {
warn!("Failed to swap proofs from mint {}: {:#}", entry.mint, e);
warn!("Failed to swap proofs from mint {}: {}", entry.mint, e);
all_already_redeemed &= e.is::<super::mint_client::AlreadyRedeemed>();
last_reason = Some(e.to_string());
// Continue with other mints if any
}
@@ -1224,10 +1232,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
}
if received_total == 0 {
match last_reason {
Some(reason) => anyhow::bail!("Could not receive this ecash: {}", reason),
None => anyhow::bail!("Failed to receive any proofs from token"),
}
return Err(receive_failure(last_reason, all_already_redeemed));
}
wallet.record_tx(
@@ -1243,6 +1248,17 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
Ok(received_total)
}
fn receive_failure(last_reason: Option<String>, all_already_redeemed: bool) -> anyhow::Error {
match last_reason {
Some(reason) if all_already_redeemed => {
anyhow::Error::new(super::mint_client::AlreadyRedeemed)
.context(format!("Could not receive this ecash: {reason}"))
}
Some(reason) => anyhow::anyhow!("Could not receive this ecash: {reason}"),
None => anyhow::anyhow!("Failed to receive any proofs from token"),
}
}
/// Receive a legacy format token (cashuSend_{amount}_{uuid}_{timestamp}).
/// For backwards compatibility during migration period.
async fn receive_legacy_token(data_dir: &Path, token_str: &str) -> Result<u64> {
@@ -1288,22 +1304,10 @@ pub async fn verify_and_receive_payment(
token_str: &str,
required_sats: u64,
) -> Result<u64> {
// Handle legacy tokens
let token_str = token_str.trim();
// Synthetic legacy balances are not cryptographic proof of payment.
if token_str.starts_with("cashuSend_") {
let amount = token_str
.split('_')
.nth(1)
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
if amount < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
amount,
required_sats
);
}
let received = receive_legacy_token(data_dir, token_str).await?;
return Ok(received);
anyhow::bail!("Legacy ecash cannot authorize a paid download");
}
// Fedimint notes (#3): a buyer whose balance is in Fedimint pays with notes
@@ -1326,52 +1330,45 @@ pub async fn verify_and_receive_payment(
// Parse and validate the token (cashuA or cashuB)
let token = CashuToken::deserialize(token_str)?;
let total = token.total_amount();
if token.unit.as_deref().unwrap_or("sat") != "sat" {
anyhow::bail!("Payment must be denominated in sats");
}
// A sale must redeem atomically at one mint. Otherwise a later mint
// failure can consume earlier inputs without delivering the purchase.
let entry = match token.token.as_slice() {
[entry] => entry,
_ => anyhow::bail!("Use a single-mint token for this payment"),
};
let total = entry
.proofs
.iter()
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
.ok_or_else(|| anyhow::anyhow!("Payment amount overflow"))?;
if total < required_sats {
anyhow::bail!(
"Insufficient payment: {} sats, need {} sats",
total,
required_sats
);
anyhow::bail!("Insufficient payment: {total} sats, need {required_sats} sats");
}
// Verify mints are accepted
let accepted = load_accepted_mints(data_dir).await?;
for mint_url in token.mint_urls() {
if !accepted.mints.iter().any(|m| m == mint_url) {
anyhow::bail!("Mint '{}' not accepted", mint_url);
}
if !accepted
.mints
.iter()
.any(|m| m.trim_end_matches('/') == entry.mint.trim_end_matches('/'))
{
anyhow::bail!("Mint is not in the seller's accepted mints list");
}
// Swap proofs at mint (this verifies they're unspent and gives us fresh proofs)
let client = mint_client(data_dir, &entry.mint).await?;
let result = client
.swap_at_least(
&entry.proofs,
&amount_to_denominations(total),
required_sats,
)
.await?;
let received_total = result.new_proofs.iter().map(|p| p.amount).sum();
// Load after the network call, so an unrelated wallet update during the
// swap is not overwritten with a pre-swap snapshot.
let mut wallet = load_wallet(data_dir).await?;
let mut received_total = 0u64;
for entry in &token.token {
let client = mint_client(data_dir, &entry.mint).await?;
let entry_total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(entry_total);
match client.swap(&entry.proofs, &target_amounts).await {
Ok(result) => {
let amount: u64 = result.new_proofs.iter().map(|p| p.amount).sum();
wallet.add_proofs(&entry.mint, result.new_proofs);
received_total += amount;
}
Err(e) => {
warn!("Payment verification failed at mint {}: {}", entry.mint, e);
}
}
}
if received_total < required_sats {
anyhow::bail!(
"Payment verification failed: only {} of {} sats verified",
received_total,
required_sats
);
}
wallet.add_proofs(entry.mint.trim_end_matches('/'), result.new_proofs);
wallet.record_tx(
TransactionType::Receive,
@@ -1632,6 +1629,18 @@ fn default_mint_url() -> String {
#[cfg(test)]
mod tests {
#[test]
fn mixed_mint_failures_do_not_discard_a_retryable_claim() {
let reason = super::super::mint_client::ALREADY_REDEEMED_MSG.to_string();
assert!(super::receive_failure(Some(reason.clone()), true)
.is::<super::super::mint_client::AlreadyRedeemed>());
assert!(!super::receive_failure(Some(reason), false)
.is::<super::super::mint_client::AlreadyRedeemed>());
assert!(
!super::receive_failure(None, true).is::<super::super::mint_client::AlreadyRedeemed>()
);
}
use super::*;
use tempfile::TempDir;
@@ -2443,3 +2452,7 @@ mod tests {
assert_eq!(w.mint_url, "https://mint.minibits.cash/Bitcoin");
}
}
#[cfg(test)]
#[path = "payment_tests.rs"]
mod payment_tests;
+266 -34
View File
@@ -47,7 +47,8 @@
//! key, a crash mid-loop) must not silently lose the coins, so every fetched
//! token is persisted to `MinibitsState::pending_claims` *before* decrypt/
//! redeem is attempted, and stays there — retried on every later poll — until
//! it succeeds. `ClaimOutcome::failed_count` reports how many are still
//! it succeeds or every mint reports that it was already spent.
//! `ClaimOutcome::failed_count` reports how many are still
//! stuck so the caller can surface it instead of it being a log-only event.
//! Separately, `ensure_mint_accepted` keeps the Minibits mint on the node's
//! accepted-mints allow-list: the address is inherently backed by that one
@@ -167,6 +168,9 @@ pub struct MinibitsState {
/// already-spent token) but wasteful and noisy.
#[serde(default)]
pub last_dm_seen_at: u64,
/// Resume a bounded backward scan before advancing to newer relay events.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relay_scan: Option<RelayScan>,
/// Event ids already queued from the relay. `created_at` has only
/// one-second resolution, so a strict `since = last + 1` watermark can
/// permanently miss a second payment published later in the same second.
@@ -627,6 +631,7 @@ async fn register_new_state(
created_at: chrono::Utc::now().to_rfc3339(),
pending_claims: Vec::new(),
last_dm_seen_at: 0,
relay_scan: None,
seen_dm_ids: Vec::new(),
last_receipt_id: 0,
last_receipt_sats: 0,
@@ -652,6 +657,16 @@ pub struct ClaimOutcome {
pub receipt_at: u64,
}
/// True when `ecash::receive_token` failed because the token was already
/// redeemed (mint error 11001, see `mint_client::describe_mint_error_code`) —
/// a terminal condition, not a reason to retry. Seen on a deployed node,
/// 2026-09-15: a claim that had already been swept kept failing this way on
/// every poll forever, since nothing distinguished it from a transient
/// failure worth retrying.
fn is_already_redeemed(err: &anyhow::Error) -> bool {
err.is::<super::mint_client::AlreadyRedeemed>()
}
const NO_CLAIMS: ClaimOutcome = ClaimOutcome {
claimed_count: 0,
received_sats: 0,
@@ -697,38 +712,58 @@ fn outcome_with_latest_receipt(
/// three real payments that `/claim` never surfaced. Best-effort: a relay
/// error here must not abort the poll, since `pending_claims` may still hold
/// earlier fetches worth retrying.
///
/// Queries `RELAY_URL` (the service's own relay) alone first — the happy
/// path for a poll is one WebSocket connection, not three, and the wallet's
/// derived Nostr pubkey isn't broadcast to the public fallback relays unless
/// it's actually needed. Only when that relay is unreachable does it fall
/// back to all of `CLAIM_RELAY_URLS`. Results are paged (capped at
/// `CLAIM_MAX_PAGES`) since a relay returns only the newest `limit` events for
/// a filter. A durable backward cursor keeps older pages reachable even after
/// newly queued claims advance the normal forward watermark.
async fn fetch_relay_dms(
our_pubkey: nostr_sdk::PublicKey,
server_pubkey: nostr_sdk::PublicKey,
since: u64,
) -> Vec<(String, u64, String, String)> {
resume: Option<RelayScan>,
) -> RelayBatch {
let client = Client::default();
for url in CLAIM_RELAY_URLS {
if let Err(e) = client.add_relay(*url).await {
warn!("Minibits: could not add relay {url}: {e}");
if let Err(e) = client.add_relay(RELAY_URL).await {
warn!("Minibits: could not add relay {RELAY_URL}: {e}");
}
let primary_reachable = client
.try_connect_relay(RELAY_URL, std::time::Duration::from_secs(3))
.await
.is_ok();
if !primary_reachable {
warn!("Minibits: primary relay {RELAY_URL} unreachable, falling back to public relays too");
for url in &CLAIM_RELAY_URLS[1..] {
if let Err(e) = client.add_relay(*url).await {
warn!("Minibits: could not add relay {url}: {e}");
}
}
client.connect().await;
}
client.connect().await;
// Give relays a moment to finish the WebSocket handshake before the
// fetch's own timeout starts consuming that time.
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
// Nostr timestamps have one-second resolution. Query the boundary second
// inclusively: a later-published payment may legitimately share that
// timestamp. `seen_dm_ids` performs the exact deduplication locally.
let filter = Filter::new()
.author(server_pubkey)
.pubkey(our_pubkey)
.kind(Kind::from(4u16))
.since(Timestamp::from(since))
.limit(200);
let result = match client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await
{
Ok(events) => {
let mut out: Vec<(String, u64, String, String)> = events
let batch = collect_relay_pages(since, resume, |scan| {
let client = &client;
async move {
let mut filter = Filter::new()
.author(server_pubkey)
.pubkey(our_pubkey)
.kind(Kind::from(4u16))
.since(Timestamp::from(scan.since))
.limit(scan.limit);
if let Some(until) = scan.until {
filter = filter.until(Timestamp::from(until));
}
let events = client
.fetch_events(filter, std::time::Duration::from_secs(5))
.await?;
Ok(events
.into_iter()
.map(|e| {
(
@@ -738,18 +773,83 @@ async fn fetch_relay_dms(
e.id.to_hex(),
)
})
.collect();
out.sort_by_key(|(_, created_at, _, _)| *created_at);
out
.collect())
}
Err(e) => {
warn!("Minibits: relay fetch for claim DMs failed: {e}");
Vec::new()
}
};
})
.await;
client.shutdown().await;
result
batch
}
const CLAIM_PAGE_LIMIT: usize = 200;
const CLAIM_MAX_PAGES: usize = 5;
type RelayDm = (String, u64, String, String);
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RelayScan {
since: u64,
until: Option<u64>,
limit: usize,
}
struct RelayBatch {
dms: Vec<RelayDm>,
resume: Option<RelayScan>,
}
/// NIP-01 returns newest events first. Walk backward with an inclusive `until`
/// boundary, deduplicating event ids. A full boundary second needs a larger
/// limit, not `until - 1`, which would skip payments sharing that timestamp.
/// Persist the cursor at the page cap or on failure so older claims cannot be
/// hidden by the newest timestamp already queued in `last_dm_seen_at`.
async fn collect_relay_pages<F, Fut>(
since: u64,
resume: Option<RelayScan>,
mut fetch: F,
) -> RelayBatch
where
F: FnMut(RelayScan) -> Fut,
Fut: std::future::Future<Output = Result<Vec<RelayDm>>>,
{
let mut scan = resume.unwrap_or(RelayScan {
since,
until: None,
limit: CLAIM_PAGE_LIMIT,
});
let mut out = Vec::new();
let mut ids = std::collections::HashSet::new();
let mut resume = Some(scan);
for _ in 0..CLAIM_MAX_PAGES {
let events = match fetch(scan).await {
Ok(events) => events,
Err(e) => {
warn!("Minibits: relay fetch failed; preserving scan cursor: {e}");
break;
}
};
let count = events.len();
let oldest = events.iter().map(|e| e.1).min();
for event in events {
if ids.insert(event.3.clone()) {
out.push(event);
}
}
if count < scan.limit {
resume = None;
break;
}
if let Some(oldest) = oldest {
if scan.until == Some(oldest) {
scan.limit = scan.limit.saturating_add(CLAIM_PAGE_LIMIT);
} else {
scan.until = Some(oldest);
scan.limit = CLAIM_PAGE_LIMIT;
}
}
resume = Some(scan);
}
out.sort_by(|a, b| (a.1, &a.3).cmp(&(b.1, &b.3)));
RelayBatch { dms: out, resume }
}
fn queue_relay_dm(
@@ -907,8 +1007,15 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
// NIP-04 DM on relays, not via `/claim` above. `since` is our own
// watermark (Nostr events never expire off a relay, so without it we'd
// re-fetch and re-attempt every claim ever sent on every poll).
let dms = fetch_relay_dms(identity.keys.public_key(), server_pk, state.last_dm_seen_at).await;
for (content, created_at, author, event_id) in dms {
let batch = fetch_relay_dms(
identity.keys.public_key(),
server_pk,
state.last_dm_seen_at,
state.relay_scan,
)
.await;
state.relay_scan = batch.resume;
for (content, created_at, author, event_id) in batch.dms {
if author != state.server_nostr_pubkey {
warn!("Minibits: ignoring claim DM from unexpected pubkey {author}");
continue;
@@ -964,6 +1071,14 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
sats += got;
info!("Minibits: redeemed a claimed payment ({got} sats)");
}
Err(e) if is_already_redeemed(&e) => {
// Terminal: the value was already swept (a relay-watermark
// replay, or a claim redeemed by an earlier run before a
// crash lost track of it). Retrying can never succeed, so
// drop it instead of leaving `failed_count` stuck non-zero
// forever — see archy-x250-pa3, 2026-09-15.
info!("Minibits mint reports this claim was already redeemed; removing it from the retry queue");
}
Err(e) => {
warn!("Minibits claim decrypted but failed to redeem ({e}); will retry next poll");
still_pending.push(claim.clone());
@@ -994,6 +1109,123 @@ pub async fn claim_and_redeem(data_dir: &Path) -> Result<ClaimOutcome> {
#[cfg(test)]
mod tests {
fn simulated_relay_page(events: &[RelayDm], scan: RelayScan) -> Vec<RelayDm> {
let mut page: Vec<_> = events
.iter()
.filter(|e| e.1 >= scan.since && scan.until.is_none_or(|until| e.1 <= until))
.cloned()
.collect();
page.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.3.cmp(&b.3)));
page.truncate(scan.limit);
page
}
fn relay_fixture(count: usize, same_second: bool) -> Vec<RelayDm> {
(1..=count)
.map(|n| {
(
format!("claim-{n}"),
if same_second { 100 } else { n as u64 },
"service".into(),
format!("id-{n:06}"),
)
})
.collect()
}
#[tokio::test]
async fn relay_paging_fetches_older_claims_in_newest_first_backlog() {
let events = relay_fixture(450, false);
let batch = collect_relay_pages(0, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert_eq!(batch.dms.len(), 450);
assert!(batch.resume.is_none());
assert_eq!(batch.dms.first().unwrap().1, 1);
assert_eq!(batch.dms.last().unwrap().1, 450);
}
#[tokio::test]
async fn relay_paging_preserves_payments_at_the_same_timestamp() {
let events = relay_fixture(250, true);
let batch = collect_relay_pages(100, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert_eq!(batch.dms.len(), 250);
assert!(batch.resume.is_none());
}
#[tokio::test]
async fn relay_page_cap_resumes_older_claims_after_watermark_advances() {
let events = relay_fixture(1300, false);
let mut state = MinibitsState::default();
let first = collect_relay_pages(0, None, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert!(first.resume.is_some());
state.relay_scan = first.resume;
let mut ids = std::collections::HashSet::new();
for (content, time, author, id) in first.dms {
ids.insert(id.clone());
queue_relay_dm(&mut state, content, time, id, author);
}
assert_eq!(state.last_dm_seen_at, 1300);
let state: MinibitsState =
serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
let second = collect_relay_pages(state.last_dm_seen_at, state.relay_scan, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
assert!(second.resume.is_none());
ids.extend(second.dms.into_iter().map(|e| e.3));
assert_eq!(ids.len(), 1300);
}
#[tokio::test]
async fn relay_fetch_failure_keeps_the_unfinished_page_cursor() {
let events = relay_fixture(450, false);
let mut requests = 0;
let first = collect_relay_pages(0, None, |scan| {
requests += 1;
std::future::ready(if requests == 1 {
Ok(simulated_relay_page(&events, scan))
} else {
Err(anyhow!("relay timeout"))
})
})
.await;
assert_eq!(first.dms.len(), 200);
assert_eq!(first.resume.unwrap().until, Some(251));
let second = collect_relay_pages(450, first.resume, |scan| {
std::future::ready(Ok(simulated_relay_page(&events, scan)))
})
.await;
let ids: std::collections::HashSet<_> = first
.dms
.into_iter()
.chain(second.dms)
.map(|e| e.3)
.collect();
assert_eq!(ids.len(), 450);
}
#[test]
fn only_typed_spent_claims_are_terminal_even_with_wrapped_errors() {
let spent = anyhow::Error::new(super::super::mint_client::AlreadyRedeemed)
.context("receive token")
.context("claim failed");
assert!(is_already_redeemed(&spent));
assert!(!is_already_redeemed(&anyhow!(
super::super::mint_client::ALREADY_REDEEMED_MSG
)));
assert!(!is_already_redeemed(&anyhow!(
"mint temporarily unreachable"
)));
}
use super::*;
#[test]
+127 -35
View File
@@ -71,10 +71,28 @@ pub struct MintResult {
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
///
/// Text of the NUT error-code-11001 translation, exposed so callers that
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
/// Minibits claim) can recognize an already-spent token as terminal rather
/// than retrying it forever.
pub const ALREADY_REDEEMED_MSG: &str =
"This ecash has already been redeemed — it can't be claimed twice.";
/// Typed terminal condition: never infer spent proofs from a mint's free text.
#[derive(Debug)]
pub(super) struct AlreadyRedeemed;
impl std::fmt::Display for AlreadyRedeemed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(ALREADY_REDEEMED_MSG)
}
}
impl std::error::Error for AlreadyRedeemed {}
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code {
10001 => "The mint rejected these coins as invalid.",
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
11001 => ALREADY_REDEEMED_MSG,
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
11004 => "This request is still being processed by the mint — try again in a moment.",
@@ -124,8 +142,29 @@ fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
/// translation layered on top via `.context()` so `{}` — what reaches the
/// wallet user — shows something actionable instead of raw mint JSON.
fn mint_error(op: &str, status: reqwest::StatusCode, body: &str) -> anyhow::Error {
let friendly = describe_mint_error_body(status, body);
anyhow::anyhow!("{} failed ({}): {}", op, status, body).context(friendly)
let cause = anyhow::anyhow!("{} failed ({}): {}", op, status, body);
if serde_json::from_str::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("code").and_then(|c| c.as_i64()))
== Some(11001)
{
return cause.context(AlreadyRedeemed);
}
cause.context(describe_mint_error_body(status, body))
}
fn fee_adjusted_targets(requested: &[u64], mut available: u64) -> Vec<u64> {
let mut outputs = Vec::new();
for &amount in requested {
if available >= amount {
outputs.push(amount);
available -= amount;
} else {
outputs.extend(amount_to_denominations(available));
break;
}
}
outputs
}
/// HTTP client for a single Cashu mint.
@@ -487,6 +526,21 @@ impl MintClient {
/// Swap proofs for new proofs of different denominations.
/// This is how we "receive" a token — swap it for fresh proofs that only we know.
pub async fn swap(&self, inputs: &[Proof], target_amounts: &[u64]) -> Result<SwapResult> {
self.swap_at_least(inputs, target_amounts, 0).await
}
/// Refuse a payment whose mint fees would leave the seller underpaid,
/// before consuming any input proofs.
pub async fn swap_at_least(
&self,
inputs: &[Proof],
target_amounts: &[u64],
minimum: u64,
) -> Result<SwapResult> {
// V4 tokens carry short keyset IDs. Every swap path (including paid
// files and streams) must expand these, not only wallet imports.
let resolved = self.resolve_truncated_keyset_ids(inputs).await?;
let inputs = resolved.as_slice();
let keyset = self.get_active_sat_keyset().await?;
// NUT-02: a mint may charge a per-input fee, and it rejects the swap
@@ -494,16 +548,35 @@ impl MintClient {
// should equal outputs less fee`). Applied here rather than at each
// call site so send, receive and cross-mint swaps are all covered.
// Fee-free mints (Minibits) compute 0 and are unaffected.
let inputs_total: u64 = inputs.iter().map(|p| p.amount).sum();
let fee = match self.get_keysets().await {
Ok(ks) => super::cashu::swap_fee_for(inputs, &ks),
Err(e) => {
debug!("Could not read keyset fees ({e:#}) — assuming fee-free mint");
0
}
};
anyhow::ensure!(!inputs.is_empty(), "No input proofs to swap");
let inputs_total = inputs
.iter()
.try_fold(0u64, |sum, p| sum.checked_add(p.amount))
.context("Input amount overflow")?;
let keysets = self.get_keysets().await?;
let mut fee_ppk = 0u64;
for proof in inputs {
let input_keyset = keysets
.iter()
.find(|k| k.id == proof.id)
.context("The mint does not recognize an input keyset")?;
anyhow::ensure!(
input_keyset.unit == "sat",
"Input keyset is not denominated in sats"
);
fee_ppk = fee_ppk
.checked_add(input_keyset.input_fee_ppk)
.context("Mint fee overflow")?;
}
let fee = fee_ppk.div_ceil(1000);
let spendable = inputs_total.saturating_sub(fee);
let requested: u64 = target_amounts.iter().sum();
if spendable < minimum {
anyhow::bail!("Payment would leave {spendable} sats after mint fees; need {minimum} sats. No proofs were redeemed.");
}
let requested = target_amounts
.iter()
.try_fold(0u64, |sum, amount| sum.checked_add(*amount))
.context("Output amount overflow")?;
let owned_targets: Vec<u64>;
let target_amounts: &[u64] = if requested > spendable {
if spendable == 0 {
@@ -514,7 +587,10 @@ impl MintClient {
debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
);
owned_targets = amount_to_denominations(spendable);
// Callers put payment outputs before change. Keep that prefix
// intact while fees reduce change; re-splitting the entire sum
// can omit a payment denomination after consuming the inputs.
owned_targets = fee_adjusted_targets(target_amounts, spendable);
&owned_targets
} else {
target_amounts
@@ -559,6 +635,9 @@ impl MintClient {
let mut new_proofs = Vec::new();
for (sig, (secret, r, amount)) in signatures.iter().zip(blinding_data.iter()) {
if sig.amount != *amount || sig.id != keyset.id {
anyhow::bail!("Mint returned a swap signature for an unexpected amount or keyset");
}
let c_prime = sig.c_prime_as_pubkey()?;
let mint_key = keyset.key_for_amount(*amount)?;
let c = bdhke::unblind_signature(&c_prime, r, &mint_key)?;
@@ -705,43 +784,35 @@ impl MintClient {
/// Repair proofs whose keyset id is a truncated NUT-02 **v2** id.
///
/// A v2 keyset id is 33 bytes (version byte `0x01` + 32-byte hash), but
/// wallets written against the original 8-byte format truncate it when
/// they build a token. The mint then reads the `0x01` version, expects 33
/// compact V4 tokens carry an 8-byte short ID. The swap endpoint needs
/// the full ID restored from the mint's keyset list. The mint then reads the `0x01` version, expects 33
/// bytes, and rejects the swap — reported as
/// `inputs[0].id: NUT02: ID length invalid` behind a bare 422 (seen with
/// a Minibits-issued token, 2026-08-17).
///
/// The id only names which keyset signed the proof, so restoring the full
/// id the mint advertises is exactly what the sender meant. It is also
/// safe to attempt: an id that names the wrong keyset fails signature
/// verification at the mint and no coins move. Anything already valid, or
/// with no unambiguous match, is passed through untouched so the mint's
/// own error is what the operator sees.
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
/// safe to attempt: the mint still verifies the proof signature. Unknown
/// or ambiguous short IDs are rejected before redemption.
async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Result<Vec<Proof>> {
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
if !needs_repair {
return proofs.to_vec();
return Ok(proofs.to_vec());
}
// The mint's own keyset list, in the reference implementation's shape
// so its NUT-02 resolver can consume it directly.
let known = match self.get_cdk_keysets().await {
Ok(k) => k,
Err(e) => {
debug!("Could not list keysets to repair truncated keyset ids: {e:#}");
return proofs.to_vec();
}
};
let known = self.get_cdk_keysets().await?;
proofs
.iter()
.cloned()
.map(|mut p| {
if let Some(full) = super::cashu::resolve_keyset_id(&p.id, &known) {
debug!("Expanded short keyset id {} to {} for swap", p.id, full);
p.id = full;
if is_truncated_v2_keyset_id(&p.id) {
p.id = super::cashu::resolve_keyset_id(&p.id, &known)
.context("The mint cannot resolve this short keyset ID unambiguously")?;
}
p
Ok(p)
})
.collect()
}
@@ -777,7 +848,7 @@ impl MintClient {
let mut all_new_proofs = Vec::new();
for entry in &token.token {
if entry.mint != self.url {
if entry.mint.trim_end_matches('/') != self.url {
debug!(
"Skipping proofs from different mint {} (ours: {})",
entry.mint, self.url
@@ -788,8 +859,7 @@ impl MintClient {
let total: u64 = entry.proofs.iter().map(|p| p.amount).sum();
let target_amounts = amount_to_denominations(total);
let proofs = self.resolve_truncated_keyset_ids(&entry.proofs).await;
let result = self.swap(&proofs, &target_amounts).await?;
let result = self.swap(&entry.proofs, &target_amounts).await?;
all_new_proofs.extend(result.new_proofs);
}
@@ -803,6 +873,28 @@ impl MintClient {
#[cfg(test)]
mod tests {
#[test]
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
let spent = super::mint_error(
"Swap",
reqwest::StatusCode::BAD_REQUEST,
r#"{"code":11001,"detail":"Token Already Spent"}"#,
)
.context("Receive failed");
assert!(spent.is::<super::AlreadyRedeemed>());
let body =
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
.is::<super::AlreadyRedeemed>()
);
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
.is::<super::AlreadyRedeemed>()
);
}
use super::*;
#[test]
@@ -0,0 +1,428 @@
//! Real HTTP/curve-signature regressions for paid Cashu redemption.
use super::*;
use crate::wallet::{bdhke, cashu::Proof};
use bitcoin::secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey};
use hyper::{
service::{make_service_fn, service_fn},
Body, Request, Response, Server,
};
use serde_json::{json, Value};
use std::{
convert::Infallible,
sync::{Arc, Mutex},
};
const ACTIVE: &str = "0011223344556677";
const V2: &str = "011111111111111111111111111111111111111111111111111111111111111111";
struct Mint {
url: String,
requests: Arc<Mutex<Vec<Value>>>,
task: tokio::task::JoinHandle<()>,
failure: Arc<std::sync::atomic::AtomicU16>,
}
impl Drop for Mint {
fn drop(&mut self) {
self.task.abort();
}
}
fn signing_key() -> SecretKey {
SecretKey::from_slice(&[7; 32]).unwrap()
}
fn signed_point(point: PublicKey) -> String {
point
.mul_tweak(&Secp256k1::new(), &Scalar::from(signing_key()))
.unwrap()
.to_string()
}
fn proof(id: &str, amount: u64) -> Proof {
let secret = format!("test-{id}-{amount}");
Proof {
amount,
id: id.into(),
c: signed_point(bdhke::hash_to_curve(secret.as_bytes()).unwrap()),
secret,
}
}
impl Mint {
async fn start(fee: u64, failure: Option<u16>) -> Self {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let requests = Arc::new(Mutex::new(Vec::new()));
let seen = requests.clone();
let failure = Arc::new(std::sync::atomic::AtomicU16::new(failure.unwrap_or(0)));
let rejection = failure.clone();
let spent = Arc::new(Mutex::new(std::collections::HashSet::<String>::new()));
let service = make_service_fn(move |_| {
let seen = seen.clone();
let rejection = rejection.clone();
let spent = spent.clone();
async move {
Ok::<_, Infallible>(service_fn(move |req: Request<Body>| {
let seen = seen.clone();
let rejection = rejection.clone();
let spent = spent.clone();
async move {
let mut status = 200;
let body = match req.uri().path() {
"/v1/keysets" => json!({"keysets":[
{"id": ACTIVE,"unit":"sat","active":true,"input_fee_ppk":fee},
{"id": V2,"unit":"sat","active":false,"input_fee_ppk":fee}
]}),
"/v1/keys" => {
let public =
PublicKey::from_secret_key(&Secp256k1::new(), &signing_key())
.to_string();
let keys: serde_json::Map<String, Value> = (0..16)
.map(|i| ((1u64 << i).to_string(), json!(public)))
.collect();
json!({"keysets":[{"id": ACTIVE,"unit":"sat","keys":keys}]})
}
"/v1/swap" => {
let body: Value = serde_json::from_slice(
&hyper::body::to_bytes(req.into_body()).await.unwrap(),
)
.unwrap();
seen.lock().unwrap().push(body.clone());
let inputs = body["inputs"].as_array().unwrap();
let outputs = body["outputs"].as_array().unwrap();
let code = rejection.load(std::sync::atomic::Ordering::SeqCst);
if code != 0 {
status = code;
json!({"detail":"mock mint rejection"})
} else if inputs.iter().any(|p| p["id"] != V2 && p["id"] != ACTIVE)
{
status = 422;
json!({"detail":[{"msg":"NUT02: ID length invalid"}]})
} else if inputs.iter().any(|p| {
spent
.lock()
.unwrap()
.contains(p["secret"].as_str().unwrap())
}) {
status = 400;
json!({"code":11001,"detail":"Token Already Spent"})
} else {
let total: u64 =
inputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
let out: u64 =
outputs.iter().map(|p| p["amount"].as_u64().unwrap()).sum();
assert_eq!(
out,
total - (inputs.len() as u64 * fee).div_ceil(1000)
);
for p in inputs {
spent
.lock()
.unwrap()
.insert(p["secret"].as_str().unwrap().into());
}
json!({"signatures":outputs.iter().map(|o| json!({
"amount":o["amount"],"id":ACTIVE,
"C_":signed_point(o["B_"].as_str().unwrap().parse().unwrap())
})).collect::<Vec<_>>()})
}
}
_ => {
status = 404;
json!({})
}
};
Ok::<_, Infallible>(
Response::builder()
.status(status)
.header("Content-Type", "application/json")
.body(Body::from(body.to_string()))
.unwrap(),
)
}
}))
}
});
let server = Server::from_tcp(listener).unwrap().serve(service);
let task = tokio::spawn(async move {
server.await.unwrap();
});
Self {
url,
requests,
task,
failure,
}
}
async fn wallet(&self) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
save_accepted_mints(
dir.path(),
&AcceptedMints {
mints: vec![format!("{}/", self.url)],
},
)
.await
.unwrap();
dir
}
}
#[tokio::test]
async fn paid_v4_inactive_v2_keyset_is_expanded_and_cryptographic_proofs_saved() {
let mint = Mint::start(0, None).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)])
.serialize_v4()
.unwrap();
let decoded = CashuToken::deserialize(&token).unwrap();
assert_eq!(
decoded.token[0].proofs[0].id.len(),
16,
"reproduce the short V4 ID"
);
assert_eq!(
verify_and_receive_payment(dir.path(), &token, 100)
.await
.unwrap(),
100
);
let wallet = load_wallet(dir.path()).await.unwrap();
assert_eq!(wallet.balance(), 100);
for p in wallet.proofs {
assert_eq!(
p.proof.c,
signed_point(bdhke::hash_to_curve(p.proof.secret.as_bytes()).unwrap())
);
}
assert!(mint.requests.lock().unwrap()[0]["inputs"]
.as_array()
.unwrap()
.iter()
.all(|p| p["id"] == V2));
assert!(verify_and_receive_payment(dir.path(), &token, 100)
.await
.is_err());
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 100);
}
#[tokio::test]
async fn paid_v3_full_v2_and_v1_ids_work() {
for id in [V2, ACTIVE] {
let mint = Mint::start(0, None).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(id, 128)])
.serialize()
.unwrap();
assert_eq!(
verify_and_receive_payment(dir.path(), &token, 100)
.await
.unwrap(),
128
);
}
}
#[tokio::test]
async fn fees_cannot_consume_underpayment_and_allowed_fees_credit_actual_value() {
let mint = Mint::start(1000, None).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
.serialize_v4()
.unwrap();
assert!(verify_and_receive_payment(dir.path(), &token, 128)
.await
.unwrap_err()
.to_string()
.contains("after mint fees"));
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(
verify_and_receive_payment(dir.path(), &token, 127)
.await
.unwrap(),
127
);
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 127);
}
#[tokio::test]
async fn rejected_mint_response_does_not_credit_wallet() {
for status in [200, 400, 422, 500, 503] {
let mint = Mint::start(0, Some(status)).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
.serialize_v4()
.unwrap();
assert!(verify_and_receive_payment(dir.path(), &token, 100)
.await
.is_err());
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
}
}
#[tokio::test]
async fn invalid_untrusted_multimint_and_underpaid_tokens_never_reach_swap() {
let mint = Mint::start(0, None).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)]);
let mut invalid = vec![
"cashuSend_500_abc_1700000000".into(),
"cashuBinvalid".into(),
];
let mut wrong_unit = token.clone();
wrong_unit.unit = Some("usd".into());
invalid.push(wrong_unit.serialize().unwrap());
let mut multi = token.clone();
multi.token.push(token.token[0].clone());
invalid.push(multi.serialize().unwrap());
let mut untrusted = token.clone();
untrusted.token[0].mint = "http://127.0.0.1:1".into();
invalid.push(untrusted.serialize().unwrap());
for id in ["00ffffffffffffff", "01ffffffffffffff"] {
invalid.push(
CashuToken::new(&mint.url, vec![proof(id, 128)])
.serialize()
.unwrap(),
);
}
for value in invalid {
assert!(verify_and_receive_payment(dir.path(), &value, 100)
.await
.is_err());
}
assert!(
verify_and_receive_payment(dir.path(), &token.serialize().unwrap(), 129)
.await
.is_err()
);
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
}
#[tokio::test]
async fn buyer_token_rejected_by_seller_can_be_refunded_without_balance_loss() {
let mint = Mint::start(0, Some(422)).await;
let buyer = mint.wallet().await;
let seller = mint.wallet().await;
let mut wallet = load_wallet(buyer.path()).await.unwrap();
wallet.mint_url = mint.url.clone();
wallet.add_proofs(&mint.url, vec![proof(V2, 64), proof(V2, 32), proof(V2, 4)]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let token = send_token(buyer.path(), 100).await.unwrap();
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0);
assert!(verify_and_receive_payment(seller.path(), &token, 100)
.await
.is_err());
mint.failure.store(0, std::sync::atomic::Ordering::SeqCst);
assert_eq!(receive_token(buyer.path(), &token).await.unwrap(), 100);
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
assert!(receive_token(buyer.path(), &token).await.is_err());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 100);
}
#[tokio::test]
async fn unreachable_mint_does_not_credit_seller() {
let mint = Mint::start(0, None).await;
let dir = mint.wallet().await;
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
.serialize_v4()
.unwrap();
mint.task.abort();
tokio::task::yield_now().await;
assert!(verify_and_receive_payment(dir.path(), &token, 100)
.await
.is_err());
assert_eq!(load_wallet(dir.path()).await.unwrap().balance(), 0);
}
#[tokio::test]
async fn send_with_fees_preserves_payment_denominations_and_saves_change() {
// 128 inputs - 2 fee = 126. Splitting 126 as one sum omits 1,
// which is needed for a 65-sat payment, after consuming the inputs.
let mint = Mint::start(1000, None).await;
let buyer = mint.wallet().await;
let mut wallet = load_wallet(buyer.path()).await.unwrap();
wallet.mint_url = mint.url.clone();
let first = proof(V2, 64);
let mut second = first.clone();
second.secret.push_str("-second");
second.c = signed_point(bdhke::hash_to_curve(second.secret.as_bytes()).unwrap());
wallet.add_proofs(&mint.url, vec![first, second]);
save_wallet(buyer.path(), &wallet).await.unwrap();
let encoded = send_token(buyer.path(), 65).await.unwrap();
assert_eq!(
CashuToken::deserialize(&encoded).unwrap().total_amount(),
65
);
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 61);
}
#[tokio::test]
async fn paid_file_gate_delivers_bytes_only_after_payment_and_does_not_charge_missing_files() {
use crate::content_server::{
self, AccessControl, Availability, ContentCatalog, ContentItem, ServeResult,
};
for (exists, accepts_cashu, price) in [
(true, true, 100),
(true, false, 100),
(false, true, 100),
(true, true, 129),
] {
let mint = Mint::start(0, None).await;
let seller = mint.wallet().await;
let item = ContentItem {
id: "paid-test".into(),
filename: "test.txt".into(),
mime_type: "text/plain".into(),
size_bytes: 5,
description: String::new(),
added_at: String::new(),
availability: Availability::AllPeers,
access: AccessControl::Paid {
price_sats: price,
accepted: vec![if accepts_cashu { "ecash" } else { "fedimint" }.into()],
},
};
content_server::save_catalog(seller.path(), &ContentCatalog { items: vec![item] })
.await
.unwrap();
if exists {
tokio::fs::create_dir_all(seller.path().join("content/files"))
.await
.unwrap();
tokio::fs::write(seller.path().join("content/files/test.txt"), b"hello")
.await
.unwrap();
}
let token = CashuToken::new(&mint.url, vec![proof(V2, 128)])
.serialize_v4()
.unwrap();
let result = content_server::serve_content(
seller.path(),
"paid-test",
Some(&token),
None,
None,
None,
false,
)
.await
.unwrap();
if exists && accepts_cashu && price <= 128 {
match result {
ServeResult::Ok(bytes, mime) => {
assert_eq!(bytes, b"hello");
assert_eq!(mime, "text/plain");
}
_ => panic!("paid content was not delivered"),
}
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 128);
} else {
assert!(matches!(
result,
ServeResult::NotFound | ServeResult::PaymentRequired(_)
));
assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 0);
assert!(mint.requests.lock().unwrap().is_empty());
}
}
}
+83 -29
View File
@@ -989,6 +989,24 @@ impl AppManifest {
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
if let Some(value) = self.app.extensions.get("install_prerequisites") {
let items = value.as_sequence().ok_or_else(|| {
ManifestError::Invalid("install_prerequisites must be a list of app ids".into())
})?;
for item in items {
let id = item.as_str().unwrap_or_default();
if id.is_empty()
|| id == self.app.id
|| !id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
{
return Err(ManifestError::Invalid(
"install_prerequisites must contain valid other app ids".into(),
));
}
}
}
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
@@ -1074,6 +1092,14 @@ impl AppManifest {
// `..` copy sources). See docs/manifest-hooks-design.md.
self.app.hooks.validate()?;
if let Some(value) = self.app.extensions.get("backup_before_runtime_change") {
if value.as_bool().is_none() {
return Err(ManifestError::Invalid(
"backup_before_runtime_change must be boolean".into(),
));
}
}
Ok(())
}
}
@@ -1111,6 +1137,7 @@ fn validate_security(policy: &SecurityPolicy) -> Result<(), ManifestError> {
"SETGID",
"SETUID",
"SYS_ADMIN",
"SYS_CHROOT",
];
let mut seen = HashSet::new();
for cap in &policy.capabilities {
@@ -1746,40 +1773,48 @@ app:
}
}
exempt.sort();
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
// upstream's own safe-for-public
// subset that wallets connect to directly as a "remote node" over
// plain HTTP JSON-RPC — same reasoning as electrumx's 50001).
// cuprate's unrestricted RPC (full node control) stays loopback-only
// (auth: local), not in this set.
//
// 26 as of 2026-08-16: the 25 below plus phoenixd 9740, a
// loopback-only JSON API whose own generated http password
// authenticates every request (added with the phoenixd onboarding,
// which did not update this count — exactly the drift this test
// exists to catch).
//
// 25 as of the v1.7.123 port-policy round: bitcoin p2p (8333 ×2),
// core-lightning 9736/9835, electrumx 50001, fedimint 8173/8174,
// fedimint-gateway 8176/9737, gitea ssh 2222, lightning-stack
// 8091/9738/10010, lnd 9735/10009/18080, netbird 3478/8086/8087,
// pine TLS 10381 + the three voice ports (10200/10300/10400 — the
// disclosed known gap), router SSDP/mDNS 1900/5353. Every one is a
// deliberate, rationale-carrying exemption; the release-gate test
// stage timed out that cycle, so the count here lagged at 17.
// Reviewed 2026-09-30: lightning-stack's three retired endpoints
// disappeared; Cuprate restricted RPC moved from none to gate-open.
// Compare exact endpoints, not just a count that can hide substitutions.
let expected = [
("bitcoin-core", 8333),
("bitcoin-knots", 8333),
("core-lightning", 9736),
("core-lightning", 9835),
("cuprate", 18183),
("electrumx", 50001),
("fedimint", 8173),
("fedimint", 8174),
("fedimint-gateway", 8176),
("fedimint-gateway", 9737),
("gitea", 2222),
("lnd", 9735),
("lnd", 10009),
("lnd", 18080),
("netbird", 8087),
("netbird-server", 3478),
("netbird-server", 8086),
("phoenixd", 9740),
("pine", 10381),
("pine-openwakeword", 10400),
("pine-piper", 10200),
("pine-whisper", 10300),
("router", 1900),
("router", 5353),
]
.into_iter()
.map(|(id, port)| (id.to_owned(), port))
.collect::<Vec<_>>();
assert_eq!(
exempt.len(),
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
exempt, expected,
"unauthenticated endpoint set changed; review each exemption"
);
}
/// `auth: open` ports are served by the gate WITHOUT its login challenge,
/// so they are the second unauthenticated-by-the-gate surface and get the
/// same review guard as `auth: none`. Each one must be an app that
/// enforces a real login of its own.
/// same review guard as `auth: none`. Each must enforce its own login or
/// have an explicitly reviewed public protocol purpose.
#[test]
fn gate_open_ports_are_all_accounted_for() {
let apps = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps");
@@ -1801,6 +1836,8 @@ app:
}
}
open.sort();
// Cuprate 18090 is its deliberately public restricted RPC subset;
// unrestricted node-control RPC remains container-loopback-only.
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
// by anonymous payers), and — since the v1.8.7 platform round — the
@@ -1808,18 +1845,35 @@ app:
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
// (tailnet login on the web console). Both enforce their own login,
// and an operator can re-gate either from Settings → Access control.
// Angor's indexer exposes public chain data/transaction broadcast;
// its optional standalone relay accepts signed public Nostr events.
// Neither mounts credentials or the node's internal relay database.
assert_eq!(
open,
vec![
("angor-indexer".to_string(), 8998u16),
("angor-relay".to_string(), 8091u16),
("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16),
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
("tailscale".to_string(), 8240u16),
],
"gate-open port set changed — every entry must be an app with its own login"
"gate-open port set changed — review login or intentional public protocol purpose"
);
}
#[test]
fn invalid_install_prerequisites_are_rejected() {
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
assert!(AppManifest::parse(&yaml)
.unwrap_err()
.to_string()
.contains("install_prerequisites"));
}
}
#[test]
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
// Two different questions, and conflating them caused both gate
+38 -53
View File
@@ -310,59 +310,7 @@ impl PodmanClient {
);
continue;
}
// Honour the manifest's protocol (default tcp). netbird's STUN port
// is 3478/udp; forcing tcp here would publish the wrong protocol and
// silently break relay discovery.
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
// Effective bind. A gated port with no declared bind would
// publish 0.0.0.0 — the app would own every host address, which
// is both the exposure itself and the reason the daemon's app
// gate cannot bind those addresses to authenticate them. Pin it
// to loopback so the gate can take the external addresses.
//
// Doing it HERE, at container creation, is the point: the pin and
// the gate's takeover then both come from the daemon and cannot
// disagree. The earlier attempt put this decision in manifest
// data instead, and a node whose manifests lagged the binary
// published Bitcoin's loopback-only RPC across the LAN
// (test node, 2026-08-03).
//
// A port that already declares a bind is never overridden — that
// is exactly what keeps `bind: 127.0.0.1` ports host-local and
// leaves `auth: none` protocol ports (LND gRPC/REST, electrum)
// published as they are, so remote wallets keep working.
// NOTE: the daemon deliberately does NOT rewrite this. Pinning a
// published port to loopback is how an app hands its external
// addresses to the gate, but it belongs in the manifest, not in
// daemon-side inference:
//
// * `bind` is already honoured by every publish path (here and
// in package::install), so a manifest edit needs no code.
// * inference here would cover only THIS path — proven on
// a test node, where a recreate went through another one and
// the pin never applied.
// * and inferring from an ABSENT field is what republished
// Bitcoin's loopback RPC across the LAN, and came within one
// container-recreate of pinning LND's gRPC/REST and breaking
// every remote wallet.
//
// So the migration ships as `bind: 127.0.0.1` in the signed
// catalog. Verified 2026-08-03 that a disk-only manifest edit is
// overridden by the catalog, which is precisely why the catalog is
// the right and only place to carry it.
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
port_mappings.push(mapping);
port_mappings.push(podman_publish_mapping(port));
}
let mut mounts = Vec::new();
@@ -751,6 +699,25 @@ pub fn image_uses_insecure_registry(image: &str) -> bool {
.is_some_and(|host| INSECURE_REGISTRY_HOSTS.contains(&host))
}
// Keep the explicitly declared bind and transport identical to Quadlet. The
// app gate owns external listeners; container publication must not bypass it.
fn podman_publish_mapping(port: &crate::manifest::PortMapping) -> serde_json::Value {
let protocol = match port.protocol.to_ascii_lowercase().as_str() {
"udp" => "udp",
"sctp" => "sctp",
_ => "tcp",
};
let mut mapping = serde_json::json!({
"container_port": port.container,
"host_port": port.host,
"protocol": protocol,
});
if !port.bind.is_empty() {
mapping["host_ip"] = serde_json::json!(port.bind);
}
mapping
}
fn podman_network_settings(
network: Option<&str>,
network_policy: &str,
@@ -1110,6 +1077,24 @@ mod tests {
));
}
#[test]
fn portainer_manifest_keeps_private_network_and_loopback_api_publication() {
let m = AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert_eq!(
podman_network_settings(
m.app.container.network.as_deref(),
&m.app.security.network_policy
),
("slirp4netns", None)
);
assert_eq!(
podman_publish_mapping(&m.app.ports[0]),
serde_json::json!({
"container_port": 9000, "host_port": 9000, "protocol": "tcp", "host_ip": "127.0.0.1"
})
);
}
#[test]
fn podman_network_settings_uses_networks_map_for_custom_networks() {
assert_eq!(
+56 -19
View File
@@ -618,6 +618,46 @@ impl DockerRuntime {
}
}
// Docker is a development fallback. Refuse Podman-only network modes instead
// of silently installing a different topology; still honor binds for other apps.
fn docker_network_and_ports(manifest: &AppManifest, offset: u16) -> Result<Vec<String>> {
let network = manifest
.app
.container
.network
.as_deref()
.filter(|v| !v.is_empty())
.unwrap_or(&manifest.app.security.network_policy);
if matches!(network, "slirp4netns" | "pasta") {
anyhow::bail!("this app requires rootless Podman networking ({network})");
}
let mut args = Vec::new();
if !network.is_empty() && network != "isolated" {
args.extend(["--network".to_owned(), network.to_owned()]);
}
for port in &manifest.app.ports {
let host = port
.host
.checked_add(offset)
.context("published port offset overflow")?;
let bind = if port.bind.is_empty() {
String::new()
} else {
format!("{}:", port.bind)
};
let protocol = if port.protocol.is_empty() {
"tcp"
} else {
&port.protocol
};
args.extend([
"-p".to_owned(),
format!("{bind}{host}:{}/{protocol}", port.container),
]);
}
Ok(args)
}
#[async_trait]
impl ContainerRuntime for DockerRuntime {
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
@@ -657,25 +697,7 @@ impl ContainerRuntime for DockerRuntime {
cmd.arg("--read-only");
}
match manifest.app.security.network_policy.as_str() {
"host" => {
cmd.arg("--network").arg("host");
}
"isolated" => {
// Docker uses bridge network by default
}
_ => {
cmd.arg("--network")
.arg(&manifest.app.security.network_policy);
}
}
// Port mappings with offset
for port in &manifest.app.ports {
let host_port = port.host + port_offset;
cmd.arg("-p")
.arg(format!("{}:{}", host_port, port.container));
}
cmd.args(docker_network_and_ports(manifest, port_offset)?);
// Volumes
for volume in &manifest.app.volumes {
@@ -1035,6 +1057,21 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn docker_fallback_rejects_rootless_only_topology_and_preserves_bind_protocol() {
let mut m =
AppManifest::parse(include_str!("../../../apps/portainer/manifest.yml")).unwrap();
assert!(docker_network_and_ports(&m, 0).is_err());
m.app.container.network = Some("bridge".into());
m.app.ports[0].protocol = "udp".into();
let args = docker_network_and_ports(&m, 1).unwrap();
assert_eq!(
args,
vec!["--network", "bridge", "-p", "127.0.0.1:9001:9000/udp"]
);
assert!(docker_network_and_ports(&m, u16::MAX).is_err());
}
#[test]
fn missing_container_classifier_covers_podman5_phrasings() {
// podman 5.x `inspect` phrasing for a missing container.
+5 -3
View File
@@ -16,9 +16,11 @@ lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces.
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
install reports zero npm advisories; its type-check, 152 unit tests, and
Archipelago subpath production build pass. Keeping this mechanical security
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. On 2026-09-30 the lockfile was refreshed again for `brace-expansion`
1.1.21/5.0.12, `fast-uri` 3.1.8 and `ip-address` 10.7.2 after fresh node
installs failed the retained dependency audit. The resulting clean install
reports zero npm advisories; its type-check, 152 unit tests, and Archipelago
subpath production build pass. The complete image also builds on the X250. Keeping this mechanical security
update separate makes both the upstream integration and future dependency
refreshes auditable.
@@ -1,5 +1,5 @@
diff --git a/package-lock.json b/package-lock.json
index 20631bb..0933917 100644
index 20631bb..86b6f86 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -63,7 +63,7 @@
@@ -495,9 +495,9 @@ index 20631bb..0933917 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -678,9 +678,9 @@ index 20631bb..0933917 100644
- "version": "1.1.15",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
- "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "version": "1.1.21",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+ "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -833,9 +833,9 @@ index 20631bb..0933917 100644
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
- "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+ "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
+ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -859,9 +859,9 @@ index 20631bb..0933917 100644
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
+ "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -893,9 +893,9 @@ index 20631bb..0933917 100644
- "version": "10.2.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
- "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
+ "version": "10.7.2",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
+ "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -1445,4 +1445,3 @@ index bd7190c..6aa5a6f 100644
import { vi } from "vitest";
// Mock window.matchMedia
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY tailwind.css /usr/share/nginx/html/
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
# (the only upstream this UI proxies) is unauthenticated by design —
# Monero's safe-for-public subset — so there is nothing to substitute at
# start time and no rotation to follow.
COPY nginx.conf /etc/nginx/conf.d/default.conf
#
# Run nginx as root to avoid chown failures in rootless Podman user
# namespaces. The rest of the nginx image is unchanged.
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 18091
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Cuprate Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
.bg-layer {
position: fixed;
inset: 0;
z-index: -10;
background-image: url('assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
opacity: .42;
}
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
left, compact live status cards on the right. */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
.info-card-copy .sub { margin:0; font-size:.6875rem; }
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
[data-panel].tab-hidden { display:none; }
@media (max-width:700px) {
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
.app-header-text { text-align:center; }
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
.app-header-actions > .info-card { width:100%; }
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
}
@media (min-width:701px) {
.app-header { flex-wrap:nowrap; }
.app-header-text { min-width:0; }
}
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
header h1 .accent { color: inherit; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
.stat .value.warn { color: #f7931a; }
.stat .value.err { color: #ef4444; }
.stat .value.ok { color: #22c55e; }
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.notice {
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.35);
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
}
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
.notice b { color: white; }
.endpoint {
display: flex; align-items: center; gap: 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
}
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="wrap">
<header class="glass-card" style="padding:1.5rem;">
<div class="app-header">
<div class="app-header-id">
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
<div class="app-header-text">
<h1><span class="accent">Cuprate</span> Monero Node</h1>
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
</div>
</div>
<div class="app-header-actions">
<div class="info-card">
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
</div>
</div>
</div>
</header>
<div id="offlineNotice" class="notice error" style="display:none;">
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
Check the service status and try again shortly.
</div>
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
</nav>
<div class="grid" style="margin-top:1rem;">
<div class="glass-card" data-panel="node">
<div class="card-title">Blockchain Sync</div>
<div class="height-hero">
<span class="big" id="height">—</span>
<span class="of" id="targetOf">of —</span>
</div>
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
<div class="progress-label" id="syncLabel">Waiting for node…</div>
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="peers">
<div class="card-title">Peers &amp; Traffic</div>
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="insights">
<div class="card-title">Chain &amp; Disk</div>
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="connect">
<div class="card-title">Connect a Wallet</div>
<div class="endpoint">
<span id="walletEndpoint">—</span>
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
</div>
<div class="hint">
Restricted RPC — Monero's own safe-for-public subset, what Feather,
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
stays container-loopback only and is never published.
</div>
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
</div>
</div>
<footer>
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
Data served from this node's restricted RPC, refreshed every 15 seconds.
</footer>
</div>
<script>
const RPC = 'cuprate-rpc/';
const POLL_MS = 15000;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
}
function formatUptime(secs) {
const s = Number(secs);
if (!Number.isFinite(s) || s < 0) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function setStat(id, value, cls) {
const el = document.getElementById(id);
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
el.className = 'value' + (cls ? ' ' + cls : '');
}
function setStatus(kind, text) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('headerStatusDot');
dot.className = 'dot ' + kind;
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
}
async function callRpc(endpoint) {
const response = await fetch(RPC + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
cache: 'no-store',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.error) throw new Error(data.error);
return data;
}
function render(info, heightFallback) {
const height = info.height ?? heightFallback ?? 0;
// Monero's get_info returns target_height == 0 when the node is
// FULLY SYNCED — the field is the height being caught up to, not
// the chain tip, so `??` cannot substitute for the 0 case (a
// synced node would sit forever at "Syncing — 0.00%"). Treat
// 0/absent as "target is our own height" — the same sentinel
// core/archipelago/src/electrs_status.rs branches on.
const rawTarget = info.target_height ?? info.target ?? 0;
const target = rawTarget > 0 ? rawTarget : height;
document.getElementById('height').textContent = tabular(height);
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
const synced = target > 0 && height >= target;
if (synced) {
document.getElementById('syncLabel').textContent = 'Fully synced';
setStatus('online', 'Synced');
} else {
const behind = Math.max(0, target - height);
document.getElementById('syncLabel').textContent =
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
setStatus('syncing', 'Syncing');
}
const nettype = info.mainnet ? 'Mainnet'
: info.testnet ? 'Testnet'
: info.stagenet ? 'Stagenet'
: (info.net || info.nettype || '—');
setStat('nettype', nettype);
document.getElementById('headerNetwork').textContent = nettype;
document.getElementById('headerHeight').textContent = tabular(height);
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
const started = info.start_time ?? info.startup_time;
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
setStat('outConns', tabular(info.outgoing_connections_count));
setStat('inConns', tabular(info.incoming_connections_count));
setStat('rpcConns', tabular(info.rpc_connections_count));
setStat('whitePeers', tabular(info.white_peerlist_size));
setStat('grayPeers', tabular(info.grey_peerlist_size));
setStat('txPool', tabular(info.tx_pool_size));
const alt = Number(info.alt_blocks_count || 0);
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
setStat('difficulty', tabular(info.difficulty));
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
const free = info.free_space;
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
document.getElementById('walletEndpoint').textContent =
`${window.location.hostname}:18090`;
}
}
async function refresh() {
let height = null;
try {
const h = await callRpc('get_height');
height = h.height;
} catch { /* get_info below carries the real error */ }
try {
const info = await callRpc('get_info');
render(info, height);
} catch {
setStatus('offline', 'Node offline');
if (height !== null) document.getElementById('height').textContent = tabular(height);
}
}
function copyEndpoint(btn) {
const text = document.getElementById('walletEndpoint').textContent;
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* best effort */ }
document.body.removeChild(ta);
done();
}
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', () => {
const tab = button.dataset.tab;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
});
});
// Height alone answers even while get_info is warming up; if both
// fail the offline card explains the disk gate as a likely cause.
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
server {
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
# This container is host-networked, so nginx binds the HOST's address
# directly; a bare `listen` would expose the page on LAN, Tailscale and
# the mesh with the app gate nowhere in front of it. Binding loopback lets
# the daemon claim the external addresses and authenticate them;
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
listen 127.0.0.1:18091;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Session gate for the RPC proxy below. Internal: reachable only by
# nginx's own auth_request subrequest, never by a client.
location = /_session_check {
internal;
proxy_pass http://127.0.0.1:5678/auth/session-check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
}
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
# — Monero's own safe-for-public subset, what remote-node wallets use).
# It injects no credentials the caller lacks, but it is still session-
# gated here so the whole companion behaves as one authenticated surface
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
# proxy: loopback reaches it without the gate's challenge).
location /cuprate-rpc/ {
# Preflight carries no cookies by design — answer it before the gate,
# otherwise the browser reports an opaque CORS failure instead of a 401.
if ($request_method = OPTIONS) { return 204; }
auth_request /_session_check;
proxy_pass http://127.0.0.1:18090/;
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 Access-Control-Allow-Origin $scheme://$http_host always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
}
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
# a rebuilt companion image must actually be seen by the browser, while the
# ETag still saves the transfer when nothing changed.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY qrcode.js /usr/share/nginx/html/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
#
+1 -1
View File
@@ -1,4 +1,4 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
FROM source.archipelago-foundation.org/lfg2025/nginx:1.27.4-alpine
# Copy the HTML file
COPY index.html /usr/share/nginx/html/
+60 -18
View File
@@ -989,7 +989,7 @@
// ── State ───────────────────────────────────────────────────────
let unit = 'sats';
let state = { info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
let state = { readiness: null, info: null, channels: [], pending: null, peers: [], payments: [], invoices: [], txns: [], fees: null, graph: null };
let peerSort = { col: 'peer', dir: 1 };
let activityFilter = 'all';
let logsLoaded = false;
@@ -1142,9 +1142,19 @@
}
async function refreshAll() {
if (state.refreshing) return;
state.refreshing = true;
const icon = document.getElementById('refreshIcon');
if (icon) icon.classList.add('animate-spin-slow');
try {
state.readiness = await lndSafe('/archy-status', null);
if (state.readiness && state.readiness.state.startsWith('waiting_')) {
state.info = null;
state.onchainStale = true;
state.chanbalStale = true;
renderAll();
return;
}
const [info, channels, pending, peers, fees, graph, payments, invoices, txns] = await Promise.all([
lndSafe('/v1/getinfo', null),
lndSafe('/v1/channels', { channels: [] }),
@@ -1166,10 +1176,17 @@
state.invoices = (invoices && invoices.invoices) || [];
state.txns = (txns && txns.transactions) || [];
// Balances are separate so one failing endpoint can't blank the rest.
state.onchain = await lndSafe('/v1/balance/blockchain', null);
state.chanbal = await lndSafe('/v1/balance/channels', null);
// Preserve known balances on outage; never decode an error as zero.
const [onchain, chanbal] = await Promise.all([
lndSafe('/v1/balance/blockchain', null),
lndSafe('/v1/balance/channels', null),
]);
state.onchainStale = !validBalance(onchain && (onchain.confirmed_balance ?? onchain.total_balance));
state.chanbalStale = !validBalance(chanbal && (chanbal.local_balance?.sat ?? chanbal.balance));
if (!state.onchainStale) state.onchain = onchain;
if (!state.chanbalStale) state.chanbal = chanbal;
} finally {
state.refreshing = false;
if (icon) icon.classList.remove('animate-spin-slow');
}
renderAll();
@@ -1192,11 +1209,17 @@
const pill = document.getElementById('headerStatusPill');
const dot = document.getElementById('headerStatusDot');
if (!g) {
setText('headerStatusText', 'Unreachable');
pill.className = 'pill bad';
dot.className = 'status-dot-sm bg-red';
document.getElementById('syncCard').style.display = 'none';
const waiting = state.readiness && state.readiness.state.startsWith('waiting_');
if (!g || waiting) {
setText('headerStatusText', waiting ? state.readiness.message : 'Connecting to LND');
pill.className = 'pill warn';
dot.className = 'status-dot-sm bg-yellow';
document.getElementById('syncCard').style.display = '';
setText('syncSubtitle', waiting ? state.readiness.message + '. Lightning will become available automatically.' : 'Checking Lightning availability. Retrying automatically.');
setText('syncBlockLabel', '');
setText('syncPercent', '');
document.getElementById('syncProgressBar').style.width = '0%';
for (const id of ['syncChain', 'syncGraph', 'syncHeight', 'syncPeers']) setText(id, '—');
return;
}
@@ -1237,6 +1260,11 @@
}
// ── Balances ────────────────────────────────────────────────────
function validBalance(value) {
return (typeof value === 'number' || (typeof value === 'string' && /^\d+$/.test(value)))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0;
}
function renderBalances() {
const onchainConfirmed = num(state.onchain && (state.onchain.confirmed_balance ?? state.onchain.total_balance));
const onchainUnconfirmed = num(state.onchain && state.onchain.unconfirmed_balance);
@@ -1253,22 +1281,23 @@
const haveOnchain = !!state.onchain;
const haveChan = !!cb;
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balTotal', haveOnchain && haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', state.onchainStale || state.chanbalStale ? 'balance unavailable · last known values' : haveOnchain && haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balLightning', haveChan ? lnLocal : null);
setText('balLightningSub', !haveChan ? 'waiting for LND'
setText('balLightningSub', !haveChan ? 'waiting for LND' : state.chanbalStale ? 'last known balance'
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
setText('balOnchainSub', !haveOnchain ? 'waiting for LND' : state.onchainStale ? 'last known balance'
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
setText('liqLocal', fmtAmount(lnLocal));
setText('liqRemote', fmtAmount(lnRemote));
const liquidityReady = haveChan && !state.chanbalStale && !!state.info;
setText('liqLocal', liquidityReady ? fmtAmount(lnLocal) : '—');
setText('liqRemote', liquidityReady ? fmtAmount(lnRemote) : '—');
const total = lnLocal + lnRemote;
const localPct = total > 0 ? (lnLocal / total) * 100 : 50;
document.getElementById('liqBarLocal').style.width = localPct + '%';
document.getElementById('liqBarRemote').style.width = (100 - localPct) + '%';
setText('liqHint', total > 0
document.getElementById('liqBarLocal').style.width = (liquidityReady ? localPct : 0) + '%';
document.getElementById('liqBarRemote').style.width = (liquidityReady ? 100 - localPct : 0) + '%';
setText('liqHint', !liquidityReady ? 'Channel capacity is unavailable while waiting for LND.' : total > 0
? Math.round(localPct) + '% of your channel capacity is outbound (sendable).'
: 'Open a channel to start sending and receiving over Lightning.');
}
@@ -1284,6 +1313,15 @@
function renderSummary() {
const g = state.info;
if (!g) {
for (const id of ['statPeers', 'statActiveChannels', 'statCapacity', 'statRoutingMonth', 'healthHeight', 'healthPending', 'chActive', 'chInactive', 'chPending', 'chCapacity']) setText(id, '—');
for (const id of ['statChannelsSub', 'channelsLinkSub']) setText(id, 'Waiting for LND');
for (const id of ['healthChain', 'healthGraph']) {
const pill = document.getElementById(id);
pill.textContent = '—'; pill.className = 'pill warn';
}
return;
}
const chans = state.channels;
const active = chans.filter(c => c.active).length;
const inactive = chans.length - active;
@@ -1321,6 +1359,10 @@
function renderChannels() {
const el = document.getElementById('channelList');
if (!el) return;
if (!state.info) {
el.innerHTML = '<div class="empty-state">Waiting for LND. Existing channels will appear when it is ready.</div>';
return;
}
const q = (document.getElementById('channelFilter').value || '').toLowerCase();
let list = state.channels.slice();
if (q) list = list.filter(c => String(c.remote_pubkey || '').toLowerCase().includes(q) || String(c.chan_id || '').includes(q));
+6 -7
View File
@@ -1,14 +1,13 @@
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
#
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
# after any backend restart (podman reassigns the IP). See the script header.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
# Keep the upstream startup logic; repair its rendered proxy configuration.
# Publish this derived image under an Archipelago-specific tag, never the
# upstream version tag that the registry mirror can overwrite.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
FROM ${BASE}
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
# nginx -g "daemon off;") are inherited unchanged.
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
+29
View File
@@ -0,0 +1,29 @@
# Mempool frontend DNS recovery
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
start. Recreating the backend can change its Podman address while the frontend
continues to serve its static page, leaving all API/WebSocket requests offline.
Build and test the derived image before publishing:
```sh
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
python3 scripts/test-mempool-dns-recovery.py
```
The base is pinned by digest. The wrapper preserves upstream runtime options,
then repairs all four local API/WebSocket routes after placeholder rendering.
DNS is cached for five seconds using the container network resolver. Explicit
rewrites preserve API prefixes and query arguments; backend absence does not
prevent nginx startup. An unexpected upstream configuration fails startup
instead of silently omitting the fix.
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
mirror when updating mempool. Every upstream update must rebuild this wrapper
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
mapping, repeated repair, and frontend restart).
Publish the tested image before publishing the signed app catalog. Both the
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
+4 -136
View File
@@ -1,137 +1,5 @@
#!/bin/sh
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
CONF=/etc/nginx/conf.d/nginx-mempool.conf
# ─── archipelago patch ────────────────────────────────────────────────────
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
# start and caches it for the process lifetime. Podman reassigns the backend
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
#
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
# location→URI rewriting, each block is rewritten to preserve its original
# path mapping exactly:
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
# Resolver address: podman's aardvark-dns answers on the network gateway
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
# works on any podman network/subnet (and still falls back for Docker).
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
if ! grep -q 'set \$mp_backend' "$CONF"; then
awk -v res_addr="$ARCHY_RESOLVER" '
BEGIN { res = 0 }
/^[[:space:]]*location / && res == 0 {
print "\tresolver " res_addr " valid=10s ipv6=off;"
res = 1
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
{ print }
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
fi
# ─── end archipelago patch ────────────────────────────────────────────────
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
export LIGHTNING=true
fi
# Runtime overrides - read env vars defined in docker compose
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__ROOT_NETWORK__=${ROOT_NETWORK:=}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
__AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
# Export as environment variables to be used by envsubst
export __MAINNET_ENABLED__
export __TESTNET_ENABLED__
export __TESTNET4_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __ROOT_NETWORK__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__
export __ADDITIONAL_CURRENCIES__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@"
set -eu
# Preserve the pinned upstream entrypoint (including new runtime options).
# Apply our DNS repair only after it has rendered the nginx configuration.
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# Resolve the backend again after container IP changes. Run after upstream
# placeholder substitution, so the repair also works on an existing container.
set -eu
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
resolv=${2:-/etc/resolv.conf}
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
tmp=$(mktemp "${conf}.archy.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
BEGIN {
base = "http://" backend ":" port
print "# Archipelago: refresh backend DNS after container replacement."
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
print "resolver_timeout 3s; # archy-dns"
}
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
/^[[:space:]]*location[[:space:]]/ { location = $2 }
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
target = $2
sub(/;$/, "", target)
path = substr(target, length(base) + 1)
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
print "Unexpected backend location: " location > "/dev/stderr"
failed = 1; exit 1
}
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
print "Unexpected backend URI mapping" > "/dev/stderr"
failed = 1; exit 1
}
# Explicitly preserve prefix substitution and query arguments. A variable
# proxy_pass without a URI forwards the rewritten URI and original args.
print "\t\tset $mp_backend " backend ";"
if (path != location)
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
print "\t\tproxy_pass http://$mp_backend:" port ";"
count++
next
}
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
{ print }
END {
if (failed || count != 4) {
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
exit 1
}
}
' "$conf" > "$tmp"
cat "$tmp" > "$conf"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
/patch/repair-nginx.sh
nginx -t
exec "$@"
+88
View File
@@ -3,6 +3,94 @@
Working backlog of forward-looking items not yet scoped into a dedicated plan
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Framework incident — closed with operator acceptance
- **CLOSED WITH OPERATOR ACCEPTANCE (2026-09-30): Framework LND startup /
missing Receive address / false zero balance.** Startup, native balances,
Cashu address and source integration were verified; the operator accepted the
remaining display check and authorized release. See the incident record for evidence.
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
and the repository `AGENTS.md` session-start instructions.
## Next release after 1.8.21 — reported 2026-09-30
Release status and acceptance gates: [execution checklist](next-release-20260930.md).
- [ ] **Release blocker: Gitea → Portainer repository integration.** Diagnose
smart-HTTP reachability from Portainer's actual request namespace, then provide
one declarative topology and idempotent migration for fresh installs and
existing nodes. Preserve gate/auth boundaries, operator configuration,
repository/key/database mounts and Portainer stacks. Cover install order,
lifecycle/reboot/update convergence, clone/push and source-branch/Compose-file
acceptance with a disposable integration setup. Ship in both OTA and ISO;
a healthy Gitea root page is insufficient. Operator supplied a private handover;
deployment addresses and credentials must not be committed.
- [ ] **New X250: GitWorkshop failed at 70%; slow Nginx installation.** Missing
ISO build contexts restored on-node; package staging/smoke checks added.
GitWorkshop dependency audit refreshed and build/HTTP recovery verified;
Nginx was a slow successful image pull. Aggregate progress label corrected.
Include the validated repair in the next OTA/ISO. See lifecycle evidence.
- [ ] **Angor indexer service in the app store**, requested after the other
current repair/review work (2026-09-30). Follow the repository's app-development
and packaging documentation; treat it as a headless service unless upstream
documentation establishes a UI. Verify Bitcoin/Mempool requirements, decide
whether an existing first-class relay meets Angor's requirements or a relay
must be packaged with the indexer, and use the Angor logo from angor.io for its
service icon. Official current deployment documentation located and reviewed: stock Mempool
plus an optional strfry relay. Both headless services and the dependency guard
are implemented; API outage/recovery and five relay lifecycle cycles passed.
Final candidate install/lifecycle checks and signed delivery remain pending.
Install on the development box; Bitcoin must finish syncing for indexed queries.
- [ ] **App lifecycle: keep installed apps visible through restart and hard
refresh; gate embedded/browser launches on actual web and listener readiness.**
Source repair and scoped live acceptance passed; full release gate pending.
Includes durable inventory reconstruction,
concurrent inventory writes, stale scan/lifecycle updates, delayed HTTP startup,
and the app gate's post-install listener delay. See
[app lifecycle repair evidence](app-lifecycle-repair-20260930.md).
- [x] Review and repair open paid-download PRs #161 and #162, refresh both
branches from main, run independent and combined isolated suites, and verify
rootless file permissions in disposable scratch storage. Combined result:
1,585 passed, zero failed, four existing tests ignored. See the
[review evidence and remaining acceptance work](pr-review-20260930.md).
- [x] Integrate the reviewed PR branches into the next release and run funded
candidate acceptance, including Tor-only transport and payments with change.
Operator authorized completing the normal merge/closure workflow on
2026-09-30. Both PRs are now merged and closed through Gitea; integrate
local repair commits and sync git/ngit before release. The combined candidate
is deployed on both test endpoints. Funded Tor-only purchase with change,
confirmed refund, exact Files bytes and zero-cost repeat delivery passed.
The updated source still needs inclusion in signed OTA/ISO artifacts.
- [ ] Design durable recovery for an accepted payment whose response is lost.
Preserve the truthful unconfirmed-refund warning and prevent automatic
duplicate payment while that recovery work is outstanding.
- [x] **ThinkPad X250 kiosk: Bitcoin version choices readable above pruning.**
Replaced the native popup with inline radio choices. Actual Chromium 152 kiosk
assertions and screenshot verify white-on-dark choices, selection changes and
layout above pruning controls. Focused component tests pass. Included in the
next-release source; published 1.8.21 artifacts remain unchanged.
## 1.8.21 repair and release tasks — completed 2026-09-30
See the [execution record](repair-release-20260929.md) for evidence and limits.
- [x] Fix Cashu paid-file redemption between dev and Shorty; test keyset IDs,
mint errors, fees, and refund reporting before live validation.
- [x] Record Framework verification and the operator's acceptance of the
remaining display check before release.
- [x] Replace the unavailable tx1138.com explorer default with mempool.space;
migrate the old default with fresh consent and preserve custom/local explorers.
- [x] Offer pruning in the Bitcoin installation version modal, using the same
pruning settings as automatic pruning even on large disks.
- [x] Explain Bitcoin warmup without raw RPC errors; gate LND unlock on Bitcoin
RPC readiness and show install/start/sync waiting states with automatic recovery.
- [x] Test the completed changes on this development box, then publish a new
signed OTA and raw ISO release. Record any remaining verification gaps.
## Dev & build process (priority)
- Formalize the contributor workflow: releases, CI, maintainers, automated
+10
View File
@@ -765,3 +765,13 @@ Every supported app must satisfy the lifecycle contract:
For apps with special dependencies, launch must explain dependency wait states instead of showing a dead iframe. Examples include Bitcoin sync/IBD, Lightning wallet readiness, Nostr signer bridge injection, Tailscale login/auth, and app-specific setup screens.
Runtime changes should be validated with focused tests first, then the release lifecycle harness on the validation host when host access is intentionally resumed.
### Adapters for shared services
A service that reuses an installed stack can declare `install_prerequisites`
with the required component app ids and keep the runtime relationship in
`dependencies`. This refuses an incomplete installation before creating the
adapter instead of reporting a successful installation with no usable backend.
For example, Angor Indexer requires `mempool-api` (shown to users as its owning
Mempool app), shares that index and declares only an `api` interface. API-only
interfaces belong in Services and do not generate browser launch buttons.
+111
View File
@@ -0,0 +1,111 @@
# App lifecycle repair — 2026-09-30
Status: source repairs, optimized build, new-node recovery and scoped live
lifecycle acceptance verified. Full release gate remains pending.
These are next-release changes. Published 1.8.21 artifacts remain unchanged.
## Report
The operator reports that restarting an app can make it disappear, and a hard
refresh offers installation again. Newly installed apps sometimes fail to
connect in both embedded views and browser tabs. The new X250 additionally reproduced GitWorkshop disappearing during install
and Nginx Proxy Manager spending approximately 14 minutes at 70%. A disposable
app on the dev box exposed a separate restart failure.
## Findings and repairs
- Quadlet removes containers during stop/restart. The scanner protected existing
in-memory entries but did not reconstruct an absent app on a fresh daemon.
It now synthesizes stopped entries from the durable installed set, respecting
uninstall records, normalizing container prefixes, and preserving cached
metadata. Absence does not establish an image version or available update.
- Concurrent read/modify/write operations could lose installed-app records;
in-place writes could expose truncated JSON to readers. Serialize writers,
publish by atomic rename, and sync the file and parent directory. Legacy
package install/uninstall success paths update the durable record too.
- Scans and lifecycle/progress operations could replace a newer model from an
older snapshot. Use locked mutations for lifecycle/progress, and merge scan
results only into entries unchanged since the scan's merge snapshot.
- Container running state and TCP accept alone did not establish HTTP readiness.
Add explicit `ui-ready` based on bounded HTTP probes of the loopback upstream;
reject connection failures and server errors, accept normal redirects and
authentication challenges, and do not follow redirects or send credentials.
Self-signed HTTPS apps are probed locally without certificate validation.
- The app gate swept new listeners only every 60 seconds. Wake that sweep
immediately for a ready upstream whose declared gate port is not yet claimed,
and withhold readiness until external and Tor listener claims exist.
- Fixed launch URLs could bypass suppressed runtime URLs. Enforce readiness in
app cards, details, centralized embedded/browser launchers, and session frames.
Starting/restarting clears readiness immediately. A waiting frame does not
load an iframe and resumes when the backend reports readiness.
### New X250 findings
- The published ISO copied only `bitcoin-ui`, `lnd-ui` and `electrs-ui` build
directories. GitWorkshop failed because `/opt/archipelago/docker/archipelago-source`
was missing. Copy the complete docker source tree for bundled and unbundled
ISOs, matching OTA packaging. Validate every manifest build context and
Dockerfile in OTA staging, ISO staging and the mounted ISO smoke test.
- After restoring the omitted contexts, GitWorkshop's retained npm audit rejected
newly reported brace-expansion, fast-uri and ip-address vulnerabilities.
Refresh the existing pinned dependency patch, keeping the audit enabled.
Clean install/audit (zero advisories), type-check, 152 upstream tests and
subpath production build pass. The image builds on the X250 and `/healthz`
returns 200. No wallet or Bitcoin container restart was needed.
- Nginx was receiving data, not frozen: over 1 GB read during the pull. It
completed at 12:40:46 UTC after starting at 12:26:27; its web endpoint returns
200. The orchestrated path previously labelled the entire download/build/start
operation "Creating container" at 70%. Give that aggregate operation its own
truthful label and earlier phase; no byte-level download estimate is claimed.
- Restore install progress immediately from an already-loaded server snapshot,
so a new store created after hard refresh does not wait for another mutation.
- Replace the install modal's native version popup with inline radio choices.
On this actual X250's Chromium 152 kiosk renderer, selection changes work,
options have white text on dark backgrounds, and remain above pruning controls.
Screenshot and browser assertions captured; no install confirmation was clicked.
### Restart safety
The disposable fixture restart at 12:38:05 UTC stopped its container, then
`ss | kill` in runtime port cleanup sent SIGTERM to the management daemon at
12:38:35. The daemon owned the gate listener on the same port at other addresses.
Systemd restarted management; Bitcoin and LND container IDs/start times were
unchanged. Remove port-owner kills and broad `pkill` patterns from restart,
install recovery and Grafana preparation. Recovery now uses the existing
container-ID-aware ghost reaper: absent container ownership must be established
before a process is terminated. A real listening-socket regression checks that
conflict cleanup preserves the host listener. App-gate manifest lookup now honors
`ARCHIPELAGO_APPS_DIR`, matching the orchestrator's configured manifest root.
## Validation
- Full frontend suite: 139 files, 1,126 tests passed; final focused kiosk/store
checks: nine passed. Production frontend build passed.
- Final isolated backend suite: 1,567 passed, zero failed, four existing ignored
tests. Optimized backend build passed and was deployed to the development node.
- Tests cover empty runtime inventory, alias deduplication, uninstall exclusion,
concurrent durable writes, concurrent state changes, stale scan publication,
TCP-without-HTTP, HTTP statuses including 502/503, and gate listener claims.
- Live disposable Node fixture delayed HTTP startup by 25 seconds. Desktop and
mobile retained the waiting screen through hard refresh without mounting an
iframe, then opened the exact fixture page automatically when ready.
- Restart retained the app in both state APIs throughout and returned to ready;
the management PID did not change. Stopping removed the Quadlet container;
restarting management reconstructed its installed/stopped entry without a
false update offer. Starting it again succeeded. Desktop and mobile continued
to show the installed app after hard refresh.
- LAN access required node authentication and returned exact fixture bytes after
authentication. The fixture was uninstalled through the package lifecycle API;
its temporary manifest root and service override were removed.
- Bitcoin and LND container IDs and start times stayed unchanged through all
scoped checks and management restarts. No wallet data was used by the fixture.
- X250 kiosk checks also opened the repaired GitWorkshop and Nginx Proxy Manager
pages successfully, with no failed local resource loads.
## Limits
This prevents the identified lifecycle/readiness failures; it cannot guarantee
that an app or network never fails after a successful readiness check. Actual
application failures must remain visible rather than being labelled successful.
The full lifecycle/reboot release gate and funded acceptance of the reviewed
paid-download PRs remain pending. The X250 kiosk fix has live rendering evidence.
+32
View File
@@ -290,3 +290,35 @@ app:
Validate with `scripts/validate-app-manifest.sh` and regenerate the catalog
with `scripts/generate-app-catalog.py` (drift-checked in CI by
`scripts/check-app-catalog-drift.py`).
### Persistent-state backup for runtime repairs
`app.backup_before_runtime_change: true` opts an app into a stopped-state snapshot
before reconciliation changes a service’s network, ports, security settings,
command or health configuration. Image-upgrade backup policy remains separate. The orchestrator
archives writable persistent bind mounts under the node data directory, collapses
nested mounts, excludes the runtime Podman socket, and preserves the previous
Quadlet definition for rollback. Named volumes, outside-data-root state and
symlinked mount roots fail closed rather than silently producing an incomplete
backup. A failed snapshot resumes the original service and leaves migration
pending. Private archives are retained under `migration-backups/`; fresh installs
and unchanged runtime configurations do not create migration snapshots.
Catalog generation preserves the previously published base manifest for older
daemons and puts opted-in network changes in a signed `manifest_variants` entry
requiring `runtime-migration-backup-v1`. New runtimes select only variants whose
complete requirement list they support. Supply `BASE_CATALOG` when generating
against a different reviewed pre-migration catalog. This keeps catalog refresh
from applying a migration before the matching OTA code is installed.
### Existing shared-service prerequisites
`app.install_prerequisites` is an optional list of existing app ids, for example
`[mempool-api]` for a headless indexer adapter. The runtime checks their manifest
container names before recording installation or changing any dependency. If one
is missing, installation refuses with its owning app's title and removes the
optimistic install tile. Runtime observation errors fail closed. This does not
automatically install dependencies, alter Bitcoin pruning, or require a synced
backend merely to recognize an already-installed service. Declare ongoing
relationships separately in `dependencies`; use the app health check for actual
API readiness. Self-dependencies and malformed ids are invalid.

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