Compare commits

..
Author SHA1 Message Date
archipelago 83b77796fc chore: release v1.7.98-alpha 2026-06-16 14:07:49 -04:00
archipelagoandClaude Opus 4.8 a569104620 fix(web5): carry node DID through to Connected Nodes routing
The backend already sends did in federation peer lists, but the Peer
type omitted it and federationNodeToPeer() dropped it when mapping. Add
did?: string to Peer and pass node.did through, so trusted/observer
node rows route to Federation/Mesh by their real DID (falling back to
pubkey/onion) instead of failing the build on a missing property.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:02:16 -04:00
archipelagoandClaude Opus 4.8 7e84434ff6 test(update): stage .download-complete marker in roundtrip test
The #26 fix makes has_staged_update require the .download-complete
marker, so the state self-heal treats a marker-less staging dir as a
partial download and clears update_in_progress. The roundtrip test
staged a binary file but not the marker, so it began failing. Write
the marker to simulate a *complete* staged update.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:41:18 -04:00
archipelago 981a86cc26 style: cargo fmt (update.rs has_staged_update + #16/#36 changes) 2026-06-16 11:30:51 -04:00
archipelago b943ca5db2 docs(whats-new): sync v1.7.98-alpha block 2026-06-16 11:29:30 -04:00
archipelago cb3d567b7d docs(changelog): curate v1.7.98-alpha notes 2026-06-16 11:29:30 -04:00
archipelagoandClaude Opus 4.8 45ac9be965 fix(kiosk): cap chromium resources + drop GPU rasterization when headless (#36)
The kiosk chromium pinned ~92% of a core (software-compositing spin from
--enable-gpu-rasterization on a GPU-less/headless node), saturating the machine
and starving the backend + container builds — it caused the .198 receive timeout
and the deploy storms.

- archipelago-kiosk.service: CPUQuota=75% + MemoryMax/High + Delegate, so a
  runaway kiosk can never take the whole node down.
- archipelago-kiosk-launcher.sh: detect /dev/dri — use GPU rasterization only
  when a GPU exists, else --disable-gpu (avoids the headless spin).
- bootstrap::ensure_kiosk_hardened: OTA self-heal that installs the updated
  unit+launcher on already-deployed nodes, daemon-reloads, and only try-restarts
  a *running* kiosk (never re-enables an operator-disabled one).

cargo check clean; launcher bash -n clean; unit syntax valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:10:26 -04:00
archipelagoandClaude Opus 4.8 ab6fcef6f3 fix(containers): periodically restart crashed stack members at runtime (#16/#17)
immich_server/redis/postgres + indeedhub-* are multi-container stack members
whose sub-container app_ids are NOT in package_data, so the health monitor skips
them as "orphans" and never restarts them when they exit — Immich/IndeedHub stay
down until the next reboot (the boot-only start_stopped_stack_containers was the
only recovery). Spawn a 120s supervisor that reuses that same recovery at
runtime. It cheaply skips already-running containers and honours the user-stopped
list (set on every container by package.stop), so it only revives genuinely
crashed members and never fights a user stop.

cargo check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:49:36 -04:00
archipelagoandClaude Opus 4.8 c7cd068e1a feat(connected-nodes): cap tabs at ~4 w/ scroll; node→Federation, message→chat (#37)
- All four tabs (trusted/observers/messages/requests) capped at max-h-72 with
  internal scroll, so the screen stays short instead of growing very long.
- Clicking a node row navigates to that node in the Federation screen
  (?node=did); the Message button (stop-propagation) deep-links to that peer\047s
  mesh chat (?peer=), using the Mesh.vue ?peer handler.

type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:41:00 -04:00
archipelagoandClaude Opus 4.8 82cfc8ccba fix(update): failed download returns to Download, not Install (#26)
A resumable-but-failed download leaves partial component files in update-staging.
has_staged_update() treated ANY staged file as "install-ready", so the state
self-heal kept update_in_progress=true and the UI showed Install instead of
Download (no clean retry).

- update.rs: write a .download-complete marker only after EVERY component
  downloads+verifies; has_staged_update() now checks that marker. Partial/failed
  downloads (no marker) correctly read as not-staged → self-heal clears
  update_in_progress → UI shows Download. Resume still works (partial files kept).
- SystemUpdate.vue: on a genuine download failure, reset downloaded/in_progress
  and re-sync, so the user lands back on Download immediately.

cargo check + vue-tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:31:12 -04:00
archipelagoandClaude Opus 4.8 3a9d1db763 feat(identity): seed-derivation verifier + KAT; rename "Your DID"→"Node DID"
- scripts/verify-seed-derivation.py: stdlib-only tool to cryptographically prove
  a node's on-disk keys (node_key→DID, nostr_secret→npub, fips_key) are derived
  from its onboarding seed exactly as seed.rs documents (BIP-39 → PBKDF2-HMAC-
  SHA512 → HKDF-SHA256 with per-key domain separation).
- seed.rs: known-answer regression test cross-checking Rust node_key + nostr
  bytes against the Python verifier (locks the derivation).
- en.json: "Your DID" → "Node DID".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:17:29 -04:00
archipelagoandClaude Opus 4.8 67609eea91 fix(toast): add fromPubkey to App.vue toast reset (type fix for #33)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:53:21 -04:00
archipelagoandClaude Opus 4.8 9c025b4cea test(toast): add fromPubkey to toastMessage literals (type fix for #33)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:51:14 -04:00
archipelagoandClaude Opus 4.8 ef2991a117 fix(chat): send Archipelago(Tor) group messages concurrently so 'sending' clears fast (#32)
sendArchMessage looped over every federation node sequentially (await
sendMessageToPeer per node), so the spinner stayed up until the slowest/offline
node's Tor request finished — long after online peers had received the message.
Send to all peers concurrently (Promise.allSettled); the spinner now clears
after the slowest single delivery, not the sum.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:42:51 -04:00
archipelagoandClaude Opus 4.8 9a518db7b8 feat(settings): show DID on every node + add seed-derived node npub (#13)
- DID: the Identity card read the DID only from localStorage('neode_did'), so
  nodes/browsers that never cached it (e.g. .116/.228) showed no DID. Fall back
  to the node.did RPC and cache it — the DID now shows everywhere.
- npub: add the node's seed-derived Nostr public key (npub) to the Identity card
  next to the DID + onion, fetched from node.nostr-pubkey, with a copy button.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:37:09 -04:00
archipelagoandClaude Opus 4.8 aa9e0f02b7 fix(cloud): pin peer file-card filename + action buttons to the bottom (#11)
Make each peer file card a flex column filling its grid cell (flex flex-col
h-full) and pin the body row (filename + Play/Download) with mt-auto, so cards
with a media preview and cards without line their footers up across the row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 09:27:29 -04:00
archipelagoandClaude Opus 4.8 edd03e542d feat(storage): encrypt chat history + mesh contacts at rest, atomic writes, persist contacts (#12)
User: chat history (messages + mesh/Tor contacts) must persist and be
secure/encrypted per best practice. Root cause of the .198 loss was the B17
mount race writing empty stores over real data (B17 already fixes the trigger);
this hardens storage so it can never silently lose or expose data:

- storage_crypto: shared at-rest envelope mirroring credentials::store — key =
  SHA-256(domain ‖ node identity key) (seed-derived, per-store domain
  separation), ChaCha20-Poly1305 AEAD with a random 96-bit nonce, tamper-evident.
  Transparent migration of legacy plaintext files. Unit-tested (round-trip,
  wrong-key/tamper rejection, plaintext detection).
- messages.json: encrypted at rest + ATOMIC write (temp+rename) so a crash/
  reboot mid-write cannot corrupt history; decrypt-with-migration on load; a
  failed decrypt never overwrites the on-disk data.
- mesh contacts (alias/notes/pinned/blocked): were ONLY in memory and lost on
  every restart — now persisted to mesh-contacts.json (encrypted, atomic),
  loaded on MeshState startup, saved after contacts-save/contacts-block.

Explicit clear (mesh.clear-all) still wipes everything, as intended.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:54:37 -04:00
archipelagoandClaude Opus 4.8 774ca28847 feat(fips): auto-activate + reliability (retry, warm paths) — make FIPS the robust primary (B14b/#27)
User priority: FIPS is the main transport but it was unreliable and needed a
manual "Activate" button. Improvements (all in the FIPS dial/supervisor):

- Auto-activate: ensure_activated() installs the daemon config + starts the
  service on its own once seed onboarding has materialised the key — no Activate
  button needed. Idempotent; runs from the supervisor every 45s so a node that
  onboards after boot still comes up automatically.
- Dial retry: try_fips_get/post now retry ONCE on a connect/timeout error. The
  first dial to a peer triggers NAT hole-punching and often times out before the
  path is up; the retry lands on the now-warm path — the main reason calls were
  dropping to Tor despite the peer being FIPS-reachable.
- More patient connect_timeout (5s→8s) so a reachable-but-cold peer isn't
  abandoned to Tor while hole-punching completes.
- Path warmer: spawn_fips_supervisor() keeps hole-punched paths to known
  federation peers warm (every 45s, concurrent), so on-demand dials are fast and
  land on FIPS.
- Confirmed the daemon config already enables BOTH udp + tcp transports
  (render_config_yaml), so FIPS already uses TCP where UDP is blocked; the Tor
  fallback was path-establishment, addressed above.

cargo check + fmt clean. Backend — needs a binary rebuild+deploy to validate on
.116/.198 (watch last_transport flip fips, and FIPS coming up with no button).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 08:16:02 -04:00
archipelagoandClaude Opus 4.8 b602a9cea5 feat(toast): message toast opens the related chat + has a close icon (#33)
- Add a close (X) button to the message toast (closeToast, @click.stop) like the
  system notifications.
- Carry the sender pubkey on the toast; clicking now deep-links to that
  conversation (/dashboard/mesh?peer=<pubkey>) instead of the generic mesh page.
- Mesh.vue reads ?peer= on mount and opens the matching peer (by pubkey_hex/did),
  gracefully falling back to the mesh list when no match (B1/B2 identity).

type-check clean; useMessageToast tests 11/11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 07:39:52 -04:00
archipelago 4576964be4 docs(tracker): file new backlog as gitea #32-#35; relay UI + fedimint CSS live on .116 2026-06-16 06:41:22 -04:00
archipelagoandClaude Opus 4.8 c481afc7d9 fix(media): loader before peer video/audio plays + accurate error (B3/B22)
Streaming a peer file connects over mesh/Tor before the first frame, so the
player sat blank. Add a loading state:
- PeerFiles video modal: spinner overlay ("Connecting to peer…") until the
  <video> fires playing/canplay; an error overlay on failure instead of a
  silent black box.
- useAudioPlayer: loading flag driven by loadstart/waiting vs canplay/playing;
  GlobalAudioPlayer shows a spinner in the transport button while connecting.
- Fix the misleading audio error "Could not play audio. File Browser may not be
  running." (wrong for peer content) → "Could not play this audio file. The peer
  may be offline…" (B22).

type-check clean; useAudioPlayer tests 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:45:17 -04:00
archipelagoandClaude Opus 4.8 921363542c fix(fedimint+home): guardian UI CSS resolves; quickstart goals full-width
- docker/fedimint-ui/nginx.conf: the local /assets/ handler 404'd the real
  fedimint guardian UI's own bundled CSS (bootstrap.min.css, style.css) →
  unstyled app. B13 fixed our local icon; this adds a @guardian_assets proxy
  fallback to :8177 so the guardian's own /assets/* resolve. Verified live on
  .116: /app/fedimint/assets/bootstrap.min.css 404→200 text/css. (needs
  archy-fedimint-ui image rebuild to persist on nodes.)
- Home.vue: Quick Start Goals card regained lg:col-span-2 so it fills its row
  on desktop instead of sitting at half width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 05:29:57 -04:00
archipelago 82659e9f4e docs(tracker): v1.7.97-alpha cut + mid-rollout state (116 deployed, 198 deploying, fleet pending) 2026-06-16 04:31:18 -04:00
archipelago 47c16971a7 chore: release v1.7.97-alpha 2026-06-16 04:16:13 -04:00
archipelagoandClaude Opus 4.8 b08e4c4268 test(filebrowser): align listDirectory tests with B4 content-type guard
The B4 fix made listDirectory require a JSON content-type (to detect the
SPA-fallback HTML / 502 cases) and changed the non-OK error string, but its
tests still mocked headerless responses + the old message, so they failed —
which also polluted the run and tripped AppIconGrid's teardown. Give the JSON
mock a content-type, update the non-OK expectation, and add a test for the
guard's friendly-error path. Full suite now 667/667 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 03:46:18 -04:00
archipelago 1278caa249 docs(whats-new): sync v1.7.97-alpha block into Settings What's New modal 2026-06-16 03:39:50 -04:00
archipelago 8a62ae008c docs(tracker): B17 root-caused + fixed (data-volume mount ordering), verified .198 2026-06-16 03:38:58 -04:00
archipelago 9da66da776 docs(changelog): add B17 boot-flap fix to v1.7.97-alpha notes 2026-06-16 03:33:58 -04:00
archipelagoandClaude Opus 4.8 34b1fdc1a3 fix(boot): order archipelago.service after the data volume mount (B17)
On production nodes /var/lib/archipelago (the app data dir AND podman's
graphroot=/var/lib/archipelago/containers/storage) is a separate
device-mapper volume. archipelago.service ordered only After=network-online
.target, so on cold boots it (and its ExecStartPre) could start BEFORE
var-lib-archipelago.mount, write to the bare mountpoint on rootfs, fail every
podman call, exit, and be restarted every 5s until the volume mounted — the
"~20x [FAILED] Failed to start over ~5min" boot flap. Proven live on .198:
"var-lib-archipelago.mount: Directory /var/lib/archipelago to mount over is
not empty, mounting anyway" — the service had written there pre-mount.

Fix: RequiresMountsFor=/var/lib/archipelago (adds Requires= + After= on the
mount unit).
- image-recipe/configs/archipelago.service: ships the directive on fresh ISOs.
- bootstrap::ensure_archipelago_mount_ordering(): self-heals already-deployed
  nodes' installed unit + daemon-reload (boot-ordering only, effective next
  reboot; never restarts the running service). Idempotent; harmless on rootfs
  installs (maps to the always-mounted root).

Verified on .198: after applying, systemctl shows After=var-lib-archipelago
.mount and systemd-analyze verify is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 03:33:29 -04:00
archipelago 2943fd0c5e style(core): cargo fmt (B1/B3/B13 follow-up — satisfy release fmt gate) 2026-06-16 03:09:18 -04:00
archipelago 486f1a061c docs(changelog): curate v1.7.97-alpha notes (13 fixes + image optimization) 2026-06-16 03:07:17 -04:00
archipelago dd0fac0e15 docs(tracker): B16 done (bitcoin tile retain/Updating…, unit-tested); image-opt staged for .97 2026-06-16 02:59:33 -04:00
archipelagoandClaude Opus 4.8 83dbd25c50 fix(home): bitcoin sync tile no longer vanishes on a transient poll (B16)
The Home > System bitcoin tile is gated on bitcoinAvailable===true, so any
transient bitcoin.getinfo failure (RPC busy during heavy IBD, route-change
scan) could blank it even though the node is fine. Add a bitcoinStale flag:
- getinfo fails while the container is Running, or package data is momentarily
  absent → retain the last-known value and mark it stale (tile stays, shows
  "Updating…" instead of a frozen figure presented as live).
- container authoritatively Stopped/Exited → flip to not-available as before
  (no stale-as-live).
- first-ever poll times out but container Running → show the tile as updating
  rather than staying hidden on a syncing node.

Harness: src/stores/__tests__/homeStatus.test.ts (6 cases) — red before, green
after. type-check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:57:35 -04:00
archipelagoandClaude Opus 4.8 386d4bfc3f perf(ui): losslessly optimize background images; convert bg-mesh PNG→JPEG
- 16 JPEGs re-encoded lossless via jpegtran (optimized Huffman + progressive,
  EXIF stripped) — pixel-identical, ~4-11% smaller each.
- bg-mesh.jpg was a 5.8MB RGBA PNG mislabeled .jpg → real progressive JPEG
  (mozjpeg q92, opaque), 5.8MB → 0.76MB (-87%).
- Synced optimized assets into web/dist and per-app container UIs (lnd/bitcoin/
  fedimint/aiui) + app-icons. Source img dir 21.4MB → 16MB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:19:50 -04:00
archipelagoandClaude Opus 4.8 bf24bbc15a fix(mempool): resolve CORE_RPC_HOST to the actual bitcoin node (Knots/Core) (B12)
CORE_RPC_HOST was hardcoded to bitcoin-knots in three env-render paths, so on a
bitcoin-core node (container named bitcoin-core) mempool-api could not reach
Bitcoin RPC. Both node variants are reachable on archy-net by container name —
only the name differs.

- Legacy direct-podman (stacks.rs) and config.rs::get_app_config now use a new
  dependencies::detect_bitcoin_rpc_host() (pure, unit-tested pick_bitcoin_host).
- Quadlet/manifest path (the modern fleet default): add a {{BITCOIN_HOST}}
  derived-env placeholder — HostFacts.bitcoin_host + resolve_derived_env render
  it; prod_orchestrator detects Knots/Core via podman ps, resolved on demand
  only for manifests that use the placeholder. mempool-api manifest moves
  CORE_RPC_HOST from static env to derived_env: {{BITCOIN_HOST}}.

Tests: pick_bitcoin_host (5 cases incl. substring safety), container-crate
resolve_derived_env, and orchestrator mempool_core_rpc_host_follows_bitcoin_node
(core->bitcoin-core, knots->bitcoin-knots). No-regression confirmed: picker
returns bitcoin-knots live on .198. Live bitcoin-core validation pending (no
core node available). Sibling hardcodes (lnd/btcpay/electrumx/fedimint) tracked
as B12b.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 02:07:39 -04:00
archipelagoandClaude Opus 4.8 987a961f4a fix(nginx): self-heal fedimint asset rewrite on deployed nodes — HTTP + HTTPS (B13)
The B13 template fix only fixed fresh ISOs. Already-deployed nodes keep their
old nginx config, where /app/fedimint/ proxies to :8175 without rewriting the
Guardian UI's root-rooted asset URLs (src="/assets/...", url("/assets/...")).
Those resolve against the SPA root: bg-network.jpg exists there by luck, but
app-icons/fedimint.jpg 404s (location /assets/ uses try_files =404) — the
visibly-broken icon.

bootstrap.rs::patch_nginx_conf now heals both paths on startup:
- Style A (main conf, HTTP): swaps the old single nostr-provider sub_filter tail
  for the full reroot set; byte-matches the shipped template.
- Style B (HTTPS app-proxy snippet): the snippet's fedimint block has no
  sub_filter and a per-node-varying trailing directive, so anchor on the unique
  :8175 proxy_pass and insert the reroot set after it (nginx ignores directive
  order). Snippet added to the bootstrap nginx loop (skipped on HTTP-only nodes).

missing_* flags are now gated on their splice anchors so the included snippet
neither attempts the main-conf-only patches nor logs warn-skips every boot.
Idempotent via the 'href="/' 'href="/app/fedimint/' marker.

Verified on .198 (both paths): fedimint app-icon 404 -> 200 image/jpeg; nginx -t
OK; containers survived restart (Quadlet); idempotent steady state, no warn spam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:03:04 -04:00
archipelagoandClaude Opus 4.8 a50b6df21b fix(nginx): rewrite fedimint UI asset paths so CSS applies (B13, fresh-ISO)
Fedimint UI HTML/CSS reference absolute /assets/* paths; under /app/fedimint/
those hit the main SPA, not the fedimint container, so the UI renders
unstyled. Add the proven sub_filter asset-rewrite pattern (as indeedhub/
botfights use) to the /app/fedimint/ block in the nginx template + https
snippet (also rewrites url(...) for the CSS background image). Bootstrap
self-heal for already-deployed nodes is the documented resume point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:52:30 -04:00
archipelagoandClaude Opus 4.8 8427e219ea docs(tracker): round-2 status (B15/B7 done, B13/B12/B16 deferred w/ plans)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:31:24 -04:00
archipelagoandClaude Opus 4.8 c0d41cf8cf fix(ui): faster bitcoin sync refresh + unstick ElectrumX loader (B15,B7)
B15: Home system stats (incl. bitcoin sync %) polled every 30s — too slow;
now 10s so sync progress tracks the actual block height more closely.

B7: the ElectrumX sync overlay was gated only on status!=='synced', so if
the status never flips to 'synced' (ElectrumX stale/disconnected) the loader
stuck on top forever. Now the overlay hides and the app iframe loads when
the sync status is stale (fail-open), while still showing during active
indexing. type-check EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:29:44 -04:00
archipelagoandClaude Opus 4.8 eb55c88e1a docs(tracker): B6/B7/B12/B13/B15/B16 root causes + fix plans
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:43:01 -04:00
archipelagoandClaude Opus 4.8 31fe91b99a docs(tracker): B13 fedimint CSS investigation progress
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:13:28 -04:00
archipelagoandClaude Opus 4.8 b9cc4bd780 docs(tracker): B14b FIPS reachability findings (dial-time, not npub/service)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:11:47 -04:00
archipelagoandClaude Opus 4.8 6c92eacba0 docs(tracker): add B22 (peer download/audio errors), B23 (group chat), B3 PASSED-http
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:09:31 -04:00
archipelagoandClaude Opus 4.8 602b9cd3df fix(nginx): route /api/peer-content/* to the backend for B3 streaming
The B3 streaming proxy endpoint existed in the backend but nginx had no
location for /api/peer-content/*, so the browser's requests fell through to
the SPA (200 text/html) and media still wouldn't play. Add an
NGINX_PEER_CONTENT_BLOCK that bootstrap patches into every server block
(forwards Cookie for session auth + Range, proxy_buffering off). Idempotent;
covers fresh-ISO nodes too since bootstrap runs on every startup.

Verified on .198: after restart the async nginx patch lands and
/api/peer-content/<onion>/<id> returns 401 (reaches backend, auth-gated)
instead of the SPA; nginx block present in both server blocks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:07:39 -04:00
archipelagoandClaude Opus 4.8 5c8707432b fix(cloud): Range-streaming proxy for peer media so it plays/seeks (B3)
Peer media (music/video) wouldn't play: the frontend downloaded the whole
file via RPC as base64 and made a non-seekable Blob URL, so <video>/large
<audio> stalled and big files hit the RPC timeout.

Add GET /api/peer-content/<onion>/<id> — a same-origin, session-gated proxy
that forwards the browser's Range header to the peer's /content/<id> (which
already returns 206 Partial Content) and passes status + Content-Range +
Content-Type back. PeerFiles.playMedia() now points <video>/<audio> at this
streaming URL for free content instead of buffering a base64 blob, so the
player can seek and start immediately. Onion/id validated to prevent
SSRF/path traversal. (Paid preview keeps its existing flow.)

Verified: cargo build --release EXIT 0; vue-tsc --noEmit EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:46:51 -04:00
archipelagoandClaude Opus 4.8 4cac6bc835 docs(tracker): record B1/B2/B4/B14/B21 done + B14b; next B3
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:27:51 -04:00
archipelagoandClaude Opus 4.8 0801dd6632 feat(cloud): show Tor/FIPS transport pill on peer browse (B21)
content.browse-peer now returns the transport that actually reached the
peer (fips/tor/mesh/lan). PeerFiles shows it as a small coloured pill next
to the peer name (FIPS/Mesh green, LAN blue, Tor amber) and the loading
text no longer hardcodes "Connecting via Tor" (it was misleading when FIPS
was used). Pairs with B14 (transport recording).

Verified: cargo build --release EXIT 0; vue-tsc --noEmit EXIT 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:25:39 -04:00
archipelagoandClaude Opus 4.8 1c6dc153ce fix(content): use re-exported federation::record_peer_transport path (repair build)
The B14 commit referenced crate::federation::storage::record_peer_transport
but `storage` is a private module — record_peer_transport is re-exported at
crate::federation::. E0603 broke the build. Use the re-exported path (as
load_nodes/fips_npub_for_onion already do). Verified: cargo build --release
EXIT 0. Also logs B21 (Tor/FIPS pill) plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:15:01 -04:00
archipelagoandClaude Opus 4.8 f2e3710c28 fix(content): record peer transport on cloud browse/download/preview (B14)
The 4 content peer handlers (browse, download, download_paid, preview)
captured the transport returned by PeerRequest::send_get() but discarded
it, so the federation node's last_transport was never updated for cloud
activity — the UI showed Tor/none even when FIPS was used. Call
record_peer_transport() after each successful fetch (same as sync does).

Note: live data shows FIPS still reaches only some peers (many genuinely
fall back to Tor) — tracked separately as B14b (FIPS reachability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:02:13 -04:00
archipelagoandClaude Opus 4.8 ed4931064b fix(federation,cloud): dedup trusted nodes + chat contacts by onion; guard cloud my-folders (B1,B2,B4)
B1/B2: the same physical node can linger in the federation list under two
dids (e.g. after a did/key change). An onion is a node's unique stable
identity, so two entries with the same onion are one node. This showed the
node twice in the trusted-node list (B1) and as two mesh chat contacts —
one by name+logo, one by raw did (B2).
- storage::load_nodes now collapses same-onion entries (keep first, merge
  fips_npub/name/last_state) so every consumer (list + chat seed + sync)
  sees one entry per node.
- federation::sync merge_transitive_peers also matches by onion (not just
  did) so new transitive hints don't re-add a known node under a new did.
- mesh::seed_federation_peers_into_mesh skips already-seeded onions (belt
  and suspenders).
- Unit tests for dedup_nodes_by_onion (collapse + onion-suffix handling).

B4: filebrowser-client.listDirectory only checked res.ok before res.json(),
so when File Browser is absent (nginx serves the SPA index.html, 200) or
down (502) the JSON parse threw the opaque "Unexpected token '<'". Now it
checks the content-type and throws a friendly "File Browser is not
available" the Cloud view already renders as an empty state.

Verified: dedup unit tests 2/2; live .198 (15 entries→13 distinct onions)
restarted healthy on new binary; B4 guard present in built bundle + deployed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 12:29:12 -04:00
archipelagoandClaude Opus 4.8 1db720af13 fix(lnd): repair fleet-wide CORS on LND connect-wallet endpoints (B5)
The LND wallet UI (served on its own app port) fetches /lnd-connect-info
and /proxy/lnd/* cross-origin, so both need correct CORS headers.

(a) Older nginx configs add their own Access-Control-Allow-Origin in the
    /lnd-connect-info location on top of the one the backend sets, yielding
    a DUPLICATE header that browsers reject ("multiple values"). bootstrap
    now strips that redundant nginx add_header (backend owns CORS).
(b) /proxy/lnd/* returned a 401 with no CORS headers when the session
    check failed, so the browser saw an opaque CORS error instead of a
    readable 401. Add unauthorized_cors() and use it on that path.

Adds tests/production-quality/ (bug tracker + lnd-cors-test.sh harness).
Verified: harness 4/4 on .116, .198, .103.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 11:31:14 -04:00
archipelagoandClaude Opus 4.8 8c3c79543e chore: sync core/Cargo.lock to 1.7.96-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:15:24 -04:00
archipelago 7aa1ca013f chore: release v1.7.96-alpha 2026-06-15 10:14:05 -04:00
archipelagoandClaude Opus 4.8 5af9a22b98 feat(fips): selectable TCP/UDP transport when adding a seed anchor
The add-anchor form previously hardcoded transport=udp. Expose a
TCP/UDP selector (default tcp) so public internet anchors and
local-network anchors can both be added. Includes changelog + What's
New entry for v1.7.96-alpha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:12:23 -04:00
archipelagoandClaude Opus 4.8 786498a57a fix(kiosk): remove kiosk launcher grid, show normal app on the display
The kiosk attached-display showed a separate app-tile launcher grid
(Kiosk.vue at /kiosk) instead of the normal onboarding/login/dashboard.
The grid is auth-gated, so it only surfaced once the kiosk browser held a
persisted session; otherwise it bounced to login — masking the issue.

Remove the grid entirely. /kiosk now just persists kiosk mode + safe-area
insets and redirects to the root app. The launcher keeps pointing at
/kiosk (not directly at /) so the 'kiosk' localStorage flag is still set —
App.vue uses it to skip the remote relay, which would otherwise double
xdotool input on the kiosk display. Route made public so the auth guard
doesn't bounce it before the redirect runs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:03:07 -04:00
archipelagoandClaude Opus 4.8 790ad154f3 chore: sync core/Cargo.lock to 1.7.95-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:04:30 -04:00
archipelagoandClaude Opus 4.8 0c8991b519 test(multinode): assertion-based two-node E2E smoke suite
Adds tests/multinode/smoke.sh on the existing multinode.bash lib: an
assertion suite (pass/fail + non-zero exit) driving two real nodes through
login, onion + FIPS identity, FIPS anchor-connected, federation pairing
both directions, peer content browse over the mesh, and the removed-node
tombstone (with an optional 3rd node C for the transitive-reappear case).
Guards the v1.7.94/v1.7.95 fixes. Content-browse + tombstone checks
skip-with-note against peers older than v1.7.95.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 09:03:58 -04:00
archipelago e2c2f942c2 chore: release v1.7.95-alpha 2026-06-15 08:48:22 -04:00
archipelagoandClaude Opus 4.8 937ba7e115 chore: sync core/Cargo.lock to 1.7.94-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:09:55 -04:00
archipelagoandClaude Opus 4.8 e056c2477b fix(fips,federation,ui): mesh content browse, removed-node tombstones, modal sizing
FIPS peer content browse over the mesh was failing with "Peer returned
error: 404 Not Found" and never falling back to Tor. `is_peer_allowed_path`
only allowed `/content/<id>` (item fetches) — the catalog endpoint is
exactly `/content` (no trailing slash), so it 404'd over the FIPS peer
listener. A FIPS 404 was also treated as a successful response, so the dial
never retried Tor. Fixes: allow `/content` over the mesh; add
`fips_should_fall_back()` so a FIPS 404/5xx in Auto mode falls back to Tor
(handles version-skew peers reaching a different route). Also correct the
reconnect hint text — the public anchor is TCP/8443, not UDP/8668.

Federation: deleted nodes reappeared because transitive discovery
(`merge` of a peer's advertised trusted peers) re-added any unknown DID.
Add a tombstone store (`removed-nodes.json`): remove_node tombstones the
DID, transitive merge skips tombstoned DIDs, and a remote-triggered
peer-joined is ignored for a removed DID. Explicit local re-add (add_node)
clears the tombstone.

UI: the app credentials modal panel stretched edge-to-edge (height:100%,
max-width:none, items-stretch overlay). Constrain it to a centered card
(max-width 34rem, rounded, dimmed full-screen backdrop) matching the
AppIconGrid / wallet-receive modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:09:26 -04:00
archipelago 7bd22f1f80 chore: release v1.7.94-alpha 2026-06-15 07:09:58 -04:00
archipelagoandClaude Opus 4.8 cfb0e4735a chore: sync What's New modal for v1.7.94-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:43:20 -04:00
archipelagoandClaude Opus 4.8 95f9a805b1 feat(fips): connect to public mesh anchor over TCP + wire daemon updates
The whole fleet was silently never reaching the FIPS mesh: the default
public anchor was configured as fips.v0l.io:8668/udp, but the anchor only
answers on TCP/8443. Fix the default to 185.18.221.160:8443/tcp (IPv4
literal — the hostname resolves IPv6-first and the daemon binds v4-only,
which fails the handshake with EAFNOSUPPORT), and auto-seed it in
anchors::load() so every node dials it without operator action (removal
still persists). Proven live on .116: cold start → anchor_connected in
~400ms, anchor became mesh parent.

Wire fips::update::apply() against upstream GitHub releases (stable
channel only): resolve /releases/latest → SHA256-verify the .deb against
checksums-linux.txt → install → restart. dpkg runs via `systemd-run` to
escape archipelago's ProtectSystem=strict sandbox (else /var/lib/dpkg is
read-only), with --force-confold (archipelago manages /etc/fips conffiles)
and --force-downgrade (dev builds sort newer than the stable tag).
Validated live: .116 upgraded 0.3.0-dev -> stable v0.3.0.

Also: standalone fips-ui dashboard app (apps/fips-ui + docker/fips-ui,
static nginx proxying /rpc/v1 same-origin, copiable own-anchor address);
reserve UI port 8336; register fips/fips-ui as platform-managed. Includes
the Lightning wallet cross-origin (CORS) + LND proxy auth + nginx
self-healer fix so the wallet screen connects instead of "failed to fetch".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:41:48 -04:00
archipelagoandClaude Opus 4.8 640dc87a5f chore: sync core/Cargo.lock to 1.7.93-alpha (release leftover)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:21:07 -04:00
archipelago 327a4e34dd chore: release v1.7.93-alpha 2026-06-14 15:18:34 -04:00
archipelagoandClaude Opus 4.8 bf2793be7b chore: sync What's New modal for v1.7.93-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:45:56 -04:00
archipelagoandClaude Opus 4.8 1973d76427 style: rustfmt lnd migrate_locked_wallet matches! call
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:41:40 -04:00
archipelagoandClaude Opus 4.8 403fa6eff3 docs: changelog for v1.7.93-alpha (LND wallet self-heal)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:38:57 -04:00
archipelagoandClaude Opus 4.8 3214d6aff3 fix(lnd): self-heal unrecoverable locked wallet via wipe+recreate
When an existing LND wallet is locked and none of the candidate passwords
(per-node secret, legacy constant) open it, the node can never auto-unlock
unattended. unlock_existing_wallet now returns Ok(false) for "all candidates
actively rejected" (vs Err for transient "LND not ready"), and
ensure_wallet_initialized responds by recreating the wallet:

  - mark the lnd container user-stopped so the health monitor won't
    re-launch it (and re-open the wallet) mid-wipe,
  - stop lnd, delete its wallet/chain/graph state as root,
  - start lnd, wait for NON_EXISTING, re-init a fresh wallet on the
    per-node secret, then clear the user-stopped flag.

LND runs as a plain bridge-network podman container (not a Quadlet unit),
so it is restarted via `systemd-run --user --scope podman`, matching the
orchestrator/health-monitor path.

Alpha nodes hold no funds and a wallet locked with an unknown password is
already inaccessible, so the wipe loses nothing reachable. Completes the
forward fix from 91adc281 for nodes whose wallet pre-dates the per-node
secret and whose password is unrecorded (e.g. .116/.228).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:08:33 -04:00
archipelagoandClaude Opus 4.8 459046b21c docs: resume notes for LND wallet fix (in-progress, branch lnd-wallet-password-fix)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:26:10 -04:00
archipelagoandClaude Opus 4.8 91adc281ca fix(lnd): per-node wallet password + locked-wallet self-heal on login
Replaces the fleet-wide hardcoded WALLET_PASSWORD='hellohello' that left wallets
LOCKED after OTA/reboot (auto-unlock used the wrong password fleet-wide).

Forward fix (both init paths unified, validated cargo check + LND REST mechanics
on a scratch wallet):
- Per-node random 256-bit secret in secrets/lnd-wallet-password (0600), mirroring
  secrets/bitcoin-rpc-password. read_wallet_password (no-gen) vs
  ensure_wallet_password (gen at init only).
- container/lnd.rs init AND api/rpc/lnd/wallet.rs seed-derived init both use the
  per-node secret (wallet.rs keeps recoverable derived entropy; password unified).
- Unlock tries [per-node secret, legacy 'hellohello']; single-attempt primitive
  distinguishes invalid-passphrase (fail fast, try next) from not-ready (retry),
  so a wrong password no longer hangs the boot path ~60s.

Migration (candidate-unlock + rotate, best-effort at login):
- change_wallet_password (WalletUnlocker.ChangePassword) + migrate_locked_wallet:
  if LOCKED, try candidates as current pw and ChangePassword onto the per-node
  secret so future boots auto-unlock. Hooked into auth.login (non-blocking) with
  the just-verified password as the candidate.

NOT YET: seed-recovery fallback for wallets where no candidate matches (e.g.
.116/.228) — destructive, needs entropy-source/funds-safety handling; next pass.
NOT shipped: pending end-to-end validation on a real node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:19:56 -04:00
archipelagoandClaude Opus 4.8 a9c4e54023 chore: sync core/Cargo.lock to 1.7.92-alpha (release leftover)
create-release.sh bumps Cargo.toml but not the lock's archipelago version line;
the cargo build regenerates it post-commit. Same as the 1.7.91 leftover — worth
fixing create-release.sh to stage Cargo.lock, tracked separately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:42:13 -04:00
archipelagoandClaude Opus 4.8 8c8e4d7a29 test: gate that LND wallet is unlocked after restart (catches fleet-wide lock)
A wrong/locked LND wallet password leaves the wallet LOCKED after every
restart/OTA, breaking all Bitcoin-receive + Lightning ops fleet-wide — and the
harness was blind to it: live-lnd-address-type treats 'wallet locked' as PASS,
os-audit treated lnd-unreachable as WARN, and the archipelago lnd.getinfo RPC
masks a locked wallet (returns all-zero success).

- tests/release/run.sh: new 'live-lnd-unlocked' stage polls LND's unauth
  /v1/state and FAILs if still LOCKED after a 60s grace window.
- tests/lifecycle/os-audit.sh: probe lnd.newaddress (the real receive path,
  which surfaces LND_WALLET_LOCKED) instead of lnd.getinfo; locked = hard FAIL,
  not-installed = WARN.

Proven on .116 (genuinely locked): os-audit now reports
'[FAIL] lnd wallet unlocked (lnd.newaddress) wallet LOCKED'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:36:12 -04:00
archipelagoandClaude Opus 4.8 9d3347463a docs: record v1.7.91 + v1.7.92 published; What's New gate; .116 nginx fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:20:35 -04:00
archipelago d462e44453 chore: release v1.7.92-alpha 2026-06-14 09:09:57 -04:00
archipelagoandClaude Opus 4.8 1af583e1ab docs: add third v1.7.92 changelog bullet (What's New backfill) + sync modal
create-release staging requires >=3 curated release-note bullets. The What's
New restoration is itself user-facing, so it's an honest third note; mirror it
into the modal's v1.7.92 block via sync-whats-new.py.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:03:18 -04:00
archipelagoandClaude Opus 4.8 2fac63e58c feat(release): gate that Settings 'What's New' modal stays in sync with CHANGELOG
The What's New modal (AccountInfoSection.vue) hardcodes one block per release
and had silently drifted: it sat at v1.7.84 while the fleet shipped through
v1.7.92, so eight releases of notes never reached users in Settings.

- scripts/sync-whats-new.py: renders a modal block from each CHANGELOG version
  that's missing one (curated bullets, dev-process 'Validation…' lines dropped),
  inserts newest-first; never touches older hand-written pre-CHANGELOG history.
  --check mode lists anything missing and exits non-zero.
- tests/release/run.sh: new 'whats-new-sync' static gate runs --check, so a
  release with an un-surfaced CHANGELOG entry fails before shipping.
- Backfilled the eight missing blocks (v1.7.85 … v1.7.92) into the modal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:31:43 -04:00
archipelagoandClaude Opus 4.8 2999ab62ea docs: changelog for v1.7.92-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:04:13 -04:00
archipelagoandClaude Opus 4.8 5b052372b7 test(resilience): gate host-reboot batch on os-audit (L3 per-boot health)
batch_host_reboot previously asserted only container-set equality after the
reboot. Add the os-audit.sh per-boot health gate: after rpc_login succeeds
post-reboot, run os-audit against the target (ARCHY_LOCAL=0, https) and record
host_reboot_osaudit PASS/FAIL. This asserts the node is actually healthy after
a reboot — RPC up, OTA not wedged (FM12), every app reachable with valid launch
metadata, FM-guards green — not just that the right containers exist. Validated
green on .116 (11 pass / 0 fail / 0 warn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 08:01:30 -04:00
archipelagoandClaude Opus 4.8 4232424b23 fix(ui): suppress app-unreachable overlay while ElectrumX sync screen shows
When ElectrumX is still building its index (or waiting on the Bitcoin node),
AppSessionFrame shows a sync 'pre UI'. The iframe-blocked fallback ('App not
reachable / retrying') was not gated on electrsSync, so it painted over the
sync screen and read as a hard connection error. Gate it on !electrsSync,
mirroring the iframe's own guard.

Also harden the lifecycle health probe: container_health used jq '// "unknown"',
which only catches null/false — an empty-string health (a brief window under
load) rendered as a blank 'bad health: X is '. Map empty to 'unknown' so the
retry loop keeps waiting instead of failing on a transient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:58:24 -04:00
archipelagoandClaude Opus 4.8 60fe761def chore: sync core/Cargo.lock to 1.7.91-alpha (release leftover)
create-release.sh bumps Cargo.toml; the lock's archipelago version line is
regenerated by the subsequent cargo build and was left uncommitted after the
v1.7.91-alpha release commit. The shipped binary is built from the bumped
Cargo.toml, so this is bookkeeping only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 07:58:03 -04:00
archipelago 9b9fa9cdee chore: release v1.7.91-alpha 2026-06-14 05:32:38 -04:00
archipelagoandClaude Opus 4.8 329e7811eb test(lifecycle): add os-audit OS-wide health gate; docs: v1.7.91 resume notes
os-audit.sh: one non-destructive scorecard tying backend/RPC health, the
all-apps lifecycle audit (delegates to remote-lifecycle.sh), and the FM-guards
(port-drift, secret-completeness, orphan-container sweep, OTA-wedge). The
per-boot building block for the reboot-survival loop. FM12 check uses jq has()
not // (// treats a legit false as empty). Section A validated all-PASS on .116.

docs: v1.7.91 release-pass resume notes + the bitcoinReceive blocker writeup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:36:06 -04:00
archipelagoandClaude Opus 4.8 21aaacc8b4 fix(ui): guard receive-code index access — unblocks v1.7.91 frontend build
codeMatch[1] is string|undefined under noUncheckedIndexedAccess; using it
directly as an index into RECEIVE_CODE_MESSAGES failed vue-tsc (TS2538) and
aborted create-release.sh at the frontend build step. Bind to a const and
narrow before indexing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 04:35:21 -04:00
archipelagoandClaude Opus 4.8 ab85827187 docs: changelog for v1.7.91-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:59:49 -04:00
archipelagoandClaude Opus 4.8 bea745047d docs: record F1 live validation on .116 (green)
Before/after on the live node confirms the launch_url_port fix:
jellyfin/btcpay/fedimint/gitea/portainer/botfights all went from
lan_address=None to a resolved http://localhost:PORT/ URL; harness
focused audit passed, exit 0. Also documents that archipelago.service
restarts are safe on .116 (containers run in the user-1000 slice, a
different cgroup, and survived the restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:55:58 -04:00
archipelagoandClaude Opus 4.8 a483fe4baa fix: derive launch port from URL authority, not naive rsplit
reachable_lan_address() parsed the launch port with url.rsplit(':')
which yields "8096/" for manifest interfaces.main URLs that carry a
path (http://localhost:8096/). That fails to parse and silently drops
a perfectly reachable launch URL, so apps like jellyfin, btcpay-server,
fedimint, gitea, nextcloud and portainer showed running with no launch
link in the UI. New launch_url_port() reads digits after the final
colon (mirroring port_from_url in the RPC layer) and tolerates a
trailing path. Adds regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:35:19 -04:00
archipelagoandClaude Opus 4.8 0ed892a412 fix: wallet receive reliability, bitcoin install self-heal, ElectrumX app tile
Fixes three Bitcoin/wallet failures observed across the fleet on v1.7.90-alpha
(all nodes were already on the latest build — these were live bugs, not stale
builds), plus the missing ElectrumX tile, and adds automated coverage so each
can't regress silently.

Receive address (".116 receive fails", ".228 false 'wallet is locked'"):
- LND publishes its REST API on a host port that can drift from the manifest
  (a container created when the mapping was 8080 kept publishing 8080 after the
  manifest moved to 18080). The in-process client connects to the manifest port,
  gets connection-refused, and wallet init fails forever while the container
  looks "Up". Add published-port drift detection to the reconciler
  (container_ports_drifted / host_port_bindings_drifted) that recreates a
  drifted backend even for restart-sensitive apps — a drifted container is
  already broken, so leaving it "untouched" only perpetuates the failure.
- Receive errors now carry a stable [CODE] token (REST_UNREACHABLE, WALLET_LOCKED,
  WALLET_UNINITIALIZED, SYNCING) and always start with "Bitcoin address" so they
  survive the RPC error sanitizer instead of collapsing to the generic
  "Operation failed". The UI maps the code instead of guessing wallet state from
  substrings — so an unreachable REST endpoint is no longer mislabelled "locked".

Bitcoin install (".198 bitcoin gone / reinstall just stops"):
- bitcoin-knots requires the secret bitcoin-rpc-txrelay-rpcauth, which was only
  generated by the tx-relay flow. Nodes that never used tx-relay lacked it, so
  secret resolution hard-failed and the whole Bitcoin stack cascaded. Generate
  it idempotently before bitcoin starts (ensure_app_secrets, reusing
  ensure_txrelay_credentials), and name the missing secret in the error so a
  genuine gap is actionable instead of a bare "IO error".

ElectrumX app tile missing on every node with it installed:
- The catalog generator dropped electrumx because the manifest had no
  interfaces.main block, so the tile had no launch URL and was hidden. Declare
  the companion UI port (50002) in the manifest, regenerate the catalog, and let
  an app with a known launch URL stay launchable while its backend is still
  "starting" (ElectrumX indexes for 10m+).

Test harness:
- New lifecycle bats suites: bitcoin-receive, port-drift, secret-completeness
  (validated live; port-drift catches the real .116 drift).
- Rust unit tests for drift detection, the receive reason-code classifier, and
  the named-missing-secret error; vitest for the UI code mapping.
- create-release.sh now runs tests/release/run.sh and aborts the release on
  failure — previously it ran no tests at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:12:56 -04:00
archipelago bb808df89a chore: release v1.7.90-alpha 2026-06-13 05:05:14 -04:00
archipelagoandClaude Opus 4.8 c800293f1f fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout
- LND wallet: request correct address type so receive-address generation
  no longer 400s
- AIUI/app session: on-screen pointer can click + type into app content
  (incl. app store search); "open in new tab" opens the phone browser;
  mobile credential modal centered instead of full-height
  (remote-relay.ts, AppSession.vue, AppSessionFrame.vue, AppIconGrid.vue,
  openExternal.ts, WebViewScreen.kt) + remote-relay tests
- health_monitor: electrs auto-recovers from a corrupt index and shows a
  percent/block-height progress screen while reindexing (useElectrsSync.ts)
- update.rs: drop retired tx1138 secondary mirror (one-time migration);
  longer download timeout for slow connections
- CHANGELOG: v1.7.90-alpha notes
- tests/release/run.sh: harness tweaks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 04:49:32 -04:00
archipelago 340b981b79 chore: release v1.7.89-alpha 2026-06-13 01:34:11 -04:00
archipelagoandClaude Opus 4.8 c49e8fcacd fix: harden OTA updates, AIUI desktop gap, LND no-proxy
- update.rs: post-OTA probe falls back to http://127.0.0.1/ on connect
  error (nginx binds :80, not :443) so good updates are no longer rolled
  back; recover stuck update_in_progress; avoid ETXTBSY on running binary
- LND: REST client bypasses proxy, GET newaddress p2wkh, wallet
  readiness/unlock after restart
- Dashboard.vue: chat route back to plain h-full (desktop bottom-gap fix)
- vite.config.ts: dev-only /aiui proxy
- tests/release/run.sh: release gate harness (static+frontend+backend)
- CHANGELOG: v1.7.89-alpha notes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 01:23:32 -04:00
archipelago 495b90782a fix: restore AIUI mobile layout 2026-06-12 06:01:24 -04:00
archipelago 0cfb4dc81c chore: release v1.7.88-alpha 2026-06-12 05:12:52 -04:00
archipelago b8ac68d844 fix: restore aiui and bitcoin receive before release 2026-06-12 05:10:03 -04:00
archipelago eaf13effd5 fix: restore fast AIUI launch 2026-06-12 05:04:42 -04:00
archipelago 0339268c43 chore: sync cargo lock for v1.7.87-alpha 2026-06-12 04:55:09 -04:00
archipelago 6fd1cf9ba7 chore: release v1.7.87-alpha 2026-06-12 04:49:58 -04:00
archipelago 8d4b309753 fix: patch bitcoin receive and full-screen launch overlays 2026-06-12 04:42:23 -04:00
archipelago b11c6c17d1 chore: release v1.7.86-alpha 2026-06-12 04:21:18 -04:00
archipelago e474a2b4c9 chore: sync generated release artifacts 2026-06-12 03:15:24 -04:00
archipelago 00c32688f8 chore: release v1.7.85-alpha 2026-06-12 03:14:59 -04:00
archipelago d6f108d818 chore: snapshot release workspace 2026-06-12 03:00:15 -04:00
archipelago 6a30ff11bd chore: release v1.7.84-alpha 2026-06-11 04:44:58 -04:00
211 changed files with 8720 additions and 4493 deletions
@@ -137,7 +137,11 @@ fun WebViewScreen(
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
).apply {
// Required when launching from a non-Activity/binder
// thread (the JS bridge below runs off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
@@ -169,8 +173,29 @@ fun WebViewScreen(
allowContentAccess = true
allowFileAccess = false
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
// chain; without this, window.open() from a Vue click
// handler silently no-ops and "Open in new tab" dies.
javaScriptCanOpenWindowsAutomatically = true
}
// Deterministic bridge for "open in the phone's browser".
// The web UI calls window.ArchipelagoNative.openExternal(url)
// when present (companion app), falling back to window.open
// in a plain mobile browser. This avoids relying on the
// window.open → onCreateWindow path, which noopener/noreferrer
// can suppress in the WebView.
val webViewRef = this
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { openExternalUrl(url) }
}
},
"ArchipelagoNative",
)
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
isLoading = true
+126
View File
@@ -1,5 +1,131 @@
# Changelog
## v1.7.98-alpha (2026-06-16)
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).
- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.
- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.
- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.
- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.
- Message notifications now have a close button and open the relevant chat when tapped.
- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.
- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).
- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.
- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.
- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.
## v1.7.97-alpha (2026-06-16)
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
## v1.7.96-alpha (2026-06-15)
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
## v1.7.95-alpha (2026-06-15)
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
## v1.7.94-alpha (2026-06-15)
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
## v1.7.93-alpha (2026-06-14)
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
## v1.7.92-alpha (2026-06-14)
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
## v1.7.91-alpha (2026-06-14)
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
- The Electrum server app is back on the home screen and can be launched again.
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
## v1.7.90-alpha (2026-06-13)
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
- In the companion app, the on-screen pointer can now click into apps and type — including the app store search box — instead of clicks and keystrokes not reaching app content.
- "Open in a new tab" from the companion app now opens the app in your phone's browser, instead of doing nothing. The normal mobile browser keeps working as before.
- The login/credentials pop-up on phones is once again a centered, properly sized window rather than stretching the full height of the screen.
- The Electrum server now recovers on its own if its index ever gets corrupted, and shows a clear progress screen (with percent complete and block height) while it builds its index, instead of a blank or broken page.
- Software updates are more reliable on slow internet connections — downloads are given much more time to finish before giving up.
## v1.7.89-alpha (2026-06-12)
- The AI assistant looks the way it always did again: no extra back button or close button on phones, and the desktop view fills the whole screen without a gap at the bottom.
- System updates are much more reliable: updates that previously got stuck partway or failed to install now complete cleanly, and a failed update can no longer block all future updates.
- After an update, the system now checks itself correctly on every node type, so working updates are no longer mistakenly undone.
- Generating a Bitcoin receive address works again on nodes where a network proxy previously got in the way.
- The Lightning wallet now recovers and unlocks itself properly after restarts.
## v1.7.88-alpha (2026-06-12)
- AIUI now loads immediately again instead of waiting on a production availability probe and cache-busted iframe URL, restoring the lighter launch behavior from before the regression.
- Bitcoin receive now uses LND's GET-based newaddress flow with the native SegWit address type, fixing the `501 Method Not Allowed` response from the previous POST attempt.
- Validation pending on the AIUI rollback; the rest of the release train remains unchanged.
## v1.7.87-alpha (2026-06-12)
- Bitcoin receive now calls LND's on-chain address endpoint with the correct REST method, and backend failures keep the specific address-generation error instead of collapsing into the generic operation-failed message.
- App launch credential interstitials now render as true full-screen overlays, and the launcher loading indicator uses the neutral brand palette instead of a blue spinner.
- Validation passed with `git diff --check`, `npm run type-check`, and the focused frontend tests for `bitcoinReceive` and `AppIconGrid`.
## v1.7.86-alpha (2026-06-12)
- Fleet now preserves the last known node list, alerts, and selection locally while telemetry refreshes in the background, so the dashboard no longer blanks on tab switches or update scans.
- Connected nodes and identities now reuse their last loaded data instead of reloading the visible list every time the user revisits the tab.
- The Fleet matrix and detail views now show actual node names and host information instead of raw node id prefixes.
- The network map only redraws when its graph data actually changes, which stops the D3 scene from visually resetting on every refresh tick.
- Mobile federation and system-update actions now stack full width, and the ElectrumX app health check allows a long startup window so slow sync nodes do not restart mid-index.
- Validation passed with `git diff --check`, focused frontend tests, and `npm run type-check`.
## v1.7.85-alpha (2026-06-12)
- ElectrumX now runs with less cache pressure and more memory headroom, reducing the restart loop seen during sync catch-up.
- Portainer is pinned to `2.19.4` instead of `latest`, avoiding schema-drift restarts from surprise image updates.
- LND receive-address creation now asks for a native SegWit address and returns clearer wallet/readiness failures when an address is not available.
- Fleet telemetry now carries server name, hostname, and server URL, and the Fleet dashboard shows those names instead of hashed node ids.
- Trusted federation peers are still auto-added transitively, but the local node no longer imports itself back into the fleet list.
- Validation passed locally for the touched frontend helpers, `git diff --check`, and Rust formatting.
## v1.7.84-alpha (2026-06-11)
- Bitcoin trusted-node relay approvals now generate restricted `txrelay` RPC credentials when needed and restart the active Bitcoin backend so bitcoind loads the new `rpcauth` whitelist.
- Kiosk mode now includes a browser safe-area path for HDMI displays that crop edges, and self-update refreshes kiosk launcher/systemd files so display fixes ship to existing nodes. The experimental X11 scaling safe-area is opt-in to avoid stretching TV output.
- Wi-Fi setup now reports scan errors instead of showing an empty network list, supports retrying scans from the modal, parses escaped `nmcli` SSIDs correctly, and can join open networks without forcing a WPA password.
- Bitcoin Core now matches Bitcoin Knots for restricted relay RPC support, including the txrelay secret injection and transaction broadcast whitelist.
- The restricted Bitcoin relay whitelist now includes `submitpackage` and `gettxout`, covering newer wallet/package-relay broadcast flows without opening wallet/admin RPC.
- The Bitcoin UI companion image is pinned to `1.7.84-alpha` across release metadata and the Quadlet fallback path, avoiding stale `latest` detection during OTA updates.
- Container scanning now uses an RAII in-flight guard so timeout and error paths cannot leave the scanner stuck in a permanently busy state.
- Validation passed with `cargo fmt`, `cargo check -p archipelago`, `git diff --check`, and focused source review of the relay message/approval path.
## v1.7.83-alpha (2026-06-11)
- App launch metadata now derives more consistently from app manifests, with typed launch interfaces and catalog generation updates that keep packaged apps aligned with their runtime ports and launch surfaces.
+1 -1
View File
@@ -122,7 +122,7 @@ echo ""
# Install custom app dependencies
echo "Installing custom app dependencies..."
for app in did-wallet endurain morphos-server router web5-dwn; do
for app in did-wallet endurain morphos-server router; do
if [ -d "apps/$app" ]; then
echo " - Installing $app dependencies..."
cd "apps/$app"
+2 -2
View File
@@ -20,8 +20,8 @@
- **Mempool** block explorer and fee estimator
- **Fedimint** federation guardian and gateway
### Self-Hosted Apps (30)
Bitcoin (ThunderHub), Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Self-Hosted Apps (29)
Bitcoin, Storage (FileBrowser, Immich, Nextcloud), Productivity (Penpot, Vaultwarden), Media (Jellyfin, PhotoPrism), Search (SearXNG), AI (Ollama), Network (Tailscale, Nginx Proxy Manager), Home (Home Assistant), Nostr (nostr-rs-relay, Nostrudel), Dev (Grafana, Portainer), and more.
### Decentralized Identity
- Ed25519 node identity with DID Documents (did:key)
+1 -1
View File
@@ -425,7 +425,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:latest",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.19.4",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
+1 -2
View File
@@ -8,7 +8,6 @@
| bitcoin-knots | 8332 (RPC), 8333 (P2P) | v28.1 |
| lnd | 9735 (P2P), 10009 (gRPC), 8080 (REST) | v0.17.4-beta |
| btcpay-server | 23000 (HTTP) | v1.13.5 |
| thunderhub | 3010 (HTTP) | v0.13.31 |
| mempool | 4080 (HTTP) | v2.5.0 |
| electrumx | 50001 (TCP), 50002 (SSL) | latest |
| fedimint | 8173 (API), 8174 (Web) | v0.10.0 |
@@ -43,7 +42,7 @@ cd apps
./build.sh <app-id> # Build specific app
```
Custom apps with local source: `router`, `did-wallet`, `web5-dwn`. All other apps use official container images.
Custom apps with local source: `router`, `did-wallet`. All other apps use official container images.
## App Structure
-2
View File
@@ -24,7 +24,6 @@ This document lists all port assignments for Archipelago apps.
| strfry | 8082 | TCP | HTTP/WebSocket | 18082 |
| did-wallet | 8083 | TCP | Web UI | 18083 |
| router | 8084, 5353, 1900 | TCP/UDP | Web UI, mDNS, SSDP | 18084, 15353, 11900 |
| web5-dwn | 3000 | TCP | HTTP API | 13000 |
| meshtastic | 4403, 1883 | TCP | HTTP API, MQTT | 14403, 11883 |
## Development Ports (Offset: +10000)
@@ -53,7 +52,6 @@ In development mode, all ports are offset by 10000 to avoid conflicts with produ
| Strfry | http://localhost:18082 |
| DID Wallet | http://localhost:18083 |
| Router | http://localhost:18084 |
| Web5 DWN | http://localhost:13000 |
| Meshtastic | http://localhost:14403 |
## Port Conflict Resolution
+2 -4
View File
@@ -30,14 +30,13 @@ cd apps
./build.sh
```
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet, web5-dwn) will be built from source.
This will build all apps that have Dockerfiles. Standard apps (bitcoin-core, lnd, etc.) will use their official images, while custom apps (router, did-wallet) will be built from source.
### Build Specific App
```bash
./build.sh router
./build.sh did-wallet
./build.sh web5-dwn
```
## Running Apps via Archipelago
@@ -64,7 +63,6 @@ In development mode, apps are accessible on offset ports:
- **Router**: http://localhost:18084
- **DID Wallet**: http://localhost:18083
- **Web5 DWN**: http://localhost:13000
- **Nostr RS Relay**: http://localhost:18081
- **Strfry**: http://localhost:18082
@@ -72,7 +70,7 @@ See [PORTS.md](./PORTS.md) for complete port mapping.
## Development Workflow
### For Custom Apps (router, did-wallet, web5-dwn)
### For Custom Apps (router, did-wallet)
1. **Make changes** to source code in `apps/<app-id>/src/`
2. **Rebuild** the container:
-2
View File
@@ -8,7 +8,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **bitcoin-knots** — Full Bitcoin node (v28.1)
- **lnd** — Lightning Network Daemon (v0.17.4-beta)
- **btcpay-server** — Payment processor (v1.13.5)
- **thunderhub** — Lightning management UI (v0.13.31)
- **mempool** — Block explorer and fee estimator (v2.5.0)
- **electrumx** — Electrum server
- **fedimint** — Federated Bitcoin minting (v0.10.0)
@@ -18,7 +17,6 @@ Containerized applications for the Archipelago Bitcoin Node OS. All apps run in
- **nostrudel** — Nostr web client (v0.40.0)
### Web5 & Identity
- **web5-dwn** — Decentralized Web Node (v0.4.0)
- **did-wallet** — Web5 DID Wallet
### Self-Hosted Services
+14 -3
View File
@@ -26,10 +26,19 @@ app:
echo "bitcoind not found in image" >&2;
exit 127;
fi;
if [ "${DISK_GB:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
RPC_USER="$(printenv BITCOIN_RPC_USER)";
RPC_PASS="$(printenv BITCOIN_RPC_PASS)";
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)";
DISK_GB_VALUE="$(printenv DISK_GB || true)";
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
else
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 -rpcuser="${BITCOIN_RPC_USER}" -rpcpassword="${BITCOIN_RPC_PASS}";
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
fi
derived_env:
- key: DISK_GB
@@ -37,6 +46,8 @@ app:
secret_env:
- key: BITCOIN_RPC_PASS
secret_file: bitcoin-rpc-password
- key: BITCOIN_RPC_TXRELAY_RPCAUTH
secret_file: bitcoin-rpc-txrelay-rpcauth
data_uid: "100101:100101"
dependencies:
+1 -1
View File
@@ -33,7 +33,7 @@ app:
RPC_HEADROOM="-rpcthreads=16 -rpcworkqueue=256";
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0";
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee";
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips";
fi;
if [ "${DISK_GB_VALUE:-0}" -lt 1000 ]; then
exec "$BITCOIND" -datadir=/home/bitcoin/.bitcoin -noconf -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS -rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS";
-3
View File
@@ -10,8 +10,6 @@ app:
pull_policy: if-not-present
dependencies:
- app_id: web5-dwn
version: ">=1.0.0"
- storage: 2Gi
resources:
@@ -40,7 +38,6 @@ app:
options: [rw]
environment:
- DWN_ENDPOINT=http://web5-dwn:3000
- WALLET_STORAGE=/app/wallet
health_check:
-1
View File
@@ -34,5 +34,4 @@ app.post('/api/wallet/did/create', async (req, res) => {
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`DID Wallet listening on port ${port}`);
console.log(`DWN endpoint: ${process.env.DWN_ENDPOINT || 'http://web5-dwn:3000'}`);
});
+17 -2
View File
@@ -25,7 +25,7 @@ app:
resources:
cpu_limit: 0
memory_limit: 4Gi
memory_limit: 6Gi
disk_limit: 50Gi
security:
@@ -48,15 +48,30 @@ app:
- COIN=Bitcoin
- DB_DIRECTORY=/data
- SERVICES=tcp://:50001,rpc://0.0.0.0:8000
- CACHE_MB=3072
- CACHE_MB=1024
- MAX_SEND=10000000
# The ElectrumX dashboard tile is served by the host-networked companion UI
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
# so the tile resolves a launch URL without relying on the hand-maintained
# override in appSessionConfig.ts (which the generator can clobber). The
# backend only validates this block — it does not proxy/health-check it.
interfaces:
main:
name: Web UI
description: ElectrumX server status and connection details
type: ui
port: 50002
protocol: http
health_check:
type: tcp
endpoint: localhost:50001
interval: 30s
timeout: 5s
retries: 3
start_period: 10m
bitcoin_integration:
rpc_access: read-only
+42
View File
@@ -0,0 +1,42 @@
app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
restart, and stable-channel daemon updates) go through the existing
fips.* RPC methods, authenticated by the browser's own archipelago
session — there is no separate secret to manage.
container:
build:
context: /opt/archipelago/docker/fips-ui
dockerfile: Dockerfile
tag: localhost/fips-ui:local
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
ports: []
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8336
path: /
interval: 30s
timeout: 5s
retries: 3
+6 -1
View File
@@ -8,6 +8,12 @@ app:
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
# Core — resolved at apply time from host facts (B12). Hardcoding either
# breaks mempool's RPC connection on the other.
derived_env:
- key: CORE_RPC_HOST
template: "{{BITCOIN_HOST}}"
secret_env:
- key: CORE_RPC_PASSWORD
secret_file: bitcoin-rpc-password
@@ -47,7 +53,6 @@ app:
- ELECTRUM_HOST=electrumx
- ELECTRUM_PORT=50001
- ELECTRUM_TLS_ENABLED=false
- CORE_RPC_HOST=bitcoin-knots
- CORE_RPC_PORT=8332
- CORE_RPC_USERNAME=archipelago
- DATABASE_ENABLED=true
+1 -1
View File
@@ -6,7 +6,7 @@ app:
category: development
container:
image: 146.59.87.168:3000/lfg2025/portainer:latest
image: 146.59.87.168:3000/lfg2025/portainer:2.19.4
pull_policy: if-not-present
data_uid: "1000:1000"
-6
View File
@@ -1,6 +0,0 @@
node_modules
dist
*.log
.git
.gitignore
README.md
-38
View File
@@ -1,38 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
# Copy built application
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Create non-root user
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser && \
mkdir -p /app/data && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 3000
ENV DWN_STORAGE_PATH=/app/data
ENV DID_METHOD=key
CMD ["node", "dist/index.js"]
-35
View File
@@ -1,35 +0,0 @@
# Web5 DWN (Decentralized Web Node)
Personal data store for Web5. Store and sync your decentralized data across devices.
## Building
```bash
# From the apps directory
./build.sh web5-dwn
# Or manually
cd web5-dwn
docker build -t archipelago/web5-dwn:latest .
```
## Development
```bash
cd web5-dwn
npm install
npm run dev
```
## Ports
- **3000**: HTTP API (dev: 13000)
## Running Locally
```bash
docker run -p 3000:3000 \
-v /tmp/archipelago-dev/web5-dwn:/app/data \
-e DWN_STORAGE_PATH=/app/data \
archipelago/web5-dwn:latest
```
-55
View File
@@ -1,55 +0,0 @@
app:
id: web5-dwn
name: Decentralized Web Node
version: 1.0.0
description: Personal data store for Web5. Store and sync your decentralized data across devices.
container:
image: archipelago/web5-dwn:1.0.0
image_signature: cosign://...
pull_policy: if-not-present
dependencies:
- storage: 5Gi
resources:
cpu_limit: 1
memory_limit: 512Mi
disk_limit: 5Gi
security:
capabilities: []
readonly_root: true
no_new_privileges: true
user: 1000
seccomp_profile: default
network_policy: isolated
apparmor_profile: web5-dwn
ports:
- host: 3000
container: 3000
protocol: tcp # HTTP API
volumes:
- type: bind
source: /var/lib/archipelago/web5-dwn
target: /app/data
options: [rw]
environment:
- DWN_STORAGE_PATH=/app/data
- DID_METHOD=key
health_check:
type: http
endpoint: http://localhost:3000
path: /health
interval: 30s
timeout: 5s
retries: 3
web5_integration:
did_support: true
dwn_protocol: true
sync_enabled: true
-2747
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
{
"name": "web5-dwn",
"version": "1.0.0",
"description": "Decentralized Web Node for Web5",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts"
},
"dependencies": {
"express": "^4.18.2",
"@web5/api": "^0.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"typescript": "^5.3.3",
"ts-node": "^10.9.2"
}
}
-34
View File
@@ -1,34 +0,0 @@
import express from 'express';
const app = express();
const port = 3000;
// Middleware
app.use(express.json());
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'web5-dwn' });
});
// DWN API endpoints
app.post('/dwn', async (req, res) => {
// Placeholder for DWN protocol implementation
res.json({
status: 'ok',
message: 'DWN protocol endpoint (placeholder)'
});
});
app.get('/dwn', async (req, res) => {
res.json({
status: 'ok',
message: 'DWN query endpoint (placeholder)'
});
});
// Start server
app.listen(port, '0.0.0.0', () => {
console.log(`Web5 DWN listening on port ${port}`);
console.log(`Storage path: ${process.env.DWN_STORAGE_PATH || '/app/data'}`);
});
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
+1 -1
View File
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.83-alpha"
version = "1.7.97-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.83-alpha"
version = "1.7.98-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+82 -7
View File
@@ -202,6 +202,27 @@ impl ApiHandler {
.unwrap()
}
/// A 401 that still carries CORS headers, for endpoints fetched
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
/// port). Without the ACAO header the browser surfaces an opaque CORS
/// error instead of the 401, so the app can't tell it just needs auth.
/// `origin` is the already-validated reflect value from `app_cors_origin`
/// (empty string when the origin isn't allowed → no CORS header added).
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
let mut builder = Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.header("Vary", "Origin");
if !origin.is_empty() {
builder = builder
.header("Access-Control-Allow-Origin", origin)
.header("Access-Control-Allow-Credentials", "true");
}
builder.body(hyper::Body::from(body_bytes)).unwrap()
}
/// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> {
let mut origins = vec![
@@ -256,6 +277,45 @@ impl ApiHandler {
}
}
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
/// host but use a different port, so the strict allowlist (`host_ip`, no
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
/// header ("blocked by CORS policy"). Reflect the Origin when its host
/// matches the request's own `Host` header — i.e. the app lives on the same
/// address the node is being reached by, which transparently covers the LAN
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
/// to enumerate them. Auth is still enforced by the session cookie; this
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
/// origin) when there is no match.
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
if let Some(origin) = self.validate_origin(headers) {
return origin;
}
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
return String::new();
};
// host portion (no scheme, no port) of an `scheme://host[:port]` value
let host_of = |s: &str| -> Option<String> {
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
let host = host_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_port);
(!host.is_empty()).then(|| host.to_string())
};
let origin_host = host_of(origin);
let req_host = headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.and_then(host_of);
match (origin_host, req_host) {
(Some(o), Some(r)) if o == r => origin.to_string(),
_ => String::new(),
}
}
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
@@ -265,9 +325,10 @@ impl ApiHandler {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Vary", "Origin");
if let Some(origin) = self.validate_origin(req.headers()) {
let preflight_origin = self.app_cors_origin(req.headers());
if !preflight_origin.is_empty() {
builder = builder
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Origin", &preflight_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true");
@@ -448,7 +509,8 @@ impl ApiHandler {
// No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
let origin = self.app_cors_origin(&headers);
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
}
// Container logs — requires session
@@ -460,13 +522,26 @@ impl ApiHandler {
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
}
// LND proxy — requires session
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
// Peer content streaming proxy — Range-streams a peer's media file
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_lnd_proxy(path, &origin).await
self.handle_peer_content_stream(p, &headers).await
}
// LND proxy — requires session. The LND wallet UI calls this
// cross-origin from its own app port, so even the 401 must carry
// CORS headers; otherwise the browser reports a bare CORS failure
// ("No 'Access-Control-Allow-Origin' header") instead of a
// readable 401 the UI can act on.
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
let origin = self.app_cors_origin(&headers);
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized_cors(&origin));
}
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
}
// DWN health — unauthenticated
+113 -13
View File
@@ -99,33 +99,61 @@ impl ApiHandler {
pub(super) async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
// The LND wallet UI is served on its own APP_PORTS origin and fetches
// this cross-origin, so it needs the CORS headers echoed back.
let cors = |builder: hyper::http::response::Builder| {
builder
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
};
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
Ok(cors(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
Err(e) => Ok(cors(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
pub(super) async fn handle_lnd_proxy(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("{LND_REST_BASE_URL}{suffix}");
match reqwest::get(&url).await {
// LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
// sends no macaroon. Use the shared authenticated client instead — the
// same one lnd.getinfo and the wallet RPCs use.
let request = match rpc.lnd_client().await {
Ok((client, macaroon_hex)) => client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.map_err(anyhow::Error::from),
Err(e) => Err(e),
};
match request {
Ok(resp) => {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
@@ -157,4 +185,76 @@ impl ApiHandler {
}
}
}
/// Range-streaming proxy for a peer's content file (B3). The browser's
/// `<video>`/`<audio>` element makes Range requests; we forward the Range
/// header to the peer's `/content/<id>` (which already returns 206 Partial
/// Content) and pass the bytes + Content-Range/Content-Type straight back.
/// This replaces the old path of downloading the whole file as base64 into
/// a non-seekable Blob URL, which broke playback/seeking for video and
/// large audio. Same-origin + session-authenticated (checked by caller).
/// Path: `/api/peer-content/<onion>/<content_id>`.
pub(super) async fn handle_peer_content_stream(
&self,
path: &str,
headers: &hyper::HeaderMap,
) -> Result<Response<hyper::Body>> {
let bad = |msg: &str| {
Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(serde_json::json!({ "error": msg }).to_string()),
))
};
let rest = path.strip_prefix("/api/peer-content/").unwrap_or("");
let (onion, content_id) = match rest.split_once('/') {
Some((o, c)) if !o.is_empty() && !c.is_empty() => (o, c),
_ => return bad("expected /api/peer-content/<onion>/<content_id>"),
};
// Validate to prevent SSRF / path traversal.
let onion_norm = onion.trim_end_matches(".onion");
let onion_ok = onion_norm.len() == 56
&& onion_norm
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
let id_ok = !content_id.contains("..")
&& content_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
if !onion_ok || !id_ok {
return bad("invalid onion or content id");
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let peer_path = format!("/content/{}", content_id);
let mut req = crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &peer_path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(60));
if let Some(r) = headers.get("range").and_then(|v| v.to_str().ok()) {
req = req.header("Range", r.to_string());
}
match req.send_get().await {
Ok((resp, _transport)) => {
let status = resp.status().as_u16();
let rh = resp.headers().clone();
let bytes = resp.bytes().await.unwrap_or_default();
let mut builder = Response::builder()
.status(status)
.header("Accept-Ranges", "bytes");
for h in ["content-type", "content-range", "content-length"] {
if let Some(v) = rh.get(h).and_then(|v| v.to_str().ok()) {
builder = builder.header(h, v);
}
}
Ok(builder
.body(hyper::Body::from(bytes))
.unwrap_or_else(|_| Response::new(hyper::Body::empty())))
}
Err(e) => Ok(build_response(
StatusCode::BAD_GATEWAY,
"application/json",
hyper::Body::from(serde_json::json!({ "error": e.to_string() }).to_string()),
)),
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
mod handler;
mod rpc;
pub(crate) mod rpc;
pub use handler::ApiHandler;
+24
View File
@@ -227,6 +227,9 @@ impl RpcHandler {
let report = serde_json::json!({
"node_id": node_id,
"node_name": data.server_info.name.clone().filter(|n| !n.trim().is_empty()),
"hostname": system_hostname().await,
"server_url": local_server_url(&self.config.host_ip),
"version": data.server_info.version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -507,3 +510,24 @@ impl RpcHandler {
}))
}
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
fn local_server_url(host_ip: &str) -> Option<String> {
let host_ip = host_ip.trim();
if host_ip.is_empty() || host_ip == "127.0.0.1" {
None
} else {
Some(format!("https://{host_ip}"))
}
}
+13
View File
@@ -33,6 +33,19 @@ impl RpcHandler {
tracing::info!("[onboarding] login successful");
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
// password by rotating it onto the per-node secret, using the password
// the user just authenticated with as a candidate. Non-blocking so login
// is never slowed or broken when LND isn't installed / already unlocked.
let candidate = password.to_string();
tokio::spawn(async move {
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
Ok(false) => {} // not locked, or seed-recovery required
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
}
});
// Ensure NostrVPN config exists — covers the case where onboardingComplete
// was never called (e.g., user took the "already set up" shortcut).
let data_dir = self.config.data_dir.clone();
+76 -7
View File
@@ -3,6 +3,7 @@ use crate::container::docker_packages;
use crate::data_model::{Notification, NotificationLevel};
use crate::{bitcoin_status, identity, peers};
use anyhow::{Context, Result};
use archipelago_container::ContainerState;
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use hmac::{Hmac, Mac};
use rand::RngCore;
@@ -106,7 +107,7 @@ struct TrustedRelayPeer {
}
#[derive(Debug, Clone)]
struct TxRelayCredentials {
pub(crate) struct TxRelayCredentials {
username: String,
password: String,
}
@@ -164,7 +165,11 @@ impl RpcHandler {
update_endpoint(&params, "tor_endpoint", &mut state.settings.tor_endpoint)?;
if state.settings.enabled_for_peers {
let credentials_were_ready = txrelay_credentials_available(&self.config.data_dir).await;
ensure_txrelay_credentials(&self.config.data_dir).await?;
if !credentials_were_ready {
self.restart_bitcoin_backends_for_txrelay().await;
}
}
if params.get("selected_peer_pubkey").is_some() {
@@ -354,7 +359,11 @@ impl RpcHandler {
);
}
let credentials = if status == RelayRequestStatus::Approved {
Some(ensure_txrelay_credentials(&self.config.data_dir).await?)
let credentials = ensure_txrelay_credentials(&self.config.data_dir).await?;
if request_direction == RelayRequestDirection::Incoming {
self.restart_bitcoin_backends_for_txrelay().await;
}
Some(credentials)
} else {
None
};
@@ -476,6 +485,34 @@ impl RpcHandler {
}
self.state_manager.update_data(data).await;
}
async fn restart_bitcoin_backends_for_txrelay(&self) {
let Some(orchestrator) = self.orchestrator.as_ref().cloned() else {
tracing::debug!("Skipping txrelay backend restart; orchestrator unavailable");
return;
};
tokio::spawn(async move {
for app_id in ["bitcoin-knots", "bitcoin-core"] {
let Ok(status) = orchestrator.status(app_id).await else {
continue;
};
if status.state != ContainerState::Running {
continue;
}
match orchestrator.restart(app_id).await {
Ok(()) => tracing::info!(
app_id,
"Restarted Bitcoin backend to load txrelay RPC credentials"
),
Err(e) => tracing::warn!(
app_id,
error = %e,
"Failed to restart Bitcoin backend after txrelay credential update"
),
}
}
});
}
}
pub(crate) async fn record_incoming_relay_message(
@@ -588,22 +625,36 @@ fn trusted_relay_peers(
}
async fn txrelay_credential_status(data_dir: &Path) -> serde_json::Value {
let credentials_available = txrelay_credentials_available(data_dir).await;
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password_available = fs::metadata(&password_path).await.is_ok();
let rpcauth_available = fs::metadata(&rpcauth_path).await.is_ok();
let client_env_available = fs::metadata(&client_env_path).await.is_ok();
json!({
"username": TXRELAY_USER,
"available": password_available && rpcauth_available && client_env_available,
"available": credentials_available,
"password_available": password_available,
"rpcauth_available": rpcauth_available,
"client_env_available": client_env_available,
"client_env_path": client_env_path.display().to_string(),
"restart_hint": "If this was just generated, restart Bitcoin Core/Knots so bitcoind loads the txrelay rpcauth whitelist.",
"restart_hint": "Archipelago restarts the active Bitcoin backend after generating txrelay credentials so bitcoind loads the restricted rpcauth whitelist.",
})
}
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
async fn txrelay_credentials_available(data_dir: &Path) -> bool {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
fs::metadata(&password_path).await.is_ok()
&& fs::metadata(&rpcauth_path).await.is_ok()
&& fs::metadata(&client_env_path).await.is_ok()
}
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
/// the random password, its derived `rpcauth` line, and the client env file.
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
/// required `secret_env`, so this must run before bitcoind starts — otherwise
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password = match read_trimmed(&password_path).await {
Some(value) => value,
@@ -614,8 +665,8 @@ async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredential
}
};
let rpcauth = match read_trimmed(&rpcauth_path).await {
Some(value) => value,
None => {
Some(value) if rpcauth_matches_password(&value, TXRELAY_USER, &password) => value,
_ => {
let generated = generate_rpcauth(TXRELAY_USER, &password);
write_secret_file(&rpcauth_path, &generated).await?;
generated
@@ -684,6 +735,24 @@ fn generate_rpcauth(username: &str, password: &str) -> String {
format!("{username}:{salt_hex}${hash_hex}")
}
fn rpcauth_matches_password(rpcauth: &str, username: &str, password: &str) -> bool {
let Some(rest) = rpcauth.strip_prefix(&format!("{username}:")) else {
return false;
};
let Some((salt_hex, expected_hash)) = rest.split_once('$') else {
return false;
};
if salt_hex.is_empty() || expected_hash.is_empty() {
return false;
}
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(salt_hex.as_bytes()) else {
return false;
};
mac.update(password.as_bytes());
let hash_hex = hex::encode(mac.finalize().into_bytes());
hash_hex.eq_ignore_ascii_case(expected_hash)
}
fn preferred_endpoint(settings: &BitcoinRelaySettings) -> Option<String> {
if settings.allow_https {
if let Some(endpoint) = settings.https_endpoint.clone() {
@@ -731,7 +731,6 @@ fn health_probe_url_for_app(app_id: &str) -> Option<String> {
"bitcoin-ui" => 8334,
"botfights" => 9100,
"btcpay-server" | "btcpay" | "btcpayserver" => 23000,
"dwn" => 3100,
"electrumx" | "electrs" | "mempool-electrs" | "electrs-ui" => 50002,
"fedimint" | "fedimintd" => 8175,
"filebrowser" => 8083,
+47 -5
View File
@@ -234,7 +234,7 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
@@ -242,6 +242,15 @@ impl RpcHandler {
.send_get()
.await
.context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14) so the UI
// reflects FIPS vs Tor truthfully instead of always showing Tor/none.
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
let body: serde_json::Value = response.json().await.unwrap_or_default();
@@ -294,13 +303,21 @@ impl RpcHandler {
fips_npub.is_some()
);
let (response, _transport) =
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14).
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
@@ -309,11 +326,20 @@ impl RpcHandler {
));
}
let body: serde_json::Value = response
let mut body: serde_json::Value = response
.json()
.await
.context("Failed to parse peer catalog")?;
// Surface the transport that actually reached the peer so the cloud
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
if let Some(obj) = body.as_object_mut() {
obj.insert(
"transport".to_string(),
serde_json::Value::String(transport.to_string()),
);
}
Ok(body)
}
@@ -353,7 +379,7 @@ impl RpcHandler {
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, _transport) =
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
@@ -362,6 +388,14 @@ impl RpcHandler {
.send_get()
.await
.context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14).
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
// Payment was rejected — token is spent but content not received
@@ -418,13 +452,21 @@ impl RpcHandler {
fips_npub.is_some()
);
let (response, _transport) =
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer for preview")?;
// Record which transport actually reached the peer (B14).
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
@@ -55,6 +55,7 @@ impl RpcHandler {
"package.restart" => self.handle_package_restart(params).await,
"package.uninstall" => self.clone().spawn_package_uninstall(params).await,
"package.update" => self.clone().spawn_package_update(params).await,
"package.check-updates" => self.handle_package_check_updates(params).await,
"package.credentials" => self.handle_package_credentials(params).await,
"app.filebrowser-token" => self.handle_filebrowser_token().await,
@@ -533,6 +533,19 @@ impl RpcHandler {
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
}
// Respect operator removal: a peer the operator deleted must not
// silently re-join via a stale invite. The tombstone is only cleared
// by an explicit local action (manually adding the node or accepting
// an incoming invite) — not by a remote-triggered join.
if federation::load_removed_dids(&self.config.data_dir)
.await
.unwrap_or_default()
.contains(did)
{
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
}
let node = FederatedNode {
did: did.to_string(),
pubkey: pubkey.to_string(),
+8 -6
View File
@@ -115,10 +115,12 @@ impl RpcHandler {
} else if !after.key_present {
"no_seed_key"
} else if after.authenticated_peer_count == 0 {
// Daemon is up with a key but hasn't authenticated any
// peers — almost always outbound UDP/8668 dropped by the
// local firewall/router, or the anchor itself being down.
"no_outbound_udp_or_anchor_down"
// Daemon is up with a key but hasn't authenticated any peers —
// almost always the outbound connection to the anchor being
// dropped by the local firewall/router, or the anchor itself
// being down. The public anchor is reached over TCP/8443 (not
// UDP/8668 — that endpoint is dead).
"no_outbound_or_anchor_down"
} else {
"peers_but_no_anchor"
};
@@ -126,8 +128,8 @@ impl RpcHandler {
"connected" => "An anchor is reachable.",
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
"no_outbound_udp_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
"no_outbound_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
"peers_but_no_anchor" =>
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
_ => "",
+45 -22
View File
@@ -31,7 +31,7 @@ impl RpcHandler {
let password = params
.get("password")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing required parameter: password"))?;
.unwrap_or("");
// Validate SSID (prevent command injection)
if ssid.len() > 64 || ssid.contains('\0') {
@@ -284,7 +284,7 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
let networks: Vec<serde_json::Value> = stdout
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(3, ':').collect();
let parts = split_nmcli_escaped(line, 3);
if parts.len() < 3 {
return None;
}
@@ -305,6 +305,28 @@ async fn scan_wifi() -> Result<Vec<serde_json::Value>> {
Ok(networks)
}
fn split_nmcli_escaped(line: &str, limit: usize) -> Vec<String> {
let mut fields = Vec::new();
let mut current = String::new();
let mut chars = line.chars();
while let Some(ch) = chars.next() {
if ch == '\\' {
if let Some(next) = chars.next() {
current.push(next);
}
} else if ch == ':' && fields.len() + 1 < limit {
fields.push(current);
current = String::new();
} else {
current.push(ch);
}
}
fields.push(current);
fields
}
/// Connect to a WiFi network using nmcli.
async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
let conn_name = format!("archipelago-wifi-{ssid}");
@@ -321,27 +343,28 @@ async fn connect_wifi(ssid: &str, password: &str) -> Result<()> {
.output()
.await;
let mut args = vec![
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
];
if !password.is_empty() {
args.extend(["wifi-sec.key-mgmt", "wpa-psk", "wifi-sec.psk", password]);
}
let output = tokio::process::Command::new("nmcli")
.args([
"connection",
"add",
"type",
"wifi",
"con-name",
&conn_name,
"ifname",
"*",
"ssid",
ssid,
"wifi-sec.key-mgmt",
"wpa-psk",
"wifi-sec.psk",
password,
"ipv4.method",
"auto",
"ipv6.method",
"auto",
])
.args(args)
.output()
.await
.context("Failed to run nmcli wifi profile create")?;
+2
View File
@@ -38,6 +38,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(10))
.danger_accept_invalid_certs(true)
.build()
@@ -180,6 +181,7 @@ impl RpcHandler {
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(10))
.build()
+1
View File
@@ -63,6 +63,7 @@ impl RpcHandler {
let macaroon_bytes = read_lnd_admin_macaroon().await?;
let macaroon_hex = hex::encode(&macaroon_bytes);
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(15))
.danger_accept_invalid_certs(true)
.build()
+206 -10
View File
@@ -9,29 +9,77 @@ use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
)
})?;
let resp = client
let resp = match client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
// LND's REST gateway parses `type` as the AddressType enum by its
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
// valid enum value and returns 400 "parsing field type"; the native
// SegWit (bech32) variant is WITNESS_PUBKEY_HASH (= 0).
.query(&[("type", "WITNESS_PUBKEY_HASH")])
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
{
Ok(resp) => resp,
Err(e) => {
// The .116 case: LND container is up but its REST endpoint isn't
// reachable on the expected port (e.g. published-port drift), so
// the connection is refused. This is NOT a locked wallet — emit a
// distinct code so the UI stops mislabelling it.
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
return Err(receive_error(
RECEIVE_REST_UNREACHABLE,
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
));
}
};
let body: serde_json::Value = resp
.json()
let status = resp.status();
let raw_body = resp
.text()
.await
.context("Failed to parse newaddress response")?;
.context("Bitcoin address response could not be read")?;
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
serde_json::json!({
"raw": raw_body,
})
});
if let Some(error) = body.get("error").and_then(|v| v.as_str()) {
anyhow::bail!("LND could not generate an address: {}", error);
if !status.is_success() {
let message = lnd_error_message(&body);
let code = classify_lnd_address_error(&message);
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
return Err(receive_error(code, default_receive_detail(code)));
}
if let Some(error) = body
.get("error")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
let code = classify_lnd_address_error(error);
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
return Err(receive_error(code, default_receive_detail(code)));
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.ok_or_else(|| {
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
)
})?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -504,12 +552,20 @@ impl RpcHandler {
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
entropy.zeroize();
// Use the per-node secret as the LND wallet password (NOT the
// caller-supplied one) so the unattended boot path can auto-unlock this
// wallet. The wallet stays recoverable from the Archipelago seed via the
// derived entropy above. This unifies both init paths on one password
// source — the divergence here is what left wallets locked fleet-wide.
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
let wallet_password_b64 =
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
// Call LND REST API to initialize wallet with derived entropy.
// LND must be running but NOT yet initialized (no existing wallet).
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(30))
.danger_accept_invalid_certs(true)
.build()
@@ -548,3 +604,143 @@ impl RpcHandler {
}))
}
}
fn lnd_error_message(body: &serde_json::Value) -> String {
body.get("message")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.unwrap_or("unknown LND error")
.to_string()
}
// Stable, machine-readable reason codes for receive-address failures. They are
// embedded in the error message as a `[CODE]` token so the frontend
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
// instead of guessing wallet state by substring-matching — which is what made
// .228 report "wallet is locked" when LND's REST was merely unreachable.
//
// Every receive error string starts with "Bitcoin address" so it survives the
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
// flattened to the generic "Operation failed" message (the .116 symptom).
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
/// Build a receive-address error carrying a reason code the UI can map.
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
}
/// A sensible default human message per code (used for non-UI callers and logs;
/// the frontend renders its own copy from the code).
fn default_receive_detail(code: &str) -> &'static str {
match code {
RECEIVE_REST_UNREACHABLE => {
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
}
RECEIVE_WALLET_LOCKED => {
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
}
RECEIVE_WALLET_UNINITIALIZED => {
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
}
RECEIVE_SYNCING => {
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
}
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
}
}
/// Classify a non-2xx LND error body/message into a reason code. The wording of
/// LND's REST errors is stable enough to bucket: a locked wallet, an
/// uninitialized wallet, a syncing chain, or some other failure.
fn classify_lnd_address_error(message: &str) -> &'static str {
let m = message.to_lowercase();
if m.contains("locked") || m.contains("unlock") {
RECEIVE_WALLET_LOCKED
} else if m.contains("synchroniz")
|| m.contains("syncing")
|| m.contains("not yet ready")
|| m.contains("in the process of starting")
{
RECEIVE_SYNCING
} else if m.contains("wallet not found")
|| m.contains("not exist")
|| m.contains("uninitialized")
|| m.contains("not initialized")
|| m.contains("create a wallet")
|| m.contains("no wallet")
{
RECEIVE_WALLET_UNINITIALIZED
} else {
RECEIVE_LND_ERROR
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lnd_error_message_prefers_message_field() {
let body = serde_json::json!({
"error": "grpc proxy error",
"message": "wallet locked",
});
assert_eq!(lnd_error_message(&body), "wallet locked");
}
#[test]
fn lnd_error_message_falls_back_to_unknown() {
assert_eq!(
lnd_error_message(&serde_json::json!({})),
"unknown LND error"
);
}
#[test]
fn classify_locked_wallet() {
assert_eq!(
classify_lnd_address_error("wallet locked, please unlock"),
RECEIVE_WALLET_LOCKED
);
}
#[test]
fn classify_uninitialized_wallet() {
assert_eq!(
classify_lnd_address_error("wallet not found, create a wallet first"),
RECEIVE_WALLET_UNINITIALIZED
);
}
#[test]
fn classify_syncing() {
assert_eq!(
classify_lnd_address_error("server is still in the process of starting"),
RECEIVE_SYNCING
);
}
#[test]
fn classify_unknown_is_generic_error() {
assert_eq!(
classify_lnd_address_error("some other failure"),
RECEIVE_LND_ERROR
);
}
#[test]
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
// and carry the [CODE] token the frontend parses.
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
let s = format!("{err}");
assert!(s.starts_with("Bitcoin address"), "got: {s}");
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
}
}
@@ -1184,6 +1184,12 @@ impl RpcHandler {
entry.pinned = p;
}
let saved = entry.clone();
let snapshot = contacts.clone();
drop(contacts);
// Persist (encrypted, atomic) so the customisation survives restarts.
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
tracing::warn!("failed to persist mesh contacts: {e}");
}
Ok(serde_json::json!({
"saved": true,
"pubkey": pubkey,
@@ -1215,6 +1221,11 @@ impl RpcHandler {
let mut contacts = state.contacts.write().await;
let entry = contacts.entry(pubkey.clone()).or_default();
entry.blocked = blocked;
let snapshot = contacts.clone();
drop(contacts);
if let Err(e) = crate::mesh::save_mesh_contacts(&self.config.data_dir, &snapshot).await {
tracing::warn!("failed to persist mesh contacts: {e}");
}
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
}
@@ -63,6 +63,7 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"Failed to start",
"Container",
"Image",
"Bitcoin address",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
+36 -48
View File
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint-gateway"
| "indeedhub"
| "immich"
| "fips"
| "fips-ui"
)
}
@@ -312,11 +314,6 @@ pub(super) fn get_health_check_args(app_id: &str, _rpc_pass: &str) -> Vec<String
"30s",
"3",
),
"dwn" => (
"curl -sf http://localhost:3000/health || exit 1",
"30s",
"3",
),
"portainer" => return vec![],
"ollama" => ("curl -sf http://localhost:11434/ || exit 1", "30s", "3"),
"fedimint" => ("curl -sf http://localhost:8175/ || exit 1", "60s", "3"),
@@ -360,10 +357,10 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX: large cache materially speeds initial history indexing.
// CACHE_MB=3072 below needs container headroom for Python, rocksdb,
// socket buffers, and reorg/indexing spikes.
"electrumx" | "mempool-electrs" | "electrs" => "4g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
// conservative and give the process headroom to avoid restart loops.
"electrumx" | "mempool-electrs" | "electrs" => "6g",
"cryptpad" => "512m",
"ollama" => "4g",
// Medium apps
@@ -384,7 +381,6 @@ pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
"uptime-kuma" => "256m",
"filebrowser" => "256m",
"searxng" => "512m",
"dwn" => "256m",
"portainer" => "256m",
"nostr-rs-relay" | "nostr-relay" => "256m",
"routstr" => "512m",
@@ -756,27 +752,33 @@ pub(super) async fn get_app_config(
None,
None,
),
"mempool-api" => (
vec!["8999:8999".to_string()],
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
"CORE_RPC_HOST=bitcoin-knots".to_string(),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
"DATABASE_USERNAME=mempool".to_string(),
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
],
None,
None,
),
"mempool-api" => {
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
(
vec!["8999:8999".to_string()],
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
"DATABASE_USERNAME=mempool".to_string(),
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
],
None,
None,
)
}
"electrumx" | "mempool-electrs" | "electrs" => {
(
vec!["50001:50001".to_string()],
@@ -789,11 +791,9 @@ pub(super) async fn get_app_config(
"COIN=Bitcoin".to_string(),
"DB_DIRECTORY=/data".to_string(),
"SERVICES=tcp://:50001,rpc://0.0.0.0:8000".to_string(),
// Sync-speed: bigger LRU/write cache during initial
// history index. Default is 1200MB; the container gets
// 4g (config.rs::get_memory_limit) so 3072 fits with
// headroom.
"CACHE_MB=3072".to_string(),
// Keep cache below the container limit; high values
// have caused OOM/restart loops during catch-up.
"CACHE_MB=1024".to_string(),
// Block-fetcher concurrency — defaults are conservative
// for shared hosts; 4 is plenty for one bitcoind backend.
"MAX_SEND=10000000".to_string(),
@@ -1129,18 +1129,6 @@ pub(super) async fn get_app_config(
None,
)
}
"dwn" => (
vec!["3100:3000".to_string()],
vec!["/var/lib/archipelago/dwn:/dwn/data".to_string()],
vec![
"DS_PORT=3000".to_string(),
"DS_MESSAGES_STORE_URI=level://data/messages".to_string(),
"DS_DATA_STORE_URI=level://data/data".to_string(),
"DS_EVENT_LOG_URI=level://data/events".to_string(),
],
None,
None,
),
"botfights" => {
let jwt_secret = read_or_generate_secret("botfights-jwt").await;
(
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
})
}
/// Detect the container name of the running Bitcoin node so dependent stacks
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
/// Core are both reachable on archy-net by their container name — only the name
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
pub(super) async fn detect_bitcoin_rpc_host() -> String {
let out = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output(),
)
.await;
if let Ok(Ok(o)) = out {
if o.status.success() {
let running = String::from_utf8_lossy(&o.stdout);
if let Some(name) = pick_bitcoin_host(&running) {
return name;
}
}
}
"bitcoin-knots".to_string()
}
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
podman_names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
}
#[cfg(test)]
mod bitcoin_host_tests {
use super::pick_bitcoin_host;
#[test]
fn picks_knots() {
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
}
#[test]
fn picks_core() {
let ps = "lnd\nbitcoin-core\nelectrumx\n";
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
}
#[test]
fn picks_plain_bitcoin() {
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
}
#[test]
fn none_when_no_bitcoin_node() {
let ps = "electrumx\nlnd\narchy-mempool-db\n";
assert_eq!(pick_bitcoin_host(ps), None);
}
#[test]
fn ignores_substring_matches() {
// A companion UI container must NOT be mistaken for the node itself.
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
assert_eq!(pick_bitcoin_host(ps), None);
}
}
/// Verify that required dependency services are running before installing an app.
/// Returns an error with a user-friendly message if dependencies are missing.
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
@@ -1152,6 +1152,9 @@ impl RpcHandler {
let deps = super::dependencies::detect_running_deps().await?;
super::dependencies::check_install_deps("mempool", &deps)?;
let (_, rpc_pass) = crate::bitcoin_rpc::bitcoin_rpc_credentials().await;
// CORE_RPC_HOST must match the actual Bitcoin node container name —
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC (B12).
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
install_log("INSTALL START: mempool (stack: mariadb + mempool-api + mempool-web)").await;
@@ -1275,7 +1278,7 @@ impl RpcHandler {
"-e",
"ELECTRUM_TLS_ENABLED=false",
"-e",
"CORE_RPC_HOST=bitcoin-knots",
&format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
"-e",
"CORE_RPC_PORT=8332",
"-e",
+43 -7
View File
@@ -32,8 +32,11 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing package id"))?;
validate_app_id(package_id)?;
// Verify an update is actually available
let pinned = image_versions::pinned_image_for_app(package_id)
// Verify an update is actually available. Prefer the remote app catalog
// (decoupled from the binary OTA), falling back to the image-versions.sh
// pin when the catalog is absent or doesn't cover this app.
let pinned = crate::container::app_catalog::catalog_primary_image(package_id)
.or_else(|| image_versions::pinned_image_for_app(package_id))
.ok_or_else(|| anyhow::anyhow!("No pinned image found for {}", package_id))?;
// Note: the `already updating` guard lives in `spawn_package_update`
@@ -149,6 +152,28 @@ impl RpcHandler {
}
}
/// Manual "check for updates": refresh the remote app catalog now. The
/// package scanner recomputes each app's `available-update` from the fresh
/// catalog on its next cycle and pushes it to the UI. Best-effort — a fetch
/// failure leaves the cached catalog in place and reports `refreshed: false`.
pub(in crate::api::rpc) async fn handle_package_check_updates(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
match crate::container::app_catalog::refresh_catalog(&self.config.data_dir).await {
Ok(count) => Ok(serde_json::json!({
"status": "ok",
"refreshed": true,
"catalog_apps": count,
})),
Err(e) => Ok(serde_json::json!({
"status": "ok",
"refreshed": false,
"error": e.to_string(),
})),
}
}
/// Core update execution: stop → pull → remove → recreate → verify.
async fn execute_update(
&self,
@@ -385,13 +410,24 @@ impl RpcHandler {
package_id: &str,
pinned_primary: &str,
) -> Vec<(String, String)> {
let stack_images = image_versions::pinned_images_for_stack(package_id);
let mut stack_images = image_versions::pinned_images_for_stack(package_id);
if stack_images.is_empty() {
// Single container app
vec![(package_id.to_string(), pinned_primary.to_string())]
} else {
stack_images
// Single container app — pinned_primary already prefers the catalog.
return vec![(package_id.to_string(), pinned_primary.to_string())];
}
// Stack app: override per-container images with the catalog where it
// provides them; components the catalog omits keep the image-versions.sh
// pin. This lets a single component (e.g. the IndeeHub frontend) be
// bumped without touching the rest of the stack.
let catalog_images = crate::container::app_catalog::catalog_stack_images(package_id);
if !catalog_images.is_empty() {
for (name, image) in stack_images.iter_mut() {
if let Some(catalog_image) = catalog_images.get(name) {
*image = catalog_image.clone();
}
}
}
stack_images
}
/// Rollback: restart old containers if they still exist.
+4
View File
@@ -133,6 +133,10 @@ impl RpcHandler {
/// Apply git-based update: runs self-update.sh which pulls, builds, and restarts.
pub(super) async fn handle_update_git_apply(&self) -> Result<serde_json::Value> {
if std::env::var("ARCHIPELAGO_GIT_UPDATES").is_err() {
anyhow::bail!("git/self-build updates are disabled; use manifest OTA updates instead");
}
let script = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/home/archipelago".to_string()),
)
+225 -4
View File
@@ -30,8 +30,22 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
// Kiosk hardening (#36): keep the deployed unit + launcher in sync with the
// repo so the CPU/memory cap and the GPU-vs-headless flag selection reach
// already-installed nodes via OTA, not just fresh ISOs.
const KIOSK_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-kiosk.service");
const KIOSK_LAUNCHER: &str =
include_str!("../../../image-recipe/configs/archipelago-kiosk-launcher.sh");
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
/// HTTP-only nodes, in which case the bootstrap loop skips it.
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
@@ -41,6 +55,41 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
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
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\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 Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\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 lacking the peer-content streaming proxy.
/// Without it, the browser's `<video>`/`<audio>` Range requests to
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
/// disables buffering so streaming works. Kept in sync with the canonical
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file\n location /api/peer-content/ {\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 Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 120s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
/// nodes converge to the same config fresh ISOs ship with.
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
/// at all (older than the main conf's), and the directive that follows it varies
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
/// proxying there) and insert the reroot set right after it — directive order
/// inside a location block is irrelevant to nginx. Idempotent via the same
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -476,6 +525,92 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
/// B17 self-heal: ensure the installed archipelago.service waits for the data
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
/// never restart the running service here. Idempotent; no-op if the unit is absent
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
/// the requirement to the always-mounted root).
pub async fn ensure_archipelago_mount_ordering() {
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
Ok(c) => c,
Err(e) => {
tracing::debug!(
"mount-ordering self-heal: {} not readable ({}) — skipping",
ARCHIPELAGO_SERVICE_PATH,
e
);
return;
}
};
if current.contains(MOUNT_REQUIRE_LINE) {
return; // already healed
}
// Insert the directive into the [Unit] section, immediately before [Service].
let Some(idx) = current.find("\n[Service]") else {
tracing::warn!(
"mount-ordering self-heal: no [Service] section in {} — skipping",
ARCHIPELAGO_SERVICE_PATH
);
return;
};
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
patched.push_str(&current[..idx]);
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
patched.push_str(MOUNT_REQUIRE_LINE);
patched.push_str(&current[idx..]);
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
Ok(true) => {
info!(
"B17: added '{}' to archipelago.service (effective next reboot)",
MOUNT_REQUIRE_LINE
);
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
}
}
Ok(false) => {}
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
}
}
/// #36 self-heal: keep the kiosk unit + launcher current on already-deployed
/// nodes so the CPU/memory cap (a runaway chromium was saturating the node and
/// starving the backend) and the GPU-vs-headless flag selection arrive via OTA.
/// No-op on nodes without the kiosk installed; only restarts the kiosk if it's
/// actually running (so it never re-enables an operator-disabled kiosk).
pub async fn ensure_kiosk_hardened() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // kiosk not installed on this node
}
let svc_changed = write_root_if_needed(KIOSK_SERVICE_PATH, KIOSK_SERVICE)
.await
.unwrap_or(false);
let launcher_changed = write_root_if_needed(KIOSK_LAUNCHER_PATH, KIOSK_LAUNCHER)
.await
.unwrap_or(false);
if launcher_changed {
let _ = host_sudo(&["chmod", "+x", KIOSK_LAUNCHER_PATH]).await;
}
if svc_changed || launcher_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("kiosk hardening: daemon-reload failed: {:#}", e);
}
// try-restart only restarts a currently-active unit — leaves a stopped/
// disabled kiosk alone.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-kiosk.service"]).await;
info!("kiosk: applied resource cap + GPU-flag hardening (#36)");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend
@@ -496,7 +631,11 @@ async fn run_nginx() -> Result<bool> {
let mut changed = false;
let mut patched_paths = Vec::<PathBuf>::new();
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
for path in [
NGINX_CONF_PATH,
NGINX_ENABLED_CONF_PATH,
NGINX_HTTPS_SNIPPET_PATH,
] {
let candidate = Path::new(path);
if !candidate.exists() {
debug!("{} missing — skipping nginx bootstrap", path);
@@ -514,18 +653,100 @@ async fn run_nginx() -> Result<bool> {
Ok(changed)
}
/// Reflective CORS add_headers that older configs placed inside the
/// `/lnd-connect-info` location. The backend now sets a validated
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("read {}", path))?;
let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
if !missing_app_catalog && !missing_bitcoin_status {
// Each "missing" flag is gated on the splice anchor actually being present,
// so an included snippet that legitimately has none of these endpoints (the
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
// every boot — it falls through to the fedimint heal alone.
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|| content.contains(" location /electrs-status {");
let missing_app_catalog = content
.contains(" # DWN endpoints — peer access over Tor (no auth)")
&& !content.contains("location /api/app-catalog");
let missing_bitcoin_status = content.contains(" location /electrs-status {")
&& !content.contains("location /bitcoin-status");
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
&& !has_lnd_dup_cors
&& !needs_fedimint_css
{
return Ok(false);
}
let mut patched = content.clone();
if has_lnd_dup_cors {
// Drop the redundant nginx-side CORS headers so the backend's single
// validated Access-Control-Allow-Origin is the only one returned.
patched = patched.replace(NGINX_LND_DUP_CORS, "");
}
if needs_fedimint_css {
// Style A (main conf): the block already injects nostr-provider, so swap
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
// node's fedimint block doesn't match OLD.
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
// the marker so it can never double-apply after Style A already healed.
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
}
}
if missing_lnd_proxy {
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
// back to `/electrs-status` (since 2026-03-08) for even older configs.
// Both appear once per archipelago server block, so the block is added
// to every server block that proxies to the backend.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if !patched.contains(anchor) {
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
} else {
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
if missing_peer_content {
// Same anchoring as the LND proxy: prepend the block to every server
// block so /api/peer-content/* reaches the backend instead of the SPA.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if patched.contains(anchor) {
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
} else {
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
}
}
if missing_bitcoin_status {
let anchor = " location /electrs-status {";
if !patched.contains(anchor) {
@@ -0,0 +1,343 @@
//! Remote app version catalog — DECOUPLES per-app updates from the binary OTA.
//!
//! Background: `image_versions.rs` reads the pinned image tags from
//! `image-versions.sh`, which is deployed *with the archipelago binary*. That
//! coupled every app update to a full node release. This module adds a remote
//! catalog (`app-catalog.json`) fetched over HTTP from the same origin as the
//! OTA manifest, refreshed periodically and on demand. Bumping an app's version
//! is then a JSON edit + push — no binary release.
//!
//! Resolution order (origin-always-wins, matching the DHT design's posture):
//! 1. Remote catalog (this module) — the live source of "available update".
//! 2. `image-versions.sh` pin — offline/baseline fallback when the catalog is
//! missing or doesn't cover the app.
//!
//! ## Forward-compatibility with the DHT distribution plan
//! (`docs/dht-distribution-design.md`)
//! This catalog IS the "discovery / authenticity" layer of that plan. The schema
//! is deliberately extensible so the later phases bolt on WITHOUT a breaking
//! change:
//! - `signature` / `signed_by` (top level) — Phase 0 seed-derived release-root
//! signature over the canonical JSON. Absent today; verified when present.
//! - per-image `digest` / `size` — BLAKE3/SHA-256 content address + length, so
//! the iroh swarm can fetch images by hash with the registry as origin.
//! Unknown fields are ignored (no `deny_unknown_fields`), so adding fields on the
//! publisher side never breaks older nodes.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
use tracing::{debug, info, warn};
/// Filename for both the published catalog and the on-node cache.
pub const APP_CATALOG_FILE: &str = "app-catalog.json";
/// Cache of the parsed catalog, invalidated when the cache file mtime changes.
static CACHE: Mutex<Option<CacheEntry>> = Mutex::new(None);
struct CacheEntry {
mtime: SystemTime,
catalog: AppCatalog,
}
/// Top-level catalog document.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppCatalog {
/// Schema version. 1 = current. Bump only on incompatible changes.
#[serde(default)]
pub schema: u32,
/// Publish date (RFC 3339 or YYYY-MM-DD). Informational.
#[serde(default)]
pub updated: String,
/// app_id -> entry.
#[serde(default)]
pub apps: HashMap<String, AppCatalogEntry>,
/// DHT-plan forward-compat: detached signature over the canonical JSON,
/// produced by the seed-derived release-root key. Absent today.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
/// DHT-plan forward-compat: publisher identity (did:key / npub).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signed_by: Option<String>,
}
/// Per-app catalog entry.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct AppCatalogEntry {
/// User-facing version string (drives the "Update available" badge text).
pub version: String,
/// Primary single-container image reference (`registry/repo:tag`). For stack
/// apps this is the primary container's image (the one whose version the
/// badge tracks — e.g. the IndeeHub frontend).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Stack apps only: container_name -> image reference. Components omitted here
/// fall back to the `image-versions.sh` pin during an update.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub images: Option<HashMap<String, String>>,
/// DHT-plan forward-compat: content address of the primary image (unused now).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
/// DHT-plan forward-compat: size in bytes of the primary image (unused now).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
/// Optional human-readable changelog lines for this version.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub changelog: Vec<String>,
}
/// Read-side cache file search order. Mirrors `image_versions.rs`: the running
/// daemon's data dir first (via env for dev), then the canonical runtime path.
fn cache_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(dir) = std::env::var("ARCHIPELAGO_DATA_DIR") {
paths.push(Path::new(&dir).join(APP_CATALOG_FILE));
}
paths.push(Path::new("/var/lib/archipelago").join(APP_CATALOG_FILE));
paths
}
fn find_cache_file() -> Option<(PathBuf, SystemTime)> {
for p in cache_paths() {
if let Ok(meta) = p.metadata() {
if let Ok(mtime) = meta.modified() {
return Some((p, mtime));
}
}
}
None
}
/// Load and cache the on-node catalog. Returns an empty catalog when absent —
/// callers then fall back to `image-versions.sh`.
fn load_catalog() -> AppCatalog {
let (path, mtime) = match find_cache_file() {
Some(v) => v,
None => return AppCatalog::default(),
};
{
let cache = CACHE.lock().unwrap();
if let Some(ref entry) = *cache {
if entry.mtime == mtime {
return entry.catalog.clone();
}
}
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
debug!("app-catalog: failed to read {}: {}", path.display(), e);
return AppCatalog::default();
}
};
let catalog: AppCatalog = match serde_json::from_str(&content) {
Ok(c) => c,
Err(e) => {
warn!("app-catalog: invalid JSON at {}: {}", path.display(), e);
return AppCatalog::default();
}
};
{
let mut cache = CACHE.lock().unwrap();
*cache = Some(CacheEntry {
mtime,
catalog: catalog.clone(),
});
}
catalog
}
fn entry_for(app_id: &str) -> Option<AppCatalogEntry> {
load_catalog().apps.get(app_id).cloned()
}
/// Primary image for an app per the remote catalog, if covered.
pub fn catalog_primary_image(app_id: &str) -> Option<String> {
entry_for(app_id).and_then(|e| e.image)
}
/// Per-container stack image overrides from the catalog (container_name -> image).
pub fn catalog_stack_images(app_id: &str) -> HashMap<String, String> {
entry_for(app_id).and_then(|e| e.images).unwrap_or_default()
}
/// Image override for the orchestrator's install/upgrade path. Returns the
/// catalog's primary image for `app_id` ONLY when it refers to the same
/// repository as the manifest's current image — a guard so a catalog typo can
/// never redirect an app to an unrelated image. `None` means "use the manifest
/// image as-is" (catalog absent, app uncovered, or repo mismatch).
pub fn catalog_image_override(app_id: &str, manifest_image: &str) -> Option<String> {
let candidate = catalog_primary_image(app_id)?;
let same_repo = crate::container::image_versions::image_without_registry_or_tag(&candidate)
== crate::container::image_versions::image_without_registry_or_tag(manifest_image);
if same_repo {
Some(candidate)
} else {
warn!(
"app-catalog: ignoring image for {} — repo mismatch (catalog={}, manifest={})",
app_id, candidate, manifest_image
);
None
}
}
/// Decoupled "available update" check for ALL apps.
///
/// Prefers the remote catalog; when the catalog covers the app, its verdict is
/// authoritative (so we never advertise a stale `image-versions.sh` pin over a
/// newer catalog, nor vice-versa). Falls back to the deployed pin only when the
/// catalog is missing or doesn't cover the app.
pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<String> {
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(
&catalog_image,
running_image,
);
}
// Not covered by the catalog -> baseline pin from image-versions.sh.
crate::container::image_versions::available_update_for_app(app_id, running_image)
}
/// Derive candidate catalog URLs from the OTA mirror list by swapping the
/// manifest filename for the catalog filename. Falls back to the default
/// manifest origin when no mirrors are configured.
fn catalog_urls_from_mirrors(mirrors: &[crate::update::UpdateMirror]) -> Vec<String> {
let mut urls: Vec<String> = mirrors
.iter()
.filter_map(|m| {
// mirror.url ends with ".../releases/manifest.json"
if m.url.ends_with("manifest.json") {
Some(m.url.replace("manifest.json", APP_CATALOG_FILE))
} else {
None
}
})
.collect();
urls.dedup();
urls
}
/// Fetch the catalog from the first reachable mirror and atomically write it to
/// `<data_dir>/app-catalog.json`. Returns the number of apps in the catalog on
/// success. Best-effort: a fetch failure leaves the existing cache untouched
/// (origin-always-wins; updates simply aren't refreshed this cycle).
pub async fn refresh_catalog(data_dir: &Path) -> anyhow::Result<usize> {
let mirrors = crate::update::load_mirrors(data_dir)
.await
.unwrap_or_default();
let urls = catalog_urls_from_mirrors(&mirrors);
if urls.is_empty() {
debug!("app-catalog: no mirror-derived URLs to fetch from");
return Ok(0);
}
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()?;
let mut last_err: Option<anyhow::Error> = None;
for url in &urls {
match fetch_one(&client, url).await {
Ok(catalog) => {
let count = catalog.apps.len();
write_cache(data_dir, &catalog)?;
// Invalidate the in-process cache so the next read re-parses.
*CACHE.lock().unwrap() = None;
info!("app-catalog: refreshed from {} ({} apps)", url, count);
return Ok(count);
}
Err(e) => {
debug!("app-catalog: fetch {} failed: {}", url, e);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("no catalog mirrors reachable")))
}
async fn fetch_one(client: &reqwest::Client, url: &str) -> anyhow::Result<AppCatalog> {
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
anyhow::bail!("HTTP {}", resp.status());
}
let body = resp.text().await?;
let catalog: AppCatalog = serde_json::from_str(&body)?;
// NOTE (DHT Phase 0): when `catalog.signature` is present, verify it against
// the seed-derived release-root pubkey here before accepting. Until signing
// ships we accept unsigned catalogs (same trust level as today's manifest).
Ok(catalog)
}
fn write_cache(data_dir: &Path, catalog: &AppCatalog) -> anyhow::Result<()> {
let dest = data_dir.join(APP_CATALOG_FILE);
let tmp = data_dir.join(format!("{}.tmp", APP_CATALOG_FILE));
let json = serde_json::to_string_pretty(catalog)?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &dest)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_and_ignores_unknown_fields() {
let json = r#"{
"schema": 1,
"updated": "2026-06-16",
"future_field": "ignored",
"signature": "sig123",
"signed_by": "did:key:zABC",
"apps": {
"indeedhub": {
"version": "1.0.1",
"image": "146.59.87.168:3000/lfg2025/indeedhub:1.0.1",
"digest": "blake3:deadbeef",
"size": 12345,
"another_future_field": true
}
}
}"#;
let cat: AppCatalog = serde_json::from_str(json).unwrap();
assert_eq!(cat.schema, 1);
assert_eq!(cat.signature.as_deref(), Some("sig123"));
let e = cat.apps.get("indeedhub").unwrap();
assert_eq!(e.version, "1.0.1");
assert_eq!(
e.image.as_deref(),
Some("146.59.87.168:3000/lfg2025/indeedhub:1.0.1")
);
assert_eq!(e.digest.as_deref(), Some("blake3:deadbeef"));
}
#[test]
fn empty_catalog_when_absent_is_default() {
let cat = AppCatalog::default();
assert!(cat.apps.is_empty());
assert!(cat.signature.is_none());
}
#[test]
fn catalog_url_derived_from_mirror() {
let mirrors = vec![crate::update::UpdateMirror {
url: "http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
label: "Server 1".to_string(),
}];
let urls = catalog_urls_from_mirrors(&mirrors);
assert_eq!(
urls,
vec![
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/app-catalog.json"
.to_string()
]
);
}
}
@@ -172,8 +172,10 @@ impl DockerPackageScanner {
// Extract actual version from container image tag
let running_version = image_versions::extract_version_from_image(&container.image);
// Decoupled from the binary OTA: prefer the remote app catalog,
// falling back to the image-versions.sh pin when uncovered/offline.
let available_update =
image_versions::available_update_for_app(&app_id, &container.image);
crate::container::app_catalog::available_update_for_app(&app_id, &container.image);
let package = PackageDataEntry {
state: package_state.clone(),
@@ -276,7 +278,6 @@ fn get_app_tier(app_id: &str) -> &'static str {
"core"
}
"btcpay" | "btcpay-server" | "btcpayserver" => "core",
"dwn" => "core",
"filebrowser" => "core",
// Recommended: enhanced functionality
"fedimint" | "fedimint-gateway" => "recommended",
@@ -518,13 +519,6 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/indeedhub/indeedhub".to_string(),
tier: "",
},
"dwn" => AppMetadata {
title: "Decentralized Web Node".to_string(),
description: "Store and sync personal data with DID-based access control".to_string(),
icon: "/assets/img/app-icons/dwn.svg".to_string(),
repo: "https://github.com/TBD54566975/dwn-server".to_string(),
tier: "",
},
"tor" | "archy-tor" => AppMetadata {
title: "Tor".to_string(),
description: "Anonymous overlay network for privacy".to_string(),
@@ -707,7 +701,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
if !requires_reachable_launch(app_id) {
return Some(url);
}
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
let Some(port) = launch_url_port(&url) else {
return None;
};
if launch_port_reachable(port).await {
@@ -718,6 +712,23 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
}
}
/// Extract the TCP port from a launch URL's authority.
///
/// The candidate URL can carry a path when it comes from a manifest
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u16>()
.ok()
}
async fn launch_port_reachable(port: u16) -> bool {
matches!(
tokio::time::timeout(
@@ -796,3 +807,26 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::Updating => "updating",
}
}
#[cfg(test)]
mod launch_url_port_tests {
use super::launch_url_port;
#[test]
fn parses_port_with_trailing_path() {
// Regression: manifest interfaces.main yields a path-suffixed URL.
// The old rsplit(':') parse produced "8096/" and dropped the URL.
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
}
#[test]
fn parses_bare_authority_port() {
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
}
#[test]
fn rejects_url_without_port() {
assert_eq!(launch_url_port("http://localhost/"), None);
}
}
@@ -187,9 +187,6 @@ fn image_var_for_app(app_id: &str) -> Option<&'static str> {
// Penpot (primary = frontend)
"penpot" | "penpot-frontend" => Some("PENPOT_FRONTEND_IMAGE"),
// DWN
"dwn" => Some("DWN_SERVER_IMAGE"),
// AI
"routstr" => Some("ROUTSTR_IMAGE"),
@@ -216,7 +213,7 @@ pub fn available_update_for_app(app_id: &str, running_image: &str) -> Option<Str
available_update_for_images(&pinned, running_image)
}
fn available_update_for_images(pinned: &str, running_image: &str) -> Option<String> {
pub fn available_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;
@@ -258,7 +255,7 @@ fn is_floating_tag(tag: &str) -> bool {
matches!(tag, "latest" | "stable" | "release" | "main")
}
fn image_without_registry_or_tag(image: &str) -> &str {
pub fn image_without_registry_or_tag(image: &str) -> &str {
let without_tag = strip_tag(image);
match without_tag.split_once('/') {
Some((first, rest))
+349 -58
View File
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
pub const WALLET_PASSWORD: &str = "hellohello";
/// Per-node LND wallet password file (random, 0600). Replaces the old
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
/// unlock fallback so wallets created by those builds still open; new wallets
/// never use it, and the login-path migration rotates away from it.
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
@@ -76,18 +85,128 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
let admin_macaroon = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon";
let wallet_db = "/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/wallet.db";
if file_exists_as_root(wallet_db).await {
if file_exists_as_root(admin_macaroon).await {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
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(());
}
}
}
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";
/// 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;
@@ -121,68 +240,227 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
}
}
async fn unlock_existing_wallet() -> Result<()> {
/// Read the per-node wallet password from the secrets file, if present.
/// Never generates one — absence means "fall back to legacy / not set yet".
async fn read_wallet_password() -> Option<String> {
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
(!pw.is_empty()).then_some(pw)
}
/// Return the per-node wallet password, generating and persisting a fresh
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
/// wallet — calling it merely to unlock an existing wallet would record a
/// password that doesn't match it.
pub(crate) async fn ensure_wallet_password() -> Result<String> {
if let Some(pw) = read_wallet_password().await {
return Ok(pw);
}
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut raw);
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
}
fs::write(path, &pw)
.await
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
Ok(pw)
}
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
/// per-node secret (current scheme) first, then the legacy constant so wallets
/// created by older builds still open.
async fn unlock_password_candidates() -> Vec<String> {
let mut v = Vec::new();
if let Some(pw) = read_wallet_password().await {
v.push(pw);
}
v.push(LEGACY_WALLET_PASSWORD.to_string());
v
}
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
/// password (no point retrying) vs keep waiting for LND to come up.
enum UnlockAttempt {
Unlocked,
WrongPassword,
NotReady,
}
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
/// (WrongPassword — try the next candidate, don't retry) from transient
/// not-ready / connection errors (NotReady — worth retrying).
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
let body = serde_json::json!({
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
});
match client
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() || text.contains("already unlocked") {
UnlockAttempt::Unlocked
} else if text.contains("invalid passphrase") {
UnlockAttempt::WrongPassword
} else {
UnlockAttempt::NotReady
}
}
Err(_) => UnlockAttempt::NotReady,
}
}
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
/// password was actively rejected (unrecoverable — caller should recreate);
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
async fn unlock_existing_wallet() -> Result<bool> {
unlock_existing_wallet_via_rest().await
}
async fn unlock_existing_wallet_via_rest() -> Result<()> {
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
let candidates = unlock_password_candidates().await;
// Retry only while LND's unlocker isn't ready yet. If every candidate is
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
// with a clear message instead of hanging the boot path for 60s+ (the wallet
// was created with a password this node doesn't have → migration/recovery).
for _ in 0..60 {
let mut all_rejected = true;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
UnlockAttempt::Unlocked => return Ok(true),
UnlockAttempt::WrongPassword => {}
UnlockAttempt::NotReady => all_rejected = false,
}
}
if all_rejected {
tracing::warn!(
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
with a password this node does not have",
candidates.len()
);
return Ok(false);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
}
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/state"))
.send()
.await
.ok()?;
let v: serde_json::Value = resp.json().await.ok()?;
v.get("state")
.and_then(|s| s.as_str())
.map(|s| s.to_string())
}
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
/// rejected (wrong password — try the next candidate); Err = transport/other.
async fn change_wallet_password(
client: &reqwest::Client,
current: &str,
new: &str,
) -> Result<bool> {
let body = serde_json::json!({
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
});
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
.json(&body)
.send()
.await
.context("calling LND changepassword")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
Ok(true)
} else if text.contains("invalid passphrase") {
Ok(false)
} else {
anyhow::bail!("LND changepassword returned {status}: {text}")
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
/// login, when the onboarding password is available as a candidate. If the
/// per-node secret already opens the wallet, just unlock. Otherwise try each
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
.await
.context("writing lncli password")?;
}
let out = child
.wait_with_output()
.await
.context("waiting for lncli")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let msg = format!("{stderr}{stdout}");
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
return Ok(());
}
last_err = Some(msg);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Only act on a wallet that is actually LOCKED.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(false);
}
anyhow::bail!(
"lncli wallet unlock failed: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
// If the per-node secret already opens it, nothing to rotate — just unlock.
if let Some(secret) = read_wallet_password().await {
if matches!(
try_unlock_once(&client, &secret).await,
UnlockAttempt::Unlocked
) {
return Ok(true);
}
}
// The wallet's new password becomes the per-node secret (generate if absent).
let new_secret = ensure_wallet_password().await?;
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(true);
}
for cand in candidates {
if cand.is_empty() || *cand == new_secret {
continue;
}
match change_wallet_password(&client, cand, &new_secret).await {
Ok(true) => {
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
return Ok(true);
}
Ok(false) => continue, // wrong current password — try next candidate
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
}
}
tracing::warn!(
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
);
Ok(false)
}
#[derive(Debug, Deserialize)]
@@ -204,6 +482,7 @@ struct InitWalletRequest {
async fn init_wallet_via_rest() -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
@@ -223,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
let wallet_password =
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
@@ -237,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
}
}
Ok(())
@@ -305,12 +587,12 @@ async fn decode_lnd_unlocker_response<T: for<'de> Deserialize<'de>>(
anyhow::bail!("LND REST {path} returned {status}: {text}")
}
#[allow(dead_code)]
async fn lnd_getinfo_ready(admin_macaroon: &str) -> bool {
let Ok(macaroon) = read_file_as_root(admin_macaroon).await else {
return false;
};
let Ok(client) = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
@@ -448,7 +730,16 @@ mod tests {
}
#[test]
fn wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
fn legacy_wallet_password_is_valid_for_lncli() {
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
}
#[tokio::test]
async fn unlock_candidates_always_include_legacy_fallback() {
// With no per-node secret on disk in the test env, candidates fall back
// to the legacy constant so old wallets still open.
let cands = unlock_password_candidates().await;
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod app_catalog;
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
@@ -297,6 +297,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
Ok(())
}
/// Pure published-port drift check. `port_bindings_json` is the JSON that
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
/// manifest container-port is positively published to a *different* host port
/// than the manifest now asks for. Absence of a binding is deliberately NOT
/// treated as drift here — that case is handled by the host-port repair/restart
/// path and by host-networked apps that publish nothing — so we never trigger a
/// destructive recreate on a false positive.
fn host_port_bindings_drifted(
port_bindings_json: &str,
manifest_ports: &[archipelago_container::manifest::PortMapping],
) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
Ok(v) => v,
Err(_) => return false,
};
let Some(map) = parsed.as_object() else {
return false;
};
for port in manifest_ports {
let proto = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
let key = format!("{}/{}", port.container, proto);
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
// Container-port not currently published — not our case.
continue;
};
if bindings.is_empty() {
continue;
}
let expected = port.host.to_string();
let matches_expected = bindings.iter().any(|b| {
b.get("HostPort")
.and_then(|h| h.as_str())
.map(|h| h == expected)
.unwrap_or(false)
});
if !matches_expected {
return true;
}
}
false
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if podman_socket_accepts_connections(socket_path).await {
@@ -725,6 +772,8 @@ pub struct ProdContainerOrchestrator {
use_quadlet_backends: bool,
#[cfg(test)]
test_disk_gb: Option<u64>,
#[cfg(test)]
test_bitcoin_host: Option<String>,
}
struct FileSecretsProvider {
@@ -734,8 +783,19 @@ struct FileSecretsProvider {
impl SecretsProvider for FileSecretsProvider {
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
let path = self.root.join(name);
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
Ok(data.trim().to_string())
match std::fs::read_to_string(&path) {
Ok(data) => Ok(data.trim().to_string()),
// Name the missing secret explicitly so the failure is actionable
// instead of a bare "IO error: No such file or directory" that hides
// which secret (and which app) is blocked.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(ManifestError::Invalid(format!(
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
path.display()
)))
}
Err(e) => Err(ManifestError::Io(e)),
}
}
}
@@ -774,6 +834,8 @@ impl ProdContainerOrchestrator {
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
#[cfg(test)]
test_bitcoin_host: None,
})
}
@@ -792,6 +854,7 @@ impl ProdContainerOrchestrator {
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false,
test_disk_gb: None,
test_bitcoin_host: None,
}
}
@@ -1054,6 +1117,7 @@ impl ProdContainerOrchestrator {
let lock = self.app_lock(&app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);
@@ -1094,6 +1158,22 @@ impl ProdContainerOrchestrator {
self.run_post_start_hooks(&app_id).await?;
return Ok(ReconcileAction::Started);
}
// Published-port drift means the container exists but maps
// its ports to the wrong host ports (e.g. lnd REST stuck on
// host 8080 while the manifest/clients expect 18080, as seen
// on .116). The container is already non-functional, so
// recreate it even for restart-sensitive apps during boot —
// leaving it "untouched" would perpetuate the breakage.
if self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
if self.container_env_drifted(&name, &resolved_manifest).await {
if mode == ReconcileMode::ExistingOnly
&& is_restart_sensitive_app(&app_id)
@@ -1154,8 +1234,12 @@ impl ProdContainerOrchestrator {
// reading it. A Rewritten outcome is fine here — we're
// about to start from a stopped state anyway.
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
if self.container_env_drifted(&name, &resolved_manifest).await
|| self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -1297,10 +1381,33 @@ impl ProdContainerOrchestrator {
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
self.ensure_app_secrets(&lm.manifest.app.id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
// Decouple the app image from the shipped manifest: prefer the remote
// app catalog when it covers this app with a same-repo image. This makes
// both the pull below and create_container() below use the catalog tag,
// so an app update no longer requires a binary/runtime release. Falls
// back to the manifest image when the catalog is absent/uncovered.
if let Some(current) = resolved_manifest.app.container.image.clone() {
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
&resolved_manifest.app.id,
&current,
) {
if catalog_image != current {
tracing::info!(
app_id = %resolved_manifest.app.id,
from = %current,
to = %catalog_image,
"app-catalog: overriding manifest image"
);
resolved_manifest.app.container.image = Some(catalog_image);
}
}
}
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
anyhow::anyhow!(
"manifest for {} has invalid container source (neither image nor build)",
lm.manifest.app.id
@@ -2234,9 +2341,45 @@ impl ProdContainerOrchestrator {
host_ip,
host_mdns,
disk_gb,
// Cheap default; resolve_dynamic_env fills the real node name on
// demand (it costs a podman call) only for manifests that use
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
bitcoin_host: "bitcoin-knots".to_string(),
}
}
/// Container name of the running Bitcoin node (`bitcoin-knots` or
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
/// Synchronous `podman ps` to match the surrounding host-fact detection;
/// defaults to `bitcoin-knots` when none is running (B12).
fn bitcoin_host(&self) -> String {
#[cfg(test)]
if let Some(host) = &self.test_bitcoin_host {
return host.clone();
}
// Mirrors api::rpc::package::dependencies (the legacy install path);
// both Bitcoin node variants are reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
.unwrap_or_else(|| "bitcoin-knots".to_string())
}
#[cfg(test)]
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
self.test_bitcoin_host = Some(host.to_string());
}
fn detect_host_ip() -> Option<String> {
let output = Command::new("hostname").arg("-I").output().ok()?;
if !output.status.success() {
@@ -2300,8 +2443,38 @@ impl ProdContainerOrchestrator {
Self::detect_disk_gb()
}
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
/// bitcoind (and everything that depends on it) down. Generating it here,
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
if cfg!(test) {
return Ok(());
}
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
.await
.context("ensuring bitcoin tx-relay credentials")?;
}
Ok(())
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut facts = self.detect_host_facts();
// Only pay the podman cost to detect Knots-vs-Core when this manifest
// actually templates the Bitcoin node into its env (mempool — B12).
if manifest
.app
.container
.derived_env
.iter()
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
{
facts.bitcoin_host = self.bitcoin_host();
}
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
@@ -2443,6 +2616,42 @@ impl ProdContainerOrchestrator {
false
}
/// True when a *running* container publishes a manifest container-port to a
/// different host port than the manifest now asks for (published-port
/// drift). This catches the class of failure seen on .116, where `lnd` was
/// created mapping host 8080 -> container 8080 but the current manifest maps
/// host 18080 -> container 8080, so every in-process REST client (which
/// connects to the manifest port) gets connection-refused forever while the
/// container looks "Up". `container_env_drifted` never inspects ports, and
/// `wait_for_manifest_host_ports` only restarts the stale container (which
/// republishes the wrong mapping), so without this check the drift is
/// self-perpetuating.
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) {
return false;
}
if manifest.app.ports.is_empty() {
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{json .HostConfig.PortBindings}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let bindings = String::from_utf8_lossy(&output.stdout);
host_port_bindings_drifted(&bindings, &manifest.app.ports)
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(());
@@ -2752,6 +2961,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(app_id).await?;
let name = compute_container_name(&lm.manifest);
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2913,6 +3123,61 @@ mod tests {
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
archipelago_container::manifest::PortMapping {
host,
container,
protocol: "tcp".to_string(),
}
}
#[test]
fn port_drift_detected_when_host_port_differs() {
// The .116 case: container publishes container-port 8080 on host 8080,
// but the manifest now asks for host 18080.
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_host_port_matches() {
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_binding_absent() {
// Absence is handled elsewhere (host-port repair / host-networked apps);
// never treat it as drift to avoid a destructive recreate on a false
// positive.
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
}
#[test]
fn no_drift_on_unparseable_bindings() {
assert!(!host_port_bindings_drifted(
"not json",
&[port(18080, 8080)]
));
}
#[test]
fn missing_secret_error_names_the_secret() {
use archipelago_container::manifest::SecretsProvider;
let provider = FileSecretsProvider {
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
};
let err = provider
.read("bitcoin-rpc-txrelay-rpcauth")
.expect_err("missing secret must error");
let msg = err.to_string();
assert!(
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
"error should name the missing secret, got: {msg}"
);
}
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
/// the exact sequence of side effects.
#[derive(Default)]
@@ -3220,11 +3485,11 @@ app:
- /data/.filebrowser.json
volumes:
- type: bind
source: /tmp/filebrowser-srv
source: /var/lib/archipelago/filebrowser-srv
target: /srv
options: [rw]
- type: bind
source: /tmp/filebrowser-data
source: /var/lib/archipelago/filebrowser-data
target: /data
options: [rw]
"#;
@@ -3244,7 +3509,7 @@ app:
secret_file: bitcoin-rpc-password
volumes:
- type: bind
source: /tmp/lnd
source: /var/lib/archipelago/lnd
target: /root/.lnd
"#;
AppManifest::parse(yaml).unwrap()
@@ -3298,6 +3563,35 @@ app:
assert!(!calls.iter().any(|c| c.starts_with("build_image:")));
}
#[tokio::test]
async fn mempool_core_rpc_host_follows_bitcoin_node() {
// B12: mempool's CORE_RPC_HOST must resolve to whichever Bitcoin node
// container is running (Knots OR Core), not a hardcoded value.
let yaml = "app:\n id: mempool-api\n name: mempool-api\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: CORE_RPC_HOST\n template: \"{{BITCOIN_HOST}}\"\n";
for (node, expected) in [
("bitcoin-core", "bitcoin-core"),
("bitcoin-knots", "bitcoin-knots"),
] {
let rt = Arc::new(MockRuntime::default());
let mut orch = orch_with(rt).await;
orch.set_bitcoin_host_for_test(node);
let mut manifest = AppManifest::parse(yaml).unwrap();
orch.resolve_dynamic_env(&mut manifest).unwrap();
assert!(
manifest
.app
.environment
.iter()
.any(|e| e == &format!("CORE_RPC_HOST={expected}")),
"node={node}: expected CORE_RPC_HOST={expected}, got {:?}",
manifest.app.environment
);
}
}
#[tokio::test]
async fn install_fresh_build_when_image_absent() {
let rt = Arc::new(MockRuntime::default());
+2 -2
View File
@@ -800,7 +800,7 @@ mod tests {
QuadletUnit {
name: "archy-bitcoin-ui".into(),
description: "Bitcoin RPC UI proxy".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:latest".into(),
image: "146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha".into(),
network: NetworkMode::Host,
user: Some("0:0".into()),
memory_mb: Some(128),
@@ -828,7 +828,7 @@ mod tests {
let s = sample_unit().render();
assert!(s.contains("[Container]"));
assert!(s.contains("ContainerName=archy-bitcoin-ui"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:latest"));
assert!(s.contains("Image=146.59.87.168:3000/lfg2025/bitcoin-ui:1.7.84-alpha"));
assert!(s.contains("Pull=never"));
assert!(s.contains("Network=host"));
assert!(s.contains("DropCapability=ALL"));
+2 -2
View File
@@ -14,8 +14,8 @@ mod types;
pub use invites::{accept_invite, create_invite};
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
remove_node, save_nodes, set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+183 -1
View File
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
pub(crate) const FEDERATION_DIR: &str = "federation";
pub(crate) const NODES_FILE: &str = "nodes.json";
pub(crate) const INVITES_FILE: &str = "invites.json";
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
/// federation discovery can't silently re-add a peer they deleted.
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
/// Top-level file structures.
#[derive(Debug, Default, Serialize, Deserialize)]
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
pub(crate) nodes: Vec<FederatedNode>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct RemovedFile {
pub(crate) removed: Vec<RemovedNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RemovedNode {
pub(crate) did: String,
pub(crate) removed_at: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct InvitesFile {
pub(crate) outgoing: Vec<FederationInvite>,
@@ -44,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
.await
.context("Failed to read federation nodes")?;
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.nodes)
Ok(dedup_nodes_by_onion(file.nodes))
}
/// Collapse entries that share an onion. An onion is a node's stable, unique
/// network identity, so two entries with the same onion are the SAME physical
/// node lingering under two dids (e.g. after a did/key change). Returning both
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
/// from the duplicates into it, then drop them. Non-destructive to disk; the
/// deduped list persists the next time nodes are saved (add/sync).
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
use std::collections::HashMap;
let mut by_onion: HashMap<String, usize> = HashMap::new();
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
for node in nodes {
let key = node.onion.trim_end_matches(".onion").to_string();
if key.is_empty() {
out.push(node);
continue;
}
if let Some(&idx) = by_onion.get(&key) {
let kept = &mut out[idx];
if kept.fips_npub.is_none() {
kept.fips_npub = node.fips_npub;
}
if kept.name.is_none() {
kept.name = node.name;
}
if kept.last_state.is_none() {
kept.last_state = node.last_state;
}
continue;
}
by_onion.insert(key, out.len());
out.push(node);
}
out
}
/// Look up a federated peer's FIPS npub given their onion address.
@@ -114,6 +164,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
if exists {
anyhow::bail!("Node with DID {} is already federated", node.did);
}
// Explicitly (re-)adding a node clears any prior tombstone so the
// operator can intentionally bring back a previously removed peer.
let _ = untombstone_did(data_dir, &node.did).await;
nodes.push(node);
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
@@ -127,9 +180,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
anyhow::bail!("No federated node with DID {}", did);
}
save_nodes(data_dir, &nodes).await?;
// Tombstone the DID so transitive federation discovery (a still-federated
// peer advertising this DID as one of *its* trusted peers) can't silently
// re-add it. Best-effort: a failed tombstone write must not fail the
// remove the operator asked for.
let _ = tombstone_did(data_dir, did).await;
Ok(nodes)
}
/// Load the set of tombstoned (operator-removed) DIDs.
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(std::collections::HashSet::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read removed nodes")?;
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.removed.into_iter().map(|r| r.did).collect())
}
/// Record a DID as removed. Idempotent.
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let path = dir.join(REMOVED_FILE);
let mut file: RemovedFile = if path.exists() {
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default()
} else {
RemovedFile::default()
};
if !file.removed.iter().any(|r| r.did == did) {
file.removed.push(RemovedNode {
did: did.to_string(),
removed_at: chrono::Utc::now().to_rfc3339(),
});
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
/// Clear a DID's tombstone (operator explicitly re-added it).
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(());
}
let mut file: RemovedFile =
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default();
let before = file.removed.len();
file.removed.retain(|r| r.did != did);
if file.removed.len() != before {
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
pub async fn set_trust_level(
data_dir: &Path,
did: &str,
@@ -236,6 +350,44 @@ mod tests {
}
}
#[test]
fn test_dedup_nodes_by_onion_collapses_same_onion() {
// Two entries share an onion (same physical node under two dids) — must
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
let mut dup = make_node("did:key:zDUP", "shared.onion");
dup.fips_npub = Some("npub1merged".to_string());
dup.name = Some("Sapien".to_string());
let nodes = vec![
make_node("did:key:zKEEP", "shared.onion"),
dup,
make_node("did:key:zOTHER", "other.onion"),
];
let out = dedup_nodes_by_onion(nodes);
assert_eq!(out.len(), 2, "two distinct onions remain");
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
assert_eq!(
kept.fips_npub.as_deref(),
Some("npub1merged"),
"merges fips_npub from the dropped duplicate"
);
assert_eq!(
kept.name.as_deref(),
Some("Sapien"),
"merges name from the dup"
);
}
#[test]
fn test_dedup_onion_suffix_insensitive() {
// The ".onion" suffix must not affect the match.
let nodes = vec![
make_node("did:key:z1", "abc"),
make_node("did:key:z2", "abc.onion"),
];
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
}
#[tokio::test]
async fn test_load_nodes_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
@@ -287,6 +439,36 @@ mod tests {
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_tombstones_and_readd_clears_it() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
// No tombstones yet.
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
// Removing tombstones the DID so transitive discovery won't re-add it.
remove_node(dir.path(), "did:key:z1").await.unwrap();
let removed = load_removed_dids(dir.path()).await.unwrap();
assert!(
removed.contains("did:key:z1"),
"removed DID must be tombstoned"
);
// Explicitly re-adding clears the tombstone (intentional re-federate).
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
assert!(
!load_removed_dids(dir.path())
.await
.unwrap()
.contains("did:key:z1"),
"explicit re-add must clear the tombstone"
);
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
+103 -3
View File
@@ -66,7 +66,9 @@ pub async fn sync_with_peer(
// hop. Only runs when the source is Trusted — Observer-level peers
// don't get to expand our federation on their own authority.
if peer.trust_level == TrustLevel::Trusted {
if let Err(e) = merge_transitive_peers(data_dir, &peer.did, &state.federated_peers).await {
if let Err(e) =
merge_transitive_peers(data_dir, &peer.did, local_did, &state.federated_peers).await
{
tracing::warn!(
peer_did = %peer.did,
error = %e,
@@ -109,18 +111,30 @@ pub async fn sync_with_peer_by_did(data_dir: &Path, peer_did: &str) -> Result<No
async fn merge_transitive_peers(
data_dir: &std::path::Path,
source_did: &str,
local_did: &str,
hints: &[FederationPeerHint],
) -> Result<()> {
if hints.is_empty() {
return Ok(());
}
let mut nodes = super::storage::load_nodes(data_dir).await?;
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
// them via transitive discovery, or deleted (e.g. stale test) nodes
// reappear on the next sync with any peer that still lists them.
let removed = super::storage::load_removed_dids(data_dir)
.await
.unwrap_or_default();
let mut added = 0u32;
let mut refreshed = 0u32;
for hint in hints {
// Don't import our own DID (a peer advertising us back).
if hint.did == source_did {
// Don't import the source peer advertising itself, or our own DID
// when the source advertises us back as one of its trusted peers.
if hint.did == source_did || hint.did == local_did {
continue;
}
// Skip anything the operator deliberately removed.
if removed.contains(&hint.did) {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
@@ -131,6 +145,27 @@ async fn merge_transitive_peers(
}
continue;
}
// Same physical node advertised under a DIFFERENT did? Match on the
// onion (its stable network identity). Without this, a node that
// appears under two dids (e.g. after a key/did change) gets added
// twice — showing up duplicated in the trusted-node list (B1) and as
// two separate mesh chat contacts (B2). Merge into the existing entry.
let hint_onion = hint.onion.trim_end_matches(".onion");
if !hint_onion.is_empty() {
if let Some(existing) = nodes
.iter_mut()
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
{
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
existing.fips_npub = hint.fips_npub.clone();
}
if existing.name.is_none() && hint.name.is_some() {
existing.name = hint.name.clone();
}
refreshed += 1;
continue;
}
}
nodes.push(FederatedNode {
did: hint.did.clone(),
pubkey: hint.pubkey.clone(),
@@ -359,4 +394,69 @@ mod tests {
Some("npub1a")
);
}
#[tokio::test]
async fn merge_transitive_peers_skips_source_and_local_node() {
let dir = tempfile::tempdir().unwrap();
super::super::storage::save_nodes(
dir.path(),
&[FederatedNode {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
trust_level: TrustLevel::Trusted,
added_at: "now".into(),
last_seen: None,
last_state: None,
fips_npub: None,
last_transport: None,
last_transport_at: None,
}],
)
.await
.unwrap();
merge_transitive_peers(
dir.path(),
"did:key:zSource",
"did:key:zLocal",
&[
FederationPeerHint {
did: "did:key:zSource".into(),
pubkey: "aa".into(),
onion: "source.onion".into(),
name: Some("Source".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zLocal".into(),
pubkey: "bb".into(),
onion: "local.onion".into(),
name: Some("Local".into()),
fips_npub: None,
},
FederationPeerHint {
did: "did:key:zPeer".into(),
pubkey: "cc".into(),
onion: "peer.onion".into(),
name: Some("Kitchen".into()),
fips_npub: Some("npub1peer".into()),
},
],
)
.await
.unwrap();
let nodes = super::super::storage::load_nodes(dir.path()).await.unwrap();
assert_eq!(nodes.len(), 2);
assert!(nodes.iter().all(|n| n.did != "did:key:zLocal"));
let peer = nodes
.iter()
.find(|n| n.did == "did:key:zPeer")
.expect("trusted transitive peer should be added");
assert_eq!(peer.name.as_deref(), Some("Kitchen"));
assert_eq!(peer.trust_level, TrustLevel::Trusted);
assert_eq!(peer.fips_npub.as_deref(), Some("npub1peer"));
}
}
+76 -13
View File
@@ -28,12 +28,38 @@ use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
/// installs — the one the upstream daemon dials anyway. Operators can
/// remove it from the UI once their own cluster has independent anchors.
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
/// Operators can remove it from the UI once their own cluster has
/// independent anchors (removal persists, see `load`/`remove`).
///
/// IMPORTANT transport details, learned the hard way (see git history /
/// the 2026-06-15 debugging on .116):
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
/// (host pings on both IP families but never completes a UDP FIPS
/// handshake). `fips/config.rs` always knew this; the old default
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
/// An IPv4 literal sidesteps the resolver entirely.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
label: "Public anchor (fips.v0l.io)".to_string(),
}
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. Returns an empty list if the file
/// doesn't exist yet — a first-boot node with no operator config.
/// Load the seed-anchor list. A node that has never edited its anchor
/// list (no file yet) gets the default public anchor so it can bootstrap
/// the mesh out of the box. Once the operator edits anchors — including
/// removing the default — a file exists and is authoritative, so removal
/// persists and we never silently re-add it.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(Vec::new());
return Ok(vec![default_public_anchor()]);
}
let bytes = tokio::fs::read(&path)
.await
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
///
/// Invoked through `sudo -n`: the upstream daemon's control socket
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
/// archipelago service user is not in the `fips` group, so a bare
/// `fipsctl connect` fails with EACCES. This matches the privileged
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
/// Without it, seed anchors persist to disk but never actually dial,
/// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
let out = Command::new("sudo")
.args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"fipsctl exited {}: {}",
"sudo fipsctl connect exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("fipsctl launch failed: {}", e),
message: format!("sudo fipsctl launch failed: {}", e),
},
};
if result.ok {
@@ -185,10 +230,28 @@ mod tests {
}
#[tokio::test]
async fn load_missing_returns_empty() {
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty());
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
}
#[tokio::test]
+99 -21
View File
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
/// worth retrying over Tor, which reaches a different (less-filtered) route.
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
/// and are returned as-is so we neither mask real errors nor double latency.
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -82,17 +93,61 @@ pub async fn peer_base_url(npub: &str) -> Result<String> {
Ok(format!("http://[{}]:{}", ip, PEER_PORT))
}
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy,
/// short timeout — fall back to Tor on failure.
/// Build an HTTP client tuned for FIPS peer-to-peer dialing. No proxy.
/// `connect_timeout` is generous enough to let NAT hole-punching complete on
/// the first dial (FIPS is UDP hole-punched; the path often isn't established
/// until the first packets flow), so a reachable-but-cold peer isn't abandoned
/// to Tor prematurely. Reliability over latency — FIPS is the preferred path.
pub fn client() -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.connect_timeout(Duration::from_secs(5))
.connect_timeout(Duration::from_secs(8))
.user_agent("archipelago-fips/1")
.build()
.expect("static reqwest client config")
}
/// Send a FIPS request with ONE retry on a connect/timeout error.
///
/// The first dial to a peer typically triggers NAT hole-punching and can time
/// out before the overlay path is established; a quick retry then lands on the
/// now-warm path. Without this, a single cold-path failure drops the call to
/// Tor even though the peer is FIPS-reachable — the main reason FIPS "isn't
/// robust". Only connect/timeout errors are retried (a real HTTP response,
/// including 4xx/5xx, is returned as-is for the caller to interpret).
async fn send_with_retry(rb: reqwest::RequestBuilder) -> Result<reqwest::Response, reqwest::Error> {
let retry = rb.try_clone();
match rb.send().await {
Ok(resp) => Ok(resp),
Err(e) if (e.is_connect() || e.is_timeout()) && retry.is_some() => {
// Brief pause so the hole-punch packets from the first attempt can
// traverse before we re-dial onto the warmed path.
tokio::time::sleep(Duration::from_millis(600)).await;
retry.expect("retry builder present").send().await
}
Err(e) => Err(e),
}
}
/// Proactively warm the hole-punched FIPS path to a peer: resolve its overlay
/// address and open a short connection to its peer listener. Hole-punched
/// paths and NAT mappings go cold after ~30-60s of no traffic, after which the
/// next real dial pays the full re-punch cost and often falls back to Tor.
/// Keeping the path warm is what makes FIPS the transport that actually gets
/// used. Best-effort: any error (peer offline, UDP blocked) is ignored — the
/// connection attempt itself is what re-punches and refreshes the path.
pub async fn warm_path(npub: &str) {
if !is_service_active().await {
return;
}
let Ok(base) = peer_base_url(npub).await else {
return;
};
let c = client();
// The response status is irrelevant; establishing the connection warms it.
let _ = tokio::time::timeout(Duration::from_secs(8), c.get(&base).send()).await;
}
// ── DNS wire-format helpers ─────────────────────────────────────────────
fn encode_query(id: u16, npub: &str) -> Result<Vec<u8>> {
@@ -294,13 +349,22 @@ impl<'a> PeerRequest<'a> {
let pref = self.preference().await;
// FIPS-only or Auto: try FIPS first.
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_post_json(body).await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_post_json(body).await? {
Some(resp) => {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_post_json(body).await?;
@@ -312,13 +376,19 @@ impl<'a> PeerRequest<'a> {
use crate::settings::transport::TransportPref;
let pref = self.preference().await;
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_get().await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_get().await?;
@@ -348,10 +418,14 @@ impl<'a> PeerRequest<'a> {
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match rb.send().await {
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!("FIPS POST {} failed: {}, falling back to Tor", url, e);
tracing::debug!(
"FIPS POST {} failed after retry: {}, falling back to Tor",
url,
e
);
Ok(None)
}
}
@@ -377,10 +451,14 @@ impl<'a> PeerRequest<'a> {
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match rb.send().await {
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
tracing::debug!(
"FIPS GET {} failed after retry: {}, falling back to Tor",
url,
e
);
Ok(None)
}
}
+57
View File
@@ -33,6 +33,63 @@ pub mod service;
pub mod update;
use serde::{Deserialize, Serialize};
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
/// materialised the fips key, install the daemon config + start the service if
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
/// transport and should come up on its own — the UI "Activate" button is now a
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
/// when the service is already active.
pub async fn ensure_activated(data_dir: &std::path::Path) {
let identity_dir = identity_dir_from(data_dir);
if !identity_dir.join("fips_key").exists() {
return; // pre-onboarding: nothing to activate yet
}
if dial::is_service_active().await {
return; // already up
}
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
if let Err(e) = config::install(&identity_dir).await {
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
return;
}
if let Err(e) = service::activate(SERVICE_UNIT).await {
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
return;
}
tracing::info!("FIPS auto-activated");
}
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
/// is done but the service is down — so it comes up with zero user interaction,
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
/// so one slow/offline peer doesn't delay the rest.
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(45));
loop {
tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await;
if !dial::is_service_active().await {
continue;
}
let nodes = crate::federation::load_nodes(&data_dir)
.await
.unwrap_or_default();
let mut handles = Vec::new();
for node in nodes {
if let Some(npub) = node.fips_npub.clone() {
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
}
}
for h in handles {
let _ = h.await;
}
}
});
}
use std::path::{Path, PathBuf};
/// Systemd unit name supervised by archipelago.
+332 -87
View File
@@ -1,156 +1,401 @@
//! User-triggered FIPS upgrade from the upstream default branch.
//! User-triggered FIPS upgrade from upstream GitHub releases.
//!
//! Flow (no auto-update, no background polling — user clicks a button):
//! 1. Query GitHub for the upstream repo's default branch, then the
//! latest commit on it. (jmcorgan/fips default is `master`, not
//! `main` — we resolve it dynamically so a future rename Just Works.)
//! 2. Compare with the installed daemon version reported by
//! `fipsctl --version`. If identical, report "up to date".
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
//! 4. SHA256-verify the download.
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
//! (`/releases/latest` returns the newest non-prerelease, non-draft
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
//! reported by `fipsctl --version`. A dev/pre-release build of the
//! same number (`0.3.0-dev`) counts as older than the released tag.
//! 3. Pick the Debian package asset matching the host architecture
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
//! 4. Download both, SHA256-verify the .deb against the checksums file.
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
//!
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
//! publish stable release assets for per-commit builds. This module
//! currently implements steps 12 (the "is there anything newer?" query)
//! and stubs out 35 so the RPC/UI can wire through. The apply path
//! returns a clear "not yet available" error until the artefact source
//! is decided.
//! Upstream began publishing tagged releases with `.deb` artefacts and
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
//! the apply path is fully wired against those assets.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{service, UPSTREAM_REPO};
const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "archipelago-fips-updater";
/// Result of `check_update()` — what the dashboard renders.
/// Result of `check()` — what the dashboard renders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheck {
/// Currently installed daemon version (from `fipsctl --version`).
pub current: Option<String>,
/// Short SHA of the latest commit on upstream `main`.
pub latest_commit: String,
/// True when the installed version string does not mention the latest SHA.
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
pub latest_version: String,
/// True when the installed version is older than `latest_version`.
pub update_available: bool,
/// Release channel this check tracked. Currently always "stable".
pub channel: String,
/// Browser download URL of the architecture-matched .deb for the
/// latest release, when one exists (informational; apply() re-resolves).
pub asset_url: Option<String>,
/// Human-readable note for the UI.
pub notes: String,
}
/// Query GitHub for the latest commit on the upstream default branch and
/// compare to the installed version. Never errors on "no package installed"
/// — that is itself a valid state where an update is available.
/// One GitHub release as we consume it.
#[derive(Debug, Clone, Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Build HTTP client")
}
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
/// asset lookup simply finds nothing and surfaces a clear error.
fn deb_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
}
}
/// Query GitHub for the latest stable release and compare to the installed
/// version. Never errors on "no package installed" — that is itself a valid
/// state where an update is available.
pub async fn check() -> Result<UpdateCheck> {
let current = service::daemon_version().await.ok();
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(15))
.build()
.context("Build HTTP client")?;
let branch = fetch_default_branch(&client).await?;
let latest = fetch_head_sha(&client, &branch).await?;
let short = latest.chars().take(7).collect::<String>();
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
let update_available = match &current {
Some(v) => !v.contains(&short),
Some(v) => version_is_older(v, &release.tag_name),
None => true,
};
let asset_url = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.map(|a| a.browser_download_url.clone());
let notes = if update_available {
format!(
"Upstream {} is at {}; installed: {}",
branch,
short,
"Update available: {} (installed: {})",
release.tag_name,
current.as_deref().unwrap_or("not installed")
)
} else {
format!("Up to date ({} @ {})", branch, short)
format!("Up to date ({})", release.tag_name)
};
Ok(UpdateCheck {
current,
latest_commit: short,
latest_version: release.tag_name,
update_available,
channel: "stable".to_string(),
asset_url,
notes,
})
}
/// Apply the update. Stubbed pending a stable artefact source for
/// per-commit builds of the `fips` debian package. When this is wired
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
/// Download, verify, and install the latest stable FIPS release, then
/// restart the daemon. Steps: resolve release → match .deb for this arch
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
pub async fn apply() -> Result<()> {
anyhow::bail!(
"FIPS auto-apply not yet wired — upstream does not publish stable \
per-commit .deb artefacts for main. Upgrade manually for now: \
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
)
}
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
let deb = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.ok_or_else(|| {
anyhow::anyhow!(
"release {} has no .deb for architecture {}",
release.tag_name,
deb_arch()
)
})?;
let checksums = release
.assets
.iter()
.find(|a| a.name == "checksums-linux.txt")
.ok_or_else(|| {
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
})?;
// Download the .deb (bytes) and the checksums (text).
let deb_bytes = client
.get(&deb.browser_download_url)
.send()
.await
.context("GitHub repo API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub repo API returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
body.get("default_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
}
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.context("download .deb")?
.error_for_status()
.context(".deb download HTTP error")?
.bytes()
.await
.context("read .deb body")?;
let checksums_text = client
.get(&checksums.browser_download_url)
.send()
.await
.context("GitHub commits API")?;
if !resp.status().is_success() {
.context("download checksums")?
.error_for_status()
.context("checksums download HTTP error")?
.text()
.await
.context("read checksums body")?;
// Verify SHA256 against the checksums manifest (sha256sum format:
// "<hex>␠␠<filename>"). The filename column may include a leading
// "*" (binary mode) or a path prefix, so match on the basename.
let expected = checksums_text
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
let base = name.rsplit('/').next().unwrap_or(name);
(base == deb.name).then(|| hash.to_lowercase())
})
.next()
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
let actual = {
let mut hasher = Sha256::new();
hasher.update(&deb_bytes);
hex::encode(hasher.finalize())
};
if actual != expected {
anyhow::bail!(
"GitHub commits API returned {} for branch {}",
resp.status(),
branch
"SHA256 mismatch for {}: expected {}, got {}",
deb.name,
expected,
actual
);
}
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
body.get("sha")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
// Stage the verified .deb in /tmp (shared with the host — the
// service runs with PrivateTmp=no) and install it.
let dest = std::env::temp_dir().join(&deb.name);
tokio::fs::write(&dest, &deb_bytes)
.await
.with_context(|| format!("write {}", dest.display()))?;
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
// and `/var/lib/dpkg` are read-only *inside the service's mount
// namespace* — and a `sudo` child inherits that namespace, so a
// bare `sudo dpkg -i` fails with "Read-only file system" on the
// dpkg database. `systemd-run` asks PID 1 to launch the command in
// a fresh transient scope outside our sandbox, where the real
// (writable) host filesystem is visible. `--wait` blocks until it
// finishes and propagates the exit status; `--pipe` forwards
// dpkg's output; `--collect` reaps the unit even on failure.
//
// dpkg flags, both load-bearing for this package specifically:
// --force-confold: the fips package ships conffiles under
// /etc/fips that archipelago rewrites at install time, so dpkg
// hits an interactive "keep/replace?" conffile prompt. With our
// closed stdin that aborts the configure step ("EOF on stdin at
// conffile prompt") and leaves the package half-unpacked
// (status `iU`), which `fips.status` then reports as
// `installed:false`. confold = keep our managed config, no prompt.
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
// orders as NEWER than the stable tag `0.3.0` (a trailing
// `-dev` sorts above the bare release). Moving a dev build onto
// the stable line is therefore a dpkg "downgrade"; without this
// flag dpkg warns and exits non-zero. Our own version_is_older()
// gate already decided this is the wanted direction.
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
// other maintainer-script prompt.
let dpkg = tokio::process::Command::new("sudo")
.args([
"-n",
"systemd-run",
"--collect",
"--wait",
"--quiet",
"--pipe",
"--",
"env",
"DEBIAN_FRONTEND=noninteractive",
"dpkg",
"--force-confold",
"--force-downgrade",
"-i",
])
.arg(&dest)
.output()
.await
.context("sudo systemd-run dpkg -i failed to launch")?;
// Best-effort cleanup regardless of dpkg result.
let _ = tokio::fs::remove_file(&dest).await;
if !dpkg.status.success() {
anyhow::bail!(
"dpkg -i {} exited {}: {}",
deb.name,
dpkg.status,
String::from_utf8_lossy(&dpkg.stderr).trim()
);
}
// Restart whichever fips unit is supervising the daemon so the new
// binary takes over.
let unit = service::active_unit().await;
service::restart(unit)
.await
.with_context(|| format!("restart {} after install", unit))?;
Ok(())
}
/// `/releases/latest` returns the most recent non-prerelease, non-draft
/// release. We still re-check the flags defensively in case the endpoint
/// or repo settings change.
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("GitHub releases/latest API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
}
let release: Release = resp.json().await.context("Parse release JSON")?;
if release.draft || release.prerelease {
anyhow::bail!(
"releases/latest returned a {} release ({})",
if release.draft { "draft" } else { "prerelease" },
release.tag_name
);
}
Ok(release)
}
fn is_deb_for_arch(name: &str) -> bool {
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
}
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
// Take the first whitespace token, drop a leading 'v'.
let tok = s.split_whitespace().next().unwrap_or(s);
let tok = tok.strip_prefix('v').unwrap_or(tok);
// Split off any pre-release / build suffix.
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
Some(i) => (&tok[..i], &tok[i..]),
None => (tok, ""),
};
let mut it = core.split('.');
let major = it.next()?.parse::<u64>().ok()?;
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
let has_prerelease = rest.starts_with('-');
Some(((major, minor, patch), has_prerelease))
}
/// True when `installed` is strictly older than release tag `latest`.
/// Same numeric triple but `installed` carries a pre-release suffix while
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
/// If either side can't be parsed, fall back to "differs ⇒ update".
fn version_is_older(installed: &str, latest: &str) -> bool {
match (parse_version(installed), parse_version(latest)) {
(Some((ic, ipre)), Some((lc, lpre))) => {
if ic != lc {
ic < lc
} else {
// Equal cores: a pre-release is older than the final release.
ipre && !lpre
}
}
_ => {
// Unparseable: be conservative — offer the update unless the
// installed string already mentions the latest tag.
!installed.contains(latest.trim_start_matches('v'))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_apply_returns_clear_stub_error() {
let err = apply().await.unwrap_err().to_string();
assert!(
err.contains("not yet wired"),
"apply() should return an explicit not-yet-wired error, got: {}",
err
);
#[test]
fn test_deb_arch_maps_known() {
// On the host running tests this is whatever the test arch is;
// just assert it returns a non-empty, lowercase token.
let a = deb_arch();
assert!(!a.is_empty());
assert_eq!(a, a.to_lowercase());
}
#[test]
fn test_version_older() {
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
assert!(version_is_older("0.2.1", "v0.3.0"));
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
assert!(!version_is_older("0.3.0", "v0.3.0"));
assert!(!version_is_older("0.4.0", "v0.3.0"));
assert!(!version_is_older("0.3.1", "v0.3.0"));
}
#[test]
fn test_parse_version() {
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
}
#[test]
fn test_is_deb_for_arch() {
let arch = deb_arch();
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
assert!(!is_deb_for_arch("checksums-linux.txt"));
assert!(!is_deb_for_arch(&format!(
"fips-0.3.0-linux-{}.tar.gz",
arch
)));
}
#[test]
fn test_update_check_serialises() {
let uc = UpdateCheck {
current: Some("0.2.0-abc1234".to_string()),
latest_commit: "def5678".to_string(),
current: Some("0.3.0-dev".to_string()),
latest_version: "v0.3.0".to_string(),
update_available: true,
channel: "stable".to_string(),
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
notes: "test".to_string(),
};
let json = serde_json::to_string(&uc).unwrap();
assert!(json.contains("latest_commit"));
assert!(json.contains("latest_version"));
assert!(json.contains("update_available"));
assert!(json.contains("stable"));
}
}
+119
View File
@@ -731,6 +731,89 @@ async fn restart_container(name: &str, state: &str) -> bool {
}
}
/// ElectrumX/electrs on-disk data dir. Wiped to force a clean resync when its
/// LevelDB is detected corrupt (see `maybe_recover_corrupt_electrumx`).
const ELECTRUMX_DATA_DIR: &str = "/var/lib/archipelago/electrumx";
/// Restart attempt at which we check for — and recover from — a corrupt
/// ElectrumX database. Late enough that a transient restart won't trigger a
/// destructive resync, early enough to self-heal before MAX_RESTART_ATTEMPTS.
const ELECTRUMX_DB_RESET_ATTEMPT: u32 = 3;
fn is_electrumx(name: &str) -> bool {
let id = name.strip_prefix("archy-").unwrap_or(name);
matches!(id, "electrumx" | "electrs" | "mempool-electrs")
}
/// True when a container's logs show the specific corrupt-LevelDB signature.
/// ElectrumX exit-loops with a plyvel error when its `hist`/`utxo` LevelDB
/// loses its CURRENT/MANIFEST pointer — typically after an unclean SIGKILL
/// (e.g. the cgroup cascade on a service restart). We match the exact failure
/// so a normal restart never triggers the destructive resync below.
fn looks_like_corrupt_electrumx_db(logs: &str) -> bool {
logs.contains("create_if_missing is false")
|| logs.contains("Corruption:")
|| (logs.contains("plyvel") && logs.contains("does not exist"))
}
async fn electrumx_db_corrupt(name: &str) -> bool {
let out = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::process::Command::new("podman")
.args(["logs", "--tail", "60", name])
.output(),
)
.await;
match out {
Ok(Ok(output)) => {
let logs = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
looks_like_corrupt_electrumx_db(&logs)
}
_ => false,
}
}
/// Wipe the ElectrumX LevelDB stores so the next start resyncs from scratch.
/// Files are owned by the container's mapped UID, so removal needs host sudo.
/// The mount point itself is preserved (the container expects it to exist).
/// Returns true if the reset succeeded.
async fn reset_electrumx_data() -> bool {
let rm = format!(
"rm -rf {dir}/hist {dir}/utxo {dir}/meta {dir}/COIN",
dir = ELECTRUMX_DATA_DIR,
);
matches!(
crate::update::host_sudo(&["sh", "-c", &rm]).await,
Ok(status) if status.success()
)
}
/// Self-heal a wedged ElectrumX: if it's exit-looping on a corrupt database,
/// wipe its data dir once (at `ELECTRUMX_DB_RESET_ATTEMPT`) so the impending
/// restart resyncs cleanly. Bounded to electrs containers, gated on the exact
/// corruption signature, and fired once per failure streak — when the resync
/// stabilises the restart tracker clears, so a future corruption can heal too.
async fn maybe_recover_corrupt_electrumx(name: &str, attempt: u32) {
if attempt != ELECTRUMX_DB_RESET_ATTEMPT || !is_electrumx(name) {
return;
}
if !electrumx_db_corrupt(name).await {
return;
}
warn!(
"ElectrumX {} is exit-looping on a corrupt database — resetting its data dir to force a clean resync",
name
);
if reset_electrumx_data().await {
info!("ElectrumX data dir reset; a fresh resync will begin on restart");
} else {
warn!("Failed to reset ElectrumX data dir (host sudo rm failed) — manual recovery may be needed");
}
}
/// Spawn the health monitor background task.
pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
tokio::spawn(async move {
@@ -993,6 +1076,10 @@ pub fn spawn_health_monitor(state: Arc<StateManager>, data_dir: PathBuf) {
.unwrap_or(&90)
);
// Before restarting, self-heal a corrupt ElectrumX DB so
// the restart resyncs cleanly instead of crash-looping.
maybe_recover_corrupt_electrumx(&container.name, attempt).await;
let restarted = restart_container(&container.name, &container.state).await;
if !restarted || attempt >= MAX_RESTART_ATTEMPTS {
@@ -1471,4 +1558,36 @@ mod tests {
]
);
}
#[test]
fn is_electrumx_matches_electrs_variants_only() {
assert!(is_electrumx("electrumx"));
assert!(is_electrumx("archy-electrumx"));
assert!(is_electrumx("electrs"));
assert!(is_electrumx("mempool-electrs"));
assert!(!is_electrumx("bitcoin-knots"));
assert!(!is_electrumx("lnd"));
assert!(!is_electrumx("electrs-ui")); // the web UI, not the indexer
}
#[test]
fn corrupt_db_detection_matches_plyvel_signature() {
// The exact line ElectrumX exit-loops on (observed on .116).
let corrupt = "plyvel._plyvel.Error: b'Invalid argument: hist: does not exist (create_if_missing is false)'";
assert!(looks_like_corrupt_electrumx_db(corrupt));
assert!(looks_like_corrupt_electrumx_db(
"Corruption: bad block in hist"
));
}
#[test]
fn corrupt_db_detection_ignores_healthy_logs() {
let healthy = "INFO:BlockProcessor:our height: 117,009 daemon: 953,480 UTXOs 28MB\n\
INFO:SessionManager:RPC server listening on 0.0.0.0:8000";
assert!(!looks_like_corrupt_electrumx_db(healthy));
// "catching up" / normal restart noise must not trigger a destructive wipe.
assert!(!looks_like_corrupt_electrumx_db(
"Prefetcher:catching up to daemon height 953,480"
));
}
}
+40
View File
@@ -64,6 +64,7 @@ mod server;
mod session;
mod settings;
mod state;
mod storage_crypto;
mod streaming;
mod totp;
mod transport;
@@ -271,6 +272,15 @@ async fn main() -> Result<()> {
// delays server readiness; best-effort, warnings only.
tokio::spawn(bootstrap::ensure_doctor_installed());
// 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.
tokio::spawn(bootstrap::ensure_archipelago_mount_ordering());
// #36: keep the kiosk unit + launcher hardened (CPU/mem cap + GPU-vs-headless
// flags) on already-deployed nodes via OTA; no-op if the kiosk isn't installed.
tokio::spawn(bootstrap::ensure_kiosk_hardened());
// Spawn periodic container snapshot (for crash recovery)
crash_recovery::spawn_snapshot_task(config.data_dir.clone());
@@ -291,6 +301,31 @@ async fn main() -> Result<()> {
});
}
// Periodically restart crashed multi-container stack members (immich,
// indeedhub, …) at RUNTIME, not just at boot. The health monitor skips them
// as "orphans" because the sub-container app_ids (e.g. immich_server) aren't
// in package_data, so without this a crashed immich_server / indeedhub-api
// never comes back until the next reboot (#16/#17). Reuses the boot
// recovery, which cheaply skips already-running containers and respects the
// user-stopped list, so this only acts on genuinely-down stack members.
{
let data_dir = config.data_dir.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(120));
tick.tick().await; // consume the immediate tick; boot recovery covers t0
loop {
tick.tick().await;
let report = crash_recovery::start_stopped_stack_containers(&data_dir).await;
if report.recovered > 0 {
info!(
"🔄 Stack supervisor: restarted {} crashed stack member(s) (failed: {:?})",
report.recovered, report.failed
);
}
}
});
}
// Spawn disk space monitor (warns at 85%, auto-cleans at 90%)
disk_monitor::spawn_disk_monitor(config.data_dir.clone());
@@ -306,6 +341,11 @@ async fn main() -> Result<()> {
electrs_status::spawn_status_cache();
bitcoin_status::spawn_status_cache();
// FIPS supervisor: auto-activate FIPS after onboarding (no Activate button
// needed) and keep hole-punched paths to federation peers warm so peer dials
// land on FIPS (the preferred transport) instead of falling back to Tor.
fips::spawn_fips_supervisor(config.data_dir.clone());
let startup_ms = startup_start.elapsed().as_millis();
info!(
"Server listening on http://{} (startup: {}ms)",
+83
View File
@@ -37,6 +37,7 @@ use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
const MESH_CONTACTS_FILE: &str = "mesh-contacts.json";
/// Derive a stable synthetic `contact_id` for a federation peer from its
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
@@ -99,7 +100,17 @@ pub(crate) async fn seed_federation_peers_into_mesh(
Ok(n) => n,
Err(_) => return,
};
// Skip nodes whose onion we've already seeded: the same physical node can
// linger in the federation list under two dids (see B1/B2). Seeding both
// would create two chat contacts for one node — one by name+logo and one
// by raw did. One onion → one mesh contact.
let mut seen_onions = std::collections::HashSet::new();
for node in nodes {
let onion_key = node.onion.trim_end_matches(".onion").to_string();
if !onion_key.is_empty() && !seen_onions.insert(onion_key) {
tracing::debug!(did = %node.did, onion = %node.onion, "skipping duplicate federation node (onion already seeded)");
continue;
}
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
}
}
@@ -200,6 +211,66 @@ pub async fn save_ignored_radio_contacts(data_dir: &Path, pubkeys: &[String]) ->
Ok(())
}
/// Load persisted mesh contact customisations (alias / notes / pinned / blocked),
/// decrypting at rest with the node key and migrating any legacy plaintext file.
/// Returns an empty map on any error so a read failure never loses live state.
pub async fn load_mesh_contacts(
data_dir: &Path,
) -> std::collections::HashMap<String, listener::ContactEntry> {
let path = data_dir.join(MESH_CONTACTS_FILE);
let Ok(raw) = fs::read(&path).await else {
return std::collections::HashMap::new();
};
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
raw
} else {
match crate::storage_crypto::derive_key(
data_dir,
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
)
.await
{
Ok(k) => match crate::storage_crypto::open(&raw, &k) {
Ok(p) => p,
Err(e) => {
warn!("mesh contacts: decrypt failed ({e}); keeping in-memory state");
return std::collections::HashMap::new();
}
},
Err(_) => return std::collections::HashMap::new(),
}
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
/// Persist mesh contact customisations, encrypted at rest with the node key and
/// written atomically (temp + rename) so a crash mid-write can't corrupt them.
pub async fn save_mesh_contacts(
data_dir: &Path,
contacts: &std::collections::HashMap<String, listener::ContactEntry>,
) -> Result<()> {
fs::create_dir_all(data_dir).await.ok();
let content = serde_json::to_vec(contacts).context("Failed to serialize mesh contacts")?;
let bytes = match crate::storage_crypto::derive_key(
data_dir,
crate::storage_crypto::DOMAIN_MESH_CONTACTS,
)
.await
{
Ok(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
Err(_) => content, // no key yet (pre-onboarding) → plaintext rather than no-write
};
let path = data_dir.join(MESH_CONTACTS_FILE);
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, &bytes)
.await
.context("Failed to write mesh contacts tmp")?;
fs::rename(&tmp, &path)
.await
.context("Failed to rename mesh contacts")?;
Ok(())
}
/// Detect serial devices that could be mesh radios.
/// Checks both Meshcore (via probe) and legacy Meshtastic paths.
pub async fn detect_devices() -> Vec<String> {
@@ -294,6 +365,18 @@ impl MeshService {
}
}
// Restore persisted contact customisations (alias/notes/pinned/blocked),
// decrypted with the node key, so they survive restarts.
{
let saved = load_mesh_contacts(data_dir).await;
if !saved.is_empty() {
let mut contacts = state.contacts.write().await;
for (pk, entry) in saved {
contacts.insert(pk, entry);
}
}
}
Ok(Self {
state,
config,
+40 -1
View File
@@ -71,7 +71,7 @@ async fn build_telemetry_report(
data_dir: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
// Anonymous node ID — truncated SHA-256 hash of pubkey
let (node_id, version, container_count, running_count, peer_count, containers) =
let (node_id, node_name, version, container_count, running_count, peer_count, containers) =
if let Some(ref sm) = state {
let (data, _) = sm.get_snapshot().await;
let id = {
@@ -98,6 +98,10 @@ async fn build_telemetry_report(
.count();
(
id,
data.server_info
.name
.clone()
.filter(|n| !n.trim().is_empty()),
data.server_info.version.clone(),
data.package_data.len(),
running,
@@ -107,6 +111,7 @@ async fn build_telemetry_report(
} else {
(
"unknown".to_string(),
None,
"unknown".to_string(),
0,
0,
@@ -125,6 +130,8 @@ async fn build_telemetry_report(
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.map(|f| f as u64)
.unwrap_or(0);
let hostname = system_hostname().await;
let server_url = local_server_url(data_dir).await;
// Latest metrics snapshot
let latest = store.latest().await;
@@ -166,6 +173,9 @@ async fn build_telemetry_report(
Ok(serde_json::json!({
"node_id": node_id,
"node_name": node_name,
"hostname": hostname,
"server_url": server_url,
"version": version,
"uptime_secs": uptime_secs,
"cpu_cores": cpu_cores,
@@ -181,6 +191,35 @@ async fn build_telemetry_report(
}))
}
async fn system_hostname() -> Option<String> {
let output = tokio::process::Command::new("hostname")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let hostname = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!hostname.is_empty()).then_some(hostname)
}
async fn local_server_url(data_dir: &std::path::Path) -> Option<String> {
let _ = data_dir;
let output = tokio::process::Command::new("hostname")
.arg("-I")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let ip = String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && ip.contains('.'))?
.to_string();
Some(format!("https://{ip}"))
}
/// POST a telemetry report to the central collector.
async fn post_telemetry_report(url: &str, report: &serde_json::Value) -> anyhow::Result<()> {
let client = reqwest::Client::builder()
+69 -4
View File
@@ -43,18 +43,68 @@ fn data_path() -> &'static Mutex<Option<PathBuf>> {
PATH.get_or_init(|| Mutex::new(None))
}
/// At-rest encryption key for messages.json, derived from the node identity in
/// `init()`. `None` only if the node key is unreadable (pre-onboarding) — in
/// which case we persist plaintext rather than lose messages.
fn enc_key() -> &'static Mutex<Option<[u8; 32]>> {
static KEY: OnceLock<Mutex<Option<[u8; 32]>>> = OnceLock::new();
KEY.get_or_init(|| Mutex::new(None))
}
/// Initialize message store — load from disk. Call once at startup.
pub async fn init(data_dir: &Path) {
let path = data_dir.join("messages.json");
*data_path().lock().unwrap_or_else(|e| e.into_inner()) = Some(path.clone());
if let Ok(content) = tokio::fs::read_to_string(&path).await {
if let Ok(loaded) = serde_json::from_str::<MessageStore>(&content) {
// Derive + cache the at-rest encryption key (bound to this node's identity).
match crate::storage_crypto::derive_key(data_dir, crate::storage_crypto::DOMAIN_MESSAGES).await
{
Ok(k) => *enc_key().lock().unwrap_or_else(|e| e.into_inner()) = Some(k),
Err(e) => tracing::warn!(
"message store: encryption key unavailable ({e}); will persist plaintext"
),
}
let Ok(raw) = tokio::fs::read(&path).await else {
return; // no file yet (new node)
};
// Decrypt the on-disk blob, transparently migrating a legacy plaintext file.
let mut was_plaintext = false;
let bytes = if crate::storage_crypto::is_plaintext_json(&raw) {
was_plaintext = true;
Some(raw)
} else {
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
match key {
Some(k) => match crate::storage_crypto::open(&raw, &k) {
Ok(p) => Some(p),
Err(e) => {
tracing::error!(
"message store: decrypt failed ({e}); NOT overwriting on-disk data"
);
None
}
},
None => None,
}
};
if let Some(bytes) = bytes {
if let Ok(loaded) = serde_json::from_slice::<MessageStore>(&bytes) {
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
*guard = loaded;
tracing::info!("Loaded {} messages from disk", guard.messages.len());
}
}
// Eagerly re-write a legacy plaintext file as encrypted on first boot.
if was_plaintext
&& enc_key()
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some()
{
persist();
tracing::info!("message store: migrated plaintext messages.json to encrypted at rest");
}
}
/// Persist current messages to disk.
@@ -63,13 +113,28 @@ pub async fn init(data_dir: &Path) {
fn persist() {
let guard = store().lock().unwrap_or_else(|e| e.into_inner());
let path_guard = data_path().lock().unwrap_or_else(|e| e.into_inner());
let key = *enc_key().lock().unwrap_or_else(|e| e.into_inner());
if let Some(ref path) = *path_guard {
if let Ok(content) = serde_json::to_string(&*guard) {
if let Ok(content) = serde_json::to_vec(&*guard) {
let path = path.clone();
drop(path_guard);
drop(guard);
tokio::task::spawn(async move {
let _ = tokio::fs::write(&path, content).await;
// Encrypt at rest when the node key is available; fall back to
// plaintext rather than drop the write if it somehow isn't.
let bytes = match key {
Some(k) => crate::storage_crypto::seal(&content, &k).unwrap_or(content),
None => content,
};
// Atomic write: stage to a temp file then rename, so a crash or
// reboot mid-write can never truncate/corrupt the real history
// (rename is atomic on the same filesystem).
let tmp = path.with_extension("json.tmp");
if tokio::fs::write(&tmp, &bytes).await.is_ok() {
let _ = tokio::fs::rename(&tmp, &path).await;
} else {
let _ = tokio::fs::remove_file(&tmp).await;
}
});
}
}
+1
View File
@@ -28,6 +28,7 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback
8336, // FIPS UI (fips-ui)
];
/// Start of range for allocating web app ports when preferred is taken.
+17
View File
@@ -543,4 +543,21 @@ mod tests {
}
}
}
#[test]
fn test_node_key_known_answer_vs_python_verifier() {
// Cross-checks scripts/verify-seed-derivation.py: same mnemonic must
// produce the same node_key bytes in Rust and in the Python verifier.
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let key = derive_node_ed25519(&seed).unwrap();
assert_eq!(
hex::encode(key.to_bytes()),
"3b4f4a1450450260ae360adb9c33ea5eb86356fa14454ca0067dd4b51ea8be87"
);
let nostr = derive_node_nostr_key(&seed).unwrap();
assert_eq!(
hex::encode(nostr.secret_key().to_secret_bytes()),
"3a94fb32efab2a5025401d53fd7d82b41323a5c06ad14ce528ebe3a813d88831"
);
}
}
+52 -8
View File
@@ -15,6 +15,7 @@ use hyper::server::conn::Http;
use hyper::service::service_fn;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -28,6 +29,25 @@ pub struct Server {
_state_manager: Arc<StateManager>,
}
struct ContainerScanGuard<'a> {
scanning: &'a AtomicBool,
}
impl<'a> ContainerScanGuard<'a> {
fn try_acquire(scanning: &'a AtomicBool) -> Option<Self> {
scanning
.compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed)
.ok()
.map(|_| Self { scanning })
}
}
impl Drop for ContainerScanGuard<'_> {
fn drop(&mut self) {
self.scanning.store(false, Ordering::Release);
}
}
impl Server {
pub async fn new(
config: Config,
@@ -362,7 +382,7 @@ impl Server {
// Skip missed ticks instead of catching up — prevents burst of scans
// after a slow podman response (which causes DB lock storms)
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let scanning = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let scanning = std::sync::Arc::new(AtomicBool::new(false));
loop {
tokio::select! {
_ = interval.tick() => {}
@@ -377,13 +397,12 @@ impl Server {
continue;
}
}
if scanning.load(std::sync::atomic::Ordering::Relaxed) {
let Some(_scan_guard) = ContainerScanGuard::try_acquire(&scanning) else {
debug!("Skipping container scan — previous scan still in progress");
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
continue;
}
scanning.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = scan_and_update_packages(
};
let scan_result = scan_and_update_packages(
&scanner,
&state,
identity_clone.as_ref(),
@@ -391,8 +410,8 @@ impl Server {
&mut absence_tracker,
&mut transitional_since,
)
.await
{
.await;
if let Err(e) = scan_result {
error!("Failed to update containers: {}", e);
if is_podman_scan_timeout(&e) {
scan_backoff_until = Some(Instant::now() + Duration::from_secs(30));
@@ -402,7 +421,6 @@ impl Server {
scan_backoff_until = None;
}
scan_tick.send_modify(|n| *n = n.wrapping_add(1));
scanning.store(false, std::sync::atomic::Ordering::Relaxed);
}
});
}
@@ -751,6 +769,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without
// this the catalog 404s over the mesh and `content.browse-peer`
// fails with "Peer returned error: 404 Not Found" (and never
// falls back to Tor, since a 404 is a successful HTTP exchange).
| "/content"
)
// Prefix-matched content endpoints (peer file browse + fetch)
|| path.starts_with("/content/")
@@ -1360,6 +1385,25 @@ mod merge_tests {
}
}
#[test]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
// slash). It must be reachable over the peer (FIPS) listener, else
// `content.browse-peer` 404s over the mesh. Item fetches are
// "/content/<id>".
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
assert!(
is_peer_allowed_path("/content/abc123"),
"items must be allowed"
);
assert!(is_peer_allowed_path("/rpc/v1"));
assert!(is_peer_allowed_path("/health"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
}
#[test]
fn preserves_transitional_state_on_merge() {
// existing: user initiated a stop, spawn_transitional set Stopping.
+108
View File
@@ -0,0 +1,108 @@
//! At-rest encryption for local state stores (chat messages, mesh contacts).
//!
//! Best-practice envelope, matching `credentials::store`:
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
//! seed-derived and never leaves the device, so each store is bound to this
//! node's identity — a stolen disk image is unreadable without it, and the
//! per-domain separator means one store's key can't open another.
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
//! tamper-evident — any on-disk modification fails to open.
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
//! then re-written encrypted on the next save. No data is stranded.
use anyhow::{Context, Result};
use std::path::Path;
/// Domain separators — one per store so keys never overlap.
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
/// Derive a 32-byte key bound to this node's identity for a given store domain.
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
let node_key_path = data_dir.join("identity").join("node_key");
let key_bytes = tokio::fs::read(&node_key_path)
.await
.context("reading node key for at-rest encryption")?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(domain);
hasher.update(&key_bytes);
let mut key = [0u8; 32];
key.copy_from_slice(&hasher.finalize());
Ok(key)
}
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
let nonce_bytes: [u8; 12] = rand::random();
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
let ct = cipher
.encrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
plaintext,
)
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
let mut out = Vec::with_capacity(12 + ct.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(out)
}
/// Decrypt `nonce ‖ ciphertext`.
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
if data.len() < 12 {
anyhow::bail!("ciphertext too short");
}
let (nonce, ct) = data.split_at(12);
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
cipher
.decrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
ct,
)
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
}
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
/// reliable migration signal.
pub fn is_plaintext_json(raw: &[u8]) -> bool {
matches!(raw.first(), Some(b'{') | Some(b'['))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_round_trips() {
let key = [7u8; 32];
let msg = br#"{"messages":[{"m":"hi"}]}"#;
let sealed = seal(msg, &key).unwrap();
// Encrypted output must NOT be readable plaintext.
assert!(!is_plaintext_json(&sealed));
assert_ne!(&sealed[12..], &msg[..]);
assert_eq!(open(&sealed, &key).unwrap(), msg);
}
#[test]
fn open_fails_on_wrong_key_or_tamper() {
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
assert!(open(&sealed, &[2u8; 32]).is_err());
let mut tampered = sealed.clone();
*tampered.last_mut().unwrap() ^= 0x01;
assert!(open(&tampered, &[1u8; 32]).is_err());
}
#[test]
fn detects_plaintext_vs_ciphertext() {
assert!(is_plaintext_json(b"{\"a\":1}"));
assert!(is_plaintext_json(b"[]"));
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
}
}
+191 -51
View File
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::fs;
use tracing::{debug, info};
use tracing::{debug, info, warn};
/// Live download progress counters. Updated by download_component_resumable
/// as bytes arrive and read by the update.status RPC so the UI can show
@@ -66,10 +66,6 @@ fn is_newer(candidate: &str, current: &str) -> bool {
const DEFAULT_UPDATE_MANIFEST_URL: &str =
"http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json";
/// Secondary mirror on tx1138 gitea — independent network path so a
/// single-provider outage doesn't knock out both mirrors.
const DEFAULT_SECONDARY_MIRROR_URL: &str =
"https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json";
const UPDATE_STATE_FILE: &str = "update_state.json";
const UPDATE_MIRRORS_FILE: &str = "update-mirrors.json";
/// Marker written by apply_update() just before the service restart and
@@ -107,16 +103,10 @@ fn mirrors_path(data_dir: &Path) -> std::path::PathBuf {
}
fn default_mirrors() -> Vec<UpdateMirror> {
vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
},
UpdateMirror {
url: DEFAULT_SECONDARY_MIRROR_URL.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
]
vec![UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
}]
}
/// Load the operator-configured mirror list. Returns defaults if the
@@ -144,14 +134,17 @@ pub async fn load_mirrors(data_dir: &Path) -> Result<Vec<UpdateMirror>> {
return Ok(default_mirrors());
}
// One-time migration: the Hetzner VPS at 23.182.128.160 was
// decommissioned 2026-04-23. Existing nodes have it baked into their
// saved mirror list (was the original Server 1). Strip it on load so
// we don't spend seconds per install timing out against a dead host.
// Exception to the usual "explicit removals stick" rule: the user
// never chose to add this — it was a default.
// One-time migrations: drop decommissioned release servers that may be
// baked into existing nodes' saved mirror lists. Strip them on load so
// we don't spend seconds per install timing out against a dead/stale host.
// - 23.182.128.160: Hetzner VPS, decommissioned 2026-04-23.
// - git.tx1138.com: retired as a release server 2026-06-13 — its main
// branch had diverged and stopped receiving releases, so it only
// ever served a stale manifest as the secondary mirror.
// Exception to the usual "explicit removals stick" rule: the user never
// chose to add these — they were defaults.
let before = list.len();
list.retain(|m| !m.url.contains("23.182.128.160"));
list.retain(|m| !m.url.contains("23.182.128.160") && !m.url.contains("git.tx1138.com"));
let mut changed = list.len() != before;
// Merge in any default URLs the saved config is missing.
@@ -182,17 +175,13 @@ fn force_ovh_update_primary(list: &mut Vec<UpdateMirror>) {
for mirror in list.iter_mut() {
if mirror.url == DEFAULT_UPDATE_MANIFEST_URL {
mirror.label = "Server 1 (OVH)".to_string();
} else if mirror.url == DEFAULT_SECONDARY_MIRROR_URL {
mirror.label = "Server 2 (tx1138)".to_string();
}
}
list.sort_by_key(|m| {
if m.url == DEFAULT_UPDATE_MANIFEST_URL {
0
} else if m.url == DEFAULT_SECONDARY_MIRROR_URL {
1
} else {
2
1
}
});
}
@@ -367,12 +356,20 @@ async fn probe_frontend_once() -> Result<()> {
.context("build probe client")?;
// Prefer HTTPS since that's the failure mode we're catching (nginx
// 500 on the PWA). HTTP usually redirects to HTTPS and would mask
// the bug.
let resp = client
.get("https://127.0.0.1/")
.send()
.await
.context("probe GET https://127.0.0.1/")?;
// the bug. BUT not every node binds 443 on loopback (.116 serves
// plain HTTP; 443 there belongs to tailscale) — on a *connect*
// error, fall back to HTTP so a healthy node isn't "verified" into
// a rollback. An HTTP error status stays fatal on whichever scheme
// answered.
let resp = match client.get("https://127.0.0.1/").send().await {
Ok(resp) => resp,
Err(e) if e.is_connect() => client
.get("http://127.0.0.1/")
.send()
.await
.context("probe GET http://127.0.0.1/ (https not bound on loopback)")?,
Err(e) => return Err(e).context("probe GET https://127.0.0.1/"),
};
let status = resp.status();
if status.is_success() || status.is_redirection() {
return Ok(());
@@ -502,6 +499,8 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
.context("Reading update state")?;
let mut state: UpdateState = serde_json::from_str(&data).context("Parsing update state")?;
let mut changed = false;
// Keep current_version in sync with the binary. Sideloaded nodes
// (ssh + cp /usr/local/bin/archipelago) don't touch the state file,
// so without this the running 1.7.0-alpha binary would keep seeing
@@ -517,11 +516,43 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
// if there's genuinely something newer.
state.available_update = None;
state.manifest_mirror = None;
changed = true;
}
// `update_in_progress` means a manifest OTA is downloaded and staged,
// ready for apply. Older git/self-build update paths could leave this
// flag stuck true without a staging directory, which traps the UI in an
// unrecoverable state. Heal that on every state load.
if state.update_in_progress && !has_staged_update(data_dir).await {
warn!(
staging = %data_dir.join("update-staging").display(),
"Clearing stale update_in_progress without staged OTA files"
);
state.update_in_progress = false;
changed = true;
}
if changed {
save_state(data_dir, &state).await?;
}
Ok(state)
}
/// Marker written only after EVERY component has downloaded and verified.
/// Distinguishes a complete, install-ready staging from the partial files a
/// resumable-but-failed download leaves behind.
const STAGED_COMPLETE_MARKER: &str = ".download-complete";
async fn has_staged_update(data_dir: &Path) -> bool {
// A *complete* staged update carries the marker. A partial/failed download
// leaves component files (kept for resume) but no marker, so it reads as
// "not staged" — the state self-heal then clears update_in_progress and the
// UI returns to Download instead of stranding the user on Install.
fs::metadata(data_dir.join("update-staging").join(STAGED_COMPLETE_MARKER))
.await
.is_ok()
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
let path = data_dir.join(UPDATE_STATE_FILE);
let data = serde_json::to_string_pretty(state)?;
@@ -728,10 +759,15 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
.context("Failed to create staging dir")?;
let client = reqwest::Client::builder()
// Per-request budget; each attempt gets the full hour. A retry
// restarts the budget cleanly.
.timeout(std::time::Duration::from_secs(3600))
.connect_timeout(std::time::Duration::from_secs(30))
// Per-request budget; each attempt gets the full window, and a retry
// resumes via Range from the partial file (download_component_resumable),
// so this is an upper bound per attempt, not the whole download. Sized
// generously for slow machines on slow links: a ~200MB release at a
// crawling ~50KB/s is ~70min, which the old 1h budget could cut off
// mid-attempt. 3h leaves ample headroom; raising it cannot slow down or
// break a fast download (those finish well inside the old limit).
.timeout(std::time::Duration::from_secs(10800))
.connect_timeout(std::time::Duration::from_secs(60))
.build()
.context("Failed to create HTTP client")?;
@@ -772,7 +808,10 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
);
}
// Mark update as downloaded
// Mark update as downloaded. Write the completion marker FIRST so a crash
// between the two can't leave update_in_progress=true without the marker
// (which the self-heal would then clear, harmlessly forcing a re-download).
let _ = fs::write(staging_dir.join(STAGED_COMPLETE_MARKER), b"1").await;
let mut state = load_state(data_dir).await?;
state.update_in_progress = true;
save_state(data_dir, &state).await?;
@@ -1051,6 +1090,17 @@ pub async fn apply_update(data_dir: &Path) -> Result<()> {
let current_binary = Path::new("/usr/local/bin/archipelago");
if current_binary.exists() {
let backup_path = backup_dir.join("archipelago");
// A leftover backup from an earlier rollback can be root-owned
// (rollback used to chown it in place), and fs::copy O_TRUNCs the
// existing file — EACCES as the service user, wedging every apply
// (seen on .116, v1.7.86 OTA). Unlink first; the dir is
// service-owned so unlink works even when the file isn't ours.
if backup_path.exists() {
if let Err(e) = fs::remove_file(&backup_path).await {
tracing::warn!(error = %e, "unlink of stale binary backup failed, retrying via host_sudo");
let _ = host_sudo(&["rm", "-f", &backup_path.to_string_lossy()]).await;
}
}
fs::copy(current_binary, &backup_path)
.await
.context("Failed to backup current binary")?;
@@ -1388,21 +1438,38 @@ pub async fn rollback_update(data_dir: &Path) -> Result<()> {
let backup_binary = backup_dir.join("archipelago");
if backup_binary.exists() {
// Use host_sudo + mv so we escape the archipelago service's
// ProtectSystem=strict mount namespace. A plain fs::copy or
// `sudo cp` from inside the service hits EROFS on /usr/local/bin,
// which would silently orphan the rollback — exactly the
// opposite of what auto-rollback is for. Pattern matches
// apply_update()'s binary swap above.
// Same two namespace gotchas as apply_update()'s binary swap:
// `cp` straight onto the running binary is O_TRUNC and fails
// ETXTBSY (exit 1 — exactly what broke the .116 rollback), and
// plain sudo inherits ProtectSystem=strict, so everything goes
// through host_sudo. Copy to a temp name on the same filesystem,
// fix ownership on the TEMP file (never the stored backup — an
// in-place chown is what later wedged apply_update), then mv,
// which is an atomic rename and tolerates a busy destination.
let backup_str = backup_binary.to_string_lossy().to_string();
let _ = host_sudo(&["chmod", "0755", &backup_str]).await;
let _ = host_sudo(&["chown", "root:root", &backup_str]).await;
let status = host_sudo(&["cp", &backup_str, "/usr/local/bin/archipelago"])
let tmp = format!(
"/usr/local/bin/.archipelago.rollback.{}",
chrono::Utc::now().timestamp_millis()
);
let copy = host_sudo(&["cp", &backup_str, &tmp])
.await
.context("Failed to stage backup binary via host_sudo")?;
if !copy.success() {
anyhow::bail!(
"cp backup binary to {} failed (exit {:?})",
tmp,
copy.code()
);
}
let _ = host_sudo(&["chmod", "0755", &tmp]).await;
let _ = host_sudo(&["chown", "root:root", &tmp]).await;
let status = host_sudo(&["mv", &tmp, "/usr/local/bin/archipelago"])
.await
.context("Failed to restore backup binary via host_sudo")?;
if !status.success() {
let _ = host_sudo(&["rm", "-f", &tmp]).await;
anyhow::bail!(
"cp backup binary into /usr/local/bin failed (exit {:?})",
"mv backup binary into /usr/local/bin failed (exit {:?})",
status.code()
);
}
@@ -1450,9 +1517,26 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
// Check every hour; act based on schedule setting
let mut tick = interval(Duration::from_secs(3600));
// Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick.
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
debug!(
"Update scheduler: initial app-catalog refresh failed: {}",
e
);
}
loop {
tick.tick().await;
// App-catalog refresh is INDEPENDENT of the OTA schedule below: it only
// populates per-app update availability (the "Update" button still has
// to be clicked — nothing auto-applies). Best-effort; on failure the
// previously cached catalog stays in place (origin-always-wins).
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
debug!("Update scheduler: app-catalog refresh failed: {}", e);
}
let state = match load_state(&data_dir).await {
Ok(s) => s,
Err(e) => {
@@ -1620,9 +1704,38 @@ mod tests {
async fn test_load_mirrors_returns_defaults_when_absent() {
let dir = tempfile::tempdir().unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert_eq!(list.len(), 2);
assert_eq!(list.len(), 1);
assert!(list[0].url.contains("146.59.87.168"));
assert!(list[1].url.contains("git.tx1138.com"));
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"tx1138 was retired as a release server and must not be a default mirror"
);
}
#[tokio::test]
async fn test_load_mirrors_strips_retired_tx1138_mirror() {
// A node that was running before tx1138 was retired has it baked
// into its saved mirror list. load_mirrors must strip it on load.
let dir = tempfile::tempdir().unwrap();
let saved = vec![
UpdateMirror {
url: DEFAULT_UPDATE_MANIFEST_URL.to_string(),
label: "Server 1 (OVH)".to_string(),
},
UpdateMirror {
url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/manifest.json"
.to_string(),
label: "Server 2 (tx1138)".to_string(),
},
];
save_mirrors(dir.path(), &saved).await.unwrap();
let list = load_mirrors(dir.path()).await.unwrap();
assert!(
!list.iter().any(|m| m.url.contains("git.tx1138.com")),
"retired tx1138 mirror should be stripped on load; got {:?}",
list
);
assert!(list.iter().any(|m| m.url.contains("146.59.87.168")));
}
#[tokio::test]
@@ -1636,7 +1749,7 @@ mod tests {
let back = load_mirrors(dir.path()).await.unwrap();
// load_mirrors merges in any missing default mirrors so a node
// that explicitly added a single custom mirror still gets the
// built-in OVH + tx1138 fallbacks. The custom mirror is preserved.
// built-in OVH default. The custom mirror is preserved.
assert!(
back.iter().any(|m| m.url == "https://example.com/m.json"),
"custom mirror should round-trip; got {:?}",
@@ -1764,6 +1877,17 @@ mod tests {
#[tokio::test]
async fn test_save_and_load_state_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let staging = dir.path().join("update-staging");
tokio::fs::create_dir_all(&staging).await.unwrap();
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
// A *complete* staged update carries the marker; without it the state
// self-heal correctly treats this as a partial download and clears
// update_in_progress (see has_staged_update / #26).
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@@ -1800,6 +1924,22 @@ mod tests {
assert!(loaded.available_update.is_none());
}
#[tokio::test]
async fn test_load_state_clears_stale_in_progress_without_staging() {
let dir = tempfile::tempdir().unwrap();
let state = UpdateState {
update_in_progress: true,
..UpdateState::default()
};
save_state(dir.path(), &state).await.unwrap();
let loaded = load_state(dir.path()).await.unwrap();
assert!(!loaded.update_in_progress);
let persisted = load_state(dir.path()).await.unwrap();
assert!(!persisted.update_in_progress);
}
#[tokio::test]
async fn test_dismiss_update_clears_available() {
let dir = tempfile::tempdir().unwrap();
+15 -2
View File
@@ -858,6 +858,11 @@ pub struct HostFacts {
/// `/` if the data partition is not yet mounted). Drives the
/// prune-vs-full-node decision in bitcoin-knots custom_args.
pub disk_gb: u64,
/// Container name of the running Bitcoin node — `bitcoin-knots` or
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
/// right host. Both are reachable on archy-net by their container name;
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
pub bitcoin_host: String,
}
impl HostFacts {
@@ -868,13 +873,14 @@ impl HostFacts {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-knots".to_string(),
}
}
}
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
/// with `HostFacts`. Centralized so validation and rendering agree.
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"];
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
@@ -957,7 +963,8 @@ impl ContainerConfig {
.template
.replace("{{HOST_IP}}", &facts.host_ip)
.replace("{{HOST_MDNS}}", &facts.host_mdns)
.replace("{{DISK_GB}}", &facts.disk_gb.to_string());
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
format!("{}={}", e.key, value)
})
.collect()
@@ -1463,6 +1470,10 @@ app:
key: "INFO".to_string(),
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
},
DerivedEnv {
key: "CORE_RPC_HOST".to_string(),
template: "{{BITCOIN_HOST}}".to_string(),
},
],
secret_env: vec![],
data_uid: None,
@@ -1471,11 +1482,13 @@ app:
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-core".to_string(),
};
let out = c.resolve_derived_env(&facts);
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
assert_eq!(out[1], "INFO=192.168.1.116-2000");
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
}
struct MapSecretsProvider {
-1
View File
@@ -123,7 +123,6 @@ impl PodmanClient {
"immich_server" | "immich" => "http://localhost:2283",
"nginx-proxy-manager" => "http://localhost:8081",
"fedimint-gateway" => "http://localhost:8176",
"dwn" => "http://localhost:3100",
"endurain" => "http://localhost:8080",
"netbird" => "http://localhost:8087",
"electrs" | "archy-electrs-ui" => "http://localhost:50002",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 869 KiB

+15 -1
View File
@@ -5,10 +5,24 @@ server {
proxy_intercept_errors on;
error_page 500 502 503 504 = @wait_page;
# Serve our own wait-page/icon assets locally first, but fall back to the
# real fedimint guardian (:8177) for ITS bundled /assets/*.css|js. Without
# the fallback, the guardian UI's stylesheets resolve to this local root,
# 404, and the app renders unstyled (B13 fixed the local icon; this fixes
# the guardian UI's own CSS).
location /assets/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
try_files $uri @guardian_assets;
}
location @guardian_assets {
proxy_pass http://127.0.0.1:8177;
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;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
+19
View File
@@ -0,0 +1,19 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
#
# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the
# browser's own archipelago session — there is NO per-node secret to
# substitute, so (unlike bitcoin-ui) the nginx config is baked straight
# into the image rather than bind-mounted/rendered at container-create.
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 8336
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]
+478
View File
@@ -0,0 +1,478 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FIPS Mesh</title>
<style>
:root {
--bg: #0e1116;
--panel: #161b22;
--panel-2: #1c232c;
--border: #2a323d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #2f81f7;
--ok: #2ea043;
--warn: #d29922;
--bad: #f85149;
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
.wrap { max-width: 860px; margin: 0 auto; padding: 24px 18px 64px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
header h1 { font-size: 20px; margin: 0; font-weight: 600; }
.sub { color: var(--muted); margin: 0 0 20px; font-size: 13px; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
margin-bottom: 16px;
}
.card h2 {
font-size: 13px; text-transform: uppercase; letter-spacing: .04em;
color: var(--muted); margin: 0 0 14px; font-weight: 600;
}
.row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; gap: 12px; }
.row + .row { border-top: 1px solid var(--border); }
.row .k { color: var(--muted); }
.row .v { font-variant-numeric: tabular-nums; text-align: right; word-break: break-all; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600;
border: 1px solid transparent;
}
.pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.pill.ok { color: var(--ok); background: rgba(46,160,67,.12); }
.pill.warn { color: var(--warn); background: rgba(210,153,34,.12); }
.pill.bad { color: var(--bad); background: rgba(248,81,73,.12); }
.pill.muted { color: var(--muted); background: rgba(139,148,158,.12); }
.btns { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 14px;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--bad); }
button:disabled { opacity: .5; cursor: default; }
input, select {
font: inherit; background: var(--bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; width: 100%;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid .full { grid-column: 1 / -1; }
label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.anchor-item { padding: 10px 0; }
.anchor-item + .anchor-item { border-top: 1px solid var(--border); }
.anchor-item .top { display: flex; justify-content: space-between; gap: 10px; align-items: baseline; }
.anchor-item .addr { color: var(--muted); font-size: 12px; word-break: break-all; }
.anchor-item .npub { font-size: 12px; color: var(--muted); word-break: break-all; }
.notice { padding: 10px 12px; border-radius: 8px; font-size: 13px; margin-top: 10px; display: none; }
.notice.show { display: block; }
.notice.info { background: rgba(47,129,247,.12); color: var(--accent); }
.notice.good { background: rgba(46,160,67,.12); color: var(--ok); }
.notice.error { background: rgba(248,81,73,.12); color: var(--bad); }
.spin { display: inline-block; width: 13px; height: 13px; border: 2px solid currentColor;
border-right-color: transparent; border-radius: 50%; animation: r .7s linear infinite; vertical-align: -2px; }
@keyframes r { to { transform: rotate(360deg); } }
.muted-note { color: var(--muted); font-size: 12px; margin-top: 8px; }
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>FIPS Mesh</h1>
<span id="anchorPill" class="pill muted">Loading…</span>
</header>
<p class="sub">Encrypted mesh transport. This node reaches the network through seed anchors; a connected anchor keeps FIPS routing fast instead of degrading to Tor.</p>
<div class="card">
<h2>Status</h2>
<div class="row"><span class="k">Daemon installed</span><span class="v" id="sInstalled"></span></div>
<div class="row"><span class="k">Version</span><span class="v" id="sVersion"></span></div>
<div class="row"><span class="k">Service</span><span class="v" id="sService"></span></div>
<div class="row"><span class="k">Seed key present</span><span class="v" id="sKey"></span></div>
<div class="row"><span class="k">Authenticated peers</span><span class="v" id="sPeers"></span></div>
<div class="row"><span class="k">Anchor connected</span><span class="v" id="sAnchor"></span></div>
<div class="row"><span class="k">This node's npub</span><span class="v" id="sNpub"></span></div>
<div class="btns">
<button id="btnRefresh">Refresh</button>
<button id="btnReconnect">Reconnect</button>
<button id="btnRestart">Restart daemon</button>
<button id="btnInstall">Install / repair</button>
</div>
<div id="actionNotice" class="notice"></div>
</div>
<div class="card">
<h2>This node as an anchor</h2>
<p class="muted-note" style="margin-top:0">Share these with another node's operator so they can add this node as a seed anchor (their <em>Seed Anchors → Add</em> form). The address is whatever host you reached this dashboard at, so it's reachable the same way you got here.</p>
<div class="row">
<span class="k">npub</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownNpub" style="font-size:12px"></code>
<button data-copy="ownNpub">Copy</button>
</span>
</div>
<div class="row">
<span class="k">Address</span>
<span class="v" style="display:flex;gap:8px;align-items:center">
<code id="ownAddr" style="font-size:12px"></code>
<button data-copy="ownAddr">Copy</button>
</span>
</div>
<div id="ownReach" class="muted-note"></div>
<div id="copyNotice" class="notice"></div>
</div>
<div class="card">
<h2>Updates · stable channel</h2>
<div class="row"><span class="k">Installed</span><span class="v" id="uCurrent"></span></div>
<div class="row"><span class="k">Latest stable</span><span class="v" id="uLatest"></span></div>
<div class="row"><span class="k">Status</span><span class="v" id="uStatus"></span></div>
<div class="btns">
<button id="btnCheck">Check for updates</button>
<button id="btnApply" class="primary" disabled>Apply update</button>
</div>
<div id="updateNotice" class="notice"></div>
<p class="muted-note">Updates download the signed <code>.deb</code> from the upstream <code>jmcorgan/fips</code> releases, verify its SHA-256 against the published checksums, install it, and restart the daemon.</p>
</div>
<div class="card">
<h2>Seed Anchors</h2>
<div id="anchorList"><p class="muted-note">Loading…</p></div>
<div style="margin-top:14px">
<div class="grid">
<div class="full"><label>npub</label><input id="aNpub" placeholder="npub1…" autocomplete="off" spellcheck="false" /></div>
<div><label>Address (host:port)</label><input id="aAddr" placeholder="192.168.1.116:8668" autocomplete="off" spellcheck="false" /></div>
<div><label>Transport</label><select id="aTransport"><option value="udp">udp</option><option value="tcp">tcp</option></select></div>
<div class="full"><label>Label (optional)</label><input id="aLabel" placeholder="Home anchor" autocomplete="off" /></div>
</div>
<div class="btns">
<button id="btnAddAnchor" class="primary">Add anchor</button>
<button id="btnApplyAnchors">Re-apply all</button>
</div>
<div id="anchorNotice" class="notice"></div>
</div>
</div>
</div>
<script>
const RPC_ENDPOINT = '/rpc/v1';
function cookieValue(name) {
return document.cookie
.split('; ')
.find(row => row.startsWith(`${name}=`))
?.split('=').slice(1).join('=') || '';
}
async function rpc(method, params = {}) {
const headers = { 'Content-Type': 'application/json' };
const csrf = cookieValue('csrf_token');
if (csrf) headers['X-CSRF-Token'] = decodeURIComponent(csrf);
const res = await fetch(RPC_ENDPOINT, {
method: 'POST',
headers,
credentials: 'include',
cache: 'no-store',
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) {
throw new Error(body.error?.message || `RPC ${method} failed (${res.status})`);
}
return body.result;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
}
function setText(id, v, fallback = '—') {
const el = document.getElementById(id);
if (el) el.textContent = (v === null || v === undefined || v === '') ? fallback : v;
}
function pill(state, text) {
return `<span class="pill ${state}">${escapeHtml(text)}</span>`;
}
function setHtml(id, html) {
const el = document.getElementById(id);
if (el) el.innerHTML = html;
}
function notice(id, kind, msg) {
const el = document.getElementById(id);
if (!el) return;
if (!msg) { el.className = 'notice'; el.textContent = ''; return; }
el.className = `notice show ${kind}`;
el.innerHTML = msg;
}
function busy(btn, on, label) {
if (!btn) return;
if (on) {
btn.dataset.label = btn.dataset.label || btn.textContent;
btn.disabled = true;
btn.innerHTML = `<span class="spin"></span> ${escapeHtml(label || btn.dataset.label)}`;
} else {
btn.disabled = false;
btn.textContent = btn.dataset.label || btn.textContent;
}
}
function renderStatus(s) {
setText('sInstalled', s.installed ? 'Yes' : 'No');
setText('sVersion', s.version);
const active = s.service_active;
setHtml('sService', active
? pill('ok', s.service_state || 'active')
: pill('bad', s.service_state || 'inactive'));
setHtml('sKey', s.key_present ? pill('ok', 'present') : pill('warn', 'missing'));
const peers = s.authenticated_peer_count || 0;
setHtml('sPeers', peers > 0 ? pill('ok', String(peers)) : pill('warn', '0 — isolated'));
setHtml('sAnchor', s.anchor_connected ? pill('ok', 'connected') : pill('bad', 'unreachable'));
setText('sNpub', s.npub);
if (s.npub) document.getElementById('ownNpub').textContent = s.npub;
const ap = document.getElementById('anchorPill');
if (s.anchor_connected) { ap.className = 'pill ok'; ap.textContent = 'Anchor connected'; }
else if (peers > 0) { ap.className = 'pill warn'; ap.textContent = 'Peers, no anchor'; }
else if (s.service_active) { ap.className = 'pill bad'; ap.textContent = 'Isolated'; }
else { ap.className = 'pill muted'; ap.textContent = 'Daemon down'; }
}
async function loadStatus() {
try {
const s = await rpc('fips.status');
renderStatus(s);
setText('uCurrent', s.version, 'not installed');
} catch (e) {
notice('actionNotice', 'error', escapeHtml(e.message));
}
}
function renderAnchors(list) {
if (!list || list.length === 0) {
setHtml('anchorList', '<p class="muted-note">No seed anchors configured. Add one below so this node can reach the mesh.</p>');
return;
}
const html = list.map(a => `
<div class="anchor-item">
<div class="top">
<strong>${escapeHtml(a.label || a.address)}</strong>
<button class="danger" data-remove="${escapeHtml(a.npub)}">Remove</button>
</div>
<div class="addr">${escapeHtml(a.address)} · ${escapeHtml(a.transport || 'udp')}</div>
<div class="npub">${escapeHtml(a.npub)}</div>
</div>`).join('');
setHtml('anchorList', html);
document.querySelectorAll('[data-remove]').forEach(btn => {
btn.addEventListener('click', () => removeAnchor(btn.dataset.remove, btn));
});
}
async function loadAnchors() {
try {
const r = await rpc('fips.list-seed-anchors');
renderAnchors(r.seed_anchors);
} catch (e) {
setHtml('anchorList', `<p class="muted-note">${escapeHtml(e.message)}</p>`);
}
}
async function removeAnchor(npub, btn) {
busy(btn, true, 'Removing');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.remove-seed-anchor', { npub });
renderAnchors(r.seed_anchors);
notice('anchorNotice', 'good', 'Anchor removed.');
} catch (e) {
notice('anchorNotice', 'error', escapeHtml(e.message));
busy(btn, false);
}
}
// --- wire up buttons ---
document.getElementById('btnRefresh').addEventListener('click', loadStatus);
document.getElementById('btnReconnect').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Reconnecting…');
notice('actionNotice', 'info', 'Restarting the daemon and waiting for an anchor — this takes about 20 seconds…');
try {
const r = await rpc('fips.reconnect');
renderStatus(r.after);
const kind = r.after.anchor_connected ? 'good' : 'error';
notice('actionNotice', kind, `${escapeHtml(r.hint || r.likely_cause)}`);
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnRestart').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Restarting…');
notice('actionNotice', '', '');
try {
const r = await rpc('fips.restart');
notice('actionNotice', 'good', `Restarted ${escapeHtml(r.unit || 'fips service')}.`);
await loadStatus();
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnInstall').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Installing…');
notice('actionNotice', '', '');
try {
const s = await rpc('fips.install');
renderStatus(s);
notice('actionNotice', 'good', 'Config and key re-materialised; service activated.');
} catch (err) {
notice('actionNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnCheck').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Checking…');
notice('updateNotice', '', '');
const applyBtn = document.getElementById('btnApply');
try {
const c = await rpc('fips.check-update');
setText('uCurrent', c.current, 'not installed');
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
applyBtn.disabled = !c.update_available;
notice('updateNotice', c.update_available ? 'info' : 'good', escapeHtml(c.notes || ''));
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApply').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Updating…');
notice('updateNotice', 'info', 'Downloading, verifying, and installing the new FIPS daemon, then restarting it…');
try {
await rpc('fips.apply-update');
notice('updateNotice', 'good', 'Update installed and daemon restarted.');
btn.disabled = true;
await loadStatus();
await rpc('fips.check-update').then(c => {
setText('uLatest', c.latest_version);
setHtml('uStatus', c.update_available ? pill('warn', 'update available') : pill('ok', 'up to date'));
}).catch(() => {});
} catch (err) {
notice('updateNotice', 'error', escapeHtml(err.message));
busy(btn, false);
}
});
document.getElementById('btnAddAnchor').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const npub = document.getElementById('aNpub').value.trim();
const address = document.getElementById('aAddr').value.trim();
const transport = document.getElementById('aTransport').value;
const label = document.getElementById('aLabel').value.trim();
if (!npub.startsWith('npub1')) { notice('anchorNotice', 'error', 'npub must start with npub1…'); return; }
if (!address.includes(':')) { notice('anchorNotice', 'error', 'Address must be host:port (e.g. 192.168.1.116:8668).'); return; }
busy(btn, true, 'Adding…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.add-seed-anchor', { npub, address, transport, label });
renderAnchors(r.seed_anchors);
const applied = (r.apply || []).find(x => x.npub === npub);
const ok = applied ? applied.ok : true;
notice('anchorNotice', ok ? 'good' : 'info',
ok ? 'Anchor added and pushed to the running daemon.' : `Anchor saved. Daemon push: ${escapeHtml(applied?.message || 'pending')}`);
['aNpub','aAddr','aLabel'].forEach(id => document.getElementById(id).value = '');
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
document.getElementById('btnApplyAnchors').addEventListener('click', async (e) => {
const btn = e.currentTarget;
busy(btn, true, 'Applying…');
notice('anchorNotice', '', '');
try {
const r = await rpc('fips.apply-seed-anchors');
const okCount = (r.results || []).filter(x => x.ok).length;
notice('anchorNotice', 'good', `Re-applied ${okCount}/${r.applied || 0} anchors to the daemon.`);
await loadStatus();
} catch (err) {
notice('anchorNotice', 'error', escapeHtml(err.message));
} finally {
busy(btn, false);
}
});
// This node's own anchor address: the host the operator reached the
// dashboard at is, by definition, reachable for them — so it's the
// right dial hint. FIPS listens on UDP 8668.
const FIPS_PORT = 8668;
function isPrivateLan(host) {
return /^10\./.test(host)
|| /^192\.168\./.test(host)
|| /^172\.(1[6-9]|2\d|3[01])\./.test(host)
|| host === 'localhost' || host === '127.0.0.1';
}
function populateOwnAnchor() {
const host = window.location.hostname;
const addr = `${host}:${FIPS_PORT}`;
document.getElementById('ownAddr').textContent = addr;
const reach = document.getElementById('ownReach');
if (/^100\./.test(host)) {
reach.innerHTML = 'This is a Tailscale address — reachable by any node on your tailnet, including over the internet.';
} else if (isPrivateLan(host)) {
reach.innerHTML = '⚠ This is a private LAN address — it only works for nodes on the same local network. For a node across the internet, share this nodes Tailscale (100.x) or public IP with UDP 8668 reachable, or have both nodes use a common public anchor instead.';
} else {
reach.innerHTML = 'This looks like a public address — reachable over the internet if UDP 8668 is open/forwarded to this node.';
}
}
document.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', async () => {
const text = document.getElementById(btn.dataset.copy)?.textContent || '';
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
notice('copyNotice', 'good', 'Copied.');
setTimeout(() => notice('copyNotice', '', ''), 1500);
} catch {
notice('copyNotice', 'error', `Copy failed — select manually: ${escapeHtml(text)}`);
}
});
});
// initial load
populateOwnAnchor();
loadStatus();
loadAnchors();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
server {
listen 8336;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy archipelago RPC same-origin so the browser never makes a
# cross-origin request (no CORS needed). The FIPS app is served on
# this node's :8336; cookies are scoped by host (not port), so the
# browser already carries the `session` (HttpOnly) and `csrf_token`
# cookies set by the main UI on :80. We forward both, plus the
# X-CSRF-Token header the app derives from the readable csrf_token
# cookie, to the backend RPC on 127.0.0.1:5678.
#
# Unlike bitcoin-ui this config is fully static (baked into the
# image) there is no upstream secret to substitute; the browser's
# own archipelago session is the credential.
location /rpc/v1 {
proxy_pass http://127.0.0.1:5678/rpc/v1;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Cookie $http_cookie;
proxy_set_header X-CSRF-Token $http_x_csrf_token;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 60s;
add_header Cache-Control "no-store";
}
location / {
try_files $uri $uri/ /index.html;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

After

Width:  |  Height:  |  Size: 510 KiB

+17 -2
View File
@@ -1,6 +1,6 @@
# 1.8-alpha Improvements Tracker
Last updated: 2026-06-11 00:17 EDT
Last updated: 2026-06-12 01:15 EDT
This tracks the user-facing improvement list that must land with the `1.8-alpha`
container migration release and the next ISO cut produced from that release. It
@@ -116,6 +116,19 @@ header gap; and removed the My Apps desktop category dropdown. Focused
Marketplace/App config tests, type-check, and scoped `git diff --check` passed.
Browser smoke against the already-running local Vite/mock session is still next.
Active-session update, 2026-06-12 01:15 EDT: system update UX hardening landed
locally. `load_state()` now clears stale `update_in_progress` when no staged OTA
files exist, so failed legacy update attempts cannot leave the update screen
permanently stuck. Direct `update.git-apply` is gated behind
`ARCHIPELAGO_GIT_UPDATES`, preventing production nodes from accidentally entering
the local git/self-build path that requires `cargo`. `.116` was recovered from a
failed self-build attempt by applying its already-staged manifest OTA; it is now
on `1.7.84-alpha`, backend health is OK, nginx is active/config-valid, HTTP UI
returns `200`, `update_in_progress=false`, and staging was removed. Validation:
`cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`
passed; focused `cargo test` was blocked by a local `rust-lld` undefined hidden
symbol linker failure unrelated to the updater patch.
Done criteria for this tracker:
- Code/UI items: implemented, covered by targeted test or manual smoke check,
@@ -148,6 +161,7 @@ Done criteria for this tracker:
| Fix BTCPay issue from desktop file "BTCPay Issues" | blocked | Need file contents or path to that desktop artifact. |
| Check Nostr Discoverable Nodes and get it working correctly | in-progress | Discover modal now keeps discovered rows visible during relay refresh/failure and shows `Searching relays...` instead of dropping to an empty state. Covered by `DiscoverModal.test.ts`, local type-check, and `git diff --check`. Needs live relay/trust validation before marking done. |
| Make sure update password is working properly | done | Backend now returns separate SSH update status so a successful web password change is not reported as a full failure when optional SSH password update fails. Settings modal shows success plus SSH warning and stays open for review. Covered by local type-check, focused modal/RPC tests, auth unit test, `cargo check -p archipelago`, and `git diff --check`. |
| Prevent System Update screen from getting permanently stuck | done | Update state loading now reconciles `update_in_progress` with the actual manifest OTA staging directory and clears stale stuck state when no staged files exist. Direct git/self-build apply is disabled unless `ARCHIPELAGO_GIT_UPDATES` is explicitly set, so production nodes cannot fall into the old `self-update.sh` path that requires local `cargo`. `.116` was recovered by applying its valid staged manifest OTA and verified on `1.7.84-alpha` with backend health OK, nginx active/config-valid, HTTP UI `200`, `update_in_progress=false`, and staging removed. Validated locally with `cargo fmt --check`, `cargo check -p archipelago`, and scoped `git diff --check`; focused `cargo test` was blocked by a local `rust-lld` linker artifact failure unrelated to the updater patch. |
| Do UI performance and general performance improvements | todo | Needs profiling target; start with obvious loading/render issues. |
| Make sure companion app is all working well, had issues with tab apps | in-progress | Mobile app-session now keeps apps that require a new tab inside the session fallback instead of auto-opening an external tab and closing immediately. Covered by `AppSessionMobileNewTab.test.ts`, existing app-session config tests, app launcher tests, local type-check, and `git diff --check`. Broader companion smoke test still needed before marking done. |
| Even though performance is better, on reboot/restart backend/update show checking-containers notification instead of no apps | done | My Apps now shows a dedicated `Checking containers` card when initial backend data has loaded but `server-info.status-info.containers-scanned` is still false and no apps are ready to render, instead of falling through to the no-apps empty state. A follow-up UI pass preserves the last known app list when a later scanner/backoff update reports an empty package map with `containers-scanned=false`, and shows a refresh status banner above the grid. Validated by local type-check, targeted tests, and `git diff --check`; follow-up validation passed `npm test -- --run src/views/apps/__tests__/appPackageCache.test.ts` and `npm run type-check`. |
@@ -194,7 +208,7 @@ Done criteria for this tracker:
| Work on setup screens function and flows | in-progress | Onboarding setup choice now shows only usable paths: Fresh Start and Restore from Seed. Removed the disabled `Connect Existing (Coming Soon)` option, and covered default Fresh routing plus Restore routing with `OnboardingOptions.test.ts`; `useOnboarding.test.ts`, local type-check, and `git diff --check` passed. Broader onboarding/setup audit still needed before marking done. |
| Work on Easy Mode experience | in-progress | Easy Mode goal configure steps now route to their owning app/screen instead of silently completing without navigation; verify steps now expose a `Check & Continue` action; configure/info/verify actions start goal progress before completing the active step. Covered by `goalStepActions.test.ts`, existing goal store tests, local type-check, and `git diff --check`. Broader Easy Mode product scope still needed before marking done. |
| Update My Apps homescreen to show most-used apps instead of hardcoded | done | App launches are recorded locally through the app launcher, and the Home My Apps card now shows the top three installed user apps by launch count/recency with a running-app/name fallback when there is no history. Covered by `appUsage.test.ts`, existing app launcher tests, local type-check, targeted tests, and `git diff --check`. |
| Improve Full Archive Node dependent apps UX | todo | Already partly represented by Bitcoin-pruned install block; needs broader dependency UX. |
| Improve Full Archive Node dependent apps UX | in-progress | Electrum-style apps already block install on pruned Bitcoin nodes; Marketplace/App Store cards now surface an inline warning that a full archive Bitcoin node is required instead of only showing a terse `Bitcoin Pruned` button. Covered by `MarketplaceAppCard.test.ts` and local type-check. Broader dependency UX remains. |
| Fix incorrect modals that are wrong color and are not full-screen overlay | done | Custom Teleport modals that still used the old light `bg-black/10` overlay now use the same full-screen `bg-black/60` overlay treatment as BaseModal/newer modals. Verified no fixed modal overlays retain `bg-black/10`; validated by local type-check, targeted tests, and `git diff --check`. |
| Prevent modals from allowing background scroll | done | Added shared scroll-lock composable, root-level body lock, wheel/touch containment, and explicit dashboard route-panel locking. User validated the background no longer scrolls behind modal overlays. |
| Look over gamepad navigation | todo | Needs focused controller-nav pass. |
@@ -206,6 +220,7 @@ Done criteria for this tracker:
| Delete app data option and uninstall warning | done | Uninstall dialogs in My Apps and App Details now include a clear warning plus a `Delete app data and reset it` choice. Leaving it off preserves app data for later reinstall; checking it passes `preserve_data=false` through `package.uninstall` so the app is fully reset. Covered by `AppsUninstallModal.test.ts`, `rpc-client.test.ts`, local type-check, targeted tests, and `git diff --check`. |
| Add App Store container with recommended apps that change to Home Screen | done | Home now shows up to three uninstalled core/recommended App Store apps and routes clicks through the existing Marketplace App Details handoff. Installed aliases are honored, so recommendations disappear once the app is installed and the app moves into normal My Apps/Home behavior. Follow-up layout polish moved Cloud back into the second card slot, moved Recommended Apps into Cloud's previous slot, and placed Quick Start inside the grid next to Wallet to avoid an odd-width row. Covered by `homeRecommendations.test.ts`, local type-check, `git diff --check`, and Playwright Home dashboard smoke against local Vite/mock backend. |
| Add QR code to download mobile companion app in login-triggered modal and improve modal | done | Companion intro modal now renders a QR code on desktop and a direct download button on mobile. It reads `VITE_COMPANION_APK_URL` and falls back to `/packages/archipelago-companion.apk.zip`; the APK zip is now published at `neode-ui/public/packages/archipelago-companion.apk.zip` so the modal can serve it immediately. Covered by local type-check, `git diff --check`, and manual file placement verification. |
| Fix TV HDMI overscan clipping in kiosk mode | in-progress | Kiosk launcher now passes a browser safe-area fallback through `/kiosk?safe_area=...`; `/kiosk` now persists the safe-area value during redirect; self-update and deploy paths refresh kiosk launcher/services. The X11 safe-area attempt is opt-in because it stretched the live TV output on `100.66.157.120`. Wi-Fi UI fixes are included in the same OTA patch: scan errors are visible, scans can be retried, escaped SSIDs parse correctly, and open networks do not require a password. Needs live validation on HDMI node `100.66.157.120` after applying the visible OTA update. |
| Video calling Picture-in-Picture | blocked | Need referenced document or desired provider/library. |
| Card-based loading visuals on App Store pages | done | Discover and Marketplace now show app-card skeleton grids while community/Nostr catalog data is loading and no cards are available yet, instead of a centered spinner/empty state. Validated by local type-check, targeted tests, and `git diff --check`. |
+148 -1
View File
@@ -1,6 +1,153 @@
# Migration Status Report
Last updated: 2026-06-11
Last updated: 2026-06-14
## RESUME CHECKPOINT (2026-06-14, after SSH drop)
State right now, so any disconnect resumes cleanly:
- **`main` = `a483fe4b`** = the other agent's 4 fixes (`0ed892a4`: wallet receive / bitcoin
install self-heal / ElectrumX tile / extended test gate) + **my F1 fix committed on top**
(`launch_url_port` in `docker_packages.rs` + 3 regression tests). Tree is clean (only two
untracked `docs/*.md` tracking files remain). Not pushed.
- The old isolated `archy-f1` worktree was **removed** — built the combined tree in-place.
- ✅ **DONE — combined backend release build** (`cd core && TMPDIR=/home/archipelago/.buildtmp
cargo build --release -p archipelago`, 7m46s, exit 0). `/tmp` is a full tmpfs so `TMPDIR`
MUST point at `/home/archipelago/.buildtmp`.
- ✅ **DONE — sideloaded + restarted on `.116`.** Backed up old binary to
`/usr/local/bin/archipelago.pre-f1.bak`, `install`ed new binary (root:root 755),
`sudo systemctl restart archipelago` (new MainPID 2885863).
- ✅ **F1 VALIDATED LIVE on `.116` (2026-06-14).** See "FINDING F1" below — before/after proves
the fix. Harness focused audit `jellyfin,filebrowser`**all checks passed, exit 0**.
- **IMPORTANT — restart is SAFE on this node:** containers run rootless under
`user-1000.slice/user@1000.service/app.slice`, a DIFFERENT cgroup from
`/system.slice/archipelago.service`. They survived both the 01:47 and this restart
(bitcoin/lnd/btcpay/immich/indeedhub all intact, count stayed 36). The
`feedback_no_systemctl_deploy_until_quadlet` cgroup-cascade warning does NOT apply to `.116`'s
current config. (The reconciler does recreate a few app containers like jellyfin/fedimint on
adoption — normal level-triggered behavior, not casualties.)
- **RELEASE IN PROGRESS — v1.7.91-alpha (user approved 2026-06-14).** Bundles the other agent's
4 fixes (`0ed892a4`) + F1 (`a483fe4b`) + changelog (`ab858271`). Steps:
1. ✅ Freed `/tmp` (removed stale published frontend tarballs 1.7.83→1.7.89; ~1.1G free) —
`create-release.sh` writes the 184MB frontend tarball to `/tmp` (hardcoded, NOT TMPDIR).
2. ✅ `cargo fmt -p archipelago --check` clean; curated layman changelog added + committed.
3. 🔄 `TMPDIR=/home/archipelago/.buildtmp scripts/create-release.sh 1.7.91-alpha`
(runs `tests/release/run.sh` gate → bumps Cargo.toml/package.json → builds backend+frontend
→ manifest → commit "chore: release v1.7.91-alpha" → tag `v1.7.91-alpha`). MUST set TMPDIR
or cargo's ring C-build fails on the full `/tmp` tmpfs.
- **AFTER create-release.sh:** `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`
`git push origin main && git push gitea-local main``git push --tags` (origin+gitea-local).
Ship target per memory: vps2 (146.59.87.168) is PRIMARY OTA manifest; tx1138 RETIRED.
- Verify packaged tarball actually contains the new version string before trusting the build
(npm run build can silently produce stale dist — see `feedback_frontend_build_verify`).
## Validation node (ACTIVE)
As of 2026-06-14 the app-migration lifecycle validation moves from `.198` (remote, OVH) to
**`.116` — the local dev node (`archi-thinkpad`, `192.168.1.116`)** because it is the machine
this session runs on, so the harness drives it over loopback instead of SSH (much faster, no
network latency). A separate agent owns OS-level fixes + its own test harness; this track owns
the **app-packaging migration** lifecycle validation only.
How to drive the harness against `.116` (local):
```bash
ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' \
ARCHY_APPS='meshtastic,jellyfin,filebrowser,uptime-kuma' \
tests/lifecycle/remote-lifecycle.sh # focused, audit-only (non-destructive)
```
- `.116` serves nginx on **:80 only** (443 is tailscale's) → use `ARCHY_SCHEME=http`, `ARCHY_HOST=127.0.0.1`.
- Local node is healthy: `update_state.json.current_version == 1.7.90-alpha`, `update_in_progress=false`
(the OTA self-heal that was a follow-up gap in PROGRESS_MEMORY is now confirmed resolved on .116).
- Login password for `.116`: `ThisIsWeb54321@` (verified against `auth.login`). Note: auth.login
has a login rate-limiter — avoid rapid repeated attempts.
- `.198` results below remain the prior baseline; new results are tagged `[.116]`.
### [.116] audit log (newest first)
- **2026-06-14 — focused audit `meshtastic,jellyfin,filebrowser,uptime-kuma` (audit-only, non-destructive):**
harness exit 1, FAILED checks: 1.
- `filebrowser` — running, pass (also passed a standalone single-app smoke run).
- `uptime-kuma` — running, pass.
- `meshtastic``state=absent`. Not installed on `.116` (was installed/validated on `.198`).
Not a regression; just node state. To exercise meshtastic here, install it first (it needs
`/dev/ttyUSB0`, which `.116` may not have) or drop it from the focused set on this node.
- `jellyfin` — **running but FAILED: "launch metadata missing: jellyfin has no lan_address".**
**ROOT-CAUSED 2026-06-14 — real, current bug in the working tree (a regression).** See
"FINDING F1" below.
### [.116] FINDING F1 — manifest launch URLs with a path are silently dropped (OPEN, fix pending)
**Symptom:** `jellyfin` is `running` and genuinely serving (`curl 127.0.0.1:8096/` → 302), but
`container-list` reports `lan_address: null`, so the UI/harness sees no launch URL.
**Root cause:** `core/archipelago/src/container/docker_packages.rs::reachable_lan_address()` parses
the port out of the candidate URL with `url.rsplit(':').next()`. When the candidate comes from the
manifest `interfaces.main` (via `PodmanClient::lan_address_for`
`core/container/src/podman_client.rs::manifest_primary_interface_url`), the URL **includes the
manifest `path`** — e.g. jellyfin → `http://localhost:8096/`. Then `rsplit(':').next()` yields
`"8096/"`, which **fails to `parse::<u16>()`**, so the function hits its `else { return None }`
branch and drops a perfectly reachable launch URL. (Diagnostic tell: the dropped-at-parse path
emits **no** log, whereas a genuine unreachable port logs "suppressing unreachable launch URL".
jellyfin has no such log; uptime-kuma — whose candidate `…:3002` has no path — does.)
**Why it's a regression:** the old `extract_lan_address(ports)` produced `http://localhost:PORT`
(no path), which parsed fine. The newer manifest-interface feature appends the declared `path`,
so any app routed through `lan_address_for` now yields `…:PORT/` and trips the parser.
**Blast radius (apps in `requires_reachable_launch` whose `interfaces.main.path` = `/`):**
`botfights`, `btcpay-server`, `fedimint`, `jellyfin`, `gitea`, `nextcloud`, `portainer`.
(`filebrowser`/`nextcloud`/`nginx-proxy-manager`/`vaultwarden` are in `uses_allocated_launch_port`
so they hit `extract_lan_address` first and dodge it; `grafana`/`mempool`/`uptime-kuma`/`searxng`
have no manifest `interfaces.main` path.) On `.198` this likely went unnoticed because those apps
weren't all running during the launch-metadata assertion, or predated the interfaces.main addition.
**Fix (IMPLEMENTED in working tree, uncommitted):**
`docker_packages.rs::reachable_lan_address` now parses the port via a new `launch_url_port()`
helper that reads digits after the final colon (`take_while(is_ascii_digit)`), mirroring the
RPC-layer `port_from_url`, so `http://localhost:8096/``Some(8096)`. Added unit tests
(`launch_url_port_tests`) covering the trailing-path regression, the bare-authority case, and a
no-port reject. The existing `lan_address_prefers_manifest_main_interface` test only exercised
`lan_address_for` (which always returned `…:8175/`) and never the `reachable_lan_address` wrapper,
which is why the bug slipped through.
**Unit validation: GREEN (2026-06-14).** `cargo test -p archipelago --bin archipelago launch_url_port`
→ 3 passed / 0 failed (trailing-path, bare-authority, no-port-reject); crate compiles clean.
**Coordination note (shared tree):** the repo is on branch `fix/wallet-receive-portdrift-secrets`
at commit `bb808df8` (= the deployed 1.7.90-alpha). A parallel agent has uncommitted changes here
(lnd `wallet.rs`, `bitcoin_relay.rs`, `prod_orchestrator.rs`, electrumx manifest, neode-ui, new
bats). To validate F1 in isolation (and NOT deploy their in-flight work onto the live node, nor
disturb their tree), the live-validation build is done in a detached git worktree at
`/home/archipelago/archy-f1` = clean `bb808df8` + only the F1 `docker_packages.rs` change. Build:
`cd /home/archipelago/archy-f1/core && TMPDIR=/home/archipelago/.buildtmp cargo build --release -p archipelago`
(`.116`'s `/tmp` is a 7.7G tmpfs that runs 100% full → the ring crate's C compile fails with
"No space left on device"; redirect `TMPDIR` to `/` which has ~399G). After validation the
worktree is removed (`git worktree remove`). NOTE: sideloading replaces the OTA-managed
`/usr/local/bin/archipelago` with a local 1.7.90-alpha+F1 build until the next OTA — back up the
current binary first (`/usr/local/bin/archipelago.pre-f1.bak`).
**Live validation status — ✅ GREEN on `.116` (2026-06-14).** Built combined tree (`a483fe4b`),
sideloaded, restarted `archipelago.service`. Before/after on the live node (old buggy binary → new):
| app | OLD lan_address | NEW lan_address |
|---|---|---|
| jellyfin | `None` ❌ | `http://localhost:8096/` ✅ |
| btcpay-server | `None` ❌ | `http://localhost:23000/` ✅ |
| fedimint | `None` ❌ | `http://localhost:8175/` ✅ |
| gitea | `None` ❌ | `http://localhost:3001/` ✅ |
| portainer | `None` ❌ | `http://localhost:9000/` ✅ |
| botfights | `None` ❌ | `http://localhost:9100/` ✅ |
| nextcloud | `:8085` ✓ | `:8085` (unchanged — allocated-port path) |
| filebrowser | `:8083` ✓ | `:8083` (unchanged) |
Harness focused audit `jellyfin,filebrowser`**all checks passed, exit 0**. Unit tests green.
No container casualties (all 36 survived; see RESUME CHECKPOINT for the cgroup detail).
NOTE: Do NOT run the prod binary directly to "check a version" —
`/usr/local/bin/archipelago <anyflag>` boots a whole second node instance (learned the hard way
2026-06-14; it exited without leaving a stray, but don't repeat).
## Goal
+44
View File
@@ -0,0 +1,44 @@
# Progress Memory
Last updated: 2026-06-13
## Current State
- `v1.7.90-alpha` release is complete, tagged, pushed, uploaded, and verified on vps2.
- Release commit: `bb808df8` (chore: release v1.7.90-alpha).
- Feature commit: `c800293f` (fix: bitcoin receive, AIUI pointer input, electrs self-heal, OTA timeout).
- Gitea tag: `v1.7.90-alpha` (on origin/gitea-vps2).
- Live OTA manifest on the update host (146.59.87.168) now resolves to `1.7.90-alpha`; both
artifact download URLs (binary + frontend tarball) return HTTP 200.
- v1.7.89-alpha was already fully shipped before this session.
## What shipped in v1.7.90-alpha
- Bitcoin receive address generation fixed (correct address type, no more 400).
- AIUI/app session: on-screen pointer can click + type into app content (incl. app store
search); "open in new tab" opens the phone browser; mobile credential modal centered.
- Electrs self-heals from a corrupt index and shows a percent/block-height progress screen.
- update.rs: retired tx1138 secondary mirror dropped (one-time migration); longer download
timeout for slow connections.
## Verification
- Full release harness green (8 stages): git-diff, cargo-fmt, catalog-drift, release-manifest,
ui-type-check, ui-unit-tests (80 files / 655 tests), cargo-check, cargo-test-weekly.
- Freshly built binary embeds `1.7.90-alpha` (no stale 1.7.89); frontend dist rebuilt fresh
(new AppSession bundle); manifest sha256 + size match on-disk artifacts.
## Known gaps / follow-ups
- `gitea-local` (localhost:3000) push FAILS from this node — redirects to /login (auth).
The v1.7.88 and v1.7.89 tags were also already missing there, so this is a pre-existing
condition on this node, not a v1.7.90 regression. vps2 is the primary OTA mirror and is fine.
- OTA self-update verification on THIS node (.116) not yet observed this session — the node
should auto-apply from the live 1.7.90-alpha manifest; confirm
`update_state.json.current_version == 1.7.90-alpha` after the scheduler runs.
## Resume Context
- If a later session resumes, continue from the next active product/release task, not this
finished release.
- Broader context: docs/WEEKLY_RELEASE_TRACKER.md, docs/RESUME.md, docs/NEXT_TERMINAL_HANDOFF.md
+288
View File
@@ -0,0 +1,288 @@
# Weekly Release Tracker
Last updated: 2026-06-14 (session on node .116 / archi-thinkpad)
---
# ▶ IN PROGRESS — LND wallet auto-unlock fix (2026-06-14)
## RESUME PROMPT (paste into a fresh session, on .116 / archi-thinkpad, tree at /home/archipelago/Projects/archy)
> Resume the LND wallet-password fix. Read memory `project_lnd_wallet_password.md` FIRST (full
> root-cause + design + validated facts). Work is on branch `lnd-wallet-password-fix` (pushed to
> gitea-vps2, commit 91adc281, NOT merged to main, NOT shipped). Bug: hardcoded
> `WALLET_PASSWORD="hellohello"` left LND wallets LOCKED fleet-wide after OTA → Bitcoin-receive
> shows "wallet is locked" on every updated node. DONE + cargo-checked: per-node random secret
> (secrets/lnd-wallet-password), both init paths unified, candidate-unlock with fail-fast,
> login-time candidate-migration (ChangePassword). DETECTION GATE already shipped on main
> (commit 8c8e4d7a). DECISION: alpha, NO funds on nodes → destructive wipe+recreate is OK and
> wanted UNATTENDED for ALL nodes in the next update. A wallet locked with an unknown password is
> already inaccessible, so wiping loses nothing reachable.
## EXACT NEXT STEPS — LND fix (in order)
1. **Finish seed/fresh recovery** (REMAINING piece): in `container/lnd.rs ensure_wallet_initialized`,
when wallet.db exists but ALL unlock candidates fail → wipe wallet.db (+ macaroons + graph/chain
mainnet state, as root via host_sudo) and re-init fresh (random genseed + per-node secret) so the
node self-heals unattended at boot. (Login-time candidate-migration already handles nodes whose
pw matches.) Validate the wipe→reinit mechanic on the scratch LND first (see below).
2. **Scratch validation** (was in progress, .249 unreachable from .116's subnet → use a throwaway
`lnd-scratch` podman container on .116, regtest/neutrino, REST :18099 — already proven for
init/unlock/ChangePassword). Test: init(passA) → restart→LOCKED → delete wallet.db while locked →
confirm /v1/state→NON_EXISTING (may need container restart) → genseed+initwallet fresh → unlock.
NOTE: scratch wallet.db lives at the container's LND data dir (regtest), `podman exec lnd-scratch
find / -name wallet.db`. CLEAN UP: `podman rm -f lnd-scratch` when done.
3. `cargo check -p archipelago` (on .116 ~15-30s incremental; full test compile ~9min).
4. **End-to-end on .228** (reachable 192.168.1.x, SSH pw `archipelago`, UI pw unknown, NO funds —
has a locked unknown-pw wallet = perfect auto-recreate test): build binary
(`ARCHIPELAGO_TARGET=archipelago@192.168.1.228 scripts/deploy-to-target.sh` or per
reference_deploy_to_nodes), deploy, restart, confirm wallet auto-recreates+unlocks, lncli state
RPC_ACTIVE, lnd.newaddress returns an address. Run os-audit against .228 → lnd check PASS.
5. Merge `lnd-wallet-password-fix` → main, then **cut + publish v1.7.93-alpha** (carries the LND
fix). Ship ritual: create-release.sh 1.7.93-alpha → add CHANGELOG (≥3 layman bullets) → run
sync-whats-new.py (the new What's-New gate will require it) → publish-release-assets.sh gitea-vps2
→ push origin/gitea-vps2 + tags → verify live manifest==1.7.93-alpha. Heads-up: create-release
leaves core/Cargo.lock version-bump uncommitted (commit it as a chore, both .91 and .92 hit this).
## Context: how we got here (this session, all on node .116)
- Shipped **v1.7.91-alpha** (bitcoinReceive TS2538 build fix) and **v1.7.92-alpha** (ElectrumX
overlay-during-sync fix; L3 reboot os-audit gate; What's-New sync gate + 8-version backfill) —
both LIVE on vps2. Restored .116-local nginx `/lnd-connect-info` route (was dropped 2026-06-10).
- Triaged user symptoms: ElectrumX "can't connect" = electrs syncing / Bitcoin verifying (not a
regression); .228 "5/14 apps after reboot" = normal ~5min staggered startup (all 14 came up).
- LND lock bug found + detection gate shipped + forward fix & migration implemented (this section).
---
# ✔ DONE PASS — v1.7.91-alpha + v1.7.92-alpha (2026-06-14)
## Outcome (both releases PUBLISHED + LIVE on vps2)
- **v1.7.91-alpha** — bitcoinReceive.ts TS2538 build-blocker fixed; cut, published, verified
live (`manifest.version==1.7.91-alpha`), tag `v1.7.91-alpha` on vps2. The fleet OTA'd to it
(confirmed on .116 + .198).
- **v1.7.92-alpha** — cut, published, verified live (`manifest.version==1.7.92-alpha`), tag on
vps2, main@d462e444. Carries:
- `fix(ui)` ElectrumX **overlay-during-sync** bug — the "App not reachable / retry" overlay
no longer paints over the ElectrumX sync screen (AppSessionFrame.vue gated on `!electrsSync`).
- `test(resilience)` **L3 per-boot health gate**`batch_host_reboot` now runs os-audit.sh
after reboot (RPC/OTA/all-apps/FM-guards), not just container-set equality. os-audit validated
11/0/0 green on .116.
- `feat(release)` **What's New sync gate**`scripts/sync-whats-new.py` + `whats-new-sync`
stage in tests/release/run.sh. Backfilled the 8 missing modal blocks (v1.7.85→.92); the gate
fails any release whose CHANGELOG version isn't in the Settings modal.
- **.116 node fix (not shipped — local config)**: restored the `/lnd-connect-info` nginx proxy
route that a 2026-06-10 "before-116-routing" change had dropped (fell through to SPA). Backup at
`/etc/nginx/conf.d/rpc.tx1138.com.conf.bak-lndconnect-*`. Shipped template already has the route.
- **User symptoms triaged (none were .91/.92 regressions)**: receive-generate "unchanged" = .91's
receive change was a behavior-preserving build guard; ElectrumX "can't connect" on .198 = Bitcoin
node mid-"Verifying blocks…" (-28) so electrs was "waiting for Bitcoin node"; on .116 electrs was
~59% mid-sync. The overlay UX bug is fixed regardless.
## Known follow-ups (not blockers)
- **gitea-local mirror push fails** (`localhost:3000` → redirect to `/login`, token auth). vps2 is
the OTA source and is fine; gitea-local secondary mirror is stale. Diagnose the local Gitea token.
- `sync-whats-new.py` only **inserts missing** versions; it does not rewrite a block when CHANGELOG
bullets for an already-present version change (had to delete+resync the .92 block by hand to pick
up its 3rd bullet). Fine for the forward case; enhance to idempotently re-render if needed.
## What happened this session
- `scripts/create-release.sh 1.7.91-alpha` was running; its release gate PASSED all 7 checks,
backend built clean (7m22s), then it **FAILED at step [4/8] frontend build** with:
`src/utils/bitcoinReceive.ts(23,24): error TS2538: Type 'undefined' cannot be used as an index type.`
Cause: `noUncheckedIndexedAccess``codeMatch[1]` is `string | undefined` and was used directly
to index `RECEIVE_CODE_MESSAGES`. **FIXED**`const code = message.match(/\[([A-Z_]+)\]/)?.[1]`
then `if (code && RECEIVE_CODE_MESSAGES[code])`. `npx vue-tsc --noEmit` is now clean (exit 0).
The failed run aborted BEFORE bumping the manifest (still 1.7.90) or tagging (no v1.7.91 tag),
but it HAD already partial-bumped Cargo.toml/package.json/locks to 1.7.91 — those partial bumps
are reverted (create-release.sh re-owns the bump); only the genuine TS fix + harness are committed.
- Built a new OS-wide health harness `tests/lifecycle/os-audit.sh` (non-destructive, one scorecard):
Section A backend/RPC health, Section B all-apps lifecycle audit (delegates to remote-lifecycle.sh),
Section C FM-guards (port-drift + secret-completeness bats, orphan-container sweep). Section A
validated all-PASS on .116. Fixed a jq bug in the FM12 OTA-wedge check: `//` treats a legit
`false` as empty and fell through to "unknown" — now uses `has()`. Section B is slow (~3 min) and
opaque while running because output is captured (`out=$(...)`) not streamed — minor wart, TODO.
## EXACT NEXT STEPS — v1.7.91 (in order)
1. Confirm clean tree + on main (`git status`; create-release.sh requires `git diff --quiet HEAD`).
The TS fix + os-audit.sh are committed & pushed; version-bump artifacts reverted to 1.7.90.
2. Re-run the release: `scripts/create-release.sh 1.7.91-alpha`. Backend is cached (only a .ts
changed) so it's fast; the frontend build now passes. It bumps versions, builds, writes
releases/manifest.json (→1.7.91-alpha), commits, and tags v1.7.91-alpha.
- Memory guards: grep the staged frontend tarball for "1.7.91-alpha" before shipping (silent
vue-tsc failures); tarball must be flat (`tar -C web/dist/neode-ui .`).
3. Publish: `scripts/publish-release-assets.sh 1.7.91-alpha gitea-vps2`, then
`git push origin main && git push origin --tags` (origin pushes to BOTH gitea-local + vps2).
4. Verify manifest LIVE (this is "published"):
`curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json | jq .version`
must show `1.7.91-alpha`. **Then notify the user — they asked to be told when 1.7.91 publishes.**
5. os-audit harness: run a full green pass on .116
(`ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=http ARCHY_PASSWORD='ThisIsWeb54321@' tests/lifecycle/os-audit.sh`),
confirm Section A FM12 now reads `update_in_progress=false` (PASS not WARN), review B + C findings,
then wire os-audit.sh into the reboot-survival (L3) loop as the per-boot gate.
---
# ─ HISTORY — v1.7.89-alpha pass (2026-06-12), superseded ─
Last updated: 2026-06-12 ~17:45 EDT (session on node .116)
## RESUME PROMPT (paste into a fresh session)
> Continue the v1.7.89-alpha release pass from /home/archipelago/Projects/archy on node .116.
> Read docs/WEEKLY_RELEASE_TRACKER.md fully first — it has root causes, fixes already made,
> and exact next steps. Do NOT redo: AIUI revert (done, validated), updater fixes in
> core/archipelago/src/update.rs (done, uncommitted), .116 OTA unwedge (done). Resume at
> "EXACT NEXT STEPS" below.
## EXACT NEXT STEPS (in order)
1. Backend focused tests were running in background:
`cd core && timeout 1500 cargo test -p archipelago -- update:: lnd container::image_versions scanner`
(log: /tmp/claude-.../tasks/bds4jk19e.output — if lost, just rerun the command; first
attempt died at 400s timeout during test compile, 1500s is the right budget).
Need: all green.
2. RESOLVED before session end: vitest recheck passed clean — EXIT=0, 79 files / 645 tests,
even while cargo test was compiling. The earlier harness ui-unit-tests FAIL was load/flake
(machine saturated by the parallel cargo test compile), not a real failure. On resume just
rerun `tests/release/run.sh --quick` WITHOUT a parallel cargo build to confirm green;
if it ever fails again, the failing test name is in the stage output (drop `--silent`).
3. Run full harness: `tests/release/run.sh` (static+frontend+backend). Then commit ALL
working-tree changes (one commit, e.g. "fix: harden OTA updates, AIUI desktop gap, LND
no-proxy" — CHANGELOG v1.7.89 section is already curated).
4. Cut release: `scripts/create-release.sh 1.7.89-alpha` (needs clean tree, on main,
validates CHANGELOG section exists — it does). Then
`tests/release/run.sh --manifest` should pass, and grep the staged frontend tarball
for 1.7.89-alpha (memory: silent build failures).
5. Publish: `scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2`, then
`git push origin main && git push origin --tags` and push gitea-local + tags too.
Verify manifest live on http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json
6. Verify OTA on THIS node (.116): schedule is auto_apply; either wait for the scheduler
or trigger via UI. Confirm /var/lib/archipelago/update_state.json current_version
becomes 1.7.89-alpha, `update_in_progress` returns to false, web-ui + binary versions
MATCH (this node currently has web-ui 1.7.84 / binary 1.7.85 mismatch — the OTA heals it),
and journalctl shows "Post-OTA verification succeeded" (the new probe falls back to
http://127.0.0.1/ which is what .116 serves).
7. Update this tracker + docs/PROGRESS_MEMORY.md, mark tasks done.
Purpose: live tracker for this pass — test everything shipped this week (v1.7.83→v1.7.89),
build the release test harness, fix OTA updates on .116, make updates bulletproof, cut v1.7.89-alpha.
If the session is cut off, resume from here.
## Task status
| # | Task | Status |
|---|------|--------|
| 1 | AIUI revert (mobile back/close gone, desktop gap fixed) | DONE — validated |
| 2 | Dev server on :8100 with embedded AIUI | DONE — see below |
| 3 | Inventory this week's release-log items | DONE — see checklist |
| 4 | Test harness covering this week + seed of system-wide harness | IN PROGRESS |
| 5 | Fix OTA updates on .116 + bulletproof updates | IN PROGRESS — diagnosis below |
| 6 | Cut v1.7.89-alpha release | PENDING (gates: 4, 5) |
## State of the working tree
- HEAD = 495b9078 (v1.7.89 changelog + AIUI mobile restore committed).
- Uncommitted, intended for v1.7.89-alpha:
- `neode-ui/src/views/Dashboard.vue` — chat route back to plain `h-full` (desktop bottom-gap fix). Validated.
- `core/.../rpc/lnd/*` + `container/lnd.rs` — LND REST no-proxy + wallet readiness/unlock fixes.
- Version bumps to 1.7.89-alpha (Cargo.toml, package.json, locks), CHANGELOG entry.
- `neode-ui/vite.config.ts` — added `/aiui` dev proxy (keep; dev-only convenience).
## AIUI validation (task 1) — DONE
- HEAD already removed the mobile back button and restored `hideClose=true` (495b9078).
- Working-tree Dashboard.vue removes `dashboard-scroll-panel mobile-scroll-pad` from the chat
route (that padding caused the desktop bottom gap); mesh keeps its styling.
- Chat CSS verified byte-identical to last-good 34c4e87d (May 20).
- Playwright check (desktop 1440x900, mobile 390x844): chat fills full viewport, no bottom gap,
no mobile back/close. `npm run type-check` + focused route tests + full vitest (645/645) pass.
## Dev server on :8100 (task 2) — DONE
- Running: `BACKEND_URL=http://127.0.0.1:5678 VITE_AIUI_URL=/aiui/ npx vite --host 0.0.0.0 --port 8100`
from `neode-ui/` (real local backend on 5678).
- AIUI now embeds in /dashboard/chat via new vite proxy `/aiui``http://127.0.0.1:80`
(the node's deployed AIUI), same-origin like production.
- Secondary throwaway instance for automated checks: :8101 against mock backend
(`node mock-backend.js` on 5959, password `password123`).
## This week's shipped items (v1.7.83 → v1.7.89) — test checklist
### Frontend (vitest/type-check/build cover most; full suite 645/645 green 2026-06-12)
- [x] AIUI fast launch, no availability probe (v1.7.88) — covered by visual check + Chat.vue tests
- [x] AIUI mobile layout restore (v1.7.89) — playwright visual check
- [x] App-session launch metadata from manifests / typed interfaces (v1.7.83) — appSessionConfig tests
- [x] OnlyOffice + Saleor removal (v1.7.83) — catalog tests
- [ ] Bitcoin receive UI flow end-to-end (v1.7.87/88) — needs live LND node check
- [ ] Fleet tab keeps node list/alerts during refresh, names not hashes (v1.7.85/86) — store tests?
- [ ] Credential interstitial full-screen overlay (v1.7.87) — visual
- [ ] Mobile federation/system-update buttons full width (v1.7.86) — visual
### Backend (cargo)
- [ ] LND REST no-proxy client + GET newaddress p2wkh (v1.7.88/89) — unit tests + live check
- [ ] LND wallet readiness/unlock after restart (v1.7.89) — unit + live
- [ ] Bitcoin trusted-node relay rpcauth/txrelay (v1.7.84) — unit tests exist? check
- [ ] Container scanner RAII in-flight guard (v1.7.84) — cargo test
- [ ] ElectrumX health-check startup window + cache tuning (v1.7.85/86)
- [ ] Portainer pin 2.19.4 / bitcoin-ui image pin (v1.7.84/85) — image-versions tests
- [ ] Fleet telemetry name/hostname/URL fields (v1.7.85)
- [ ] Federation no self-import (v1.7.85)
- [ ] Kiosk safe-area + self-update refreshes kiosk files (v1.7.84)
- [ ] Wi-Fi scan error/retry/escaped SSID/open networks (v1.7.84)
### OTA / updates (task 5)
- [ ] .116 stuck: current 1.7.85-alpha, `update_in_progress: true` since 1.7.88 attempt — diagnose+fix
- [ ] Updater hardening: stuck-in-progress recovery, resumable/atomic apply, verify post-restart version
## OTA diagnosis on .116 — ROOT CAUSES FOUND + FIXED (code staged for v1.7.89)
Four bugs, all reproduced from the journal (Jun 12 03:4504:33):
1. Post-OTA probe only tries `https://127.0.0.1/`; .116's nginx binds only :80 (443 is
tailscale's) → connection refused × 18 → a GOOD 1.7.85 update was "rolled back".
FIX: probe falls back to `http://127.0.0.1/` on connect error (update.rs probe_frontend_once).
2. That rollback's binary restore did `host_sudo cp` onto the RUNNING binary → ETXTBSY exit 1
→ binary stayed 1.7.85 while web-ui rolled back to 1.7.84 (mismatch confirmed live).
FIX: rollback now cp→tmp→atomic mv, same pattern as apply (update.rs rollback_update).
3. The rollback chown'd `update-backup/archipelago` root:root IN PLACE → next apply's
fs::copy (as service user) hit EACCES → "Failed to backup current binary" × 3 → 1.7.86/88
never applied. FIX: apply unlinks stale backup first; rollback chowns only its temp copy.
4. Failed apply left `update_in_progress: true` wedged (staging still populated so the
stale-flag guard never fires). Unwedged operationally; fixed structurally by 13.
Operational cleanup DONE on .116 (2026-06-12 17:15): removed root-owned
`update-backup/archipelago`, stale `update-staging/` (1.7.86), and the stale
`update-pending-verify.json`. Next state load clears `update_in_progress`.
NOTE: live web-ui is 1.7.84 / binary 1.7.85 (mismatch from bug 2). Not hand-patched —
the v1.7.89 OTA will resync both. Good 1.7.85 frontend is quarantined at
`/opt/archipelago/web-ui.failed.1781250438247`.
Verification plan: after v1.7.89 release, watch .116 auto-apply (schedule auto_apply),
confirm `update_state.json.current_version == 1.7.89-alpha` and web-ui version matches.
## Test harness (task 4) — CREATED at tests/release/run.sh
- Stages: static (git diff --check, cargo fmt, catalog drift, optional --manifest),
frontend (type-check, full vitest), optional --with-build (build + grep dist for version),
backend (cargo check + focused cargo test: update:: lnd container::image_versions scanner,
all wrapped in `timeout`), optional --live URL smoke (/, /aiui/, /rpc/v1).
- Results so far (2026-06-12): type-check PASS, full vitest 645/645 PASS, cargo fmt PASS,
cargo check PASS, catalog drift PASS (3 pre-existing MISSING_CATALOG warnings, exit 0,
identical on HEAD). Focused backend cargo tests running (first run hit the known slow
test-compile on .116 at 400s timeout; rerunning with 1500s).
- AIUI embed verified end-to-end via playwright on :8101 (mock backend): iframe loads,
`ready` handshake clears the loading overlay, hideClose honored.
- Release flow confirmed: commit all → `scripts/create-release.sh 1.7.89-alpha` (validates
curated CHANGELOG section, builds, manifests, commits, tags) →
`scripts/publish-release-assets.sh 1.7.89-alpha gitea-vps2` → push origin main + tags.
Tarball layout/perms safety is already inside create-release-manifest.sh.
- CHANGELOG v1.7.89 section rewritten layman-readable (updater fixes added).
## Release gates for v1.7.89-alpha (task 6)
1. All harness stages green locally.
2. OTA fix for stuck `update_in_progress` included + .116 updates successfully to the new release.
3. Frontend build: grep packaged tarball for "1.7.89-alpha" before shipping (memory: silent vue-tsc failures).
4. Flat tarball layout (`tar -C web/dist/neode-ui .`).
5. Commit, tag `v1.7.89-alpha`, push origin + gitea-local + tags, publish release assets, verify
manifest + node OTA picks it up.
+44
View File
@@ -156,6 +156,50 @@ 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 `/`.
### Nostr Signer Bridge (NIP-07)
Apps embedded in the Archipelago iframe can use the node's Nostr identity to sign
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:
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.)
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).
> 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.
Declare iframe intent in the manifest so the launcher embeds (vs. opens a new tab):
```yaml
metadata:
launch:
open_in_new_tab: false # default; set true only if the app cannot be iframed
```
## Security Requirements
These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged.
+12 -1
View File
@@ -27,6 +27,7 @@ Allowed RPC methods:
```text
sendrawtransaction
submitpackage
testmempoolaccept
getmempoolinfo
getrawmempool
@@ -37,6 +38,7 @@ getblockcount
getblockhash
getblockheader
getrawtransaction
gettxout
decoderawtransaction
decodescript
estimatesmartfee
@@ -113,7 +115,7 @@ The Bitcoin Knots app should add the restricted user only when the secret exists
RPC_TXRELAY_AUTH="$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)"
RPC_TXRELAY_FLAGS="-rpcwhitelistdefault=0"
if [ -n "$RPC_TXRELAY_AUTH" ]; then
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblockheader,getrawtransaction,decoderawtransaction,decodescript,estimatesmartfee"
RPC_TXRELAY_FLAGS="$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips"
fi
```
@@ -245,6 +247,15 @@ curl -sS --user "$BITCOIN_RPC_TXRELAY_USER:$BITCOIN_RPC_TXRELAY_PASSWORD" \
Expected result is a Bitcoin RPC validation error such as `TX decode failed`,
which confirms the request reached `sendrawtransaction`.
If a wallet verifies the connection but reports `RPC Forbidden` during
broadcast, the credentials authenticated but the broadcast method was outside
the loaded `txrelay` whitelist. Restart the active Bitcoin backend after
updating the whitelist, then test both `sendrawtransaction` and, for newer
package-relay clients, `submitpackage`. Also confirm the public reverse proxy
passes the wallet's `Authorization` header through to `127.0.0.1:8332`; do not
point public wallet traffic at the Bitcoin UI `/bitcoin-rpc/` helper, because
that helper injects the local dashboard credential.
Check that wallet/admin RPC is blocked:
```sh
@@ -1,37 +1,97 @@
#!/bin/bash
# Start X server in the background
/usr/bin/Xorg :0 -nocursor vt1 -nolisten tcp -keeptty &
XPID=$!
sleep 2
# Check if X started
if ! kill -0 $XPID 2>/dev/null; then
echo 'ERROR: Xorg failed to start'
exit 1
fi
# Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$!
export DISPLAY=:0
export HOME=/home/archipelago
# Allow archipelago user to connect
xhost +SI:localuser:archipelago 2>/dev/null
X_READY=false
for _ in $(seq 1 30); do
if kill -0 "$XPID" 2>/dev/null && xrandr --query >/tmp/archipelago-kiosk-xrandr.txt 2>/dev/null; then
X_READY=true
break
fi
sleep 0.5
done
# Disable screen blanking
xset s off 2>/dev/null
xset -dpms 2>/dev/null
xset s noblank 2>/dev/null
if [ "$X_READY" != "true" ]; then
echo 'ERROR: Xorg failed to become ready'
kill "$XPID" 2>/dev/null || true
exit 1
fi
# Hide cursor
unclutter -idle 3 -root &
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
# Kill any stale Chromium instances before starting
pkill -u archipelago -f 'chromium.*kiosk' 2>/dev/null
configure_display() {
command -v xrandr >/dev/null 2>&1 || return 0
local output mode internal width height
output=$(awk '/ connected/ && $1 !~ /^eDP|^LVDS/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || return 0
mode=$(awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
if ($0 ~ /\*/) { print $1; exit }
if (!first) first = $1
}
active && /^[^[:space:]]/ { active = 0 }
END { if (first) print first }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$mode" ] || mode=1920x1080
# Kiosk should use one native output. A spanning desktop makes Chromium land
# on the laptop panel or stretch across both outputs.
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
[ "$internal" = "$output" ] || xrandr --output "$internal" --off 2>/dev/null || true
done
xrandr --output "$output" \
--primary \
--mode "$mode" \
--pos 0x0 \
--scale 1x1 \
--panning 0x0 \
--transform none 2>/dev/null || true
width=${mode%x*}
height=${mode#*x}
case "$width:$height" in *[!0-9:]*|:*) width=1920; height=1080 ;; esac
# Browser safe-area fallback for TVs that crop edges. Driver underscan is
# preferable, but many Intel HDMI outputs do not expose that property.
KIOSK_SAFE_AREA_X_PX=${KIOSK_SAFE_AREA_X_PX:-$((width * 3 / 100))}
KIOSK_SAFE_AREA_Y_PX=${KIOSK_SAFE_AREA_Y_PX:-$((height * 3 / 100))}
}
configure_display
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
xset -dpms 2>/dev/null || true
xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1
# Run Chromium as archipelago user in a restart loop
# GPU vs headless (#36). On a real kiosk display with a GPU, GPU rasterization is
# fast. On a GPU-less / headless server (no /dev/dri), --enable-gpu-rasterization
# forces GPU paths that fall back to software compositing and SPIN a full core at
# ~92% CPU, saturating the node. Detect the GPU and pick safe flags accordingly.
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
GPU_FLAGS="--enable-gpu-rasterization --num-raster-threads=2"
else
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
fi
while true; do
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \
--app=http://localhost/kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--noerrdialogs \
--disable-infobars \
--disable-translate \
@@ -42,12 +102,12 @@ while true; do
--disable-save-password-bubble \
--disable-suggestions-service \
--disable-component-update \
--enable-gpu-rasterization \
--num-raster-threads=2 \
$GPU_FLAGS \
--renderer-process-limit=2 \
--window-size=9999,9999 \
--window-size=1920,1080 \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=1 \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
@@ -60,5 +120,4 @@ while true; do
sleep 3
done
# Cleanup
kill $XPID 2>/dev/null
kill "$XPID" 2>/dev/null || true
@@ -20,5 +20,15 @@ TimeoutStartSec=360
Restart=always
RestartSec=5
# Resource guardrail (#36). On GPU-less / headless hardware chromium could spin
# software compositing at ~92% of a core, saturating the node and starving the
# backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory
# so a runaway kiosk can never take the whole machine down; Delegate so the cap
# also binds the chromium/Xorg children in this unit's cgroup.
Delegate=yes
CPUQuota=75%
MemoryMax=1500M
MemoryHigh=1200M
[Install]
WantedBy=multi-user.target
+8
View File
@@ -2,6 +2,14 @@
Description=Archipelago Backend
After=network-online.target archipelago-setup-tor.service
Wants=network-online.target
# The data dir AND podman's graphroot (containers/storage) both live on the
# separate /var/lib/archipelago volume. Without this, on a cold boot the service
# (and its ExecStartPre) can start BEFORE var-lib-archipelago.mount, write to the
# bare mountpoint on rootfs, fail every podman call, exit, and get restarted every
# 5s until the volume mounts (~5 min of "[FAILED] Failed to start" on boot — B17).
# RequiresMountsFor adds both Requires= and After= on the mount unit so we never
# start until the data volume is mounted.
RequiresMountsFor=/var/lib/archipelago
[Service]
Type=notify
+8 -1
View File
@@ -652,7 +652,14 @@ server {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter_types text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/fedimint/';
sub_filter 'src="/' 'src="/app/fedimint/';
sub_filter "href='/" "href='/app/fedimint/";
sub_filter "src='/" "src='/app/fedimint/";
sub_filter 'url("/' 'url("/app/fedimint/';
sub_filter "url('/" "url('/app/fedimint/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/fedimint-gateway/ {
@@ -174,7 +174,14 @@ location /app/fedimint/ {
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter_types text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/fedimint/';
sub_filter 'src="/' 'src="/app/fedimint/';
sub_filter "href='/" "href='/app/fedimint/";
sub_filter "src='/" "src='/app/fedimint/";
sub_filter 'url("/' 'url("/app/fedimint/';
sub_filter "url('/" "url('/app/fedimint/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/fedimint-gateway/ {
+1 -3
View File
@@ -110,7 +110,7 @@ tail -f /tmp/neode-dev.log
- **Docker Optional** - Apps run for real if Docker/Podman is available, otherwise simulated
- **Auto-Detection** - Automatically detects container runtime and adapts
- **WebSocket Support** - Real-time state updates via JSON patches
- **Pre-loaded Apps** - 8 apps always visible in My Apps
- **Pre-loaded Apps** - 7 apps always visible in My Apps
### Pre-installed Apps (always running in mock mode)
- `bitcoin` - Bitcoin Core (port 8332)
@@ -119,7 +119,6 @@ tail -f /tmp/neode-dev.log
- `mempool` - Blockchain explorer (port 4080)
- `filebrowser` - Web file manager (port 8083)
- `lorabell` - LoRa doorbell (no UI port)
- `thunderhub` - Lightning node management (port 3010)
- `fedimint` - Federated Bitcoin mint (port 8175)
Additional apps can be installed from the Marketplace (30+ available).
@@ -226,4 +225,3 @@ The warning is non-fatal - Vite still works, but upgrading is recommended.
---
Happy coding! 🎨⚡
+1 -1
View File
@@ -55,7 +55,7 @@ The mock backend supports multiple startup modes via `VITE_DEV_MODE`:
The mock backend (`mock-backend.js`) simulates the full Rust backend for local development:
**Pre-installed apps** (always visible in My Apps):
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, ThunderHub, Fedimint
- Bitcoin Core, LND, Electrs, Mempool, FileBrowser, LoraBell, Fedimint
**Marketplace**: 30+ curated apps with Docker images, install/uninstall simulation
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.83-alpha",
"version": "1.7.98-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.83-alpha",
"version": "1.7.98-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.83-alpha",
"version": "1.7.98-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

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