Compare commits

...
Author SHA1 Message Date
ssmithxandClaude Sonnet 5 2773532769 fix(ecash): resolve short v2 keyset ids before verifying a cashuB payment
The cashu crate's V4 (cashuB) encoder always writes a NUT-02 v2 keyset
id in its short 8-byte form (serialize_v4_keyset_id narrows to
ShortKeysetId unconditionally), which is spec-compliant: the receiver
must expand it against the mint's keyset list before spending. The
payment-receive loop in ecash.rs called MintClient::swap() directly
with the short id still attached, so mint.minibits.cash (whose active
keyset is v2) rejected every cashuB payment with
`422 inputs[0].id: NUT02: ID length invalid` — hence "seller doesn't
accept your Cashu mint" on any peer purchase.

MintClient::receive_token() already resolves this via
resolve_truncated_keyset_ids(); expose it pub(crate) and call it from
the ecash.rs loop too. The only other swap() call sites either run
after resolution or operate on our own full-id proofs.

Adds a test documenting that the short form is what crosses the wire,
so serialize_v4 is not "fixed" to defeat it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 14:47:49 +00:00
ssmithxandClaude Sonnet 5 c86a2436e5 fix(ecash): stop swallowing the mint's real reason for a 422
Two independent bugs were hiding the actual cause of a failed Cashu
swap/verify behind "mint returned 422 Unprocessable Entity with no
further detail":

- describe_mint_error_body() only read `detail` as a plain string, but
  FastAPI (which most mint implementations, including Nutshell, are
  built on) reports validation errors as an array of {loc, msg, type}
  objects. That shape fell through to the generic fallback even when
  the mint sent a specific reason.
- The warn!() logging a failed swap in ecash.rs used `{}` (top-level
  message only) instead of `{:#}`, discarding the raw mint body that
  mint_error() already attaches to the error's cause chain for exactly
  this purpose.

Confirmed live against mint.minibits.cash (2026-09-18): a real 422
during a peer-to-peer ecash payment logged nothing actionable on
either end because of this pair of bugs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 04:25:58 +00: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
archipelago 0fac51b9c5 chore: preserve signed release catalog 2026-09-12 16:00:16 -04:00
archipelago 4f0d123f27 feat: open GitWorkshop at Archipelago repository
Demo images / Build & push demo images (push) Successful in 3m33s
2026-09-12 15:57:57 -04:00
archipelago 13b1329c21 test: keep Cuprate stack as one app entry
Demo images / Build & push demo images (push) Successful in 4m0s
2026-09-12 15:33:05 -04:00
archipelago c4aa72dccc fix: route installs to apps or services
Demo images / Build & push demo images (push) Successful in 3m39s
2026-09-12 15:07:33 -04:00
archipelago d35474f774 fix: defensively hide legacy node identity
Demo images / Build & push demo images (push) Successful in 3m31s
2026-09-12 10:24:01 -04:00
archipelago a03f340bd1 fix: keep node key out of profile signer picker
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-12 10:06:41 -04:00
archipelago caaa2e729e fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s
2026-09-12 09:35:25 -04:00
archipelago fbb3ada87d chore: publish release v1.8.13-alpha
Demo images / Build & push demo images (push) Successful in 3m46s
2026-09-12 06:44:01 -04:00
archipelago 72e84439ee chore: prepare release v1.8.13-alpha 2026-09-12 06:40:21 -04:00
archipelago 5081a4fe7d docs: expand v1.8.13-alpha release notes 2026-09-12 05:41:13 -04:00
archipelago 39727dacbc style: format generated app ports 2026-09-12 05:37:55 -04:00
archipelago 1e409007d4 chore: regenerate app port metadata 2026-09-12 05:05:37 -04:00
archipelago 8f144c3038 chore: remove retired AdGuard app and refresh release docs 2026-09-12 05:05:33 -04:00
archipelago 8258705df7 chore: sync v1.8.13-alpha whats new 2026-09-12 04:44:34 -04:00
archipelago d13002e022 docs: add v1.8.13-alpha release notes 2026-09-12 04:44:20 -04:00
archipelago e625b29d9e fix: route GitWorkshop installs through orchestrator 2026-09-12 04:41:14 -04:00
archipelago c4ed9fb1fa release: sign app catalog for v1.8.12-alpha 2026-09-12 04:16:21 -04:00
archipelago 2bc5e98edb chore: publish release v1.8.12-alpha
Demo images / Build & push demo images (push) Successful in 3m49s
2026-09-11 15:17:17 -04:00
archipelago c1e14f7c7a chore: prepare release v1.8.12-alpha 2026-09-11 15:13:40 -04:00
archipelago 564ffe1c47 fix(indeedhub): generate per-node encryption root 2026-09-11 11:25:44 -04:00
archipelago c34d6ef76f docs(release): finalize 1.8.12 notes
Demo images / Build & push demo images (push) Successful in 4m22s
2026-09-11 06:55:17 -04:00
archipelago dac29baf97 fix(release): surface companion build and secure GitWorkshop deps 2026-09-11 06:10:59 -04:00
archipelago ef8c3a76be chore(release): define 1.8.12 publication gates 2026-09-11 05:37:21 -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
archipelago f5c0ba85cd feat(release): stage GitWorkshop and next node updates 2026-09-09 18:15:21 -04:00
archipelago 973356df16 fix(ecash): harden Minibits claim persistence 2026-09-08 21:16:57 -04:00
e5a0d95459 fix(ecash): fetch Minibits claims from Nostr relays, not the dead /claim REST poll
Confirmed live 2026-09-08 against three real Lightning payments to a
registered @minibits.cash address: POST /claim (the only claim source
claim_and_redeem checked) always returned an empty array, no matter
how long or how often it was polled. Independently queried
wss://relay.minibits.cash and found all three payments sitting there
as NIP-04-encrypted kind-4 DMs, #p-tagged to the wallet's own Nostr
pubkey and authored by the Minibits service key — that is the actual
delivery channel for a payment made to the address, and this module
never looked at it.

fetch_relay_dms queries CLAIM_RELAY_URLS (the service's own relay plus
two public fallbacks) for kind-4 events tagged to our pubkey, feeding
matching content into the existing pending_claims retry pipeline
unchanged. A new last_dm_seen_at watermark stops the same (immutable,
never-expiring) relay event from being re-fetched and re-attempted on
every poll. The REST /claim call stays in place alongside it in case
it serves some other payment path — this only adds the missing one.

fix(ecash): trim stray whitespace before parsing a cashuA/cashuB token

Once the relay fix above surfaced the three real payments, all three
failed to redeem with "Invalid base64 in cashuB token" — the decrypted
NIP-04 content had a trailing space after the base64 payload (Minibits'
own encoding), which every base64 alphabet in decode_token_base64
rejects outright. CashuToken::deserialize now trims the whole token
string before touching the "cashuA"/"cashuB" prefix or payload. This is
a general robustness fix, not just a Minibits workaround — the same
stray-whitespace failure could hit a hand-pasted token from a clipboard
copy just as easily.

Both fixes verified end-to-end against production: all three stuck
payments (20 + 5 + 20 = 45 sats) redeemed cleanly on the first poll
after deploying this build to archy-x250-pa3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
b9862c7643 fix(ui): escape a second live vue-i18n message-compile crash + add a full-sweep test
Same class of bug as the Minibits address label
(settings.passwordNeedSpecial: "...(!@#$%^&* etc.)" — a bare @ vue-i18n
parses as linked-message syntax). This one is live in
ChangePasswordSection.vue's password-strength validator: typing a new
password with no special character throws this exact
SyntaxError the moment the message is rendered. Fixed the same way
({'@'} escaping).

Added locales/__tests__/i18nMessagesCompile.test.ts, which walks every
string in every locale file and asks the real vue-i18n compiler to
parse it — confirmed it fails on both bad strings before their fixes
and passes clean now, with no other landmines left in either locale
file. This closes the whole bug class rather than just these two
instances; a future bad interpolation string fails `npm test` instead
of only a live crash report.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
6fe9c5f81b fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
28454264ac test(ui): guard the ecash-tab-click path in ReceiveBitcoinModal
Operator report (2026-09-08): clicking the Ecash tab appeared to close
the whole Receive modal. Added a regression test simulating the exact
click, both for wallet.ecash-lnaddress succeeding and failing — the
tab switch alone never emits `close` or unmounts the dialog in either
case, so this isn't reproduced by a plain component-level click; the
investigation continues with the reporter for a browser-console repro.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
84b04d1634 fix(ecash): recover from a truncated/corrupt Minibits state file
archy-x250-pa3's data volume filled to 100% (cuprate at 125G, since
removed) while a client had the ecash receive tab open. save_state's
write landed mid-truncate, leaving wallet/minibits.json at 0 bytes.
load_state then hard-failed every wallet.ecash-lnaddress call with
"EOF while parsing a value", surfaced in the UI as "Lightning address
unavailable" — permanently, since nothing ever cleared the bad file.

Registration is idempotent per pubkey (re-registering returns the same
lud16 Minibits already assigned), so there's no reason a corrupt local
mirror of that state should be fatal. load_state now treats an empty
or unparseable state file the same as a missing one — re-register and
recover the same address — instead of erroring. Manually cleared the
stuck file on archy-x250-pa3 as an immediate fix; this closes the gap
so it self-heals next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ce5c04d49d fix(ecash): stop Minibits LN-address claims from being silently lost
A Minibits /claim response consumes the payment server-side the instant
it's returned — it can never be re-fetched. claim_and_redeem previously
decrypted/redeemed each claim inline and just warn!-logged any failure,
so a mint-unreachable blip, a stale cached server key, or an operator
who'd edited their accepted-mints list to drop the default mint (via
streaming.configure-mints) could make a real payment vanish with
nothing but a log line to show for it — claimed_count/received_sats
still came back as a clean 0, identical to "nothing arrived."

Now: every fetched claim is persisted to MinibitsState.pending_claims
before decrypt/redeem is attempted, survives failures across polls
instead of being dropped, and claim_and_redeem no longer bails out on a
fetch error without first retrying whatever was already pending.
ensure_mint_accepted self-heals the accepted-mints allow-list so the
Minibits mint (the address is inherently backed by it) can't be
excluded out from under a claim. ClaimOutcome gains failed_count,
threaded through wallet.ecash-lnaddress-claim and shown in
ReceiveBitcoinModal so a stuck claim is visible instead of silent.

Also fixes the server_nostur_pubkey field-name typo (no live state to
migrate — this feature hasn't shipped yet).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00
ssmithxandarchipelago ce9fca1c38 feat(ecash): Minibits @minibits.cash Lightning address on Cashu receive
The wallet used Minibits only as a Cashu mint, so the node could hold and
swap ecash there but had no addressable name at it. This derives a LUD-16
Lightning address (name@minibits.cash) from the node's own ecash wallet and
surfaces it in the ecash Receive tab above the existing paste-token box.

Identity reuses the NUT-13 ecash phrase, so there is no second secret:
  - seedHash = sha256(mnemonic.to_seed("")) — the exact hash the Minibits app
    stores, so restoring the same phrase recovers the same address both ways;
  - Nostr keys via NIP-06 at m/44'/1237'/0'/0/0 (nostr-sdk Keys::from_mnemonic,
    pinned by a unit test against the NIP-06 vector so a bump cannot silently
    move the derivation and orphan the profile).

