Compare commits

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

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

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

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

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

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

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

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

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

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

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

prod_orchestrator also registers cuprate-ui in UI_APP_IDS (its
companion commit follows).
2026-09-12 16:14:02 -04:00
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 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
192 changed files with 13556 additions and 2220 deletions
+23
View File
@@ -0,0 +1,23 @@
# Blocking incident: Framework LND startup and false zero balance
Before starting or resuming work in this repository, read
`docs/incident-framework-lnd-startup.md` and check its status.
The user explicitly required this incident to take priority over later work.
While its status is OPEN:
- Surface this blocker at session start, including when the user asks for unrelated work.
- Prioritize investigation and repair on the actual Framework node. If access is
unavailable, ask for it and continue useful offline investigation; defer unrelated
work unless the user explicitly overrides this priority.
- A manual LND restart, a source patch, passing local tests, or publishing an OTA
does not resolve the incident. Do not mark it fixed until the Framework's startup,
Receive flow, and balance behavior are verified on the node, including a controlled
reboot with access and recovery arrangements in place.
- Preserve wallet identity, wallet/channel databases, credentials, and backups.
Never run wallet wipe/recreation as an automatic investigation or recovery step.
- Record evidence, changes, validation, and remaining work in the incident document.
This priority comes from the user's explicit instruction on 2026-09-15. It remains
in effect across sessions until the documented acceptance criteria are met or the
user explicitly changes it.
+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()
},
+102 -1
View File
@@ -1,5 +1,106 @@
# Changelog
## Unreleased
## v1.8.18-alpha (2026-09-18)
- Framework startup prioritizes Bitcoin and LND before unrelated containers, and unavailable LND balances remain unavailable instead of appearing as false zeroes.
- Cashu Receive guides unseeded wallets through recovery-phrase setup, with shorter backup guidance and a single-column layout.
- Added live Framework verification for automatic LND unlock, native balance preservation, Cashu address registration, and proof preservation.
## v1.8.17-alpha (2026-09-15)
- Minibits claims that every mint reports as already spent leave the retry queue, clearing repeated failure notices. Network errors and mixed mint failures remain queued for another attempt.
- Minibits polls its primary relay first and connects to public fallback relays only when the primary is unreachable, reducing unnecessary connections.
- Large payment backlogs are fetched from newest to oldest with a saved cursor, so polling can resume after interruptions or page limits. Payments sharing the same timestamp remain reachable.
- Added regression coverage for spent-claim classification, wrapped and mixed mint errors, same-second payments, and interrupted or multi-poll backlogs.
## v1.8.16-alpha (2026-09-15)
- App updates refresh and verify the signed catalog before changing containers. A failed refresh or manifest reload cancels the update, and automatic updates wait for a successful refresh.
- Fixed repeated Mempool update offers: downstream `-archyN` patches now sort above their upstream release, and moving a published image between registry namespaces does not hide a genuine upgrade.
- Updates inspect installed component versions, refuse known downgrades, skip containers already at the target versions, and verify the resulting versions before reporting success.
- Added regression coverage for stale catalogs, matching versions, publisher namespace changes, stack component updates, and keeping running containers untouched when no upgrade is needed.
## v1.8.15-alpha (2026-09-13)
- Cuprate is presented as one user-facing app in My Apps, including its UI launch button; the generated dashboard companion is hidden as an implementation detail instead of appearing under Services.
- Added regression coverage for Cuprate install and installed-state grouping.
- Release validation was rerun on the corrected tree before OTA and ISO publication.
## v1.8.14-alpha (2026-09-13)
- **Cuprate gains a first-party companion dashboard.** The Monero node now has a Bitcoin-style status UI, safe app grouping, a 450 GB disk-safety gate, and a restricted RPC that is never exposed as a launch page.
- **Bitcoin Core Tor enrollment uses the correct protocol identity.** `bitcoin-core` is forwarded on port 8333 and resolves to its own hidden-service directory without disturbing legacy Bitcoin aliases.
- **GitWorkshop opens Archipelago’s canonical ngit repository by default.** The launcher and registry promotion use the full maintainer/relay/`archy` coordinate, with regression coverage for Companion and browser-tab launches.
- **Release validation is stricter.** The registry gate now checks the complete canonical source deep link, and the merged candidate passed the full frontend and focused backend test suites.
## v1.8.13-alpha (2026-09-12)
- **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.
@@ -44,7 +145,7 @@
- **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 last stragglers — Nginx Proxy Manager, Tailscale, Ollama, CryptPad, and AdGuard Home — now 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. The four apps retired earlier (FIPS, Nostr VPN, Routstr, Penpot) are finally dropped from the catalog, and Cuprate's manifest — which carried a duplicated metadata block that strict parsers reject — is fixed.
- **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.
+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
+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
+39 -14
View File
@@ -9,19 +9,31 @@
"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": "adguardhome",
"title": "AdGuard Home",
"version": "v0.107.79",
"description": "Network-wide ad and tracker blocking: a DNS server that filters every device on your LAN, with a web console for rules and client management.",
"icon": "",
"author": "AdGuard",
"category": "networking",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79",
"repoUrl": "https://github.com/AdguardTeam/AdGuardHome"
},
{
"id": "alby-hub",
"title": "Alby Hub",
@@ -247,6 +259,19 @@
},
"tier": "optional"
},
{
"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",
"title": "Grafana",
@@ -353,13 +378,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+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
-91
View File
@@ -1,91 +0,0 @@
app:
id: adguardhome
name: AdGuard Home
version: v0.107.79
upstream:
kind: github
repo: AdguardTeam/AdGuardHome
description: >-
Network-wide ad and tracker blocking: a DNS server that filters every
device on your LAN, with a web console for rules and client management.
container:
image: source.archipelago-foundation.org/lfg2025/adguardhome:v0.107.79
pull_policy: if-not-present
network: pasta
dependencies:
- storage: 1Gi
resources:
memory_limit: 512Mi
disk_limit: 1Gi
security:
capabilities: [NET_BIND_SERVICE]
readonly_root: false
no_new_privileges: true
network_policy: isolated
ports:
- host: 3030
container: 3000
protocol: tcp
bind: 127.0.0.1
# 3030, not AdGuard Home's conventional 3000: Grafana owns :3000 on a
# node, and both being installable means the host ports must not
# collide (the orchestrator refuses/loads warn on overlap).
# open: the setup wizard and admin console carry AdGuard Home's own
# login; the gate fronts the port (TLS, header fixes) without a
# second cookie challenge.
auth: open
auth_rationale: >-
AdGuard Home enforces its own admin login on the console, and the
first-run wizard must answer before any account exists.
- host: 53
container: 53
protocol: udp
# none: plain DNS must answer every unauthenticated query from LAN
# devices — a login page in front of :53 breaks every client on the
# network by design.
auth: none
auth_rationale: >-
Plain DNS answers unauthenticated by protocol: resolvers and clients
send queries directly; a login challenge would make DNS unreachable.
- host: 53
container: 53
protocol: tcp
auth: none
auth_rationale: >-
DNS-over-TCP fallback (truncated responses, zone transfers); same
protocol-level requirement as the UDP port.
volumes:
- type: bind
source: /var/lib/archipelago/adguardhome
target: /opt/adguardhome
options: [rw]
environment: []
health_check:
type: tcp
endpoint: localhost:3030
interval: 30s
timeout: 5s
retries: 3
interfaces:
main:
name: Admin console
description: AdGuard Home web console
type: ui
port: 3030
protocol: http
path: /
metadata:
author: AdGuard
category: networking
repo: https://github.com/AdguardTeam/AdGuardHome
tier: optional
+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
+58 -21
View File
@@ -36,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
@@ -82,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
@@ -103,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
@@ -128,21 +165,21 @@ app:
# - 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 amishparadise
# 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
# (ssmithx@archy-dev-pa:/home/ssmithx/cuprate/Cuprated.toml,
# verified 2026-09-01), which deliberately runs file logging quieter
# 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
+10 -2
View File
@@ -16,11 +16,13 @@ app:
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
+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
+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
+2 -2
View File
@@ -67,13 +67,13 @@
{
"id": "mempool",
"title": "Mempool Explorer",
"version": "3.0.0",
"version": "3.3.1-archy1",
"description": "Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.",
"icon": "/assets/img/app-icons/mempool.webp",
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"dockerImage": "source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.10-alpha"
version = "1.8.18-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+4 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.10-alpha"
version = "1.8.18-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"
@@ -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,
@@ -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,
+129 -51
View File
@@ -73,6 +73,42 @@ struct LndChannelBalanceResponse {
pending_open_local_balance: Option<LndAmount>,
}
/// Reject unavailable LND data before it can be decoded as an empty, zero wallet.
async fn get_lnd_json<T: serde::de::DeserializeOwned>(
client: &reqwest::Client,
url: &str,
macaroon_hex: &str,
) -> Result<T> {
client
.get(url)
.header("Grpc-Metadata-macaroon", macaroon_hex)
.send()
.await
.context("LND is unavailable; balance could not be checked")?
.error_for_status()
.context("LND is not ready; balance could not be checked")?
.json()
.await
.context("LND returned invalid wallet data")
}
fn checked_balances(
wallet: LndBalanceResponse,
channels: LndChannelBalanceResponse,
) -> Result<(i64, i64, i64)> {
fn sats(value: Option<String>) -> Result<i64> {
let value = value.context("LND omitted a balance; balance is unavailable")?;
let amount: i64 = value.parse().context("LND returned an invalid balance")?;
anyhow::ensure!(amount >= 0, "LND returned a negative balance");
Ok(amount)
}
Ok((
sats(wallet.total_balance)?,
sats(channels.local_balance.and_then(|a| a.sat))?,
sats(channels.pending_open_local_balance.and_then(|a| a.sat))?,
))
}
impl RpcHandler {
pub(in crate::api::rpc) async fn handle_lnd_getinfo(&self) -> Result<serde_json::Value> {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
@@ -85,45 +121,26 @@ impl RpcHandler {
.build()
.context("Failed to create HTTP client")?;
let get_info: LndGetInfoResponse = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?
.json()
.await
.context("Failed to parse LND getinfo response")?;
let channel_balance: LndChannelBalanceResponse = match client
.get(format!("{LND_REST_BASE_URL}/v1/balance/channels"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or(LndChannelBalanceResponse {
local_balance: None,
pending_open_local_balance: None,
}),
Err(_) => LndChannelBalanceResponse {
local_balance: None,
pending_open_local_balance: None,
},
};
let wallet_balance: LndBalanceResponse = match client
.get(format!("{LND_REST_BASE_URL}/v1/balance/blockchain"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
{
Ok(resp) => resp.json().await.unwrap_or(LndBalanceResponse {
total_balance: None,
}),
Err(_) => LndBalanceResponse {
total_balance: None,
},
};
let get_info: LndGetInfoResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/getinfo"),
&macaroon_hex,
)
.await?;
let channel_balance: LndChannelBalanceResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/balance/channels"),
&macaroon_hex,
)
.await?;
let wallet_balance: LndBalanceResponse = get_lnd_json(
&client,
&format!("{LND_REST_BASE_URL}/v1/balance/blockchain"),
&macaroon_hex,
)
.await?;
let (balance_sats, channel_balance_sats, pending_open_balance) =
checked_balances(wallet_balance, channel_balance)?;
let (identity_pubkey, uris) = map_identity(&get_info);
@@ -135,18 +152,9 @@ impl RpcHandler {
num_peers: get_info.num_peers.unwrap_or(0),
synced_to_chain: get_info.synced_to_chain.unwrap_or(false),
block_height: get_info.block_height.unwrap_or(0),
balance_sats: wallet_balance
.total_balance
.and_then(|s| s.parse().ok())
.unwrap_or(0),
channel_balance_sats: channel_balance
.local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
pending_open_balance: channel_balance
.pending_open_local_balance
.and_then(|a| a.sat.and_then(|s| s.parse().ok()))
.unwrap_or(0),
balance_sats,
channel_balance_sats,
pending_open_balance,
};
Ok(serde_json::to_value(info)?)
@@ -268,6 +276,76 @@ impl RpcHandler {
mod tests {
use super::*;
#[test]
fn unavailable_balances_are_not_zero() {
for body in [r#"{}"#, r#"{"code":14,"message":"wallet locked"}"#] {
assert!(checked_balances(
serde_json::from_str(body).unwrap(),
serde_json::from_str(body).unwrap(),
)
.is_err());
}
for value in ["bad", "-1", "9223372036854775808"] {
let wallet = LndBalanceResponse {
total_balance: Some(value.into()),
};
let channels = serde_json::from_str(
r#"{"local_balance":{"sat":"5"},"pending_open_local_balance":{"sat":"0"}}"#,
)
.unwrap();
assert!(checked_balances(wallet, channels).is_err());
}
}
#[test]
fn verified_zero_and_nonzero_balances_survive() {
for expected in [0, 42] {
let wallet = LndBalanceResponse {
total_balance: Some(expected.to_string()),
};
let channels = serde_json::from_value(serde_json::json!({
"local_balance":{"sat":expected.to_string()},
"pending_open_local_balance":{"sat":"0"}
}))
.unwrap();
assert_eq!(
checked_balances(wallet, channels).unwrap(),
(expected, expected, 0)
);
}
}
#[tokio::test]
async fn locked_wallet_http_response_is_not_successful_getinfo() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0; 2048];
stream.read(&mut buf).await.unwrap();
let body =
r#"{"code":9,"message":"wallet locked, unlock it to enable full RPC access"}"#;
stream.write_all(format!(
"HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(), body
).as_bytes()).await.unwrap();
});
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(2))
.build()
.unwrap();
assert!(get_lnd_json::<LndGetInfoResponse>(
&client,
&format!("http://{addr}/v1/getinfo"),
"test"
)
.await
.is_err());
server.await.unwrap();
}
/// A real compressed secp256k1 pubkey shape: 66 hex characters.
const GOOD_PUBKEY: &str = "03a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
+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\" =>"));
}
}
+31 -1
View File
@@ -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};
+192 -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
@@ -2789,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"
)
}
@@ -2800,11 +2760,43 @@ fn is_unknown_app_id_error(err: &anyhow::Error) -> bool {
#[cfg(test)]
mod tests {
use super::{
orchestrator_install_app_id, parse_setup_token, 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 [
@@ -2836,6 +2828,7 @@ mod tests {
"gitea",
"portainer",
"meshtastic",
"archipelago-source",
] {
assert!(uses_orchestrator_install_flow(app));
assert!(should_try_orchestrator_install(app, true));
@@ -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;
@@ -400,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,
);
+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);
}
@@ -657,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"),
@@ -906,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 -35
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"),
@@ -182,7 +189,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
"immich" | "immich_server" => Some("IMMICH_SERVER_IMAGE"),
// Networking
"adguardhome" => Some("ADGUARDHOME_IMAGE"),
"tor" | "archy-tor" => Some("ALPINE_TOR_IMAGE"),
_ => None,
@@ -205,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`),
@@ -423,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#"
@@ -445,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"));
+7 -121
View File
@@ -96,129 +96,21 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
match unlock_existing_wallet().await? {
true => {
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
false => {
// Every candidate password was actively rejected: this wallet was
// created with a password this node no longer has, so it can never
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
// locked with an unknown password is already inaccessible, so wipe +
// recreate it on the per-node secret to self-heal at boot.
recreate_wallet_destructively().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
}
unlock_existing_wallet_no_wipe().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
/// LND data subdirectories holding wallet + channel + graph state. Removing them
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
/// so deletion is destructive — only done once the wallet is already unrecoverable.
const LND_STATE_DIRS: &[&str] = &[
"/var/lib/archipelago/lnd/data/chain",
"/var/lib/archipelago/lnd/data/graph",
];
/// Podman container name for the core LND app (see `compute_container_name`:
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
const LND_CONTAINER: &str = "lnd";
/// Canonical on-host admin macaroon — same path the RPC layer reads.
const LND_ADMIN_MACAROON: &str =
"/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
/// Archipelago data dir (default; not overridden in prod). Holds the
/// `user-stopped.json` that gates health-monitor auto-restart.
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
/// candidate password can open the existing wallet.
async fn recreate_wallet_destructively() -> Result<()> {
tracing::warn!(
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
);
// The health monitor restarts any container it sees stopped; mark LND
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
// Always cleared below so LND auto-recovers normally afterwards.
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
let result = wipe_and_reinit_wallet().await;
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
result
}
async fn wipe_and_reinit_wallet() -> Result<()> {
podman_user_scoped(&["stop", LND_CONTAINER])
.await
.context("stopping lnd before wallet wipe")?;
for dir in LND_STATE_DIRS {
let status = host_sudo(&["rm", "-rf", dir])
.await
.with_context(|| format!("removing {dir}"))?;
if !status.success() {
anyhow::bail!("removing {dir} exited with {status}");
}
}
podman_user_scoped(&["start", LND_CONTAINER])
.await
.context("restarting lnd after wallet wipe")?;
wait_for_wallet_state("NON_EXISTING").await?;
init_wallet_via_rest().await
}
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
/// how the orchestrator/health-monitor manage rootless containers (keeps the
/// container out of the archipelago service's cgroup).
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
let out = tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args(args)
.output()
.await
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
async fn wait_for_wallet_state(target: &str) -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
for _ in 0..120 {
if wallet_state(&client).await.as_deref() == Some(target) {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND did not reach state {target} after wallet wipe")
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
@@ -390,14 +282,8 @@ async fn unlock_existing_wallet_via_rest() -> Result<bool> {
)
}
/// Unlock an existing wallet WITHOUT the destructive fallback.
///
/// `ensure_wallet_initialized` wipes and recreates a wallet no candidate
/// password can open — correct for a boot path that must self-heal, and exactly
/// wrong for macaroon rotation, which restarts LND against a wallet the operator
/// still wants. Rotation calls this instead, so there is no code path from
/// "rotate my credentials" to "delete my wallet": a rejected password surfaces
/// as an error the caller reports, never as a wipe.
/// Unlock the existing wallet, preserving its identity and channel data when
/// passwords are unavailable or rejected. Used by boot and credential rotation.
pub(crate) async fn unlock_existing_wallet_no_wipe() -> Result<()> {
match unlock_existing_wallet().await? {
true => Ok(()),
@@ -538,7 +424,7 @@ async fn init_wallet_via_rest() -> Result<()> {
{
UnlockerResponse::Value(seed) => seed,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
unlock_existing_wallet_no_wipe().await?;
return Ok(());
}
};
@@ -569,7 +455,7 @@ async fn init_wallet_via_rest() -> Result<()> {
.await;
}
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
unlock_existing_wallet_no_wipe().await?;
}
}
@@ -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
@@ -1855,7 +1864,7 @@ impl ProdContainerOrchestrator {
// Durable installation record, consulted alongside the perishable
// `was_running` snapshot for desired-state recovery below.
let installed_apps = crate::crash_recovery::load_installed_apps(&self.data_dir).await;
let (manifests, container_name_by_app_id): (
let (mut manifests, container_name_by_app_id): (
Vec<LoadedManifest>,
std::collections::HashMap<String, String>,
) = {
@@ -1886,6 +1895,15 @@ impl ProdContainerOrchestrator {
.collect();
(filtered, names)
};
// Wallet readiness must not wait behind unrelated image pulls/builds.
// A running LND container can still be locked after boot; its post-start
// hook must run promptly. Reconcile Bitcoin first, then LND, before the
// rest of the catalog. Each app still honors stopped/uninstalled markers.
manifests.sort_by_key(|lm| match lm.manifest.app.id.as_str() {
"bitcoin-knots" | "bitcoin-core" | "bitcoin" => 0,
"lnd" => 1,
_ => 2,
});
// Live container names (any state), for the same recovery check.
let present_containers: std::collections::HashSet<String> = self
.runtime
@@ -1944,6 +1962,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 +3600,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 +3656,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 +4676,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 +5106,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 +5231,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 +5428,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 +5764,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
@@ -6224,6 +6407,42 @@ app:
assert!(cascade_pairs_for_report(&r, &none).is_empty());
}
#[tokio::test]
async fn reconcile_wallet_start_precedes_unrelated_failed_image_pull() {
let rt = Arc::new(MockRuntime::default());
rt.set_state("bitcoin-knots", ContainerState::Exited);
rt.set_state("lnd", ContainerState::Exited);
*rt.fail_pull.lock().unwrap() = Some("registry unreachable".into());
let mut orch = orch_with(rt.clone()).await;
orch.set_disk_gb_for_test(2000);
for id in ["unrelated", "lnd", "bitcoin-knots"] {
orch.insert_manifest_for_test(
pull_manifest(id, &format!("docker.io/example/{id}:1")),
PathBuf::from(format!("/tmp/{id}")),
)
.await;
}
let report = orch.reconcile_all().await;
assert!(report.failures.iter().any(|(id, _)| id == "unrelated"));
let calls = rt.calls();
let bitcoin = calls
.iter()
.position(|c| c == "start_container:bitcoin-knots")
.unwrap();
let lnd = calls
.iter()
.position(|c| c == "start_container:lnd")
.unwrap();
let pull = calls
.iter()
.position(|c| c.starts_with("pull_image:"))
.unwrap();
assert!(
bitcoin < lnd && lnd < pull,
"wallet startup was delayed by unrelated recovery: {calls:?}"
);
}
#[tokio::test]
async fn reconcile_starts_exited_container() {
let rt = Arc::new(MockRuntime::default());
@@ -6625,6 +6844,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, 3030, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087,
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,
];
+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.
+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,
+47
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);
}
@@ -508,6 +516,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 {
+26 -4
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> {
@@ -1632,6 +1642,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
+50 -3
View File
@@ -71,10 +71,28 @@ pub struct MintResult {
/// keyset codes shared by NUT-02/03/04/05 — the codes a swap/melt/mint call
/// can actually hit. Returns `None` for anything else (e.g. Lightning/quote
/// codes in the 20000s) so the caller falls back to the mint's own `detail`.
///
/// Text of the NUT error-code-11001 translation, exposed so callers that
/// received an `anyhow::Error` from a receive/redeem path (e.g. a replayed
/// Minibits claim) can recognize an already-spent token as terminal rather
/// than retrying it forever.
pub const ALREADY_REDEEMED_MSG: &str =
"This ecash has already been redeemed — it can't be claimed twice.";
/// Typed terminal condition: never infer spent proofs from a mint's free text.
#[derive(Debug)]
pub(super) struct AlreadyRedeemed;
impl std::fmt::Display for AlreadyRedeemed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(ALREADY_REDEEMED_MSG)
}
}
impl std::error::Error for AlreadyRedeemed {}
fn describe_mint_error_code(code: i64) -> Option<&'static str> {
Some(match code {
10001 => "The mint rejected these coins as invalid.",
11001 => "This ecash has already been redeemed — it can't be claimed twice.",
11001 => ALREADY_REDEEMED_MSG,
11002 => "This ecash is already being redeemed elsewhere — try again in a moment.",
11003 => "The mint already issued new coins for this exact request — there's nothing left to redeem.",
11004 => "This request is still being processed by the mint — try again in a moment.",
@@ -124,8 +142,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.
@@ -803,6 +828,28 @@ impl MintClient {
#[cfg(test)]
mod tests {
#[test]
fn spent_condition_comes_from_code_not_remote_text_and_survives_context() {
let spent = super::mint_error(
"Swap",
reqwest::StatusCode::BAD_REQUEST,
r#"{"code":11001,"detail":"Token Already Spent"}"#,
)
.context("Receive failed");
assert!(spent.is::<super::AlreadyRedeemed>());
let body =
serde_json::json!({"code":11002,"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_REQUEST, &body)
.is::<super::AlreadyRedeemed>()
);
let body = serde_json::json!({"detail":super::ALREADY_REDEEMED_MSG}).to_string();
assert!(
!super::mint_error("Swap", reqwest::StatusCode::BAD_GATEWAY, &body)
.is::<super::AlreadyRedeemed>()
);
}
use super::*;
#[test]
+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
}
+3 -10
View File
@@ -1746,11 +1746,6 @@ app:
}
}
exempt.sort();
// 30 as of 2026-08-31: the 28 below plus adguardhome's two DNS ports
// (53 udp + tcp) — plain DNS answers unauthenticated by protocol, the
// same reason router's mDNS/SSDP and every p2p port is exempt; each
// carries its auth_rationale in the manifest.
//
// 28 as of 2026-08-23: the 26 below plus cuprate's two exemptions —
// 18183 (Monero p2p gossip, same reasoning as bitcoin's 8333) and
// 18090 (host mapping for Monero's canonical 18089 restricted RPC,
@@ -1776,7 +1771,7 @@ app:
// stage timed out that cycle, so the count here lagged at 17.
assert_eq!(
exempt.len(),
30,
28,
"unauthenticated port set changed — review before updating this count: {exempt:?}"
);
}
@@ -1811,13 +1806,11 @@ app:
// 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), adguardhome 3000 (AGH admin
// accounts + first-run wizard). All enforce their own login, and an
// operator can re-gate any of them from Settings → Access control.
// (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![
("adguardhome".to_string(), 3000u16),
("btcpay-server".to_string(), 23000u16),
("gitea".to_string(), 3001u16),
("nginx-proxy-manager".to_string(), 8081u16),
+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"));
}
}
+8 -8
View File
@@ -24,12 +24,12 @@ const TOLLGATE_VERSION: &str = "v0.5.0";
/// 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",
"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",
"arm_cortex-a7" => "arm_cortex-a7",
"x86_64" => "x86_64",
_ => return None,
};
Some(format!(
@@ -69,8 +69,9 @@ pub fn install_tollgate(router: &Router) -> Result<()> {
}
// Package not in any feed — download the .ipk directly.
let arch = router
.run_ok("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(|| {
@@ -162,8 +163,7 @@ pub fn install_tollgate_apk_native(router: &Router) -> Result<()> {
size
);
}
let (add_out, add_code) =
router.run("apk add --allow-untrusted /tmp/tollgate.apk 2>&1")?;
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());
+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)
+71
View File
@@ -118,6 +118,61 @@ fn provision_firewall(router: &Router) -> Result<()> {
Ok(())
}
/// Fold the upstream `tollgate-module-basic-go` installer's own default
/// AP(s) onto the gated `tollgate` network.
///
/// `install::install_ipk` runs the package's `/etc/uci-defaults/*` first-boot
/// scripts itself (no real package manager to trigger them on OpenWrt 25.x —
/// see its doc comment). Those upstream scripts rebrand OpenWrt's
/// factory-default wifi sections (`wireless.default_radioN`, present on
/// every fresh install) to a `TollGate-<serial>` SSID, but only ever touch
/// the SSID — they leave `network` at its original `lan` binding. Nothing
/// else in this project's own provisioning (`provision_ssid` above) ever
/// looks at those sections; it only manages the separate `wireless.tollgate`
/// SSID it creates itself. Left alone, the result is two open SSIDs
/// broadcasting side by side: ours (gated by NoDogSplash) and upstream's
/// (wide open on `lan`, with a direct route to whatever's plugged into the
/// wired LAN port).
///
/// Confirmed live against archy-x250-pa3 2026-09-07: a client joining
/// "TollGate-3458" landed on `br-lan` with unrestricted WAN forwarding and
/// zero NoDogSplash involvement — free, unmetered internet, no captive
/// portal, on the router's own admin network.
///
/// Must run after `provision_network` (needs the `tollgate` network/bridge
/// to already exist) and before the network/wifi restart in
/// `restart_services` picks the new binding up.
pub fn regate_upstream_default_aps(router: &Router) -> Result<()> {
let sections = router.run_ok(
"uci show wireless 2>/dev/null | grep -o '^wireless\\.default_radio[0-9]*' | sort -u",
)?;
for section in sections.lines().map(str::trim).filter(|s| !s.is_empty()) {
let network_key = format!("{}.network", section);
let current = router.uci_get(&network_key).unwrap_or_default();
let ssid = router
.uci_get(&format!("{}.ssid", section))
.unwrap_or_default();
// A failed/changed upstream first-boot script can leave a stock
// default_radioN section in place. Moving that interface merely
// because it is on LAN can seize the router's existing management AP.
// Only the public APs the TollGate installer demonstrably rebranded
// belong on the paid network.
if should_regate_upstream_ap(&current, &ssid) {
info!(
"[{}] Re-gating upstream default AP {} ({}) onto the tollgate network",
router.host, section, ssid
);
router.uci_set(&network_key, "tollgate")?;
}
}
router.uci_commit(Some("wireless"))?;
Ok(())
}
fn should_regate_upstream_ap(network: &str, ssid: &str) -> bool {
network.trim() == "lan" && ssid.trim().starts_with("TollGate-")
}
/// Return the first available wireless radio device name (e.g. "radio0").
fn detect_radio(router: &Router) -> Result<String> {
let out =
@@ -126,3 +181,19 @@ fn detect_radio(router: &Router) -> Result<String> {
let radio = out.trim().split('.').nth(1).unwrap_or("radio0").to_string();
Ok(radio)
}
#[cfg(test)]
mod tests {
use super::should_regate_upstream_ap;
#[test]
fn regates_only_confirmed_upstream_tollgate_aps() {
assert!(should_regate_upstream_ap("lan", "TollGate-3458"));
assert!(should_regate_upstream_ap(" lan\n", " TollGate-A1B2 "));
assert!(!should_regate_upstream_ap("lan", "OpenWrt"));
assert!(!should_regate_upstream_ap("lan", "Archipelago Admin"));
assert!(!should_regate_upstream_ap("tollgate", "TollGate-3458"));
assert!(!should_regate_upstream_ap("lan", "tollgate-3458"));
}
}
+40
View File
@@ -0,0 +1,40 @@
FROM docker.io/library/node:24-alpine AS build
# GitWorkshop has no release artifacts, so pin the audited source revision.
# The fetch verifies that the exact requested commit was checked out before any
# dependency or build command runs.
ARG GITWORKSHOP_COMMIT=dc36db64f6a2cca29d109829eabaf0a49d4bf4da
RUN apk add --no-cache git
WORKDIR /src
RUN git init \
&& git remote add origin https://github.com/DanConwayDev/gitworkshop.git \
&& git fetch --depth=1 origin "${GITWORKSHOP_COMMIT}" \
&& git checkout --detach FETCH_HEAD \
&& test "$(git rev-parse HEAD)" = "${GITWORKSHOP_COMMIT}"
COPY gitworkshop-archipelago.patch /tmp/gitworkshop-archipelago.patch
RUN git apply --check /tmp/gitworkshop-archipelago.patch \
&& git apply /tmp/gitworkshop-archipelago.patch
COPY gitworkshop-dependencies.patch /tmp/gitworkshop-dependencies.patch
RUN git apply --check /tmp/gitworkshop-dependencies.patch \
&& git apply /tmp/gitworkshop-dependencies.patch
RUN npm ci \
&& npm audit --audit-level=moderate
RUN APP_BASE_PATH=/app/archipelago-source/ \
APP_RELEASE_VERSION="archipelago-${GITWORKSHOP_COMMIT}" \
npm run build
FROM docker.io/library/nginx:1.27.4-alpine
COPY --from=build /src/dist/ /usr/share/nginx/html/
COPY nginx-main.conf /etc/nginx/nginx.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY UPSTREAM.md /usr/share/doc/archipelago-source/UPSTREAM.md
# Run both nginx master and workers as the packaged unprivileged user. Writable
# runtime paths live on the manifest's small mode-1777 `/tmp` tmpfs, so the
# container needs neither Linux capabilities nor a writable root filesystem.
EXPOSE 8337
ENTRYPOINT []
USER nginx
CMD ["nginx", "-g", "daemon off;"]
+31
View File
@@ -0,0 +1,31 @@
# GitWorkshop upstream
This image packages the GitWorkshop NIP-34 web client from:
- Source: https://github.com/DanConwayDev/gitworkshop
- Pinned commit: `dc36db64f6a2cca29d109829eabaf0a49d4bf4da`
- Upstream project: https://gitworkshop.dev/
- App icon: `public/icons/icon.svg` from the same pinned revision (the artwork
is only inset onto Archipelago's standard icon safe area).
The Archipelago integration patch only makes the upstream Vite/React
application work below Archipelago's `/app/archipelago-source/` mount, injects
the existing consent-gated Archipelago NIP-07 provider, disables the
development-only `localhost:4869` cache-relay probe, and removes two unreachable
lookup relays from the defaults. It does not replace GitWorkshop's NIP-34,
GRASP, repository browser, issue, pull-request, or review interfaces.
The separate dependency patch refreshes the npm lockfile and moves `fflate` to
0.8.3, `react-router-dom` to 7.18.3, and Vitest to 5.0.0. The resulting clean
install reports zero npm advisories; its type-check, 152 unit tests, and
Archipelago subpath production build pass. Keeping this mechanical security
update separate makes both the upstream integration and future dependency
refreshes auditable.
The pinned revision and current upstream `main` do not contain a license file,
the package metadata declares no license, and GitHub reports no detected
license. Archipelago's owner explicitly accepted the resulting redistribution
risk on 2026-09-11. This is a project risk decision, not a claim that
GitWorkshop is licensed or that downstream recipients receive rights from its
copyright holders. An explicit upstream license remains the preferred,
auditable resolution.
@@ -0,0 +1,418 @@
diff --git a/index.html b/index.html
index 6894507..a917f5d 100644
--- a/index.html
+++ b/index.html
@@ -14,7 +14,7 @@
property="og:description"
content="Decentralized GitHub alternative over Nostr"
/>
- <meta property="og:image" content="/og-image.png" />
+ <meta property="og:image" content="%BASE_URL%og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta
@@ -22,15 +22,19 @@
content="%APP_NAME% — git collaboration without the platform"
/>
<meta name="twitter:card" content="summary_large_image" />
- <meta name="twitter:image" content="/og-image.png" />
+ <meta name="twitter:image" content="%BASE_URL%og-image.png" />
<meta
http-equiv="content-security-policy"
- content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
+ content="default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; frame-src 'self' http: https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:"
/>
- <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
- <link rel="icon" type="image/png" href="/favicon.png" />
- <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
- <link rel="manifest" href="/manifest.webmanifest" />
+ <link rel="icon" type="image/svg+xml" href="%BASE_URL%favicon.svg" />
+ <link rel="icon" type="image/png" href="%BASE_URL%favicon.png" />
+ <link rel="apple-touch-icon" href="%BASE_URL%icons/apple-touch-icon.png" />
+ <link
+ rel="manifest"
+ href="/manifest.webmanifest"
+ crossorigin="use-credentials"
+ />
<style>
body {
margin: 0;
@@ -41,7 +45,9 @@
background: #16171e;
}
</style>
- <script src="/theme-init.js"></script>
+ <script src="%BASE_URL%theme-init.js"></script>
+ <script src="%BASE_URL%archipelago-nostrdb-config.js"></script>
+ <script data-no-nip98 src="/nostr-provider.js?v=tab-signer-v4"></script>
</head>
<body>
<div id="root">
@@ -119,7 +125,7 @@
<div id="splash-content">
<img
class="splash-logo"
- src="/icons/icon-192x192.png"
+ src="%BASE_URL%icons/icon-192x192.png"
width="64"
height="64"
alt=""
diff --git a/public/archipelago-nostrdb-config.js b/public/archipelago-nostrdb-config.js
new file mode 100644
index 0000000..8f598c4
--- /dev/null
+++ b/public/archipelago-nostrdb-config.js
@@ -0,0 +1,7 @@
+// window.nostrdb.js probes a developer-only relay at localhost:4869 unless
+// configured before the app module graph loads. On an installed node that
+// address means the user's own device, can never be the app's cache relay,
+// and is correctly blocked by GitWorkshop's production CSP.
+window.nostrdbConfig = Object.assign({}, window.nostrdbConfig || {}, {
+ localRelays: [],
+});
diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx
index 0f681e7..16dff02 100644
--- a/src/AppRouter.tsx
+++ b/src/AppRouter.tsx
@@ -20,6 +20,8 @@ import { MaintainerAcceptanceMonitor } from "./components/MaintainerAcceptanceMo
import { useRepoPath } from "./hooks/useRepoPath";
import { REPO_KIND } from "./lib/nip34";
import { getGitWorkshopPath } from "./lib/gitworkshopUrl";
+import { useLoginActions } from "./hooks/useLoginActions";
+import { accounts } from "./services/accounts";
/**
* Handles public GitWorkshop links in native builds. This stays inside the
@@ -365,9 +367,46 @@ function LegacyRedirect() {
return <RepoLayout />;
}
+/** Turn an eager node identity choice into GitWorkshop's extension login. */
+function ArchipelagoIdentityLogin() {
+ const login = useLoginActions();
+ const loginRef = useRef(login.extension);
+ const loginRunning = useRef(false);
+ loginRef.current = login.extension;
+
+ useEffect(() => {
+ const bridge = (
+ window as Window & {
+ archipelagoNostr?: {
+ onIdentitySelected?: (
+ callback: (identity: { nostr_pubkey: string }) => void,
+ ) => () => void;
+ };
+ }
+ ).archipelagoNostr;
+ if (!bridge?.onIdentitySelected) return;
+
+ return bridge.onIdentitySelected(() => {
+ if (accounts.getActive() || loginRunning.current) return;
+ loginRunning.current = true;
+ void loginRef
+ .current()
+ .catch((error) => {
+ console.error("Archipelago automatic login failed:", error);
+ })
+ .finally(() => {
+ loginRunning.current = false;
+ });
+ });
+ }, []);
+
+ return null;
+}
+
function AppRouter() {
return (
- <BrowserRouter>
+ <BrowserRouter basename={import.meta.env.BASE_URL}>
+ <ArchipelagoIdentityLogin />
<NativeGitWorkshopLinks />
<NativeAndroidBackButton />
<ScrollToTop />
diff --git a/src/components/AppFooter.tsx b/src/components/AppFooter.tsx
index 3eb76d2..22b079b 100644
--- a/src/components/AppFooter.tsx
+++ b/src/components/AppFooter.tsx
@@ -62,7 +62,7 @@ export function AppFooter() {
className="flex items-center gap-2 hover:opacity-80 transition-opacity w-fit"
>
<img
- src="/icons/icon.svg"
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
alt="GitWorkshop"
className="h-6 w-6"
/>
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index b31aa79..cec9d39 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -155,7 +155,11 @@ export function AppHeader() {
to="/"
className="group transition-opacity hover:opacity-80 shrink-0"
>
- <img src="/icons/icon.svg" alt="GitWorkshop" className="h-8 w-8" />
+ <img
+ src={`${import.meta.env.BASE_URL}icons/icon.svg`}
+ alt="GitWorkshop"
+ className="h-8 w-8"
+ />
</Link>
<div className="flex items-center gap-2 ml-auto">
diff --git a/src/main.tsx b/src/main.tsx
index 1e4fead..ae3a045 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -10,9 +10,11 @@ import "@fontsource-variable/inter";
// itself, so subsequent loads are fully uncontrolled. Capacitor packages local
// assets and does not use this web-deployment cleanup worker.
if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
- navigator.serviceWorker.register("/sw.js").catch(() => {
- /* ignore — browser may block in certain envs */
- });
+ navigator.serviceWorker
+ .register(`${import.meta.env.BASE_URL}sw.js`)
+ .catch(() => {
+ /* ignore — browser may block in certain envs */
+ });
}
createRoot(document.getElementById("root")!).render(
diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx
index 18e3593..685c422 100644
--- a/src/pages/NotFound.tsx
+++ b/src/pages/NotFound.tsx
@@ -28,7 +28,7 @@ const NotFound = () => {
Oops! Page not found
</p>
<a
- href="/"
+ href={import.meta.env.BASE_URL}
className="text-blue-500 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 underline"
>
Return to Home
diff --git a/src/services/settings.ts b/src/services/settings.ts
index 8f9a1a7..5438a72 100644
--- a/src/services/settings.ts
+++ b/src/services/settings.ts
@@ -124,8 +124,6 @@ export const fallbackRelaysCustomised$ = isCustomised$(
* These are used by the event loaders to find events more efficiently.
*/
export const DEFAULT_LOOKUP_RELAYS = normalizeRelayList([
- "wss://purplepag.es",
- "wss://index.hzrd149.com",
"wss://indexer.coracle.social",
]);
diff --git a/vite.config.ts b/vite.config.ts
index 0534fb9..d94fc70 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -39,37 +39,38 @@ function htmlAppNamePlugin(): Plugin {
*/
function manifestPlugin(): Plugin {
const virtualId = "/manifest.webmanifest";
+ const appBase = process.env.APP_BASE_PATH ?? "/";
const manifest = JSON.stringify(
{
name: "GitWorkshop.dev",
short_name: "GitWorkshop",
description: "Decentralized GitHub alternative over Nostr",
- start_url: "/",
+ start_url: appBase,
display: "standalone",
background_color: "#16171e",
theme_color: "#16171e",
categories: ["development", "productivity", "utilities"],
icons: [
{
- src: "/icons/icon-192x192.png",
+ src: `${appBase}icons/icon-192x192.png`,
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
- src: "/icons/icon-512x512.png",
+ src: `${appBase}icons/icon-512x512.png`,
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
- src: "/icons/pwa-maskable-192x192.png",
+ src: `${appBase}icons/pwa-maskable-192x192.png`,
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
- src: "/icons/pwa-maskable-512x512.png",
+ src: `${appBase}icons/pwa-maskable-512x512.png`,
sizes: "512x512",
type: "image/png",
purpose: "maskable",
@@ -82,6 +83,12 @@ function manifestPlugin(): Plugin {
return {
name: "manifest",
+ transformIndexHtml(html) {
+ return html.replace(
+ 'href="/manifest.webmanifest"',
+ `href="${appBase}manifest.webmanifest"`,
+ );
+ },
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url === virtualId) {
@@ -104,6 +111,10 @@ function manifestPlugin(): Plugin {
// https://vitejs.dev/config/
export default defineConfig(() => ({
+ // Archipelago serves GitWorkshop behind the dashboard origin. Vite's base
+ // controls emitted asset URLs while BrowserRouter consumes the same value
+ // below, so repository routes remain valid below that mount point.
+ base: process.env.APP_BASE_PATH ?? "/",
define: {
__APP_NAME__: JSON.stringify(name),
__APP_RELEASE_VERSION__: JSON.stringify(
diff --git a/src/components/auth/AccountSwitcher.tsx b/src/components/auth/AccountSwitcher.tsx
index f59a7d2..3168910 100644
--- a/src/components/auth/AccountSwitcher.tsx
+++ b/src/components/auth/AccountSwitcher.tsx
@@ -46,7 +46,7 @@ function SignerTypeBadge({ account }: { account: IAccount }) {
return (
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
<Puzzle className="w-3 h-3" />
- Extension
+ Extension / Archipelago
</span>
);
if (account instanceof NostrConnectAccount)
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx
index 11f6716..0bba6a2 100644
--- a/src/components/auth/LoginDialog.tsx
+++ b/src/components/auth/LoginDialog.tsx
@@ -239,9 +239,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
try {
if (!("nostr" in window)) {
throw new Error(
- "Nostr extension not found. Please install a NIP-07 extension.",
+ "No NIP-07 signer found. Open GitWorkshop through Archipelago or install a browser extension.",
);
}
+ const archipelago = (
+ window as Window & {
+ archipelagoNostr?: { selectIdentity?: () => Promise<unknown> };
+ }
+ ).archipelagoNostr;
+ if (archipelago?.selectIdentity) await archipelago.selectIdentity();
await login.extension();
onLogin();
onClose();
@@ -437,7 +443,9 @@ const LoginDialog: React.FC<LoginDialogProps> = ({
disabled={isLoading}
>
<Puzzle className="w-4 h-4" />
- {isLoading ? "Logging in..." : "Log in with Extension"}
+ {isLoading
+ ? "Logging in..."
+ : "Log in with Extension / Archipelago"}
</Button>
)}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index b8de377..7f72f31 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -23,6 +23,7 @@ import {
ChevronUp,
Pin,
Search,
+ Globe2,
} from "lucide-react";
import { CreateRepoDialog } from "@/components/CreateRepoDialog";
import { Button } from "@/components/ui/button";
@@ -36,6 +37,7 @@ import { useUserActivity } from "@/hooks/useUserActivity";
import { useUserRepositories } from "@/hooks/useUserRepositories";
import { useUserFollowedRepos } from "@/hooks/useUserFollowedRepos";
import { useUserPinnedCoords } from "@/hooks/useUserPinnedRepos";
+import { useRepositorySearch } from "@/hooks/useRepositorySearch";
import { useNotifications } from "@/hooks/useNotifications";
import { useUserProfileSubscription } from "@/hooks/useUserProfileSubscription";
import { useUserPath } from "@/hooks/useUserPath";
@@ -409,6 +411,63 @@ function FollowedReposPanel({ pubkey }: { pubkey: string }) {
);
}
+// ---------------------------------------------------------------------------
+// Recent repositories from the wider Nostr network
+// ---------------------------------------------------------------------------
+
+function NetworkRepositoriesPanel() {
+ const { repos, isLoading } = useRepositorySearch("");
+ const recent = repos?.slice(0, 6);
+
+ return (
+ <div className="h-fit">
+ <div className="pb-3 flex items-center justify-between gap-3">
+ <h3 className="text-base font-semibold flex items-center gap-2">
+ <Globe2 className="h-4 w-4 text-muted-foreground" />
+ Nostr network
+ </h3>
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
+ asChild
+ >
+ <Link to="/search">
+ Browse all
+ <ArrowRight className="h-3 w-3 ml-1" />
+ </Link>
+ </Button>
+ </div>
+
+ {recent === undefined || (isLoading && recent.length === 0) ? (
+ <div className="space-y-1">
+ {Array.from({ length: 5 }).map((_, i) => (
+ <RepoRowSkeleton key={i} />
+ ))}
+ </div>
+ ) : recent.length > 0 ? (
+ <div className="space-y-0.5">
+ {recent.map((repo) => (
+ <RepoListItem
+ key={`${repo.selectedMaintainer}:${repo.dTag}`}
+ repo={repo}
+ />
+ ))}
+ </div>
+ ) : (
+ <div className="py-6 text-center">
+ <p className="text-sm text-muted-foreground">
+ No network repositories available
+ </p>
+ <p className="text-xs text-muted-foreground/60 mt-1">
+ Check the git index relay in Settings
+ </p>
+ </div>
+ )}
+ </div>
+ );
+}
+
// ---------------------------------------------------------------------------
// Embedded notifications panel (compact, inbox only, max 5)
// ---------------------------------------------------------------------------
@@ -591,6 +650,8 @@ export function Dashboard() {
<MyRepositoriesPanel pubkey={pubkey} />
<Separator className="opacity-40" />
<FollowedReposPanel pubkey={pubkey} />
+ <Separator className="opacity-40" />
+ <NetworkRepositoriesPanel />
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
worker_processes auto;
pid /tmp/nginx.pid;
error_log /dev/stderr notice;
events {
worker_connections 256;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
keepalive_timeout 65;
client_body_temp_path /tmp/client_temp;
proxy_temp_path /tmp/proxy_temp;
fastcgi_temp_path /tmp/fastcgi_temp;
uwsgi_temp_path /tmp/uwsgi_temp;
scgi_temp_path /tmp/scgi_temp;
include /etc/nginx/conf.d/*.conf;
}
+42
View File
@@ -0,0 +1,42 @@
server {
# Host networking is required for the loopback-only Archipelago RPC.
# Keep nginx itself on loopback so the authenticated app gate owns every
# externally reachable listener.
listen 127.0.0.1:8337;
server_name _;
root /usr/share/nginx/html;
index index.html;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = /manifest.webmanifest {
default_type application/manifest+json;
try_files $uri =404;
}
location = /app/archipelago-source/manifest.webmanifest {
default_type application/manifest+json;
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
try_files $uri =404;
}
# The normal dashboard proxy strips this prefix before forwarding, while
# direct app-gate access preserves it. Supporting both keeps health/debug
# access useful without making launch depend on any particular interface.
location ^~ /app/archipelago-source/ {
rewrite ^/app/archipelago-source/(.*)$ /$1 break;
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
location / {
try_files $uri $uri/ /index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
}
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Error</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>An error occurred.</h1>
<p>Sorry, the page you are looking for is currently unavailable.<br/>
Please try again later.</p>
<p>If you are the system administrator of this resource then you should check
the error log for details.</p>
<p><em>Faithfully yours, nginx.</em></p>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
COPY 50x.html /usr/share/nginx/html/
COPY assets/ /usr/share/nginx/html/assets/
# Unlike bitcoin-ui, the nginx.conf is baked into the image, not
# bind-mounted: there is no secret to render in. Cuprate's restricted RPC
# (the only upstream this UI proxies) is unauthenticated by design —
# Monero's safe-for-public subset — so there is nothing to substitute at
# start time and no rotation to follow.
COPY nginx.conf /etc/nginx/conf.d/default.conf
#
# Run nginx as root to avoid chown failures in rootless Podman user
# namespaces. The rest of the nginx image is unchanged.
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 18091
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

+407
View File
@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Cuprate Node - Archipelago</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', sans-serif;
min-height: 100vh;
background: #000;
color: white;
overflow-x: hidden;
}
.bg-layer {
position: fixed;
inset: 0;
z-index: -10;
background-image: url('assets/img/bg-network.jpg');
background-size: cover;
background-position: center;
opacity: .42;
}
.glass-card {
position: relative;
background: rgba(0, 0, 0, 0.60);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
border-radius: 1rem;
padding: 1.5rem;
}
.glass-button {
background-color: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(18px);
border: 1px solid rgba(255, 255, 255, 0.18);
color: rgba(255, 255, 255, 0.9);
border-radius: 0.5rem;
padding: 0.4rem 0.9rem;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.3s ease;
}
.glass-button:hover { color: white; background-color: rgba(0, 0, 0, 0.7); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 2rem 1rem 3rem; }
/* Match the Bitcoin dashboard's app-header rhythm: identity on the
left, compact live status cards on the right. */
.app-header { display:flex; align-items:center; justify-content:space-between; gap:1.5rem; flex-wrap:wrap; }
.app-header-id { display:flex; align-items:center; gap:1rem; min-width:0; flex:1 1 auto; }
.app-header-actions { display:flex; align-items:center; justify-content:flex-end; gap:.75rem; flex:0 0 auto; flex-wrap:nowrap; margin-left:auto; }
.info-card { display:flex; align-items:center; gap:.75rem; background:rgba(0,0,0,.6); backdrop-filter:blur(24px); border-radius:.75rem; padding:.65rem .85rem; box-shadow:inset 0 1px 0 rgba(255,255,255,.16); min-height:3.25rem; }
.info-card .card-icon { width:1.25rem; height:1.25rem; color:rgba(255,255,255,.6); flex:0 0 auto; }
.info-card-copy { display:flex; flex-direction:column; gap:.15rem; }
.info-card-copy .sub { margin:0; font-size:.6875rem; }
.info-card-copy strong { font-size:.8125rem; font-weight:600; color:rgba(255,255,255,.95); white-space:nowrap; }
.node-mark { width:3.25rem; height:3.25rem; border-radius:.85rem; display:grid; place-items:center; background:#050505; border:1px solid rgba(255,255,255,.18); box-shadow:0 8px 24px rgba(0,0,0,.55),inset 0 1px 0 rgba(255,255,255,.2); }
.tabbar { display:flex; gap:.35rem; padding:.35rem; margin:1.25rem 0; background:rgba(0,0,0,.62); border:1px solid rgba(255,255,255,.12); border-radius:.85rem; overflow-x:auto; }
.tab-btn { flex:1 0 auto; border:0; border-radius:.6rem; padding:.65rem 1rem; background:transparent; color:rgba(255,255,255,.55); cursor:pointer; font-size:.8rem; font-weight:600; }
.tab-btn:hover { color:#fff; background:rgba(255,255,255,.06); }
.tab-btn.active { color:#fff; background:linear-gradient(135deg,rgba(247,147,26,.3),rgba(255,255,255,.08)); box-shadow:inset 0 1px 0 rgba(255,255,255,.15); }
[data-panel].tab-hidden { display:none; }
@media (max-width:700px) {
.wrap { padding:1rem 1rem calc(6.75rem + env(safe-area-inset-bottom, 0px)); }
.app-header { flex-direction:column; align-items:stretch; gap:1rem; }
.app-header-id { flex-direction:column; justify-content:center; text-align:center; }
.app-header-text { text-align:center; }
.app-header-actions { flex-direction:column; align-items:stretch; justify-content:center; gap:.6rem; margin-left:0; }
.app-header-actions > .info-card { width:100%; }
.tabbar { position:fixed; left:.75rem; right:.75rem; bottom:calc(.5rem + env(safe-area-inset-bottom, 0px)); z-index:40; margin:0; padding:.35rem; border-radius:1rem; box-shadow:0 10px 30px rgba(0,0,0,.65); }
.tab-btn { min-width:4.5rem; padding:.7rem .5rem; font-size:.7rem; }
}
@media (min-width:701px) {
.app-header { flex-wrap:nowrap; }
.app-header-text { min-width:0; }
}
header { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
header h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -0.02em; }
header h1 .accent { color: inherit; }
.sub { color: rgba(255, 255, 255, 0.55); font-size: 0.85rem; margin-top: 0.2rem; }
.pill {
display: inline-flex; align-items: center; gap: 0.45rem;
padding: 0.35rem 0.8rem; border-radius: 999px;
font-size: 0.78rem; font-weight: 600;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.06);
}
.dot { width: 8px; height: 8px; border-radius: 50%; background: #777; }
.dot.online { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.dot.syncing { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.dot.offline { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.pill.online .dot { background: #22c55e; box-shadow: 0 0 8px #22c55e; }
.pill.syncing .dot { background: #f7931a; box-shadow: 0 0 8px #f7931a; }
.pill.offline .dot { background: #ef4444; box-shadow: 0 0 8px #ef4444; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1rem; }
.card-title {
font-size: 0.72rem; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; color: rgba(255, 255, 255, 0.5);
margin-bottom: 1rem;
}
.stat { display: flex; justify-content: space-between; align-items: baseline; padding: 0.45rem 0; border-bottom: 1px solid rgba(255, 255, 255, 0.07); }
.stat:last-child { border-bottom: none; }
.stat .label { color: rgba(255, 255, 255, 0.55); font-size: 0.82rem; }
.stat .value { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.95rem; text-align: right; }
.stat .value.warn { color: #f7931a; }
.stat .value.err { color: #ef4444; }
.stat .value.ok { color: #22c55e; }
.height-hero { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.height-hero .big { font-size: 2.4rem; font-weight: 800; font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.height-hero .of { color: rgba(255, 255, 255, 0.45); font-size: 1rem; font-variant-numeric: tabular-nums; }
.progress { height: 8px; border-radius: 999px; background: rgba(255, 255, 255, 0.1); overflow: hidden; margin: 0.5rem 0 0.35rem; }
.progress-fill { height: 100%; width: 0%; border-radius: 999px; background: linear-gradient(90deg, #f7931a, #ffc46b); transition: width 0.6s ease; }
.progress-label { font-size: 0.75rem; color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.notice {
margin-top: 1rem; padding: 0.9rem 1.1rem; border-radius: 0.75rem;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.35);
font-size: 0.82rem; line-height: 1.5; color: rgba(255, 255, 255, 0.8);
}
.notice.error { background: rgba(239, 68, 68, 0.08); border-color: rgba(239, 68, 68, 0.4); }
.notice b { color: white; }
.endpoint {
display: flex; align-items: center; gap: 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem; padding: 0.55rem 0.75rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.82rem; overflow-x: auto; white-space: nowrap;
}
.hint { font-size: 0.75rem; color: rgba(255, 255, 255, 0.45); margin-top: 0.6rem; line-height: 1.5; }
footer { margin-top: 2rem; text-align: center; color: rgba(255, 255, 255, 0.35); font-size: 0.72rem; }
</style>
</head>
<body>
<div class="bg-layer"></div>
<div class="wrap">
<header class="glass-card" style="padding:1.5rem;">
<div class="app-header">
<div class="app-header-id">
<div class="node-mark"><img src="assets/img/app-icons/cuprate.svg" alt="Cuprate" style="width:2.35rem;height:2.35rem;object-fit:contain"></div>
<div class="app-header-text">
<h1><span class="accent">Cuprate</span> Monero Node</h1>
<div class="sub">Rust implementation of the Monero protocol, on Archipelago</div>
</div>
</div>
<div class="app-header-actions">
<div class="info-card">
<div class="relative"><span class="dot" id="headerStatusDot"></span><span class="absolute inset-0 dot animate-ping opacity-50"></span></div>
<div class="info-card-copy"><div class="sub">Status</div><strong id="statusText">Connecting…</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>
<div class="info-card-copy"><div class="sub">Network</div><strong id="headerNetwork">—</strong></div>
</div>
<div class="info-card">
<svg class="card-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7h16M4 12h16M4 17h16" /></svg>
<div class="info-card-copy"><div class="sub">Height</div><strong id="headerHeight">—</strong></div>
</div>
</div>
</div>
</header>
<div id="offlineNotice" class="notice error" style="display:none;">
<b>Cuprate is not reachable.</b> The node may be stopped, still installing, or syncing.
Check the service status and try again shortly.
</div>
<nav class="tabbar" role="tablist" aria-label="Cuprate dashboard sections">
<button class="tab-btn active" data-tab="node" role="tab">Node</button>
<button class="tab-btn" data-tab="insights" role="tab">Insights</button>
<button class="tab-btn" data-tab="peers" role="tab">Peers</button>
<button class="tab-btn" data-tab="connect" role="tab">Connect</button>
</nav>
<div class="grid" style="margin-top:1rem;">
<div class="glass-card" data-panel="node">
<div class="card-title">Blockchain Sync</div>
<div class="height-hero">
<span class="big" id="height">—</span>
<span class="of" id="targetOf">of —</span>
</div>
<div class="progress"><div class="progress-fill" id="syncBar"></div></div>
<div class="progress-label" id="syncLabel">Waiting for node…</div>
<div class="stat"><span class="label">Network</span><span class="value" id="nettype">—</span></div>
<div class="stat"><span class="label">Uptime</span><span class="value" id="uptime">—</span></div>
<div class="stat"><span class="label">Node time</span><span class="value" id="nodeTime">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="peers">
<div class="card-title">Peers &amp; Traffic</div>
<div class="stat"><span class="label">Outgoing connections</span><span class="value" id="outConns">—</span></div>
<div class="stat"><span class="label">Incoming connections</span><span class="value" id="inConns">—</span></div>
<div class="stat"><span class="label">RPC connections</span><span class="value" id="rpcConns">—</span></div>
<div class="stat"><span class="label">Known peers (white)</span><span class="value" id="whitePeers">—</span></div>
<div class="stat"><span class="label">Known peers (gray)</span><span class="value" id="grayPeers">—</span></div>
<div class="stat"><span class="label">Mempool transactions</span><span class="value" id="txPool">—</span></div>
<div class="stat"><span class="label">Alt blocks</span><span class="value" id="altBlocks">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="insights">
<div class="card-title">Chain &amp; Disk</div>
<div class="stat"><span class="label">Difficulty</span><span class="value" id="difficulty">—</span></div>
<div class="stat"><span class="label">Chain size</span><span class="value" id="chainSize">—</span></div>
<div class="stat"><span class="label">Free disk</span><span class="value" id="freeSpace">—</span></div>
</div>
<div class="glass-card tab-hidden" data-panel="connect">
<div class="card-title">Connect a Wallet</div>
<div class="endpoint">
<span id="walletEndpoint">—</span>
<button class="glass-button" onclick="copyEndpoint(this)">Copy</button>
</div>
<div class="hint">
Restricted RPC — Monero's own safe-for-public subset, what Feather,
monero-wallet-rpc and the GUI use for a “remote node”. Full (unrestricted) RPC
stays container-loopback only and is never published.
</div>
<div class="stat" style="margin-top:0.9rem;"><span class="label">P2P port</span><span class="value" id="p2pPort">18183</span></div>
<div class="stat"><span class="label">Restricted RPC port</span><span class="value">18090</span></div>
</div>
</div>
<footer>
Cuprate is work-in-progress software; it independently validates Monero consensus rules.
Data served from this node's restricted RPC, refreshed every 15 seconds.
</footer>
</div>
<script>
const RPC = 'cuprate-rpc/';
const POLL_MS = 15000;
function tabular(n) { return Number(n || 0).toLocaleString('en-US'); }
function formatBytes(bytes) {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) return '—';
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
let v = n, i = 0;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i += 1; }
return `${v >= 100 || i === 0 ? tabular(Math.round(v)) : v.toFixed(1)} ${units[i]}`;
}
function formatUptime(secs) {
const s = Number(secs);
if (!Number.isFinite(s) || s < 0) return '—';
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function setStat(id, value, cls) {
const el = document.getElementById(id);
el.textContent = (value === null || value === undefined || value === '') ? '—' : value;
el.className = 'value' + (cls ? ' ' + cls : '');
}
function setStatus(kind, text) {
document.getElementById('statusText').textContent = text;
const dot = document.getElementById('headerStatusDot');
dot.className = 'dot ' + kind;
document.getElementById('offlineNotice').style.display = (kind === 'offline') ? '' : 'none';
}
async function callRpc(endpoint) {
const response = await fetch(RPC + endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
cache: 'no-store',
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data && data.error) throw new Error(data.error);
return data;
}
function render(info, heightFallback) {
const height = info.height ?? heightFallback ?? 0;
// Monero's get_info returns target_height == 0 when the node is
// FULLY SYNCED — the field is the height being caught up to, not
// the chain tip, so `??` cannot substitute for the 0 case (a
// synced node would sit forever at "Syncing — 0.00%"). Treat
// 0/absent as "target is our own height" — the same sentinel
// core/archipelago/src/electrs_status.rs branches on.
const rawTarget = info.target_height ?? info.target ?? 0;
const target = rawTarget > 0 ? rawTarget : height;
document.getElementById('height').textContent = tabular(height);
document.getElementById('targetOf').textContent = `of ${tabular(target)}`;
const pct = target > 0 ? Math.min(100, (height / target) * 100) : 0;
document.getElementById('syncBar').style.width = pct.toFixed(2) + '%';
const synced = target > 0 && height >= target;
if (synced) {
document.getElementById('syncLabel').textContent = 'Fully synced';
setStatus('online', 'Synced');
} else {
const behind = Math.max(0, target - height);
document.getElementById('syncLabel').textContent =
`${pct.toFixed(2)}% — ${tabular(behind)} blocks behind`;
setStatus('syncing', 'Syncing');
}
const nettype = info.mainnet ? 'Mainnet'
: info.testnet ? 'Testnet'
: info.stagenet ? 'Stagenet'
: (info.net || info.nettype || '—');
setStat('nettype', nettype);
document.getElementById('headerNetwork').textContent = nettype;
document.getElementById('headerHeight').textContent = tabular(height);
const nowSecs = info.time ?? info.adjusted_time ?? Math.floor(Date.now() / 1000);
const started = info.start_time ?? info.startup_time;
setStat('uptime', started ? formatUptime(nowSecs - started) : '—');
setStat('nodeTime', new Date(nowSecs * 1000).toLocaleString());
setStat('outConns', tabular(info.outgoing_connections_count));
setStat('inConns', tabular(info.incoming_connections_count));
setStat('rpcConns', tabular(info.rpc_connections_count));
setStat('whitePeers', tabular(info.white_peerlist_size));
setStat('grayPeers', tabular(info.grey_peerlist_size));
setStat('txPool', tabular(info.tx_pool_size));
const alt = Number(info.alt_blocks_count || 0);
setStat('altBlocks', tabular(alt), alt > 0 ? 'warn' : undefined);
setStat('difficulty', tabular(info.difficulty));
setStat('chainSize', formatBytes(info.blocks_size ?? info.block_sizes?.[0]));
const free = info.free_space;
const freeWarn = Number.isFinite(free) && free > 0 && free < 50 * 1024 * 1024 * 1024;
setStat('freeSpace', formatBytes(free), freeWarn ? 'warn' : undefined);
if (!document.getElementById('walletEndpoint').textContent.includes(':')) {
document.getElementById('walletEndpoint').textContent =
`${window.location.hostname}:18090`;
}
}
async function refresh() {
let height = null;
try {
const h = await callRpc('get_height');
height = h.height;
} catch { /* get_info below carries the real error */ }
try {
const info = await callRpc('get_info');
render(info, height);
} catch {
setStatus('offline', 'Node offline');
if (height !== null) document.getElementById('height').textContent = tabular(height);
}
}
function copyEndpoint(btn) {
const text = document.getElementById('walletEndpoint').textContent;
const done = () => { btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = 'Copy'; }, 1500); };
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(done).catch(() => { done(); });
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); } catch { /* best effort */ }
document.body.removeChild(ta);
done();
}
}
document.querySelectorAll('.tab-btn').forEach((button) => {
button.addEventListener('click', () => {
const tab = button.dataset.tab;
document.querySelectorAll('.tab-btn').forEach((b) => b.classList.toggle('active', b === button));
document.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('tab-hidden', panel.dataset.panel !== tab));
});
});
// Height alone answers even while get_info is warming up; if both
// fail the offline card explains the disk gate as a likely cause.
refresh();
setInterval(refresh, POLL_MS);
</script>
</body>
</html>
+56
View File
@@ -0,0 +1,56 @@
server {
# Loopback ONLY — same rule as docker/bitcoin-ui and docker/electrs-ui.
# This container is host-networked, so nginx binds the HOST's address
# directly; a bare `listen` would expose the page on LAN, Tailscale and
# the mesh with the app gate nowhere in front of it. Binding loopback lets
# the daemon claim the external addresses and authenticate them;
# see appgate::listener and apps/cuprate-ui/manifest.yml (auth: gated).
listen 127.0.0.1:18091;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Session gate for the RPC proxy below. Internal: reachable only by
# nginx's own auth_request subrequest, never by a client.
location = /_session_check {
internal;
proxy_pass http://127.0.0.1:5678/auth/session-check;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
}
# Cuprate's restricted RPC (host-published on 127.0.0.1:18090, auth: open
# — Monero's own safe-for-public subset, what remote-node wallets use).
# It injects no credentials the caller lacks, but it is still session-
# gated here so the whole companion behaves as one authenticated surface
# (same defence-in-depth bitcoin-ui applies to its credential-injecting
# proxy: loopback reaches it without the gate's challenge).
location /cuprate-rpc/ {
# Preflight carries no cookies by design — answer it before the gate,
# otherwise the browser reports an opaque CORS failure instead of a 401.
if ($request_method = OPTIONS) { return 204; }
auth_request /_session_check;
proxy_pass http://127.0.0.1:18090/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
add_header Access-Control-Allow-Origin $scheme://$http_host always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Vary "Origin" always;
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
}
# no-cache (revalidate), not no-store — same reasoning as docker/bitcoin-ui:
# a rebuilt companion image must actually be seen by the browser, while the
# ETag still saves the transfer when nothing changed.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}
+6 -7
View File
@@ -1,14 +1,13 @@
# Archipelago mempool frontend — adds a resilient nginx backend proxy.
#
# The only delta vs the upstream image is /patch/entrypoint.sh, which rewrites
# the generated nginx-mempool.conf to use `resolver` + a variable proxy_pass so
# the frontend re-resolves the backend (mempool-api) via DNS on every request.
# Without this, nginx pins the backend IP at startup and serves 502 / "offline"
# after any backend restart (podman reassigns the IP). See the script header.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0
# Keep the upstream startup logic; repair its rendered proxy configuration.
# Publish this derived image under an Archipelago-specific tag, never the
# upstream version tag that the registry mirror can overwrite.
ARG BASE=source.archipelago-foundation.org/lfg2025/mempool-frontend@sha256:d63498a109622475c913db4e3199d893f2440a451450e542923d2e55a38407a0
FROM ${BASE}
# --chmod keeps the exec bit (build runs as USER 1000, plain COPY lands root:0644
# → "not executable"). Base USER/ENTRYPOINT/CMD (1000 / /patch/entrypoint.sh /
# nginx -g "daemon off;") are inherited unchanged.
COPY --chmod=0755 entrypoint.sh /patch/entrypoint.sh
RUN cp /patch/entrypoint.sh /patch/upstream-entrypoint.sh
COPY --chmod=0755 entrypoint.sh start-nginx.sh repair-nginx.sh /patch/
+29
View File
@@ -0,0 +1,29 @@
# Mempool frontend DNS recovery
The stock v3.3.1 nginx configuration resolves `mempool-api` only when workers
start. Recreating the backend can change its Podman address while the frontend
continues to serve its static page, leaving all API/WebSocket requests offline.
Build and test the derived image before publishing:
```sh
podman build --pull=never -t source.archipelago-foundation.org/chaum/mempool-frontend:v3.3.1-archy1 docker/mempool-frontend
python3 scripts/test-mempool-dns-recovery.py
```
The base is pinned by digest. The wrapper preserves upstream runtime options,
then repairs all four local API/WebSocket routes after placeholder rendering.
DNS is cached for five seconds using the container network resolver. Explicit
rewrites preserve API prefixes and query arguments; backend absence does not
prevent nginx startup. An unexpected upstream configuration fails startup
instead of silently omitting the fix.
Use an Archipelago-specific image tag. Do not replace it with a stock upstream
mirror when updating mempool. Every upstream update must rebuild this wrapper
and pass the recovery test (backend absent, changed IP, HTTP and WebSocket
mapping, repeated repair, and frontend restart).
Publish the tested image before publishing the signed app catalog. Both the
mempool umbrella image mapping and the archy-mempool-web embedded manifest must
point at the patched image. Keep scripts/image-versions.sh in sync. The frontend
health check must reach `/api/v1/backend-info` through nginx, not only `/`.
+4 -136
View File
@@ -1,137 +1,5 @@
#!/bin/sh
__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__=${BACKEND_MAINNET_HTTP_HOST:=127.0.0.1}
__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__=${BACKEND_MAINNET_HTTP_PORT:=8999}
__MEMPOOL_FRONTEND_HTTP_PORT__=${FRONTEND_HTTP_PORT:=8080}
CONF=/etc/nginx/conf.d/nginx-mempool.conf
# ─── archipelago patch ────────────────────────────────────────────────────
# The stock frontend writes `proxy_pass http://<backend>:8999` with a literal
# hostname and NO resolver, so nginx resolves the backend IP ONCE at worker
# start and caches it for the process lifetime. Podman reassigns the backend
# container's IP whenever it is restarted/recreated (gate, OTA, crash, reboot
# re-IPAM), after which nginx keeps proxying to the dead IP → /api hangs, the
# websocket 502s, and the mempool UI shows "offline" until nginx is reloaded.
#
# Fix: force per-request DNS re-resolution via `resolver` + a variable in
# proxy_pass. Because a variable in proxy_pass disables nginx's automatic
# location→URI rewriting, each block is rewritten to preserve its original
# path mapping exactly:
# /api/v1/ws, /ws → "/" (var + "/" replaces the whole URI)
# /api/v1 → identity (no-URI proxy_pass passes $uri unchanged)
# /api/ → /api/v1/$1 (explicit rewrite, then no-URI proxy_pass)
# Operates on the __PLACEHOLDER__ tokens so the host/port sed below fills in
# the concrete values (incl. the `set $mp_backend` line). Idempotent.
# Resolver address: podman's aardvark-dns answers on the network gateway
# (e.g. 10.89.0.1), NOT Docker's 127.0.0.11. Read it from resolv.conf so this
# works on any podman network/subnet (and still falls back for Docker).
ARCHY_RESOLVER=$(awk '/^nameserver/ { print $2; exit }' /etc/resolv.conf 2>/dev/null)
ARCHY_RESOLVER=${ARCHY_RESOLVER:-127.0.0.11}
if ! grep -q 'set \$mp_backend' "$CONF"; then
awk -v res_addr="$ARCHY_RESOLVER" '
BEGIN { res = 0 }
/^[[:space:]]*location / && res == 0 {
print "\tresolver " res_addr " valid=10s ipv6=off;"
res = 1
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1\/;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\trewrite ^/api/(.*)$ /api/v1/$1 break;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
/proxy_pass http:\/\/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__\/api\/v1;/ {
print "\t\tset $mp_backend __MEMPOOL_BACKEND_MAINNET_HTTP_HOST__;"
print "\t\tproxy_pass http://$mp_backend:__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__;"
next
}
{ print }
' "$CONF" > "$CONF.archy" && mv "$CONF.archy" "$CONF"
fi
# ─── end archipelago patch ────────────────────────────────────────────────
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__/${__MEMPOOL_BACKEND_MAINNET_HTTP_HOST__}/g" /etc/nginx/conf.d/nginx-mempool.conf
sed -i "s/__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__/${__MEMPOOL_BACKEND_MAINNET_HTTP_PORT__}/g" /etc/nginx/conf.d/nginx-mempool.conf
cp /etc/nginx/nginx.conf /patch/nginx.conf
sed -i "s/__MEMPOOL_FRONTEND_HTTP_PORT__/${__MEMPOOL_FRONTEND_HTTP_PORT__}/g" /patch/nginx.conf
cat /patch/nginx.conf > /etc/nginx/nginx.conf
if [ "${LIGHTNING_DETECTED_PORT}" != "" ];then
export LIGHTNING=true
fi
# Runtime overrides - read env vars defined in docker compose
__MAINNET_ENABLED__=${MAINNET_ENABLED:=true}
__TESTNET_ENABLED__=${TESTNET_ENABLED:=false}
__TESTNET4_ENABLED__=${TESTNET_ENABLED:=false}
__SIGNET_ENABLED__=${SIGNET_ENABLED:=false}
__LIQUID_ENABLED__=${LIQUID_ENABLED:=false}
__LIQUID_TESTNET_ENABLED__=${LIQUID_TESTNET_ENABLED:=false}
__ITEMS_PER_PAGE__=${ITEMS_PER_PAGE:=10}
__KEEP_BLOCKS_AMOUNT__=${KEEP_BLOCKS_AMOUNT:=8}
__NGINX_PROTOCOL__=${NGINX_PROTOCOL:=http}
__NGINX_HOSTNAME__=${NGINX_HOSTNAME:=localhost}
__NGINX_PORT__=${NGINX_PORT:=8999}
__BLOCK_WEIGHT_UNITS__=${BLOCK_WEIGHT_UNITS:=4000000}
__MEMPOOL_BLOCKS_AMOUNT__=${MEMPOOL_BLOCKS_AMOUNT:=8}
__BASE_MODULE__=${BASE_MODULE:=mempool}
__ROOT_NETWORK__=${ROOT_NETWORK:=}
__MEMPOOL_WEBSITE_URL__=${MEMPOOL_WEBSITE_URL:=https://mempool.space}
__LIQUID_WEBSITE_URL__=${LIQUID_WEBSITE_URL:=https://liquid.network}
__MINING_DASHBOARD__=${MINING_DASHBOARD:=true}
__LIGHTNING__=${LIGHTNING:=false}
__AUDIT__=${AUDIT:=false}
__MAINNET_BLOCK_AUDIT_START_HEIGHT__=${MAINNET_BLOCK_AUDIT_START_HEIGHT:=0}
__TESTNET_BLOCK_AUDIT_START_HEIGHT__=${TESTNET_BLOCK_AUDIT_START_HEIGHT:=0}
__SIGNET_BLOCK_AUDIT_START_HEIGHT__=${SIGNET_BLOCK_AUDIT_START_HEIGHT:=0}
__ACCELERATOR__=${ACCELERATOR:=false}
__ACCELERATOR_BUTTON__=${ACCELERATOR_BUTTON:=true}
__SERVICES_API__=${SERVICES_API:=https://mempool.space/api/v1/services}
__PUBLIC_ACCELERATIONS__=${PUBLIC_ACCELERATIONS:=false}
__HISTORICAL_PRICE__=${HISTORICAL_PRICE:=true}
__ADDITIONAL_CURRENCIES__=${ADDITIONAL_CURRENCIES:=false}
# Export as environment variables to be used by envsubst
export __MAINNET_ENABLED__
export __TESTNET_ENABLED__
export __TESTNET4_ENABLED__
export __SIGNET_ENABLED__
export __LIQUID_ENABLED__
export __LIQUID_TESTNET_ENABLED__
export __ITEMS_PER_PAGE__
export __KEEP_BLOCKS_AMOUNT__
export __NGINX_PROTOCOL__
export __NGINX_HOSTNAME__
export __NGINX_PORT__
export __BLOCK_WEIGHT_UNITS__
export __MEMPOOL_BLOCKS_AMOUNT__
export __BASE_MODULE__
export __ROOT_NETWORK__
export __MEMPOOL_WEBSITE_URL__
export __LIQUID_WEBSITE_URL__
export __MINING_DASHBOARD__
export __LIGHTNING__
export __AUDIT__
export __MAINNET_BLOCK_AUDIT_START_HEIGHT__
export __TESTNET_BLOCK_AUDIT_START_HEIGHT__
export __SIGNET_BLOCK_AUDIT_START_HEIGHT__
export __ACCELERATOR__
export __ACCELERATOR_BUTTON__
export __SERVICES_API__
export __PUBLIC_ACCELERATIONS__
export __HISTORICAL_PRICE__
export __ADDITIONAL_CURRENCIES__
folder=$(find /var/www/mempool -name "config.js" | xargs dirname)
echo ${folder}
envsubst < ${folder}/config.template.js > ${folder}/config.js
exec "$@"
set -eu
# Preserve the pinned upstream entrypoint (including new runtime options).
# Apply our DNS repair only after it has rendered the nginx configuration.
exec /patch/upstream-entrypoint.sh /patch/start-nginx.sh "$@"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# Resolve the backend again after container IP changes. Run after upstream
# placeholder substitution, so the repair also works on an existing container.
set -eu
conf=${1:-/etc/nginx/conf.d/nginx-mempool.conf}
resolv=${2:-/etc/resolv.conf}
backend=${BACKEND_MAINNET_HTTP_HOST:-127.0.0.1}
port=${BACKEND_MAINNET_HTTP_PORT:-8999}
resolver=$(awk '/^nameserver/ { print $2; exit }' "$resolv")
[ -n "$resolver" ] || { echo 'No DNS resolver configured' >&2; exit 1; }
case "$resolver" in *:*) resolver="[$resolver]" ;; esac
case "$backend" in *[!a-zA-Z0-9._-]*|'') echo 'Invalid backend hostname' >&2; exit 1 ;; esac
case "$port" in *[!0-9]*|'') echo 'Invalid backend port' >&2; exit 1 ;; esac
tmp=$(mktemp "${conf}.archy.XXXXXX")
trap 'rm -f "$tmp"' EXIT HUP INT TERM
awk -v backend="$backend" -v port="$port" -v resolver="$resolver" '
BEGIN {
base = "http://" backend ":" port
print "# Archipelago: refresh backend DNS after container replacement."
print "resolver " resolver " valid=5s ipv6=off; # archy-dns"
print "resolver_timeout 3s; # archy-dns"
}
/# Archipelago: refresh backend DNS/ || /# archy-dns/ { next }
/^[[:space:]]*location[[:space:]]/ { location = $2 }
/^[[:space:]]*proxy_pass[[:space:]]/ && index($2, base) == 1 {
target = $2
sub(/;$/, "", target)
path = substr(target, length(base) + 1)
if (location != "/api/v1/ws" && location != "/ws" && location != "/api/v1" && location != "/api/") {
print "Unexpected backend location: " location > "/dev/stderr"
failed = 1; exit 1
}
if (path != "/" && path != "/api/v1" && path != "/api/v1/") {
print "Unexpected backend URI mapping" > "/dev/stderr"
failed = 1; exit 1
}
# Explicitly preserve prefix substitution and query arguments. A variable
# proxy_pass without a URI forwards the rewritten URI and original args.
print "\t\tset $mp_backend " backend ";"
if (path != location)
print "\t\trewrite ^" location "(.*)$ " path "$1 break;"
print "\t\tproxy_pass http://$mp_backend:" port ";"
count++
next
}
/proxy_pass http:\/\/\$mp_backend:/ { count++ }
{ print }
END {
if (failed || count != 4) {
print "Expected four backend proxies; refusing an incomplete DNS repair" > "/dev/stderr"
exit 1
}
}
' "$conf" > "$tmp"
cat "$tmp" > "$conf"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
/patch/repair-nginx.sh
nginx -t
exec "$@"
+243
View File
@@ -0,0 +1,243 @@
# Archipelago 1.8.12-alpha Release Plan
**Target date:** 2026-09-11
**Scope:** OTA, signed app registry, canonical Nostr source publication,
Companion 0.5.32 alpha, and an x86_64 installer ISO
**Candidate base:** `f5c0ba85` plus the release-preparation changes documented
here
## Release Outcome
This release is complete only when all of the following are true:
1. Existing nodes can install the signed OTA and retain AIUI, app data, app
manifests, Nostr identities, Lightning state, and network configuration.
2. A fresh ISO installation boots, reports `1.8.12-alpha`, and serves the same
dashboard/runtime payload as the OTA.
3. The signed app registry contains GitWorkshop, its embedded manifest, and the
registry-owned Source promotion.
4. Archipelago's canonical repository is announced by
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg` as the
stable identifier `archy`, is cloneable through `nostr://`, and opens in
GitWorkshop from the Source promotion.
5. Contributors can create an issue or proposal without gaining canonical
merge, release-tag, catalog-signing, or OTA-signing authority.
Shipping GitWorkshop alone does not satisfy item 4. The client is the interface;
the signed NIP-34 announcement and reachable Git object servers are the source
publication.
## Audited Candidate State
### Ready
- The accumulated candidate is committed and the development node is healthy.
- GitWorkshop is healthy behind the authenticated same-origin app gate.
- The app works as a top-level Companion WebView and as a browser tab; the node
signer remains consent-gated and identity-switchable.
- The Source icon, description, banner, Popular/All Apps placement, and
maintainer npub are present in the development catalog.
- Payment receipts, Minibits claim persistence, update cancellation, app
credential interstitials, Gitea limits, certificate guidance, and the pasta
listener repair are included in the candidate.
- `ngit 2.6.3` and `git-remote-nostr 2.6.3` are installed on the node.
- The existing public Gitea source and Smart HTTP clone endpoints respond with
HTTP 200.
- The release-root signer exists locally as a binary, while its mnemonic
remains correctly offline.
- ISO dependencies (`xorriso`, `mksquashfs`, and QEMU) are installed.
- The corrected release gate passed on 2026-09-11: 1,093 frontend tests,
production UI build, registry candidate/trust checks, Rust check, 114 focused
backend tests plus orchestration, and live dashboard/AIUI/RPC probes. Android
lint and the mobile Chromium signer-handoff regression also pass.
- GitWorkshop's refreshed dependency lock reports zero production or
development npm advisories. Its type-check, all 152 unit tests, and exact
Archipelago subpath production build pass with `fflate` 0.8.3, React Router
7.18.3, and Vitest 5.0.0.
- The locally served Companion candidate is 28,157,847 bytes with SHA-256
`30e0b6257f17bb5e072941ccce6fde04b6c8ae59a49b45169e1920aec1b108cf`.
- The Discover banner and automatic Companion prompt read that artifact's
no-cache metadata, display 0.5.32 build 52, and remember dismissal per build;
the ISO gate derives the same expected version from the Android project.
- The owner accepted the risk of redistributing the pinned, unlicensed
GitWorkshop revision on 2026-09-11. `NOTICE` and `UPSTREAM.md` disclose that
decision without claiming an upstream license.
### Not Yet Complete
- This checkout is not logged into `ngit` and has no Nostr repository remote or
cached kind `30617` announcement.
- The production signed catalog still contains 61 apps but not
`archipelago-source` or `storefront`; a freshly generated candidate contains
62 apps, 61 embedded manifests, GitWorkshop 0.4.0, and the Source promotion.
- The previous public Companion download was 0.5.28-debug. A 0.5.32-debug
candidate has now been built with the same certificate and can update that
alpha channel; the accepted 0.5.32-uat build remains a separate package.
- The shared debug certificate is intentionally public and its private key is
reachable in an existing public Git branch. It provides upgrade continuity,
not production authenticity. A future production Companion must use the
`com.archipelago.app` package with a privately held release key.
- The repository is large: this checkout has about 5.4 GiB of packed objects,
and the Gitea repository is about 5 GiB. Public GRASP replication must be
allowed enough time and verified rather than assumed complete.
- The provided secrets audit reports known false positives in documentation,
path-variable names, and deliberate test keys. Before Nostr publication,
record that review and ensure local Git remote credentials are rotated; local
`.git/config` credentials are not tracked and must never be copied into an
announcement or release log.
## Required Owner Inputs
These secrets must be entered interactively and must not be sent in chat,
committed, placed in shell history, or stored on the node:
1. Access to the Nostr maintainer identity matching the npub above. Prefer an
interactive NIP-46 connection through `ngit account connect`; do not pass an
nsec on a command line.
2. The 24-word Archipelago release-master mnemonic for three signing actions:
the OTA manifest, the app catalog, and the ISO checksum document.
3. A decision to publish 0.5.32 on the existing explicitly non-production
`.debug` Companion channel, omit it, or pause for a secure production-app
signing ceremony.
The shared Companion update certificate is not a production secret. It was
temporarily recovered from the public branch, verified against the distributed
0.5.28 APK, used to build the 0.5.32 alpha upgrade candidate, and removed from
the working tree. Neither it nor the UAT keystore belongs in the release commit.
## Execution Order
### Gate A — Freeze And Production Companion
1. Verify the shared Companion certificate against the existing public APK
before building anything. This is complete for the alpha candidate.
2. Build the 0.5.32 shared-key alpha APK with v1, v2, and v3 signature schemes.
3. Verify package ID, version code/name, signing digest, and install-as-upgrade
behavior on the owner's phone.
4. Stage the APK and its JSON version metadata in the dashboard payload.
5. Run formatting, frontend type checking/unit tests/build, Android lint/build,
focused backend tests, manifest validation, registry-candidate validation,
secret review, and live node smoke probes.
6. Deploy that exact build on this node and perform the short final UAT:
GitWorkshop and IndeedHub first login, identity switching, Companion WebView,
external tab, certificate install, payment receipt, update cancellation, and
credential interstitial.
Stop on any failure. Do not sign or publish a workaround build.
### Gate B — Publish Archipelago Source Through Nostr
Run Nostr publication from a disposable clean clone so `ngit init` cannot
repoint or rewrite the release checkout's remotes.
1. Confirm the clean clone matches the final candidate and contains `LICENSE`,
`CONTRIBUTING.md`, `SECURITY.md`, and `NOTICE`.
2. Connect `ngit` interactively to the canonical maintainer identity and verify
`ngit account whoami` reports the expected npub.
3. Publish identifier `archy`, title `Archipelago`, the public project
description/homepage, the existing Gitea HTTPS clone URL, and multiple
current public GRASP services. Use at least `relay.ngit.dev` and
`gitnostr.com`; add an Archipelago-operated GRASP service later without
changing the repository coordinate.
4. Allow the complete repository push to finish. Do not treat a published
announcement as proof that all Git objects arrived.
5. Verify the announcement author, identifier, relays, clone URLs, default
branch, and release tags with `ngit repo`.
6. In a second empty directory, clone the printed `nostr://` URL and compare its
`HEAD` tree to the candidate.
7. Open
`/npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg/archy`
in the packaged GitWorkshop and confirm README/code browsing works.
8. Publish a test issue/proposal from a non-maintainer identity, review it from
the maintainer identity, and confirm it cannot change canonical refs without
maintainer authorization.
9. Confirm clone/browse still work with one GRASP server unavailable. A later
infrastructure release must add an Archipelago-operated GRASP endpoint; the
signed coordinate remains stable when servers change.
The Source promotion carries the deterministic npub/identifier deep-link. It
must not be published to the registry until steps 5–8 pass.
### Gate C — Prepare And Publish OTA 1.8.12-alpha
1. Convert the top `Unreleased` changelog section into
`v1.8.12-alpha (2026-09-11)` and synchronize What's New.
2. Run `bash scripts/create-release.sh 1.8.12-alpha`. Enter the release mnemonic
once when prompted and terminate input with Ctrl-D.
3. Verify the new release commit, annotated tag, staged backend/frontend
artifacts, signed pending manifest, embedded AIUI, APK version, and clean
tree.
4. Publish with
`SKIP_ISO=1 scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
The script uploads and hashes artifacts before pushing the fleet-visible
manifest. Never push `main` manually around this ordering.
5. Verify both local and public manifests, both asset hashes, public HTTPS
download links, and update availability from this node.
6. Apply the OTA to this node first, reboot if requested, and rerun live smoke
probes before allowing the catalog publication.
### Gate D — Publish The Signed App Registry
This follows the OTA canary because GitWorkshop's manifest builds from
`/opt/archipelago/docker/archipelago-source`, which older nodes do not have.
1. Generate `releases/app-catalog.json` from the final manifests.
2. Require 62 entries, 61 embedded manifests, `archipelago-source` 0.4.0,
registry-owned storefront metadata, and the canonical Source deep-link.
3. Run strict metadata, manifest, and deployed-registry trust-floor checks.
4. Sign the exact catalog bytes using `bash scripts/sign-catalog.sh` and verify
the pinned release-root DID.
5. Commit and push only the signed catalog after the OTA canary succeeds.
6. Confirm `/api/app-catalog` verifies the signature and a fresh node can see,
install, launch, stop, start, and uninstall GitWorkshop.
### Gate E — Build, Test, Sign, And Attach The ISO
1. From clean `main` with the live signed 1.8.12 manifest and tag, run
`bash scripts/build-iso-release.sh`. Do not use `--skip-gates` or `--no-qemu`
for the publishable candidate.
2. Require the release harness, strict catalog check, full Rust test suite,
version/artifact checks, ISO mount smoke test, and QEMU boot attempt.
3. Record the ISO filename, byte size, SHA-256, build commit, and whether QEMU
was conclusive. If QEMU is inconclusive, boot the ISO on the target test
machine before publication.
4. Sign the checksum document with
`bash scripts/sign-iso-checksums.sh <iso>` and cryptographically verify it.
5. Attach the ISO, plain checksum, and signed checksum JSON by rerunning
`scripts/publish-release-assets.sh 1.8.12-alpha gitea-vps2`.
6. Verify Gitea's stored sizes and public HTTPS links, then download/hash the
public checksum documents. Flash/boot/install on real hardware before
calling the ISO generally available.
## Rollback Rules
- OTA: the live manifest remains 1.8.11-alpha until all 1.8.12 artifacts have
uploaded and verified. If canary application fails, do not publish the new
catalog; restore the previous manifest/artifacts through the established
rollback path.
- Registry: retain the previously signed catalog. Roll back by publishing a
newly signed catalog that removes GitWorkshop or restores its prior entry;
never edit signed JSON in place.
- Nostr source: repository state is authoritative only when signed by the
configured maintainer. Add replacement servers before removing failed ones,
then republish and run `ngit sync`.
- ISO: an ISO is not referenced by the OTA manifest. A failed ISO build/upload
cannot block or corrupt an already verified OTA; do not advertise a partial
attachment.
- Companion: retain 0.5.28 and the verified alpha signer until 0.5.32 installs
as an upgrade. Never replace the public APK with the differently signed UAT
build. Do not describe the public debug-key channel as production-secure.
## Final Release Record
Before announcing completion, record in the release notes:
- release/tag/commit and signed manifest DID;
- backend and frontend SHA-256 values;
- signed catalog commit, app count, and verification result;
- canonical `nostr://` URL, NIP-34 coordinate, GRASP/clone endpoints, and clone
verification commit;
- Companion package/version/signing digest and APK SHA-256;
- ISO filename/size/SHA-256/signature and smoke/QEMU/real-hardware results;
- owner UAT acceptance and the explicit GitWorkshop no-license/dependency-risk
decisions.
+1 -1
View File
@@ -132,7 +132,7 @@ curl -s http://<node>/rpc/v1 -b jar.txt -H 'Content-Type: application/json' \
Login returns a `session` cookie. State-changing calls also need the `X-CSRF-Token` header. Exactly twelve read-only methods are CSRF-exempt, so for those the cookie alone is enough:
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-version` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
`node-messages-received` · `server.echo` · `server.get-state` · `system.stats` · `system.get-settings` · `system.get-node-key` · `system.get-metrics` · `system.get-hostname` · `tor.status` · `tor.onion-addresses` · `bitcoin.relay-status` · `federation.list-nodes`
Anything not on that list — including `bitcoin.getinfo` and `monitoring.current` — needs the CSRF header. If TOTP is enabled, follow the login with `auth.login.totp`.
+1 -1
View File
@@ -129,7 +129,7 @@ The ISO redistributes a full Debian (trixie) system plus ~29 container image tar
- [ ] **GPL source offer for the ISO** — kernel, GRUB, busybox/live-boot, coreutils, nftables, cryptsetup, wireguard-tools, SYSLINUX `isohdpfx.bin`, etc. Easiest compliance: keep `/usr/share/doc/*/copyright` (the build already does ✓) **and** publish, per release, either a mirror of the exact Debian source packages (`apt-get source` snapshot / snapshot.debian.org pointer) or a written offer in the docs. Add this to the release checklist.
- [ ] **AGPLv3 images redistributed** (mempool, Grafana, Vaultwarden, SearXNG, PhotoPrism, Nextcloud, Immich, CryptPad, MinIO): AGPL compliance = make corresponding source available. You ship a **modified** mempool-frontend (`docker/mempool-frontend` entrypoint patch) — the patch is in-repo, so compliance is met once the repo is public; state this in docs. For unmodified images, link upstream sources in the app catalog.
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, AdGuard Home, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **GPLv2/GPLv3 images** (MariaDB, Jellyfin, strfry): unmodified redistribution → provide license text + upstream source links (a `license` + `sourceUrl` field per `app-catalog/catalog.json` entry solves this catalog-wide).
- [ ] **Non-free firmware** (firmware-realtek/iwlwifi/misc/linux-nonfree, intel/amd microcode): redistributable but proprietary — disclose in docs ("includes non-free firmware for hardware support"), like Debian's own non-free-firmware ISOs do.
- [ ] The ISO build's live-server image capture (`podman save` of whatever matches on the dev server) is a compliance hazard — bundle only from the audited image list.
- [ ] FIPS daemon (jmcorgan/fips v0.4.1, MIT ✓) and nostr-rs-relay binary (MIT ✓): include their license texts in the notices bundle.
+8
View File
@@ -3,6 +3,14 @@
Working backlog of forward-looking items not yet scoped into a dedicated plan
doc. See [`ROADMAP.md`](ROADMAP.md) for the curated, public-facing direction.
## Blocking incident — before unrelated work
- **OPEN: Framework LND startup / missing Receive address / false zero balance.**
User requires investigation and a verified fix on the actual node before later
unrelated work. Access is pending; a manual LND restart is only a workaround.
See [incident evidence and closure criteria](incident-framework-lnd-startup.md)
and the repository `AGENTS.md` session-start instructions.
## Dev & build process (priority)
- Formalize the contributor workflow: releases, CI, maintainers, automated
+211 -29
View File
@@ -31,9 +31,6 @@ app:
entrypoint: ["sh", "-lc"]
custom_args:
- /app/start.sh
derived_env:
- key: PUBLIC_URL
template: https://{{HOST_MDNS}}:8180
secret_env:
- key: APP_PASSWORD
secret_file: my-app-password
@@ -55,6 +52,8 @@ app:
- host: 8180
container: 8080
protocol: tcp
bind: 127.0.0.1
auth: gated
volumes:
- type: bind
@@ -125,13 +124,59 @@ app:
| `app.environment` | Static `KEY=value` environment entries |
| `app.health_check` | HTTP or TCP health check settings |
| `app.devices` | Explicit device paths |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and [launch hints](#browser-iframe-and-companion-launch-modes) |
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
### Iframe embedding — the rules
#### What Archipelago decides, and what the app must declare
Archipelago works out the reachable hostname and browser scheme at launch
time. An app must not bake a LAN IP, Tailscale IP, FIPS address, `.local`
name, or the dashboard's current `http`/`https` scheme into its UI URL.
Declare the UI once in `interfaces.main`, put the matching port behind the
app gate, and use relative URLs for the app's own assets and links.
| Concern | App author | Archipelago |
|---|---|---|
| UI location | Declare `interfaces.main.port`, `protocol`, and `path` | Uses the address through which this browser reached the node |
| Exposure | Bind a `gated`/`open` port to `127.0.0.1` | Publishes it on supported LAN, Tailscale, FIPS, and Tor ingress |
| HTTP/HTTPS | Serve the declared upstream protocol locally | Keeps HTTP pages on HTTP; on an HTTPS dashboard, gate-fronted app ports use HTTPS on the same port |
| Embedded or top-level | Default to iframe; declare an exception when required | Chooses iframe, browser tab, or companion-native view from generated launch metadata |
| Navigation | Use relative same-app URLs and normal absolute external URLs | Preserves the selected node address and routes external links out of the companion app view |
`interfaces.main.protocol` describes the service behind the gate. It does
not tell application code to hard-code that scheme into browser links: the
gate can terminate TLS in front of a locally plain-HTTP container.
There are two important limits:
- `auth: none` bypasses the gate, so Archipelago cannot add TLS or make that
port safe to embed from an HTTPS dashboard. Use it for protocols, not
ordinary web UIs.
- Same-origin mounts such as `/app/archipelago-source/` are platform-owned
integrations. A normal app cannot request an arbitrary dashboard path in
its manifest; use `interfaces.main` and a gated port.
When the platform does provide one of those same-origin mounts, the nginx
location must pass its exact mount as `X-Forwarded-Prefix` to the app gate:
```nginx
location /app/example/ {
proxy_pass http://127.0.0.2:8123/;
proxy_set_header X-Forwarded-Prefix /app/example;
}
```
The trailing slash on `proxy_pass` strips the mount from the upstream request;
the header lets the gate put it back into its login form, login-page assets,
and successful redirect. Omitting it makes a fresh mobile-browser session post
to the dashboard's root `/__archipelago-gate/login`, which is not an app-gate
endpoint and will normally return 405. This header is host integration config,
not app-controlled manifest metadata, and must be a fixed literal path.
The dashboard opens apps in an **embedded frame** (My Apps → app session) by
default. Whether that works is decided by HTTP headers, not by wishes, so
know the mechanics:
@@ -145,7 +190,7 @@ know the mechanics:
behind Archipelago's app gate the clickjacking threat those headers address
is already handled — every proxied request is authenticated by the gate
first.
- Therefore **the gate neutralizes frame blocking on proxied responses**: it
- Therefore **the gate neutralizes frame blocking on gate-fronted responses**: it
removes `X-Frame-Options` and strips only the `frame-ancestors` directive
from the app's CSP. The rest of the app's CSP (script-src, connect-src, …)
passes through untouched — the gate never weakens the app's own content
@@ -214,6 +259,37 @@ underscores. Supported interface types are `ui`, `api`, and `metrics`; only
`type: ui` is treated as a launchable app surface. Supported protocols are
`http` and `https`, and `path` must start with `/`.
### Browser, iframe, and companion launch modes
Launch behavior is generated from the manifest. Application code should not
sniff for a particular node IP or companion user-agent.
```yaml
metadata:
launch:
# Use only for OAuth/WebAuthn, JS frame-busting, or another top-level
# browser requirement that the gate cannot repair.
open_in_new_tab: false
# Keep a different, app-specific parent-frame integration alive in the
# Android companion. Standard Archipelago NIP-07 no longer needs this.
requires_host_frame: false
```
- Desktop/PWA: iframeable apps stay in the dashboard.
`open_in_new_tab: true` apps open in a browser tab.
- Android companion: ordinary apps open in the native in-app browser with its
own navigation controls. `requires_host_frame: true` apps stay in the
dashboard iframe so `window.parent.postMessage` integrations remain alive.
- Never set both flags. A top-level page cannot simultaneously require its
parent frame.
- Relative app paths are resolved against the active dashboard origin before
a native launch, so the same package works through LAN, Tailscale, and FIPS.
Test all four relevant paths before submission: HTTP dashboard iframe, HTTPS
dashboard iframe with the node CA installed, companion launch, and every
external link or login redirect that leaves the app.
### Nostr Signer Bridge (NIP-07)
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
@@ -221,43 +297,149 @@ events without managing their own keys. Archipelago injects a **NIP-07 provider*
(`window.nostr` with `getPublicKey()` / `signEvent()` / `nip04` / `nip44`) that bridges
to the host. Your app code uses standard NIP-07 — no Archipelago-specific API.
**How injection works.** After install, the host copies `nostr-provider.js` into the
app container and patches the app's web server so every page loads it and the app is
iframe-embeddable. This is **best-effort** and depends on your server config exposing
the right hooks. For an **nginx-served SPA** (the supported reference shape, e.g.
IndeeHub) your `nginx.conf` must satisfy this contract:
**How injection works.** The dashboard owns the consent UI and postMessage
host, and ships the canonical `nostr-provider.js`, but generic containers are
not silently rewritten. Package the provider explicitly with a manifest
`copy_from_host` hook (or bake the same provider into the image) and inject it
into every HTML document your app serves. IndeeHub's manifest is the
hook-based reference; Archipelago Source's outer same-origin nginx mount is a
platform-owned reference.
1. **Be iframe-embeddable.** Do not send a hard `X-Frame-Options: DENY`. The host
strips a `SAMEORIGIN`/`DENY` `X-Frame-Options` header line if present; restrictive
CSP `frame-ancestors` will still block embedding.
2. **Keep an exact-match `location = /sw.js {` block.** The provider's no-cache
`location = /nostr-provider.js` block is inserted immediately before it.
3. **Keep an SPA fallback line `try_files $uri $uri/ /index.html;`.** A
`sub_filter` that injects `<script src="/nostr-provider.js"></script>` before
`</head>` is inserted right after it. (nginx must have `ngx_http_sub_module` —
stock `nginx:alpine` does.)
For an **nginx-served SPA**, use this contract:
1. **Be iframe-embeddable.** The app gate removes `X-Frame-Options` and only
the CSP `frame-ancestors` directive from responses, but your own config
should still express the intended embedded deployment rather than relying
on repair.
2. Serve `/nostr-provider.js` with `Cache-Control: no-cache, no-store`. Never
precache the provider or the dashboard `/nostr-signer` navigation in an app
service worker; signing protocol updates must reach existing installations.
3. Inject a versioned provider URL such as
`<script src="/nostr-provider.js?v=tab-signer-v4"></script>` before
`</head>` in every SPA document. The token prevents an older iframe-only
provider from surviving a dashboard update in the browser's asset cache.
`sub_filter` is suitable when nginx has
`ngx_http_sub_module` (stock `nginx:alpine` does).
4. **If you proxy an API that does NIP-98 URL verification**, expose
`proxy_set_header X-Forwarded-Prefix /api;`; the host rewrites it to honor the
outer reverse proxy's prefix.
The patch is **idempotent** (it checks for an existing `nostr-provider` reference
before editing) and re-runs on reinstall. If you rename or remove any of the anchor
strings above, injection silently no-ops and `window.nostr` will be undefined in your
app — so guard those lines in your config (see the contract comment block at the top of
IndeeHub's `nginx.conf` for a template).
Make the hook **idempotent** and fail its verification step if the provider is
not present after install. A silent no-op leaves `window.nostr` undefined and
is not release-ready.
> Non-nginx servers (Next.js `node server.js`, etc.) are not auto-patched today. Either
> serve via nginx, or ship `nostr-provider.js` yourself and reference it in your HTML;
> the canonical script lives at `/opt/archipelago/web-ui/nostr-provider.js` on the node.
> Non-nginx servers (Next.js `node server.js`, etc.) should ship the provider
> themselves and reference it in their HTML; the canonical host copy is
> `/opt/archipelago/web-ui/nostr-provider.js`.
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
Choose the launch mode for the app itself; the signer works in either shape:
```yaml
metadata:
launch:
open_in_new_tab: false # default; set true only if the app cannot be iframed
open_in_new_tab: false
requires_host_frame: false
```
The provider supports both launch shapes. In a dashboard iframe it talks to
the dashboard parent directly. In a browser tab or the companion's standalone
WebView it creates a dashboard-origin signer frame, which renders the same
identity chooser and consent card over the app and relays NIP-07 requests to
the authenticated node session. It deliberately does not depend on
`window.opener`, so `noopener` tab launches remain safe and functional.
The app gate's successful login supplies the host-wide session and CSRF cookie
pair in a fresh external browser; the signer broker validates that session
directly and does not require the browser to have visited or logged into the
dashboard first. Existing session-only browser tabs are repaired on their next
gate-fronted app response. Do not add a second dashboard-login prerequisite in
application code.
For that reason a NIP-07 app does **not** need `requires_host_frame: true`.
Use the flag only if the app has some other parent-frame protocol. If a
top-level app sends its own `Content-Security-Policy`, its `frame-src` must
permit the dashboard origin; apps intended to work over every node address can
allow `http:` and `https:` while relying on the provider's strict same-host
parent validation. A policy limited to `frame-src 'self'` will block the
broker when the app is running on a different port.
**Consent UI belongs to the platform.** Do not build a second signer modal,
request a top-level window, or overlay the entire dashboard. A standard NIP-07
call pauses while Archipelago shows its contained consent card inside the
active app surface. After approval, the shared Nostr identity ring provides a
short signing loader and completion state. The same host-owned flow renders in
desktop browsers, installed PWAs, and the Android companion WebView.
Silent background requests and remembered approvals deliberately keep the
broker frame hidden; only an identity choice or an actual consent prompt may
reveal it. If an app performs NIP-98 bootstrap and then navigates, it must wait
for the provider Promise to finish rather than independently reloading while
the consent result is still visible. The canonical provider coordinates its
automatic IndeeHub-style session reload with the broker's hide notification.
For top-level apps, that broker document must remain transparent. When hidden,
its iframe must stay loaded but be reduced to a non-interactive 1px surface and
parked physically off-screen. Removing/display-hiding the full-viewport iframe,
or leaving it full-size with only `visibility:hidden`, can make Android WebView
and mobile Chromium retain its last black/grey compositor surface above a
healthy app until refresh. Keeping one parked broker also prevents a visible
hide/recreate flash between `getPublicKey` and `signEvent`. The canonical
provider owns this lifecycle; apps must not copy or manipulate its iframe.
Apps should treat the NIP-07 Promise as an ordinary asynchronous operation:
disable only the initiating control, preserve the user's draft, handle a user
denial as a normal rejected request, and render the returned result when it
resolves. Never infer approval from elapsed time and never ask the user for an
`nsec` as a fallback.
Archipelago recognizes a synchronous, user-triggered `getPublicKey()` as an
account-selection action. An Archipelago-packaged app should still ask the host
to show the identity chooser explicitly before login, especially when other
asynchronous work happens between the click and the NIP-07 call. This prevents
a returning user from being silently locked to the identity chosen on first use:
```js
await window.archipelagoNostr?.selectIdentity?.()
const pubkey = await window.nostr.getPublicKey()
```
`archipelagoNostr.selectIdentity()` is an optional host enhancement, not part of
NIP-07. Apps must continue to work when it is absent (for example with a normal
browser extension). Invoke it only from a deliberate login/account-switch
action; routine signing calls should continue using the remembered identity.
If a first-launch choice should create the app account automatically, use the
provider's sticky identity subscription and call the ordinary extension-login
action from it:
```js
const unsubscribe = window.archipelagoNostr?.onIdentitySelected?.(() => {
if (!alreadyLoggedIn()) loginWithNip07()
})
```
The callback runs immediately when an identity was selected just before the
React/Vue component mounted, closing the load-event race seen in browser tabs
and Companion WebViews. Call `unsubscribe()` when the component unmounts. The
selected public key remains available to the immediately following
`getPublicKey()` call; do not add a timeout, reload, or second lookup between
those operations. A plain `archipelago:identity` message remains available for
backward compatibility, but it is not a reliable framework lifecycle API.
Submission testing for a Nostr-signed app must include:
1. `getPublicKey` allow, deny, and remembered consent;
2. `signEvent` with a readable event-kind/content preview;
3. the contained review → identity-ring loader → completion sequence;
4. changing the selected identity and confirming remembered consent does not
cross identity boundaries;
5. HTTP and HTTPS dashboard frames, a `noopener` browser-tab launch, and the
Android companion's standalone WebView;
6. choosing an identity immediately when the first-launch picker appears, to
prove the app's account store is ready before the result arrives; and
7. Companion → **Open in browser** in a browser with no prior dashboard
localStorage: complete the app gate, then prove the contained signer can
choose an identity and sign without asking for a second node login; and
8. after the first identity choice and after NIP-98 authentication, confirm the
underlying app paints immediately—no black frame and no manual reload.
## Security Requirements
Two different things enforce these, and it's worth knowing which is which:
+31
View File
@@ -173,6 +173,37 @@ override wins over the manifest in both directions and applies on the next
request — your app cannot assume the gate is or isn't in front of it, so it
must always enforce its own authorization for sensitive operations.
## Launch metadata
`metadata.launch` is consumed by catalog generation and the dashboard
launcher. It is currently an extension rather than a Rust-validated field:
```yaml
metadata:
launch:
open_in_new_tab: false
requires_host_frame: false
```
| Field | Default | Meaning |
|---|---|---|
| `open_in_new_tab` | `false` | The app must be top-level because header repair cannot solve its OAuth/WebAuthn flow, JavaScript frame-busting, or strict cookies. Desktop opens a browser tab; Android uses its native in-app browser. |
| `requires_host_frame` | `false` | Keep the app in the dashboard iframe even in the Android companion because it consumes an app-specific parent-frame integration. Standard Archipelago NIP-07 works in iframes, tabs, and the companion WebView without this flag; the platform renders consent inside the active app surface. |
Do not set both fields to `true`. The generated TypeScript launch tables are
the runtime source used by the dashboard, so run
`python3 scripts/generate-app-catalog.py` after changing either value. See
[`app-developer-guide.md`](app-developer-guide.md#browser-iframe-and-companion-launch-modes)
for the HTTP/HTTPS and test matrix.
Platform-owned same-origin mounts are not manifest features. If Archipelago
adds one, its nginx location must send a fixed
`X-Forwarded-Prefix: /app/<id>` header to the app gate whenever `proxy_pass`
strips that prefix. The gate uses it for challenge form/assets and the
post-login redirect; without it, a fresh external browser posts to the
dashboard root and receives 405. Ordinary registry apps should declare a
gated `interfaces.main` port instead of requesting such a mount.
## Volumes
```yaml
@@ -0,0 +1,119 @@
# Incident — 2026-09-15: Minibits Cashu claim stuck retrying an already-redeemed token
## Report
User: "The cashu server is unable to get it's tokens from nostr on
[affected node]" — clarified as the Cashu **client wallet**
(Minibits `@minibits.cash` Lightning-address receive flow), not a mint
server. UI showed: *"a payment arrived but couldn't be redeemed yet (1)"*.
## Root cause
`wallet::minibits::claim_and_redeem` (`core/archipelago/src/wallet/minibits.rs`)
polls Nostr relays for NIP-04-encrypted Cashu tokens sent to the node's
`@minibits.cash` address, decrypts them, and redeems them at the mint. A
token that fails to redeem is kept in `MinibitsState.pending_claims` and
retried on the next poll — by design, so a *transient* failure (mint briefly
down, decrypt hiccup) never drops real money.
But one queued claim had already been redeemed (mint error **11001 "Token
Already Spent"** — most likely double-delivered by the relay, or redeemed
by an earlier run before a crash lost track of it). That's a *terminal*
condition, not a transient one: the code didn't distinguish the two, so it
retried the same dead claim every ~6 seconds forever:
```
WARN archipelago::wallet::ecash: Failed to swap proofs from mint https://mint.minibits.cash/Bitcoin:
This ecash has already been redeemed — it can't be claimed twice.: {"code":11001,"detail":"Token Already Spent"}
WARN archipelago::wallet::minibits: Minibits claim decrypted but failed to redeem (...); will retry next poll
```
Confirmed via `sudo journalctl -u archipelago.service` on the affected node,
and via `/var/lib/archipelago/wallet/minibits.json`, which had exactly one
`pending_claims` entry. Each poll also unconditionally queried all three
`CLAIM_RELAY_URLS` (`relay.minibits.cash`, `relay.damus.io`, `nos.lol`)
instead of the primary relay only, adding needless churn and leaking the
wallet's Nostr pubkey to two relays it didn't need to touch — `relay.damus.io`
was additionally failing NIP-42 auth / 503ing on every poll.
**No funds were at risk** — an already-redeemed token has zero remaining
value. The only symptom was a permanently stuck "couldn't be redeemed yet"
banner and wasted relay connections.
### Why this had already been "fixed" once and came back
This exact bug (terminal-11001 handling + relay-query reduction) was fixed
on 2026-09-09 on branch `feat/minibits-lnurl-receive` (commits `4e410d7`,
`489995c`) and pushed to `origin`. **That branch was never merged into
`main`.** `main` carries its own, independently-diverged rewrite of
`minibits.rs` that never got those two hardening fixes. The affected node
OTA'd to `1.8.16-alpha` (built from `main`) earlier on 2026-09-15, so the bug
resurfaced on the first replayed/double-delivered claim after that update.
## Fix
Two parts:
### 1. Immediate unstick (affected node, operational, no code change)
- Backed up `/var/lib/archipelago/wallet/minibits.json`.
- Stopped `archipelago.service`, emptied `pending_claims` (`[]`) in the
state file, restarted the service.
- Verified via `journalctl` that polling resumed cleanly with no further
"already been redeemed" warnings.
### 2. Code fix, ported into `main`
- **`core/archipelago/src/wallet/mint_client.rs`**: exposed the existing
NUT error-code-11001 translation as a public constant,
`ALREADY_REDEEMED_MSG`, and a typed `AlreadyRedeemed` condition identified
only by the structured mint error code. Remote text cannot impersonate it.
- **`core/archipelago/src/wallet/minibits.rs`**:
- Added `is_already_redeemed(&anyhow::Error) -> bool`, checking the error
chain for the typed `AlreadyRedeemed` condition. The ecash receive path
preserves it only when all failed mint entries report already-spent proofs;
mixed terminal/transient failures remain retryable.
- In the claim redeem loop, a redeem failure matching
`is_already_redeemed` is now dropped (logged at `info!`, not retried)
instead of being pushed back onto `pending_claims`. Every other failure
still retries next poll, unchanged.
- `fetch_relay_dms` now connects to `RELAY_URL` (the Minibits relay)
alone first via `try_connect_relay`, and only adds the two public
fallback relays (`relay.damus.io`, `nos.lol`) if that primary relay is
unreachable. Also paginates the DM fetch (200/page, capped at 5 pages)
backward with an inclusive `until` boundary. The cursor persists across
polls when capped or interrupted, independently of the forward watermark.
A full same-second boundary is fetched with a larger limit rather than
skipped, so multiple payments sharing a timestamp remain reachable.
Deliberately **not** ported from the unmerged branch: its `STATE_LOCK`
skip-if-busy guard and per-claim attempt-count backstop. `main`'s existing
`MINIBITS_STATE_LOCK` already fully serializes claim polls (blocks rather
than skips — a different but equally valid way to close the same race), and
an attempt-count backstop would have required reshaping the `PendingClaim`
enum for marginal extra protection beyond what the 11001 fix already covers.
## Verification
- `cargo build -p archipelago` — clean, no new warnings.
- `cargo test -p archipelago --bin archipelago wallet::minibits` — existing
suite still green (see PR/commit for the run).
- Live on the affected node: claim poll loop confirmed quiet post-unstick
(only `relay.minibits.cash` connects logged, no redeem-failure warnings).
## Lesson (recorded in memory)
A fix that lives only on an unmerged feature branch is not a fix that's
actually deployed. Before trusting a memory or changelog claim that
something "shipped," check which branch the running/released build was
built from (`git log <branch>..main` / `main..<branch>`) rather than
assuming a pushed branch was merged.
## Pre-merge review regressions
- A 450-event newest-first backlog is completely fetched.
- 250 distinct payments sharing one timestamp are preserved.
- A 1,300-event backlog resumes after the five-page cap and a state reload.
- An interrupted relay fetch retains its unfinished cursor.
- Only structured error 11001 is terminal, including when errors are wrapped;
remote free text and mixed mint failures cannot discard a retryable claim.
+378
View File
@@ -0,0 +1,378 @@
# Framework: LND startup, missing Receive address, false zero balance
**Status: OPEN — Framework startup and Cashu address verified live; source integration and final dashboard balance confirmation remain.**
Reported: 2026-09-15. Source inspected: main at `3b9b74da` (v1.8.17-alpha publication).
The Framework's installed version and exact incident time have not been verified.
## Mandatory priority across sessions
The user explicitly requested that this be investigated and fixed on the node
before resuming unrelated work in later sessions. `AGENTS.md` in the repository
and `/home/archipelago/.codex/AGENTS.md` carry this session-start priority.
Only live verification below, or an explicit user change of priority, clears it.
## Reported observations
- Framework stopped showing its Lightning address in Receive.
- After a restart, LND did not initialize and the UI displayed a balance of zero.
- Manually restarting LND restored operation.
- Node access will be supplied later. No Framework connection, restart, wallet
operation, or deployment was performed during this offline investigation.
- Still clarify whether the restart was a full reboot or management-service
restart, and which Receive item vanished: a Lightning invoice, an on-chain
address, or the Cashu tab's `@minibits.cash` address.
A successful manual restart is a workaround, not a root cause or durable fix.
The zero display does not establish that any funds were lost. Its relation to
v1.8.17-alpha is unknown; do not infer a release regression from timing alone.
## Confirmed source findings
### 1. LND errors can be presented as successful zero balances
`core/archipelago/src/api/rpc/lnd/info.rs`, `handle_lnd_getinfo`:
- `/v1/getinfo` is decoded without checking HTTP success. Its response fields are
optional, so an error object such as `{"code":14,"message":"wallet not ready"}`
can deserialize with every expected field absent instead of rejecting the call.
- Channel and blockchain balance requests suppress connection/JSON failures and
substitute responses with absent balances. HTTP status is not checked here either.
- Missing or unparsable balances become `0` through `unwrap_or(0)`.
- `neode-ui/src/views/Home.vue`, `loadWeb5Status`, treats this RPC response as
success, sets the wallet connected flag, overwrites prior balances, and can
persist the false zero in the wallet snapshot. Its existing failure handling
preserves prior balances only when the RPC actually rejects.
This is a confirmed code defect and a plausible explanation for the reported
display. It is not proof of the Framework's failure sequence.
Required fix: reject unsuccessful/incomplete LND balance responses or model
availability explicitly end to end. Never translate unavailable data into a
verified zero. Preserve known balances with a clear unavailable/stale indication;
show an unknown state when no valid balance is known. Genuine successful zeros
must still render as zero. Cover outage, partial failure, cold load, and recovery.
### 2. Startup readiness and wallet unlock need live evidence
- `main.rs` runs crash/container boot recovery before starting the reconciler.
- `crash_recovery.rs` can start existing containers directly.
- `container/prod_orchestrator.rs` runs LND post-start hooks on explicit restart
and on normal reconciliation of already-running containers. Therefore it is
incorrect to conclude that running containers categorically skip unlock.
- `container/lnd.rs::ensure_wallet_initialized` checks wallet existence and
`/v1/getinfo`, then attempts unlock. Its unlock wait budget is approximately ten
minutes; per-request timeouts can extend elapsed time. Historical comments
describe slow database startup and restart loops, but that is not Framework evidence.
- `health_monitor.rs` models LND's Bitcoin dependency. Container-running state
alone is not proof of wallet readiness, Bitcoin connectivity, or invoice readiness.
Investigate boot ordering, Bitcoin readiness, listener/port mapping, wallet unlock,
mount availability, stopped markers, restart counters, and actual reconcile logs.
### 3. Destructive automatic recovery exists; exclude it from diagnosis
`container/lnd.rs::ensure_wallet_initialized` calls
`recreate_wallet_destructively` when all candidate passwords are rejected. That
function can delete the LND chain and graph data directories. Its comment assumes
alpha wallets hold no real funds; that assumption must not guide this investigation.
No evidence establishes that it ran on Framework. Preserve the original wallet
and channels; rejected passwords must lead to a recoverable error, not automatic
wallet deletion. Review and disable this destructive fallback before using a
modified initialization path as a repair. The existing
`unlock_existing_wallet_no_wipe` demonstrates the non-destructive error behavior.
### 4. The missing address must be identified precisely
`ReceiveBitcoinModal.vue` generates Lightning invoices using `lnd.createinvoice`
after a readiness check, and Bitcoin addresses using `lnd.newaddress`. Its Cashu
Lightning address uses `wallet.ecash-lnaddress` and the Minibits service separately.
Do not assume the Minibits address disappears because LND is down. Trace the actual
tab and response once the user clarifies and the node can be inspected.
## Next session: live investigation order
1. Request Framework access and verify node identity without publishing its hostname,
address, credentials, or wallet identifiers. Do not substitute the development box.
2. Record installed backend/image versions, boot and incident timestamps, and exact
restart/action sequence. Capture current and previous-boot management/LND logs
before another restart can obscure evidence. Keep raw logs private and redact
secrets, invoices, wallet identifiers, and personally identifying data in summaries.
3. Read container/service state, restart counters, mounts, stopped markers, listener
mappings, Bitcoin readiness, LND wallet state, and authenticated API results.
Never dump container environments, macaroons, passwords, seeds, or wallet databases.
4. Compare HTTP status and data from LND getinfo/balance endpoints with the RPC and
visible Receive/balance state. Distinguish unavailable data, locked wallet,
syncing wallet, and genuine zero. Preserve last-known balance evidence privately.
5. Establish whether the manual restart ran a missing/failed hook, waited out a
dependency, refreshed networking/credentials, or masked another failure.
6. Implement the evidenced startup repair and unavailable-balance handling with
regressions. Preserve wallet/channel state and arrange recovery access before
deploying or deliberately rebooting the node.
## Acceptance criteria — all required to close
- [x] Root cause of Framework startup failure supported by node evidence.
- [x] Fix implemented and focused regression tests pass.
- [ ] Failed, locked, delayed, and partial LND responses never masquerade as a
fresh zero balance; genuine zero remains correct.
- [x] Existing wallet identity and channel state preserved through the repair.
- [x] Framework starts LND and reaches usable wallet readiness after a controlled
full reboot, without manually restarting LND.
- [ ] The originally affected Receive flow works after boot and after recovery;
outages show an actionable state and recover without requiring a page reload.
- [ ] Display confirmation pending; authenticated LND balances match pre-reboot values.
- [x] LND logs show no restart loop, repeated unlock failure, or wallet-recreation path.
- [ ] Evidence, tested versions, deployment, and limitations recorded here; user
informed of live results. Only then set status RESOLVED and clear the blockers.
## Work completed so far
2026-09-15: source investigation and persistent session-start instructions only.
No code fix, release, node deployment, or live reproduction for this incident yet.
## Live evidence captured 2026-09-15
Access was provided during the same session. Read-only inspection confirmed:
- Framework runs `1.8.17-alpha-dev`; the current full boot began at 18:40:09 UTC.
- LND opened its databases in 6.7 seconds and requested its wallet password at
18:40:20. It then rejected GetInfo/ChannelBalance/WalletBalance as wallet locked.
- The management service's first sequential reconcile pass was occupied by
unrelated image recovery, including a missing voice image from 18:40:24 and
later a missing Core Lightning image. Manifests are iterated from a HashMap;
wallet readiness has no initial priority. Boot recovery itself completed at
18:40:18; the first full app-reconcile report appeared at 18:44:34.
- The user's manual LND restart was recorded at 18:42:33. The replacement LND
process started at 18:42:40, requested its password at 18:43:05, and unlocked
at 18:43:07 through the explicit restart hook. This supports delayed unlock
behind unrelated recovery, rather than a missing wallet or bad password.
- At inspection, `/v1/state` reports SERVER_ACTIVE; getinfo reports chain and
graph sync and two active channels. Both authenticated balance endpoints
report nonzero balances. No wallet-recreation event was found in captured logs.
- The Minibits RPC separately fails with “The ecash wallet has no seed yet”.
`wallet/cashu_seed.json` and `wallet/minibits.json` are absent. The existing
ecash wallet is present with proofs and an August modification timestamp.
Do not overwrite it or generate an unrelated recovery identity. Still identify
which Receive item the user meant before declaring this part repaired.
Private raw evidence: `/home/archipelago/.local/state/archy-incidents/framework-lnd-20260915/`.
Files have mode 0600 and the directory 0700. Do not commit or publish raw logs.
Candidate changes on `investigate/framework-lnd-startup`:
- Run Bitcoin and LND reconciliation before unrelated image pulls/builds.
- Reject failed/incomplete LND balance responses instead of manufacturing zeros.
- Preserve known Home balances on invalid responses, visibly label unavailable
balances, and clear the warning after a successful refresh.
- Remove automatic destructive wallet recreation; failed unlock preserves data.
- Add backend outage/zero/ordering regressions and UI failure/recovery coverage.
These changes are not yet deployed or verified through a Framework reboot.
### Candidate validation and staging
Source fix commit: `4237fb5e` on `investigate/framework-lnd-startup`.
- 44 focused backend tests passed (including LND errors, genuine zero, startup ordering).
- 58 additional reconciliation/update tests passed.
- 12 Home UI tests passed, including outage/partial response/cold-load/recovery cases.
- Rust formatting, frontend type checking and production build passed.
- Optimized backend build passed in 8m02s.
- Both candidate artifacts were copied to Framework and SHA-256 matched locally.
- Private on-node baseline and static channel backup are under
`/var/lib/archipelago/support/framework-lnd-20260915/`, along with the previous
backend, dashboard, and `rollback.sh`. This directory is root-only.
- Candidate staged at `/tmp/archy-framework-candidate/`; not applied yet.
- A timing confirmation for the maintenance restart/full reboot was requested
because it interrupts all node services. Do not reboot while that is pending.
- SSH works through the temporary control socket
`/tmp/archy-framework-connection/control`. No SSH password was saved to disk.
- The supplied SSH password did not authenticate to the dashboard. Do not guess
additional passwords or alter dashboard authentication. Native LND diagnostics
are authenticated using its existing local macaroon without printing it.
Status remains OPEN until deployment and live boot/Receive/balance verification.
### Authorized deployment and full reboot — 2026-09-15
The user answered “yes please” to applying the staged fix and rebooting. Timing
approval is no longer pending. Applied the staged backend and dashboard after
rechecking both checksums and rollback copies. There were no pending channel
HTLCs at reboot. No wallet data, secrets, or recovery identities were replaced.
Live results:
- A different boot ID confirms a full reboot occurred.
- Running backend on disk matches candidate SHA-256
`5a354f76ebe619561eef0d318e4f41f177d04004682504d7434d632733f8e298`.
- Management service started around 19:23:57 UTC; LND asked for its wallet
password at 19:24:10 and logged automatic unlock at 19:24:18. No manual LND
restart or interactive unlock was used after this reboot.
- LND reports SERVER_ACTIVE and chain sync. Its identity and channel-point set
are identical to the private pre-reboot baseline; both channels are active.
- On-chain and Lightning balances exactly equal the pre-reboot values.
- LND container and systemd restart counts are zero after recovery.
- Public HTTP checks on the node returned 200 for the dashboard index and new
Home bundle; their bytes match the installed candidate, including the new
unavailable-balance notice.
- Captured post-reboot management and LND journals in the private local evidence
directory. Detailed before/after identity, channel, and balance records remain
in the root-only support directory on Framework.
The user was asked to refresh the dashboard and confirm the originally missing
Receive item and displayed balances. Keep OPEN until that reply is assessed;
Minibits seed absence was a separate finding and must not be mistaken for an
LND startup failure. Candidate is a direct node deployment, not a newly signed
fleet release. The source branch must be integrated before a subsequent release
can preserve this fix across the fleet.
### Cashu Receive follow-up
The user confirmed that the remaining error is specifically on the Ecash tab:
“Lightning address unavailable — you can still paste a token below.”
Read-only checks confirm Framework has an encrypted node master seed, existing
Cashu proofs, and neither `wallet/cashu_seed.json` nor `wallet/minibits.json`.
The existing Minibits handler requires an ecash seed, but setup was available
only through the Settings backup screen; Receive hid the actionable cause.
UI fix commit: `a3b64670`.
- Receive checks the non-secret seed status when registration fails.
- Unseeded wallets get the existing password/TOTP/backup-passphrase-verified setup
component directly in Receive, with import/restore controls excluded from this
focused setup screen. Setup derives from the saved node seed when present.
- The recovery words stay in the existing local reveal UI, are cleared on Done,
and are never emitted to Receive. Receive retries registration after Done.
- Seeded wallets with service outages get Retry, without offering a new identity.
- Ten focused Receive/backup tests and the production UI build passed.
- Deployed the dashboard change without restarting services; live HTTP index and
setup bundle returned 200 and byte-matched the candidate.
- Backed up original Cashu proofs to the root-only support directory as
`ecash-before-address-setup.json`. No seed or proof mutation was performed by
the assistant. Prior LND-fixed dashboard is also backed up there.
The user was asked to refresh Receive → Ecash → Set up address, authenticate in
that node UI, and click Done. Dashboard password is required to decrypt the node
seed; the SSH password did not authenticate to the dashboard. Do not request or
print recovery words, bypass authentication, or create an unrelated random seed.
After completion, verify saved seed/profile presence, registration success,
address display, and unchanged original proofs before closing the incident.
### Cashu setup completed and verified — 2026-09-15
The user initially reported a forgotten passphrase, then said “did it now”. No
independent-seed fallback was implemented or used. The user completed the existing
password-verified setup themselves; the assistant did not receive recovery words.
Read-only node verification confirmed:
- `wallet/cashu_seed.json` exists, is nonempty, and records source `node-seed`.
- `wallet/minibits.json` exists with a `@minibits.cash` address and no pending claims.
- The original ecash wallet file is byte-for-byte unchanged from the protected
pre-setup copy; every original proof is preserved.
- The registered address's public LNURL-pay metadata returns HTTP 200, tag
`payRequest`, an HTTPS callback, and a valid amount range. No invoice was paid
and no funded payment test was performed.
LND automatic startup and native balances were already verified after the full
reboot. Cashu setup and address registration are now also verified on Framework.
Do not ask for the forgotten passphrase again or propose a replacement Cashu seed.
Remaining: integrate the tested source branch before the next fleet release;
record final human confirmation of the rendered dashboard balance (native balances
match exactly, and UI failure/recovery regressions pass). Keep this follow-up
visible across sessions; do not rebuild/reboot/reinitialize a working wallet just
to repeat already completed checks.
### Backup copy and layout — 2026-09-15
At the user's request, shortened the ecash backup explanations and stacked each
card section's text and full-width action vertically. Kept the distinction
between node-derived and separate phrases, and the warning that a newly created
phrase covers future coins rather than existing legacy coins.
All 10 Receive/backup tests and the production UI build pass. Deployed the UI to
Framework without a restart; served index and backup-component bundle match the
build byte-for-byte. The prior UI is saved as `web-ui-before-backup-copy` in the
protected incident directory. Source integration and final rendered dashboard
balance confirmation remain pending as above.
### LNURL comment-length report — 2026-09-15
User reports a maximum-comment-length error in some sending wallets. Live
Framework address metadata advertises integer `commentAllowed: 100`. The QR
contains the address only; Archy's Receive UI does not add a comment. The
Minibits-hosted callback returned invoices for omitted/empty comments, 100 ASCII
characters, 101 ASCII characters, and 100 accented characters. These were unpaid
invoice requests at the advertised minimum amount; no funds were sent.
The callback did not reproduce the error, including beyond its advertised limit.
Sending-wallet validation against the advertised 100-character limit is therefore
a hypothesis, not a confirmed root cause. Asked which wallets fail and whether
an empty comment also fails. Need that result before selecting a code fix.
The service controls the advertised limit; changing local Receive text or QR
cannot raise it for other wallets.
### Primal Spark: automatic recipient note exceeds the address limit
User clarified that no comment was entered and the sender is Primal Spark.
Checked Framework's management journal over the preceding 20 minutes: no
comment-length errors, service active, and zero pending Minibits claims. Recent
claim polling connected to and disconnected from the relay normally. Historical
seed-authentication failures preceded the successful setup already documented.
The live address's Minibits `text/plain` description is **101 ASCII characters**,
while `commentAllowed` is **100**. Description template (address redacted):
`Pay to [ADDRESS] with Lightning. Receiver will receive ecash into Minibits Wallet.`
Primal Android source at `36939db97213e7f8eeefaa4adaf125d839fc662e`:
- `WalletTextParserImpl.handleLnUrlText` assigns the parsed description to
`DraftTx.noteRecipient`, including for Lightning-address input.
- `TransactionEditor` initializes its editable recipient note from that value.
- `SparkWalletServiceImpl` passes it untrimmed to `PrepareLnurlPayRequest.comment`.
- Breez Spark source at `8bb38ec292a590907360c4e7f2a4134b8f09de9e`,
`common/src/lnurl/pay.rs::validate_user_input`, rejects a comment exceeding the
limit with the exact reported error before requesting the callback.
This identifies a concrete compatibility failure: the address description can
become an automatic over-limit comment without the sender typing anything.
The user confirmed that explicitly clearing the prefilled recipient note made
the payment work, and supplied the same description observed in live metadata.
This confirms the automatic-comment compatibility failure. The installed Primal
platform/version was not captured. Node logs alone cannot show sender-side
validation or requests to the external Minibits callback.
Durable upstream correction: Primal should keep receiver metadata separate from
the sender's comment and enforce the limit on actual user comments. Minibits can
also shorten its description or raise its advertised comment limit. Archy does
not serve this external LNURL metadata; do not rename an existing wallet address,
rotate its seed, or claim that a local dashboard edit fixes this sender behavior.
### Primal workaround confirmed by user
The user confirmed successful payment after removing the automatic description.
The permanent sender-side correction is to leave the recipient comment empty by
default and retain receiver metadata only as display text. In Primal Android,
remove the assignment of the LNURL description to the draft recipient note in
`WalletTextParserImpl.handleLnUrlText`; also validate explicitly entered comments
against the endpoint's limit. No upstream change has been submitted or deployed.
Existing Framework addresses and wallet identities remain unchanged.
### Can Archy shorten the current address description?
Inspected Minibits' public wallet client (`src/services/minibitsService.ts`,
`updateWalletProfile`) and `WalletProfileRecord`. The supported profile update
fields are name, lud16, and avatar; there is no exposed LNURL description or
comment-limit setting. Its public web repository also contains no implementation
of the LNURL metadata endpoint or description template.
For the existing `@minibits.cash` address, no supported client-side mechanism
to shorten this text was found. Do not send guessed profile-update fields or
rename the address to disguise the problem. A Minibits server change could use
`Pay to [ADDRESS]`, well below the current limit. Controlling this metadata in
Archy would instead require an Archy-hosted LNURL service/address and correct
invoice metadata binding; rewriting the QR label or only proxying edited metadata
is insufficient. No wallet/profile mutations were made during this investigation.
+230 -198
View File
@@ -1,252 +1,284 @@
# Nostr Git Source Hosting Plan
This plan describes how Archipelago can publish and accept contributions to its
source code through `ngit`, NIP-34, and GRASP while keeping the developer
experience inside Archipelago.
**Reviewed:** 2026-09-11
## Goals
**Status:** GitWorkshop integration is deployed and engineering-tested on the
development node. The owner reported the corrected flows working and requested
a production release on 2026-09-11. Canonical repository publication remains
incomplete: the checkout has no Nostr repository configuration and `ngit` is
not logged into the canonical maintainer identity. The signed production app
catalog also does not yet carry GitWorkshop or its storefront promotion.
- Publish Archipelago source from a sanitized, fresh-history repository.
- Make the in-app registry the primary onboarding path for contributors.
- Let contributors clone, branch, push PR branches, open PRs, and discuss issues
with a Nostr identity from their Archipelago node.
- Follow the Bitcoin Core development model: broad public review and easy forks,
with canonical merge authority held by a small maintainer set.
- Give contributors full read, fork, and proposal rights, but no direct merge
rights on the canonical repository.
- Keep the official maintainer identity and merge authority separate from user
node identities.
The Android companion opens Source as a top-level page in its native in-app
WebView. GitWorkshop's injected NIP-07 provider creates a small authenticated
dashboard-origin signer broker within that page, so the app itself is never
kept in a dashboard iframe. The App Store carries the upstream GitWorkshop
icon, source-focused copy, and a dedicated contribution banner. Popular ordering and promotional
placement are registry-owned `storefront` metadata rather than node-OS UI
policy; these are also part of owner UAT.
## Current Building Blocks
## Goal
Archipelago already has most of the primitives needed for this:
Archipelago users can install a Source app from the app registry, obtain the
Archipelago source, browse it, and contribute through the established Nostr Git
ecosystem. Git remains the version-control engine, Nostr NIP-34 carries
repository identity and collaboration events, and GRASP transports Git objects.
- App manifests and the app registry already install developer tooling as
rootless Podman apps.
- The `gitea` app provides a conventional fallback Git UI and package registry.
- The app launcher already exposes a consent-gated NIP-07 bridge for launched
apps using `getPublicKey`, `signEvent`, NIP-04, and NIP-44 requests.
- The backend exposes node and identity Nostr signing RPC methods.
- FIPS gives nodes a stable mesh identity and private transport path, but repo
announcements and PRs should remain NIP-34 compatible on normal Nostr relays.
- DWN protocol registration exists and can be used later for local contribution
metadata/cache, but should not be required for the first public workflow.
The app must make public contribution easy without giving contributors direct
merge or release authority. Canonical refs, merge status, release tags, and
catalog signatures remain controlled by explicitly configured Archipelago
maintainers.
## Protocol Basis
## Product Decision
Use existing Nostr Git conventions rather than inventing an Archipelago-only
protocol:
Archipelago will package the upstream GitWorkshop web client instead of
building another NIP-34 repository interface.
- NIP-34 repository announcement events identify repositories with kind `30617`.
- NIP-34 repository state events publish branch/tag refs with kind `30618`.
- NIP-34 patches, pull requests, PR updates, issues, and status events use kinds
`1617`, `1618`, `1619`, `1621`, and `1630`-`1633`.
- `ngit` provides the `git-remote-nostr` helper for `nostr://` clone URLs and PR
branches.
- GRASP servers provide Git Smart HTTP storage while Nostr events remain the
authority for repository identity, refs, PRs, issues, and maintainer state.
GitWorkshop already provides repository discovery, a sparse Git explorer,
issues, pull requests, and review workflows. Archipelago owns only the node
integration around it:
- installable app metadata and a pinned upstream build;
- a same-origin `/app/archipelago-source/` launch path that works through the
dashboard address the user already opened, whether that is LAN, Tailscale,
FIPS, DNS, IPv4, or IPv6;
- authenticated routing through the existing app gate;
- an injected, consent-gated NIP-07 provider so GitWorkshop can use a selected
node identity without receiving its private key;
- source provenance, security validation, upgrades, and rollback.
Archipelago will not duplicate GitWorkshop's repository browser, issue/PR,
fork, diff, relay, or GRASP behavior in private `source.*` RPC methods.
Primary references:
- https://ngit.dev/how-it-works
- https://github.com/DanConwayDev/gitworkshop
- https://gitworkshop.dev/
- https://nips.nostr.com/34
- https://docs.rs/crate/ngit/latest/source/README.md
- https://ngit.dev/grasp/
## Recommended Architecture
## Trust And Permissions
### Apps
- GitWorkshop runs as a static, read-only container behind the app gate.
- The iframe may request NIP-07 operations through `postMessage`; only the
exact launched frame and expected origin are accepted.
- `getPublicKey`, event signing, encryption, and decryption require explicit
dashboard consent. A remembered decision is scoped to node origin, app,
selected identity, and method.
- Contributor private keys never enter the GitWorkshop container.
- Browser-origin signing calls from direct high-port app origins are rejected;
they must pass through the dashboard consent bridge.
- Maintainer and release keys must not be placed on ordinary user nodes.
- Relay and GRASP data is untrusted. Canonical status is derived only from the
signed repository announcement and configured maintainer identities.
Create two first-party apps:
## Upstream Pin And Redistribution Decision
- `ngit`: CLI/runtime package containing `ngit` and `git-remote-nostr`.
- `archipelago-source`: web UI for cloning Archipelago source, viewing NIP-34
issues/PRs, opening branches, and submitting PR events.
The development image currently pins GitWorkshop commit
`dc36db64f6a2cca29d109829eabaf0a49d4bf4da` (2026-07-28). The integration patch
only adds base-path support and the Archipelago NIP-07 provider.
The `archipelago-source` app should depend on `ngit`. It can also recommend
Gitea for users who want a conventional local web Git UI, but Gitea should not
be the source of truth for public contribution permissions.
The pinned revision and current upstream `main` have no license file, the npm
package metadata declares no license, and GitHub reports no detected license.
An earlier project-site description of “MIT” is not a license grant bundled
with the code. On 2026-09-11 the Archipelago owner explicitly accepted the
risk of publishing the patched build without an upstream license. That removes
the project's internal release hold, but it does not turn the upstream code
into open-source software or grant downstream rights. The absence is disclosed
in `NOTICE` and the packaged `UPSTREAM.md`.
### Contributor Onboarding
Preferred follow-up: ask upstream to add an SPDX-recognized license file
(MIT if that remains their intent), then re-pin at or after that commit and add
GitWorkshop plus its copyright/license notice to Archipelago's `NOTICE` and
generated image inventory. A written grant that explicitly permits compiling,
modifying, and redistributing this app is an alternative, but is harder for
downstream users to audit. A public GitHub repository or permission to fork is
not sufficient redistribution permission. Production dependency-audit findings
must also be resolved or explicitly accepted before release.
When the user installs `archipelago-source` from the registry:
The release-preparation audit on 2026-09-09 ran `npm audit --omit=dev` against
the exact pinned commit and reported 4 high and 6 moderate advisories, with
fixes available for every affected package. The same commit remains upstream
`main`, so repinning alone does not resolve them. The final runtime image is
static nginx rather than Node, which makes the Hono server findings unlikely to
be runtime-reachable, but browser/runtime dependencies such as `fflate` and
React Router still require an upstream dependency update or an explicit,
written risk acceptance before registry publication.
1. Show a modal before first launch: "Contribute to Archipelago".
2. Explain that the app will use their Archipelago Nostr identity to clone and
sign contribution events.
3. Display the maintainer repository announcement, clone URL, maintainer npub,
and relay/GRASP endpoints.
4. Ask for consent to:
- fetch repository metadata from configured relays,
- clone source through `nostr://`,
- create local branches,
- sign NIP-34 issue/PR/comment events,
- push PR branches to approved GRASP servers.
5. Store approval per app origin, identity id, repository id, and relay set.
## Canonical Archipelago Repository
This should build on the existing NIP-07 app-launcher bridge, but use a more
specific permission scope than the generic sign-event approval.
The canonical announcement maintainer is
`npub1w3sqdkrhn0gyuvsex32effzgnfpyde6qrrc4u467flg5e9txh4wsfn5vjg`.
The repository already contains a root MIT `LICENSE` and `CONTRIBUTING.md`;
contributors agree to license their contributions under that MIT License.
### Identity And Permissions
The user-facing Source app can ship for local evaluation before the canonical
Archipelago Nostr repository exists, but it must not pretend a placeholder is
canonical. Canonical launch requires:
Use four identity classes:
1. A sanitized public `archy` source repository.
2. An offline or tightly controlled maintainer identity.
3. A signed NIP-34 kind `30617` repository announcement.
4. At least one Archipelago-operated relay/GRASP endpoint and one independent
compatible mirror.
5. Tested `nostr://` clone, proposal, update, review, merge-status, server-loss,
and recovery flows.
6. A GitWorkshop link/configuration that opens the verified `archy` repository.
- `archipelago-maintainer`: an offline or tightly controlled Nostr key that
signs the canonical kind `30617` repo announcement and status/merge events.
- `archipelago-merge-maintainer`: one of the small set of maintainer npubs
allowed to advance canonical refs and publish valid merged/applied status.
- `archipelago-build`: release automation key for signed release artifacts and
CI status events. It must not have merge authority.
- `contributor`: user node or app-specific identity used for PRs, issues, and
comments.
The existing HTTP Git remote remains a fallback until those drills pass.
Contributor rights:
## Delivery Milestones
- Clone the repository.
- Open issues.
- Push proposal branches using `pr/<npub>/<short-topic>` or `pr/<event-id>`.
- Publish NIP-34 PR/update/comment events.
- Rebase and update their own PR branch.
- Run local validation and attach status evidence.
### 1. Plan And Protocol Review — complete
Contributor restrictions:
- Confirmed NIP-34/ngit/GRASP as the interoperability layer.
- Defined contributor, maintainer, build, and release trust boundaries.
- Confirmed that installation must ultimately come from the Archipelago app
registry and include a path to the upstream/source code.
- Cannot update `refs/heads/main` or release branches in canonical state.
- Cannot publish maintainer-valid merge/applied status.
- Cannot alter the canonical repository announcement.
- Cannot publish release catalog signatures.
### 2. Runtime Feasibility — complete
Maintainer rights:
- Validated pinned `ngit` and `git-remote-nostr` binaries on supported node
architectures.
- Exercised public `nostr://` discovery/clone behavior.
- Established that app lifecycle dependencies do not share executables or
filesystems, avoiding an invalid two-container CLI design.
- Publish/update the canonical repo announcement.
- Publish canonical `refs/heads/main` state.
- Mark PRs merged/closed/draft via NIP-34 status events.
- Sign release tags and catalog updates.
These CLI checks remain useful for canonical repository operations and release
validation; they are not a reason to build a second browser client.
Fork rights:
### 3. Node Integration Foundation — complete
- Any contributor can create their own NIP-34 kind `30617` repository
announcement for a fork.
- Fork announcements should use the NIP-34 `u` tag to point back to the
canonical `archy` repository.
- The source app should make forking a first-class path: "Fork on Nostr", clone
the fork locally, push branches to the contributor's GRASP list, and open PRs
back to canonical Archipelago when they want review.
- Forks can have their own maintainer npubs, relays, policies, and release
cadence, but the app should clearly label them as forks unless signed by the
canonical maintainer set.
- Added the installable app manifest, catalog metadata, icon, and port
reservation.
- Added identity selection and a generic consent-gated NIP-07 bridge.
- Kept signing secrets out of the app container.
The GRASP server policy should enforce this by accepting pushes to maintainer
refs only when backed by signed maintainer state, while allowing contributor PR
refs from their own npubs.
### 4. GitWorkshop Pivot — complete on the development node
## Repository Layout
- Replace the prototype Source UI and all private `source.*` APIs with the
pinned upstream GitWorkshop build.
- Mount it below `/app/archipelago-source/` and proxy to the authenticated app
gate, eliminating hard-coded address and high-port launch behavior.
- Validate upstream base-path routing, static assets, browser refresh/deep
links, NIP-07 requests, container hardening, and install/restart behavior.
- Deploy the resulting daemon, dashboard, and app only on this development
node, then hand it to the owner for UAT.
Canonical repo announcement:
The pinned integration patch applies cleanly to a fresh upstream checkout. The
upstream unit suite passes 152 tests, Archipelago's full frontend suite passes
1,091 tests across 137 files, the production dashboard build and Android UAT
lint/build pass, and the manifest passes all 16 validators. The read-only,
capability-free container passes health, asset, manifest, and base-path checks.
The live same-origin route reaches the authenticated app gate through the
node's loopback, LAN, Tailscale, and FIPS addresses. A rollback snapshot is at
`/var/backups/archipelago/pre-uat-fixes-20260908-1140` on the development node.
- repo id: `archy`
- display name: `Archipelago`
- clone URLs:
- `nostr://<maintainer-npub>/<relay-hint>/archy`
- `https://<grasp-host>/<maintainer-npub>/archy.git`
- relays:
- Archipelago-operated relay
- at least two public Nostr relays that support the event load
- GRASP servers:
- Archipelago-operated GRASP instance
- one public GRASP-compatible mirror
### 5. Owner UAT — accepted, final release-candidate retest required
Keep the existing HTTP Git remote as a mirror during launch. The docs can
present `nostr://` as the preferred contribution path once the workflow is
proven.
The owner exercised the corrected node deployment and requested release on
2026-09-11. A short final retest remains required after the 0.5.32 release-channel
Companion APK and canonical Archipelago repository deep-link replace their UAT
counterparts; those two release inputs were not present in the accepted build.
## UI Requirements
For companion testing, the node hosts a local-only Archipelago Companion
`0.5.32-uat` at `/packages/archipelago-companion-0.5.32-uat.apk`. It uses the
separate package ID `com.archipelago.app.uat`, installs beside the existing
companion, and includes the native WebView launch plus Android's native node-CA
installer. Its SHA-256 is
`8924d7ba3a013e0db09a5f1e72c21de7886e5e21ed1b2e31d585495183191fe7`.
The production companion download remains unchanged.
The source app should provide:
Owner UAT should cover:
- A first-run contribution modal with a real Archipelago source graphic, not a
generic text-only dialog.
- Current clone status and local path.
- Branch list, changed files, commit form, and push/open-PR flow.
- PR inbox, issue list, maintainer status, and relay health.
- Explicit identity indicator showing which npub will sign events.
- A merge rights indicator that clearly says contributors can propose changes
but cannot merge them.
- A fork flow that creates a user-owned NIP-34 repo announcement and remote,
then offers "Open PR to Archipelago" from any fork branch.
- Maintainer badges based only on pinned canonical maintainer npubs, not relay
metadata or server-side account names.
- Links to container docs, deployment docs, manifest spec, and open-source
readiness tasks.
1. Install/reinstall GitWorkshop from the local App Store and open it from the
App Store, Apps screen, and Source banner. Confirm Discover shows Popular
Apps first, the banner after two desktop rows, and the remaining catalog
under All Apps; confirm the GitWorkshop mark is no longer the old icon.
2. Confirm it opens as a top-level page in Companion's native in-app browser,
not a dashboard iframe, and loads without a blank or "webpage unavailable"
screen. Confirm Back and Close return through the Companion UI correctly.
3. Select a node identity, exercise `getPublicKey` and signing prompts, verify
the contained consent surface, short identity-circle loader, success/error,
allow/deny/remember behavior, then change identity and confirm consent is
requested again. Repeat this flow inside the Companion WebView.
4. Edit a Nostr identity and confirm the identity-specific success screen shows
the saved identity, relay coverage, event ID, copy action, and honest partial
publish warning when a relay does not accept the update.
5. Browse a known NIP-34 repository and exercise the contribution actions that
GitWorkshop exposes without granting direct merge or release authority.
6. From Companion settings, choose **Download this node's certificate** and
confirm Android opens the system CA-install prompt for this node. Confirm
the ordinary browser link still downloads the `.crt` file.
7. Repeat launch through whichever of LAN, Tailscale, FIPS, DNS, IPv4, or IPv6
is available; the app must follow the dashboard origin rather than a stored
address. A raw numeric address works over HTTP; for HTTPS over Tailscale use
the node's MagicDNS hostname because the certificate is issued to that name,
not to the numeric Tailscale address.
## Backend Work
UAT follow-up on 2026-09-08 found three integration defects: the mounted gate
used root-relative form/assets and returned nginx 405 in a fresh mobile
browser; silent signer requests flashed the full-screen broker frame in the
Companion WebView; and IndeeHub reloaded while the signer's success surface was
still closing, leaving Android WebView blank. The fixes are implemented with a
validated forwarded mount, consent-driven broker visibility, and a coordinated
post-auth reload plus native page-commit fallback. These items remain pending
owner retest on the development node; their implementation is not UAT
acceptance. The fixes were deployed locally on 2026-09-08. Live engineering
checks confirm that mounted gate pages and assets retain the app prefix, gate
POSTs return the application's 401 response instead of nginx 405 over HTTP and
LAN HTTPS, both apps are healthy, and the served provider and UAT APK match
their build hashes.
Add an RPC module for source contribution workflow:
A further Companion retest showed a black surface immediately after the first
identity selection even though authentication, reload, application data, and
`/api/auth/me` all completed successfully. The common cause was the Android
Chromium compositor retaining the hidden broker iframe's last full-screen black
canvas. The broker route now has a genuinely transparent document, and hidden
brokers stay loaded as a non-interactive 1px surface parked off-screen so the
identity choice and immediately following sign request share one broker.
Companion covers an expected authentication navigation with its branded loader
until the app commits a new frame. This is deployed in `0.5.32-uat` and
remains pending owner visual retest.
- `source.repo-info`: returns canonical announcement, clone URL, relay set,
maintainer npubs, and local clone state.
- `source.ensure-ngit`: verifies the `ngit` app/runtime is installed.
- `source.clone`: clones or updates the local source checkout.
- `source.status`: returns branch, dirty files, ahead/behind, and PR state.
- `source.commit`: creates a local commit from selected files.
- `source.fork`: creates a contributor-owned NIP-34 fork announcement and local
remote.
- `source.open-pr`: pushes a PR branch and publishes a kind `1618` event.
- `source.update-pr`: updates the branch and publishes kind `1619`.
- `source.issue`: publishes a kind `1621` event.
### 6. Canonical Nostr Launch — pending
Backend must shell out through a narrow command wrapper, never arbitrary user
commands. The wrapper should set an isolated working tree under
`/var/lib/archipelago/source/archy`, run as the Archipelago service user, and
deny operations outside that path.
- Publish and configure the signed `archy` kind `30617` announcement.
- Bring up and test the chosen relays and GRASP servers.
- Deep-link/configure GitWorkshop to the verified repository.
- Run the real-node proposal and recovery drills listed above.
## Security Model
### 7. Release — pending canonical publication and signing gates
- Never expose maintainer private keys to an Archipelago node.
- Prefer app-specific contributor identities over the node's default identity.
- Require per-action consent for first PR push, issue creation, and signing any
event that tags the canonical repository.
- Pin the canonical maintainer npub in the app manifest and backend config.
- Keep the canonical merge-maintainer allow list signed by the
`archipelago-maintainer` key; never infer merge rights from GRASP server
accounts.
- Verify the canonical kind `30617` event signature before displaying clone
instructions.
- Treat GRASP servers as untrusted storage; verify Git refs against signed
Nostr state.
- Do not use destructive git operations from the UI without an explicit modal.
- Store local clones and generated patches outside app container writable roots
unless the user exports them.
Only after engineering tests, the final release-candidate retest, canonical
launch tests, and dependency-risk disposition may the team:
## MVP
- build and publish a production multi-architecture app image;
- sign/update the production app-registry entry;
- include the integration in an OTA or ISO;
- add release notes and migration/rollback instructions.
1. Package `ngit` as a first-party app.
2. Stand up one Archipelago-operated GRASP server and one Nostr relay.
3. Publish sanitized fresh-history `archy` through `ngit init`.
4. Add a simple `archipelago-source` app that clones source and links out to the
preferred Nostr Git browser.
5. Add app-launcher consent scopes for repository-specific NIP-34 signing.
6. Allow issues and PR branch submission from contributor npubs.
7. Add a one-click fork flow that publishes a contributor-owned fork
announcement referencing canonical Archipelago.
8. Keep maintainer merge/status publication manual.
The production companion signing path also needs an explicit release decision.
The current branch omits the shared debug keystore expected by
`scripts/publish-companion-apk.sh` (an older repository revision contains it),
while the local UAT key is intentionally unsuitable for public artifacts.
Before publishing, verify upgrade compatibility against the already-distributed
companion's signing certificate and stage only the intended production-signed
APK.
## Later
## Completed Next-OTA Follow-ups
- Native PR review UI with file diffs and inline comments.
- CI status events signed by the build identity.
- FIPS-first source sync between trusted Archipelago nodes.
- Private prerelease repositories using NIP-42 allow lists and/or protected
events if the ecosystem support is mature enough.
- Multi-maintainer policy with threshold signatures or explicit maintainer-list
rotation events.
- The container doctor detects a missing rootless Podman `pasta` listener and
restarts only the affected container, including the intermittent Nginx Proxy
Manager port 8081 case. TCP and UDP bindings are checked independently.
- The node-certificate UI contains the approved macOS, iOS/iPadOS, Windows,
Android, Linux, browser restart, DNS, and symptom/cause guidance, while the
Companion hands the downloaded node CA to Android's system installer.
## Open Questions
## Open Decisions Before Canonical Launch
- Which maintainer npub should become canonical for `archy`?
- Should contributor identities be node-default or app-specific by default?
- Which GRASP implementation should be deployed first: `ngit-grasp` or another
NIP-34/GRASP-compatible relay?
- Should the source app include a full web Git UI in v1, or launch Gitea/ngit
browser links for review while keeping signing/submission native?
- What exact license and contribution certificate should contributors accept
before submitting PR events?
- Which Archipelago-operated and independent relay/GRASP endpoints are used?
- Does the owner accept the recorded 4 high and 6 moderate GitWorkshop npm
advisories for this release, or must the integration patch update them first?
- Will upstream add an explicit GitWorkshop license as a post-release
clarification for downstream users?
@@ -262,7 +262,12 @@ ROOTFS_STAMP="$WORK_DIR/archipelago-rootfs.recipe.sha256"
# were added to the Dockerfile below — the cache condition never looked at
# the recipe. Hash the rootfs-defining region of this script; any edit to it
# forces a rebuild. `--rebuild` still forces one unconditionally.
RECIPE_HASH=$(sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0" | sha256sum | cut -d' ' -f1)
RECIPE_HASH=$(
{
sed -n '/^# STEP 1: Build complete root filesystem/,/^# STEP 2: Build minimal installer/p' "$0"
cat "$SCRIPT_DIR/../configs/install-ngit.sh"
} | sha256sum | cut -d' ' -f1
)
if [ ! -f "$ROOTFS_TAR" ] || [ "${1:-}" == "--rebuild" ] || [ "$(cat "$ROOTFS_STAMP" 2>/dev/null)" != "$RECIPE_HASH" ]; then
echo " Using Docker to create Debian root filesystem..."
@@ -451,6 +456,13 @@ COPY --from=fips-builder /tmp/fips.deb /tmp/fips.deb
RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install-recommends /tmp/fips.deb && \
apt-get clean && rm -rf /var/lib/apt/lists/* && rm /tmp/fips.deb
# Install the pinned Nostr Git runtime. The installer verifies the release
# archive before copying ngit and git-remote-nostr into /usr/bin.
COPY install-ngit.sh /tmp/install-ngit.sh
RUN chmod 0755 /tmp/install-ngit.sh && \
/tmp/install-ngit.sh && \
rm /tmp/install-ngit.sh
# Configure locale
RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
@@ -796,6 +808,11 @@ NGINXCONF
echo " Using nostr-relay-config.toml from configs/"
fi
if [ -f "$SCRIPT_DIR/../configs/install-ngit.sh" ]; then
cp "$SCRIPT_DIR/../configs/install-ngit.sh" "$WORK_DIR/install-ngit.sh"
echo " Using pinned ngit installer from configs/"
fi
# Copy WireGuard helper script (privileged peer management)
if [ -f "$SCRIPT_DIR/../../scripts/archipelago-wg" ]; then
cp "$SCRIPT_DIR/../../scripts/archipelago-wg" "$WORK_DIR/archipelago-wg"
@@ -1584,7 +1601,7 @@ IMAGES_CAPTURED_FROM_SERVER=0
if [ -n "$DEV_SERVER" ] && [ "$DEV_SERVER" != "localhost" ] && [ "$DEV_SERVER" != "127.0.0.1" ]; then
echo " Capturing container images from live server ($DEV_SERVER)..."
# Patterns match against `podman images` repository names (not container names)
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager adguard"
CAPTURE_PATTERNS="bitcoin-ui bitcoinknots lnd lnd-ui electrs-ui filebrowser mempool backend frontend electrs tailscale homeassistant home-assistant btcpayserver nbxplorer postgres alpine-tor nostr-rs-relay strfry fedimintd gatewayd dwn-server vaultwarden searxng mariadb valkey nginx-alpine portainer nginx-proxy-manager"
REMOTE_TMP="/tmp/archipelago-image-capture-$$"
SAVED_LIST=$(ssh "$DEV_SERVER" "mkdir -p $REMOTE_TMP && for p in $CAPTURE_PATTERNS; do img=\$(podman images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -i \"\$p\" | head -1); [ -n \"\$img\" ] && podman save -o \"$REMOTE_TMP/\$p.tar\" \"\$img\" 2>/dev/null && echo \"\$p\"; done" 2>/dev/null) || true
for p in $SAVED_LIST; do
@@ -1631,7 +1648,6 @@ ${PHOTOPRISM_IMAGE} photoprism.tar
${NEXTCLOUD_IMAGE} nextcloud.tar
${NPM_IMAGE} nginx-proxy-manager.tar
${ONLYOFFICE_IMAGE} onlyoffice.tar
${ADGUARDHOME_IMAGE} adguardhome.tar
"
# Pull and save each image (force target arch) only if not already present
@@ -6,7 +6,7 @@ After=archipelago.service
Type=oneshot
# Runs as root: needs to kill orphaned conmon processes, fix permissions
User=root
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
ExecStart=/opt/archipelago/scripts/container-doctor.sh --local
TimeoutStartSec=300
StandardOutput=journal
StandardError=journal
+82
View File
@@ -0,0 +1,82 @@
#!/bin/sh
# Install the exact ngit runtime validated for Archipelago source hosting.
#
# The GitHub release archive contains both `ngit` and `git-remote-nostr`.
# Keep version, filenames, and SHA-256 values together so image builds and
# OTA updates cannot silently resolve a newer upstream release.
set -eu
NGIT_VERSION="2.6.3"
NGIT_RELEASE_BASE="https://github.com/DanConwayDev/ngit-cli/releases/download/v${NGIT_VERSION}"
X86_64_ASSET="ngit-v${NGIT_VERSION}-x86_64-unknown-linux-gnu.2.17.tar.gz"
X86_64_SHA256="81dd9b6a11a4a0feb946e56f55d557dc24075f1dcdda00ac35f9fd01920b9779"
AARCH64_ASSET="ngit-v${NGIT_VERSION}-aarch64-unknown-linux-gnu.2.17.tar.gz"
AARCH64_SHA256="e9d9437b7574e729b5a5d5cd800ebd668b73e6eb5c5859d52f83114c2f4b08b8"
install_root="${ARCHIPELAGO_NGIT_INSTALL_ROOT:-}"
case "$install_root" in
""|/*) ;;
*)
echo "ARCHIPELAGO_NGIT_INSTALL_ROOT must be empty or absolute" >&2
exit 2
;;
esac
install_dir="${install_root}/usr/bin"
ngit_bin="${install_dir}/ngit"
helper_bin="${install_dir}/git-remote-nostr"
if [ -x "$ngit_bin" ] && [ -x "$helper_bin" ] && \
[ "$($ngit_bin --version 2>/dev/null || true)" = "ngit ${NGIT_VERSION}" ] && \
[ "$($helper_bin --version 2>/dev/null || true)" = "v${NGIT_VERSION}" ]; then
echo "ngit ${NGIT_VERSION} already installed"
exit 0
fi
machine="${ARCHIPELAGO_NGIT_ARCH:-$(uname -m)}"
case "$machine" in
x86_64|amd64)
asset="$X86_64_ASSET"
expected_sha256="$X86_64_SHA256"
;;
aarch64|arm64)
asset="$AARCH64_ASSET"
expected_sha256="$AARCH64_SHA256"
;;
*)
echo "Unsupported ngit architecture: $machine" >&2
exit 2
;;
esac
download_dir=$(mktemp -d -t archipelago-ngit.XXXXXX)
cleanup() {
rm -rf -- "$download_dir"
}
trap cleanup EXIT HUP INT TERM
archive="$download_dir/$asset"
curl --fail --silent --show-error --location \
--proto '=https' --tlsv1.2 \
--retry 3 --connect-timeout 20 \
--output "$archive" "$NGIT_RELEASE_BASE/$asset"
actual_sha256=$(sha256sum "$archive" | awk '{print $1}')
if [ "$actual_sha256" != "$expected_sha256" ]; then
echo "ngit archive checksum mismatch for $asset" >&2
echo "expected: $expected_sha256" >&2
echo "actual: $actual_sha256" >&2
exit 1
fi
# Extract only the two expected top-level files. Unexpected archive content is
# never copied into the host filesystem.
tar -xzf "$archive" -C "$download_dir" ngit git-remote-nostr
mkdir -p "$install_dir"
install -m 0755 "$download_dir/ngit" "$ngit_bin"
install -m 0755 "$download_dir/git-remote-nostr" "$helper_bin"
[ "$($ngit_bin --version)" = "ngit ${NGIT_VERSION}" ]
[ "$($helper_bin --version)" = "v${NGIT_VERSION}" ]
echo "installed ngit ${NGIT_VERSION} for $machine"
+41 -2
View File
@@ -52,6 +52,17 @@ server {
try_files $uri =404;
}
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
# companion WebView. This document alone may be framed by another port on
# the same node; signing RPCs still require an authenticated node session.
location = /nostr-signer {
try_files /index.html =404;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
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;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
#
# /aiui/-scoped CSP (AIUI-04, D-19 unaffected — this is a build-time/
@@ -691,13 +702,32 @@ server {
sub_filter "src='/" "src='/app/botfights/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script><script>window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="arcade-input"&&d.key){var t=d.action==="up"?"keyup":"keydown";document.dispatchEvent(new KeyboardEvent(t,{key:d.key,bubbles:true}))}})</script></head>';
}
# GitWorkshop follows the dashboard origin so every supported ingress
# works without separately publishing an app port. The app gate on
# 127.0.0.2 preserves session authentication before forwarding to the
# loopback-only container.
location /app/archipelago-source/ {
proxy_pass http://127.0.0.2:8337/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_request_buffering off;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 1G;
client_max_body_size 10G;
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
# Override parent add_header to allow iframe embedding
@@ -1037,6 +1067,16 @@ server {
return 504 '{"error":{"code":"BACKEND_TIMEOUT","message":"Service did not respond in time"}}';
}
# Dashboard-origin Nostr signer for apps opened as their own browser tab or
# companion WebView. Keep this aligned with the HTTP server block.
location = /nostr-signer {
try_files /index.html =404;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
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;
}
# AIUI SPA (Chat mode iframe) — SPA fallback for client-side routing
#
# /aiui/-scoped CSP — see the HTTP server block above for the full
@@ -1479,4 +1519,3 @@ server {
proxy_read_timeout 86400s;
}
}
@@ -33,9 +33,27 @@ location /app/uptime-kuma/ {
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
# GitWorkshop follows the dashboard origin; the app gate keeps the route
# session-authenticated before it reaches the loopback-only container.
location /app/archipelago-source/ {
proxy_pass http://127.0.0.2:8337/;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Prefix /app/archipelago-source;
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
proxy_read_timeout 300s;
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
proxy_request_buffering off;
client_max_body_size 10G;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -0,0 +1,71 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { expect, test } from '@playwright/test'
const providerSource = readFileSync(
resolve(process.cwd(), 'public/nostr-provider.js'),
'utf8',
)
test('mobile Chromium returns to the live app after the signer is hidden', async ({ context, page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await context.route('**/*', async (route) => {
const url = new URL(route.request().url())
if (url.pathname === '/nostr-provider.js') {
await route.fulfill({ contentType: 'application/javascript', body: providerSource })
return
}
if (url.port === '' && url.pathname === '/nostr-signer') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><body style="margin:0;background:#111827">
<script>
addEventListener('message', (event) => {
if (event.data && event.data.type === 'archipelago:signer-init') {
parent.postMessage({ type: 'archipelago:signer-show' }, event.origin)
setTimeout(() => {
parent.postMessage({
type: 'archipelago:signer-identity',
identity: { nostr_pubkey: 'browser-handoff-key' },
}, event.origin)
parent.postMessage({ type: 'archipelago:signer-hide' }, event.origin)
}, 50)
}
})
parent.postMessage({ type: 'archipelago:signer-ready' }, '*')
</script>
</body></html>`,
})
return
}
if (url.port === '7778') {
await route.fulfill({
contentType: 'text/html',
body: `<!doctype html><html><head><title>IndeedHub</title>
<script src="/nostr-provider.js" data-no-nip98></script>
<style>html,body{margin:0;background:#166534}#app{height:100vh;color:white;display:grid;place-items:center}</style>
</head><body><main id="app">app-ready</main><script>
addEventListener('load', () => {
archipelagoNostr.onIdentitySelected((identity) => {
document.querySelector('#app').textContent = 'signed-in:' + identity.nostr_pubkey
})
})
</script></body></html>`,
})
return
}
await route.abort()
})
await page.goto('http://app.test:7778/')
await expect(page.locator('#app')).toHaveText('signed-in:browser-handoff-key')
const broker = page.locator('#archipelago-nostr-signer')
await expect(broker).toHaveCount(1)
await expect(broker).toHaveCSS('width', '1px')
await expect(broker).toHaveCSS('height', '1px')
await expect(broker).toHaveCSS('opacity', '0')
await expect(page.locator('body')).toHaveCSS('background-color', 'rgb(22, 101, 52)')
expect(await page.evaluate(() => document.elementFromPoint(195, 422)?.id)).toBe('app')
})
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.10-alpha",
"version": "1.8.18-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.10-alpha",
"version": "1.8.18-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.10-alpha",
"version": "1.8.18-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+3
View File
@@ -13,6 +13,9 @@ export default defineConfig({
screenshot: 'only-on-failure',
trace: 'off',
ignoreHTTPSErrors: true,
launchOptions: process.env.ARCHY_CHROMIUM_EXECUTABLE
? { executablePath: process.env.ARCHY_CHROMIUM_EXECUTABLE }
: undefined,
},
projects: [
{
@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="title">
<title id="title">GitWorkshop</title>
<!-- Official GitWorkshop mark from pinned upstream commit dc36db6, inset to
match Archipelago's app-icon safe area. The versioned filename also
prevents an older Source prototype icon surviving in browser caches. -->
<g transform="translate(13 13) scale(1.59375)">
<defs>
<clipPath id="gitworkshop-clip-dc36db6">
<rect width="64" height="64" rx="16" fill="#fff"/>
</clipPath>
</defs>
<g clip-path="url(#gitworkshop-clip-dc36db6)">
<rect width="64" height="64" rx="16" fill="#9333EA"/>
<path fill="#fff" fill-rule="evenodd" d="M14.999 34.961v-17.96h4v17.96c0 3.172 1.492 6.152 4.015 8.036L28.377 47a14.026 14.026 0 0 1 5.622 11.24V64h-4v-5.76c0-3.17-1.492-6.15-4.015-8.035l-5.363-4.004a14.027 14.027 0 0 1-5.622-11.24Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M48.999 34.961v-17.96h-4v17.96c0 3.172-1.492 6.152-4.015 8.036L35.62 47a14.027 14.027 0 0 0-5.622 11.24V64h4v-5.76c0-3.17 1.492-6.15 4.015-8.035l5.363-4.004A14.027 14.027 0 0 0 49 34.962Z" clip-rule="evenodd"/>
<path fill="#fff" fill-rule="evenodd" d="M29.799 64.001V27.887h4.4V64h-4.4Z" clip-rule="evenodd"/>
<path fill="#fff" d="M40.999 18.001a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm-29.985 0a6 6 0 1 1 12 0 6 6 0 0 1-12 0Zm15.985 12a5 5 0 1 1 10 0 5 5 0 0 1-10 0Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

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