Backend (wallet/minibits.rs) implements the verified live /v3 flow: NIP-42
challenge/verify -> JWT, idempotent /profile registration with collision
retry, and /claim polling that NIP-04-decrypts each token (service pubkey read
from the address's own LUD-16 metadata, constant fallback) and redeems it
through ecash::receive_token. Mainnet-only; state cached 0600 in
wallet/minibits.json.

New RPC: wallet.ecash-lnaddress (register-or-read, idempotent) and
wallet.ecash-lnaddress-claim (sweep Lightning payments into ecash). The modal
fetches the address on tab open, renders QR + copy, and sweeps claims while
open; a registration failure is non-fatal so paste-token still works.

Verified end-to-end against production: registered a disposable
@minibits.cash address, confirmed it resolves via /.well-known/lnurlp, and the
claim poll returns cleanly.
2026-09-08 21:16:57 -04:00
archipelago e661f237f1 fix(openwrt): harden TollGate PR integration 2026-09-08 21:06:36 -04:00
f9af30b08a feat(openwrt): make TollGate payout Lightning address configurable
Archipelago never touched /etc/tollgate/identities.json — the "owner"
payout identity was whatever the router's TollGate install happened to
default to. Confirmed live against archy-x250-pa3: an unmodified upstream
placeholder (tollgate@minibits.cash), meaning 79% of every customer payment
would auto-payout to an address the operator never chose and doesn't
control.

Adds TollGateConfig.payout_address (opt-in — None leaves the router
untouched), config::apply_payout_identity() to merge it into the "owner"
entry of identities.json without disturbing the merchant keypair or the
other profit-share identities, an RPC param on openwrt.provision-tollgate,
and a status field + reconfigure-form input in the OpenWrt Gateway panel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
87a5025341 docs(tollgate-sweep): document two live-confirmed drain-CLI bugs
sweep_once() has never actually swept anything: `tollgate wallet drain
cashu` (no flags) blocks on an interactive y/N confirmation that Router::run
can never answer over a non-PTY SSH exec (empty stdin -> EOF -> defaults to
N -> "Operation cancelled." with exit code 0), so the drain_code != 0 check
can't catch it and every tick silently no-ops.

The obvious fix isn't safe either: `--json` skips the prompt, but confirmed
live against archy-x250-pa3 that on a wallet.db with a stale duplicate
per-mint entry (trailing-slash leftover from before the mint_url fix), it
completes a real swap against the good entry, then aborts on the second
(empty, stale) entry and reports "success": false without ever printing or
persisting the resulting token anywhere. 50 sats went from spendable balance
to gone in that one call. Documented so nobody "fixes" this by wiring in
--json before upstream fixes the partial-failure data loss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
2947277205 fix(openwrt): close TollGate free-access gap and mint URL mismatch
Two bugs found live against archy-x250-pa3: TollGate-3458 (the upstream
tollgate-module-basic-go installer's own default AP, rebranded from
OpenWrt's factory default wireless.default_radioN sections) was left
bound to `network=lan` — wide open, unmetered, and sharing the router's
admin LAN — because install_ipk() runs the upstream package's own
uci-defaults scripts but nothing reconciled the AP they create with the
separate `tollgate` network/bridge/firewall this project's own
provision_ssid() sets up for the "archipelago" SSID. Fixed by folding any
default_radioN section left on `lan` onto the `tollgate` network right
after it's created.

Separately, a caller-supplied mint_url with a trailing slash
(https://mint.minibits.cash/Bitcoin/) got written byte-for-byte into
accepted_mints[0].url, which tollgate-wrt string-compares exactly against
a token's embedded (slash-less) mint URL — rejecting every otherwise-valid
token as an "untrusted mint". Fixed by trimming trailing slashes before
the value is used anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KTdMfVJChCwCCYF1ZTRQLc
2026-09-08 21:06:36 -04:00
archipelago db52c06a72 chore(catalog): sign Cuprate registry update 2026-09-07 05:12:05 -04:00
archipelago 4b14b62e74 chore: publish release v1.8.11-alpha
Demo images / Build & push demo images (push) Successful in 3m40s
2026-09-07 04:35:48 -04:00
archipelago 5da91e4099 chore: prepare release v1.8.11-alpha 2026-09-07 04:32:02 -04:00
archipelago 62731cc729 test(ui): use shipped app for generated launch port check 2026-09-07 03:30:29 -04:00
archipelago 5e17ace690 style(openwrt): format TollGate installer 2026-09-07 03:26:31 -04:00
archipelago b010471a4a chore(release): prep v1.8.11 notes and link checks 2026-09-07 03:26:01 -04:00
ai c4ede96517 Merge PR #154: docs(openwrt): OpenWrt Gateway setup guide + live-tested fixes
Demo images / Build & push demo images (push) Successful in 3m52s
2026-09-07 07:24:33 +00:00
ai be06e1a502 Merge PR #153: fix(cuprate): enable fast_sync and raise DB cache 2026-09-07 07:24:25 +00:00
ssmithxandClaude Sonnet 5 094f42312c docs(openwrt): document the confirmed working end-to-end install flow
Adds a verification checklist (service running, nodogsplash bound to
br-tollgate not br-lan via the rendered config not just UCI, LAN/SSH
untouched, mint probes succeeding) plus notes on the dev-build test-mint
injection and the default-route race between a router's LAN interface
and the node's other uplinks before the router's own WAN/WISP is live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 03:06:58 +00:00
ssmithxandClaude Sonnet 5 da8c3ec193 docs(openwrt): note the Ctrl+T/LuCI workaround for setting the initial root password
Archipelago's Connect form only authenticates with an existing password;
it has no flow for setting one on a fresh, passwordless router. On the
node's kiosk display there's no visible tab bar, so Ctrl+T to open a new
tab to LuCI is the way to set it before Connect will work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-07 02:53:21 +00:00
ssmithxandClaude Sonnet 5 4fdf8e8c58 fix(openwrt): bump pinned TollGate release v0.2.0 -> v0.5.0
The install code was hardcoded to the Oct 2025 v0.2.0 release —
nine releases behind. Its changelog covers exactly the failures hit
live against archy-x250-pa3: a mint with an empty/broken keyset
crash-looped tollgate-wrt forever (v0.5.0 adds "graceful degradation
when Cashu mints fail"), and the bundled captive-portal JS had zero
CBOR support, hard-rejecting the cashuB (NUT-00 V4) tokens modern
wallets like Minibits generate by default.

Also: v0.5.0 publishes native .apk packages for aarch64_cortex-a53
and x86_64. install_tollgate_apk_native now prefers those directly
(apk add handles deps/postinst/uci-defaults itself) instead of always
falling back to the manual ar/tar .ipk extraction dance, which only
exists because earlier releases had no native apk build at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 17:28:20 +00:00
ssmithxandClaude Sonnet 5 61b5d93b11 docs(openwrt): document the transient post-reboot apk-update failure
Observed live on archy-x250-pa3: right after WAN reconnects (fresh
boot or WAN reconfigure), the first Install attempt can fail with
"apk update failed ... router may have no internet access" purely
because the WiFi-uplink STA association hasn't finished yet — it's
not a real error, just retry a few seconds later. Also cross-referenced
the now-fixed /usr/bin/opkg hardcoding bug for anyone hitting it on an
older build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 16:36:53 +00:00
ssmithxandClaude Sonnet 5 be06b3ce2b fix(ui): stop sending an empty ssh_password over the saved router connection
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.

Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:07 +00:00
ssmithxandClaude Sonnet 5 f3d96ae2ee fix(openwrt): resolve opkg/apk via $PATH, not a hardcoded /usr/bin path
opkg_check() and every opkg/apk invocation hardcoded /usr/bin/opkg and
/usr/bin/apk. Official OpenWrt images don't all symlink /bin into
/usr/bin — the glinet_gl-mt3000 24.10.2 build keeps them as separate
real directories with opkg living in /bin — so the check silently
missed a perfectly normal install and TollGate provisioning failed
with "this router's firmware may not support package management".

Switched every call to resolve through the router's own $PATH
(command -v / bare opkg / apk) instead. Reproduced and fixed live
against archy-x250-pa3, 2026-09-05.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 15:14:00 +00:00
ssmithxandClaude Sonnet 5 a4ae375617 docs(openwrt): fix TollGate step — install is separate from configure
Step 4 described a single "Provision TollGate" action that prompts for
price/step/mint upfront. The real UI (OpenWrtGateway.vue) doesn't work
that way: "Install TollGate" is a one-click action with no config form
that installs with defaults, and price/step/mint/enabled are only
editable afterward via a separate "Edit" panel. Caught while walking
through a live install.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:32:39 +00:00
ssmithxandClaude Sonnet 5 0646bc4e85 docs(openwrt): add GL.iNet AX3000 → stock OpenWrt flashing steps
Worked example for the Beryl AX (GL-MT3000, mediatek/filogic) verified
against the OpenWrt wiki and firmware selector: exact sysupgrade image
filename, GL.iNet UI / LuCI flash path, post-flash SSH state, and the
U-Boot recovery procedure if the flash goes sideways.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:26:21 +00:00
ssmithxandClaude Sonnet 5 0faaf4577f docs: add OpenWrt Gateway setup guide
Walks a node operator through pairing an OpenWrt router over SSH,
running the WAN/WISP wizard, and provisioning TollGate pay-as-you-go
WiFi — plus an RPC/architecture reference for developers. Distills
the openwrt crate, RPC handlers, and Vue panel into user-facing steps
that didn't exist anywhere in docs/ before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
2026-09-05 14:07:05 +00:00
ssmithxandClaude Sonnet 5 f9a1ef031c fix(cuprate): front the restricted RPC port with a Tor onion
The restricted-RPC port (18090) was `auth: none`, which the app gate
treats as fully exempt — no onion, no takeover, LAN/Tailscale IP only.
Flip it to `auth: open`: the gate still binds the external addresses
and fronts a Tor onion for the port, just without a dashboard login
challenge, since Monero wallet clients (Feather, monero-wallet-rpc,
GUI) speak plain HTTP JSON-RPC and can't hold a session cookie.

P2P (18183) stays `none` — no reason to Tor-front raw gossip.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it takes effect on any
node, since origin (catalog) wins over disk for catalog-covered apps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NZnsiMtyJxiJBuvv7yLPUF
2026-09-03 14:51:24 +00:00
ssmithxandClaude Sonnet 5 cf240df4b6 fix(cuprate): enable fast_sync and raise DB cache — sustained 45% CPU
The default manifest baked in the exact broken config found on an
affected fleet node: no fast_sync (defaults false, forcing full ring-sig/
RandomX verification on every block) and target_max_memory capped at
~2.8GiB, which starved cuprated's DB cache into constant eviction/flush
(595GB/24h of block I/O on a node just appending ~2MB blocks every 2
minutes). A reference node with fast_sync = true and an 8GiB cache ran
at 2.8% CPU at the same chain height and block rate.

Set fast_sync = true and target_max_memory = 8GiB to match the healthy
reference config, and raise resources.memory_limit from 4Gi to 10Gi so
the container still has headroom above the larger cache.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RR7jRaicvqsJaqQQ92jpPQ
2026-09-03 08:56:52 +00:00
archipelago d8320896c4 chore: publish release v1.8.10-alpha
Demo images / Build & push demo images (push) Successful in 3m26s
2026-09-01 19:01:54 -04:00
archipelago b87f1f0612 chore: prepare release v1.8.10-alpha 2026-09-01 18:58:33 -04:00
archipelago 1ca002661b fix(lnd): SendPaymentV2 needs an explicit fee budget — absent means ZERO
Demo images / Build & push demo images (push) Successful in 3m28s
v1.8.9's move to Router.SendPaymentV2 shipped without fee_limit_sat,
and the v2 route treats an ABSENT fee limit as zero allowed fees.
Every real route carries a routing fee (the 2-hop route here: 1.5
sats), so the pathfinder rejected them all and the wallet answered
"No route to the recipient" on EVERY send — all day, on healthy
channels with plenty of liquidity both ways.

The router debug log makes it unambiguous:
  wallet payment (v1.8.9 backend): fee_limit=0 mSAT     -> no route
  same payment by hand (lncli --fee_limit=100): fee_limit=100000 mSAT -> settles in 0.65s

My earlier "pipeline verified" claim was wrong — the manual lncli
verification set a fee limit by hand and masked this exact bug. The
400k that succeeded this morning went through the pre-update backend
on the pre-update LND.

Payments now carry lncli's own default budget — the payment amount
(100%), preferring the payer-supplied amount for zero-value invoices
and the invoice's own amount otherwise, with a nominal floor so the
limit can never be zero. Unit-pinned so it cannot regress.
2026-09-01 18:42:37 -04:00
archipelago 0d0e2e243a feat(lnd): channel-peer watchdog — a dropped peer link heals itself
Demo images / Build & push demo images (push) Successful in 3m49s
LND normally reconnects channel peers after a restart, but not reliably:
after long or repeated downtime (an app update, a node reboot,
reconciler churn) the peer link can stay down for hours while BOTH
endpoints keep the channel flagged disabled in the routing graph. The
node looks perfectly healthy, the wallet shows balance, and every
payment in either direction fails "no route to the recipient" —
observed live on framework-pt (2026-09-01): its only channel sat
disabled on both policy sides for ~17 hours after the LND 0.21.2
update, while shorty had 583k spendable and the user was told, by a
mis-mapped modal, that they had 'no payment channel'.

The channel graph is desired state — every open channel should have a
live peer connection. A daemon-side watchdog now enforces it:

- every 2 minutes, list channels + peers over LND REST
- for each channel whose remote peer is not connected, look the peer's
  advertised addresses up in the public graph and dial one
- per-peer retries throttled to 10 minutes so an unreachable peer is
  not hammered; 'already connected' counts as done; a peer with no
  advertised address is logged once per pass (cannot be dialed)
- no-ops quietly on nodes without LND (missing macaroon) and while a
  wallet is locked (503 body has no channels)

Unit tests pin the selection against the live REST shapes
(remote_pubkey in /v1/channels vs pub_key in /v1/peers).

v1.8.10 CHANGELOG + What's New entries staged so the next release run
is clean first time.
2026-09-01 17:51:15 -04:00
archipelago 9c49b502e3 docs: post-1.8.9 verification — pipeline confirmed, routing failure root-caused to framework-pt's disabled channel 2026-09-01 16:36:27 -04:00
archipelago d68a013e35 docs: tracker — v1.8.9 published, NPM live-healed on shorty via the signed catalog; funding-gate fix staged for v1.8.10 2026-09-01 11:43:33 -04:00
archipelago 1464b1b24d fix(wallet): the Lightning funding gate states the node's real channel state
Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.

The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
  confirmations, it unlocks automatically, nothing is needed from you"
  (and no "Open a channel" button — that would send the user to fix
  a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
  side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
  of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance

Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
2026-09-01 11:40:25 -04:00
archipelago 82001403b4 chore: publish release v1.8.9-alpha 2026-09-01 11:05:55 -04:00
archipelago 81ede159ac chore: prepare release v1.8.9-alpha 2026-09-01 11:02:00 -04:00
archipelago 8e988be853 chore(release): v1.8.9-alpha prep — What's New block + version bumps
Demo images / Build & push demo images (push) Successful in 3m48s
The release gate requires the freshly-built bundle to embed the new
version, and the version reaches the bundle through the What's New
modal in AccountInfoSection — there was no v1.8.9-alpha block yet, so
create-release.sh correctly refused to ship a bundle that looked stale.
This adds the block (the user-facing summary of today's LND/HTTPS/
launcher/NPM/Portainer fixes) and carries the version bumps the
aborted run had already written (Cargo.toml, Cargo.lock, package.json,
package-lock), so the re-run starts from a clean tree.

Verified: npm run build now produces assets containing 1.8.9-alpha
(Settings chunk), i.e. the exact check the script runs passes.
2026-09-01 10:54:47 -04:00
archipelago 210f7f1b12 chore(catalog): re-sign the catalog — NPM letsencrypt mount + NET_BIND_SERVICE
Regenerated from the fixed apps/nginx-proxy-manager/manifest.yml (the
only semantic change vs the previous signed catalog) and signed with
the release-root key. Catalog-covered nodes pick this up on their next
hourly fetch and the NPM start/die loop ends: s6 gets its /etc/letsencrypt
mount back and the internal nginx can bind 80/443/81 again under
--cap-drop=ALL.
2026-09-01 10:37:09 -04:00
archipelago ed49cc974f docs: tracker updated — fixes landed, tests green, remaining steps are the two mnemonic ceremonies + node updates 2026-09-01 10:31:36 -04:00
archipelago 4849186ab9 docs: incident tracker for the 2026-09-01 https/launcher/LND breakage + v1.8.9 notes
Demo images / Build & push demo images (push) Successful in 3m57s
Root-cause table, fix inventory, regression-test inventory and the
deploy/live-verification checklist for today's fleet incident — written
as the working record while the fixes land, so the deploy + verify steps
can be checked off against real nodes rather than memory. CHANGELOG
carries the user-facing notes for the release these fixes ship in.
2026-09-01 10:29:10 -04:00
archipelago 3347b8b8b9 fix(ui): https app launches and the nostr bridge follow the frame's real origin
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:

1. portAuth() looked the launch port up under the name the user clicks
   ('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
   declares those ports under the manifest id that owns them
   (archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
   portIsGateFronted answered false, and an HTTPS dashboard handed app
   frames http:// URLs — blocked as mixed content: mempool and IndeeHub
   'did not connect', bitcoin knots/core opened http:// in a new tab.
   Resolution now follows launch aliases, then a port-wide catalog scan
   that only answers when every declarer of that port agrees (a port
   any app publishes as plain HTTP is never upgraded to https).

2. The signed-catalog cache was only warmed by the Store/Discover
   views, so a user who went straight to My Apps launched apps with an
   empty cache. Warmed at dashboard mount now — fetchAppCatalog()
   already memoizes with a 1h TTL.

3. The NIP-07 bridge compared event.origin for strict equality with the
   recorded (http) app URL and replied to the recorded URL as the
   postMessage targetOrigin — both break the moment a frame is scheme-
   upgraded (cached HSTS did exactly that): every nostr request was
   silently dropped and replies to the stale origin threw. The bridge
   now matches host+port (scheme deliberately ignored) and always
   replies to event.origin — the frame's real origin.

Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
2026-09-01 10:29:05 -04:00
archipelago e382e679ae fix(apps): NPM needs /etc/letsencrypt mounted and NET_BIND_SERVICE
Converting Nginx Proxy Manager to a platform manifest (fc68c5b6) dropped
two things its image hard-requires, and the result was an endless
start/die loop — shorty-s watched it restart 3,176 times:

1. /etc/letsencrypt mount: NPM's s6 'prepare' service refuses to boot
   without it ('ERROR: /etc/letsencrypt is not mounted!'). Mounted from
   the same persistent app directory as before
   (/var/lib/archipelago/nginx-proxy-manager/letsencrypt), so existing
   certificates are preserved — no data moves, no migration.

2. NET_BIND_SERVICE: NPM's internal nginx listens on 80, 443 AND 81,
   and the orchestrator runs --cap-drop=ALL. The legacy podman-run path
   defaulted to the full capability set (and the legacy repair path in
   package/config.rs always listed it), which is why this only broke
   once the manifest became the source of truth.

The signed catalog embeds manifests with origin-wins semantics, so the
catalog carries the fix for every catalog-covered node — regenerate it
here (plus the generated store/launcher-port artifacts, which also pick
up drift from bf6ef964's retired apps). Catalog re-signing follows the
usual ceremony.
2026-09-01 10:29:05 -04:00
archipelago 77d0768a21 fix(nginx): stop pinning HSTS — actively clear it instead
The HTTPS server block sent Strict-Transport-Security:
max-age=31536000; includeSubDomains. Browsers that visited HTTPS once
cached the policy and then silently upgraded the still-open HTTP
dashboard's fetches and frames to https — a scheme change is
cross-origin, so every /rpc/v1 call died 'No Access-Control-Allow-
Origin header' while the node was perfectly healthy (framework-pt
2026-09-01: the 'Failed to fetch' storm, dashboard 'not responding',
every app frame mixed-content-blocked).

Plain HTTP is a supported access mode BY DESIGN on this platform: the
node's certificate is optional and self-signed (Settings → Node
certificate, /ca.crt flow), and setup-node-ca.sh deliberately keeps
port 80 serving for devices that haven't installed the CA. So:

- port 80 sends no HSTS at all (with the rationale inline)
- port 443 sends max-age=0, which ACTIVELY DELETES the policy already
  cached by affected browsers — leaving it absent would have kept every
  stranded browser broken for a year

tests/lifecycle/bats/nginx-hsts.bats pins all three properties at the
gate: no live policy on :80, max-age=0 (never 31536000) on :443, and
no long-lived pin anywhere in the deployed config.
2026-09-01 10:28:57 -04:00
archipelago f133d5555a feat(apps): surface Portainer's first-run setup token in the credentials interstitial
Portainer >=2.21 no longer lets whoever loads the page first claim the
admin account: on a fresh install it mints a one-time setup token and
prints it ONLY to the server logs. On an appliance that is a dead end —
'check the Portainer server logs' is exactly what a user cannot follow,
and after the 2.45.0 update it made a freshly restarted Portainer look
broken ('disappeared', then demands a token nobody can find).

package.credentials — the same RPC that powers the login-credentials
card on the app page — now extracts the setup_token line from
portainer's recent container logs and hands it over with the existing
copy-button treatment, titled and explained for a first-time user. The
token stops being printed once setup completes, and any container
recreate drops the log line, so the card disappears on its own and no
dead token lingers. Parsing is a pure, unit-tested scan against the
live-captured 2.45.0 log shape (64 hex chars after setup_token=).
2026-09-01 10:28:57 -04:00
archipelago cbd5314dd9 fix(lnd): pay through Router.SendPaymentV2 — LND 0.21 removed the old route
LND 0.21.2 removed the deprecated Lightning.SendPaymentSync REST route
(/v1/channels/transactions). The backend still called it, so every
Lightning send answered literal HTTP 404 and the wallet UI reported
'Payment failed: Not Found' fleet-wide right after the pin bump —
receive worked, which made it look intermittent.

Pay through the supported Router.SendPaymentV2 route (/v2/router/send)
instead, keeping the existing contract with the UI:
- single-record responses (no_inflight_updates) unwrapped from the
  grpc-gateway result envelope, transport errors from the nested error
- a slow multi-hop payment still resolves as pending + payment hash
  (only LND may declare failure), never a false 'Payment failed'
- LND's failure_reason codes translated to the same plain-language
  advice, invoice-expiry still says 'ask for a fresh invoice'

Guard it at the gate: tests/lifecycle/bats/lnd-api-compat.bats POSTs a
deliberately-invalid invoice to /v2/router/send on the RUNNING LND and
fails if the route answers 404 — the image/backend skew that shipped
silently last time because no test ever spoke the payment endpoint.
Also bumps the stale lnd image expectation in remote-lifecycle.sh.
2026-09-01 10:28:49 -04:00
archipelago 9fb2e1ed9e chore(catalog): sign the Cuprate logging fix 2026-09-01 08:47:29 -04:00
archipelago 7125dea05d Merge PR #152: fix Cuprate logging defaults 2026-09-01 08:39:26 -04:00
ssmithxandClaude Sonnet 5 bcdf2c75be fix(cuprate): file log level should be info, not cuprated's debug default
The previous commit on this branch copied cuprated's raw
--generate-config defaults (stdout=info, file=debug, max_log_files=7)
verbatim. Turns out that's the wrong reference: compared against
ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml — the actual
dev config this app was built and tested against — file logging is
meant to run at "info" with 14 rotated files, not the binary's raw
"debug"/7. Confirmed live on amishparadise: podman logs (stdout) was
already clean at info, but the on-disk file log
(.local/share/cuprate/logs/<date>) was flooding with per-peer DEBUG
gossip (~400KB in 2 minutes) because it inherited the binary default
instead of the intended one.

Left the resource-tuning knobs in the reference config (8GB
target_max_memory, tokio/rayon thread counts, P2P connection counts,
explicit reader_threads) out of this file — those were sized for
ssmithx's dev box and don't apply here; this manifest's
target_max_memory already stays deliberately under the container's
4Gi memory_limit.

Regenerated releases/app-catalog.json (still unsigned).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:27:33 +00:00
ssmithxandClaude Sonnet 5 e77f60085d fix(cuprate): make Cuprated.toml logging levels explicit
apps/cuprate/manifest.yml only ever wrote network/target_max_memory/
rpc.restricted.enable into Cuprated.toml, so the [tracing.stdout] and
[tracing.file] tables were silently absent — cuprated still applied
its built-in info/debug/7 defaults, but nothing on disk showed it.
Verified live on amishparadise 2026-09-01: the deployed 5-line file
had no [tracing] section at all, and the level was only discoverable
by running `cuprated --generate-config` and diffing.

Add both tables to the manifest's files[].content with the same
values cuprated already defaults to, so every new install ships a
Cuprated.toml an operator can actually read and tune. overwrite:false
means already-deployed nodes (amishparadise included) keep their
existing file untouched — this only changes what fresh installs get.

Regenerated releases/app-catalog.json (unsigned) to embed the updated
manifest; needs scripts/sign-catalog.sh before it's authoritative for
the fleet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ga6N8Jk1YdCTMMX1LjDpAr
2026-09-01 12:19:20 +00:00
archipelago 6c31eb9d4a chore(catalog): sign the LND 0.21.2 sweep 2026-09-01 07:45:39 -04:00
archipelago 63e6c64c63 fix(ci): drop the remaining stray Claude-worktree gitlinks 2026-09-01 05:27:15 -04:00
archipelago 4d8bb1fd44 fix(ci): drop a stray gitlink that broke every demo build
A Claude worktree under aiui/ was committed as a submodule gitlink
(160000) with no .gitmodules entry, so actions/checkout's recursive
submodule pass exited 128 and every 'Build & push demo images' run since
v1.8.6 failed. Removing the index entry — the worktree is local state
and was never meant to be tracked.
2026-09-01 05:27:00 -04:00
archipelago 2b4b60013c feat(lnd): build LND 0.21.2-beta in-house and sweep the pin
Demo images / Build & push demo images (push) Failing after 40s
Upstream publishes no docker images; our v0.18.4 image was built in-house.
This pass: official v0.21.2-beta release binaries (sha256-verified against
the signed release manifest), static, on alpine with the same entrypoint
shape as our existing image, pushed to our registry and smoke-run.
LND 0.21 auto-migrates the channel DB on first start (keeping a backup) —
the Update button is user-initiated, never auto-applied.
2026-09-01 05:14:50 -04:00
archipelago f0ef410948 chore(catalog): sign the swept pins 2026-09-01 04:52:28 -04:00
archipelago 19467e9b7c chore(apps): sweep pin bumps — gitea 1.27.3, vaultwarden 1.37.2, filebrowser 2.63.23, home-assistant 2026.8.3, adguardhome 0.107.79, portainer 2.45.0, pine-whisper 3.6.0
Demo images / Build & push demo images (push) Failing after 39s
First upstream sweep since v1.8.5: the safe patch/minor pins, mirrored
into our registry first (source.archipelago-foundation.org/lfg2025/*).
Held for their own careful passes: the majors (grafana 11, nextcloud,
uptime-kuma 2, bitcoin-core 29, the DBs) and consensus-sensitive apps
(fedimint, electrumx 2.0). LND 0.21.2 needs an in-house image build —
upstream publishes none.
2026-09-01 04:50:49 -04:00
archipelago 628ed252b4 chore: publish release v1.8.8-alpha 2026-09-01 04:18:16 -04:00
archipelago bc94445ca0 chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:57:23 -04:00
archipelago 04cf0f663a chore: drop the superseded v1.8.8 prep for rebuild 2026-09-01 03:49:16 -04:00
archipelago 576c642da4 fix(apps): ollama resource type + adguardhome port; gate on collisions
Demo images / Build & push demo images (push) Failing after 38s
Ollama's embedded manifest failed the typed parse (memory_limit wants a
string) so the catalog overlay was skipped for it; AdGuard Home's
conventional :3000 collided with Grafana's. The release gate now runs
the host-port collision test (repo_app_manifests_have_no_host_port_collisions)
so this class can never ship untested again.
2026-09-01 03:31:02 -04:00
archipelago 12866db84a chore: sign the v1.8.8 app catalog + release manifest 2026-09-01 03:16:10 -04:00
archipelago a184254706 style: rustfmt the ssh-mesh module 2026-09-01 02:42:39 -04:00
archipelago 192e045426 feat(ui): SSH-over-mesh card, store-listing filter, icon treatment
Demo images / Build & push demo images (push) Failing after 41s
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
2026-09-01 02:41:55 -04:00
archipelago 9ac46a69f8 feat(fips): SSH-over-mesh toggle + manifest-driven package metadata
fips/ssh_mesh.rs owns the 90-ssh.nft drop-in lifecycle: off by default,
any-peer scope behind the UI's danger confirmation or an explicit mesh
address list, reconciled on every daemon config install. The scanner now
takes installed apps' icons from their real manifest metadata (Cuprate's
Services tile) and classifies manifest-declared UI apps as launchable
even when the address probe misses (Alby Hub).
2026-09-01 02:41:55 -04:00
archipelago bf6ef9644c chore(apps): retire morphos-server, did-wallet, lightning-stack, cryptpad
Store-listing components are filtered via the shared serviceNames canon;
these four never earn a tile: MorphOS server is old, the Web5 DID wallet
and CryptPad are untested, Lightning Stack is an untracked upstream
bundle (LND covers it).
2026-09-01 02:41:55 -04:00
archipelago c32910809e chore: publish release v1.8.7-alpha 2026-09-01 01:42:12 -04:00
archipelago d2174128c5 chore: sign the v1.8.7 app catalog + release manifest 2026-09-01 01:37:24 -04:00
archipelago 2ad0171e5f fix(ui): drop the now-unused scheme helper
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 19:08:17 -04:00
archipelago 46cb0bfd37 fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
2026-08-31 18:41:00 -04:00
archipelago b8593c9090 docs(release): v1.8.7 notes — https app launches + platform round
Demo images / Build & push demo images (push) Failing after 34s
2026-08-31 18:40:51 -04:00
archipelago fc68c5b680 feat(apps): complete the manifest platform — convert the last five stragglers
Demo images / Build & push demo images (push) Failing after 36s
Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home now
carry full manifests: the app gate fronts their web ports (TLS on the
same port, node login where appropriate), installs run through the
orchestrator, and pins live in the signed catalog. Tailscale mirrors its
legacy shape exactly (userspace networking, web console on 8240, plain
HTTP for the gate to front). Ollama stays loopback-only — the
assistant's local model backend, not a web app.

Retires the four already-removed apps for good (FIPS, Nostr VPN,
Routstr, Penpot pins dropped from image-versions.sh, the generator map,
and image_versions.rs), fixes Cuprate's duplicated metadata block that
strict YAML parsers reject, and updates the port-inventory review gates
for the new open (3 own-login consoles) and exempt (2 DNS) ports.
2026-08-31 18:40:39 -04:00
archipelago 3ed75c328d style: rustfmt the signed-catalog serving 2026-08-31 17:09:16 -04:00
archipelago 687196ad3b chore: prepare release v1.8.7-alpha
Demo images / Build & push demo images (push) Failing after 37s
2026-08-31 17:08:37 -04:00
archipelago e2bd6330a1 test(app-catalog): pin the signed-catalog body gate 2026-08-31 17:08:37 -04:00
archipelago 7c0a492c43 fix(ui): launch apps on the page's scheme over HTTPS
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
2026-08-31 17:08:26 -04:00
archipelago 3089624969 Merge remote-tracking branch 'gitea-vps2/main' 2026-08-31 16:15:17 -04:00
archipelago 5b658cec67 feat(app-catalog): serve the signed catalog from the node first 2026-08-31 16:15:13 -04:00
archipelago 21b8d4b1ee catalog: add Cuprate (0.1.0-preview) 2026-08-31 16:10:38 -04:00
lfg2025 6f05f5583f Merge pull request 'docs: session record — companion 0.5.28 shipped + deployment playbook' (#151) from companion/session-2026-08-31 into main 2026-08-31 20:03:03 +00:00
Dorian 02ac4396d1 docs: session record — companion 0.5.28 shipped + the deployment playbook
Full state at session end (all public surfaces verified byte-identical
at 0.5.28/vc48; only node web-bundle redeploys outstanding), the feature
map, and the operational playbook next sessions need: Tor SOCKS proxy
for Gitea API/curl (the 'unreachable API' was a missing proxy flag),
token scopes, protected-main ship flow via -ship branch + PR + API merge,
stale local main lineage, the foundation server's two surfaces, demo CI
auto-redeploy, build/test commands, and the open items.
2026-08-31 21:02:59 +01:00
archipelago 5ffdcc9936 docs(release): explain the v1.8.7 correction
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:46:18 -04:00
archipelago 9cf07e1eac fix(release): enforce the v1.8 What's New floor
Demo images / Build & push demo images (push) Failing after 39s
2026-08-31 15:44:48 -04:00
archipelago e7854702c0 chore: publish release v1.8.6-alpha
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:40:23 -04:00
archipelago d4018a6e73 chore: prepare release v1.8.6-alpha 2026-08-31 15:34:51 -04:00
archipelago b57cba63d1 Merge remote-tracking branch 'gitea-vps2/main'
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 15:12:59 -04:00
archipelago 7bc9f69b1f fix(settings): start What's New history at v1.8.0 2026-08-31 15:12:18 -04:00
lfg2025 913743923c Merge pull request 'docs: deploy handoff — companion 0.5.28 to the live surfaces' (#150) from companion/0.5.28-deploy-handoff into main 2026-08-31 19:01:58 +00:00
Dorian 241e8cfca4 docs: handoff — deploy companion 0.5.28 (vc48) to the live surfaces
For the archi-dev-box agent: companion 0.5.28 is on main (PR #149) and
Gitea raw serves it (verified byte-identical, v1+v2+v3). Remaining: the
foundation server's static /packages mirror (the real-node QR download
URL — currently 0.5.27), node web-bundle redeploys (same as the
2026-07-23 flow), and confirming the demo stack flipped after CI's
webhook redeploy. Exact commands, expected shasum, and final verify
block included.
2026-08-31 20:01:44 +01:00
archipelago 017505c431 fix(release): include every curated changelog item 2026-08-31 14:52:47 -04:00
archipelago 7a39d8fbd1 fix(settings): sort What's New history newest-first
Demo images / Build & push demo images (push) Failing after 36s
2026-08-31 14:50:55 -04:00
archipelago e3275353b9 fix(release): publish assets before exposing manifest 2026-08-31 14:45:29 -04:00
lfg2025 9f1a289d1a Merge pull request 'Companion 0.5.28 — backup & restore, NIP-46 remote signer, companion-gated install pitch' (#149) from companion/0.5.28-ship into main
Demo images / Build & push demo images (push) Failing after 41s
2026-08-31 18:38:43 +00:00
246 changed files with 18324 additions and 9539 deletions
+22 -4
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 48
versionName = "0.5.28"
versionCode = 52
versionName = "0.5.32"
vectorDrawables {
useSupportLibrary = true
@@ -41,6 +41,17 @@ android {
enableV1Signing = true
enableV2Signing = true
}
// Local-only UAT builds install beside both the production companion
// and its shared-key debug package. The ignored uat.keystore is made
// on the validation box; it must never be used for a public artifact.
create("uat") {
storeFile = file("uat.keystore")
storePassword = "android"
keyAlias = "androiduatkey"
keyPassword = "android"
enableV1Signing = true
enableV2Signing = true
}
}
buildTypes {
@@ -51,6 +62,13 @@ android {
versionNameSuffix = "-debug"
signingConfig = signingConfigs.getByName("debug")
}
create("uat") {
initWith(getByName("debug"))
applicationIdSuffix = ".uat"
versionNameSuffix = "-uat"
signingConfig = signingConfigs.getByName("uat")
matchingFallbacks += listOf("debug")
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -118,8 +136,8 @@ tasks.register<Exec>("buildRustArm64") {
tasks.matching {
it.name in listOf(
"mergeDebugNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeReleaseJniLibFolders",
"mergeDebugNativeLibs", "mergeUatNativeLibs", "mergeReleaseNativeLibs",
"mergeDebugJniLibFolders", "mergeUatJniLibFolders", "mergeReleaseJniLibFolders",
)
}.configureEach { dependsOn("buildRustArm64") }
@@ -326,8 +326,9 @@ private object KioskWebView {
private fun injectSafeAreaVars(view: WebView) {
val insets = view.rootWindowInsets ?: return // listener re-fires when real
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt()
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
val sab = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars()).bottom / density).toInt()
// The insets listener fires on every pass (every IME show/hide); skip the
// JS round-trip — and the Vue event it dispatches — when nothing changed.
val stamp = "sa:$sat,$sab"
@@ -377,7 +378,8 @@ private fun injectSafeAreaVars(view: WebView) {
private fun injectTopInset(view: WebView) {
val insets = view.rootWindowInsets ?: return
val density = view.resources.displayMetrics.density
val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt()
val compatibleInsets = androidx.core.view.WindowInsetsCompat.toWindowInsetsCompat(insets, view)
val sat = (compatibleInsets.getInsets(androidx.core.view.WindowInsetsCompat.Type.statusBars()).top / density).toInt()
if (sat <= 0) return
view.evaluateJavascript(
"""
@@ -991,6 +993,51 @@ fun WebViewScreen(
)
}
}
/** HTML downloads are not handled by WebView.
* Fetch only this connected node's public CA
* over its always-available HTTP listener,
* verify it is an actual CA certificate, then
* hand it to Android's trusted system prompt.
* No caller-controlled certificate bytes are
* accepted by this bridge. */
@android.webkit.JavascriptInterface
fun installNodeCertificate() {
scope.launch {
try {
val der = withContext(Dispatchers.IO) {
val host = android.net.Uri.parse(serverUrl).host
?: error("node URL has no host")
val caUrl = java.net.URI(
"http", null, host, 80, "/ca.crt", null, null,
).toASCIIString()
val request = okhttp3.Request.Builder().url(caUrl).build()
okhttp3.OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) error("CA download failed")
val bytes = response.body?.bytes() ?: error("empty CA")
if (bytes.size > 64 * 1024) error("CA is too large")
val cert = java.security.cert.CertificateFactory
.getInstance("X.509")
.generateCertificate(java.io.ByteArrayInputStream(bytes))
as java.security.cert.X509Certificate
if (cert.basicConstraints < 0) error("certificate is not a CA")
cert.encoded
}
}
val intent = android.security.KeyChain.createInstallIntent().apply {
putExtra(android.security.KeyChain.EXTRA_CERTIFICATE, der)
putExtra(
android.security.KeyChain.EXTRA_NAME,
"Archipelago node CA",
)
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {
// Network failure, invalid CA, or no credential installer.
}
}
}
},
"ArchipelagoNative",
)
@@ -1523,6 +1570,11 @@ private fun InAppBrowser(
var loaderIcon by remember { mutableStateOf<Bitmap?>(null) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
// Once this WebView has painted an app, keep that surface visible during
// same-app reloads/navigation. Covering every navigation with an opaque
// Compose loader caused GitWorkshop to flash, and an IndeeHub auth reload
// could remain covered when WebView omitted the final callback.
var hasCommittedPage by remember { mutableStateOf(false) }
var canGoBack by remember { mutableStateOf(false) }
var canGoForward by remember { mutableStateOf(false) }
// Main-frame load failure — the branded offline screen renders instead of
@@ -1594,6 +1646,20 @@ private fun InAppBrowser(
// Node apps (BTCPay invoices, LND, Portainer tokens) are
// served over plain HTTP too — same dead-clipboard trap.
addClipboardBridge()
val appBrowserView = this
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun expectPageTransition() {
appBrowserView.post {
hasCommittedPage = false
loading = true
appBrowserView.invalidate()
}
}
},
"ArchipelagoSurface",
)
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
@@ -1623,7 +1689,7 @@ private fun InAppBrowser(
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
loading = !hasCommittedPage
loadError = false
view?.let {
injectTopInset(it)
@@ -1632,6 +1698,7 @@ private fun InAppBrowser(
}
override fun onPageFinished(view: WebView?, u: String?) {
hasCommittedPage = true
loading = false
canGoBack = view?.canGoBack() == true
canGoForward = view?.canGoForward() == true
@@ -1641,6 +1708,14 @@ private fun InAppBrowser(
}
}
override fun onPageCommitVisible(view: WebView?, url: String?) {
// Fires when the new main-frame pixels are ready,
// earlier and more reliably than onPageFinished
// for service-worker-controlled SPAs.
hasCommittedPage = true
loading = false
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
@@ -1732,6 +1807,7 @@ private fun InAppBrowser(
text = stringResource(R.string.retry),
onClick = {
loadError = false
hasCommittedPage = false
loading = true
browser?.reload()
},
+151
View File
@@ -1,5 +1,156 @@
# Changelog
## Unreleased
## 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)
- **GitWorkshop installs reliably on fresh nodes.** The app is classified as a user-facing app while its install placeholder is being created, so it remains visible under My Apps instead of Services.
- **Fresh GitWorkshop installs build the correct image.** The production orchestrator handles its bundled build context instead of sending the local image reference through the legacy registry-pull path.
- **Curated app classification is regression-tested.** Every user-facing app remains in My Apps during installation, while headless services stay in Services.
## v1.8.12-alpha (2026-09-11)
- **Fresh IndeedHub installs no longer share a fleet-wide encryption root.** The API now generates a persistent per-node AES master secret and shares it with the media worker through the platform's protected secret environment. Existing nodes migrate the exact legacy value they are already using before any container can be recreated, preserving access to encrypted data; an unreadable or empty existing root fails safely instead of being silently replaced. The manifest path, retired fallback installer, and container repair script follow the same rule.
- **The Companion download advertises and re-announces the APK it actually serves.** The Discover banner and its install prompt now share the no-cache APK metadata, visibly report Companion 0.5.32 build 52, and remember dismissal per Android build rather than forever, so an existing browser gets one useful update prompt when the APK changes. The ISO gate reads the expected version from the Android build itself instead of accepting the stale 0.5.28 payload.
- **GitWorkshop's dependency audit is clean.** The pinned upstream client keeps its separately reviewable Archipelago integration patch and now applies a deterministic dependency patch: safe lock refreshes plus targeted `fflate`, React Router, and Vitest upgrades remove all ten production advisories and all eight development advisories. A clean install reports zero vulnerabilities; type-check, all 152 upstream unit tests, and the exact Archipelago subpath build pass.
- **Every completed payment now gets the full Lightning-style receipt screen.** Cashu and Fedimint sends no longer leave the payment form open behind a token; wallet, QR-scan, Web5, and app-requested sends all replace their forms with the animated success state. Payment hashes, transaction IDs, ecash tokens/notes, mint details, and other useful references remain copyable in the receipt, and receive completions open the same distinct payment-success modal. Minibits claims retain a short-lived durable receipt so the visible modal still reports success when another dashboard or Companion context wins the claim-poll race, while concurrent watchers now share one bounded relay fetch instead of queueing several long polls.
- **TollGate provisioning closes the free-access path without taking over an admin network.** Confirmed upstream `TollGate-*` access points are moved from LAN onto the paid network, mint URLs are normalized consistently, and operators can set a validated Lightning payout address without replacing merchant keys or other revenue-share identities. Malformed existing identity data now stops provisioning safely instead of being overwritten.
- **Cashu receive gains a human-readable Minibits Lightning address.** The node derives the profile from the existing ecash recovery phrase, collects payments from the Minibits Nostr delivery relays, and redeems them into the Cashu wallet. Claim polling is single-flight, state and already-consumed tokens are written atomically with private permissions, same-second events are deduplicated without being skipped, restored seeds cannot reuse another wallet's profile, and pending claims retain the service key that encrypted them across key rotations. The UI identifies Minibits as a third-party beta service and recommends small balances.
- **Nostr sign-in returns directly to the app instead of a black or grey frame.** The top-level signer broker now stays loaded as a 1px non-interactive surface parked physically off-screen; removing or display-hiding its full-screen cross-origin iframe could leave stale compositor pixels above IndeeHub or GitWorkshop in Android WebView and mobile Chromium until refresh. One retained broker also keeps identity selection and its immediately following signing request in a continuous UI, while Companion no longer adds a separate 180ms cover that made GitWorkshop visibly flicker.
- **Gitea is sized for source and release hosting, not an empty demo.** Its manifest storage allowance is now 50GiB, release attachments accept individual files up to 10GiB, container-package owner storage remains unlimited, and HTTP/HTTPS proxy uploads share a streamed 10GiB ceiling. Existing repository, package, LFS and release data is unchanged.
- **Companion browser-tab signing now accepts the app gate's complete session.** A fresh external browser no longer needs a prior dashboard login/localStorage marker before the dashboard-origin signer can load. The app gate now issues both the shared HttpOnly node session and its matching readable CSRF token, so identity discovery and signing RPCs work after that one login instead of rendering a misleading “No identities found” state. Normal dashboard logout/session checks keep their existing behavior.
- **Fast Nostr identity choices now survive app startup and Companion tabs.** The tab/WebView broker waits for the application load event before opening its first-run picker, queues every NIP-07 call until the signer is initialized, and hands the just-selected public key directly to the immediate login request. GitWorkshop now turns that first-run choice into its normal extension account automatically, eliminating the startup race that surfaced as IndeedHub's “Could not get public key from extension.”
- **GitWorkshop makes network projects and Archipelago login explicit.** Its signed-in dashboard now includes recent repositories from the Nostr git index, the NIP-07 action reads “Extension / Archipelago,” and explicit Archipelago logins reopen the node identity chooser instead of silently reusing the first identity. Direct, user-triggered NIP-07 logins receive the same account-switch behavior for upstream apps such as IndeedHub.
- **IndeedHub tab signing now tracks the dashboard signer.** The injected provider supports the contained signer broker in direct tabs, is cache-busted, and is reconciled after dashboard-only updates as well as app installs and starts.
- **App launches now honor credentials everywhere.** Home, Spotlight, Discover, My Apps, and app-detail launches all pass through one platform-owned credential handoff, so Portainer's first-run token and the File Browser/PhotoPrism login details can no longer be skipped by launching from the Home grid.
- **Manage Updates returns to Download immediately after cancellation.** Canceling a stalled OTA now clears both the local staged state and progress state instead of leaving an incorrect Install button visible until the page is refreshed.
- **GitWorkshop no longer probes a desktop-only localhost relay or unauthenticated manifest.** The packaged upstream client disables its default `localhost:4869` nostrdb probe, uses credentialed manifest loading, drops dead lookup relays, and permits the dashboard's contained signer broker in its frame policy.
- **Rootless app ports self-heal when `pasta` drops a listener.** The five-minute container doctor compares every running container's declared Podman port bindings with actual host listeners and restarts only a container whose listener vanished. TCP and UDP are checked separately, avoiding false restarts of services such as NetBird's UDP port 3478. This covers the intermittent Nginx Proxy Manager port 8081 rebind failure without requiring a node reboot.
- **Nostr identity actions now use one contained, companion-safe signing experience.** The old full-screen signer has been replaced by the same in-app consent surface used by embedded apps, with the animated identity circle as a brief signing indicator and an explicit completion state. Editing an identity now ends on a dedicated success screen that reports relay coverage and the event ID instead of disappearing back into the form. The app developer guide defines this platform-owned NIP-07 flow and its browser/Companion test matrix so apps do not add a second signer UI.
- **Discovery merchandising is now owned by the signed app registry.** The catalog declares the Popular Apps set and contribution promotion; Discover renders two desktop rows of popular apps, then the “Your node. Your source.” banner, then the remaining apps. GitWorkshop uses a cache-busted copy of its current upstream mark, and its catalog entry identifies the canonical Archipelago maintainer npub.
- **Companion opens Source in its native WebView and installs the node certificate.** GitWorkshop is a top-level page in the Companion in-app browser—not a dashboard iframe—and its injected provider uses the contained, consent-gated signer broker. The generic native launcher turns relative app paths into complete URLs before handing them to Android. The Node certificate button uses Android's system credential installer in the companion instead of an unsupported WebView download.
- **Node certificate guidance now covers installation and the failures people actually see.** Settings includes the complete macOS, iOS/iPadOS, Windows, Android, Linux, Firefox, and Arch/Manjaro steps; reminds users to restart browsers that cache trust decisions; separates certificate trust from DNS; and maps common browser symptoms to their likely cause.
- **Tab and Companion Nostr sign-in no longer loses the broker or an early identity choice.** The signer route validates the shared app-gate session with the implemented, authenticated `system.get-hostname` RPC instead of the nonexistent `system.get-version`. The provider also exposes a sticky identity subscription so a GitWorkshop React listener that mounts just after selection still completes the normal NIP-07 login. The dashboard service worker no longer precaches the signer route or provider, preventing an old bridge from surviving an update. This repairs GitWorkshop automatic login and IndeeHub's external mobile-browser flow.
- **The App Store now makes Archipelago's source an invitation to contribute.** GitWorkshop has its real upstream icon and source-focused description, plus a dedicated “Your node. Your source.” banner explaining that users can browse the code, clone with ngit, and send issues, patches, and reviews over Nostr.
- **Source now packages GitWorkshop instead of maintaining a separate Nostr Git interface.** The pinned upstream client runs read-only behind the authenticated app gate, launches at the dashboard's same origin under `/app/archipelago-source/`, and uses the node's consent-gated NIP-07 bridge. The upstream revision declares no license; Archipelago's owner accepted that redistribution risk without representing the client as licensed. Production publication still requires a tested canonical Archipelago NIP-34/GRASP announcement.
- **Changing the node password now reports a wrong current password directly.** The backend was already rejecting the request before changing either the web or SSH password, but its error sanitizer replaced that safe, actionable explanation with “check server logs.” The real validation error now reaches the password dialog.
- **The periodic container doctor runs from the same canonical path used by OTA updates.** Its systemd unit and embedded bootstrap still pointed at the retired source-checkout path while release updates installed the script under `/opt/archipelago/scripts`, leaving the doctor failed on nodes without that checkout. ISO, OTA bootstrap, and the deployment smoke test now agree on the `/opt` path.
## v1.8.11-alpha (2026-09-07)
- **Cuprate now syncs without burning a core for days.** The app's shipped config now enables Cuprate's checkpoint-backed `fast_sync` path, raises the database cache to 8 GiB, and gives the container a 10 GiB memory limit so the cache has real headroom. A live comparison that motivated the change saw the affected node sit around 45% CPU while the corrected config held near low single digits at the same chain height and block rate. The restricted RPC remains fronted through the safe app gate/Tor path.
- **OpenWrt Gateway setup is documented from a real install, and two setup bugs are fixed.** The new guide walks a node operator through flashing a GL.iNet AX3000 to stock OpenWrt, pairing it with Archipelago, and installing TollGate pay-as-you-go WiFi. The installer now finds `opkg`/`apk` through the router's actual `PATH` instead of assuming `/usr/bin`, the UI no longer sends an empty password over a saved router connection, and the pinned TollGate package moves to `v0.5.0` with a native `.apk` install path where upstream provides one.
- **Release publishing now checks the public Gitea download links before a manifest goes live.** The publisher already fetched every artifact back and verified its size and SHA-256; this release adds a second guard for the release page itself, so a bad Gitea `ROOT_URL` or proxy setting cannot publish working files behind broken public HTTPS download links.
## v1.8.10-alpha (2026-09-02)
- **Lightning sends work again — v1.8.9's payment switch lost the fee budget.** Moving payments to LND 0.21's supported route (Router.SendPaymentV2) shipped without a fee limit, and the v2 API treats an absent limit as **zero allowed fees**: every real route carries a routing fee, so the pathfinder rejected them all and the wallet answered "No route to the recipient" on every send — all day, on healthy channels with plenty of liquidity. The router debug log made it unambiguous (`fee_limit=0 mSAT` on every failing wallet payment; the same payment succeeded by hand the moment a fee limit was set). Payments now carry lncli's default budget (the payment amount), the wallet's amount handling for zero-value invoices is preserved, and a unit test pins the limit can never be zero again.
- **A channel that drops its peer link now heals itself — on every node.** Restarting LND (an app update, a reboot, container churn) can leave a channel's peer connection down for hours while both endpoints keep the channel flagged disabled in the routing graph: the node looks perfectly healthy, the wallet shows balance, and every payment in either direction fails "no route to the recipient". Observed live: a node's only channel sat unroutable for ~17 hours after the LND 0.21.2 update, with no sign of it in any dashboard. The daemon now watches the channel graph as desired state — every open channel should have a live peer — and reconnects any that don't, using the peer's advertised addresses. Nodes without LND are untouched; an unreachable peer is retried gently, not hammered.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had NO channel at all (the outbound sum is legitimately zero in both states), pointed the user at opening a second channel, and — for payment routing failures — even showed the *receiving* copy. The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of claiming channel problems, and only a genuinely channel-less node keeps the open-one guidance.
## v1.8.9-alpha (2026-09-01)
- **Lightning sends work again after the LND 0.21.2 update.** LND 0.21 removed the old synchronous payment route the node's backend paid through (`/v1/channels/transactions`) — every Lightning send answered the literal "Not Found" and the wallet showed "Payment failed: Not Found". The backend now pays through the supported Router.SendPaymentV2 route, keeps the same settle-then-report behaviour (a slow multi-hop payment is still tracked to completion, never falsely declared failed), and translates LND's failure reasons into plain advice. A new gate test speaks the payment route directly against the running LND, so an image/backend skew like this can never ship silently again.
- **The node no longer pins HSTS — HTTP access is a supported mode, and it stays working.** The HTTPS listener used to send `Strict-Transport-Security: max-age=31536000; includeSubDomains`; browsers that visited HTTPS once cached that and then silently upgraded the still-open HTTP dashboard's calls to HTTPS, which is a scheme change — cross-origin — so every request died as "CORS blocked / Failed to fetch" while the node was perfectly healthy. The HTTPS listener now actively clears the cached policy (`max-age=0`) and port 80 sends no HSTS at all, which is deliberate: the node's certificate is optional and self-signed, and devices that haven't installed the CA must keep plain-HTTP access (that's what Settings → Node certificate is for). If your browser already cached the old policy, visiting the dashboard over HTTPS once after this update clears it; a gate test now refuses any config that reintroduces the pin.
- **App frames open over HTTPS again — including the ones that "did not connect."** The launcher asked the signed catalog for each app's port policy under the name you click ("Mempool Web", "Bitcoin Knots"), but the catalog declares those ports under the manifest that owns them (the Mempool web container, Bitcoin UI). The lookup missed, the launcher handed the iframe an `http://` address, and the browser blocked it as mixed content — the app tile went blank or spun forever. Port resolution now follows launch aliases (mempool-web, bitcoin-knots/bitcoin-core, lnd, electrs and friends), falls back to a port-wide catalog scan when the id is unknown, and the catalog is warmed as soon as the dashboard loads rather than only in the App Store, so the very first app you open already knows which ports serve TLS.
- **Signing in to IndeeHub with Nostr works over HTTPS.** The NIP-07 bridge compared the app frame's origin for exact equality with the recorded `http://` app URL — a frame the browser upgraded to HTTPS (or any scheme change) was silently ignored, and replies addressed to the stale origin were refused outright, so Nostr sign-in quietly did nothing. The bridge now matches host and port (scheme intentionally ignored) and always replies to the frame's real origin.
- **Nginx Proxy Manager starts again.** Converting it to a platform manifest dropped two things its image needs: the `/etc/letsencrypt` mount its boot script hard-requires, and the `NET_BIND_SERVICE` capability its internal nginx needs to bind ports 80/443/81 under the orchestrator's `--cap-drop=ALL`. The result was an endless start/die loop (a node watched it restart 3,176 times). Both are declared in its manifest now, its certs live on unchanged under the same persistent app directory, and the signed catalog carries the fix so installed nodes heal on the next update.
- **Portainer's first-run token is in the app page, not buried in "server logs."** New Portainer versions mint a one-time setup token on a fresh install and print it only to the container logs — on an appliance that meant telling the user to go read a server log to get into their own app. The token now appears in the same launch interstitial as app login credentials (with a copy button), only while first-run setup is actually pending; once the admin account exists the card disappears on its own.
- **The Lightning wallet states the node's real funding state instead of "you have no channel."** Trying to send while a freshly opened channel was still waiting for on-chain confirmations — or when all its balance sits on the far side — raised a modal that claimed the node had no channel at all (the outbound sum is legitimately zero in both states). The funding gate now reads the channel list it already fetched: a confirming channel gets "it unlocks automatically once confirmed, nothing is needed from you", a far-side balance gets "you can receive, but there's nothing to send right now", a routing/liquidity payment failure says so instead of pointing at channel setup, and only a genuinely channel-less node is sent to open one.
## v1.8.8-alpha (2026-09-01)
- **SSH over the mesh is now a first-class setting.** Settings gains an "SSH over mesh" card: off by default, and when you allow it the node's mesh firewall opens port 22 — either to every mesh peer (behind an explicit "I understand" confirmation, because that's a real exposure) or only to the mesh addresses you list. The rule is owned by the node (the `90-ssh.nft` drop-in), so it survives upgrades and daemon reinstalls, and the card tells you up front whether sshd is running, whether it listens on IPv6 (the mesh is IPv6-only — this is what a broken attempt looks like before it happens), and whether password login is on (keys-only is the recommended pairing). From Termux on your phone, `fipssh <user>@<node-npub>` connects once the toggle is on — the npub is the durable address, and the command is shown with a copy button on the card.
- **The App Store now lists apps — not parts of apps.** The signed catalog carries every manifest because the node's update layer needs their pins, and the store briefly listed them all: Mempool API, LND UI, Bitcoin UI, the Pine voice engines, the IndeeHub and Immich backends, the mesh router and friends. Components are hidden from the store listing (they still appear where they belong — the Services tab of My Apps, once installed), and four entries that never earned a tile are gone outright: MorphOS server (old), the Web5 DID wallet, Lightning Stack (an untracked upstream bundle — LND covers the need), and CryptPad (never tested).
- **App icons now persist everywhere, in the proper container style.** Two fixes: installed apps render the icon from their own manifest — Cuprate no longer falls back to the generic A-mark on its Services tile — and the store grids (the Discover page) apply the same icon container treatment (backdrop, border, shadow) as My Apps, the detail pages, and Home. Manifest-declared UI apps also classify correctly again: Alby Hub installs into My Apps with a working tile, not into Services, because a probe miss no longer buries an app the manifest itself says has a frontend.
- **Installing from the store keeps you on the store page.** The install progress lives on the tile itself and the app appears in My Apps when it lands — no more being yanked to My Apps mid-browse.
## v1.8.7-alpha (2026-08-31)
- **What's New really does stop at v1.8.0 now.** The first correction removed old generated release blocks but missed six much older hand-written v1.2 sections at the bottom of the modal. Those sections are gone, and the release check now recognizes and rejects that legacy format too, so the history floor cannot falsely pass again.
- **The installer carries the same corrected release and Companion 0.5.28.** Its artifact gate now checks the companion APK version and the v1.8.0 What's New floor inside the finished ISO, so a stale frontend or phone app cannot be published under the current release label.
- **Crash dumps work on fresh installs as well as upgraded nodes.** The installer gate checks every kdump package inside the finished ISO, and `makedumpfile` is installed explicitly rather than accidentally relying on a recommended dependency that the minimal image deliberately omits.
- **Apps open over HTTPS when your node does.** Connect to your node over HTTPS and the apps you open — Vaultwarden in its own tab, BTCPay, Grafana, and the rest, on a remote browser or in the phone's in-app browser — now open on the same secure connection instead of silently dropping to plain HTTP. The node's app gate already served TLS on every app port; the dashboard was handing out `http://` addresses regardless of how you reached it. Ports the gate does not front (plain-HTTP publishes, and the API ports like Cuprate's RPC) deliberately stay on `http` — `https` there would simply fail to connect. Plain-HTTP access (the kiosk, LAN browsing) is unchanged.
- **Every app in the store is now a first-class platform app.** The remaining platform apps carry full manifests: the node's app gate fronts their web ports (TLS on the same port, the node login where appropriate, embedding fixes, Tor), installs go through the orchestrator like every other app, and their pins live in the signed catalog. Ollama stays loopback-only — it is the assistant's local model backend, not a web app. Retired apps are dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **Newly signed apps appear in the App Store immediately.** The App Store now serves the release-signed catalog the node has already fetched and verified — so publishing a signed app (like Cuprate) makes it appear for every updated node without waiting for a dashboard release. The unsigned community catalog remains only as a fallback for nodes that can't reach the registry. The same signed catalog now also decides which ports serve TLS, so nothing is upgraded to `https` that can't answer it.
## v1.8.6-alpha (2026-08-31)
- **Companion 0.5.28 is included in the node download this time, with the work that missed v1.8.5.** The companion hub can back up and restore its node list, act as a NIP-46 remote signer, and shows each paired node's FIPS mesh address with tap-to-copy. For Termux users, the included `fipssh` helper turns a durable node npub into its mesh address, so `fipssh user@npub1…` can reach SSH once that node has explicitly allowed port 22. The node-side “SSH over mesh” firewall toggle is not claimed here—it still needs implementation and remains off by default.
- **What's New now starts cleanly at v1.8.0 and is guaranteed to be newest-first.** Older alpha history no longer overwhelms the useful recent changes, the three stray v1.7 entries that appeared above current releases are gone, and the release check now fails if either the ordering or the v1.8.0 history floor drifts again.
- **A release can no longer advertise itself before its files exist.** New releases are prepared behind a pending manifest; the publisher uploads the backend and frontend, downloads both back and verifies their size and hash, and only then promotes the signed manifest to the path nodes read. The manifest generator also includes every curated What's New item instead of silently stopping after the first ten physical changelog lines.
## v1.8.5-alpha (2026-08-30)
- **Cuprate — an independent Monero node — is now an app.** Monero consensus validated by a second, unrelated codebase (Rust), the same layer of security-in-depth Bitcoin gets from Knots. Review caught two problems before anything shipped: the unrestricted RPC that can move funds stayed bound to the container's loopback (never published to the node, let alone the LAN — anything on the node could previously have reached it), and its restricted RPC moved off port 18089 to avoid colliding with Penpot. Honest caveat: upstream has cut no stable release yet, so the pin tracks an exact preview build (0.1.0-preview-18-g618ff14) and moves to their first tagged release when there is one.
+8 -1
View File
@@ -57,6 +57,13 @@ ElevenLabs TTS under a commercial-use plan.
## Redistributed software (ISO and container registry)
- **GitWorkshop** — https://github.com/DanConwayDev/gitworkshop — pinned at
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da`. The upstream revision declares
no software license. Archipelago applies a documented integration patch and
redistributes the resulting static application under an explicit owner risk
acceptance dated 2026-09-11; this notice does not claim or grant upstream
copyright permission. See `docker/archipelago-source/UPSTREAM.md`.
The Archipelago OS image is based on Debian and redistributes Debian packages
(including the Linux kernel, GRUB, and non-free firmware/microcode blobs
required for hardware support); per-package license texts are preserved at
@@ -65,7 +72,7 @@ is available via Debian (https://snapshot.debian.org) as referenced in each
release's notes. Container images offered through the app catalog and mirror
registry remain under their upstream licenses (including GPL/AGPL software
such as mempool, Nextcloud, Vaultwarden, SearXNG, PhotoPrism, Immich,
Jellyfin, MariaDB, AdGuard Home, and strfry); source links are provided in
Jellyfin, MariaDB, and strfry); source links are provided in
the app catalog. The modified mempool-frontend image is built from
`docker/mempool-frontend/` in this repository (AGPL-3.0 corresponding source).
+15 -1
View File
@@ -11,7 +11,21 @@ Podman containers managed by the Rust backend.
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-stable-orange)](https://www.rust-lang.org/)
[![Vue.js](https://img.shields.io/badge/vue.js-3.5-brightgreen)](https://vuejs.org/)
[![Version](https://img.shields.io/badge/version-1.8.0--alpha-blue)]()
[![Version](https://img.shields.io/badge/version-1.8.13--alpha-blue)](https://source.archipelago-foundation.org/lfg2025/archy/releases)
## Current release
The current pre-release is **v1.8.13-alpha**. Release notes and signed OTA
artifacts are published on [Gitea](https://source.archipelago-foundation.org/lfg2025/archy/releases).
The same source is mirrored through ngit for Nostr-native cloning and
contribution:
```
nostr://npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy
```
Clone with ngit, or use the Gitea mirror when you need a conventional Git
remote. Contributions should follow [CONTRIBUTING.md](CONTRIBUTING.md).
## What is here
Submodule aiui/.claude/worktrees/agitated-hofstadter deleted from 10e12a329f
Submodule aiui/.claude/worktrees/funny-hofstadter deleted from 1c5185a15c
Submodule aiui/.claude/worktrees/happy-colden deleted from 666e1232f4
Submodule aiui/.claude/worktrees/hardcore-beaver deleted from a817fa199f
Submodule aiui/.claude/worktrees/heuristic-raman deleted from e8e002debc
Submodule aiui/.claude/worktrees/priceless-colden deleted from aaaef7d710
+34
View File
@@ -34,6 +34,40 @@ Add an entry to `catalog.json`:
For apps with hardcoded backend configs (Bitcoin, LND, etc.), `containerConfig` is optional.
For new apps, include `containerConfig` so the backend knows how to create the container.
## Storefront layout
Discovery merchandising is app-registry data, not node-OS layout. The optional
top-level `storefront` block defines the ordered Popular Apps rows and the
promotional banners placed before the remaining `All Apps` grid:
```json
{
"storefront": {
"popular": ["bitcoin-knots", "lnd", "btcpay-server"],
"promotions": [{
"id": "my-app",
"banner": "/assets/img/featured/my-app.webp",
"eyebrow": "open source",
"headline": "Build together.",
"description": "Catalog-controlled promotional copy.",
"tag": "NOSTR // SOURCE",
"path": "/npub1maintainer/project",
"launchLabel": "Open",
"installLabel": "Install",
"detailsLabel": "Learn more →"
}]
}
}
```
Only IDs present in `apps` render. An optional promotion `path` deep-links into
the installed app; Archipelago uses this to open the canonical signed Nostr
repository rather than GitWorkshop's generic dashboard. New dashboards prefer `storefront` from the
daemon-verified signed catalog and use the bundled community copy as a local
fallback. `scripts/generate-app-catalog.sh` carries this block into the signed
release artifact; changing it does not require a node OS release once that
artifact is published.
## Categories
money, commerce, data, home, nostr, networking, community, development, l484
+432 -371
View File
@@ -9,18 +9,61 @@
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"storefront": {
"popular": [
"bitcoin-knots",
"lnd",
"btcpay-server",
"mempool",
"filebrowser",
"homeassistant"
],
"promotions": [
{
"id": "archipelago-source",
"banner": "/assets/img/featured/archipelago-source-banner.webp",
"eyebrow": "open source",
"headline": "Your node. Your source.",
"description": "Install GitWorkshop to browse Archipelago's code from your own node, clone it with ngit, and contribute issues, patches, and reviews over Nostr.",
"tag": "NGIT // NOSTR // NO SILO",
"path": "/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/relay.ngit.dev/archy",
"launchLabel": "Open GitWorkshop",
"installLabel": "Install GitWorkshop",
"detailsLabel": "How contribution works →"
}
]
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "bitcoin-core",
@@ -35,76 +78,16 @@
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "botfights",
@@ -132,127 +115,46 @@
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "fedimint",
@@ -299,87 +201,76 @@
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"id": "filebrowser",
"title": "File Browser",
"version": "2.63.23",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"3535:3535"
"8083:80"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"id": "gitea",
"title": "Gitea",
"version": "1.27.3",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"8096:8096"
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"TZ=UTC"
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
}
},
"tier": "optional"
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
"id": "archipelago-source",
"title": "GitWorkshop",
"version": "0.4.0",
"description": "Get Archipelago's source, clone it with ngit, and contribute issues, patches, and reviews over Nostr using the upstream GitWorkshop client.",
"icon": "/assets/img/app-icons/gitworkshop-dc36db6.svg",
"author": "GitWorkshop contributors",
"maintainerNpub": "npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg",
"category": "development",
"tier": "optional",
"repoUrl": "https://github.com/DanConwayDev/gitworkshop",
"dockerImage": "localhost/archipelago-source:local"
},
{
"id": "grafana",
@@ -405,6 +296,279 @@
]
}
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.8.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "lnd",
"title": "LND",
"version": "0.21.2",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"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/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "nginx-proxy-manager",
"title": "Nginx Proxy Manager",
"version": "2.12.1",
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"icon": "/assets/img/app-icons/nginx.svg",
"author": "Nginx Proxy Manager",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest",
"repoUrl": "https://github.com/NginxProxyManager/nginx-proxy-manager"
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "ollama",
"title": "Ollama",
"version": "0.5.4",
"description": "Run large language models locally. Download and run AI models like Llama, Mistral on your own hardware — served on the node's loopback for the AI assistant (Settings → Claude Auth → model backend), never exposed to the network.",
"icon": "/assets/img/app-icons/ollama.png",
"author": "Ollama",
"category": "community",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/ollama:latest",
"repoUrl": "https://github.com/ollama/ollama"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.45.0",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "tailscale",
"title": "Tailscale",
@@ -433,51 +597,6 @@
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
@@ -507,82 +626,24 @@
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.37.2",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"2342:2342"
"8082:80"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
+2
View File
@@ -25,6 +25,7 @@ This document lists all port assignments for Archipelago apps.
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
| archipelago-source | 8337 | TCP | Authenticated source UI | 18337 |
## Development Ports (Offset: +10000)
@@ -53,6 +54,7 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Meshtastic | http://localhost:14403 |
| GitWorkshop | http://localhost:18337 |
## Port Conflict Resolution
+80
View File
@@ -0,0 +1,80 @@
app:
id: archipelago-source
name: GitWorkshop
version: 0.4.0
upstream:
kind: github
repo: DanConwayDev/gitworkshop
description: >-
Get Archipelago's source, clone it with ngit, and contribute issues,
patches, and reviews over Nostr using the upstream GitWorkshop client.
category: development
container:
build:
context: /opt/archipelago/docker/archipelago-source
dockerfile: Dockerfile
tag: localhost/archipelago-source:local
resources:
cpu_limit: 1
memory_limit: 64Mi
disk_limit: 64Mi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
network_policy: host
ports:
- host: 8337
container: 8337
protocol: tcp
bind: 127.0.0.1
auth: gated
session_passthrough: true
volumes:
- type: tmpfs
target: /tmp
tmpfs_options: rw,noexec,nosuid,size=16m,mode=1777
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8337
path: /healthz
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: GitWorkshop
description: NIP-34 repository browser, issues, pull requests, and review
type: ui
port: 8337
protocol: http
path: /
metadata:
# Versioned filename deliberately invalidates dashboard/browser icon caches
# when the Source prototype is replaced by the upstream GitWorkshop mark.
icon: /assets/img/app-icons/gitworkshop-dc36db6.svg
author: GitWorkshop contributors
repo: https://github.com/DanConwayDev/gitworkshop
maintainer_npub: npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg
tier: optional
launch:
# GitWorkshop is top-level in Companion's native in-app WebView. Its
# injected NIP-07 provider creates the authenticated dashboard-origin
# signer broker itself, so no dashboard parent frame is required.
requires_host_frame: false
features:
- NIP-34 repository discovery and browsing
- Bandwidth-efficient Git explorer over GRASP
- Nostr issues, pull requests, and code review
- NIP-07 extension and NIP-46 remote-signer support
- Archipelago node identity through explicit signing consent
+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
+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
+74 -22
View File
@@ -15,11 +15,6 @@ app:
description: Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.
category: money
metadata:
icon: /assets/img/app-icons/cuprate.svg
repo: https://github.com/Cuprate/cuprate
tier: optional
container:
# Built from the upstream Dockerfile at the tip of main, 18 commits past
# the cuprated-0.1.0-preview tag (commit 618ff14, 2026-08-19) — there is
@@ -41,17 +36,38 @@ 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
memory_limit: 4Gi
disk_limit: 300Gi
# Raised from 4Gi alongside target_max_memory below (see files[] comment)
# — 2026-09-03 incident: a 4Gi/3GB-cache config starved
# cuprated's DB cache into constant eviction/flush, driving 45% sustained
# 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
# 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
@@ -87,17 +103,21 @@ app:
# bind without an explicit i_know_what_im_doing override.
# Restricted RPC: Monero's own purpose-built safe-for-public subset —
# what wallets use when connecting to a "remote node". Disabled by
# cuprated's own default; enabled via files[] below. A dashboard login
# would break wallet clients connecting programmatically, same
# reasoning as electrumx's port. The daemon still uses its canonical
# container port 18089, but Penpot already owns host port 18089, so this
# maps the public host port to the free 18090 instead.
# cuprated's own default; enabled via files[] below. `open`, not `gated`:
# the gate still takes the port over (loopback pin, external binds,
# fronts the Tor onion) but skips the dashboard login challenge, same
# reasoning as electrumx's port — wallet clients (Feather,
# monero-wallet-rpc, GUI) speak plain HTTP JSON-RPC programmatically and
# cannot complete a browser login or hold a session cookie. The daemon
# still uses its canonical container port 18089, but Penpot already owns
# host port 18089, so this maps the public host port to the free 18090
# instead.
- host: 18090
container: 18089
protocol: tcp
auth: none
auth: open
auth_rationale: >-
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot hold a dashboard session cookie.
Monero restricted RPC — the subset upstream considers safe for public/remote-node use. Wallets (Feather, monero-wallet-rpc, GUI) connect directly over plain HTTP JSON-RPC and cannot complete a browser login or hold a dashboard session cookie.
volumes:
- type: bind
@@ -108,11 +128,23 @@ app:
# Settings that need to differ from cuprated's own documented defaults
# (verified against `cuprated --generate-config` and `--dry-run` locally,
# 2026-08-21):
# - fast_sync: cuprated's own default is false, which performs full
# cryptographic verification (ring signatures + RandomX PoW) on every
# incoming block instead of trusting checkpointed history. Root-caused
# 2026-09-03 as the dominant cause of a sustained 45% CPU node,
# vs. 2.8% on a reference node with fast_sync = true — same chain height, same
# block rate. Set explicitly rather than relying on the binary
# default so fresh deploys don't silently regress into full-verify.
# - target_max_memory: cuprated's own default auto-detects total *host*
# RAM via sysinfo, which inside a memory-limited container would let
# it size caches far past what resources.memory_limit above actually
# grants — same class of problem bitcoin-knots' -dbcache sizing
# comment addresses. Set explicitly, comfortably under the 4Gi limit.
# comment addresses. Set explicitly, comfortably under the 10Gi limit.
# Previously 3000000000 (~2.8GiB); that starved the DB cache and
# forced constant eviction/flush (595GB/24h block I/O on a node just
# appending ~2MB blocks every 2 minutes) — raised to 8GiB, matching
# the healthy reference node, and
# resources.memory_limit above raised in step to keep headroom above it.
# - rpc.restricted.enable: cuprated ships this off by default; flip on
# so the auth:none host port above actually serves something instead
# of refusing every connection. port stays at its documented default
@@ -130,14 +162,34 @@ app:
# uses for its own RPC port (-rpcbind=0.0.0.0:8332 internally, gate
# restricts it externally) — not a new risk, the same one already
# reviewed and accepted for Bitcoin's RPC.
# - tracing.stdout.level / tracing.file.{level,max_log_files}: an
# operator reading Cuprated.toml on disk should be able to see and
# tune the log level directly instead of the file silently omitting
# the whole [tracing] table (verified live on the affected node
# 2026-09-01: the deployed file had no [tracing] section at all, and
# the level was only discoverable by running `cuprated
# --generate-config` and diffing). file.level is set to "info", NOT
# cuprated's own raw default of "debug" — matches the reference dev
# config this app was built and tested against (verified 2026-09-01),
# which deliberately runs file logging quieter
# than the binary default. max_log_files similarly follows that
# reference (14, not the binary default of 7).
files:
- path: /var/lib/archipelago/cuprate/Cuprated.toml
content: |
network = "Mainnet"
target_max_memory = 3000000000
fast_sync = true
target_max_memory = 8589934592
[rpc.restricted]
enable = true
[tracing.stdout]
level = "info"
[tracing.file]
level = "info"
max_log_files = 14
overwrite: false
health_check:
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-39
View File
@@ -1,39 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/public ./public
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/wallet && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV WALLET_STORAGE=/app/wallet
ENV DWN_ENDPOINT=http://web5-dwn:3000
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# DID Wallet
Web5 wallet with Decentralized Identifier (DID) support.
## Building
```bash
# From the apps directory
./build.sh did-wallet
# Or manually
cd did-wallet
docker build -t archipelago/did-wallet:latest .
```
## Development
```bash
cd did-wallet
npm install
npm run dev
```
## Ports
- **8083**: Web UI (dev: 18083)
## Running Locally
```bash
docker run -p 8083:8080 \
-v /tmp/archipelago-dev/did-wallet:/app/wallet \
-e DWN_ENDPOINT=http://localhost:13000 \
archipelago/did-wallet:latest
```
-59
View File
@@ -1,59 +0,0 @@
app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
image: archipelago/did-wallet:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: did-wallet
ports:
- host: 8088
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/did-wallet
target: /app/wallet
options: [rw]
environment:
- WALLET_STORAGE=/app/wallet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
wallet_functionality: true
bitcoin_integration: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "did-wallet",
"version": "1.0.0",
"description": "Web5 DID Wallet for Archipelago",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-23
View File
@@ -1,23 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DID Wallet</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
</style>
</head>
<body>
<h1>Web5 DID Wallet</h1>
<p>Decentralized Identity Wallet for Archipelago</p>
<div id="app">
<p>Wallet interface coming soon...</p>
</div>
</body>
</html>
-37
View File
@@ -1,37 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'did-wallet' });
});
// Wallet API endpoints
app.get('/api/wallet/info', (req, res) => {
res.json({
status: 'ok',
wallet: {
dids: [],
balance: 0
}
});
});
app.post('/api/wallet/did/create', async (req, res) => {
// Placeholder for DID creation
res.json({
status: 'ok',
did: 'did:key:placeholder'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: filebrowser
name: File Browser
version: 2.27.0
version: 2.63.23
# 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: Baseline Archipelago file manager service.
container:
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0
image: source.archipelago-foundation.org/lfg2025/filebrowser:v2.63.23
pull_policy: if-not-present
network: archy-net
custom_args: ["--config", "/data/.filebrowser.json"]
+12 -4
View File
@@ -1,7 +1,7 @@
app:
id: gitea
name: Gitea
version: "1.23"
version: "1.27.3"
# 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,15 +12,17 @@ app:
category: development
container:
image: docker.io/gitea/gitea:1.23
image: source.archipelago-foundation.org/lfg2025/gitea:1.27.3
pull_policy: if-not-present
dependencies:
- storage: 500Mi
# Source history, LFS objects, release artifacts and OCI layers all share
# this persistent store. 500Mi was only suitable for an empty demo node.
- storage: 50Gi
resources:
memory_limit: 256Mi
disk_limit: 500Mi
disk_limit: 50Gi
security:
capabilities: [CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
@@ -66,6 +68,12 @@ app:
- GITEA__server__SSH_LISTEN_PORT=22
- GITEA__server__LFS_START_SERVER=true
- GITEA__packages__ENABLED=true
# Package/LFS storage remains bounded by the node's disk, not an arbitrary
# per-owner quota. Release artifacts allow installer/OTA images up to 10GiB.
- GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1
- GITEA__packages__LIMIT_SIZE_CONTAINER=-1
- GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240
- GITEA__repository_0x2Erelease__MAX_FILES=20
- GITEA__repository__ENABLE_PUSH_CREATE_USER=true
- GITEA__repository__ENABLE_PUSH_CREATE_ORG=true
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
version: 2026.8.3
# 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: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.3
pull_policy: if-not-present
network: pasta
+8 -8
View File
@@ -19,14 +19,15 @@ app:
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [api]
# The JWT signing secret is owned here (no backend container owns it); the
# db + minio passwords are owned by indeedhub-postgres / indeedhub-minio and
# only consumed here. ensure_generated_secrets no-ops when a file already
# exists, so live values on .228 are preserved (postgres pw is fixed at
# PGDATA init — regenerating would lock the API out).
# The JWT signing secret and stable envelope-encryption root are owned here;
# the db + minio passwords are owned by indeedhub-postgres / indeedhub-minio
# and only consumed here. Existing nodes migrate the legacy AES value into
# the secret file once, while fresh nodes receive a unique per-node value.
generated_secrets:
- name: indeedhub-jwt
kind: hex32
- name: indeedhub-aes-master
kind: hex16
secret_env:
- key: DATABASE_PASSWORD
secret_file: indeedhub-db-password
@@ -34,6 +35,8 @@ app:
secret_file: indeedhub-minio-password
- key: NOSTR_JWT_SECRET
secret_file: indeedhub-jwt
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-postgres
@@ -67,9 +70,6 @@ app:
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- S3_PUBLIC_BUCKET_URL=/storage
- NOSTR_JWT_EXPIRES_IN=7d
# Fixed across the fleet (envelope-encryption master key baked by the legacy
# installer); not node-specific, so a plain env literal, not a secret.
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
- ENVIRONMENT=production
health_check:
+2 -1
View File
@@ -22,6 +22,8 @@ app:
secret_file: indeedhub-db-password
- key: AWS_SECRET_KEY
secret_file: indeedhub-minio-password
- key: AES_MASTER_SECRET
secret_file: indeedhub-aes-master
dependencies:
- app_id: indeedhub-api
@@ -51,4 +53,3 @@ app:
- S3_PUBLIC_BUCKET_NAME=indeedhub-public
- S3_PRIVATE_BUCKET_NAME=indeedhub-private
- ENVIRONMENT=production
- AES_MASTER_SECRET=0123456789abcdef0123456789abcdef
+3
View File
@@ -69,7 +69,10 @@ app:
- copy_from_host:
src: "web-ui/nostr-provider.js"
dest: "/usr/share/nginx/html/nostr-provider.js"
- exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = \/sw.js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"]
- exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /etc/nginx/conf.d/default.conf"]
- exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"]
- exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf"]
- exec: ["nginx", "-s", "reload"]
# TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at
-5
View File
@@ -1,5 +0,0 @@
# Lightning Stack - uses official image
FROM lightninglabs/lightning-stack:v0.12.0
# Default configuration is in the image
# No additional setup needed
-85
View File
@@ -1,85 +0,0 @@
app:
id: lightning-stack
name: Lightning Stack
version: 0.12.0
# No public listing exists for lightninglabs/lightning-stack (checked
# docker.io, ghcr.io and github.com) — nothing can be queried automatically,
# so this one is tracked by hand.
upstream:
kind: manual
url: no public listing for lightninglabs/lightning-stack — verify by hand
description: Complete Lightning Network implementation. Includes LND, CLN, and management tools.
container:
image: lightninglabs/lightning-stack:v0.12.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- app_id: bitcoin-core
version: ">=24.0"
- storage: 50Gi
resources:
cpu_limit: 4
memory_limit: 4Gi
disk_limit: 50Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: lightning-stack
ports:
- host: 9738
container: 9735
protocol: tcp # P2P
auth: none
auth_rationale: >-
Lightning p2p. The BOLT-8 noise handshake authenticates and encrypts the channel itself.
- host: 10010
container: 10009
protocol: tcp # gRPC
auth: none
auth_rationale: >-
LND gRPC, authenticated by macaroon over TLS. Remote wallets depend on reaching this directly.
# Mirrors lnd's 18080 exemption — same LND REST API, same macaroon auth.
- host: 8091
container: 8080
protocol: tcp # REST/Web UI
auth: none
auth_rationale: >-
LND REST, authenticated by macaroon over TLS. A browser login page would break
Zeus and every non-browser wallet client, exactly as for lnd's 18080.
volumes:
- type: bind
source: /var/lib/archipelago/lightning-stack
target: /root/.lightning
options: [rw]
environment:
- BITCOIND_HOST=bitcoin-core
- BITCOIND_RPCUSER=${BITCOIN_RPC_USER}
- BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD}
- NETWORK=mainnet
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /v1/getinfo
interval: 30s
timeout: 5s
retries: 3
bitcoin_integration:
rpc_access: admin
sync_required: true
lightning_integration:
channel_management: true
payment_routing: true
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: lnd
name: LND
version: 0.18.4
version: 0.21.2
# 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: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
image: source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta
image: source.archipelago-foundation.org/lfg2025/lnd:v0.21.2-beta
pull_policy: if-not-present
network: archy-net
# BITCOIND_HOST must follow the node's actual Bitcoin container — Knots or
+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
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-37
View File
@@ -1,37 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080
ENV MORPHOS_DATA_DIR=/app/data
CMD ["node", "dist/index.js"]
-55
View File
@@ -1,55 +0,0 @@
app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
image: archipelago/morphos-server:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 2
memory_limit: 2Gi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: morphos-server
ports:
- host: 8089
container: 8080
protocol: tcp # Web UI
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
source: /var/lib/archipelago/morphos-server
target: /app/data
options: [rw]
environment:
- MORPHOS_ENV=production
- MORPHOS_DATA_DIR=/app/data
health_check:
type: http
endpoint: http://127.0.0.1:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
File diff suppressed because it is too large Load Diff
-20
View File
@@ -1,20 +0,0 @@
{
"name": "morphos-server",
"version": "1.0.0",
"description": "MorphOS server platform",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-27
View File
@@ -1,27 +0,0 @@
import express from 'express';
const app = express();
const port = 8080;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'morphos-server', version: '1.0.0' });
});
// API endpoints
app.get('/api/info', (req, res) => {
res.json({
name: 'MorphOS Server',
version: '1.0.0',
status: 'running'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`MorphOS Server listening on port ${port}`);
console.log(`Data directory: ${process.env.MORPHOS_DATA_DIR || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+88
View File
@@ -0,0 +1,88 @@
app:
id: nginx-proxy-manager
name: Nginx Proxy Manager
version: 2.12.1
upstream:
kind: github
repo: NginxProxyManager/nginx-proxy-manager
description: >-
Reverse proxy with SSL. Beautiful web interface for managing proxies.
On a node, this manages its admin UI and upstream configuration — the
proxy's own :80/:443 listeners are not published (the node's web server
owns those ports).
container:
image: source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
# NET_BIND_SERVICE is load-bearing, not decoration: NPM's internal nginx
# listens on 80, 443 AND 81, and the orchestrator runs --cap-drop=ALL —
# without this cap every start dies with "bind() to 0.0.0.0:80 failed
# (13: Permission denied)" and s6 restart-loops forever (shorty-s,
# 2026-09-01, restart counter 3176 within hours of the manifest
# conversion). The legacy podman-run path defaulted to the full cap set,
# which is why it never showed there.
capabilities: [CHOWN, SETUID, SETGID, DAC_OVERRIDE, NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8081
container: 81
protocol: tcp
bind: 127.0.0.1
# open, not gated: NPM carries a complete admin login of its own. The
# gate still fronts the port (TLS on the same port, header fixes, retry
# page, Tor) without putting a cookie challenge in front of it.
auth: open
auth_rationale: >-
Nginx Proxy Manager enforces its own admin account on every page;
the initial setup wizard also has to answer before any account exists.
volumes:
- type: bind
source: /var/lib/archipelago/nginx-proxy-manager
target: /data
options: [rw]
# Current NPM images refuse to start unless /etc/letsencrypt is a mount in
# its own right. Keeping the files below the same persistent app directory
# preserves existing certificates while satisfying that startup contract.
- type: bind
source: /var/lib/archipelago/nginx-proxy-manager/letsencrypt
target: /etc/letsencrypt
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:81
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin UI
description: Nginx Proxy Manager admin interface
type: ui
port: 8081
protocol: http
path: /
metadata:
author: Nginx Proxy Manager
category: networking
icon: /assets/img/app-icons/nginx.svg
repo: https://github.com/NginxProxyManager/nginx-proxy-manager
tier: optional
+63
View File
@@ -0,0 +1,63 @@
app:
id: ollama
name: Ollama
version: 0.5.4
upstream:
kind: github
repo: ollama/ollama
description: >-
Run large language models locally. Download and run AI models like
Llama, Mistral on your own hardware — served on the node's loopback for
the AI assistant (Settings → Claude Auth → model backend), never exposed
to the network.
container:
image: source.archipelago-foundation.org/lfg2025/ollama:latest
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 50Gi
resources:
# No memory limit: models are sized by the disk allowance below, and a
# RAM ceiling would just OOM-kill long inferences.
disk_limit: 50Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 11434
container: 11434
protocol: tcp
# local: Ollama's REST API is consumed by the node's own assistant over
# loopback — never externally reachable, so no gate, no TLS, and no
# login surface exist at all.
bind: 127.0.0.1
auth: local
volumes:
- type: bind
source: /var/lib/archipelago/ollama
target: /root/.ollama
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:11434
interval: 30s
timeout: 5s
retries: 3
metadata:
author: Ollama
category: community
icon: /assets/img/app-icons/ollama.png
repo: https://github.com/ollama/ollama
tier: optional
+2 -2
View File
@@ -5,7 +5,7 @@ app:
# (--beam-size 1). Bumped past the image version so catalog-driven nodes
# pick up the args change; the pre-release form "3.4.1-1" would compare
# LOWER than 3.4.1 under semver and never roll out.
version: "3.4.2"
version: "3.6.0"
# Tracks the rhasspy/wyoming-whisper image we pin (Docker Hub — the
# project's GitHub tags are not the image tags). NOTE: this manifest
# deliberately ships an args-tuned revision AHEAD of the image tag (see
@@ -24,7 +24,7 @@ app:
container_name: pine-whisper
container:
image: docker.io/rhasspy/wyoming-whisper:3.4.1
image: docker.io/rhasspy/wyoming-whisper:3.6.0
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-whisper]
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: portainer
name: Portainer
version: 2.19.4
version: 2.45.0
# 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:
category: development
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
image: source.archipelago-foundation.org/lfg2025/portainer:2.45.0
pull_policy: if-not-present
data_uid: "1000:1000"
+78
View File
@@ -0,0 +1,78 @@
app:
id: tailscale
name: Tailscale
version: 1.78.0
upstream:
kind: github
repo: tailscale/tailscale
description: Zero-config VPN with WireGuard mesh networking.
container:
image: source.archipelago-foundation.org/lfg2025/tailscale:stable
pull_policy: if-not-present
network: pasta
# Mirrors the legacy curated install exactly: tailscaled in userspace
# networking (no host TUN device needed — the rootless container cannot
# have one anyway), then `tailscale web` serving the console on :8240 as
# plain HTTP the app gate can front (TLS on the same port via the node
# certificate, framing-header fixes, retry page, Tor).
entrypoint: ["sh", "-c", "tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"]
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: []
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 8240
container: 8240
protocol: tcp
bind: 127.0.0.1
# open, not gated: the web console requires the tailnet's own login for
# every administrative action — the gate fronts the port without adding
# a second login in front of it.
auth: open
auth_rationale: >-
Tailscale's web console authenticates against the tailnet account for
all administrative actions; the node's cookie challenge would be a
second, redundant login.
volumes:
- type: bind
source: /var/lib/archipelago/tailscale
target: /var/lib/tailscale
options: [rw]
environment:
- TS_STATE_DIR=/var/lib/tailscale
health_check:
type: tcp
endpoint: localhost:8240
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Web console
description: Tailscale web console
type: ui
port: 8240
protocol: http
path: /
metadata:
author: Tailscale
category: networking
icon: /assets/img/app-icons/tailscale.webp
repo: https://github.com/tailscale/tailscale
tier: recommended
+2 -2
View File
@@ -1,7 +1,7 @@
app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
version: 1.37.2
# 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: Self-hosted password vault with zero-knowledge encryption.
container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.2-alpine
pull_policy: if-not-present
network: pasta
+588
View File
@@ -0,0 +1,588 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "source.archipelago-foundation.org/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
"headline": "Stream Sovereignty",
"description": "Bitcoin documentaries with Nostr identity.",
"tag": "NOSTR IDENTITY // YOUR NODE"
},
"apps": [
{
"id": "bitcoin-knots",
"title": "Bitcoin Knots",
"version": "28.1.0",
"description": "Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-knots.webp",
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
"id": "bitcoin-core",
"title": "Bitcoin Core",
"version": "28.4.0",
"description": "Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.",
"icon": "/assets/img/app-icons/bitcoin-core.svg",
"author": "Bitcoin Core contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
"id": "lnd",
"title": "LND",
"version": "0.18.4",
"description": "Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.",
"icon": "/assets/img/app-icons/lnd.png",
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
]
},
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.4.3",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.3",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
]
},
{
"id": "mempool",
"title": "Mempool Explorer",
"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/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
"electrumx"
]
},
{
"id": "electrumx",
"title": "ElectrumX",
"version": "1.18.0",
"description": "Electrum server indexing Bitcoin chain data for lightweight wallet queries.",
"icon": "/assets/img/app-icons/electrumx.png",
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
]
},
{
"id": "indeedhub",
"title": "IndeeHub",
"version": "1.0.0",
"description": "Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.",
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
"id": "botfights",
"title": "BotFights",
"version": "1.2.11",
"description": "Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
"9100:9100"
],
"volumes": [
"/var/lib/archipelago/botfights:/app/server/data"
],
"env": [
"NODE_ENV=production",
"PORT=9100",
"FIGHT_LOOP_ENABLED=true",
"ARCHY_EMBEDDED=1"
]
}
},
{
"id": "gitea",
"title": "Gitea",
"version": "1.23",
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"icon": "/assets/img/app-icons/gitea.svg",
"author": "Gitea",
"category": "development",
"dockerImage": "docker.io/gitea/gitea:1.23",
"repoUrl": "https://gitea.com",
"containerConfig": {
"ports": [
"3001:3000",
"2222:22"
],
"volumes": [
"/var/lib/archipelago/gitea/data:/data",
"/var/lib/archipelago/gitea/config:/etc/gitea"
],
"env": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true",
"GITEA__security__X_FRAME_OPTIONS="
]
},
"tier": "optional"
},
{
"id": "filebrowser",
"title": "File Browser",
"version": "2.27.0",
"description": "Baseline Archipelago file manager service.",
"icon": "/assets/img/app-icons/file-browser.webp",
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
"8083:80"
],
"volumes": [
"/var/lib/archipelago/filebrowser:/srv",
"/var/lib/archipelago/filebrowser-data:/data"
],
"args": [
"--database=/data/database.db",
"--root=/srv",
"--address=0.0.0.0",
"--port=80"
]
}
},
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
"8081:8080"
],
"volumes": [
"/var/lib/archipelago/nostr-relay:/usr/src/app/db"
],
"env": [
"RELAY_NAME=Archipelago Nostr Relay",
"RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago"
]
}
},
{
"id": "vaultwarden",
"title": "Vaultwarden",
"version": "1.30.0",
"description": "Self-hosted password vault with zero-knowledge encryption.",
"icon": "/assets/img/app-icons/vaultwarden.webp",
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
"8082:80"
],
"volumes": [
"/var/lib/archipelago/vaultwarden:/data"
]
}
},
{
"id": "searxng",
"title": "SearXNG",
"version": "1.0.0",
"description": "Privacy-respecting metasearch engine. Search the web without tracking.",
"icon": "/assets/img/app-icons/searxng.png",
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
"8888:8080"
],
"volumes": [
"/var/lib/archipelago/searxng:/etc/searxng"
]
}
},
{
"id": "fedimint",
"title": "Fedimint Guardian",
"version": "0.10.0",
"description": "Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
"version": "0.10.0",
"description": "Fedimint gateway service with automatic LND-or-LDK backend selection.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
"8176:8176",
"9737:9737"
],
"volumes": [
"/var/lib/archipelago/fedimint-gateway:/data",
"/var/lib/archipelago/lnd:/lnd:ro"
]
}
},
{
"id": "barkd",
"title": "Ark Wallet",
"version": "0.3.0",
"description": "Ark protocol wallet daemon (barkd). Lets the node hold self-custodial off-chain bitcoin via an Ark server; the wallet talks to it over a local REST API. Signet by default while Ark matures.",
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
"3535:3535"
],
"volumes": [
"/var/lib/archipelago/barkd:/data"
]
}
},
{
"id": "jellyfin",
"title": "Jellyfin",
"version": "10.8.13",
"description": "Free media server. Stream movies, music, and photos.",
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
"8096:8096"
],
"volumes": [
"/var/lib/archipelago/jellyfin/config:/config",
"/var/lib/archipelago/jellyfin/cache:/cache"
]
}
},
{
"id": "immich",
"title": "Immich",
"version": "2.7.4",
"description": "Self-hosted photo and video backup with mobile apps and search.",
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
"id": "homeassistant",
"title": "Home Assistant",
"version": "2026.7.3",
"description": "Open source home automation platform. Control and monitor your smart home devices.",
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
"8123:8123"
],
"volumes": [
"/var/lib/archipelago/home-assistant:/config"
],
"env": [
"TZ=UTC"
]
}
},
{
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
"id": "grafana",
"title": "Grafana",
"version": "10.2.0",
"description": "Analytics and monitoring platform. Visualize metrics and create dashboards.",
"icon": "/assets/img/app-icons/grafana.png",
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
"3000:3000"
],
"volumes": [
"/var/lib/archipelago/grafana:/var/lib/grafana"
],
"env": [
"GF_PATHS_DATA=/var/lib/grafana",
"GF_USERS_ALLOW_SIGN_UP=false"
]
}
},
{
"id": "tailscale",
"title": "Tailscale",
"version": "1.78.0",
"description": "Zero-config VPN with WireGuard mesh networking.",
"icon": "/assets/img/app-icons/tailscale.webp",
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
"8240:8240"
],
"volumes": [
"/var/lib/archipelago/tailscale:/var/lib/tailscale"
],
"env": [
"TS_STATE_DIR=/var/lib/tailscale"
],
"args": [
"sh",
"-c",
"tailscaled --tun=userspace-networking & for i in $(seq 1 30); do [ -S /var/run/tailscale/tailscaled.sock ] && break; sleep 1; done; tailscale web --listen 0.0.0.0:8240 & wait"
]
}
},
{
"id": "portainer",
"title": "Portainer",
"version": "2.19.4",
"description": "Container management web UI for the local Podman socket.",
"icon": "/assets/img/app-icons/portainer.webp",
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
"9000:9000"
],
"volumes": [
"/var/lib/archipelago/portainer:/data",
"/run/user/1000/podman/podman.sock:/var/run/docker.sock"
],
"notes": "Uses the manifest-owned Podman socket bind mount preparation path."
}
},
{
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.31.4-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
"8087:80",
"8086:80",
"3478:3478/udp"
],
"volumes": [
"/var/lib/archipelago/netbird:/var/lib/netbird"
],
"notes": "Installed as a two-container stack: netbird dashboard on 8087 and netbird-server control plane on 8086 plus UDP 3478. For production clients, publish a DNS name over HTTPS with gRPC/WebSocket routing."
}
},
{
"id": "uptime-kuma",
"title": "Uptime Kuma",
"version": "1.23.0",
"description": "Self-hosted uptime monitoring.",
"icon": "/assets/img/app-icons/uptime-kuma.webp",
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
"3002:3001"
],
"volumes": [
"/var/lib/archipelago/uptime-kuma:/app/data"
],
"env": [
"TZ=UTC"
],
"args": [
"--",
"node",
"server/server.js"
]
}
},
{
"id": "photoprism",
"title": "PhotoPrism",
"version": "240915",
"description": "AI-powered photo management with facial recognition.",
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
"2342:2342"
],
"volumes": [
"/var/lib/archipelago/photoprism:/photoprism/storage"
],
"env": [
"PHOTOPRISM_ADMIN_PASSWORD=archipelago",
"PHOTOPRISM_DEFAULT_LOCALE=en"
]
}
},
{
"id": "nextcloud",
"title": "Nextcloud",
"version": "29",
"description": "Your own private cloud. File sync, calendars, contacts.",
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
"8085:80"
],
"volumes": [
"/var/lib/archipelago/nextcloud:/var/www/html"
]
}
},
{
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/phoenixd:0.9.0",
"repoUrl": "https://github.com/ACINQ/phoenixd"
},
{
"id": "cuprate",
"title": "Cuprate",
"version": "0.1.0-preview",
"description": "Alternative Monero node implementation in Rust. Independently validates Monero consensus rules, providing a layer of security and redundancy for the network.",
"icon": "/assets/img/app-icons/cuprate.svg",
"author": "Cuprate contributors",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/cuprate:0.1.0-preview-18-g618ff14",
"repoUrl": "https://github.com/Cuprate/cuprate"
}
]
}
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.5-alpha"
version = "1.8.17-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.5-alpha"
version = "1.8.17-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
@@ -90,8 +90,9 @@ rustls-pemfile = "1.0"
webpki = { package = "rustls-webpki", version = "0.101" }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake).
# nip06: NIP-06 key derivation for the Minibits @minibits.cash profile flow.
nostr-sdk = { version = "0.44", features = ["nip04", "nip06", "nip44"] }
# Backup encryption (DID identity export) + TOTP 2FA encryption
argon2 = "0.5.3"
+15
View File
@@ -145,6 +145,21 @@ impl ApiHandler {
/// URL so the App Store still renders on nodes that haven't persisted
/// a registry config yet. 15s total timeout.
async fn handle_app_catalog_proxy(&self) -> Result<Response<hyper::Body>> {
// The daemon already refreshes and verifies releases/app-catalog.json.
// Serve that release-root-anchored cache first so a newly published app
// appears immediately, without a frontend release. The old external UI
// catalog below is emergency compatibility only; it must never override
// a healthy signed catalog (Cuprate was invisible for exactly that reason).
if let Ok(body) =
crate::container::app_catalog::verified_catalog_body(&self.config.data_dir).await
{
return Ok(Response::builder()
.status(hyper::StatusCode::OK)
.header("Content-Type", "application/json")
.header("Cache-Control", "no-cache")
.body(hyper::Body::from(body))?);
}
let mut upstreams: Vec<String> = Vec::new();
if let Ok(config) = crate::container::registry::load_registries(&self.config.data_dir).await
{
@@ -269,6 +269,8 @@ impl RpcHandler {
"wallet.ecash-network" => self.handle_wallet_ecash_network().await,
"wallet.ecash-set-network" => self.handle_wallet_ecash_set_network(params).await,
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-lnaddress" => self.handle_wallet_ecash_lnaddress().await,
"wallet.ecash-lnaddress-claim" => self.handle_wallet_ecash_lnaddress_claim().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
@@ -558,6 +560,11 @@ impl RpcHandler {
self.handle_fips_remove_seed_anchor(&p).await
}
"fips.apply-seed-anchors" => self.handle_fips_apply_seed_anchors().await,
"fips.ssh-over-mesh.get" => self.handle_fips_ssh_over_mesh_get().await,
"fips.ssh-over-mesh.set" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_fips_ssh_over_mesh_set(&p).await
}
// System updates
"update.check" => self.handle_update_check().await,
+47
View File
@@ -261,4 +261,51 @@ impl RpcHandler {
}).collect::<Vec<_>>(),
}))
}
/// The SSH-over-mesh toggle state plus sshd preflights (the card explains
/// the rule instead of gating on it — see ssh_mesh.rs).
pub(super) async fn handle_fips_ssh_over_mesh_get(&self) -> Result<serde_json::Value> {
let state = fips::ssh_mesh::load(&self.config.data_dir).await;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"preflights": preflights,
}))
}
/// Set the toggle. Params: `{ enabled: bool, sources?: string[] }` —
/// an empty/absent source list opens port 22 to every mesh peer (the UI
/// confirms that explicitly before calling with it).
pub(super) async fn handle_fips_ssh_over_mesh_set(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let enabled = params
.get("enabled")
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("missing boolean 'enabled'"))?;
let sources: Vec<String> = params
.get("sources")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|s| s.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let (state, outcome) =
fips::ssh_mesh::set(&self.config.data_dir, enabled, &sources).await?;
let preflights = fips::ssh_mesh::preflights().await;
Ok(serde_json::json!({
"enabled": state.enabled,
"sources": state.sources,
"scope": if state.sources.is_empty() { "any" } else { "list" },
"applied": outcome.applied,
"removed": outcome.removed,
"reloaded": outcome.reloaded,
"preflights": preflights,
}))
}
}
@@ -55,6 +55,10 @@ impl RpcHandler {
"did": id.did,
"created_at": id.created_at,
"is_default": is_default,
// The node's operational Nostr key is intentionally
// distinguishable from user profile identities. Clients
// must never offer it in app sign-in pickers.
"is_node": is_node,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"profile": id.profile,
+152 -49
View File
@@ -4,6 +4,59 @@ use tracing::info;
use super::LND_REST_BASE_URL;
fn router_error_message(body: &serde_json::Value) -> Option<&str> {
body.get("error")
.and_then(|e| e.get("message"))
.and_then(|v| v.as_str())
.or_else(|| body.get("message").and_then(|v| v.as_str()))
}
fn payment_error(message: &str) -> anyhow::Error {
if message.to_ascii_lowercase().contains("invoice expired") {
anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
message.trim_start_matches("invoice expired. ")
)
} else {
anyhow::anyhow!("Payment failed: {message}")
}
}
fn payment_failure_reason(reason: &str) -> &'static str {
match reason {
"FAILURE_REASON_NO_ROUTE" => "No route to the recipient",
"FAILURE_REASON_INSUFFICIENT_BALANCE" => "Insufficient channel balance",
"FAILURE_REASON_TIMEOUT" => "Payment timed out in the network",
"FAILURE_REASON_INCORRECT_PAYMENT_DETAILS" => {
"Recipient rejected the payment (wrong details or expired invoice)"
}
_ => "Payment failed",
}
}
fn json_i64(value: &serde_json::Value, key: &str) -> Option<i64> {
value.get(key).and_then(|v| {
v.as_str()
.and_then(|s| s.parse().ok())
.or_else(|| v.as_i64())
})
}
/// Fee budget for a send, matching lncli's own default: the payment amount
/// (100%). Zero-amount invoices take the payer-supplied amount; fixed invoices
/// take the invoice's own amount. Falls back to a nominal 1,000 sats only when
/// both are somehow absent — the limit must never be left at LND's zero
/// default, which rejects every fee-carrying route as "no route".
fn fee_limit_sats(amount_sats: Option<u64>, decoded_amt: i64) -> i64 {
if let Some(amt) = amount_sats {
return amt as i64;
}
if decoded_amt > 0 {
return decoded_amt;
}
1_000
}
impl RpcHandler {
/// Pay a Lightning invoice.
pub(in crate::api::rpc) async fn handle_lnd_payinvoice(
@@ -65,23 +118,30 @@ impl RpcHandler {
let mut pay_body = serde_json::json!({
"payment_request": payment_request,
// Suppress intermediate stream records: one terminal Payment is
// enough, and it makes grpc-gateway's response a single JSON value.
"no_inflight_updates": true,
"timeout_seconds": 120,
// Router.SendPaymentV2 treats an ABSENT fee limit as ZERO — every
// real route carries a routing fee, so the pathfinder rejects
// them all and the wallet gets "No route to the recipient" on
// every send (fleet-wide, 2026-09-01: the v1.8.9 switch to the v2
// route shipped without this, and a manual lncli test that set
// --fee_limit masked it). lncli's own default is the payment
// amount (100%), which is what we send here.
"fee_limit_sat": fee_limit_sats(amount_sats, decoded_amt),
});
if let Some(amt) = amount_sats {
pay_body["amt"] = serde_json::json!(amt.to_string());
}
// `/v1/channels/transactions` is SYNCHRONOUS: it blocks until the
// payment settles or definitively fails, and multi-hop routing with
// retries routinely takes longer than the shared client's 15s budget.
// That 15s abort used to surface as "Payment failed" while LND kept
// paying in the background — only LND may declare a payment failed,
// so a post-connect timeout is IN FLIGHT (status: pending), never
// failure. The window is deliberately SHORT: most payments settle in
// a couple of seconds and still get their answer in one round trip,
// while a slow multi-hop route flips the UI into its "settling…"
// polling state (lnd.paymentstatus every 3s) after ~8s instead of
// freezing the modal for two minutes with no feedback (a test node
// user report, 2026-07-29).
// LND 0.21 removed the deprecated Lightning.SendPaymentSync REST route
// (`/v1/channels/transactions`). Router.SendPaymentV2 is its supported
// replacement. The old route now returns literal 404 "Not Found" on
// every payment — the fleet failure seen immediately after the 0.21.2
// update. Keep the short browser-facing wait: after LND accepts a slow
// payment we return pending and the UI follows it through
// lnd.paymentstatus instead of declaring a transport timeout a failure.
let pay_client = reqwest::Client::builder()
.no_proxy()
.connect_timeout(std::time::Duration::from_secs(10))
@@ -91,7 +151,7 @@ impl RpcHandler {
.context("Failed to create HTTP client")?;
let resp = match pay_client
.post(format!("{LND_REST_BASE_URL}/v1/channels/transactions"))
.post(format!("{LND_REST_BASE_URL}/v2/router/send"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&pay_body)
.send()
@@ -119,49 +179,42 @@ impl RpcHandler {
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse payment response")?;
.context("Failed to parse Router.SendPaymentV2 response")?;
// grpc-gateway wraps server-streaming records as {"result": ...} and
// transport/RPC failures as {"error": {"message": ...}}. Do not look
// only for the old endpoint's top-level `message`: that turns useful
// LND errors into "Unknown error".
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
// Invoices are short-lived; retrying the same one can never
// succeed, so tell the user the way out instead of just the fact.
if msg.contains("invoice expired") {
return Err(anyhow::anyhow!(
"Payment failed: this invoice has expired ({}). Ask the recipient for a fresh invoice and try again.",
msg.trim_start_matches("invoice expired. ")
));
let msg = router_error_message(&body).unwrap_or("Unknown error");
return Err(payment_error(msg));
}
let payment = body.get("result").unwrap_or(&body);
match payment.get("status").and_then(|v| v.as_str()).unwrap_or("") {
"SUCCEEDED" => {}
"FAILED" => {
let reason = payment
.get("failure_reason")
.and_then(|v| v.as_str())
.map(payment_failure_reason)
.unwrap_or("Payment failed");
return Err(anyhow::anyhow!("Payment failed: {reason}"));
}
_ => {
return Ok(serde_json::json!({
"status": "pending",
"payment_hash": decoded_hash,
"amount_sats": decoded_amt,
}));
}
return Err(anyhow::anyhow!("Payment failed: {}", msg));
}
let payment_error = body
.get("payment_error")
.and_then(|v| v.as_str())
.unwrap_or("");
if !payment_error.is_empty() {
return Err(anyhow::anyhow!("Payment failed: {}", payment_error));
}
let amount_sat = body
.get("payment_route")
.and_then(|r| r.get("total_amt"))
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.unwrap_or(decoded_amt);
let payment_hash = body
.get("payment_hash")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.unwrap_or(decoded_hash);
let amount_sat = json_i64(payment, "value_sat").unwrap_or(decoded_amt);
Ok(serde_json::json!({
"status": "succeeded",
"payment_hash": payment_hash,
// The decode endpoint returns the canonical hex hash used by our
// polling/list APIs. Router's bytes field is base64 in REST JSON.
"payment_hash": decoded_hash,
"amount_sats": amount_sat,
}))
}
@@ -482,3 +535,53 @@ impl RpcHandler {
Ok(serde_json::json!({ "transactions": transactions }))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unwraps_grpc_gateway_router_success() {
let body = serde_json::json!({
"result": { "status": "SUCCEEDED", "value_sat": "1000" }
});
let payment = body.get("result").unwrap_or(&body);
assert_eq!(
payment.get("status").and_then(|v| v.as_str()),
Some("SUCCEEDED")
);
assert_eq!(json_i64(payment, "value_sat"), Some(1000));
}
#[test]
fn reads_nested_router_error() {
let body = serde_json::json!({
"error": { "code": 2, "message": "invoice expired. valid until yesterday" }
});
let msg = router_error_message(&body).unwrap();
assert!(payment_error(msg).to_string().contains("fresh invoice"));
}
#[test]
fn router_failure_reasons_are_actionable() {
assert_eq!(
payment_failure_reason("FAILURE_REASON_NO_ROUTE"),
"No route to the recipient"
);
assert_eq!(
payment_failure_reason("FAILURE_REASON_INSUFFICIENT_BALANCE"),
"Insufficient channel balance"
);
}
#[test]
fn fee_limit_never_falls_back_to_zero() {
// SendPaymentV2 defaults an ABSENT fee limit to zero — which rejects
// every fee-carrying route as "no route". The budget must always be
// positive: the payer-supplied amount for zero-amount invoices, the
// invoice's own amount otherwise.
assert_eq!(fee_limit_sats(Some(20_000), 0), 20_000);
assert_eq!(fee_limit_sats(None, 20_000), 20_000);
assert_eq!(fee_limit_sats(None, 0), 1_000);
}
}
+12 -1
View File
@@ -64,6 +64,11 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"must be",
"cannot",
"Password",
// auth.changePassword verifies the existing node password before it
// writes either the web hash or the optional Linux/SSH password. This
// is safe, actionable validation text; masking it as an internal
// failure sent operators to the server logs for a simple typo.
"Current password is incorrect",
// OTA apply/download errors are all operator-actionable ("download it
// again", "download first") — sanitizing them to "Operation failed"
// left users stuck with no idea what to do, and hid the "already
@@ -242,6 +247,12 @@ mod sanitize_tests {
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn change_password_rejection_reaches_the_operator() {
let msg = "Current password is incorrect";
assert_eq!(sanitize_error_message(msg), msg);
}
#[test]
fn tor_unavailable_precondition_passes_through() {
let msg = "Tor address not available. Tor may not be running.";
@@ -306,7 +317,7 @@ mod sanitize_tests {
/// Deterministic: same session token always produces the same CSRF token.
/// Survives backend restarts because it depends only on the session token
/// and the on-disk remember secret (not ephemeral state).
pub(super) async fn derive_csrf_token(session_token: &str) -> String {
pub(crate) async fn derive_csrf_token(session_token: &str) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
+118 -19
View File
@@ -34,6 +34,7 @@ mod nostr;
mod onboarding_gate;
mod openwrt;
mod package;
pub(crate) use package::patch_indeedhub_nostr_provider;
pub(crate) use package::wyoming_satellite_keeper;
mod peers;
mod pine_status;
@@ -71,12 +72,53 @@ pub use middleware::PeerAddr;
// never added to it — the Phase-10 hard constraint this crate must hold.
// The list's *contents* are unchanged; only its read-visibility widens from
// "this module" to "this crate".
pub(crate) use middleware::UNAUTHENTICATED_METHODS;
use middleware::{
derive_csrf_token, extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS,
};
pub(crate) use middleware::{derive_csrf_token, UNAUTHENTICATED_METHODS};
use middleware::{extract_client_ip, extract_cookie, sanitize_error_message, CACHEABLE_METHODS};
use response::{cookie_header, json_response, ResponseCache, RpcError, RpcRequest, RpcResponse};
/// Browser apps run on dedicated high ports and can share the authenticated
/// node cookie. Nostr signing must therefore be callable by the dashboard
/// bridge (ports 80/443), not directly by an iframe that could bypass its
/// consent dialog. Requests without Origin remain available to authenticated
/// local CLI/integration clients. Development permits loopback origins.
fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> bool {
let Some(origin) = headers.get("origin").and_then(|value| value.to_str().ok()) else {
return true;
};
let Ok(url) = reqwest::Url::parse(origin) else {
return false;
};
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return false;
}
if dev_mode && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "::1")) {
return true;
}
matches!(url.port_or_known_default(), Some(80 | 443))
}
/// Read-only authenticated methods may skip CSRF, but they must still exist in
/// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight
/// session probe, so keeping the policy in one testable function protects that
/// cross-origin app-gate bootstrap contract.
fn csrf_exempt_method(method: &str) -> bool {
matches!(
method,
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-hostname"
)
}
/// Default dev password when no user is set up (matches mock-backend).
/// Dev builds only — the pre-setup login bypass that reads this is
/// cfg-gated out of release binaries.
@@ -291,6 +333,18 @@ impl RpcHandler {
debug!("RPC method: {}", rpc_req.method);
if matches!(
rpc_req.method.as_str(),
"node.nostr-sign" | "identity.nostr-sign"
) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode)
{
return Ok(self.error_response(
403,
"Nostr signing from app origins requires the dashboard consent bridge",
StatusCode::FORBIDDEN,
));
}
// Enforce authentication for non-allowlisted methods
let is_unauthenticated = UNAUTHENTICATED_METHODS.contains(&rpc_req.method.as_str());
let mut new_session_cookies: Option<(String, String)> = None;
@@ -340,21 +394,7 @@ impl RpcHandler {
// CSRF protection: validate X-CSRF-Token header via HMAC derivation from session token.
// Skip CSRF for read-only methods (polling, status) — CSRF prevents state-changing forgery.
// Skip when session was just auto-restored from remember-me (browser has stale CSRF cookie).
let csrf_exempt = matches!(
rpc_req.method.as_str(),
"node-messages-received"
| "server.echo"
| "server.get-state"
| "system.stats"
| "tor.status"
| "tor.onion-addresses"
| "bitcoin.relay-status"
| "federation.list-nodes"
| "system.get-settings"
| "system.get-node-key"
| "system.get-metrics"
| "system.get-version"
);
let csrf_exempt = csrf_exempt_method(&rpc_req.method);
if !is_unauthenticated && new_session_cookies.is_none() && !csrf_exempt {
let csrf_header = parts
.headers
@@ -735,3 +775,62 @@ impl RpcHandler {
);
}
}
#[cfg(test)]
mod nostr_signing_origin_tests {
use super::*;
use hyper::header::{HeaderMap, HeaderValue, ORIGIN};
fn headers(origin: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
if let Some(origin) = origin {
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
}
headers
}
#[test]
fn signing_accepts_dashboard_and_authenticated_non_browser_clients() {
assert!(nostr_signing_origin_allowed(&headers(None), false));
assert!(nostr_signing_origin_allowed(
&headers(Some("https://node.local")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://192.0.2.10")),
false
));
}
#[test]
fn signing_rejects_app_ports_but_allows_loopback_dev_server() {
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:8337")),
false
));
assert!(!nostr_signing_origin_allowed(
&headers(Some("https://node.local:7778")),
false
));
assert!(nostr_signing_origin_allowed(
&headers(Some("http://localhost:5173")),
true
));
}
}
#[cfg(test)]
mod session_probe_contract_tests {
use super::*;
#[test]
fn signer_session_probe_is_implemented_authenticated_and_read_only() {
const PROBE: &str = "system.get-hostname";
const DISPATCHER: &str = include_str!("dispatcher.rs");
assert!(csrf_exempt_method(PROBE));
assert!(!UNAUTHENTICATED_METHODS.contains(&PROBE));
assert!(DISPATCHER.contains("\"system.get-hostname\" =>"));
assert!(!DISPATCHER.contains("\"system.get-version\" =>"));
}
}
+32 -2
View File
@@ -135,7 +135,7 @@ impl RpcHandler {
// not /usr/bin/tollgate-module-basic-go — that's only the opkg/apk
// *package* name, never an on-disk filename.
let tollgate_installed = router
.run("/usr/bin/opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
.run("opkg list-installed 2>/dev/null | grep -q '^tollgate-module-basic-go ' || \
test -f /usr/bin/tollgate-wrt 2>/dev/null")
.map(|(_, code)| code == 0)
.unwrap_or(false);
@@ -150,6 +150,7 @@ impl RpcHandler {
"min_steps": router.uci_get("tollgate.main.min_steps").ok().and_then(|v| v.parse::<u32>().ok()).unwrap_or(1),
"currency": router.uci_get("tollgate.main.currency").unwrap_or_default(),
"mint_url": router.uci_get("tollgate.main.mint_url").unwrap_or_default(),
"payout_address":router.uci_get("tollgate.main.payout_address").unwrap_or_default(),
})
} else {
serde_json::json!({ "installed": false })
@@ -199,10 +200,15 @@ impl RpcHandler {
///
/// Params: `{ "host": "192.168.1.1", "ssh_user": "root", "ssh_password": "",
/// "price_sats": 10, "step_size_ms": 60000, "min_steps": 1,
/// "mint_url": "<optional override>" }`
/// "mint_url": "<optional override>",
/// "payout_address": "<optional Lightning address>" }`
///
/// `mint_url` defaults to `http://<this node's IP>:3338` — the local Cashu
/// mint that must be running as an Archy app before calling this endpoint.
///
/// `payout_address` sets the "owner" identity's Lightning address for
/// TollGate's own built-in payout (see `config::apply_payout_identity`).
/// Omitted or blank leaves whatever's already on the router untouched.
pub(super) async fn handle_openwrt_provision_tollgate(
&self,
params: Option<serde_json::Value>,
@@ -240,12 +246,35 @@ impl RpcHandler {
.unwrap_or_default();
let default_mint_url = format!("http://{}:{}", self.config.host_ip, LOCAL_MINT_PORT);
// Trim trailing slash(es): tollgate-wrt matches a token's embedded
// mint URL against this value with an exact string compare, and
// Cashu wallets (Minibits included) encode mint URLs without a
// trailing slash. A stray slash here means every otherwise-valid
// token gets rejected as "untrusted mint" — confirmed live against
// archy-x250-pa3 2026-09-07 with a manually-entered
// "https://mint.minibits.cash/Bitcoin/".
let mint_url = p
.get("mint_url")
.and_then(|v| v.as_str())
.unwrap_or(&default_mint_url)
.trim_end_matches('/')
.to_string();
// `None` (not sent, or sent blank) leaves whatever's already on the
// router untouched — see apply_payout_identity's doc comment for why
// that matters (an upstream-default placeholder otherwise survives
// forever, since nothing else ever writes this field).
let payout_address = p
.get("payout_address")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
if let Some(address) = payout_address.as_deref() {
tollgate::config::validate_payout_address(address)
.context("invalid TollGate payout address")?;
}
let config = TollGateConfig {
ssid: "archipelago".to_string(),
mint_url,
@@ -256,6 +285,7 @@ impl RpcHandler {
.unwrap_or(60_000),
min_steps: p.get("min_steps").and_then(|v| v.as_u64()).unwrap_or(1) as u32,
enabled: p.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true),
payout_address,
};
// Blocking SSH session, and provision runs `opkg install` over it —
@@ -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).
@@ -294,6 +295,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 +330,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 +345,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;
}
@@ -670,6 +670,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 +917,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;
@@ -1017,6 +1061,37 @@ mod tests {
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};
+278 -199
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;
@@ -74,110 +74,178 @@ async fn local_podman_image_exists(image: &str) -> Result<bool> {
}
}
pub(super) async fn patch_indeedhub_nostr_provider() {
fn patched_indeedhub_nginx_config(original: &str) -> String {
let mut conf = original
.lines()
.filter(|line| !line.contains("X-Frame-Options"))
.collect::<Vec<_>>()
.join("\n");
conf.push('\n');
if !conf.contains("location = /nostr-provider.js {") {
conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
}
if conf.contains("try_files") && !conf.contains("sub_filter") {
conf = conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js?v=tab-signer-v4\"></script></head>';",
1,
);
}
conf = conf.replace(
"src=\"/nostr-provider.js\"",
"src=\"/nostr-provider.js?v=tab-signer-v4\"",
);
conf = conf.replace("tab-signer-v2", "tab-signer-v4");
conf = conf.replace("tab-signer-v3", "tab-signer-v4");
conf.replace(
"proxy_set_header X-Forwarded-Prefix /api;",
"proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;",
)
}
pub(crate) async fn patch_indeedhub_nostr_provider() {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
// Frontend assets can change during a dashboard-only OTA while the
// IndeedHub container keeps running. Reconcile the injected provider on
// daemon startup as well as app install/start, but stay quiet when the app
// is not installed or is intentionally stopped.
let running = tokio::process::Command::new("podman")
.args(["inspect", "-f", "{{.State.Running}}", "indeedhub"])
.output()
.await;
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
.await
.map(|out| out.status.success() && String::from_utf8_lossy(&out.stdout).trim() == "true")
.unwrap_or(false);
if !running {
return;
}
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
// `podman exec` cannot always join a rootless container's delegated cgroup
// from the system service, while Podman 5's copier refuses to overwrite an
// existing regular file. Mount the rootless storage namespace instead;
// this replaces both files without entering the container's cgroup.
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
let tmp_dir = format!("/tmp/indeedhub-nginx-patch-{}-{unique}", std::process::id());
let tmp_path = format!("{tmp_dir}/default.conf");
if tokio::fs::create_dir(&tmp_dir).await.is_err() {
tracing::warn!("IndeeHub signer reconciliation could not create its temporary directory");
return;
}
let mount_out = tokio::process::Command::new("podman")
.args(["unshare", "podman", "mount", "indeedhub"])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
let container_root = mount_out
.ok()
.filter(|out| out.status.success())
.map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string())
.filter(|path| {
std::path::Path::new(path).is_absolute()
&& path.contains("/containers/storage/overlay/")
&& path.ends_with("/merged")
});
let Some(container_root) = container_root else {
let _ = tokio::fs::remove_dir(&tmp_dir).await;
tracing::warn!("IndeeHub signer reconciliation could not mount rootless storage");
return;
};
if !already_patched {
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
let provider_dest = format!("{container_root}/usr/share/nginx/html/nostr-provider.js");
let provider_copied = tokio::fs::metadata(provider_src).await.is_ok()
&& tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
provider_src,
&provider_dest,
])
.output()
.await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
expires off;\n\
}\n\n\
location = /sw.js {",
);
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
sub_filter_once on;\n\
sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
let copy_out = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await;
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
let mut config_copied = false;
if let Ok(out) = copy_out {
if out.status.success() {
if let Ok(original) = tokio::fs::read_to_string(&tmp_path).await {
let conf = patched_indeedhub_nginx_config(&original);
if conf != original && tokio::fs::write(&tmp_path, &conf).await.is_ok() {
config_copied = tokio::process::Command::new("podman")
.args([
"unshare",
"install",
"-m",
"644",
&tmp_path,
&format!("{container_root}/etc/nginx/conf.d/default.conf"),
])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
.await
.map(|out| out.status.success())
.unwrap_or(false);
if config_copied {
let _ = tokio::fs::remove_file(&tmp_path).await;
config_copied = tokio::process::Command::new("podman")
.args(["cp", "indeedhub:/etc/nginx/conf.d/default.conf", &tmp_path])
.output()
.await
.map(|out| out.status.success())
.unwrap_or(false)
&& tokio::fs::read_to_string(&tmp_path)
.await
.map(|actual| actual == conf)
.unwrap_or(false);
}
} else if conf == original
&& conf.contains("location = /nostr-provider.js {")
&& conf.contains("src=\"/nostr-provider.js?v=tab-signer-v4\"")
{
config_copied = true;
}
}
}
}
let _ = tokio::fs::remove_file(&tmp_path).await;
let _ = tokio::fs::remove_dir(&tmp_dir).await;
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf",
])
.args(["unshare", "podman", "unmount", "indeedhub"])
.output()
.await;
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.args(["kill", "--signal", "HUP", "indeedhub"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
Ok(o) if o.status.success() && provider_copied && config_copied => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
"IndeeHub signer reconciliation incomplete (provider_copied={}, config_copied={}): {}",
provider_copied,
config_copied,
String::from_utf8_lossy(&o.stderr)
);
}
@@ -306,6 +374,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
@@ -1620,124 +1689,10 @@ autopilot.active=false\n",
}
}
// IndeeHub: inject nostr-provider.js and patch container nginx for NIP-07 signing
// IndeeHub: inject the current consent-gated provider and make it work
// in both the dashboard frame and a direct browser tab.
if package_id == "indeedhub" {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
// 1. Remove X-Frame-Options so iframe embedding works
let _ = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"sed",
"-i",
"/X-Frame-Options/d",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
// 2. Copy nostr-provider.js into container
let provider_src = "/opt/archipelago/web-ui/nostr-provider.js";
if tokio::fs::metadata(provider_src).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args([
"cp",
provider_src,
"indeedhub:/usr/share/nginx/html/nostr-provider.js",
])
.output()
.await;
}
// 3. Add nostr-provider.js location block + sub_filter injection
let check = tokio::process::Command::new("podman")
.args([
"exec",
"indeedhub",
"grep",
"-q",
"nostr-provider",
"/etc/nginx/conf.d/default.conf",
])
.output()
.await;
let already_patched = check.map(|o| o.status.success()).unwrap_or(false);
if !already_patched {
// Read current nginx config from container
let cat_out = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "cat", "/etc/nginx/conf.d/default.conf"])
.output()
.await;
if let Ok(out) = cat_out {
if out.status.success() {
let conf = String::from_utf8_lossy(&out.stdout).to_string();
// Insert provider location block before the sw.js location
let conf = conf.replace(
"location = /sw.js {",
"location = /nostr-provider.js {\n\
\x20 add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n\
\x20 expires off;\n\
\x20 }\n\n\
\x20 location = /sw.js {"
);
// Inject script tag into HTML via sub_filter
let conf = if conf.contains("try_files") && !conf.contains("sub_filter") {
conf.replacen(
"try_files $uri $uri/ /index.html;",
"try_files $uri $uri/ /index.html;\n\
\x20 sub_filter_once on;\n\
\x20 sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';",
1,
)
} else {
conf
};
// Write patched config back into container
let tmp_path = "/tmp/indeedhub-nginx-patch.conf";
if tokio::fs::write(tmp_path, &conf).await.is_ok() {
let _ = tokio::process::Command::new("podman")
.args(["cp", tmp_path, "indeedhub:/etc/nginx/conf.d/default.conf"])
.output()
.await;
let _ = tokio::fs::remove_file(tmp_path).await;
}
}
}
}
// 4. Fix X-Forwarded-Prefix for NIP-98 URL reconstruction in iframe context
let _ = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "sed", "-i",
"s|proxy_set_header X-Forwarded-Prefix /api;|proxy_set_header X-Forwarded-Prefix $http_x_forwarded_prefix/api;|",
"/etc/nginx/conf.d/default.conf"])
.output()
.await;
// 5. Reload nginx to apply changes
let reload = tokio::process::Command::new("podman")
.args(["exec", "indeedhub", "nginx", "-s", "reload"])
.output()
.await;
match reload {
Ok(o) if o.status.success() => {
info!("IndeeHub: NIP-07 provider injected, nginx patched and reloaded");
}
Ok(o) => {
tracing::warn!(
"IndeeHub nginx reload failed: {}",
String::from_utf8_lossy(&o.stderr)
);
}
Err(e) => {
tracing::warn!("IndeeHub nginx reload error: {}", e);
}
}
patch_indeedhub_nostr_provider().await;
}
// Gitea: keep it on its native host port (3001). The UI opens Gitea
@@ -2040,10 +1995,59 @@ autopilot.active=false\n",
}));
}
// Portainer ≥2.21 no longer lets whoever loads the page first claim the
// admin account: on a fresh install it mints a one-time setup token and
// prints it to the SERVER LOGS, expecting the operator to go digging.
// On an appliance that is hostile UX — "check the Portainer server
// logs" is exactly the dead end users cannot follow. The token is the
// only thing standing between the user and their own app, so surface
// it in the same launch interstitial as the login credentials: extract
// it from the container logs and hand it over with a copy button.
// Once setup completes Portainer invalidates the token, and a container
// recreate (any update) drops the log line entirely — so absence of the
// line naturally makes the card disappear and no stale token lingers.
if app_id == "portainer" {
if let Some(token) = portainer_setup_token(self).await {
return Ok(serde_json::json!({
"title": "Portainer first-run token",
"description": "New Portainer versions protect the first launch with a one-time setup token instead of letting anyone on the network claim the admin account. Paste this token into Portainer's setup screen to create your administrator login. It is only valid until setup finishes — if you already created your admin account, ignore this.",
"credentials": [
{ "label": "Setup token", "value": token, "sensitive": true }
]
}));
}
}
Ok(serde_json::json!({ "credentials": [] }))
}
}
/// Extract Portainer's first-run `setup_token=…` from the live container's
/// recent logs. `None` when the line is absent (setup already done, or an
/// older Portainer without the token flow).
async fn portainer_setup_token(rpc: &RpcHandler) -> Option<String> {
let logs = rpc.get_container_logs_value("portainer", 300).await.ok()?;
let lines = logs.as_array()?;
let lines: Vec<&str> = lines.iter().filter_map(|l| l.as_str()).collect();
parse_setup_token(&lines)
}
/// Pure log-line scan: the token is 64 hex chars after `setup_token=`.
/// Sear newest-first so the most recent mint wins.
fn parse_setup_token(lines: &[&str]) -> Option<String> {
for line in lines.iter().rev() {
let Some(idx) = line.find("setup_token=") else {
continue;
};
let tail = &line[idx + "setup_token=".len()..];
let token: String = tail.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
if token.len() == 64 {
return Some(token);
}
}
None
}
async fn cleanup_stale_package_ports(package_id: &str) {
match package_id {
"grafana" => cleanup_stale_pasta_port("3000").await,
@@ -2740,6 +2744,11 @@ fn uses_orchestrator_install_flow(package_id: &str) -> bool {
| "gitea"
| "portainer"
| "meshtastic"
// Build-backed user-facing app. Route it through the production
// orchestrator so a fresh node builds its bundled image instead
// of treating localhost/archipelago-source:local as a registry
// image in the legacy installer.
| "archipelago-source"
)
}
@@ -2751,11 +2760,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
#[cfg(test)]
mod tests {
use super::{
orchestrator_install_app_id, should_try_orchestrator_install,
uses_orchestrator_install_flow,
orchestrator_install_app_id, parse_setup_token, patched_indeedhub_nginx_config,
should_try_orchestrator_install, uses_orchestrator_install_flow,
};
use crate::api::rpc::package::runtime::orchestrator_uninstall_app_ids;
#[test]
fn indeedhub_nginx_patch_is_complete_and_idempotent() {
let original = r#"server {
add_header X-Frame-Options SAMEORIGIN;
location = /sw.js {
expires off;
}
location /api/ {
proxy_set_header X-Forwarded-Prefix /api;
}
location / {
try_files $uri $uri/ /index.html;
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
}
"#;
let patched = patched_indeedhub_nginx_config(original);
assert!(!patched.contains("X-Frame-Options"));
assert!(patched.contains("location = /nostr-provider.js {"));
assert!(patched.contains("Cache-Control \"no-cache, no-store, must-revalidate\""));
assert!(patched.contains("src=\"/nostr-provider.js?v=tab-signer-v4\""));
assert!(patched.contains("X-Forwarded-Prefix $http_x_forwarded_prefix/api"));
assert_eq!(patched_indeedhub_nginx_config(&patched), patched);
let previous_broker = patched.replace("tab-signer-v4", "tab-signer-v3");
let migrated = patched_indeedhub_nginx_config(&previous_broker);
assert!(migrated.contains("tab-signer-v4"));
assert!(!migrated.contains("tab-signer-v3"));
assert_eq!(patched_indeedhub_nginx_config(&migrated), migrated);
}
#[test]
fn orchestrator_install_allowlist_includes_ported_backends() {
for app in [
@@ -2787,6 +2828,7 @@ mod tests {
"gitea",
"portainer",
"meshtastic",
"archipelago-source",
] {
assert!(uses_orchestrator_install_flow(app));
assert!(should_try_orchestrator_install(app, true));
@@ -2861,4 +2903,41 @@ mod tests {
"Error: no container with name or ID \"bitcoin-knots\" found"
));
}
#[test]
fn portainer_setup_token_is_extracted_from_log_lines() {
// Shape captured live from portainer:2.45.0 on 2026-09-01 — the
// token line is plain text inside the bordered s6 log block.
let logs = [
"2026/09/01 12:38PM INF github.com/portainer/portainer/api/database/boltdb/db.go:163 > loading PortainerDB | filename=portainer.db",
"==========================",
"setup_token=27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a",
"Paste it into the setup screen, or send it in the X-Setup-Token header.",
"==========================",
];
assert_eq!(
parse_setup_token(&logs).as_deref(),
Some("27637c02b6323972dff76bcad4caa456f957b521d3cfe3bc7fb95d2488dfd23a")
);
}
#[test]
fn portainer_setup_token_absent_when_setup_already_done() {
// An instance with an existing admin account never prints the line —
// the credentials card must not render a stale or empty token.
let logs = [
"2026/09/01 11:37AM INF api/datastore/migrator/migrate_ce.go:76 > db migrated to 2.45.0 |",
"2026/09/01 11:37:38 server: Listening on http://0.0.0.0:8000",
];
assert_eq!(parse_setup_token(&logs), None);
}
#[test]
fn portainer_setup_token_rejects_short_or_non_hex_values() {
assert_eq!(parse_setup_token(&["setup_token=abc123"]), None);
assert_eq!(
parse_setup_token(&["setup_token=".to_string().as_str()]),
None
);
}
}
@@ -4,6 +4,7 @@ mod dependencies;
mod install;
mod lifecycle;
mod pine_ha;
pub(crate) use install::patch_indeedhub_nostr_provider;
pub(crate) use pine_ha::wyoming_satellite_keeper;
mod progress;
mod runtime;
@@ -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);
+27 -2
View File
@@ -1559,6 +1559,31 @@ impl RpcHandler {
self.set_install_progress("indeedhub", n_images, n_images)
.await;
// The retired installer injected one fleet-wide AES root directly in
// the API/worker environment. Detect those consumers before removing
// anything, then persist the legacy value exactly once so an upgrade
// cannot orphan encrypted data. A genuinely fresh fallback install
// receives a random per-node root instead.
let mut had_existing_crypto_consumer = false;
for name in [
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
] {
let status =
podman_stack_status(&["container", "exists", name], PODMAN_STACK_PROBE_TIMEOUT)
.await?;
had_existing_crypto_consumer |= status.success();
}
let secrets_dir = self.config.data_dir.join("secrets");
crate::container::secrets::ensure_indeedhub_aes_master_secret(
&secrets_dir,
had_existing_crypto_consumer,
)
.context("preparing IndeedHub encryption root")?;
let aes_master = crate::container::secrets::indeedhub_aes_master_secret(&secrets_dir)?;
// Remove any leftover containers from a previous partial install (or
// from the first-boot frontend stub that used to race the installer).
// Without this, `podman run --name indeedhub` fails on name conflict
@@ -1759,7 +1784,7 @@ impl RpcHandler {
"-e".to_string(),
"NOSTR_JWT_EXPIRES_IN=7d".to_string(),
"-e".to_string(),
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
format!("AES_MASTER_SECRET={aes_master}"),
"-e".to_string(),
"ENVIRONMENT=production".to_string(),
format!("{registry}/indeedhub-api:1.0.0"),
@@ -1810,7 +1835,7 @@ impl RpcHandler {
"-e".to_string(),
"ENVIRONMENT=production".to_string(),
"-e".to_string(),
"AES_MASTER_SECRET=0123456789abcdef0123456789abcdef".to_string(),
format!("AES_MASTER_SECRET={aes_master}"),
format!("{registry}/indeedhub-ffmpeg:1.0.0"),
],
&tmp_env,
+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"
)
}
+27
View File
@@ -421,6 +421,33 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-lnaddress` — the node's Minibits Lightning address
/// (`<name>@minibits.cash`, LUD-16), derived from and authenticated by the
/// ecash wallet's own seed. Registers the profile on first use; safe to call
/// on every open of the Cashu receive screen (it is idempotent).
pub(super) async fn handle_wallet_ecash_lnaddress(&self) -> Result<serde_json::Value> {
crate::wallet::minibits::lnaddress(&self.config.data_dir).await
}
/// `wallet.ecash-lnaddress-claim` — redeem any Lightning payments that
/// arrived on the node's Minibits address as ecash. Returns the sats swept in
/// (0 when nothing was waiting), so the UI can refresh its balance.
/// `failed_count` is non-zero when a payment was fetched (and so already
/// consumed server-side) but couldn't be redeemed yet — it stays queued
/// and is retried automatically, but the UI should tell the operator
/// rather than let it be a silent, unbounded wait.
pub(super) async fn handle_wallet_ecash_lnaddress_claim(&self) -> Result<serde_json::Value> {
let outcome = crate::wallet::minibits::claim_and_redeem(&self.config.data_dir).await?;
Ok(serde_json::json!({
"claimed_count": outcome.claimed_count,
"received_sats": outcome.received_sats,
"failed_count": outcome.failed_count,
"receipt_id": outcome.receipt_id,
"receipt_sats": outcome.receipt_sats,
"receipt_at": outcome.receipt_at,
}))
}
pub(super) async fn handle_wallet_networking_profits(&self) -> Result<serde_json::Value> {
let summary = profits::get_networking_profits(&self.config.data_dir).await?;
Ok(serde_json::json!({
+289 -38
View File
@@ -148,9 +148,16 @@ impl AppGate {
let app = live.as_ref().unwrap_or(app);
let path = req.uri().path().to_string();
// A dashboard same-origin proxy strips `/app/<id>/` before this gate
// sees the URI. Carry that trusted proxy mount into the challenge's
// form/assets and its post-login redirect so the browser stays inside
// the mounted app instead of posting to the dashboard root.
let mount_prefix = forwarded_mount_prefix(req.headers());
if let Some(action) = path.strip_prefix(GATE_PREFIX) {
return self.handle_gate_action(req, app, action, client_ip).await;
return self
.handle_gate_action(req, app, action, client_ip, &mount_prefix)
.await;
}
// A browser fetches a few subresources WITHOUT credentials by
@@ -188,10 +195,25 @@ impl AppGate {
return proxy_to_app(req, app, false).await;
}
// Capture the platform session before the request is moved into the
// upstream proxy. Older app-gate sessions (issued before the paired
// CSRF-cookie fix) can then repair themselves on the very next app
// response, before the app's provider creates its signer iframe.
let session_for_csrf = crate::session::extract_session_cookie(req.headers());
let needs_csrf_cookie = cookie_value(req.headers(), "csrf_token").is_none();
match self.authorize(req.headers(), &app.app_id).await {
// The credential was a cookie (or none was needed): the
// Authorization header, if any, belongs to the app. Forward it.
Authorization::Allow => proxy_to_app(req, app, false).await,
Authorization::Allow => {
let mut response = proxy_to_app(req, app, false).await;
if needs_csrf_cookie {
if let Some(token) = session_for_csrf {
set_csrf_cookie(&mut response, &token).await;
}
}
response
}
// The credential WAS the Authorization header, and it was ours.
Authorization::AllowGateToken => proxy_to_app(req, app, true).await,
// 401 rather than a redirect: a redirect to a login page is
@@ -199,7 +221,9 @@ impl AppGate {
// clients would follow it and parse HTML as if it were their API
// response. The status says "you are not authenticated" in a way
// every client understands, and browsers still render the body.
Authorization::Challenge => login_page(app, None, StatusCode::UNAUTHORIZED),
Authorization::Challenge => {
login_page(app, None, StatusCode::UNAUTHORIZED, &mount_prefix)
}
}
}
@@ -229,6 +253,7 @@ impl AppGate {
app: &GatedPort,
action: &str,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
// Assets are GET and pre-auth by nature: the login page cannot
// render its own background or logo without them.
@@ -236,7 +261,7 @@ impl AppGate {
return self.serve_asset(name);
}
if req.method() != Method::POST {
return login_page(app, None, StatusCode::OK);
return login_page(app, None, StatusCode::OK, mount_prefix);
}
// Captured before the body is consumed. The pending-2FA session
@@ -253,17 +278,28 @@ impl AppGate {
app,
Some("Too many attempts. Wait a minute and try again."),
StatusCode::TOO_MANY_REQUESTS,
mount_prefix,
);
}
let form = match read_form(req).await {
Some(form) => form,
None => return login_page(app, Some("Malformed request."), StatusCode::BAD_REQUEST),
None => {
return login_page(
app,
Some("Malformed request."),
StatusCode::BAD_REQUEST,
mount_prefix,
)
}
};
match action {
"login" => self.do_login(app, &form, client_ip).await,
"totp" => self.do_totp(app, &form, pending, client_ip).await,
"login" => self.do_login(app, &form, client_ip, mount_prefix).await,
"totp" => {
self.do_totp(app, &form, pending, client_ip, mount_prefix)
.await
}
_ => not_found(),
}
}
@@ -288,14 +324,25 @@ impl AppGate {
.expect("asset response builds")
}
async fn do_login(&self, app: &GatedPort, form: &Form, client_ip: IpAddr) -> Response<Body> {
async fn do_login(
&self,
app: &GatedPort,
form: &Form,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
let password = field(form, "password").unwrap_or_default();
match self.auth.verify_password(&password).await {
Ok(true) => {}
_ => {
self.limiter.record_failure(client_ip).await;
return login_page(app, Some("Incorrect password."), StatusCode::UNAUTHORIZED);
return login_page(
app,
Some("Incorrect password."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
}
}
@@ -307,8 +354,8 @@ impl AppGate {
if let Ok(Some(totp_data)) = self.auth.get_totp_data().await {
if let Ok(secret) = crate::totp::decrypt_secret(&totp_data, &password) {
let pending = self.sessions.create_pending(secret).await;
let mut resp = totp_page(app, None, StatusCode::OK);
set_session_cookie(&mut resp, &pending);
let mut resp = totp_page(app, None, StatusCode::OK, mount_prefix);
set_session_cookie(&mut resp, &pending).await;
return resp;
}
}
@@ -319,12 +366,13 @@ impl AppGate {
app,
Some("Two-factor data could not be read. Sign in from the dashboard."),
StatusCode::INTERNAL_SERVER_ERROR,
mount_prefix,
);
}
let token = self.sessions.create().await;
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &token);
let mut resp = redirect_to_app(mount_prefix);
set_session_cookie(&mut resp, &token).await;
resp
}
@@ -334,10 +382,16 @@ impl AppGate {
form: &Form,
pending: Option<String>,
client_ip: IpAddr,
mount_prefix: &str,
) -> Response<Body> {
let code = field(form, "code").unwrap_or_default();
let Some(pending) = pending.filter(|s| !s.is_empty()) else {
return login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED);
return login_page(
app,
Some("Session expired."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
};
let Some(secret) = self.sessions.get_pending_secret(&pending).await else {
@@ -345,6 +399,7 @@ impl AppGate {
app,
Some("Session expired. Start again."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
};
@@ -371,17 +426,27 @@ impl AppGate {
}
match self.sessions.upgrade_to_full(&pending).await {
Some(full) => {
let mut resp = redirect_to_app();
set_session_cookie(&mut resp, &full);
let mut resp = redirect_to_app(mount_prefix);
set_session_cookie(&mut resp, &full).await;
resp
}
None => login_page(app, Some("Session expired."), StatusCode::UNAUTHORIZED),
None => login_page(
app,
Some("Session expired."),
StatusCode::UNAUTHORIZED,
mount_prefix,
),
}
}
_ => {
self.limiter.record_failure(client_ip).await;
let mut resp = totp_page(app, Some("Incorrect code."), StatusCode::UNAUTHORIZED);
set_session_cookie(&mut resp, &pending);
let mut resp = totp_page(
app,
Some("Incorrect code."),
StatusCode::UNAUTHORIZED,
mount_prefix,
);
set_session_cookie(&mut resp, &pending).await;
resp
}
}
@@ -634,7 +699,7 @@ fn strip_gate_cookies(headers: &mut hyper::HeaderMap) {
}
}
fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
async fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
// No Domain attribute, so the cookie is host-only. Cookies ignore port,
// which is what makes one sign-in cover the dashboard and every app port
// on the same host — and equally why an app on a *different* host (its
@@ -644,12 +709,82 @@ fn set_session_cookie(resp: &mut Response<Body>, token: &str) {
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
// The dashboard RPC layer requires a readable CSRF cookie as well as the
// HttpOnly session cookie. An app-gate login is a complete node login, so
// it must establish the same pair as auth.login; otherwise a fresh browser
// can open the signer broker but every identity/signing RPC is rejected
// with `has_session=true, has_header=false`.
set_csrf_cookie(resp, token).await;
}
fn redirect_to_app() -> Response<Body> {
async fn set_csrf_cookie(resp: &mut Response<Body>, token: &str) {
let csrf = crate::api::rpc::derive_csrf_token(token).await;
if let Ok(value) =
header::HeaderValue::from_str(&format!("csrf_token={csrf}; SameSite=Lax; Path=/"))
{
resp.headers_mut().append(header::SET_COOKIE, value);
}
}
fn cookie_value(headers: &HeaderMap, name: &str) -> Option<String> {
let prefix = format!("{name}=");
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.flat_map(|value| value.split(';'))
.map(str::trim)
.find_map(|pair| pair.strip_prefix(&prefix))
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
/// Validate the mount supplied by the node's own nginx proxy.
///
/// Treat this as untrusted input even though our canonical proxy sets it: a
/// client can reach an app-gate port directly and forge request headers. Only
/// a short absolute path made from ordinary URL-path characters is accepted;
/// protocol-relative URLs, dot segments, escaping and query/fragment syntax
/// all fall back to the direct-port root.
fn forwarded_mount_prefix(headers: &HeaderMap) -> String {
let Some(raw) = headers
.get("x-forwarded-prefix")
.and_then(|value| value.to_str().ok())
else {
return String::new();
};
let value = raw.trim_end_matches('/');
if value.is_empty()
|| value.len() > 256
|| !value.starts_with('/')
|| value.starts_with("//")
|| value
.bytes()
.any(|b| !(b.is_ascii_alphanumeric() || matches!(b, b'/' | b'-' | b'_' | b'.')))
|| value
.split('/')
.skip(1)
.any(|segment| segment.is_empty() || segment == "." || segment == "..")
{
return String::new();
}
value.to_owned()
}
fn gate_url(mount_prefix: &str, action: &str) -> String {
format!("{mount_prefix}{GATE_PREFIX}{action}")
}
fn redirect_to_app(mount_prefix: &str) -> Response<Body> {
let location = if mount_prefix.is_empty() {
"/".to_owned()
} else {
format!("{mount_prefix}/")
};
Response::builder()
.status(StatusCode::SEE_OTHER)
.header(header::LOCATION, "/")
.header(header::LOCATION, location)
.body(Body::empty())
.expect("static response builds")
}
@@ -674,7 +809,13 @@ dashboard and check {name} under My Apps.</p>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
);
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
let mut resp = page(
"App not responding",
app,
&body,
StatusCode::BAD_GATEWAY,
"",
);
// Header-based refresh, not <meta> or script: page()'s CSP allows no
// script, and the header keeps the retry out of the document entirely.
resp.headers_mut()
@@ -707,7 +848,7 @@ fn esc(s: &str) -> String {
/// the app's own port, so any asset URL would either hit the unauthenticated
/// app behind it or a different origin the browser may not reach.
/// One stacked layer per background, each delayed so they cross-fade in turn.
fn background_layers() -> String {
fn background_layers(mount_prefix: &str) -> String {
let step = LOGIN_BACKGROUNDS.len() as u32 * 9 / LOGIN_BACKGROUNDS.len() as u32;
LOGIN_BACKGROUNDS
.iter()
@@ -715,7 +856,7 @@ fn background_layers() -> String {
.map(|(i, name)| {
format!(
r#"<div class="bg" style="background-image:url('{prefix}asset/{name}');animation-delay:{delay}s"></div>"#,
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
delay = i as u32 * step,
)
})
@@ -938,7 +1079,13 @@ fn base64_encode(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn page(title: &str, app: &GatedPort, body: &str, status: StatusCode) -> Response<Body> {
fn page(
title: &str,
app: &GatedPort,
body: &str,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let html = format!(
r#"<!doctype html>
<html lang="en"><head>
@@ -1055,7 +1202,7 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
app_name = esc(&app.app_name),
body = body,
submit_feedback = SUBMIT_FEEDBACK_JS,
backgrounds = background_layers(),
backgrounds = background_layers(mount_prefix),
cycle = LOGIN_BACKGROUNDS.len() as u32 * 9,
hold = 100 / LOGIN_BACKGROUNDS.len() as u32,
fade = 100 / LOGIN_BACKGROUNDS.len() as u32 + 4,
@@ -1092,7 +1239,12 @@ button.loading .busy {{ display:inline-flex; align-items:center; gap:.5rem; }}
/// The challenge. Names and pictures the app being opened, so the visitor can
/// confirm what they are authenticating to rather than being asked for a
/// password by an unexplained page.
fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
fn login_page(
app: &GatedPort,
error: Option<&str>,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let body = format!(
r#"{logo}
{icon}
@@ -1110,14 +1262,19 @@ fn login_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respo
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
);
page("Sign in", app, &body, status)
page("Sign in", app, &body, status, mount_prefix)
}
/// Second factor. Reached only after the password verified, and the session
/// backing it cannot authorise anything until this completes.
fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Response<Body> {
fn totp_page(
app: &GatedPort,
error: Option<&str>,
status: StatusCode,
mount_prefix: &str,
) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>Two-factor code</h1>
@@ -1133,9 +1290,9 @@ fn totp_page(app: &GatedPort, error: Option<&str>, status: StatusCode) -> Respon
err = error
.map(|e| format!(r#"<div class="err">{}</div>"#, esc(e)))
.unwrap_or_default(),
prefix = GATE_PREFIX,
prefix = gate_url(mount_prefix, ""),
);
page("Two-factor", app, &body, status)
page("Two-factor", app, &body, status, mount_prefix)
}
#[cfg(test)]
@@ -1207,9 +1364,35 @@ mod tests {
assert_eq!(bearer_token(&headers), None);
}
#[test]
fn forwarded_mount_prefix_accepts_only_a_safe_absolute_path() {
let mut headers = HeaderMap::new();
headers.insert(
"x-forwarded-prefix",
"/app/archipelago-source/".parse().unwrap(),
);
assert_eq!(forwarded_mount_prefix(&headers), "/app/archipelago-source");
for unsafe_value in [
"//other.example/app",
"/app/../admin",
"/app//source",
"/app/source?next=//other.example",
"https://other.example/app",
"/app/%2e%2e/admin",
] {
headers.insert("x-forwarded-prefix", unsafe_value.parse().unwrap());
assert_eq!(
forwarded_mount_prefix(&headers),
"",
"accepted {unsafe_value}"
);
}
}
#[tokio::test]
async fn login_page_names_the_app() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
@@ -1222,7 +1405,7 @@ mod tests {
async fn page_escapes_app_names() {
let mut app = app();
app.app_name = r#"<script>alert(1)</script>"#.to_string();
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app, None, StatusCode::UNAUTHORIZED, "");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(!html.contains("<script>alert"));
@@ -1235,6 +1418,7 @@ mod tests {
&app(),
Some("<img src=x onerror=1>"),
StatusCode::UNAUTHORIZED,
"",
);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
@@ -1293,7 +1477,7 @@ mod tests {
#[test]
fn challenge_pages_are_uncacheable_and_framable_only_by_this_node() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
assert!(
!resp.headers().contains_key("X-Frame-Options"),
@@ -1329,7 +1513,7 @@ mod tests {
/// never 404 at all.
#[tokio::test]
async fn login_page_sources_its_art_from_the_gate() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body).to_string();
assert_eq!(
@@ -1345,13 +1529,32 @@ mod tests {
}
}
#[tokio::test]
async fn mounted_login_keeps_forms_assets_and_redirect_inside_the_app() {
let mount = "/app/archipelago-source";
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, mount);
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains(r#"action="/app/archipelago-source/__archipelago-gate/login""#));
for name in LOGIN_BACKGROUNDS {
assert!(html.contains(&format!("/app/archipelago-source{GATE_PREFIX}asset/{name}")));
}
let redirect = redirect_to_app(mount);
assert_eq!(redirect.status(), StatusCode::SEE_OTHER);
assert_eq!(
redirect.headers()[header::LOCATION],
"/app/archipelago-source/"
);
}
/// The only script the challenge pages may run is the submit-feedback
/// snippet, admitted by hash. The page must carry exactly that script,
/// and the CSP must name its hash — anything injected has a different
/// hash and stays inert.
#[tokio::test]
async fn submit_feedback_script_is_present_and_hash_pinned() {
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED);
let resp = login_page(&app(), None, StatusCode::UNAUTHORIZED, "");
let csp = resp.headers()["Content-Security-Policy"]
.to_str()
.unwrap()
@@ -1455,6 +1658,54 @@ mod tests {
assert!(headers.get(header::COOKIE).is_none());
}
#[test]
fn cookie_value_finds_only_a_nonempty_named_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"app_session=keep; csrf_token=csrf123; empty="
.parse()
.unwrap(),
);
assert_eq!(
cookie_value(&headers, "csrf_token"),
Some("csrf123".to_string())
);
assert_eq!(cookie_value(&headers, "session"), None);
assert_eq!(cookie_value(&headers, "empty"), None);
}
/// An app-gate login must be equivalent to a dashboard login. The session
/// cookie alone can load the broker route, but every identity/signing RPC
/// also needs the matching readable CSRF cookie.
#[tokio::test]
async fn app_gate_login_establishes_the_dashboard_csrf_cookie() {
let token = "app-gate-session-token";
let mut resp = redirect_to_app("");
set_session_cookie(&mut resp, token).await;
let cookies: Vec<_> = resp
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.collect();
let expected_csrf = crate::api::rpc::derive_csrf_token(token).await;
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with(&format!("session={token};"))));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with(&format!("csrf_token={expected_csrf};"))));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with("session=") && cookie.contains("HttpOnly")));
assert!(cookies
.iter()
.any(|cookie| cookie.starts_with("csrf_token=") && !cookie.contains("HttpOnly")));
}
/// The regression that killed every Nostr login on 2026-08-06.
///
/// IndeeHub's NIP-98 credential rides in `Authorization: Nostr <event>`
+135 -2
View File
@@ -26,7 +26,7 @@ const DOCTOR_SERVICE: &str =
include_str!("../../../image-recipe/configs/archipelago-doctor.service");
const DOCTOR_TIMER: &str = include_str!("../../../image-recipe/configs/archipelago-doctor.timer");
const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.sh";
const DOCTOR_SH_PATH: &str = "/opt/archipelago/scripts/container-doctor.sh";
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
@@ -85,6 +85,15 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// image-recipe/configs/nginx-archipelago.conf.
const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n";
const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n";
const NGINX_SOURCE_PROXY_BLOCK_SNIPPET: &str = "# GitWorkshop follows the dashboard origin; the app gate keeps the route\n# session-authenticated before it reaches the loopback-only container.\nlocation /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n}\n";
/// The normal dashboard sends X-Frame-Options SAMEORIGIN. This one document
/// must be frameable by an app on another port of the same node so tabs and
/// companion WebViews can use the same authenticated signer UI.
const NGINX_NOSTR_SIGNER_BLOCK: &str = " # Dashboard-origin Nostr signer for tab/WebView apps.\n location = /nostr-signer {\n try_files /index.html =404;\n add_header Cache-Control \"no-store\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n add_header Referrer-Policy \"no-referrer\" always;\n add_header Content-Security-Policy \"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'self' http://$host:* https://$host:*; base-uri 'none'; form-action 'none';\" always;\n }\n\n";
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
@@ -1231,7 +1240,7 @@ async fn run() -> Result<bool> {
let mut changed = false;
// 1. Script — lives in archipelago's home dir, user-writable.
// 1. Script — lives in the canonical OTA runtime scripts directory.
if needs_write(DOCTOR_SH_PATH, DOCTOR_SH).await {
fs::write(DOCTOR_SH_PATH, DOCTOR_SH)
.await
@@ -1580,6 +1589,62 @@ fn heal_stale_web_search_block(content: &str) -> Option<String> {
))
}
fn heal_missing_source_proxy(content: &str) -> Option<String> {
if content.contains("location /app/archipelago-source/") {
return None;
}
let indented_anchor = " location /app/gitea/ {";
if content.contains(indented_anchor) {
return Some(content.replace(
indented_anchor,
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK, indented_anchor),
));
}
let snippet_anchor = "location /app/gitea/ {";
content.contains(snippet_anchor).then(|| {
content.replace(
snippet_anchor,
&format!("{}{}", NGINX_SOURCE_PROXY_BLOCK_SNIPPET, snippet_anchor),
)
})
}
/// Older same-origin GitWorkshop blocks stripped the app mount but did not
/// tell the app gate what was stripped. Its challenge therefore posted to
/// `/__archipelago-gate/login` on the dashboard and nginx returned 405. Add
/// the mount header to every canonical source block (HTTP and HTTPS snippet).
fn heal_source_forwarded_prefix(content: &str) -> Option<String> {
if !content.contains("proxy_pass http://127.0.0.2:8337/;") {
return None;
}
let mut healed = content.to_owned();
for indent in [" ", " "] {
let old = format!(
"proxy_pass http://127.0.0.2:8337/;\n{indent}proxy_http_version 1.1;\n{indent}proxy_set_header Host $http_host;\n{indent}proxy_set_header Cookie $http_cookie;\n{indent}proxy_set_header X-Real-IP $remote_addr;\n{indent}proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n{indent}proxy_set_header X-Forwarded-Proto $scheme;\n{indent}proxy_hide_header X-Frame-Options;"
);
let new = old.replace(
&format!("\n{indent}proxy_hide_header X-Frame-Options;"),
&format!(
"\n{indent}proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n{indent}proxy_hide_header X-Frame-Options;"
),
);
healed = healed.replace(&old, &new);
}
(healed != content).then_some(healed)
}
fn heal_missing_nostr_signer(content: &str) -> Option<String> {
if content.contains("location = /nostr-signer") {
return None;
}
// The anchor occurs once in each complete HTTP/HTTPS dashboard server and
// does not occur in the separate app-proxy snippet.
let anchor = " location /aiui/ {";
content
.contains(anchor)
.then(|| content.replace(anchor, &format!("{}{}", NGINX_NOSTR_SIGNER_BLOCK, anchor)))
}
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
@@ -1610,6 +1675,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
let missing_v6_https =
content.contains("listen 443 ssl default_server;") && !content.contains("listen [::]:443");
let stale_web_search = heal_stale_web_search_block(&content).is_some();
let missing_source_proxy = heal_missing_source_proxy(&content).is_some();
let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some();
let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some();
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
@@ -1620,6 +1688,9 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
&& !missing_v6_http
&& !missing_v6_https
&& !stale_web_search
&& !missing_source_proxy
&& !missing_source_prefix
&& !missing_nostr_signer
{
return Ok(false);
}
@@ -1629,6 +1700,15 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
if let Some(p) = heal_stale_web_search_block(&patched) {
patched = p;
}
if let Some(p) = heal_missing_source_proxy(&patched) {
patched = p;
}
if let Some(p) = heal_source_forwarded_prefix(&patched) {
patched = p;
}
if let Some(p) = heal_missing_nostr_signer(&patched) {
patched = p;
}
if missing_v6_http {
patched = patched.replace(
@@ -1796,6 +1876,17 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
mod tests {
use super::*;
#[test]
fn doctor_service_uses_the_canonical_ota_script_path() {
let expected = format!("ExecStart={} --local", DOCTOR_SH_PATH);
assert!(DOCTOR_SERVICE.lines().any(|line| line == expected));
assert_eq!(
DOCTOR_SH_PATH,
"/opt/archipelago/scripts/container-doctor.sh"
);
assert!(!DOCTOR_SERVICE.contains("/home/archipelago/archy/"));
}
#[test]
fn podman_heal_outcome_no_longer_has_cleanup_variant() {
let outcome = PodmanHealOutcome::Unhealthy;
@@ -1817,6 +1908,48 @@ mod tests {
);
}
#[test]
fn source_proxy_uses_same_origin_through_authenticated_app_gate() {
let main = "server {\n location /app/gitea/ {\n }\n}\nserver {\n location /app/gitea/ {\n }\n}";
let healed = heal_missing_source_proxy(main).expect("source proxy must be added");
assert_eq!(
healed.matches("location /app/archipelago-source/").count(),
2
);
assert!(healed.contains("proxy_pass http://127.0.0.2:8337/;"));
assert!(healed.contains("proxy_set_header Cookie $http_cookie;"));
assert!(healed.contains("proxy_set_header X-Forwarded-Prefix /app/archipelago-source;"));
assert!(heal_missing_source_proxy(&healed).is_none());
let snippet = "location /app/gitea/ {\n}";
let healed = heal_missing_source_proxy(snippet).expect("snippet must be patched");
assert!(healed.starts_with("# GitWorkshop follows the dashboard origin"));
}
#[test]
fn existing_source_proxy_gets_the_forwarded_mount_once() {
let stale = "location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_hide_header X-Frame-Options;\n}";
let healed = heal_source_forwarded_prefix(stale).expect("mount header must be added");
assert_eq!(
healed
.matches("X-Forwarded-Prefix /app/archipelago-source")
.count(),
1
);
assert!(heal_source_forwarded_prefix(&healed).is_none());
}
#[test]
fn nostr_signer_is_added_to_each_dashboard_server_only_once() {
let main =
"server {\n location /aiui/ {\n }\n}\nserver {\n location /aiui/ {\n }\n}";
let healed = heal_missing_nostr_signer(main).expect("signer route must be added");
assert_eq!(healed.matches("location = /nostr-signer").count(), 2);
assert!(healed.contains("frame-ancestors 'self' http://$host:* https://$host:*"));
assert!(heal_missing_nostr_signer(&healed).is_none());
assert!(heal_missing_nostr_signer("location /app/gitea/ {}\n").is_none());
}
/// The exact ExecStart framework-pt shipped with must parse, and the
/// rewrite must preserve its listen port and forward target.
#[test]
+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;
+46 -1
View File
@@ -24,6 +24,7 @@
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
//! publisher side never breaks older nodes.
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
@@ -194,6 +195,27 @@ fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
load_catalog().apps.get(app_id).cloned()
}
/// Return the cached catalog bytes only when they carry a signature anchored
/// to the release root. This is the browser App Store's source: newly signed
/// apps must appear without waiting for a frontend OTA, while unsigned or
/// self-signed registry data must never become an install button.
pub async fn verified_catalog_body(data_dir: &Path) -> anyhow::Result<String> {
let path = data_dir.join(APP_CATALOG_FILE);
let body = tokio::fs::read_to_string(&path)
.await
.with_context(|| format!("read signed app catalog {}", path.display()))?;
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Verified { anchored: true, .. } => Ok(body),
crate::trust::SignatureStatus::Verified {
anchored: false, ..
} => {
anyhow::bail!("app catalog signer is not anchored to the release root")
}
crate::trust::SignatureStatus::Unsigned => anyhow::bail!("app catalog is unsigned"),
}
}
/// Primary image for an app per the remote catalog, if covered.
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
entry_for(app_id).and_then(|e| e.image)
@@ -378,7 +400,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,
);
@@ -641,4 +663,27 @@ mod tests {
]
);
}
// The signed-catalog body served to the browser must be the anchored,
// release-root-verified bytes — and nothing else. Unsigned caches (the
// migration-window form) and self-consistent-but-unanchored signatures
// must both be refused so a tampered mirror can never become an install
// button (same posture as the OTA manifest supply-chain gate).
#[tokio::test]
async fn verified_catalog_body_rejects_unsigned_cache() {
let dir = tempfile::tempdir().unwrap();
write_cache(
dir.path(),
r#"{"schema":1,"apps":{"demo":{"version":"1"}}}"#,
)
.unwrap();
let err = verified_catalog_body(dir.path()).await.unwrap_err();
assert!(err.to_string().contains("unsigned"));
}
#[tokio::test]
async fn verified_catalog_body_rejects_missing_cache() {
let dir = tempfile::tempdir().unwrap();
assert!(verified_catalog_body(dir.path()).await.is_err());
}
}
+43 -5
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,6 +98,7 @@ 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,
_ => &[],
}
}
@@ -104,7 +106,8 @@ pub fn companions_for(package_id: &str) -> &'static [CompanionSpec] {
/// 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 +175,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();
@@ -869,6 +890,7 @@ mod tests {
"mempool-electrs",
"fedimint",
"fedimintd",
"cuprate",
];
let known: std::collections::HashSet<&str> = ALL_COMPANIONS
.iter()
@@ -893,6 +915,7 @@ mod tests {
names(&orphan_companions(&[])),
vec![
"archy-bitcoin-ui",
"archy-cuprate-ui",
"archy-electrs-ui",
"archy-fedimint-ui",
"archy-lnd-ui"
@@ -906,7 +929,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 +952,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 +1002,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 +1061,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);
}
@@ -141,6 +141,12 @@ impl DockerPackageScanner {
// Get metadata for this app
let metadata = get_app_metadata(&app_id);
// Manifest-owned metadata (icon) wins over the static table: the
// manifest is what the catalog signed and what the App Store shows,
// so it is also what an installed tile must render.
let manifest_icon = real_manifest_metadata(&app_id)
.and_then(|m| m.get("icon").and_then(|v| v.as_str()).map(str::to_string))
.filter(|s| !s.trim().is_empty());
// Resolve UI address: separate UI containers > static map > dynamic ports
let lan_address = if app_id == "netbird" {
@@ -191,7 +197,7 @@ impl DockerPackageScanner {
static_files: StaticFiles {
license: "MIT".to_string(),
instructions: metadata.description.clone(),
icon: metadata.icon.clone(),
icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()),
},
manifest: Manifest {
id: app_id.clone(),
@@ -211,28 +217,34 @@ impl DockerPackageScanner {
author: Some("Archipelago".to_string()),
website: lan_address.clone(),
tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() {
interfaces: {
// `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the
// Services tab can still show where a backend lives.
// port. A DECLARED UI classifies the app as launchable
// even when no reachable address was confirmed this
// scan — the launch button falls back to the static
// port map, and burying a manifest-declared UI app
// (Alby Hub) in Services because a probe missed was
// exactly the classification bug this fixes.
let has_ui = super::ui_detection::has_web_ui(
&app_id,
lan_address.as_deref(),
package_state == PackageState::Running,
)
.await;
Some(Interfaces {
main: Some(MainInterface {
ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(),
lan_config: None,
}),
})
} else {
None
if lan_address.is_some() || tor_address.is_some() || has_ui {
Some(Interfaces {
main: Some(MainInterface {
ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(),
lan_config: None,
}),
})
} else {
None
}
},
},
available_update,
@@ -322,6 +334,47 @@ fn is_transient_podman_helper(app_id: &str, ports: &[String]) -> bool {
&& right.chars().all(|c| c.is_ascii_lowercase())
}
/// Raw `metadata` block of an installed app's real manifest — catalog overlay
/// first (origin-wins), disk manifest as fallback. Kept as raw JSON because
/// the typed `AppManifest` deliberately does not model `metadata`, yet its
/// `icon` is what makes an installed app's tile render the right icon on
/// every surface (My Apps, Services, launcher, companion) instead of the
/// generic A-mark — the exact regression Cuprate exposed on install.
fn real_manifest_metadata(app_id: &str) -> Option<serde_json::Value> {
for (id, value) in crate::container::app_catalog::catalog_manifest_values() {
if id == app_id {
return value.get("app").and_then(|a| a.get("metadata")).cloned();
}
}
let mut candidates = Vec::new();
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
candidates.push(
std::path::PathBuf::from(dir)
.join("../apps")
.join(app_id)
.join("manifest.yml"),
);
}
candidates.push(
std::path::PathBuf::from("/opt/archipelago/apps")
.join(app_id)
.join("manifest.yml"),
);
for path in candidates {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(value) = serde_yaml::from_str::<serde_json::Value>(&content) else {
continue;
};
let meta = value.get("app").and_then(|a| a.get("metadata")).cloned();
if meta.is_some() {
return meta;
}
}
None
}
fn get_app_metadata(app_id: &str) -> AppMetadata {
let mut meta = match app_id {
"bitcoin-core" => AppMetadata {
@@ -604,9 +657,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"),
@@ -853,6 +916,28 @@ 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;
+131 -51
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"),
@@ -163,7 +170,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"vaultwarden" => Some("VAULTWARDEN_IMAGE"),
"nextcloud" => Some("NEXTCLOUD_IMAGE"),
"searxng" => Some("SEARXNG_IMAGE"),
"cryptpad" => Some("CRYPTPAD_IMAGE"),
"filebrowser" => Some("FILEBROWSER_IMAGE"),
"nginx-proxy-manager" => Some("NPM_IMAGE"),
"portainer" => Some("PORTAINER_IMAGE"),
@@ -178,20 +184,11 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Nostr / VPN
"nostr-rs-relay" => Some("NOSTR_RS_RELAY_IMAGE"),
"nostr-vpn" => Some("NOSTR_VPN_IMAGE"),
"fips" => Some("FIPS_IMAGE"),
// Immich (primary = server)
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
_ => None,
@@ -214,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`),
@@ -341,13 +361,6 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> {
("immich_redis", "REDIS_IMAGE"),
("immich_server", "IMMICH_SERVER_IMAGE"),
],
"penpot" | "penpot-frontend" => vec![
("penpot-postgres", "PENPOT_POSTGRES_IMAGE"),
("penpot-valkey", "PENPOT_VALKEY_IMAGE"),
("penpot-backend", "PENPOT_BACKEND_IMAGE"),
("penpot-exporter", "PENPOT_EXPORTER_IMAGE"),
("penpot-frontend", "PENPOT_FRONTEND_IMAGE"),
],
"netbird" => vec![
("netbird", "NETBIRD_PROXY_IMAGE"),
("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"),
@@ -439,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#"
@@ -461,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"));
+217
View File
@@ -131,6 +131,10 @@ const LND_STATE_DIRS: &[&str] = &[
/// 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";
@@ -872,6 +876,188 @@ fn cert_sha256_thumbprint(pem: &str) -> Result<String> {
Ok(hex::encode_upper(Sha256::digest(&der)))
}
// ── Channel-peer watchdog ──────────────────────────────────────────────────
/// Every open channel's remote peer that is NOT currently connected.
/// Pure over LND's REST JSON so the selection can be unit-tested.
///
/// `/v1/peers` uses `pub_key`; `/v1/channels` uses `remote_pubkey` — the
/// asymmetry is LND's, not ours.
fn select_reconnect_targets(
channels: &serde_json::Value,
peers: &serde_json::Value,
) -> Vec<String> {
let connected: std::collections::HashSet<&str> = peers
.get("peers")
.and_then(|p| p.as_array())
.map(|arr| {
arr.iter()
.filter_map(|p| p.get("pub_key").and_then(|v| v.as_str()))
.collect()
})
.unwrap_or_default();
let mut targets: Vec<String> = channels
.get("channels")
.and_then(|c| c.as_array())
.map(|arr| {
arr.iter()
.filter_map(|c| c.get("remote_pubkey").and_then(|v| v.as_str()))
.filter(|pk| !connected.contains(pk))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
targets.sort();
targets.dedup();
targets
}
/// Reconnect peers of open channels that LND has not re-established on its
/// own. Returns the number of peers reconnected this pass.
///
/// LND normally reconnects channel peers after a restart — but not reliably:
/// when the restart outages are long or repeated (an app update, a node
/// reboot, reconciler churn), the peer link can stay down for hours while
/// BOTH endpoints keep flagging the channel `disabled` in the routing
/// graph. The node itself looks perfectly healthy and every payment in
/// either direction fails "no route to the recipient" — observed live on
/// framework-pt (2026-09-01): its only channel sat disabled on both policy
/// sides for ~17h after the LND 0.21.2 update, while the wallet showed
/// plenty of outbound. The channel graph is desired state; this keeps it.
///
/// Quietly returns Ok(0) when LND is not installed or its wallet is locked —
/// that is every node without LND, on every pass.
///
/// `last_attempt` throttles retries per peer (`min_retry`) so an unreachable
/// peer is not hammered every pass; the caller owns the map so the pass
/// itself stays stateless and testable.
pub(crate) async fn reconnect_disconnected_channel_peers(
last_attempt: &mut std::collections::HashMap<String, std::time::Instant>,
min_retry: std::time::Duration,
) -> Result<usize> {
let Ok(macaroon) = read_file_as_root(LND_ADMIN_MACAROON).await else {
return Ok(0); // LND not installed (or not initialized yet)
};
let macaroon_hex = hex::encode(macaroon);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(8))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client for the channel-peer watchdog")?;
let channels: serde_json::Value = client
.get(format!("{LND_REST_BASE_URL}/v1/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST: listing channels for the peer watchdog")?
.json()
.await
.context("parsing LND channel list")?;
// A locked wallet answers 503 with an error body — it parses as JSON
// with no "channels" key, which selects nothing. That is a quiet pass.
let peers: serde_json::Value = client
.get(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST: listing peers for the peer watchdog")?
.json()
.await
.context("parsing LND peer list")?;
let mut reconnected = 0usize;
for pubkey in select_reconnect_targets(&channels, &peers) {
if last_attempt
.get(&pubkey)
.is_some_and(|t| t.elapsed() < min_retry)
{
continue;
}
last_attempt.insert(pubkey.clone(), std::time::Instant::now());
// Where does the peer live? Its advertised addresses in the public
// graph. A peer with none (fully private) cannot be dialed from here
// — LND itself may still find it; we only log the gap once per pass.
// Unknown to the public graph (or the graph query failed) — nothing
// to dial on.
let Ok(node) = client
.get(format!("{LND_REST_BASE_URL}/v1/graph/node/{pubkey}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.and_then(|r| r.error_for_status())
else {
continue;
};
let Ok(node) = node.json::<serde_json::Value>().await else {
continue;
};
let addresses: Vec<String> = node
.get("node")
.and_then(|n| n.get("addresses"))
.and_then(|a| a.as_array())
.map(|arr| {
arr.iter()
.filter_map(|a| a.get("addr").and_then(|v| v.as_str()))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
if addresses.is_empty() {
tracing::warn!(
peer = %pubkey,
"LND channel peer is disconnected and advertises no address — cannot dial it; payments through this channel stay unroutable"
);
continue;
}
for addr in addresses {
let Some((host, port)) = addr.rsplit_once(':') else {
continue;
};
let Ok(port) = port.parse::<u32>() else {
continue;
};
let body = serde_json::json!({
"perm": false,
"timeout": "15s",
"addr": { "pubkey": pubkey, "host": host, "port": port },
});
match client
.post(format!("{LND_REST_BASE_URL}/v1/peers"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&body)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
reconnected += 1;
tracing::info!(
peer = %pubkey,
addr = %addr,
"reconnected a disconnected channel peer (channel was unroutable)"
);
break;
}
Ok(resp) => {
let msg = resp.text().await.unwrap_or_default();
// Already connected between our list call and now — success.
if msg.contains("already connected") {
break;
}
tracing::debug!(peer = %pubkey, addr = %addr, %msg, "channel-peer connect attempt failed");
}
Err(e) => {
tracing::debug!(peer = %pubkey, addr = %addr, error = %e, "channel-peer connect attempt failed");
}
}
}
}
Ok(reconnected)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -985,4 +1171,35 @@ mod tests {
let cands = unlock_password_candidates().await;
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
}
#[test]
fn reconnect_targets_pick_disconnected_channel_peers_only() {
// Shape captured from a live node: /v1/channels uses remote_pubkey,
// /v1/peers uses pub_key, and an offline channel's peer is simply
// absent from the peer list — that absence is the whole signal.
let channels = serde_json::json!({
"channels": [
{ "remote_pubkey": "AAA", "active": true },
{ "remote_pubkey": "BBB", "active": false },
{ "remote_pubkey": "AAA" }
]
});
let peers = serde_json::json!({ "peers": [ { "pub_key": "AAA" } ] });
let targets = select_reconnect_targets(&channels, &peers);
assert_eq!(targets, vec!["BBB".to_string()]);
}
#[test]
fn reconnect_targets_empty_without_channels_or_peers() {
// No LND wallet (503 error body), locked wallet, or an empty node:
// selects nothing, quietly.
let error_body = serde_json::json!({ "message": "locked" });
assert!(select_reconnect_targets(&error_body, &serde_json::json!({})).is_empty());
assert!(select_reconnect_targets(
&serde_json::json!({ "channels": [] }),
&serde_json::json!({ "peers": [] })
)
.is_empty());
}
}
@@ -47,8 +47,17 @@ use crate::update::host_sudo;
///
/// Keep in sync with the running fixture on .116. Centralized as a constant
/// so the rule is visible in one place and unit-testable.
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui"];
const UI_APP_IDS: &[&str] = &["bitcoin-ui", "electrs-ui", "lnd-ui", "cuprate-ui"];
const ARCHIVAL_BITCOIN_DISK_GB: u64 = 1000;
// The cuprate disk floor is `crate::constants::CUPRATE_MIN_DISK_GB` — one
// value shared with the install/start/restart/update RPC gates so boot
// reconcile can never resume below the line they refuse at.
use crate::constants::CUPRATE_MIN_DISK_GB;
fn requires_cuprate_disk(app_id: &str, disk_gb: u64) -> bool {
app_id == "cuprate" && disk_gb < CUPRATE_MIN_DISK_GB
}
/// Apps expected to exist from first boot on every node — the ONLY apps the
/// boot reconciler may install from nothing. Every other app needs
@@ -1944,6 +1953,23 @@ impl ProdContainerOrchestrator {
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
}
// Same shape as the archival-bitcoin skip above: recorded BEFORE
// ensure_running_with_mode, so the "absent" desired-state recovery
// below can never fire on this reason and undo it.
if mode == ReconcileMode::ExistingOnly && requires_cuprate_disk(&app_id, disk_gb) {
tracing::warn!(
app_id = %app_id,
disk_gb,
"cuprate needs a larger disk (no pruning support) — skipping start"
);
report.record(
&app_id,
ReconcileAction::Left("cuprate-insufficient-disk".into()),
);
crate::crash_recovery::pending_boot_start_done(&app_id);
crate::crash_recovery::pending_boot_start_done(&container_name);
continue;
}
match self.ensure_running_with_mode(&lm, mode).await {
// Desired-state recovery: the app has no container and was left
// "absent" by boot reconcile, BUT it was running at the last
@@ -3565,6 +3591,54 @@ impl ProdContainerOrchestrator {
Ok(())
}
/// Materialise IndeedHub's AES root before the generic generated-secret
/// pass. Old installers injected one known value directly into the API and
/// worker environments, so an upgrade with either consumer still present
/// must persist that value before container drift can recreate them. With
/// no existing consumer this is a fresh install and receives random bytes.
async fn ensure_indeedhub_aes_master(&self, manifest: &AppManifest) -> Result<()> {
if manifest.app.id != "indeedhub-api" {
return Ok(());
}
let secret_path = self
.secrets_dir
.join(crate::container::secrets::INDEEDHUB_AES_SECRET_NAME);
let preserve_legacy = if secret_path.exists() {
// The secret helper validates the existing file and, critically,
// refuses to replace a damaged encryption root.
false
} else {
let consumers = [
"indeedhub-api",
"indeedhub-ffmpeg",
"indeedhub-build_api_1",
"indeedhub-build_ffmpeg-worker_1",
];
self.runtime
.list_containers()
.await
.context("detecting an existing IndeedHub encryption-key consumer")?
.iter()
.any(|container| {
let name = container.name.trim_start_matches('/');
consumers.contains(&name)
})
};
if crate::container::secrets::ensure_indeedhub_aes_master_secret(
&self.secrets_dir,
preserve_legacy,
)? {
tracing::info!(
app = "indeedhub-api",
path = %secret_path.display(),
"Persisted the legacy IndeedHub encryption root for upgrade compatibility"
);
}
Ok(())
}
async fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
// Idempotency guard: partitioning already ran on this instance.
// Re-running would re-taint against an environment that no longer
@@ -3573,6 +3647,11 @@ impl ProdContainerOrchestrator {
if !manifest.app.container.secret_env_refs.is_empty() {
return Ok(());
}
// IndeedHub's data-encryption root needs an upgrade-aware first pass:
// generic generation alone would replace the fleet-wide legacy value
// and make previously encrypted data unreadable.
self.ensure_indeedhub_aes_master(manifest).await?;
// Materialise any manifest-declared generated secrets before they're
// read below. This is the single chokepoint every install/reconcile
// path funnels through, so an app's secrets exist by the time its
@@ -4588,6 +4667,27 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
let name = compute_container_name(&lm.manifest);
let mut resolved = lm.manifest.clone();
resolve_catalog_image(&mut resolved);
if resolved.app.container.build.is_none() {
if let Some(target) = &resolved.app.container.image {
if let Ok(running) = self.runtime.get_container_status(&name).await {
match crate::container::image_versions::compare_image_versions(
target,
&running.image,
) {
Some(std::cmp::Ordering::Less) => anyhow::bail!(
"Refusing to downgrade {} from {} to {} during update",
app_id,
running.image,
target
),
Some(std::cmp::Ordering::Equal) => return Ok(()),
_ => {}
}
}
}
}
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(&lm).await
@@ -4997,6 +5097,7 @@ mod tests {
calls: StdMutex<Vec<String>>,
/// container_name -> ContainerState. Absence = "doesn't exist".
containers: StdMutex<HashMap<String, ContainerState>>,
running_images: StdMutex<HashMap<String, String>>,
/// container_name -> Podman health status.
health: StdMutex<HashMap<String, String>>,
/// image_ref -> present. Absence = "not present in local storage".
@@ -5121,7 +5222,13 @@ mod tests {
health,
exit_code: None,
started_at: None,
image: "test-image".to_string(),
image: self
.running_images
.lock()
.unwrap()
.get(name)
.cloned()
.unwrap_or_else(|| "test-image".to_string()),
created: "now".to_string(),
ports: vec![],
lan_address: None,
@@ -5312,6 +5419,27 @@ app:
assert_eq!(compute_container_name(&m), "archy-electrs-ui");
let m = pull_manifest("lnd-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-lnd-ui");
let m = pull_manifest("cuprate-ui", "foo:1");
assert_eq!(compute_container_name(&m), "archy-cuprate-ui");
}
#[test]
fn cuprate_disk_gate_blocks_only_cuprate_on_small_disks() {
// 250 GB VPS class: the ~250 GiB Monero chain cannot fit and cuprate
// has no pruning — boot reconcile must leave it down.
assert!(requires_cuprate_disk("cuprate", 250));
assert!(requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB - 1));
assert!(!requires_cuprate_disk("cuprate", CUPRATE_MIN_DISK_GB));
assert!(!requires_cuprate_disk("cuprate", 1000));
// df failure in detect_disk_gb reads as 0 → fail closed at boot: a
// doomed sync is worse than a node that stays down until it can
// measure (same direction as the archival-bitcoin skip).
assert!(requires_cuprate_disk("cuprate", 0));
// Nothing else is gated here: bitcoin apps self-prune, everything
// else is irrelevant to the Monero chain.
for app_id in ["bitcoin-knots", "bitcoin-core", "electrumx", "mempool"] {
assert!(!requires_cuprate_disk(app_id, 0), "{app_id}");
}
}
#[test]
@@ -5627,6 +5755,52 @@ app:
"app:\n id: fedimint-gateway\n name: Fedimint Gateway\n version: 0.10.0\n container:\n image: x:1\n generated_secrets:\n - name: fedimint-gateway-hash\n kind: bcrypt\n secret_env:\n - key: FEDI_HASH\n secret_file: fedimint-gateway-hash\n"
}
fn indeedhub_api_manifest_yaml() -> &'static str {
"app:\n id: indeedhub-api\n name: IndeedHub API\n version: 1.0.0\n container:\n image: x:1\n generated_secrets:\n - name: indeedhub-aes-master\n kind: hex16\n secret_env:\n - key: AES_MASTER_SECRET\n secret_file: indeedhub-aes-master\n"
}
#[tokio::test]
async fn existing_indeedhub_consumer_gets_migration_compatible_root() {
let rt = Arc::new(MockRuntime::default());
rt.set_state("indeedhub-api", ContainerState::Running);
let mut orch = orch_with(rt).await;
let tmp = tempfile::TempDir::new().unwrap();
orch.set_secrets_dir(tmp.path().to_path_buf());
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let resolved = manifest
.app
.container
.secret_env_refs
.iter()
.find(|entry| entry.env_key == "AES_MASTER_SECRET")
.unwrap();
assert_eq!(resolved.value.len(), 32);
assert!(tmp.path().join("indeedhub-aes-master").exists());
assert!(
crate::container::secrets::ensure_indeedhub_aes_master_secret(tmp.path(), true).is_ok(),
"the migrated file remains valid and stable"
);
}
#[tokio::test]
async fn fresh_indeedhub_install_gets_random_root() {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt).await;
let tmp = tempfile::TempDir::new().unwrap();
orch.set_secrets_dir(tmp.path().to_path_buf());
let mut manifest = AppManifest::parse(indeedhub_api_manifest_yaml()).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let first = crate::container::secrets::indeedhub_aes_master_secret(tmp.path()).unwrap();
let other = tempfile::TempDir::new().unwrap();
crate::container::secrets::ensure_indeedhub_aes_master_secret(other.path(), false).unwrap();
let second = crate::container::secrets::indeedhub_aes_master_secret(other.path()).unwrap();
assert_ne!(first, second, "fresh installs must receive per-node roots");
}
/// FED-07. Rotating a compromised credential leaves the RUNNING container
/// holding the old value, so the rotation must flag the app for recreate.
/// Without the flag the drift check skips it as restart-sensitive and the
@@ -6625,6 +6799,41 @@ app:
assert_eq!(ids, vec!["bitcoin-knots", "bitcoin-ui"]);
}
#[tokio::test]
async fn upgrade_preserves_container_when_catalog_is_stale_or_already_installed() {
for (target, should_error) in [("v3.3.1", true), ("v3.3.1-archy1", false)] {
let rt = Arc::new(MockRuntime::default());
rt.set_state("update-regression", ContainerState::Running);
rt.running_images.lock().unwrap().insert(
"update-regression".into(),
"registry.test/old/mempool-frontend:v3.3.1-archy1".into(),
);
let orch = orch_with(rt.clone()).await;
orch.insert_manifest_for_test(
pull_manifest(
"update-regression",
&format!("registry.test/new/mempool-frontend:{target}"),
),
PathBuf::from("/tmp/update-regression"),
)
.await;
assert_eq!(
orch.upgrade("update-regression").await.is_err(),
should_error
);
assert!(
!rt.calls()
.iter()
.any(|call| call.starts_with("stop_container:")
|| call.starts_with("remove_container:")
|| call.starts_with("pull_image:")
|| call.starts_with("create_container:")),
"{:?}",
rt.calls()
);
}
}
#[tokio::test]
async fn upgrade_removes_and_reinstalls() {
let rt = Arc::new(MockRuntime::default());
+130
View File
@@ -140,6 +140,79 @@ fn random_base64(bytes: usize) -> String {
/// daemon read `fedimint-gateway-hash`).
pub const GATEWAY_HASH_SECRET_NAME: &str = "fedimint-gateway-hash";
/// Canonical filename for IndeedHub's envelope-encryption root. API and media
/// worker must receive the same stable value: changing it after data has been
/// encrypted can make that data unreadable.
pub const INDEEDHUB_AES_SECRET_NAME: &str = "indeedhub-aes-master";
/// The fleet-wide value used by the legacy IndeedHub installers. It remains
/// here only for the one-way migration of an already-installed stack: those
/// nodes must persist the value they have been using before the manifest
/// starts reading it from a file. Fresh installs must never receive it.
const KNOWN_LEGACY_INDEEDHUB_AES_MASTER: &str = "0123456789abcdef0123456789abcdef";
/// Ensure IndeedHub has a stable encryption root.
///
/// `preserve_legacy` is true only when an API/worker container already exists,
/// proving this is an upgrade from the installer that shipped the known legacy
/// value. In that case we persist that value once so recreating the containers
/// does not orphan encrypted data. A fresh installation gets 16 random bytes
/// encoded as 32 hex characters.
///
/// Unlike ordinary generated credentials, an existing-but-empty or unreadable
/// encryption root is never self-healed by rotation: replacement could destroy
/// access to data, so this fails loudly and leaves the file untouched.
/// Returns true only when the legacy migration value was written.
pub fn ensure_indeedhub_aes_master_secret(
secrets_dir: &Path,
preserve_legacy: bool,
) -> Result<bool> {
fs::create_dir_all(secrets_dir)
.with_context(|| format!("creating secrets dir {}", secrets_dir.display()))?;
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
if path.exists() {
let value = fs::read_to_string(&path).with_context(|| {
format!(
"reading IndeedHub encryption root {} (refusing to replace it)",
path.display()
)
})?;
if value.trim().is_empty() {
anyhow::bail!(
"IndeedHub encryption root {} is empty; refusing to replace a potentially \
data-bearing key",
path.display()
);
}
return Ok(false);
}
if preserve_legacy {
write_secret(&path, KNOWN_LEGACY_INDEEDHUB_AES_MASTER)?;
return Ok(true);
}
let spec = GeneratedSecret {
name: INDEEDHUB_AES_SECRET_NAME.to_string(),
kind: SecretGenKind::Hex16,
};
ensure_one(secrets_dir, &spec)?;
Ok(false)
}
/// Read the stable IndeedHub encryption root after it has been materialised.
pub fn indeedhub_aes_master_secret(secrets_dir: &Path) -> Result<String> {
let path = secrets_dir.join(INDEEDHUB_AES_SECRET_NAME);
let value = fs::read_to_string(&path)
.with_context(|| format!("reading IndeedHub encryption root {}", path.display()))?;
let value = value.trim();
if value.is_empty() {
anyhow::bail!("IndeedHub encryption root {} is empty", path.display());
}
Ok(value.to_string())
}
/// Detection-only denylist of bcrypt hashes that shipped as hardcoded
/// fallback credentials in this repository before FED-07. `t9YjjxkiktrlYvjajB
/// /zgOMDnSNVg4HqrbDqh47u7Jf42whNdxNqC` was substituted for the Fedimint
@@ -356,6 +429,63 @@ mod tests {
);
}
#[test]
fn indeedhub_fresh_installs_get_distinct_per_node_encryption_roots() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
assert!(!ensure_indeedhub_aes_master_secret(dir_a.path(), false).unwrap());
assert!(!ensure_indeedhub_aes_master_secret(dir_b.path(), false).unwrap());
let value_a = indeedhub_aes_master_secret(dir_a.path()).unwrap();
let value_b = indeedhub_aes_master_secret(dir_b.path()).unwrap();
assert_eq!(value_a.len(), 32);
assert!(value_a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(value_a, KNOWN_LEGACY_INDEEDHUB_AES_MASTER);
assert_ne!(value_a, value_b, "fresh nodes must not share an AES root");
let mode = std::fs::metadata(dir_a.path().join(INDEEDHUB_AES_SECRET_NAME))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn indeedhub_existing_install_persists_legacy_root_once() {
let dir = tempfile::tempdir().unwrap();
assert!(ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
assert_eq!(
indeedhub_aes_master_secret(dir.path()).unwrap(),
KNOWN_LEGACY_INDEEDHUB_AES_MASTER
);
assert!(
!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap(),
"a second migration pass must be a no-op"
);
}
#[test]
fn indeedhub_existing_unique_root_is_never_rotated() {
let dir = tempfile::tempdir().unwrap();
ensure_indeedhub_aes_master_secret(dir.path(), false).unwrap();
let before = indeedhub_aes_master_secret(dir.path()).unwrap();
assert!(!ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap());
assert_eq!(before, indeedhub_aes_master_secret(dir.path()).unwrap());
}
#[test]
fn indeedhub_empty_root_fails_without_overwriting() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(INDEEDHUB_AES_SECRET_NAME);
std::fs::write(&path, "").unwrap();
let err = ensure_indeedhub_aes_master_secret(dir.path(), true).unwrap_err();
assert!(err.to_string().contains("refusing to replace"));
assert_eq!(std::fs::read(&path).unwrap(), b"");
}
#[test]
fn gateway_credential_fresh_generation_verifies_and_is_0600() {
let dir = tempfile::tempdir().unwrap();
+3 -3
View File
@@ -6,7 +6,7 @@
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 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,
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 18091, 23000, 32838, 50002,
];
+8
View File
@@ -305,6 +305,14 @@ pub async fn install(identity_dir: &Path) -> Result<()> {
}
}
// SSH-over-mesh rides every config install so the on-state survives
// upgrades, reconnects, and the startup self-heal (see ssh_mesh.rs —
// this module owns the 90-ssh.nft slot exclusively).
let ssh_data_dir = identity_dir.parent().unwrap_or(identity_dir);
if let Err(e) = super::ssh_mesh::reconcile(ssh_data_dir).await {
tracing::warn!("ssh-over-mesh reconcile after config install failed (non-fatal): {e:#}");
}
sudo_install_file(&src_key, DAEMON_KEY_PATH, "0600").await?;
// Heal a legacy fips_key.pub that was written as bech32 npub text
// (pre-fix identity::write_fips_key_from_seed did this). Upstream
+1
View File
@@ -32,6 +32,7 @@ pub mod dial;
pub mod endpoints;
pub mod iface;
pub mod service;
pub mod ssh_mesh;
pub mod telemetry;
pub mod update;
+492
View File
@@ -0,0 +1,492 @@
//! SSH over the FIPS mesh — a first-class settings toggle.
//!
//! `fips0` is default-deny inbound: the hardening baseline (`/etc/fips/
//! fips.nft`) rejects un-allowlisted ports, and the daemon's own drop-ins
//! (`80-web-ui.nft`, `85-app-ports.nft`) do not include 22. That is correct
//! by default — but the user asked to be able to SSH their node from Termux
//! over the phone's FIPS mesh instead of keeping a second VPN around for it,
//! and the mesh path already works end-to-end (verified live: the connect
//! reaches fips0 and gets a RST from the node).
//!
//! This module owns the whole lifecycle of the `90-ssh.nft` drop-in, exactly
//! the way `config.rs` owns `80-web-ui.nft` — a hand-added rule and this
//! feature can never fight over the same slot:
//!
//! * toggle OFF → drop-in removed, port 22 refused again
//! * toggle ON → drop-in written on every toggle change AND on every
//! daemon config install (upgrade, reconnect, self-heal),
//! so the on-state survives reinstalls idempotently
//! * scope → "any" (every mesh peer — a real exposure, gated in the
//! UI behind an explicit confirmation) or an explicit list
//! of mesh addresses
//!
//! Nothing else is touched: `80-web-ui.nft` / `85-app-ports.nft` belong to
//! `config.rs`, and the sshd process itself is entirely the operator's.
use std::net::Ipv6Addr;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
/// On-disk state under the archipelago data dir. Absent file = disabled,
/// which is the safe default for every node that never touched the toggle.
const STATE_FILE: &str = "fips-ssh-over-mesh.json";
/// The drop-in slot this module owns. 90 sorts after the daemon's own
/// drop-ins (80/85) so a human reading the directory sees the deliberate
/// order; the include order does not change semantics for plain accepts.
pub const DROPIN_PATH: &str = "/etc/fips/fips.d/90-ssh.nft";
/// The hardening baseline this drop-in hangs off. Same file `config.rs`
/// reloads after its own drop-ins.
const FIPS_NFT: &str = "/etc/fips/fips.nft";
/// Persisted toggle state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SshMeshState {
/// Whether port 22 is allowed through the fips0 baseline at all.
#[serde(default)]
pub enabled: bool,
/// Mesh addresses (ULAs) the rule is restricted to. Empty = any mesh
/// peer. Kept as strings as-entered but validated as IPv6 on save.
#[serde(default)]
pub sources: Vec<String>,
}
fn state_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(STATE_FILE)
}
/// Load the persisted state. Missing file = disabled, no sources — never an
/// error, so a fresh node and a deleted file both mean "off".
pub async fn load(data_dir: &Path) -> SshMeshState {
match tokio::fs::read_to_string(state_path(data_dir)).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SshMeshState::default(),
}
}
/// Validate and normalise an operator-supplied source list. Every entry must
/// be a parseable IPv6 address (mesh addresses are full ULAs, not CIDRs) —
/// anything else is refused with the offending entry named, so a typo can
/// never silently narrow or widen the rule.
pub fn validate_sources(raw: &[String]) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(raw.len());
for entry in raw {
let trimmed = entry.trim();
if trimmed.is_empty() {
continue;
}
let addr: Ipv6Addr = trimmed
.parse()
.with_context(|| format!("not a valid mesh (IPv6) address: {trimmed:?}"))?;
out.push(addr.to_string());
}
out.dedup();
Ok(out)
}
/// Render the nft drop-in for a state. The rule shape mirrors the interim
/// manual unblock from the field notes (`ip6 saddr <ula> tcp dport 22
/// accept`) — an unrestricted rule is the same statement without the saddr.
pub fn render_dropin(state: &SshMeshState) -> String {
let mut out = String::from(
"# Written by archipelago — SSH over mesh (Settings → SSH over mesh).\n\
# Allows sshd (port 22) through the fips0 default-deny inbound\n\
# baseline. Remove = refused again; never edit 80/85-* by hand.\n",
);
if state.sources.is_empty() {
out.push_str("tcp dport 22 accept\n");
} else {
out.push_str(&format!(
"ip6 saddr {{ {} }} tcp dport 22 accept\n",
state.sources.join(", ")
));
}
out
}
/// Write or remove the drop-in to match the persisted state, then reload the
/// baseline so the change is live immediately. Returns whether a reload was
/// attempted and succeeded — a node without the hardening baseline has
/// nothing to reload (port 22 is governed by sshd and the host firewall
/// there), which is reported rather than treated as failure.
pub async fn reconcile(data_dir: &Path) -> Result<ReconcileOutcome> {
let state = load(data_dir).await;
if !state.enabled {
let removed = remove_dropin().await?;
let reloaded = reload_nft().await;
return Ok(ReconcileOutcome {
applied: false,
removed,
reloaded,
});
}
// Ensure /etc/fips/fips.d exists, exactly like config::install.
let out = Command::new("sudo")
.args(["install", "-d", "-m", "0755", "/etc/fips/fips.d"])
.output()
.await
.context("sudo install -d /etc/fips/fips.d")?;
if !out.status.success() {
anyhow::bail!(
"sudo install -d /etc/fips/fips.d failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let dropin = render_dropin(&state);
let stage = std::env::temp_dir().join(format!("fips-ssh-{}.nft", std::process::id()));
tokio::fs::write(&stage, &dropin)
.await
.context("stage ssh nft drop-in")?;
let install = Command::new("sudo")
.args(["install", "-m", "0644"])
.arg(&stage)
.arg(DROPIN_PATH)
.output()
.await;
let _ = tokio::fs::remove_file(&stage).await;
let install = install?;
if !install.status.success() {
anyhow::bail!(
"install {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&install.stderr).trim()
);
}
let reloaded = reload_nft().await;
Ok(ReconcileOutcome {
applied: true,
removed: false,
reloaded,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReconcileOutcome {
/// The allow rule is in place.
pub applied: bool,
/// A previously-written drop-in was removed this call.
pub removed: bool,
/// The hardening baseline existed and `nft -f` succeeded.
pub reloaded: bool,
}
async fn remove_dropin() -> Result<bool> {
match tokio::fs::try_exists(DROPIN_PATH).await {
Ok(true) => {}
_ => return Ok(false),
}
let out = Command::new("sudo")
.args(["rm", "-f", DROPIN_PATH])
.output()
.await
.context("sudo rm 90-ssh.nft")?;
if !out.status.success() {
anyhow::bail!(
"removing {} failed: {}",
DROPIN_PATH,
String::from_utf8_lossy(&out.stderr).trim()
);
}
tracing::info!("ssh-over-mesh: drop-in removed — port 22 refused over fips0 again");
Ok(true)
}
/// Reload the hardening baseline. Best-effort in the same spirit as
/// `config.rs`: absent baseline (nothing to reload) → Ok(false); a failed
/// reload is Ok(false) with a warn, never an error — the drop-in is on disk
/// either way and the next daemon install reloads it.
async fn reload_nft() -> bool {
match tokio::fs::try_exists(FIPS_NFT).await {
Ok(true) => {}
_ => return false,
}
match Command::new("sudo")
.args(["nft", "-f", FIPS_NFT])
.output()
.await
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tracing::warn!(
"ssh-over-mesh: nft reload failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
false
}
Err(e) => {
tracing::warn!("ssh-over-mesh: nft reload failed: {e}");
false
}
}
}
/// Persist new state and reconcile immediately. Validation happens here so
/// an invalid source list can never reach disk, and reconcile reads back
/// exactly what was saved.
pub async fn set(
data_dir: &Path,
enabled: bool,
sources: &[String],
) -> Result<(SshMeshState, ReconcileOutcome)> {
let state = SshMeshState {
enabled,
sources: validate_sources(sources)?,
};
tokio::fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir -p {}", data_dir.display()))?;
tokio::fs::write(state_path(data_dir), serde_json::to_string_pretty(&state)?)
.await
.with_context(|| format!("write {}", state_path(data_dir).display()))?;
let outcome = reconcile(data_dir).await?;
Ok((state, outcome))
}
/// Preflights surfaced in the settings card. None of these gate the toggle —
/// they explain it: writing the rule on a node whose sshd doesn't listen on
/// IPv6 simply has no effect until sshd does, and the card says so instead of
/// the user discovering it as a silent connection failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SshPreflights {
/// ssh.service (or sshd.service) is active.
pub sshd_active: bool,
/// Something listens on :22 for IPv6 (`[::]:22` or a dual-stack `*:22`).
/// fips0 is IPv6-only, so a 0.0.0.0-bound sshd is unreachable over it.
pub sshd_ipv6_listen: bool,
/// sshd_config's PasswordAuthentication (last directive wins, includes
/// after the main file). None = not found / unreadable.
pub password_auth: Option<bool>,
}
pub async fn preflights() -> SshPreflights {
SshPreflights {
sshd_active: sshd_active().await,
sshd_ipv6_listen: sshd_ipv6_listen().await,
password_auth: password_auth_enabled().await,
}
}
async fn sshd_active() -> bool {
for unit in ["ssh", "sshd"] {
if let Ok(out) = Command::new("systemctl")
.args(["is-active", "--quiet", unit])
.output()
.await
{
if out.status.success() {
return true;
}
}
}
false
}
async fn sshd_ipv6_listen() -> bool {
let Ok(out) = Command::new("ss").args(["-H", "-tln"]).output().await else {
return false;
};
let text = String::from_utf8_lossy(&out.stdout);
text.lines().any(|line| {
let mut cols = line.split_whitespace();
// -t -l: State Recv-Q Send-Q Local:Port Peer:Port → local is col 4.
let _state = cols.next();
let _recv = cols.next();
let _send = cols.next();
match cols.next() {
Some(local) => {
let port_ok = local.rsplit(':').next() == Some("22");
let v6 = local.starts_with("[::]") || local.starts_with('*');
port_ok && v6
}
None => false,
}
})
}
async fn password_auth_enabled() -> Option<bool> {
let mut directives: Vec<bool> = Vec::new();
if let Ok(main) = tokio::fs::read_to_string("/etc/ssh/sshd_config").await {
collect_password_auth(&main, &mut directives);
}
if let Ok(includes) = glob_sorted("/etc/ssh/sshd_config.d/*.conf").await {
for path in includes {
if let Ok(content) = tokio::fs::read_to_string(&path).await {
collect_password_auth(&content, &mut directives);
}
}
}
directives.pop()
}
fn collect_password_auth(content: &str, out: &mut Vec<bool>) {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("PasswordAuthentication") {
let rest = rest.trim_start();
let value = rest.split_whitespace().next().unwrap_or("");
if value.eq_ignore_ascii_case("yes") {
out.push(true);
} else if value.eq_ignore_ascii_case("no") {
out.push(false);
}
}
}
}
async fn glob_sorted(pattern: &str) -> Result<Vec<std::path::PathBuf>> {
let dir = std::path::Path::new(pattern)
.parent()
.unwrap_or_else(|| Path::new("/"));
let prefix = std::path::Path::new(pattern)
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.split('.').next())
.unwrap_or("")
.to_string();
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut entries = tokio::fs::read_dir(dir)
.await
.context("read sshd_config.d")?;
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".conf") {
files.push(entry.path());
}
}
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_is_the_default_and_missing_file_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
let state = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert!(!state.enabled);
assert!(state.sources.is_empty());
}
#[test]
fn any_peer_dropin_is_an_unrestricted_accept() {
let state = SshMeshState {
enabled: true,
sources: vec![],
};
let out = render_dropin(&state);
assert!(out.contains("tcp dport 22 accept"));
assert!(!out.contains("ip6 saddr"), "no saddr restriction expected");
}
#[test]
fn source_list_dropin_restricts_to_those_addresses() {
let state = SshMeshState {
enabled: true,
sources: vec![
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
"fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824".to_string(),
],
};
let out = render_dropin(&state);
assert!(out.contains("ip6 saddr { fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586, fd79:1aa:b9e9:4c9f:1f80:5376:9385:1824 } tcp dport 22 accept"));
}
#[test]
fn sources_must_be_ipv6_and_are_normalised() {
let bad = validate_sources(&["192.168.1.5".to_string()]).unwrap_err();
assert!(bad.to_string().contains("192.168.1.5"));
let bad = validate_sources(&["not-an-address".to_string()]).unwrap_err();
assert!(bad.to_string().contains("not-an-address"));
// Uppercase/whitespace entries normalise to canonical lowercase.
let ok = validate_sources(&[
" FD68:496D:FE34:A06D:0CF1:06E4:B6A4:3586 ".to_string(),
"fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string(),
String::new(),
])
.unwrap();
assert_eq!(
ok,
vec!["fd68:496d:fe34:a06d:cf1:6e4:b6a4:3586".to_string()]
);
}
#[test]
fn state_round_trips_through_disk() {
let dir = tempfile::tempdir().unwrap();
let state = SshMeshState {
enabled: true,
sources: vec!["fd00::1".to_string()],
};
std::fs::write(
dir.path().join(STATE_FILE),
serde_json::to_string(&state).unwrap(),
)
.unwrap();
let loaded = tokio::runtime::Runtime::new()
.unwrap()
.block_on(load(dir.path()));
assert_eq!(loaded, state);
}
#[test]
fn set_validates_before_persisting() {
let dir = tempfile::tempdir().unwrap();
let rt = tokio::runtime::Runtime::new().unwrap();
let err = rt
.block_on(set(dir.path(), true, &["bogus".to_string()]))
.unwrap_err();
assert!(err.to_string().contains("bogus"));
// Nothing was persisted.
let state = rt.block_on(load(dir.path()));
assert!(!state.enabled);
}
#[test]
fn preflight_parse_helpers_cover_the_directives() {
let mut directives = Vec::new();
collect_password_auth(
"# comment\nPasswordAuthentication yes\nMatch all\n PasswordAuthentication no\n",
&mut directives,
);
assert_eq!(directives, vec![true, false]);
}
#[test]
fn sshd_ipv6_listen_recognises_dual_stack_and_v6_only() {
assert!(line_listens("[::]:22"));
assert!(line_listens("*:22"));
assert!(!line_listens("0.0.0.0:22"));
assert!(!line_listens("[::]:80"));
}
fn line_listens(local: &str) -> bool {
let line = format!("LISTEN 0 128 {local} 0.0.0.0:*");
let mut cols = line.split_whitespace();
cols.next();
cols.next();
cols.next();
match cols.next() {
Some(l) => {
let port_ok = l.rsplit(':').next() == Some("22");
let v6 = l.starts_with("[::]") || l.starts_with('*');
port_ok && v6
}
None => false,
}
}
}
+2 -2
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,
+5
View File
@@ -413,6 +413,11 @@ async fn main() -> Result<()> {
// delays server readiness; best-effort, warnings only.
tokio::spawn(bootstrap::ensure_doctor_installed());
// Dashboard-only updates can replace the NIP-07 provider without
// recreating a running IndeedHub container. Reconcile its injected copy on
// every daemon start so tab signing never remains pinned to an old asset.
tokio::spawn(api::rpc::patch_indeedhub_nostr_provider());
// B17: heal already-deployed nodes whose archipelago.service lacks a mount
// dependency on the data volume, so cold boots stop flapping. Boot-ordering
// only — effective next reboot; never restarts the running service.
+31
View File
@@ -841,6 +841,37 @@ impl Server {
});
}
// LND channel-peer watchdog — every 2 minutes, reconnect the peers
// of open channels that LND has not re-established on its own. LND's
// reconnect logic gives up with a long backoff after repeated or
// extended downtime (an app update, a reboot, reconciler churn), and
// while the peer link is down BOTH endpoints keep the channel flagged
// `disabled` in the routing graph — payments fail "no route" in both
// directions while the node itself looks perfectly healthy. The
// channel graph is desired state; this keeps it (framework-pt,
// 2026-09-01: only channel unroutable ~17h after the 0.21.2 update).
// No-ops quietly on nodes without LND. Per-peer retries are throttled
// to 10 minutes so an unreachable peer is not hammered every pass.
{
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(120));
let mut last_attempt: HashMap<String, Instant> = HashMap::new();
loop {
interval.tick().await;
match crate::container::lnd::reconnect_disconnected_channel_peers(
&mut last_attempt,
Duration::from_secs(600),
)
.await
{
Ok(0) => {}
Ok(n) => info!(n, "LND channel-peer watchdog reconnected channel peers"),
Err(e) => debug!("LND channel-peer watchdog (non-fatal): {}", e),
}
}
});
}
// FIPS seed-anchor apply loop — every 5 minutes we re-push the
// configured seed anchors into the running fips daemon via
// `fipsctl connect`. This keeps the mesh bootstrap resilient:
+40
View File
@@ -22,6 +22,46 @@ use crate::wallet::ecash;
///
/// Returns the total sats swept in (0 if there was nothing to do, including
/// when no router is configured or it doesn't have TollGate installed).
///
/// # KNOWN BROKEN as of 2026-09-07 — do not "fix" by adding `--json` without
/// reading the rest of this comment first.
///
/// Confirmed live against archy-x250-pa3, two stacked bugs in the upstream
/// `tollgate` CLI, not in this function:
///
/// 1. **This call never actually drains anything.** `tollgate wallet drain
/// cashu` (no flags — what this function runs) prints an interactive
/// `Are you sure? (y/N)` confirmation and reads stdin for the answer.
/// `Router::run` executes over SSH with no PTY and empty stdin, so it
/// always reads EOF, defaults to "N", and prints "Operation cancelled." —
/// **with exit code 0**. The `drain_code != 0` check below can never catch
/// this, so every single tick silently falls through to "no `Token:`
/// lines found" → `Ok(0)`. No error, no log line (even at `warn!`), just
/// quiet total inaction, forever. This has presumably never swept a
/// single sat on any node.
///
/// 2. **The obvious fix is worse.** `tollgate --json wallet drain cashu`
/// *does* skip the confirmation prompt — but confirmed live: when the
/// wallet's internal per-mint registry holds more than one entry for what
/// is really the same mint (here: `https://mint.minibits.cash/Bitcoin` vs.
/// a stale `.../Bitcoin/` — leftover from before the trailing-slash
/// `mint_url` fix elsewhere in this codebase; `wallet.db` still had a
/// proof/registry entry keyed under the old slashed URL even after
/// `config.json` was corrected), the CLI appears to complete a real swap
/// against the *good* entry — spending and irreversibly consuming the
/// original proofs, per how Cashu swaps work — then hits the second,
/// empty, stale-keyed entry, reports the whole command as
/// `"success": false`, and **never prints or persists the resulting
/// token anywhere** (checked every location its own "will be saved to a
/// file" warning implies: `/etc/tollgate/ecash/`, `/root`, `/tmp`,
/// nothing). Balance went from 50 sats to 0 across that one call. The
/// funds are gone — there is no undo once a swap is submitted to the
/// mint.
///
/// Do not wire `--json` into this function until upstream fixes partial
/// per-mint failure handling in `drain cashu` to preserve/return whatever it
/// already successfully drained. Until then, the current silent-no-op
/// behavior, while useless, is at least safe.
pub async fn sweep_once(data_dir: &Path) -> Result<u64> {
let cfg = net_router::load_router_config(data_dir).await?;
if !cfg.configured {
+35 -20
View File
@@ -2159,20 +2159,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 +2193,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 +2211,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,
+86
View File
@@ -207,7 +207,15 @@ impl CashuToken {
}
/// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string.
///
/// Trims surrounding whitespace first: a token can arrive with stray
/// leading/trailing whitespace from a clipboard paste, or (confirmed
/// live, 2026-09-08) from Minibits' own NIP-04 claim-DM content, which
/// has a trailing space after the base64 — none of the base64 alphabets
/// in `decode_token_base64` tolerate that, so an otherwise-valid token
/// would hard-fail with "Invalid base64" instead of parsing.
pub fn deserialize(token_str: &str) -> Result<Self> {
let token_str = token_str.trim();
if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) {
return Self::deserialize_v4(payload);
}
@@ -481,6 +489,45 @@ pub fn amount_to_denominations(mut amount: u64) -> Vec<u64> {
mod tests {
use super::*;
/// A v4 (cashuB) token always carries a v2 keyset id in its short
/// (8-byte) form — confirmed against the real cashu 0.17.5 crate
/// (`TokenV4Token`'s `serialize_v4_keyset_id` unconditionally narrows to
/// `ShortKeysetId`) and live against mint.minibits.cash (2026-09-18).
/// That is spec-compliant, not a bug here: a receiver MUST resolve the
/// short id against the mint's keyset list before spending it (see
/// `MintClient::resolve_truncated_keyset_ids`, and its missing call site
/// that this exact round trip caught in `ecash.rs`'s payment-receive
/// path). This test documents that the short form is what actually
/// crosses the wire, so nobody re-"fixes" serialize_v4 to defeat it.
#[test]
fn v4_round_trip_shortens_a_v2_keyset_id_by_design() {
let real_v2_id = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
assert_eq!(real_v2_id.len(), 66);
let token = CashuToken {
token: vec![TokenEntry {
mint: "https://mint.minibits.cash/Bitcoin".to_string(),
proofs: vec![Proof {
amount: 2,
id: real_v2_id.to_string(),
secret: "abcdef1234567890".to_string(),
// secp256k1 generator point G — a genuinely valid
// compressed pubkey (the other tests' placeholder C
// value is not, and serialize_v4 is the first path
// here that actually parses it).
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.to_string(),
}],
}],
memo: None,
unit: Some("sat".to_string()),
};
let v4 = token.serialize_v4().expect("serialize_v4 should accept a real v2 id");
let decoded = CashuToken::deserialize(&v4).unwrap();
let got_id = &decoded.token[0].proofs[0].id;
assert_eq!(got_id, "01fc0ec0e59cd6fa", "expected the short (8-byte) v2 form on the wire");
assert!(is_truncated_v2_keyset_id(got_id));
}
#[test]
fn test_serialize_deserialize_roundtrip() {
let token = CashuToken {
@@ -508,6 +555,45 @@ mod tests {
assert_eq!(decoded.memo, Some("test token".to_string()));
}
/// Regression guard (2026-09-08): a real Minibits claim DM decrypted to
/// a cashuB token with a trailing space after the base64 payload, which
/// made every base64 alphabet in `decode_token_base64` reject it as
/// invalid — three real payments got stuck retrying forever with
/// "Invalid base64 in cashuB token" until `deserialize` started
/// trimming the whole string first. Whitespace can show up around a
/// token from more than one source (clipboard paste included), so this
/// covers cashuA too, and leading as well as trailing.
#[test]
fn deserialize_trims_stray_whitespace() {
let token = CashuToken {
token: vec![TokenEntry {
mint: "http://127.0.0.1:8175".to_string(),
proofs: vec![Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
.to_string(),
}],
}],
memo: None,
unit: Some("sat".to_string()),
};
let encoded = token.serialize().unwrap();
assert!(encoded.starts_with("cashuA"));
for wrapped in [
format!("{encoded} "),
format!(" {encoded}"),
format!(" {encoded}\n"),
format!("{encoded}\t"),
] {
let decoded = CashuToken::deserialize(&wrapped)
.unwrap_or_else(|e| panic!("failed on {wrapped:?}: {e}"));
assert_eq!(decoded.total_amount(), 8);
}
}
#[test]
fn test_total_amount_multi_proof() {
let token = CashuToken {
+43 -6
View File
@@ -1205,6 +1205,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 {
@@ -1217,6 +1218,7 @@ pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
}
Err(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 +1226,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 +1242,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> {
@@ -1353,14 +1363,29 @@ pub async fn verify_and_receive_payment(
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 {
// The reference cashu crate's V4 (cashuB) encoder always writes a
// NUT-02 v2 keyset id in its short (8-byte) form — confirmed live
// against mint.minibits.cash (2026-09-18): every cashuB payment
// carrying that mint's active v2 keyset failed verification with a
// bare 422 "NUT02: ID length invalid" because this call skipped
// straight to swap() with the short id still attached. MintClient's
// own receive_token() already resolves this correctly; this is the
// same fix, just not routed through it (the loop here also tracks
// received_total/mint-scoped errors that receive_token() doesn't).
let proofs = client.resolve_truncated_keyset_ids(&entry.proofs).await;
match client.swap(&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);
// {:#} walks the full anyhow context chain, including the raw
// mint response body `mint_error()` attaches as the cause —
// {} prints only the friendly top-level message and silently
// discards the one thing that would explain a bare 422.
warn!("Payment verification failed at mint {}: {:#}", entry.mint, e);
}
}
}
@@ -1632,6 +1657,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;
File diff suppressed because it is too large Load Diff
+125 -12
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.",
@@ -96,27 +114,68 @@ fn describe_mint_error_code(code: i64) -> Option<&'static str> {
})
}
/// Render a FastAPI-style validation error list — `detail` as an array of
/// `{"loc": [...], "msg": "...", "type": "..."}` objects — into one line per
/// entry. This is the shape FastAPI (and therefore most Cashu mint
/// implementations, including Nutshell) actually sends for a 422, not the
/// plain string the rest of this file otherwise expects; without this a
/// mint's real reason (e.g. `body -> inputs -> 0 -> id: NUT02: ID length
/// invalid`) was silently replaced with "no further detail".
fn describe_validation_errors(detail: &serde_json::Value) -> Option<String> {
let items = detail.as_array()?;
if items.is_empty() {
return None;
}
let lines: Vec<String> = items
.iter()
.filter_map(|item| {
let msg = item.get("msg").and_then(|m| m.as_str())?;
let loc = item
.get("loc")
.and_then(|l| l.as_array())
.map(|parts| {
parts
.iter()
.map(|p| p.as_str().map(str::to_string).unwrap_or_else(|| p.to_string()))
.collect::<Vec<_>>()
.join(" -> ")
})
.unwrap_or_default();
Some(if loc.is_empty() {
msg.to_string()
} else {
format!("{loc}: {msg}")
})
})
.collect();
(!lines.is_empty()).then(|| lines.join("; "))
}
/// Parse a mint's error body (`{"code": N, "detail": "..."}`) and pick the
/// best user-facing message: the plain-language translation when we know the
/// code, otherwise the mint's own `detail` text, otherwise the raw body.
/// code, otherwise the mint's own `detail` text (a plain string, or a
/// FastAPI-style validation-error array), otherwise the raw body.
fn describe_mint_error_body(status: reqwest::StatusCode, body: &str) -> String {
let parsed: Option<serde_json::Value> = serde_json::from_str(body).ok();
let code = parsed
.as_ref()
.and_then(|v| v.get("code"))
.and_then(|c| c.as_i64());
let detail = parsed
.as_ref()
.and_then(|v| v.get("detail"))
.and_then(|d| d.as_str());
let detail = parsed.as_ref().and_then(|v| v.get("detail"));
if let Some(friendly) = code.and_then(describe_mint_error_code) {
return friendly.to_string();
}
match detail {
Some(d) if !d.is_empty() => d.to_string(),
_ => format!("mint returned {} with no further detail", status),
if let Some(d) = detail {
if let Some(s) = d.as_str() {
if !s.is_empty() {
return s.to_string();
}
} else if let Some(rendered) = describe_validation_errors(d) {
return rendered;
}
}
format!("mint returned {} with no further detail", status)
}
/// Build the error for a failed mint HTTP call: `op` + status + raw body as
@@ -124,8 +183,15 @@ 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))
}
/// HTTP client for a single Cashu mint.
@@ -717,7 +783,7 @@ impl MintClient {
/// 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> {
pub(crate) async fn resolve_truncated_keyset_ids(&self, proofs: &[Proof]) -> Vec<Proof> {
let needs_repair = proofs.iter().any(|p| is_truncated_v2_keyset_id(&p.id));
if !needs_repair {
return proofs.to_vec();
@@ -803,6 +869,53 @@ 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>()
);
}
#[test]
fn a_fastapi_validation_error_array_is_rendered_not_swallowed() {
// FastAPI's actual 422 shape — `detail` is a list of
// {loc, msg, type}, not the plain string the rest of this file
// otherwise expects. Confirmed live against mint.minibits.cash
// (2026-09-18): this used to collapse to "mint returned 422
// Unprocessable Entity with no further detail", discarding the one
// piece of text that actually explains the failure.
let body = serde_json::json!({
"detail": [
{"loc": ["body", "inputs", 0, "id"], "msg": "NUT02: ID length invalid", "type": "value_error"}
]
})
.to_string();
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
assert_eq!(msg, "body -> inputs -> 0 -> id: NUT02: ID length invalid");
}
#[test]
fn an_empty_validation_error_array_falls_back_to_the_generic_message() {
let body = serde_json::json!({"detail": []}).to_string();
let msg = super::describe_mint_error_body(reqwest::StatusCode::UNPROCESSABLE_ENTITY, &body);
assert_eq!(msg, "mint returned 422 Unprocessable Entity with no further detail");
}
use super::*;
#[test]
+1
View File
@@ -6,6 +6,7 @@ pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod minibits;
pub mod mint_client;
pub mod nut13;
pub mod profits;
+12
View File
@@ -137,6 +137,18 @@ impl EcashSeed {
self.mnemonic.words().map(|w| w.to_string()).collect()
}
/// The phrase as a single string — the input to NUT-13 *and* to the NIP-06
/// Nostr derivation the Minibits profile flow needs (`crate::wallet::minibits`).
pub fn phrase(&self) -> String {
self.mnemonic.to_string()
}
/// The 64-byte BIP-39 seed. Same bytes Minibits hashes with SHA-256 to get
/// its `seedHash`, so the two wallets agree on wallet identity.
pub fn seed_bytes(&self) -> [u8; 64] {
self.seed
}
pub fn source(&self) -> SeedSource {
self.source
}
+9 -4
View File
@@ -1801,15 +1801,20 @@ app:
}
}
open.sort();
// Gitea 3001 (git clients speak basic-auth, not browser cookies) and
// Gitea 3001 (git clients speak basic-auth, not browser cookies),
// BTCPay 23000 (checkout/invoice/webhook endpoints must be reachable
// by anonymous payers). Both enforce their own account login, and an
// operator can re-gate either from Settings → Access control.
// by anonymous payers), and — since the v1.8.7 platform round — the
// three own-login consoles brought onto the manifest platform:
// 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.
assert_eq!(
open,
vec![
("btcpay-server".to_string(), 23000u16),
("gitea".to_string(), 3001u16)
("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"
);
+19 -15
View File
@@ -15,25 +15,32 @@ pub enum PkgManager {
impl Router {
/// Detect which package manager is available.
///
/// - If `/usr/bin/opkg` exists → `PkgManager::Opkg` (nothing to do).
/// - If `/usr/bin/apk` exists → run `apk update` (switching repos to HTTP
/// Looks up `opkg`/`apk` via the router's `$PATH` (`command -v`) rather
/// than a hardcoded `/usr/bin/<tool>` — official OpenWrt images don't all
/// symlink `/bin` into `/usr/bin` (e.g. the `glinet_gl-mt3000` 24.10.2
/// build keeps them as separate real directories with `opkg` living in
/// `/bin`), so a fixed absolute path silently misses a perfectly normal
/// install and reports "no package management" (archy-x250-pa3, 2026-09-05).
///
/// - If `opkg` is on PATH → `PkgManager::Opkg` (nothing to do).
/// - If `apk` is on PATH → run `apk update` (switching repos to HTTP
/// first to work around missing CA bundle on fresh images), then try
/// `apk add opkg`. If opkg is in the repos → `Opkg`. If not (OpenWrt
/// 25.x) → `ApkNative`.
/// - Neither found → error.
pub fn opkg_check(&self) -> Result<PkgManager> {
let (_, code) = self.run("test -x /usr/bin/opkg")?;
let (_, code) = self.run("command -v opkg >/dev/null 2>&1")?;
if code == 0 {
return Ok(PkgManager::Opkg);
}
let (_, apk_code) = self.run("test -x /usr/bin/apk")?;
let (_, apk_code) = self.run("command -v apk >/dev/null 2>&1")?;
if apk_code == 0 {
info!("[{}] opkg not found — using apk (OpenWrt 25.x+)", self.host);
// Fresh images ship without a CA bundle; switch repos to HTTP so
// apk's wget can reach the package index without TLS verification.
self.run_ok("sed -i 's|https://|http://|g' /etc/apk/repositories 2>/dev/null || true")?;
let (update_out, update_code) = self.run("/usr/bin/apk update 2>&1")?;
let (update_out, update_code) = self.run("apk update 2>&1")?;
if update_code != 0 {
anyhow::bail!(
"apk update failed (exit {}) — router may have no internet access. \
@@ -43,7 +50,7 @@ impl Router {
);
}
// Try to install opkg (only available on some 25.x builds).
let (add_out, add_code) = self.run("/usr/bin/apk add opkg 2>&1")?;
let (add_out, add_code) = self.run("apk add opkg 2>&1")?;
if add_code == 0 {
return Ok(PkgManager::Opkg);
}
@@ -62,7 +69,7 @@ impl Router {
}
anyhow::bail!(
"opkg not found at /usr/bin/opkg — this router's firmware may not \
"Neither opkg nor apk found on this router's $PATH — its firmware may not \
support package management (TollGate requires a standard OpenWrt build)"
);
}
@@ -70,31 +77,28 @@ impl Router {
/// `opkg update` — refresh package lists.
pub fn opkg_update(&self) -> Result<()> {
info!("[{}] opkg update", self.host);
self.run_ok("/usr/bin/opkg update")?;
self.run_ok("opkg update")?;
Ok(())
}
/// Install a package, skipping if already installed.
pub fn opkg_install(&self, package: &str) -> Result<()> {
// Check if already installed to avoid unnecessary network traffic.
let (_, code) = self.run(&format!(
"/usr/bin/opkg list-installed | grep -q '^{} '",
package
))?;
let (_, code) = self.run(&format!("opkg list-installed | grep -q '^{} '", package))?;
if code == 0 {
info!("[{}] {} already installed", self.host, package);
return Ok(());
}
info!("[{}] opkg install {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg install {}", package))?;
self.run_ok(&format!("opkg install {}", package))?;
Ok(())
}
/// Remove a package.
pub fn opkg_remove(&self, package: &str) -> Result<()> {
info!("[{}] opkg remove {}", self.host, package);
self.run_ok(&format!("/usr/bin/opkg remove {}", package))?;
self.run_ok(&format!("opkg remove {}", package))?;
Ok(())
}
@@ -121,7 +125,7 @@ impl Router {
}
info!("[{}] apk add {}", self.host, package);
self.run_ok(&format!("/usr/bin/apk add {}", package))?;
self.run_ok(&format!("apk add {}", package))?;
Ok(())
}
}
+182 -13
View File
@@ -23,6 +23,14 @@ pub struct TollGateConfig {
pub min_steps: u32,
/// Whether the TollGate service should be running and enabled at boot.
pub enabled: bool,
/// Operator's own Lightning address for the daemon's built-in payout
/// (the "owner" entry in `/etc/tollgate/identities.json`, `profit_share`
/// weight 0.79 in the upstream default). `None` leaves whatever is
/// already on the router untouched — which, on a router whose TollGate
/// wasn't provisioned through this project, is an unmodified upstream
/// placeholder nobody actually controls (confirmed live against
/// archy-x250-pa3 2026-09-07: shipped as `tollgate@minibits.cash`).
pub payout_address: Option<String>,
}
impl Default for TollGateConfig {
@@ -34,6 +42,7 @@ impl Default for TollGateConfig {
step_size_ms: 60_000,
min_steps: 1,
enabled: true,
payout_address: None,
}
}
}
@@ -46,19 +55,27 @@ impl Default for TollGateConfig {
/// tollgate.main.enabled` etc.); changing pricing or the mint here has no
/// effect on what the daemon advertises or accepts.
pub fn apply(router: &Router, cfg: &TollGateConfig) -> Result<()> {
router.uci_apply(
"tollgate",
&[
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", &cfg.step_size_ms.to_string()),
("tollgate.main.min_steps", &cfg.min_steps.to_string()),
("tollgate.main.price_per_step", &cfg.price_sats.to_string()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
],
)?;
let step_size = cfg.step_size_ms.to_string();
let min_steps = cfg.min_steps.to_string();
let price_sats = cfg.price_sats.to_string();
let mut pairs = vec![
("tollgate.main", "tollgate"),
("tollgate.main.enabled", if cfg.enabled { "1" } else { "0" }),
("tollgate.main.metric", "milliseconds"),
("tollgate.main.step_size", step_size.as_str()),
("tollgate.main.min_steps", min_steps.as_str()),
("tollgate.main.price_per_step", price_sats.as_str()),
("tollgate.main.currency", "sat"),
("tollgate.main.mint_url", &cfg.mint_url),
];
// Status-display only (see doc comment above) — only written when the
// caller actually supplied one, so a reconfigure that doesn't touch
// payout leaves whatever's already there alone.
if let Some(addr) = &cfg.payout_address {
pairs.push(("tollgate.main.payout_address", addr));
}
router.uci_apply("tollgate", &pairs)?;
Ok(())
}
@@ -97,3 +114,155 @@ pub fn apply_daemon_config(router: &Router, cfg: &TollGateConfig) -> Result<()>
.context("upload /etc/tollgate/config.json")?;
Ok(())
}
/// Set the operator's own payout Lightning address in
/// `/etc/tollgate/identities.json` — the "owner" entry under
/// `public_identities` (`profit_share` weight 0.79 in the upstream default;
/// the other entries there are revenue-share addresses for the upstream
/// project's own maintainers and must never be touched by this function).
///
/// No-op when `payout_address` is `None` — the UI only sends one when the
/// operator has actually filled the field in, so a reconfigure of price/mint
/// alone never overwrites this. Merges into whatever identities.json already
/// exists (same reasoning as `apply_daemon_config`: `owned_identities` holds
/// the merchant's own private key and must survive untouched); creates an
/// "owner" entry if none exists yet rather than erroring, since a router
/// whose TollGate wasn't provisioned through this project may have any
/// upstream-default shape here.
///
/// Must run before the daemon restart in `restart_services` — like
/// `config.json`, `tollgate-wrt` only reads `identities.json` at startup.
pub fn apply_payout_identity(router: &Router, payout_address: Option<&str>) -> Result<()> {
let Some(address) = payout_address else {
return Ok(());
};
validate_payout_address(address)?;
let existing = router.run_ok("cat /etc/tollgate/identities.json 2>/dev/null || echo '{}'")?;
let mut doc = parse_identities(&existing)?;
merge_payout_identity(&mut doc, address)?;
let json_str = serde_json::to_string_pretty(&doc).context("serialize identities.json")?;
router
.upload_file("/etc/tollgate/identities.json", json_str.as_bytes())
.context("upload /etc/tollgate/identities.json")?;
Ok(())
}
fn parse_identities(existing: &str) -> Result<serde_json::Value> {
serde_json::from_str(existing.trim()).context(
"parse existing /etc/tollgate/identities.json; refusing to overwrite malformed identity data",
)
}
/// Reject malformed values before provisioning changes anything on the
/// router. A payout typo otherwise remains dormant until the threshold is
/// reached, when the operator discovers that settlement cannot resolve.
pub fn validate_payout_address(address: &str) -> Result<()> {
let (name, domain) = address
.split_once('@')
.context("Lightning address must look like name@example.com")?;
if name.is_empty()
|| domain.is_empty()
|| domain.contains('@')
|| address.chars().any(char::is_whitespace)
{
anyhow::bail!("Lightning address must look like name@example.com");
}
Ok(())
}
fn merge_payout_identity(doc: &mut serde_json::Value, address: &str) -> Result<()> {
let identities = doc
.as_object_mut()
.context("identities.json root is not a JSON object")?
.entry("public_identities")
.or_insert_with(|| serde_json::json!([]));
let identities = identities
.as_array_mut()
.context("identities.json public_identities is not an array")?;
match identities
.iter_mut()
.find(|i| i.get("name").and_then(|n| n.as_str()) == Some("owner"))
{
Some(owner) => {
owner["lightning_address"] = serde_json::json!(address);
}
None => {
identities.push(serde_json::json!({
"name": "owner",
"pubkey": "not currently used",
"lightning_address": address,
}));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{merge_payout_identity, parse_identities, validate_payout_address};
#[test]
fn payout_merge_changes_only_owner_address() {
let mut doc = serde_json::json!({
"config_version": "v0.0.1",
"owned_identities": [{ "name": "merchant", "privatekey": "keep-secret" }],
"public_identities": [
{ "name": "owner", "pubkey": "not currently used", "lightning_address": "old@example.com" },
{ "name": "upstream", "lightning_address": "keep@example.com" }
]
});
let before_owned = doc["owned_identities"].clone();
let before_other = doc["public_identities"][1].clone();
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
assert_eq!(doc["owned_identities"], before_owned);
assert_eq!(doc["public_identities"][1], before_other);
assert_eq!(
doc["public_identities"][0]["lightning_address"],
"operator@example.com"
);
}
#[test]
fn payout_merge_can_create_missing_owner() {
let mut doc = serde_json::json!({ "public_identities": [] });
merge_payout_identity(&mut doc, "operator@example.com").unwrap();
assert_eq!(doc["public_identities"][0]["name"], "owner");
assert_eq!(
doc["public_identities"][0]["lightning_address"],
"operator@example.com"
);
}
#[test]
fn payout_address_validation_rejects_typographical_failures() {
assert!(validate_payout_address("operator@example.com").is_ok());
for invalid in [
"",
"operator",
"@example.com",
"operator@",
"a@b@c",
"a b@example.com",
] {
assert!(
validate_payout_address(invalid).is_err(),
"accepted {invalid:?}"
);
}
}
#[test]
fn malformed_identity_data_is_never_replaced() {
let err = parse_identities("{ truncated").unwrap_err();
assert!(err
.to_string()
.contains("refusing to overwrite malformed identity data"));
}
}
+82 -14
View File
@@ -6,18 +6,53 @@ use crate::Router;
/// The OpenWrt package name for the TollGate reference implementation.
const TOLLGATE_PACKAGE: &str = "tollgate-module-basic-go";
/// Direct-download fallback URLs by opkg architecture string.
/// Pinned upstream release. Was stuck on v0.2.0 (Oct 2025) until 2026-09-05 —
/// nine releases behind. v0.5.0's changelog covers exactly the failure modes
/// hit live against archy-x250-pa3: a mint with an empty/broken keyset used
/// to crash-loop the daemon forever ("graceful degradation when Cashu mints
/// fail" in v0.5.0), and the bundled captive-portal build had no CBOR support
/// at all, so it could only decode legacy `cashuA` tokens — rejecting the
/// `cashuB` (NUT-00 V4) tokens modern wallets like Minibits generate by
/// default ("portal improvements" in v0.5.0 include a JS bundle update that
/// should carry a current cashu-ts with V4 support). Bump this string to move
/// both this crate's URLs and the version baked into the source comments.
const TOLLGATE_VERSION: &str = "v0.5.0";
/// Direct-download fallback URLs by opkg architecture string, for the
/// `.ipk` (ar-archive) package format.
/// Used when the package is not in any configured feed.
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.2.0
fn ipk_url(arch: &str) -> Option<&'static str> {
match arch {
"mips_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mips_24kc.ipk"),
"mipsel_24kc" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/mipsel_24kc.ipk"),
"aarch64_cortex-a53" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a53.ipk"),
"aarch64_cortex-a72" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/aarch64_cortex-a72.ipk"),
"arm_cortex-a7" => Some("https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/v0.2.0/arm_cortex-a7.ipk"),
_ => None,
}
/// Source: https://github.com/OpenTollGate/tollgate-module-basic-go/releases/tag/v0.5.0
fn ipk_url(arch: &str) -> Option<String> {
let name = match arch {
"mips_24kc" => "mips_24kc",
"mipsel_24kc" => "mipsel_24kc",
"aarch64_cortex-a53" => "aarch64_cortex-a53",
"aarch64_cortex-a72" => "aarch64_cortex-a72",
"arm_cortex-a7" => "arm_cortex-a7",
"x86_64" => "x86_64",
_ => return None,
};
Some(format!(
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.ipk"
))
}
/// Direct-download URLs for the native Alpine-style `.apk` package format —
/// only published for a subset of architectures as of v0.5.0. Where
/// available this is strictly better than [`ipk_url`] on an apk-native
/// (OpenWrt 25.x+) router: `apk add` installs it directly (dependency
/// resolution, postinst, uci-defaults all handled by apk itself), instead of
/// the manual `ar`/`tar` extraction dance `install_ipk` has to do to unpack
/// an `.ipk` on a router with no `opkg`.
fn apk_url(arch: &str) -> Option<String> {
let name = match arch {
"aarch64_cortex-a53" => "aarch64_cortex-a53",
"x86_64" => "x86_64",
_ => return None,
};
Some(format!(
"https://github.com/OpenTollGate/tollgate-module-basic-go/releases/download/{TOLLGATE_VERSION}/tollgate-wrt_{TOLLGATE_VERSION}_{name}.apk"
))
}
/// Install tollgate-module-basic-go via opkg (OpenWrt ≤24.x).
@@ -34,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
}
// Package not in any feed — download the .ipk directly.
let arch = router
.run_ok("/usr/bin/opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'")?;
let arch = router.run_ok(
"opkg print-architecture | grep -v all | grep -v noarch | tail -1 | awk '{print $2}'",
)?;
let arch = arch.trim();
let url = ipk_url(arch).ok_or_else(|| {
@@ -88,7 +124,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
". /etc/openwrt_release 2>/dev/null \
&& a=\"${DISTRIB_ARCH:-${OPENWRT_ARCH:-}}\" \
&& [ -n \"$a\" ] && echo \"$a\" \
|| /usr/bin/apk --print-arch 2>/dev/null \
|| apk --print-arch 2>/dev/null \
|| uname -m",
)?;
// Normalise: uname -m returns bare "mipsel"/"mips"; map to 24kc variant
@@ -103,6 +139,38 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
anyhow::bail!("Could not determine router architecture");
}
// Prefer a native .apk when the release publishes one for this arch —
// `apk add` handles the install itself (deps, postinst, uci-defaults),
// skipping the manual ar/tar extraction the .ipk fallback below needs.
if let Some(url) = apk_url(arch) {
info!(
"[{}] Downloading native TollGate .apk for {} from GitHub releases",
router.host, arch
);
let (dl_out, dl_code) = router.run(&format!(
"wget --no-check-certificate -O /tmp/tollgate.apk '{}' 2>&1",
url
))?;
if dl_code != 0 {
anyhow::bail!("TollGate .apk download failed: {}", dl_out.trim());
}
let (size_out, _) = router.run("wc -c < /tmp/tollgate.apk 2>/dev/null")?;
let size: u64 = size_out.trim().parse().unwrap_or(0);
if size < 50_000 {
anyhow::bail!(
"Downloaded TollGate .apk is only {}B — wget likely captured an error page. \
Check router internet access and that the release URL is reachable.",
size
);
}
let (add_out, add_code) = router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
router.run_ok("rm -f /tmp/tollgate.apk")?;
if add_code != 0 {
anyhow::bail!("TollGate .apk install failed: {}", add_out.trim());
}
return Ok(());
}
let url = ipk_url(arch).ok_or_else(|| {
anyhow::anyhow!(
"No pre-built TollGate package for architecture '{}'. \
+7
View File
@@ -59,10 +59,17 @@ pub async fn provision(router: &Router, config: &TollGateConfig) -> Result<()> {
config::apply(router, config)?;
wifi::provision_ssid(router, config)?;
// Must come after provision_ssid (creates the `tollgate` network this
// folds the upstream installer's own default AP onto) — see
// regate_upstream_default_aps for why this is needed at all.
wifi::regate_upstream_default_aps(router)
.context("re-gate upstream tollgate-module-basic-go default AP(s)")?;
// Must come after provision_ssid (which creates br-tollgate) and before
// the daemon restart below — config.json is only read at startup.
config::apply_daemon_config(router, config)
.context("write /etc/tollgate/config.json — tollgate-wrt reads this, not UCI")?;
config::apply_payout_identity(router, config.payout_address.as_deref())
.context("write /etc/tollgate/identities.json owner payout address")?;
// Also must come after provision_ssid: points gatewayinterface at
// br-tollgate, which provision_ssid is what creates.
nodogsplash::configure(router, config)

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