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
95 changed files with 3153 additions and 457 deletions
+35
View File
@@ -1,5 +1,40 @@
# 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.
+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
@@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.94-alpha"
version = "1.7.97-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.95-alpha"
version = "1.7.98-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
+36 -2
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![
@@ -501,12 +522,25 @@ 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());
}
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
}
+72
View File
@@ -185,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()),
)),
}
}
}
+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,
@@ -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 }))
}
+27 -21
View File
@@ -752,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()],
@@ -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.
+200 -5
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
@@ -48,6 +62,34 @@ const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n
/// 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() {
@@ -483,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
@@ -503,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);
@@ -521,19 +653,66 @@ 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");
let missing_lnd_proxy = !content.contains("location /proxy/lnd/");
if !missing_app_catalog && !missing_bitcoin_status && !missing_lnd_proxy {
// 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.
@@ -552,6 +731,22 @@ async fn patch_nginx_conf(path: &str) -> Result<bool> {
}
}
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(),
@@ -213,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;
@@ -255,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))
+1
View File
@@ -1,3 +1,4 @@
pub mod app_catalog;
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
@@ -772,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 {
@@ -832,6 +834,8 @@ impl ProdContainerOrchestrator {
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
#[cfg(test)]
test_bitcoin_host: None,
})
}
@@ -850,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,
}
}
@@ -1380,7 +1385,29 @@ impl ProdContainerOrchestrator {
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
@@ -2314,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() {
@@ -2400,7 +2463,18 @@ impl ProdContainerOrchestrator {
}
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));
@@ -3489,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());
+75 -1
View File
@@ -58,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.
@@ -314,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();
+21
View File
@@ -145,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(),
+59 -7
View File
@@ -93,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>> {
@@ -374,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)
}
}
@@ -403,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.
+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,
+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;
}
});
}
}
+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"
);
}
}
+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()));
}
}
+39 -6
View File
@@ -538,12 +538,19 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
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 {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
// 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<()> {
@@ -801,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?;
@@ -1507,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) => {
@@ -1855,6 +1882,12 @@ mod tests {
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()),
+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 {
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 / {
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

+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.
@@ -79,6 +79,16 @@ xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1
# 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?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
@@ -92,8 +102,7 @@ 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=1920,1080 \
--window-position=0,0 \
@@ -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/ {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.95-alpha",
"version": "1.7.98-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.95-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.95-alpha",
"version": "1.7.98-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 894 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1019 KiB

After

Width:  |  Height:  |  Size: 954 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1016 KiB

After

Width:  |  Height:  |  Size: 943 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1019 KiB

After

Width:  |  Height:  |  Size: 954 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: 1.0 MiB

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 999 KiB

After

Width:  |  Height:  |  Size: 952 KiB

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: 5.5 MiB

After

Width:  |  Height:  |  Size: 778 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 KiB

After

Width:  |  Height:  |  Size: 965 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: 996 KiB

After

Width:  |  Height:  |  Size: 919 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 774 KiB

After

Width:  |  Height:  |  Size: 726 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

After

Width:  |  Height:  |  Size: 438 KiB

+10 -1
View File
@@ -59,6 +59,15 @@
<p class="mt-0.5 text-sm text-white/70 line-clamp-2">{{ toastMessage.text }}</p>
<p class="mt-1 text-xs text-orange-400">Click to view</p>
</div>
<button
@click.stop="messageToast.closeToast"
aria-label="Dismiss notification"
class="-mt-1 -mr-1 shrink-0 rounded-full p-1 text-white/40 transition-colors hover:bg-white/10 hover:text-white/80"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</Transition>
@@ -136,7 +145,7 @@ watch(() => appStore.isAuthenticated, (authenticated) => {
}
} else {
messageToast.stopPolling()
toastMessage.value = { show: false, text: '' }
toastMessage.value = { show: false, text: '', fromPubkey: '' }
screensaverStore.clearInactivityTimer()
screensaverStore.deactivate()
stopRemoteRelay()
@@ -21,7 +21,9 @@ function jsonResponse(body: unknown, status = 200): Response {
json: () => Promise.resolve(body),
text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)),
blob: () => Promise.resolve(new Blob([JSON.stringify(body)])),
headers: new Headers(),
// A real File Browser JSON response carries this; listDirectory now guards
// on it (B4) to detect the SPA-fallback HTML / 502 cases.
headers: new Headers({ 'content-type': 'application/json' }),
redirected: false,
type: 'basic' as ResponseType,
url: '',
@@ -119,7 +121,21 @@ describe('FileBrowserClient', () => {
mockFetch.mockResolvedValueOnce(jsonResponse(null, 404))
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('Failed to list directory: 404')
await expect(fileBrowserClient.listDirectory('/missing')).rejects.toThrow('File Browser is not available (HTTP 404)')
})
it('throws a friendly error when File Browser is absent and nginx serves the SPA (B4)', async () => {
setAuthenticated()
// 200 but text/html (SPA index.html fallback) — res.json() would throw the
// opaque "Unexpected token '<'"; the guard must surface a friendly message.
const htmlResponse = {
...jsonResponse('<!doctype html><html></html>'),
headers: new Headers({ 'content-type': 'text/html' }),
} as Response
mockFetch.mockResolvedValueOnce(htmlResponse)
await expect(fileBrowserClient.listDirectory('/')).rejects.toThrow('File Browser is not available')
})
})
+9 -1
View File
@@ -102,7 +102,15 @@ class FileBrowserClient {
const res = await fetch(`${this.baseUrl}/api/resources${safePath}`, {
headers: this.headers(),
})
if (!res.ok) throw new Error(`Failed to list directory: ${res.status}`)
if (!res.ok) throw new Error(`File Browser is not available (HTTP ${res.status})`)
// When File Browser isn't installed, nginx falls through to the SPA and
// returns index.html (200, text/html); when it's down it returns 502.
// Either way res.json() would throw the opaque "Unexpected token '<'"
// error, so detect a non-JSON body and surface a friendly message instead.
const contentType = res.headers.get('content-type') || ''
if (!contentType.includes('application/json')) {
throw new Error('File Browser is not available — install or start the File Browser app to use your folders')
}
const data: FileBrowserListResponse = await res.json()
return (data.items || []).map((item) => ({
...item,
+15
View File
@@ -586,6 +586,21 @@ class RPCClient {
})
}
async checkPackageUpdates(): Promise<{
status: string
refreshed: boolean
catalog_apps?: number
error?: string
}> {
// Refreshes the remote app catalog now (decoupled from the binary OTA).
// Per-app `available-update` badges repopulate on the next package scan
// and arrive via the usual WebSocket push.
return this.call({
method: 'package.check-updates',
timeout: 25000,
})
}
async getMarketplace(url: string): Promise<Record<string, unknown>> {
return this.call({
method: 'marketplace.get',
@@ -25,7 +25,11 @@
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
@click="togglePlay"
>
<svg v-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<svg v-if="audioPlayer.loading.value" class="w-5 h-5 animate-spin text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<svg v-else-if="!audioPlayer.playing.value" class="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7L8 5z" />
</svg>
<svg v-else class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24">
@@ -27,7 +27,7 @@ describe('useMessageToast', () => {
toast.receivedMessages.value = []
toast.lastMessageCount.value = 0
toast.loadingMessages.value = false
toast.toastMessage.value = { show: false, text: '' }
toast.toastMessage.value = { show: false, text: '', fromPubkey: '' }
})
afterEach(() => {
@@ -145,7 +145,7 @@ describe('useMessageToast', () => {
it('dismissToastAndOpenMessages clears toast and navigates', () => {
const toast = useMessageToast()
toast.toastMessage.value = { show: true, text: 'New message' }
toast.toastMessage.value = { show: true, text: 'New message', fromPubkey: '' }
toast.dismissToastAndOpenMessages()
expect(toast.toastMessage.value.show).toBe(false)
+19 -1
View File
@@ -4,6 +4,7 @@ const audio = ref<HTMLAudioElement | null>(null)
const currentSrc = ref<string | null>(null)
const currentName = ref('')
const playing = ref(false)
const loading = ref(false)
const currentTime = ref(0)
const duration = ref(0)
const error = ref<string | null>(null)
@@ -21,8 +22,22 @@ function init() {
duration.value = audio.value?.duration ?? 0
error.value = null
})
// Buffering / connecting over mesh|Tor → show a loader until it can play.
audio.value.addEventListener('loadstart', () => {
loading.value = true
})
audio.value.addEventListener('waiting', () => {
loading.value = true
})
audio.value.addEventListener('canplay', () => {
loading.value = false
})
audio.value.addEventListener('playing', () => {
loading.value = false
})
audio.value.addEventListener('ended', () => {
playing.value = false
loading.value = false
})
audio.value.addEventListener('pause', () => {
playing.value = false
@@ -33,7 +48,8 @@ function init() {
})
audio.value.addEventListener('error', () => {
playing.value = false
error.value = 'Could not play audio. File Browser may not be running.'
loading.value = false
error.value = 'Could not play this audio file. The peer may be offline, or the file may be unavailable.'
})
}
@@ -47,6 +63,7 @@ function play(src: string, name: string) {
}
if (currentSrc.value !== src) {
loading.value = true
audio.value!.src = src
currentSrc.value = src
currentName.value = name
@@ -87,6 +104,7 @@ export function useAudioPlayer() {
seek,
stop,
playing,
loading,
currentName,
currentTime,
duration,
+14 -3
View File
@@ -14,7 +14,7 @@ const MESSAGE_POLL_INTERVAL = 30000 // 30s
const receivedMessages = ref<ReceivedMessage[]>([])
const lastMessageCount = ref(0)
const loadingMessages = ref(false)
const toastMessage = ref<{ show: boolean; text: string }>({ show: false, text: '' })
const toastMessage = ref<{ show: boolean; text: string; fromPubkey: string }>({ show: false, text: '', fromPubkey: '' })
let pollTimer: ReturnType<typeof setInterval> | null = null
export function useMessageToast() {
@@ -37,6 +37,9 @@ export function useMessageToast() {
toastMessage.value = {
show: true,
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
// Only deep-link to a specific chat when it's a single new message
// from one sender; otherwise open the mesh list.
fromPubkey: newCount === 1 ? (latest?.from_pubkey ?? '') : '',
}
lastMessageCount.value = msgs.length
} else {
@@ -83,9 +86,16 @@ export function useMessageToast() {
}
function dismissToastAndOpenMessages() {
toastMessage.value = { show: false, text: '' }
const peer = toastMessage.value.fromPubkey
toastMessage.value = { show: false, text: '', fromPubkey: '' }
markAsRead()
router.push('/dashboard/mesh')
// Open the specific conversation when we know the sender; else the mesh list.
router.push(peer ? { path: '/dashboard/mesh', query: { peer } } : '/dashboard/mesh')
}
// Dismiss the toast without navigating (the close icon).
function closeToast() {
toastMessage.value = { show: false, text: '', fromPubkey: '' }
}
return {
@@ -99,5 +109,6 @@ export function useMessageToast() {
stopPolling,
markAsRead,
dismissToastAndOpenMessages,
closeToast,
}
}
+1 -1
View File
@@ -179,7 +179,7 @@
"aiDataAccess": "AI Data Access",
"serverName": "Hostname",
"sessionStatus": "Session Status",
"yourDid": "Your DID",
"yourDid": "Node DID",
"onionAddress": "Node .onion Address",
"loggedIn": "Currently logged in",
"didHelper": "Decentralized identifier for passwordless auth",
+11 -3
View File
@@ -84,12 +84,18 @@ const router = createRouter({
meta: { public: true },
},
{
// The kiosk display no longer has its own launcher screen. It runs the
// normal app (onboarding → login → dashboard) like any other client.
// This route only persists kiosk mode + safe-area insets, then redirects
// to the root app. The launcher still points Chromium here (not directly
// at `/`) so the 'kiosk' flag gets set — App.vue uses it to skip the
// remote relay, which would otherwise double xdotool input on the kiosk
// display. Public so the auth guard doesn't bounce us before beforeEnter.
path: '/kiosk',
name: 'kiosk',
component: () => import('../views/Kiosk.vue'),
meta: { public: true },
component: () => import('../views/RootRedirect.vue'),
beforeEnter: (to) => {
// Persist kiosk mode before redirect so App.vue can skip the remote relay
// (relay duplicates xdotool input on the kiosk display)
localStorage.setItem('kiosk', 'true')
const safeArea = to.query.safe_area
const safeAreaPx = Array.isArray(safeArea) ? safeArea[0] : safeArea
@@ -106,6 +112,8 @@ const router = createRouter({
if (safeAreaYPx && /^\d{1,3}$/.test(safeAreaYPx)) {
localStorage.setItem('archipelago_kiosk_safe_area_y_px', safeAreaYPx)
}
// Grid screen removed — hand off to the normal app flow.
return { path: '/' }
},
},
{
@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// Mock the rpc-client module
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
call: vi.fn(),
vpnStatus: vi.fn(),
},
}))
import { useHomeStatusStore } from '../homeStatus'
import { rpcClient } from '@/api/rpc-client'
import { PackageState, type PackageDataEntry } from '@/types/api'
const mockedRpc = vi.mocked(rpcClient)
function pkg(state: string): Record<string, PackageDataEntry> {
return { 'bitcoin-knots': { state } as unknown as PackageDataEntry }
}
describe('homeStatus — B16 bitcoin sync status retain (no vanish, no stale-as-live)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('records a successful poll as available + not stale', async () => {
const store = useHomeStatusStore()
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
await store.refreshBitcoin({})
expect(store.stats.bitcoinAvailable).toBe(true)
expect(store.stats.bitcoinSyncPercent).toBe(100)
expect(store.bitcoinStale).toBe(false)
expect(store.bitcoinLoadState).toBe('ready')
})
it('keeps the tile visible (available) but marks stale when getinfo fails while the container is Running', async () => {
const store = useHomeStatusStore()
// First a good poll so we have real sync numbers.
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 0.5 })
await store.refreshBitcoin(pkg(PackageState.Running))
expect(store.stats.bitcoinAvailable).toBe(true)
// Now a transient RPC failure (e.g. RPC busy during heavy IBD) — container still Running.
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
await store.refreshBitcoin(pkg(PackageState.Running))
expect(store.stats.bitcoinAvailable).toBe(true) // does NOT vanish
expect(store.bitcoinStale).toBe(true) // shown as "Updating…", not live
expect(store.stats.bitcoinSyncPercent).toBe(50) // last-known retained
})
it('flips to NOT available (and not stale) when getinfo fails and the container is Stopped — no stale-as-live', async () => {
const store = useHomeStatusStore()
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
await store.refreshBitcoin(pkg(PackageState.Running))
expect(store.stats.bitcoinAvailable).toBe(true)
mockedRpc.call.mockRejectedValueOnce(new Error('refused'))
await store.refreshBitcoin(pkg(PackageState.Stopped))
expect(store.stats.bitcoinAvailable).toBe(false) // genuinely down → reflect it
expect(store.bitcoinStale).toBe(false) // not "Updating…": it's authoritatively stopped
})
it('retains the last-known available value (marked stale) when package data is momentarily absent', async () => {
const store = useHomeStatusStore()
mockedRpc.call.mockResolvedValueOnce({ block_height: 800000, sync_progress: 1 })
await store.refreshBitcoin(pkg(PackageState.Running))
expect(store.stats.bitcoinAvailable).toBe(true)
// getinfo fails AND the packages map has no authoritative bitcoin entry (route change / scan).
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
await store.refreshBitcoin({})
expect(store.stats.bitcoinAvailable).toBe(true) // retained, does NOT flash "Not running"
expect(store.bitcoinStale).toBe(true)
expect(store.bitcoinLoadState).toBe('ready')
})
it('stays unknown (null) without fabricating availability when the first ever poll fails with no package data', async () => {
const store = useHomeStatusStore()
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
await store.refreshBitcoin({})
expect(store.stats.bitcoinAvailable).toBeNull() // nothing known yet — don't invent a tile
expect(store.bitcoinLoadState).toBe('error')
})
it('marks bitcoin available + stale when the first poll times out but the container is Running (syncing node)', async () => {
const store = useHomeStatusStore()
// No prior success; getinfo times out during heavy initial sync, but container is up.
mockedRpc.call.mockRejectedValueOnce(new Error('timeout'))
await store.refreshBitcoin(pkg(PackageState.Running))
expect(store.stats.bitcoinAvailable).toBe(true) // tile appears instead of staying hidden
expect(store.bitcoinStale).toBe(true) // labeled "Updating…" since we have no live numbers yet
})
})
+14 -1
View File
@@ -43,6 +43,10 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
const stats = reactive<SystemStatsSnapshot>(emptyStats())
const systemLoadState = ref<LoadState>('idle')
const bitcoinLoadState = ref<LoadState>('idle')
// True when we're showing a retained (last-known) bitcoin value because the
// latest poll failed transiently — the UI renders an "Updating…" badge so the
// figure is never presented as live, and the tile never vanishes mid-sync.
const bitcoinStale = ref(false)
const vpnLoadState = ref<LoadState>('idle')
const fipsLoadState = ref<LoadState>('idle')
const lastSystemRefreshAt = ref<number | null>(null)
@@ -109,26 +113,34 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
stats.bitcoinSyncPercent = (btc.sync_progress ?? 0) * 100
stats.bitcoinBlockHeight = btc.block_height ?? 0
stats.bitcoinAvailable = true
bitcoinStale.value = false
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
} catch {
const btcPkg = packages['bitcoin-knots'] || packages['bitcoin-core'] || packages.bitcoin
if (btcPkg?.state === PackageState.Running) {
// Container is up but the RPC call failed (busy during heavy IBD, etc.).
// Keep the tile visible with the last-known figures, marked as updating.
stats.bitcoinAvailable = true
bitcoinStale.value = true
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
return
}
if (btcPkg && (btcPkg.state === PackageState.Stopped || btcPkg.state === PackageState.Exited)) {
// Authoritatively down — reflect it (do NOT keep showing stale data as live).
stats.bitcoinAvailable = false
bitcoinStale.value = false
bitcoinLoadState.value = 'ready'
lastBitcoinRefreshAt.value = Date.now()
return
}
// No authoritative package data yet. Keep the previous known value
// rather than flashing "Not running" during route changes/scans.
// rather than flashing "Not running" during route changes/scans; if we
// had a value, surface it as "updating" instead of presenting it as live.
if (stats.bitcoinAvailable !== null) bitcoinStale.value = true
bitcoinLoadState.value = stats.bitcoinAvailable === null ? 'error' : 'ready'
}
}
@@ -186,6 +198,7 @@ export const useHomeStatusStore = defineStore('homeStatus', () => {
stats,
systemLoadState,
bitcoinLoadState,
bitcoinStale,
vpnLoadState,
fipsLoadState,
systemStatsLoaded,
-1
View File
@@ -62,7 +62,6 @@
.glass-card:focus-visible,
.sidebar-nav-item:focus-visible,
.path-option-card:focus-visible,
.kiosk-app-tile:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
+3 -2
View File
@@ -231,7 +231,7 @@
<!-- Quick Start Goals -->
<div
v-if="showQuickStart"
class="home-card transition-opacity duration-300"
class="home-card lg:col-span-2 transition-opacity duration-300"
:class="{ 'home-card-animate': animateCards, 'opacity-0 pointer-events-none': showWelcomeBlock && !animateCards }"
style="--card-stagger: 5"
>
@@ -482,7 +482,7 @@ const cloudFolderDisplay = computed(() => cloudFolderCount.value !== null ? Stri
onMounted(async () => {
try { const usage = await fileBrowserClient.getUsage(); cloudStorageUsed.value = usage.totalSize; cloudFolderCount.value = usage.folderCount } catch { /* not running */ }
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 30000); checkUpdateStatus(); loadWeb5Status()
loadSystemStats(); systemStatsInterval = setInterval(loadSystemStats, 10000); checkUpdateStatus(); loadWeb5Status()
})
// Wallet modals
@@ -506,6 +506,7 @@ const systemStatsLoaded = computed(() => homeStatus.systemStatsLoaded)
const systemStats = computed(() => ({
...homeStatus.stats,
bitcoinAvailable: homeStatus.stats.bitcoinAvailable === true,
bitcoinStale: homeStatus.bitcoinStale,
}))
const systemUptimeDisplay = computed(() => { if (homeStatus.stats.uptimeSecs === 0) return t('home.systemMonitoring'); const days = Math.floor(homeStatus.stats.uptimeSecs / 86400); const hours = Math.floor((homeStatus.stats.uptimeSecs % 86400) / 3600); if (days > 0) return `Uptime: ${days}d ${hours}h`; const mins = Math.floor((homeStatus.stats.uptimeSecs % 3600) / 60); return `Uptime: ${hours}h ${mins}m` })
-286
View File
@@ -1,286 +0,0 @@
<template>
<div class="kiosk-root" tabindex="0" ref="kioskRoot">
<!-- Kiosk launcher grid -->
<div class="kiosk-launcher">
<!-- Header -->
<div class="kiosk-header">
<div class="flex items-center gap-4">
<img :src="FALLBACK_ICON" alt="Archipelago" class="w-10 h-10" />
<div>
<h1 class="text-2xl font-bold text-white font-archipelago">Archipelago</h1>
<p class="text-sm text-white/50">{{ currentTime }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="kiosk-status-pill" :class="isConnected ? 'status-success' : 'status-error'">
<div class="w-2 h-2 rounded-full" :class="isConnected ? 'bg-green-400' : 'bg-red-400'"></div>
{{ isConnected ? t('kiosk.online') : t('kiosk.offline') }}
</div>
</div>
</div>
<!-- App grid -->
<div class="kiosk-grid">
<button
v-for="app in launchableApps"
:key="app.id"
class="kiosk-app-tile"
@click="openApp(app)"
:data-controller-focusable="true"
>
<div class="kiosk-app-icon-wrap">
<img
:src="app.icon"
:alt="app.title"
class="kiosk-app-icon"
@error="($event.target as HTMLImageElement).src = FALLBACK_ICON"
/>
<div
class="kiosk-app-status"
:class="app.running ? 'bg-green-400' : 'bg-white/30'"
/>
</div>
<span class="kiosk-app-label">{{ app.title }}</span>
</button>
</div>
<!-- Footer -->
<div class="kiosk-footer">
<span class="text-white/30 text-sm">{{ t('kiosk.navHint') }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useAppLauncherStore } from '@/stores/appLauncher'
const { t } = useI18n()
const store = useAppStore()
const appLauncher = useAppLauncherStore()
const kioskRoot = ref<HTMLElement | null>(null)
interface KioskApp {
id: string
title: string
icon: string
url: string
running: boolean
}
// Public asset path construct with BASE_URL to avoid Vite resolving it as a module import
const FALLBACK_ICON = `${import.meta.env.BASE_URL}assets/img/favico.png`
const currentTime = ref('')
const isConnected = computed(() => store.isConnected)
// Build list of launchable apps from the store's package data
const launchableApps = computed<KioskApp[]>(() => {
const pkgs = store.data?.['package-data'] || {}
const apps: KioskApp[] = []
// App URL mappings. Bitcoin UI uses its direct host-network port; loading it
// through /app/bitcoin-ui/ can render a blank shell because its assets are
// rooted at /.
const urlMap: Record<string, string> = {
'bitcoin-knots': 'http://' + window.location.hostname + ':8334',
'lnd': '/app/lnd/',
'mempool': '/app/mempool/',
'btcpay-server': '/app/btcpay/',
'homeassistant': '/app/homeassistant/',
'grafana': '/app/grafana/',
'jellyfin': '/app/jellyfin/',
'nextcloud': '/app/nextcloud/',
'immich': '/app/immich/',
'photoprism': '/app/photoprism/',
'vaultwarden': '/app/vaultwarden/',
'filebrowser': '/app/filebrowser/',
'searxng': '/app/searxng/',
'ollama': '/app/ollama/',
'portainer': '/app/portainer/',
'uptime-kuma': '/app/uptime-kuma/',
'nginx-proxy-manager': '/app/nginx-proxy-manager/',
'tailscale': '/app/tailscale/',
'fedimint': '/app/fedimint/',
'fedimint-gateway': '/app/fedimint-gateway/',
'indeedhub': 'http://localhost:7778',
'botfights': 'http://localhost:9100',
'nwnn': 'https://nwnn.l484.com',
'484-kitchen': 'https://484.kitchen',
'call-the-operator': 'https://cta.tx1138.com',
'arch-presentation': 'https://present.l484.com',
'syntropy-institute': 'https://syntropy.institute',
't-zero': 'https://teeminuszero.net',
}
for (const [id, pkg] of Object.entries(pkgs)) {
const url = urlMap[id]
if (!url) continue
const isRunning = pkg.state === 'running' ||
pkg.installed?.status === 'running'
apps.push({
id,
title: pkg.manifest?.title || id,
icon: pkg['static-files']?.icon || FALLBACK_ICON,
url,
running: isRunning,
})
}
// Sort: running apps first, then alphabetical
return apps.sort((a, b) => {
if (a.running !== b.running) return a.running ? -1 : 1
return a.title.localeCompare(b.title)
})
})
function openApp(app: KioskApp) {
// Delegate to the app launcher handles iframe overlay vs new-tab
appLauncher.open({ url: app.url, title: app.title })
}
// Clock updater
let clockInterval: ReturnType<typeof setInterval> | undefined
function updateClock() {
const now = new Date()
currentTime.value = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
onMounted(() => {
updateClock()
clockInterval = setInterval(updateClock, 30000)
kioskRoot.value?.focus()
// Connect WebSocket if not already
if (!store.isConnected) {
store.connectWebSocket().catch(() => {})
}
})
onUnmounted(() => {
if (clockInterval) clearInterval(clockInterval)
})
</script>
<style scoped>
.kiosk-root {
position: fixed;
left: var(--kiosk-safe-area-x, 0px);
top: var(--kiosk-safe-area-y, 0px);
width: calc(100vw - (var(--kiosk-safe-area-x, 0px) * 2));
height: calc(100vh - (var(--kiosk-safe-area-y, 0px) * 2));
background: #000;
outline: none;
overflow: hidden;
z-index: 9999;
}
.kiosk-launcher {
height: 100%;
display: flex;
flex-direction: column;
padding: clamp(1rem, 3vh, 2rem) clamp(1.5rem, 4vw, 3rem);
background: linear-gradient(180deg, #0a0a12 0%, #000 100%);
box-sizing: border-box;
}
.kiosk-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 2rem;
}
.kiosk-status-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.kiosk-grid {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1.5rem;
align-content: start;
overflow-y: auto;
padding: 0.5rem;
}
.kiosk-app-tile {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1.25rem 0.75rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
transition: all 0.25s ease;
cursor: pointer;
}
.kiosk-app-tile:hover,
.kiosk-app-tile:focus-visible {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(251, 146, 60, 0.4);
transform: scale(1.05);
box-shadow: 0 0 30px rgba(251, 146, 60, 0.15);
outline: none;
}
.kiosk-app-icon-wrap {
position: relative;
width: 64px;
height: 64px;
}
.kiosk-app-icon {
width: 64px;
height: 64px;
border-radius: 16px;
object-fit: cover;
background: rgba(255, 255, 255, 0.05);
}
.kiosk-app-status {
position: absolute;
bottom: -2px;
right: -2px;
width: 14px;
height: 14px;
border-radius: 50%;
border: 3px solid #000;
}
.kiosk-app-label {
font-size: 0.8125rem;
font-weight: 500;
color: rgba(255, 255, 255, 0.85);
text-align: center;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.kiosk-footer {
padding-top: 1.5rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.06);
margin-top: 1.5rem;
}
</style>
+29 -10
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMeshStore } from '@/stores/mesh'
import { useTransportStore } from '@/stores/transport'
import type { MeshMessage, MeshPeer, SessionStatus } from '@/stores/mesh'
@@ -12,6 +13,7 @@ import '@/views/mesh/mesh-styles.css'
const mesh = useMeshStore()
const transport = useTransportStore()
const route = useRoute()
// Responsive layout breakpoints
const isWideDesktop = ref(window.innerWidth >= 1536)
@@ -181,16 +183,24 @@ async function sendArchMessage() {
selfOnion = tor.tor_address
} catch { /* non-fatal */ }
const msg = messageText.value.trim()
let sent = 0
for (const node of nodes.nodes) {
const nodeOnion = node.onion || node.did
// Skip sending to ourselves (would create duplicate received message)
if (selfOnion && (nodeOnion === selfOnion || nodeOnion === selfOnion.replace('.onion', '') || selfOnion === nodeOnion + '.onion')) continue
try {
await rpcClient.sendMessageToPeer(nodeOnion, msg)
sent++
} catch { /* some peers may be offline */ }
}
const targets = nodes.nodes
.map((node) => node.onion || node.did)
// Skip sending to ourselves (would create a duplicate received message).
.filter(
(nodeOnion) =>
!(selfOnion &&
(nodeOnion === selfOnion ||
nodeOnion === selfOnion.replace('.onion', '') ||
selfOnion === nodeOnion + '.onion'))
)
// Send to all peers CONCURRENTLY so the spinner clears after the slowest
// single delivery (one Tor round-trip) rather than the sum of all of them
// previously a slow or offline node kept the "sending" spinner up long after
// the online peers had already received the message.
const results = await Promise.allSettled(
targets.map((nodeOnion) => rpcClient.sendMessageToPeer(nodeOnion, msg))
)
const sent = results.filter((r) => r.status === 'fulfilled').length
try {
await rpcClient.call({ method: 'node-store-sent', params: { message: msg } })
} catch { /* non-fatal */ }
@@ -301,6 +311,15 @@ onMounted(async () => {
loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()])
refreshOutboxCount()
// Deep-link from a message toast: open the sender's conversation if we can
// match it; otherwise just land on the mesh page (graceful fallback).
const targetPeer = typeof route.query.peer === 'string' ? route.query.peer : ''
if (targetPeer) {
const match = mesh.peers.find(
(p) => p.pubkey_hex === targetPeer || p.did === targetPeer
)
if (match) openChat(match)
}
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
if (!archPollInterval) {
+88 -12
View File
@@ -30,7 +30,15 @@
</svg>
</div>
<div class="hidden md:block">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold text-white">{{ peerDisplayName }}</h1>
<span
v-if="transportPill"
:class="transportPill.cls"
:title="transportPill.title"
class="text-xs px-2 py-0.5 rounded-full font-medium"
>{{ transportPill.label }}</span>
</div>
<p v-if="currentPeer?.did" class="text-sm text-white/50 font-mono truncate max-w-md" :title="currentPeer.did">{{ currentPeer.did }}</p>
<p v-else class="text-sm text-white/50">Peer files</p>
</div>
@@ -43,7 +51,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-white/50 text-sm">Connecting via Tor... This may take a few seconds.</p>
<p class="text-white/50 text-sm">Connecting to peer This may take a few seconds.</p>
</div>
<!-- Error -->
@@ -82,7 +90,7 @@
<div
v-for="item in catalogItems"
:key="item.id"
class="glass-card overflow-hidden"
class="glass-card overflow-hidden flex flex-col h-full"
>
<!-- Media preview (images / videos / audio) -->
<div
@@ -142,8 +150,9 @@
</div>
</div>
<!-- Card body -->
<div class="p-4 flex items-center gap-4">
<!-- Card body pinned to the bottom so the filename + action buttons
line up across cards of differing preview heights. -->
<div class="p-4 flex items-center gap-4 mt-auto">
<div v-if="!isMediaMime(item.mime_type)" class="flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center" :class="fileIconBg(item.mime_type)">
<svg class="w-5 h-5" :class="fileIconColor(item.mime_type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fileIconPath(item.mime_type)" />
@@ -226,12 +235,30 @@
</svg>
</button>
<!-- Video element -->
<video
:src="videoPlayerUrl"
class="w-full rounded-xl"
controls
autoplay
/>
<div class="relative">
<video
:src="videoPlayerUrl"
class="w-full rounded-xl bg-black"
controls
autoplay
@playing="videoLoading = false"
@canplay="videoLoading = false"
@error="videoLoading = false; videoError = true"
/>
<!-- Loader while the stream connects over mesh/Tor -->
<div v-if="videoLoading && !videoError" class="absolute inset-0 flex flex-col items-center justify-center gap-3 rounded-xl bg-black/60 pointer-events-none">
<svg class="w-8 h-8 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
</svg>
<span class="text-sm text-white/70">Connecting to peer</span>
</div>
<!-- Error state -->
<div v-if="videoError" class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-xl bg-black/70 text-center px-4">
<p class="text-sm text-white/80">Couldn't play this video</p>
<p class="text-xs text-white/50">The peer may be offline, or this preview can't be played. Try downloading it instead.</p>
</div>
</div>
<!-- Info bar -->
<div class="mt-3 flex items-center justify-between">
<div>
@@ -288,6 +315,23 @@ const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const playing = ref<string | null>(null)
const purchaseError = ref<string | null>(null)
// Transport actually used to reach this peer (returned by content.browse-peer)
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
const transport = ref<string | null>(null)
const transportPill = computed(() => {
switch (transport.value) {
case 'fips':
return { label: 'FIPS', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the fast encrypted mesh (FIPS)' }
case 'mesh':
return { label: 'Mesh', cls: 'bg-green-500/20 text-green-300', title: 'Connected over the mesh' }
case 'lan':
return { label: 'LAN', cls: 'bg-blue-500/20 text-blue-300', title: 'Connected over the local network' }
case 'tor':
return { label: 'Tor', cls: 'bg-amber-500/20 text-amber-300', title: 'Connected over Tor (slower)' }
default:
return null
}
})
const previewUrls = reactive<Record<string, string>>({})
const audioPlayer = useAudioPlayer()
@@ -295,6 +339,10 @@ const audioPlayer = useAudioPlayer()
const videoPlayerItem = ref<CatalogItem | null>(null)
const videoPlayerUrl = ref<string | null>(null)
const videoPlayerPaid = ref(false)
// Streaming a peer's file connects over mesh/Tor before the first frame, so
// show a loader until the element can actually play (or errors).
const videoLoading = ref(false)
const videoError = ref(false)
const peerDisplayName = computed(() => {
if (currentPeer.value?.name) return currentPeer.value.name
@@ -329,12 +377,13 @@ async function loadCatalog() {
loading.value = true
catalogError.value = ''
try {
const result = await rpcClient.call<{ items?: CatalogItem[] }>({
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer',
params: { onion },
timeout: 30000,
})
catalogItems.value = result?.items ?? []
transport.value = result?.transport ?? null
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
if (!hadItems) catalogItems.value = []
@@ -511,6 +560,22 @@ async function playMedia(item: CatalogItem) {
const paid = isPaidItem(item.access)
// Free content: stream via the Range-capable proxy (B3) so the player can
// seek and start instantly, instead of downloading the whole file as a
// base64 blob into a non-seekable Blob URL (which broke video/large audio).
if (!paid) {
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
if (item.mime_type.startsWith('audio/')) {
audioPlayer.play(streamUrl, item.filename.split('/').pop() || item.filename)
} else if (item.mime_type.startsWith('video/')) {
videoPlayerItem.value = item
videoPlayerUrl.value = streamUrl
videoPlayerPaid.value = false
}
return
}
// Paid content: use the preview/download flow below.
// If we already have a preview blob URL, use it
const existingUrl = previewUrls[item.id]
if (existingUrl) {
@@ -562,8 +627,19 @@ function closeVideoPlayer() {
videoPlayerItem.value = null
videoPlayerUrl.value = null
videoPlayerPaid.value = false
videoLoading.value = false
videoError.value = false
}
// Show the loader the moment a video opens; the element's playing/canplay/error
// events clear it.
watch(videoPlayerUrl, (url) => {
if (url) {
videoLoading.value = true
videoError.value = false
}
})
function triggerDownload(base64Data: string, item: CatalogItem) {
const blob = new Blob(
[Uint8Array.from(atob(base64Data), c => c.charCodeAt(0))],
+7
View File
@@ -871,6 +871,13 @@ async function downloadUpdate() {
await loadStatus()
showStatus(t('systemUpdate.upToDateMessage'))
} else {
// A failed download is NOT a staged update return the UI to the
// Download button so the user can retry, instead of stranding them on
// Install. Re-sync from the backend (its self-heal clears a stale
// update_in_progress once the partial staging is cleaned up).
downloaded.value = false
updateInProgress.value = false
await loadStatus()
showStatus(`${t('systemUpdate.downloadFailed')} ${msg}`, true)
}
if (import.meta.env.DEV) console.warn('Download failed', e)
@@ -13,7 +13,10 @@
index is still being built (the Electrum server can't serve clients
until then). Mirrors the Fedimint Guardian "wait page" design. -->
<Transition name="content-fade">
<div v-if="electrsSync" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<!-- Sync overlay only while ElectrumX is actively indexing. If the status
goes stale (ElectrumX disconnected/unresponsive) we stop blocking and
let the app's own UI load instead of a loader stuck on top (B7). -->
<div v-if="electrsSync && !electrsSync.stale" class="absolute inset-0 z-10 flex flex-col items-center justify-center">
<div class="text-center px-8 w-full max-w-md">
<div class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center">
<svg class="w-8 h-8 text-orange-300 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -42,7 +45,7 @@
</Transition>
<div
v-if="appUrl && !iframeBlocked && !electrsSync"
v-if="appUrl && !iframeBlocked && (!electrsSync || electrsSync.stale)"
class="absolute inset-0 app-session-frame-scroll-host"
tabindex="-1"
@pointerdown="focusIframe"
+3 -2
View File
@@ -60,8 +60,8 @@
<div v-if="stats.bitcoinAvailable" class="p-4 bg-white/5 rounded-lg">
<div class="flex items-center justify-between mb-2">
<p class="text-xs text-orange-400/80">Bitcoin</p>
<p class="text-sm font-medium" :class="stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400'">
{{ stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%' }}
<p class="text-sm font-medium" :class="stats.bitcoinStale ? 'text-white/50' : (stats.bitcoinSyncPercent >= 99.9 ? 'text-green-400' : 'text-orange-400')">
{{ stats.bitcoinStale ? 'Updating…' : (stats.bitcoinSyncPercent >= 99.9 ? 'Synced' : stats.bitcoinSyncPercent.toFixed(1) + '%') }}
</p>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
@@ -96,6 +96,7 @@ defineProps<{
bitcoinSyncPercent: number
bitcoinBlockHeight: number
bitcoinAvailable: boolean
bitcoinStale?: boolean
}
uptimeDisplay: string
}>()
@@ -69,11 +69,18 @@
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Address (host:port)</span>
<input v-model="draft.address" type="text" placeholder="192.168.1.116:8668" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
<input v-model="draft.address" type="text" placeholder="185.18.221.160:8443" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-white/60">Transport</span>
<select v-model="draft.transport" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none">
<option value="tcp">tcp (public anchor / over internet)</option>
<option value="udp">udp (same LAN)</option>
</select>
</label>
<label class="flex flex-col gap-1 sm:col-span-2">
<span class="text-xs text-white/60">Label (optional)</span>
<input v-model="draft.label" type="text" placeholder="Home anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
<input v-model="draft.label" type="text" placeholder="Public anchor" class="px-3 py-2 rounded-md bg-white/5 border border-white/10 text-sm text-white focus:border-white/30 focus:outline-none" />
</label>
<button type="submit" class="sm:col-span-2 min-h-[44px] glass-button rounded-lg text-sm font-medium disabled:opacity-60" :disabled="adding || !draft.npub || !draft.address">{{ adding ? 'Adding…' : 'Add anchor' }}</button>
</form>
@@ -106,9 +113,10 @@ const applying = ref(false)
const statusMessage = ref('')
const statusIsError = ref(false)
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'label'>>({
const draft = reactive<Pick<SeedAnchor, 'npub' | 'address' | 'transport' | 'label'>>({
npub: '',
address: '',
transport: 'tcp',
label: '',
})
@@ -136,7 +144,7 @@ async function addAnchor() {
params: {
npub: draft.npub.trim(),
address: draft.address.trim(),
transport: 'udp',
transport: draft.transport,
label: draft.label.trim(),
},
})
@@ -50,13 +50,19 @@ useBodyScrollLock(showReleaseNotes)
const serverTorAddressFromStore = computed(() => store.serverInfo?.['tor-address'] || null)
const torAddressFromRpc = ref<string | null>(null)
const serverTorAddress = computed(() => serverTorAddressFromStore.value || torAddressFromRpc.value)
// Fallback DID fetched from the backend when localStorage doesn't have one
// (e.g. a browser/node where onboarding never stored `neode_did`).
const didFromRpc = ref<string | null>(null)
const userDid = computed(() => {
try {
return localStorage.getItem('neode_did') || null
return localStorage.getItem('neode_did') || didFromRpc.value
} catch {
return null
return didFromRpc.value
}
})
// The node's seed-derived Nostr public key (npub), fetched from the backend.
const userNpub = ref<string | null>(null)
const copiedNpub = ref(false)
const copiedOnion = ref(false)
const copiedDid = ref(false)
@@ -100,6 +106,17 @@ async function copyDid() {
setTimeout(() => { copiedDid.value = false }, 2000)
}
async function copyNpub() {
if (!userNpub.value) return
try {
await navigator.clipboard.writeText(userNpub.value)
} catch {
return
}
copiedNpub.value = true
setTimeout(() => { copiedNpub.value = false }, 2000)
}
// Load Tor address on mount if not in store
async function init() {
if (!serverTorAddressFromStore.value) {
@@ -110,6 +127,29 @@ async function init() {
if (import.meta.env.DEV) console.warn('Tor address may not be available yet', e)
}
}
// DID: fall back to the node.did RPC when localStorage doesn't have one, so
// the Identity card shows the DID on every node (not just ones where the
// browser cached it during onboarding).
let storedDid: string | null = null
try { storedDid = localStorage.getItem('neode_did') } catch { /* unavailable */ }
if (!storedDid) {
try {
const res = await rpcClient.call<{ did?: string }>({ method: 'node.did' })
if (res?.did) {
didFromRpc.value = res.did
try { localStorage.setItem('neode_did', res.did) } catch { /* unavailable */ }
}
} catch (e) {
if (import.meta.env.DEV) console.warn('node.did unavailable', e)
}
}
// The node's seed-derived Nostr public key (npub) for the Identity card.
try {
const res = await rpcClient.call<{ nostr_npub?: string }>({ method: 'node.nostr-pubkey' })
if (res?.nostr_npub) userNpub.value = res.nostr_npub
} catch (e) {
if (import.meta.env.DEV) console.warn('node.nostr-pubkey unavailable', e)
}
}
init()
</script>
@@ -188,6 +228,59 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.98-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.98-alpha</span>
<span class="text-xs text-white/40">June 16, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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).</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>Message notifications now have a close button and open the relevant chat when tapped.</p>
<p>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.</p>
<p>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).</p>
<p>Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.</p>
<p>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.</p>
<p>App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.</p>
</div>
</div>
<!-- v1.7.97-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.97-alpha</span>
<span class="text-xs text-white/40">June 16, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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.</p>
<p>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.</p>
<p>The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.</p>
<p>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.</p>
<p>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.</p>
<p>Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.</p>
<p>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.</p>
<p>The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.</p>
<p>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.</p>
<p>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.</p>
<p>The background images throughout the interface now load faster they've been made significantly smaller with no loss of quality.</p>
</div>
</div>
<!-- v1.7.96-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.96-alpha</span>
<span class="text-xs text-white/40">June 15, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
</div>
</div>
<!-- v1.7.95-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -1483,8 +1576,8 @@ init()
<p class="text-base font-medium text-white/90">{{ t('settings.loggedIn') }}</p>
</div>
<!-- Identity Card: DID + Tor Address -->
<div v-if="userDid || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
<!-- Identity Card: DID + npub + Tor Address -->
<div v-if="userDid || userNpub || serverTorAddress" class="bg-black/20 rounded-xl px-5 py-4 border border-white/10 md:col-span-2 space-y-4">
<div v-if="userDid">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-3 min-w-0">
@@ -1507,7 +1600,29 @@ init()
<p class="text-sm font-mono text-white/90 break-all" :title="userDid">{{ userDid }}</p>
<p class="text-xs text-white/50 mt-1">{{ t('settings.didHelper') }}</p>
</div>
<div v-if="serverTorAddress" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
<div v-if="userNpub" :class="userDid ? 'pt-4 border-t border-white/10' : ''">
<div class="flex items-center justify-between gap-2 mb-2">
<div class="flex items-center gap-3 min-w-0">
<svg class="w-5 h-5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Node npub</p>
</div>
<button
@click="copyNpub"
class="shrink-0 px-3 py-1.5 rounded-lg glass-button glass-button-sm text-xs font-medium text-white/90 hover:text-white transition-colors flex items-center gap-1.5"
>
<svg v-if="!copiedNpub" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<span v-else class="text-green-400 text-xs">{{ t('common.copied') }}</span>
<span v-if="!copiedNpub">{{ t('common.copy') }}</span>
</button>
</div>
<p class="text-sm font-mono text-white/90 break-all" :title="userNpub">{{ userNpub }}</p>
<p class="text-xs text-white/50 mt-1">Your node's Nostr public key, derived from its seed.</p>
</div>
<div v-if="serverTorAddress" :class="(userDid || userNpub) ? 'pt-4 border-t border-white/10' : ''">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
+10 -7
View File
@@ -78,7 +78,7 @@
</div>
<!-- Trusted tab -->
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 flex-1 overflow-y-auto">
<div v-show="nodesContainerTab === 'trusted'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingPeers && peers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -91,7 +91,8 @@
<div
v-for="p in peers"
:key="p.pubkey"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
@@ -101,7 +102,7 @@
</div>
</div>
<button
@click="router.push('/dashboard/mesh')"
@click.stop="router.push({ path: '/dashboard/mesh', query: { peer: p.pubkey || p.did || p.onion } })"
class="px-2 py-1 text-xs rounded bg-orange-500/20 text-orange-400 hover:bg-orange-500/30 transition-colors shrink-0"
>
{{ t('web5.message') }}
@@ -110,7 +111,7 @@
</div>
<!-- Observers tab -->
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 flex-1 overflow-y-auto">
<div v-show="nodesContainerTab === 'observers'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingPeers && observers.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -123,7 +124,8 @@
<div
v-for="p in observers"
:key="p.pubkey"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
@click="router.push({ path: '/dashboard/server/federation', query: { node: p.did || p.pubkey || p.onion } })"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg cursor-pointer hover:bg-white/10 transition-colors"
>
<div class="flex items-center gap-3 min-w-0">
<div class="w-2 h-2 rounded-full shrink-0" :class="peerReachable[p.onion] ? 'bg-green-400' : 'bg-amber-400'"></div>
@@ -139,7 +141,7 @@
</div>
<!-- Messages tab -->
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 flex-1 overflow-y-auto">
<div v-show="nodesContainerTab === 'messages'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingMessages && receivedMessages.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -163,7 +165,7 @@
</div>
<!-- Requests tab -->
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 flex-1 overflow-y-auto">
<div v-show="nodesContainerTab === 'requests'" class="space-y-2 max-h-72 overflow-y-auto">
<div v-if="loadingRequests && connectionRequests.length === 0" class="p-4 text-center text-white/60 text-sm">
{{ t('common.loading') }}
</div>
@@ -406,6 +408,7 @@ function federationNodeToPeer(node: FederationNode): Peer {
return {
onion: node.onion,
pubkey: node.pubkey,
did: node.did,
name: node.name || `Federation: ${node.did?.slice(0, 16) || 'node'}`,
}
}
+1 -1
View File
@@ -159,4 +159,4 @@ export interface DwnMessageEntry {
export type VisibilityLevel = 'hidden' | 'discoverable' | 'public'
export type Peer = { onion: string; pubkey: string; name?: string }
export type Peer = { onion: string; pubkey: string; name?: string; did?: string }
+23 -16
View File
@@ -1,27 +1,34 @@
{
"version": "1.7.95-alpha",
"release_date": "2026-06-15",
"version": "1.7.98-alpha",
"release_date": "2026-06-16",
"changelog": [
"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\" \u2014 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 \u2014 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."
"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 \u2014 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 \u2014 no button to press \u2014 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 \u2014 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."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.95-alpha",
"new_version": "1.7.95-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago",
"sha256": "1036f6822387449f6ce0c2544c81c96d424d11c16343d239ae215b3ef1fcb827",
"size_bytes": 44360792
"current_version": "1.7.98-alpha",
"new_version": "1.7.98-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago",
"sha256": "f8bac49964f9ce7d6a268876fa54c91f9792803c50c8ede1ca42a52d979f98d4",
"size_bytes": 44707368
},
{
"name": "archipelago-frontend-1.7.95-alpha.tar.gz",
"current_version": "1.7.95-alpha",
"new_version": "1.7.95-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago-frontend-1.7.95-alpha.tar.gz",
"sha256": "d78ec0b2f57cde9722c43bf540730ae91d91a4317ccb1ea26eb27645b35e66c8",
"size_bytes": 184075611
"name": "archipelago-frontend-1.7.98-alpha.tar.gz",
"current_version": "1.7.98-alpha",
"new_version": "1.7.98-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago-frontend-1.7.98-alpha.tar.gz",
"sha256": "4952b12505852f46cd80e58ed73f14ecf50f47058b9e561296f467c512fca01d",
"size_bytes": 177644716
}
]
}
+23 -16
View File
@@ -1,27 +1,34 @@
{
"version": "1.7.95-alpha",
"release_date": "2026-06-15",
"version": "1.7.98-alpha",
"release_date": "2026-06-16",
"changelog": [
"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\" \u2014 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 \u2014 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."
"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 \u2014 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 \u2014 no button to press \u2014 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 \u2014 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."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.95-alpha",
"new_version": "1.7.95-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago",
"sha256": "1036f6822387449f6ce0c2544c81c96d424d11c16343d239ae215b3ef1fcb827",
"size_bytes": 44360792
"current_version": "1.7.98-alpha",
"new_version": "1.7.98-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago",
"sha256": "f8bac49964f9ce7d6a268876fa54c91f9792803c50c8ede1ca42a52d979f98d4",
"size_bytes": 44707368
},
{
"name": "archipelago-frontend-1.7.95-alpha.tar.gz",
"current_version": "1.7.95-alpha",
"new_version": "1.7.95-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.95-alpha/archipelago-frontend-1.7.95-alpha.tar.gz",
"sha256": "d78ec0b2f57cde9722c43bf540730ae91d91a4317ccb1ea26eb27645b35e66c8",
"size_bytes": 184075611
"name": "archipelago-frontend-1.7.98-alpha.tar.gz",
"current_version": "1.7.98-alpha",
"new_version": "1.7.98-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.98-alpha/archipelago-frontend-1.7.98-alpha.tar.gz",
"sha256": "4952b12505852f46cd80e58ed73f14ecf50f47058b9e561296f467c512fca01d",
"size_bytes": 177644716
}
]
}
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# Generate releases/app-catalog.json — the REMOTE per-app version catalog that
# decouples app updates from the binary OTA (see
# core/.../container/app_catalog.rs and docs/dht-distribution-design.md).
#
# Nodes fetch this file over HTTP from the OVH origin (same host as the OTA
# manifest), compare each app's catalog version against the running container
# tag, and light up the per-app "Update" button — no node release required.
#
# The app_id -> image-variable mapping below MIRRORS
# core/archipelago/src/container/image_versions.rs (image_var_for_app +
# containers_for_stack). image_versions.rs is the canonical mapping; keep this in
# sync when you add an app there.
#
# Usage:
# scripts/generate-app-catalog.sh [output-path]
# # then publish: push releases/app-catalog.json to the OVH gitea (raw URL).
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT="${1:-$ROOT/releases/app-catalog.json}"
# Export every *_IMAGE var (and ARCHY_REGISTRY) so python can read them.
set -a
# shellcheck disable=SC1091
source "$ROOT/scripts/image-versions.sh"
set +a
UPDATED="$(date -u +%Y-%m-%d)" OUT="$OUT" python3 - <<'PY'
import json, os
def img(var):
v = os.environ.get(var)
return v if v else None
def tag(image):
# version = tag after the LAST colon that follows the last slash
if not image:
return None
tail = image.rsplit('/', 1)[-1]
return tail.rsplit(':', 1)[1] if ':' in tail else 'latest'
# Single-container apps: app_id -> primary image variable.
SINGLE = {
"bitcoin-knots": "BITCOIN_KNOTS_IMAGE",
"lnd": "LND_IMAGE",
"electrumx": "ELECTRUMX_IMAGE",
"bitcoin-ui": "BITCOIN_UI_IMAGE",
"lnd-ui": "LND_UI_IMAGE",
"electrs-ui": "ELECTRS_UI_IMAGE",
"homeassistant": "HOMEASSISTANT_IMAGE",
"grafana": "GRAFANA_IMAGE",
"uptime-kuma": "UPTIME_KUMA_IMAGE",
"jellyfin": "JELLYFIN_IMAGE",
"photoprism": "PHOTOPRISM_IMAGE",
"ollama": "OLLAMA_IMAGE",
"vaultwarden": "VAULTWARDEN_IMAGE",
"nextcloud": "NEXTCLOUD_IMAGE",
"searxng": "SEARXNG_IMAGE",
"cryptpad": "CRYPTPAD_IMAGE",
"filebrowser": "FILEBROWSER_IMAGE",
"nginx-proxy-manager": "NPM_IMAGE",
"portainer": "PORTAINER_IMAGE",
"tailscale": "TAILSCALE_IMAGE",
"fedimint": "FEDIMINT_IMAGE",
"fedimint-gateway": "FEDIMINT_GATEWAY_IMAGE",
"nostr-rs-relay": "NOSTR_RS_RELAY_IMAGE",
"nostr-vpn": "NOSTR_VPN_IMAGE",
"fips": "FIPS_IMAGE",
"routstr": "ROUTSTR_IMAGE",
"adguardhome": "ADGUARDHOME_IMAGE",
}
# Stack apps: app_id -> {container_name: image variable}. The FIRST entry is the
# primary (its version drives the badge); it is also emitted as `image`.
STACK = {
"indeedhub": {
"indeedhub": "INDEEDHUB_IMAGE",
"indeedhub-api": "INDEEDHUB_API_IMAGE",
"indeedhub-ffmpeg": "INDEEDHUB_FFMPEG_IMAGE",
},
"immich": {
"immich_server": "IMMICH_SERVER_IMAGE",
"immich_postgres": "IMMICH_POSTGRES_IMAGE",
"immich_redis": "REDIS_IMAGE",
},
"penpot": {
"penpot-frontend": "PENPOT_FRONTEND_IMAGE",
"penpot-backend": "PENPOT_BACKEND_IMAGE",
"penpot-exporter": "PENPOT_EXPORTER_IMAGE",
"penpot-postgres": "PENPOT_POSTGRES_IMAGE",
"penpot-valkey": "PENPOT_VALKEY_IMAGE",
},
"mempool": {
"archy-mempool-web": "MEMPOOL_WEB_IMAGE",
"mempool-api": "MEMPOOL_BACKEND_IMAGE",
"archy-mempool-db": "MARIADB_IMAGE",
},
"btcpay": {
"btcpay-server": "BTCPAY_IMAGE",
"archy-nbxplorer": "NBXPLORER_IMAGE",
"archy-btcpay-db": "BTCPAY_POSTGRES_IMAGE",
},
}
apps = {}
for app_id, var in SINGLE.items():
image = img(var)
if image:
apps[app_id] = {"version": tag(image), "image": image}
for app_id, comps in STACK.items():
images = {name: img(var) for name, var in comps.items() if img(var)}
if not images:
continue
primary_name = next(iter(comps)) # first listed = primary
primary_image = img(comps[primary_name])
entry = {"version": tag(primary_image)}
if primary_image:
entry["image"] = primary_image
entry["images"] = images
apps[app_id] = entry
catalog = {
"schema": 1,
"updated": os.environ["UPDATED"],
"apps": dict(sorted(apps.items())),
}
with open(os.environ["OUT"], "w") as f:
json.dump(catalog, f, indent=2)
f.write("\n")
print(f"Wrote {os.environ['OUT']} with {len(apps)} apps")
PY
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Cryptographically verify that a node's on-disk keys are deterministically
derived from its onboarding seed, exactly as documented in core/archipelago/
src/seed.rs:
BIP-39 mnemonic (24 words)
-> PBKDF2-HMAC-SHA512(2048, salt="mnemonic") = 64-byte seed
-> HKDF-SHA256(salt=None, IKM=seed, info=<domain>) = each 32-byte key
"archipelago/node/ed25519/v1" -> node_key (=> Node DID)
"archipelago/nostr-node/secp256k1/v1" -> nostr_secret (=> npub)
"archipelago/fips/secp256k1/v1" -> fips_key (FIPS transport)
It compares each freshly-derived key against the bytes actually on disk under
/var/lib/archipelago/identity/. A MATCH proves the on-disk key was derived from
the seed (and nothing else). Also prints the resulting did:key for cross-check
against Settings -> Node DID.
Usage (run on the node):
sudo python3 verify-seed-derivation.py
# paste the 24-word mnemonic when prompted (input is hidden, never logged)
Pure standard library no third-party crypto packages required.
"""
import sys, os, hmac, hashlib, getpass, unicodedata
IDENT = "/var/lib/archipelago/identity"
DOMAINS = {
"node_key (=> Node DID)": (b"archipelago/node/ed25519/v1", f"{IDENT}/node_key", "raw"),
"nostr_secret (=> node npub)": (b"archipelago/nostr-node/secp256k1/v1", f"{IDENT}/nostr_secret", "nsec"),
"fips_key (FIPS transport)": (b"archipelago/fips/secp256k1/v1", f"{IDENT}/fips_key", "nsec"),
}
def hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""RFC 5869 HKDF-SHA256 with salt=None (== HashLen zero bytes)."""
salt = b"\x00" * hashlib.sha256().digest_size
prk = hmac.new(salt, ikm, hashlib.sha256).digest()
okm, t, i = b"", b"", 1
while len(okm) < length:
t = hmac.new(prk, t + info + bytes([i]), hashlib.sha256).digest()
okm += t
i += 1
return okm[:length]
# --- minimal bech32 decode (BIP-173) to recover the 32-byte secret from nsec ---
_B32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
def _bech32_decode_data(s: str) -> bytes:
s = s.strip().lower()
pos = s.rfind("1")
data = [_B32.index(c) for c in s[pos + 1:]]
data = data[:-6] # drop 6-char checksum
# convert 5-bit groups -> 8-bit bytes
acc = bits = 0
out = bytearray()
for v in data:
acc = (acc << 5) | v
bits += 5
if bits >= 8:
bits -= 8
out.append((acc >> bits) & 0xFF)
return bytes(out)
# --- minimal base58btc + multicodec to render did:key from the ed25519 pubkey ---
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
def _b58(b: bytes) -> str:
n = int.from_bytes(b, "big")
s = ""
while n:
n, r = divmod(n, 58)
s = _B58[r] + s
return "1" * (len(b) - len(b.lstrip(b"\x00"))) + s
def did_key_from_ed25519_pub(pub: bytes) -> str:
return "did:key:z" + _b58(b"\xed\x01" + pub) # 0xed01 = ed25519-pub multicodec
def main() -> int:
if not os.path.isdir(IDENT):
print(f"!! {IDENT} not found — run this on a node.")
return 2
mnemonic = getpass.getpass("Paste the node's 24-word mnemonic (hidden): ").strip()
words = mnemonic.split()
if len(words) != 24:
print(f"!! expected 24 words, got {len(words)}")
return 2
# BIP-39: seed = PBKDF2-HMAC-SHA512(NFKD(mnemonic), "mnemonic"+passphrase, 2048, 64)
norm = unicodedata.normalize("NFKD", " ".join(words)).encode("utf-8")
seed = hashlib.pbkdf2_hmac("sha512", norm, b"mnemonic", 2048, 64)
all_ok = True
for name, (info, path, fmt) in DOMAINS.items():
derived = hkdf_sha256(seed, info, 32)
try:
raw = open(path, "rb").read()
disk = raw if fmt == "raw" else _bech32_decode_data(raw.decode().strip())
disk = disk[:32]
except Exception as e:
print(f"[{name}] could not read {path}: {e}")
all_ok = False
continue
ok = disk == derived
all_ok &= ok
print(f"[{'MATCH ✅' if ok else 'MISMATCH ❌'}] {name}")
print(f" derived(seed): {derived.hex()}")
print(f" on-disk : {disk.hex()}")
# Render the Node DID from node_key.pub for a visual cross-check vs the UI.
try:
pub = open(f"{IDENT}/node_key.pub", "rb").read()[:32]
print(f"\nNode DID (from node_key.pub): {did_key_from_ed25519_pub(pub)}")
print(" ^ should equal Settings -> Node DID")
except Exception:
pass
print("\n==> ALL KEYS SEED-DERIVED ✅" if all_ok else "\n==> SOME KEYS DID NOT MATCH ❌")
return 0 if all_ok else 1
if __name__ == "__main__":
sys.exit(main())
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Multi-node RPC harness library.
#
# Unlike tests/lifecycle/lib/rpc.bash (which targets a single ARCHY_HOST),
# this drives N independent archipelago nodes in one run so we can exercise
# real node-to-node paths: federation sync over Tor, FIPS anchoring, etc.
#
# A "node handle" is a short label (e.g. A, B, alice). For each handle you
# register a base URL + UI password; the lib logs in and keeps that node's
# session/CSRF cookies in its own state file so calls never cross wires.
#
# Usage:
# source tests/multinode/lib/multinode.bash
# node_register A https://192.168.1.228 password123
# node_register B http://192.168.1.116 'ThisIsWeb54321@'
# node_login A; node_login B
# node_rpc A node.tor-address
# node_result B federation.list-nodes
#
# Requires: curl, jq.
#
# Note: this is a library — it does NOT set shell options (set -u/-e), since
# that would leak into the sourcing script. Each function guards its own vars
# with ${var:-} defaults. Callers set their own options.
# Where per-node session state lives (one file per handle).
MULTINODE_STATE_DIR="${MULTINODE_STATE_DIR:-/tmp/archy-multinode}"
mkdir -p "$MULTINODE_STATE_DIR"
# handle -> base url / password, kept in associative arrays.
declare -gA _MN_URL
declare -gA _MN_PW
declare -gA _MN_SESSION
declare -gA _MN_CSRF
# node_register HANDLE BASE_URL PASSWORD
node_register() {
local h="$1" url="$2" pw="$3"
_MN_URL[$h]="${url%/}"
_MN_PW[$h]="$pw"
}
_mn_session_file() { echo "$MULTINODE_STATE_DIR/session-$1"; }
# node_login HANDLE — authenticate and capture session + csrf cookies.
node_login() {
local h="$1"
local url="${_MN_URL[$h]:-}" pw="${_MN_PW[$h]:-}"
if [[ -z "$url" || -z "$pw" ]]; then
echo "node_login: handle '$h' not registered" >&2
return 1
fi
local headers; headers=$(mktemp)
local body
body=$(curl -sk -D "$headers" -X POST "${url}/rpc/v1" \
-H 'Content-Type: application/json' \
--data-raw "{\"jsonrpc\":\"2.0\",\"method\":\"auth.login\",\"params\":{\"password\":\"${pw}\"},\"id\":1}")
local err; err=$(echo "$body" | jq -r '.error.message // empty' 2>/dev/null)
if [[ -n "$err" ]]; then
echo "node_login[$h] failed: $err" >&2
rm -f "$headers"
return 1
fi
local session csrf
session=$(grep -i '^set-cookie: session=' "$headers" | head -1 | sed -E 's/.*session=([^;]+).*/\1/' | tr -d '\r')
csrf=$(grep -i '^set-cookie: csrf_token=' "$headers" | head -1 | sed -E 's/.*csrf_token=([^;]+).*/\1/' | tr -d '\r')
rm -f "$headers"
if [[ -z "$session" || -z "$csrf" ]]; then
echo "node_login[$h]: missing session/csrf cookie" >&2
return 1
fi
_MN_SESSION[$h]="$session"
_MN_CSRF[$h]="$csrf"
printf '%s\n%s\n' "$session" "$csrf" > "$(_mn_session_file "$h")"
}
# node_rpc HANDLE METHOD [PARAMS_JSON] — raw JSON-RPC response on stdout.
node_rpc() {
local h="$1" method="$2" params="${3:-}"
local url="${_MN_URL[$h]:-}"
local session="${_MN_SESSION[$h]:-}" csrf="${_MN_CSRF[$h]:-}"
if [[ -z "$session" || -z "$csrf" ]] && [[ -f "$(_mn_session_file "$h")" ]]; then
mapfile -t lines < "$(_mn_session_file "$h")"
session="${lines[0]:-}"; csrf="${lines[1]:-}"
_MN_SESSION[$h]="$session"; _MN_CSRF[$h]="$csrf"
fi
local payload
if [[ -z "$params" ]]; then
payload=$(jq -nc --arg m "$method" '{jsonrpc:"2.0",method:$m,id:1}')
else
payload=$(jq -nc --arg m "$method" --argjson p "$params" '{jsonrpc:"2.0",method:$m,params:$p,id:1}')
fi
curl -sk -X POST "${url}/rpc/v1" \
-H 'Content-Type: application/json' \
-H "Cookie: session=${session}; csrf_token=${csrf}" \
-H "X-CSRF-Token: ${csrf}" \
--data-raw "$payload"
}
# node_result HANDLE METHOD [PARAMS_JSON] — .result on success; prints error to
# stderr and returns non-zero on RPC error.
node_result() {
local resp; resp=$(node_rpc "$@")
local err; err=$(echo "$resp" | jq -r '.error.message // empty' 2>/dev/null)
if [[ -n "$err" ]]; then
echo "node_result[$1 $2] error: $err" >&2
return 1
fi
echo "$resp" | jq '.result'
}
# node_onion HANDLE — echo this node's own .onion address (empty if none).
node_onion() {
node_result "$1" node.tor-address 2>/dev/null | jq -r '. // empty | if type=="object" then (.onion // .address // .tor_address // empty) else . end' 2>/dev/null
}
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Controlled two-node reproduction of node-to-node federation sync.
#
# Pairs two real nodes via federation.invite/join, triggers federation.sync-state
# in both directions, and reports which transport actually carried the call and
# any per-peer error. This is the controlled repro for the reported
# "Tor connection cloud->node not working" symptom: raw Tor transport is known
# good (see README), so this isolates whether the APP-level sync path works and,
# if it fails, surfaces the exact error string.
#
# Env (override as needed):
# A_URL A_PW node A base url + UI password (default .116 http)
# B_URL B_PW node B base url + UI password (default .228 https)
# FORCE_TOR=1 set both nodes' federation transport preference to Tor first
#
# Usage: tests/multinode/repro-federation-sync.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/lib/multinode.bash"
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
bar() { printf '\n=== %s ===\n' "$*"; }
node_register A "$A_URL" "$A_PW"
node_register B "$B_URL" "$B_PW"
bar "login"
node_login A || { echo "A login failed"; exit 1; }
node_login B || { echo "B login failed"; exit 1; }
echo "A=$A_URL B=$B_URL logged in"
bar "onions"
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
echo "A onion: ${A_ONION:-<none>}"
echo "B onion: ${B_ONION:-<none>}"
if [[ "${FORCE_TOR:-0}" == "1" ]]; then
bar "force federation transport = tor on both"
node_rpc A transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
node_rpc B transport.set-preference '{"service":"federation","pref":"tor"}' | jq -c '.result // .error'
fi
bar "federation state BEFORE"
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") did=\(.did[0:24])… last_seen=\(.last_seen // "never")"' 2>/dev/null || echo " (none/err)"
bar "pair: A invites, B joins"
INV_A=$(node_result A federation.invite)
CODE_A=$(echo "$INV_A" | jq -r '.code // empty')
echo "A invite code: ${CODE_A:0:40}"
if [[ -n "$CODE_A" ]]; then
node_result B federation.join "$(jq -nc --arg c "$CODE_A" '{code:$c}')" \
&& echo "B joined A" || echo "B join FAILED"
fi
bar "pair: B invites, A joins"
INV_B=$(node_result B federation.invite)
CODE_B=$(echo "$INV_B" | jq -r '.code // empty')
echo "B invite code: ${CODE_B:0:40}"
if [[ -n "$CODE_B" ]]; then
node_result A federation.join "$(jq -nc --arg c "$CODE_B" '{code:$c}')" \
&& echo "A joined B" || echo "A join FAILED"
fi
bar "trigger sync-state on A (A dials its peers)"
node_result A federation.sync-state | jq '.'
bar "trigger sync-state on B (B dials its peers)"
node_result B federation.sync-state | jq '.'
bar "federation state AFTER (look for fresh last_seen + transport)"
echo "A knows:"; node_result A federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
echo "B knows:"; node_result B federation.list-nodes | jq -r '.[]? | " \(.name // "?") last_seen=\(.last_seen // "never") transport=\(.last_transport // .transport // "?")"' 2>/dev/null
bar "done"
+156
View File
@@ -0,0 +1,156 @@
#!/usr/bin/env bash
# Two-node (optionally three-node) end-to-end smoke suite for the full app.
#
# Unlike repro-federation-sync.sh (a diagnostic that just prints state), this
# is an ASSERTION suite: every check is pass/fail and the script exits non-zero
# if any required check fails. It exercises the real node-to-node surface and
# specifically guards the bugs fixed in v1.7.94 / v1.7.95:
# - FIPS auto-connects to the public anchor (v1.7.94)
# - peer content browse works over the mesh, not just Tor (v1.7.95 — the
# `/content` catalog used to 404 over FIPS and never fall back to Tor)
# - a removed federation node stays removed, incl. transitive re-discovery
# (v1.7.95 tombstone) — the transitive case needs node C.
#
# Nodes (override via env):
# A_URL A_PW node A (default .116 http)
# B_URL B_PW node B (default .228 https)
# C_URL C_PW node C (OPTIONAL — enables the transitive-tombstone test)
#
# Requires both nodes on v1.7.95-alpha+ for the content-browse and tombstone
# checks; older peers SKIP those (reported, not failed).
#
# Usage:
# tests/multinode/smoke.sh
# A_URL=http://192.168.1.116 B_URL=https://192.168.1.228 tests/multinode/smoke.sh
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$HERE/lib/multinode.bash"
A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:-ThisIsWeb54321@}"
B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:-password123}"
C_URL="${C_URL:-}"; C_PW="${C_PW:-}"
# ── tiny assertion framework ──────────────────────────────────────────────
PASS=0; FAIL=0; SKIP=0
declare -a FAILED_NAMES
green() { printf '\033[32m%s\033[0m' "$*"; }
red() { printf '\033[31m%s\033[0m' "$*"; }
yellow(){ printf '\033[33m%s\033[0m' "$*"; }
section() { printf '\n\033[1m── %s ──\033[0m\n' "$*"; }
ok() { printf ' %s %s\n' "$(green ✓)" "$1"; PASS=$((PASS+1)); }
no() { printf ' %s %s\n' "$(red ✗)" "$1"; FAIL=$((FAIL+1)); FAILED_NAMES+=("$1"); }
skip() { printf ' %s %s (%s)\n' "$(yellow —)" "$1" "$2"; SKIP=$((SKIP+1)); }
# assert_eq NAME EXPECTED ACTUAL
assert_eq() { [[ "$2" == "$3" ]] && ok "$1" || no "$1 (expected '$2', got '$3')"; }
# assert_true NAME VALUE — passes when VALUE is "true"
assert_true() { [[ "$2" == "true" ]] && ok "$1" || no "$1 (got '$2')"; }
# did_of HANDLE — this node's own DID via node.did (string or {did:...}).
did_of() {
node_result "$1" node.did 2>/dev/null \
| jq -r 'if type=="string" then . elif type=="object" then (.did // .node_did // empty) else empty end' 2>/dev/null
}
# pair HANDLE_INVITER HANDLE_JOINER — invite + join one direction. Echo "ok"/"fail".
pair() {
local inv code
inv=$(node_result "$1" federation.invite 2>/dev/null)
code=$(echo "$inv" | jq -r '.code // empty' 2>/dev/null)
[[ -z "$code" ]] && { echo "fail"; return; }
if node_result "$2" federation.join "$(jq -nc --arg c "$code" '{code:$c}')" >/dev/null 2>&1; then
echo "ok"
else echo "fail"; fi
}
node_register A "$A_URL" "$A_PW"
node_register B "$B_URL" "$B_PW"
HAVE_C=0
if [[ -n "$C_URL" && -n "$C_PW" ]]; then node_register C "$C_URL" "$C_PW"; HAVE_C=1; fi
# ── 1. reachability + auth ────────────────────────────────────────────────
section "reachability + login"
node_login A && ok "A login ($A_URL)" || { no "A login ($A_URL)"; echo "A unreachable — aborting"; exit 1; }
node_login B && ok "B login ($B_URL)" || { no "B login ($B_URL)"; echo "B unreachable — aborting"; exit 1; }
if [[ $HAVE_C == 1 ]]; then node_login C && ok "C login ($C_URL)" || { no "C login"; HAVE_C=0; }; fi
A_ONION=$(node_onion A); B_ONION=$(node_onion B)
[[ -n "$A_ONION" ]] && ok "A has onion address" || no "A has onion address"
[[ -n "$B_ONION" ]] && ok "B has onion address" || no "B has onion address"
# ── 2. FIPS mesh: daemon up + anchor connected (v1.7.94) ──────────────────
section "FIPS mesh / anchor"
for h in A B; do
s=$(node_result "$h" fips.status 2>/dev/null)
if [[ -z "$s" ]]; then skip "$h fips.status" "no FIPS RPC (old build?)"; continue; fi
assert_true "$h FIPS service active" "$(echo "$s" | jq -r '.service_active')"
ac=$(echo "$s" | jq -r '.anchor_connected')
if [[ "$ac" == "true" ]]; then ok "$h anchor connected"
else skip "$h anchor connected" "anchor_connected=$ac — node may need v1.7.94 + a moment to handshake"; fi
done
# ── 3. federation pairing (both directions) ───────────────────────────────
section "federation pairing"
assert_eq "A invites, B joins" "ok" "$(pair A B)"
assert_eq "B invites, A joins" "ok" "$(pair B A)"
# both should now list each other
node_result A federation.sync-state >/dev/null 2>&1
node_result B federation.sync-state >/dev/null 2>&1
A_SEES_B=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg o "${B_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
B_SEES_A=$(node_result B federation.list-nodes 2>/dev/null | jq -r --arg o "${A_ONION%.onion}" 'any((.nodes // .)[]?; (.onion // "" | gsub("\\.onion$";"")) == $o)')
assert_true "A's node list contains B" "$A_SEES_B"
assert_true "B's node list contains A" "$B_SEES_A"
# ── 4. peer content browse over the mesh (v1.7.95 fix) ────────────────────
section "peer content browse (was: 404 over mesh, no Tor fallback)"
if [[ -n "$B_ONION" ]]; then
resp=$(node_rpc A content.browse-peer "$(jq -nc --arg o "$B_ONION" '{onion:$o}')")
err=$(echo "$resp" | jq -r '.error.message // empty')
if [[ -z "$err" ]]; then
ok "A browses B's content catalog (HTTP 200)"
elif echo "$err" | grep -q '404'; then
no "A browses B's content — still 404 over mesh (is B on v1.7.95?): $err"
else
# Other errors (peer offline, no content shared) are environmental, not the bug.
skip "A browses B's content" "non-404 error: $err"
fi
else
skip "A browses B's content" "B has no onion"
fi
# ── 5. removed-node tombstone (v1.7.95) ───────────────────────────────────
section "removed-node tombstone"
B_DID=$(did_of B)
if [[ -z "$B_DID" ]]; then
skip "remove B then verify stays removed" "couldn't resolve B's DID"
else
if node_result A federation.remove-node "$(jq -nc --arg d "$B_DID" '{did:$d}')" >/dev/null 2>&1; then
still=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_eq "B removed from A's list" "false" "$still"
# Transitive test needs C: A federated with B and C; C federated with B;
# A removes B; A syncs with C (who advertises B) → B must NOT reappear.
if [[ $HAVE_C == 1 ]]; then
pair A C >/dev/null; pair C A >/dev/null; pair C B >/dev/null
node_result A federation.sync-state >/dev/null 2>&1
reappeared=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_eq "B does NOT reappear via transitive sync with C" "false" "$reappeared"
else
skip "transitive reappear via 3rd node" "set C_URL/C_PW to enable"
fi
# re-add restores B (explicit re-add clears the tombstone)
pair B A >/dev/null
node_result A federation.sync-state >/dev/null 2>&1
readded=$(node_result A federation.list-nodes 2>/dev/null | jq -r --arg d "$B_DID" 'any((.nodes // .)[]?; .did == $d)')
assert_true "explicit re-pair brings B back (tombstone cleared)" "$readded"
else
skip "remove B" "remove-node RPC failed (B may already be absent)"
fi
fi
# ── summary ───────────────────────────────────────────────────────────────
section "summary"
printf ' %s passed, %s failed, %s skipped\n' "$(green $PASS)" "$([[ $FAIL -gt 0 ]] && red $FAIL || echo $FAIL)" "$(yellow $SKIP)"
if [[ $FAIL -gt 0 ]]; then
printf ' failed:\n'; for n in "${FAILED_NAMES[@]}"; do printf ' - %s\n' "$n"; done
exit 1
fi
echo " all required checks passed"
+260
View File
@@ -0,0 +1,260 @@
# ▶▶ SESSION SAVE / RESUME (2026-06-16) — v1.7.97-alpha CUT, mid-rollout
**v1.7.97-alpha is BUILT + TAGGED LOCALLY but NOT yet published to the fleet.**
- Release commit `47c16971` ("chore: release v1.7.97-alpha") + tag `v1.7.97-alpha` exist on LOCAL main only. NOT pushed to gitea-vps2. Fleet still sees 1.7.96-alpha.
- Contents (14 fixes + image-opt): B5,B1,B2,B4,B14,B21,B3,B15,B7,B13,B12,B16,**B17**, B6-pruned-gate + lossless background-image optimization (bg-mesh PNG→JPEG).
- Release artifacts staged: `releases/v1.7.97-alpha/{archipelago, archipelago-frontend-1.7.97-alpha.tar.gz}` + `/tmp/archipelago-frontend-1.7.97-alpha.tar.gz` (177MB, flat layout verified, optimized images baked in, no APK).
- **Deployed (sideload, NOT fleet OTA):** .116 = on 1.7.97-alpha, healthy, B17 self-heal CONFIRMED (unit now has RequiresMountsFor, 36 containers survived restart). .198 = deploying (sideload binary+frontend).
- **Backup binaries for rollback:** `/usr/local/bin/archipelago.1.7.96-alpha.bak` on .116 and .198.
**REMAINING (this session, user wants to do WITH them):**
1. Finish .198 sideload; then **UI-confirm fixes together on .116/.198** + close passing Gitea issues (#8,#9,#10,#11,#12,#14,#19(code-only),#20,#21,#22,#23,#24,#29). Issue map below.
2. **Publish to fleet:** `scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2` + `git push gitea-vps2 main + tag` (AFTER joint confirm — user's call).
3. **Cut a fresh ISO** (bakes B13 nginx + B17 unit + all frontend). ISO builds run on a server (deploy-to-target / .228). Then test the ISO together.
⚠️ LESSON: never run the release binary to "check --version" — it has no such flag and BOOTS A FULL NODE (adopts containers, grabs mesh radio). Use `strings <bin> | grep version`. (Did this on .116; the instance exited on the :5678 port conflict, no harm.)
---
# ▶▶ SESSION SAVE / RESUME (2026-06-15)
**State:** v1.7.96-alpha SHIPPED. v1.7.97-alpha NOT cut yet — 10 fixes committed on **vps2 main** (`git remote: gitea-vps2`), nothing on the fleet yet. Validate on .116/.198 + UI-confirm BEFORE cutting .97.
**Resume command (run elsewhere):**
```
cd ~/Projects/archy && git fetch gitea-vps2 && git checkout main && git reset --hard gitea-vps2/main && cat tests/production-quality/TRACKER.md
```
Then continue from "IN PROGRESS" below.
**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7, **B13 (fedimint CSS self-heal — main conf + HTTPS snippet, verified .198 both paths app-icon 404→200)**, **B12 (mempool bitcoin-host detect across 3 render paths — unit-tested; live bitcoin-core validation pending)**, **B16 (bitcoin sync tile retain/Updating… — unit-tested 6/6, commit 83dbd25c)**. B6 pruned-gate already live. = 13 fixes. PLUS **image-optimization** (commit 386d4bfc — all bg images losslessly optimized, bg-mesh PNG→JPEG; user asked to include it in the .97 release).
**IN PROGRESS — B16 DONE (commit 83dbd25c). Pick up at B6 no-node-present half.** B13 + B12 + B16 DONE (committed; see entries below). REMAINING:
1. **B6** no-node-present half, **B12b** (sibling bitcoin-host hardcodes: LND/BTCPay/electrumx/fedimint + mempool dep declaration — reuse `{{BITCOIN_HOST}}`; needs validation, esp. LND/fedimint), **B14b** (FIPS reachability depth), **B22/B23** (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature).
3. **Loose end:** 4 pre-existing prod_orchestrator test failures (generated-files/data_uid fixtures use disallowed tempdir volume sources) — see B12 NOTE; separate small fix.
Note: .198 is running a sideloaded B13-era .97-dev binary (md5 4c83803d). The B12 binary was built (`core/target/release/archipelago`) but NOT sideloaded (mempool isn't on .198; .198 is Knots so B12 is a no-op there). Reflashing/OTA replaces the dev binary.
**Ship .97 when ready:** ./scripts/create-release.sh 1.7.97-alpha (curate CHANGELOG ≥3 layman bullets first + run scripts/sync-whats-new.py; SKIP_RELEASE_TESTS=1 only for the 2 known-flaky vitest timing tests) → scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2 → git push gitea-vps2 main + tag. (gitea-local push fails: token rejected — non-blocking.)
---
# Production-Quality Bug Tracker
Living tracker for the post-v1.7.96 "no new features until production quality" push.
Updated continuously as we investigate → fix → test → pass. Kept in-repo so progress
survives a session cutoff.
## Rules (from user, 2026-06-15)
- **No new features** until the OS is production / no-bugs quality.
- **Test-harness-first**: build/extend a harness for each bug before fixing.
- **Validate every fix on `.116` + `.198`** (both 192.168.1.x, pw ThisIsWeb54321@) **+ the harness** BEFORE it goes into any release. (.198 still carries the LND CORS nginx duplicate → good for fix-(a) validation; .116 does not.)
- **Priority order**: cloud/federated-nodes + mesh FIRST, then app-specific, then low-pri.
## Status legend
`TODO` · `INVESTIGATING` · `ROOT-CAUSED` · `FIXING` · `TESTING` (on .116+harness) · `PASSED` · `SHIPPED`
## Release status
- **v1.7.96-alpha — SHIPPED** (2026-06-15). Live on vps2 (primary OTA): manifest v1.7.96-alpha, assets HTTP 200, `main@8c3c7954` + tag present. Contents: kiosk grid removal + FIPS TCP/UDP anchor selector. NOTE: gitea-local (localhost) mirror push failed (token rejected → /login); non-blocking, needs refreshed token.
- **v1.7.97-alpha — IN PROGRESS** (this push). Will bundle the verified fixes below.
---
## 🔴🔴 TOP PRIORITY
### B5 — LND "connect your wallet" details/QR broken fleet-wide — ROOT-CAUSED
Origin: user escalation. Symptom: LND connect screen (served on app port :18083) can't load details/QR.
Two distinct root causes (confirmed live):
- **(a) Duplicate ACAO** on `/lnd-connect-info` (seen on .103): backend sets `Access-Control-Allow-Origin` (proxy.rs:108) AND nginx `add_header` adds a second → browser rejects "multiple values". nginx config drift. Fix: bootstrap.rs nginx patch must strip the redundant `add_header` from the `/lnd-connect-info` location (backend owns CORS).
- **(b) No ACAO on `/proxy/lnd/v1/*` 401** (fleet-wide): the unauth/auth-layer 401 is produced before the CORS-adding proxy handler (proxy.rs:135 `handle_lnd_proxy`). Browser → "No 'Access-Control-Allow-Origin' header". Fix: ensure auth-layer/early-return responses for `/proxy/lnd` + `/lnd-connect-info` carry CORS headers.
- `.116` `/lnd-connect-info` returns a single correct ACAO → symptom varies by node's nginx state.
- Backend CORS helper: handler/mod.rs `app_cors_origin()` (:270) — reflects Origin when its host == request host.
- Backend change → ships in .97. **Status: ✅ PASSED — verified on .116, .198, .103 (harness 4/4 each). Ready to bundle into .97.**
- Caveat: bootstrap's nginx dup-strip runs a few seconds AFTER /health goes green (async patch+reload) — converges within ~1 min of restart; not instant. Acceptable.
- **CODE CHANGES MADE (uncommitted):**
- `core/archipelago/src/bootstrap.rs`: added `NGINX_LND_DUP_CORS` const + strip in `patch_nginx_conf()` (removes the duplicate nginx `add_header` ACAO from `/lnd-connect-info` so the backend's single header wins). Idempotent; runs on startup nginx bootstrap. → fixes (a)
- `core/archipelago/src/api/handler/mod.rs`: new `unauthorized_cors(origin)` helper (:~205) + `/proxy/lnd/` route (:~505) computes origin first and returns `unauthorized_cors` so the 401 carries ACAO. → fixes (b)
- Test on **.116** for (b); test on **.103** for (a) [.116 has no dup to strip].
- **2026-06-15 RESULT — .116 (fix b): harness 4/4 PASS** (sideloaded built binary, restarted). `/proxy/lnd/v1/*` now returns CORS on the 401. ✅
- (Correction: an earlier "LND container MISSING" reading was a FALSE alarm — `docker` isn't in the non-interactive PATH; runtime is **podman**. Verified `lnd Up 9h` — containers SURVIVED the restart cleanly.)
- Next: deploy to .103 + run harness to confirm fix (a) (nginx dup strip).
- **Harness:** `tests/production-quality/lnd-cors-test.sh <node>` — asserts single correct ACAO on /lnd-connect-info + ACAO present on /proxy/lnd/v1/{getinfo,channels}. Baseline (2026-06-15): .116 = 2 pass/2 fail (proxy missing ACAO); .103 = 1 pass/3 fail (connect-info dup + proxy missing).
- **FIX PLAN (precise):**
1. (b) handler/mod.rs:504-508 `/proxy/lnd/` returns `Self::unauthorized()` (401, NO CORS) when session check fails → browser CORS wall. Add CORS (app_cors_origin) to that 401. Same pattern for any other app-origin early-return.
2. (a) nginx `/lnd-connect-info` location double-adds ACAO (backend + nginx `add_header`). Strip the nginx `add_header Access-Control-Allow-Origin` there; backend owns CORS. Update bootstrap.rs nginx patch to remove it on existing nodes (idempotent).
- Verify: rebuild backend, deploy to .116, run harness → expect 3/3 (or 4 assertions) PASS on .116 AND .103.
---
## 🔴 PRIORITY — cloud / federation / mesh
### B1 — Trusted-node list not clean — PASSED (onion-dedup; unit test 2/2; live .198 15→13 distinct, healthy). UI visual-confirm recommended.
Dupes, erroneous names, and non-convergent group membership across nodes. Expected: trusted nodes form a transitive group (every node connects to any newly-added trusted node; all nodes show the same set). `.103` has a long/dirty list.
### B2 — Duplicate chat contact for one node — PASSED (resolved by load-dedup feeding mesh seed; unit-tested). UI visual-confirm recommended.
Federated peer "sapien" shows TWO chats: one "sapien" WITHOUT archy logo (looks non-federated) + one named by raw DID `did:key:z6MkoSbN5CM7fBaQg2nWbCymEkFXsHnuXvec9Mjo5RtJf9dQ`. Same node keyed by both federated identity and raw DID → merge to one. Code: core/archipelago/src/mesh + mesh/typed_messages.rs (note :233 — meshcore adverts don't carry archy pubkey).
### B3 — Cloud peer media won't preview/play — FIXING (code done: /api/peer-content streaming proxy + playMedia streams free content)
Music/video preview files on peer nodes' cloud don't play (streaming/range/content-type over mesh+Tor peer fetch).
### B4 — Cloud "my folders" fails (JSON parse / 502) — PASSED (content-type guard; built, guard in bundle, deployed .198). UI visual-confirm recommended.
`Unexpected token '<', "<!doctype"` when FileBrowser absent (`/app/filebrowser/api/resources` → SPA index.html), and **502** when FileBrowser is down (seen on .103). filebrowser-client.ts:102/:106. Fix: detect FileBrowser unavailable, friendly prompt; consider nginx returning JSON 404/502 for missing `/app/<app>/` instead of SPA shell. Handle BOTH absent + down.
### B14 — cloud browse transport not recorded — FIXED (record_peer_transport in 4 content handlers; build OK). NOTE: live data shows FIPS reaches only ~4/15 peers, 6 fall back to Tor genuinely → see B14b.
Browsing trusted/peer nodes in the Cloud tab connects over Tor instead of FIPS (should prefer FIPS like the rest of mesh; same for peer browsing). cf project_fips_integration, project_tor_node_to_node_works (last_transport should be fips/mesh).
---
## 🟠 APP-SPECIFIC
### B6 — ElectrumX install gate — PARTIAL (pruned-node gate already works; "no node present" half DEFERRED: false-positive risk without UI test, needs package-presence check)
Show the yellow requirement badge when no full node / only a pruned node is present (reuse existing yellow badge pattern).
### B7 — ElectrumX UI stuck loader on top — FIXED (overlay hides + iframe shows when status stale; type-check green). UI-confirm.
UI renders but a loader sits on top; possibly stale pre-sync screen not clearing.
### B9 — IndeedHub keeps stopping on nodes — TODO
Container won't stay running (crash-loop / reconcile stop). Check logs + restart policy + health.
### B10 — Immich still crashes — TODO
Recurring crash ("still" → prior attempts). Check container logs + resource limits + DB/ML deps.
### B11 — Companion app: "open in external browser" apps don't work — TODO
Apps meant to open in a new/external browser don't launch from the companion app; need the phone-default-browser request-modal pattern mobile apps use. Relates to v1.7.90 "open in new tab from companion app".
### B12 — Mempool not connecting — FIXED (mempool host detect, 3 paths; unit-tested). Live bitcoin-core validation PENDING (no core node available).
**Bigger than the original "stacks.rs:1278" framing.** `CORE_RPC_HOST=bitcoin-knots` was hardcoded in THREE env-render paths; on a bitcoin-core node the container is named `bitcoin-core`, so mempool-api can't resolve RPC. Both Knots and Core are reachable on `archy-net` by container name — only the name differs.
- **Path 1 — legacy direct-podman** (`stacks.rs::install_mempool_stack`, used when no orchestrator): now `format!("CORE_RPC_HOST={}", detect_bitcoin_rpc_host())`. FIXED.
- **Path 2 — `config.rs::get_app_config`** (install.rs legacy path): same. FIXED.
- **Path 3 — Quadlet/manifest (THE MODERN FLEET PATH, e.g. .198)**: `prod_orchestrator` renders env from `apps/mempool-api/manifest.yml` static YAML. FIXED via a new `{{BITCOIN_HOST}}` derived-env placeholder: `HostFacts.bitcoin_host` (container/manifest.rs) + `resolve_derived_env` renders it; `prod_orchestrator::bitcoin_host()` detects Knots/Core via `podman ps` (test-injectable `set_bitcoin_host_for_test`); resolved on-demand only for manifests using the placeholder (perf). mempool-api manifest moved `CORE_RPC_HOST` from static env → `derived_env: {{BITCOIN_HOST}}`.
- New helper `dependencies::detect_bitcoin_rpc_host()` + pure `pick_bitcoin_host()`.
- **TESTS (all green):** `pick_bitcoin_host` 5 cases (knots/core/plain/none/substring-safety); container-crate `resolve_derived_env` renders `{{BITCOIN_HOST}}`; orchestrator `mempool_core_rpc_host_follows_bitcoin_node` (core→bitcoin-core, knots→bitcoin-knots). No-regression verified: picker returns `bitcoin-knots` live on .198 (so Knots nodes unchanged; existing mempool installs see no env drift).
- **VALIDATION GAP:** cannot exercise on a live bitcoin-core node (none available; .198 is Knots where the fix is a no-op). Need a Core node to confirm end-to-end.
- **FOLLOW-UP (B12b, NOT done):** same hardcode exists for siblings on bitcoin-core nodes — `config.rs` lnd(:724)/btcpay(:739)/electrumx(:782), and `prod_orchestrator::resolve_dynamic_env` fedimint `FM_BITCOIND_URL=...bitcoin-knots` (~:2425). Plus mempool-api manifest `dependencies: bitcoin-knots` (line 18) is Knots-specific bookkeeping (install-time check already accepts Core via BITCOIN_NAMES, so non-blocking). All can reuse `{{BITCOIN_HOST}}`. Deferred per user (mempool-only scope) — each needs its own validation, esp. LND/fedimint.
- **NOTE (unrelated pre-existing failures):** 4 prod_orchestrator tests fail on clean HEAD too — `install_applies_data_uid_chown_before_create`, `install_writes_manifest_generated_files_before_create`, `manifest_generated_files_{do_not_overwrite_by_default,can_overwrite_when_declared}` — their fixtures pass tempdir volume sources that `validate_bind_source` rejects (only `/var/lib/archipelago/*` + 2 sockets allowed). NOT caused by B12; worth a separate fix.
mempool can't reach the Bitcoin backend on some nodes. Investigate on .116. Check mempool→electrs→bitcoind wiring + deps.
### B13 — Fedimint UI not applying CSS — FIXED + VERIFIED on .198 (both HTTP + HTTPS)
Root cause confirmed: the Fedimint Guardian page (served by :8175) is a server-rendered status page with ~7.8KB INLINE CSS plus image assets referenced root-rooted (`src="/assets/img/app-icons/fedimint.jpg"`, `url("/assets/img/bg-network.jpg")`). Without an asset rewrite those `/assets/...` URLs resolve against the archipelago SPA root: `bg-network.jpg` happens to exist there (shared design asset → loaded by luck) but `app-icons/fedimint.jpg` does NOT → **404** (the broken/visibly-missing icon). The `location /assets/` block uses `try_files $uri =404`, so missing fedimint assets 404 rather than fall through.
Fix = nginx sub_filter set that reroots every root-rooted asset URL (`href="/`, `src="/`, `url("/`, and single-quote variants) under `/app/fedimint/`, plus `proxy_set_header Accept-Encoding ""` so the upstream doesn't gzip (sub_filter can't rewrite gzipped bodies). Shipped two ways:
- **Fresh ISOs** (committed a50b6df2): templates `image-recipe/configs/nginx-archipelago.conf` (HTTP) + `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS).
- **Already-deployed nodes** (bootstrap self-heal, this commit): `core/archipelago/src/bootstrap.rs::patch_nginx_conf` now heals BOTH the main conf (Style A — swaps the old single nostr-provider sub_filter tail for the full reroot set, byte-matches the shipped template) AND the HTTPS app-proxy snippet (Style B — anchors on the unique `:8175` proxy_pass and inserts the reroot set; robust to the snippet's varying trailing directive). `missing_*` flags now gated on their splice anchors so the healed snippet early-returns cleanly (no per-boot warn-skips). Idempotent via the `'href="/' 'href="/app/fedimint/'` marker.
VERIFIED on .198 (sideloaded built binary, restart, async self-heal converged ~15s):
- HTTP `/app/fedimint/`: live conf healed byte-identical to template; app-icon **404→200 image/jpeg (41944b)**.
- HTTPS `/app/fedimint/` (snippet): healed; same app-icon **404→200**; bg-network 200; root `/assets/img/app-icons/fedimint.jpg` returns 200 **text/html** (SPA shell) — proving the reroot is necessary.
- `nginx -t` OK both times; containers survived restart (Quadlet); both files carry the marker exactly once (idempotent steady state); no warn spam in logs.
NOTE: self-healed snippet is functionally correct but NOT byte-identical to the fresh-ISO snippet template (insert-after-proxy_pass vs full block) — acceptable; nginx ignores directive order/whitespace.
### B15 — Bitcoin UI sync progress lags — FIXED (Home.vue poll 30s→10s). UI-confirm.
Bitcoin UI doesn't update its sync progress fast enough even though the console clearly already has the block-height data. Likely a polling-interval / reactive-update gap between the status source and the UI.
### B16 — Bitcoin sync status vanishes — FIXED + UNIT-TESTED (commit 83dbd25c). UI-confirm.
The bitcoin sync status in the Home > System container disappears when it should persist/cache and show an "updating" state. Related to B15 (Bitcoin UI sync lag). Root cause: the tile is gated `v-if="stats.bitcoinAvailable===true"` (HomeSystemCard.vue:60); a transient `bitcoin.getinfo` failure (RPC busy during heavy IBD, or a route-change/scan where the packages map is momentarily empty) could blank it.
FIX (commit 83dbd25c): added a `bitcoinStale` flag to homeStatus.ts —
- getinfo fails while the bitcoin container is **Running**, OR package data is momentarily **absent** → retain last-known value + `bitcoinStale=true` (tile stays, renders **"Updating…"** instead of a frozen figure shown as live).
- container authoritatively **Stopped/Exited**`bitcoinAvailable=false`, `stale=false` (no stale-as-live — genuinely down is reflected).
- first-ever poll times out but container Running (syncing node) → show the tile as updating rather than staying hidden.
Wired `bitcoinStale` through Home.vue `systemStats` → HomeSystemCard prop; card shows "Updating…" (dimmed) when stale.
**Harness:** `neode-ui/src/stores/__tests__/homeStatus.test.ts` (6 cases) — RED before fix (5/6 fail), GREEN after (6/6). `vue-tsc --noEmit` exit 0. Full vitest suite: only pre-existing AppIconGrid cross-test teardown flake (passes 7/7 standalone; not my change). UI-confirm on .116/.198 still recommended (hard to trigger transient failure on demand — unit test is the authoritative harness here).
### B17 — archipelago.service flaps on boot before starting — FIXED + VERIFIED on .198 (commit 34b1fdc1)
On some boots, `[FAILED] Failed to start archipelago.service` printed ~20× over ~5 min before starting. ROOT CAUSE (proven live on .198): on production nodes `/var/lib/archipelago` is a **separate `/dev/mapper/archipelago-data` ext4 volume** (systemd unit `var-lib-archipelago.mount`), and podman's **graphroot=`/var/lib/archipelago/containers/storage`** lives on it too. The unit ordered only `After=network-online.target` — NO mount dependency — so on cold boots the service (and its `ExecStartPre`) could start BEFORE the volume mounted, write to the bare mountpoint on rootfs, fail every podman call, exit, and be restarted every 5s (`Restart=on-failure RestartSec=5`) until the mount appeared. Smoking gun in .198's journal: `var-lib-archipelago.mount: Directory /var/lib/archipelago to mount over is not empty, mounting anyway` — the service had written there pre-mount. Dev laptop .116 has the data dir on rootfs → never flaps (explains "on some boots"). Diagnostic: every node showed `banners == "Server listening"` (process always succeeds once it runs) ⇒ failure is systemd-level, not a Rust crash.
FIX (commit 34b1fdc1): `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 `/etc/systemd/system/archipelago.service` + `daemon-reload` (boot-ordering only — effective next reboot; never restarts the running service). Idempotent; harmless on rootfs installs.
VERIFIED on .198: applied directive → `systemctl show -p After` includes `var-lib-archipelago.mount`, `systemd-analyze verify` clean → rebooted: mount@07:35:22, archipelago banner@07:35:35 (13s AFTER mount), `banners=1 listening=1 failed_to_start=0` (zero flap), directive persisted. `cargo check` EXIT 0. NOTE: self-heal CODE (auto-patch on deployed nodes) still to be exercised with the built binary on .228 (directive was applied manually on .198); residual rootfs shadow files under the mountpoint are benign.
### B18 — Apps stop right after install (or become unstartable) — TODO
Many apps install but immediately stop, requiring a manual Start — or become unstartable entirely. Likely the install→start handoff / reconciler doesn't bring them up (or starts then they exit). Related to B9 (IndeedHub stopping), B10 (Immich). Possibly linked to the cgroup-SIGKILL-on-archipelago.service-restart issue (feedback_no_systemctl_deploy_until_quadlet) — but NOTE: on .116 (Quadlet) containers survived a service restart cleanly, so the reconciler may be fine there; reproduce on the affected nodes. Check post-install start sequencing + boot_reconciler + container restart policy + cgroup placement.
### B19 — Failed download-update lands on Install button (should be Download) — TODO
When an update download fails, the UI sometimes shows the Install button instead of returning to the Download button — a big UX issue (user can't retry the download cleanly). Check the SystemUpdate state machine's error/failure transition.
### B20 — Surface bitcoin-headers-over-mesh broadcast (send/receive toggles) — TODO (feature-adjacent, surfacing existing work)
We previously broadcast bitcoin block headers over mesh to archipelago nodes but never fully surfaced it. Want two switches: "send headers" (you broadcast) and "receive headers" (you accept). NOTE: this is feature-adjacent — surfacing existing functionality; the user added it during the no-new-features push, so treat as low-priority polish until the bug list is clear. Code: mesh block-headers (mesh.block-headers RPC seen in logs; core/archipelago/src/mesh).
### B14b — FIPS reachability: many peers fall back to Tor — INVESTIGATED (needs FIPS-network depth)
Live (2026-06-15) federation sync last_transport on .116/.198: ~4 peers fips, ~6 tor, ~5 none. So beyond the recording fix (B14), FIPS genuinely doesn't reach many federated peers (they use Tor). Investigate WHY: is fips_npub known for those peers? are they FIPS-online? is the shared anchor connecting them? (cf project_fips_integration, project_tor_node_to_node_works). This is the real "Tor not FIPS" depth.
FINDINGS (.198, 2026-06-15): archipelago-fips ACTIVE; ALL 13 peers HAVE fips_npub; last_transport = 5 fips / 5 tor / 3 none. So it's NOT a missing-npub or service-down bug — FIPS genuinely reaches some peers and not others = DIAL-TIME reachability: the 'tor' peers aren't FIPS-reachable at dial time (offline, NAT, their FIPS not registered with the shared anchor), and 'none' = fully offline (X250 roam/beta/cellular). NEXT (deeper, needs FIPS-network debugging): verify a known-online peer (e.g. .228/.116) is reachable over FIPS from .198 right now; if an online FIPS peer still falls back to Tor → real anchor/registration bug; check fips daemon peer table + anchor connectivity. Likely partly peer-availability (not fully fixable in code).
### B21 — Show Tor/FIPS transport pill on cloud browse — FIXED (build+type-check green; deploy+UI-confirm on .116/.198)
Tag whether the peer connection is Tor or FIPS and surface it as a small pill on the cloud browse screens / connection loader. Data source: federation node last_transport (now recorded by B14) exposed via federation.list-nodes; frontend renders a pill (FIPS=fast/green, Tor=slower) on PeerFiles.vue / Cloud peer view + the connection loader. Frontend-only-ish. FINDINGS: PeerFiles.vue:46 loader HARDCODES 'Connecting via Tor...' even when FIPS used (bug). Frontend types already have last_transport ('fips'|'tor'|'mesh'|'lan') federation/types.ts:31; NodeList.vue:167 already renders a transport indicator. PLAN: have content.browse-peer RETURN the transport used (B14 already computes it) → frontend shows a pill (FIPS green / Tor amber) on PeerFiles header + fix the loader text to reflect actual/attempted transport. Small backend (add transport to browse response) + frontend pill.
### B22 — Peer cloud download/audio errors (.228→.198) — TODO (pairs with B3)
Observed 2026-06-15 browsing .228's cloud from .198: (a) downloading a peer cloud file → "Operation failed. Check server logs for details." (b) playing a peer AUDIO file → "Could not play audio. File Browser may not be running." (misleading — it's a peer file, not File Browser; that's the OLD base64/blob path B3 replaces). ACTION: (a) check content.download-peer backend error on .198 logs while downloading (likely the same Range/transport/timeout path as B3, or a peer-side 4xx); (b) verify B3 streaming fixes peer audio once deployed, and fix the misleading audioPlayer error string. Get server logs: ssh .198, journalctl -u archipelago | grep -i 'content\|peer\|download'.
### B23 — Archipelago group chat (all nodes) broken/slow over Tor — TODO (PRIORITY, mesh)
The all-nodes "Archipelago group" chat (over Tor) doesn't seem to work. Facets:
- (a) Group delivery unreliable / "doesn't work" over Tor.
- (b) Messages may just be VERY SLOW (latency — likely Tor-only path; should use FIPS+Tor per the new transport method like B14, preferring FIPS).
- (c) Add the SENDER CONTACT NAME to each message so you can differentiate who sent what (group messages lack attribution).
- (d) Messages sometimes DUPLICATED (dedup by message id / sender_seq — cf mesh.ts:73 cross-transport identity (sender_pubkey, sender_seq); duplicate likely from receiving same msg over both transports or re-broadcast).
Code: core/archipelago/src/mesh (typed_messages, listener), frontend Mesh.vue/stores/mesh.ts. Relates to B2 (identity), B14/B14b (transport). Test on .116/.198 (+ a Tor-only peer like .228).
### B8 — netbird app doesn't work — TODO (LOW / much later)
(RETRACTED: CryptPad placeholder-icon — user says cryptpad is fine.)
---
## 📋 vps2 Gitea issues (lfg2025/archy) — imported 2026-06-15
- G#1 [Bug] Strange peer request behaviour — TODO (likely related to B1/federation)
- G#2 [Bug] Fix flashing USB from kiosk — TODO
- G#3 [Feature] VPN Configuration — DEFERRED (feature; no new features until production quality)
- G#4 [Bug] Bitcoind is slow — TODO
- G#5 [Feature] OpenWRT and TollGate integration — DEFERRED (feature)
- G#6 [Feature] Move dashboard/monitoring link to home screen — DEFERRED (feature)
- G#7 [Bug] Scrolling with Companion app — TODO
---
## Gitea issue mapping (vps2 lfg2025/archy)
All backlog bugs now mirrored as Gitea issues: B1→#8, B2→#9, B3→#10, B4→#11, B5→#12, B6→#13, B7→#14, B8→#15, B9→#16, B10→#17, B11→#18, B12→#19, B13→#20, B14→#21, B15→#22, B16→#23, B17→#24, B18→#25, B19→#26. (Pre-existing G#17 remain; some overlap, e.g. G#1 strange-peer ≈ B1.) Close the Gitea issue when a bug is verified+shipped.
## INVESTIGATION FINDINGS 2026-06-15 (B1/B2/B3/B4/B14) — cutoff insurance
**B1 trusted-node divergence** — ROOT-CAUSED. `federation/sync.rs` `merge_transitive_peers()` (~:140) dedupes ONLY by DID; the SAME physical node appears under multiple DIDs (same `onion` + `fips_npub`) → duplicate entries ("Arch Dev" ×2, "Sapien" ×2). No background convergence → lists diverge (.103=16 nodes, .116/.198=15). Model: `federation/types.rs:24` FederatedNode (PK=did); storage `federation/storage.rs` nodes.json; add_node dedupes by DID only (:125). FIX: in merge_transitive_peers add a SECOND match arm — if no DID match, match by normalized `onion` (trim .onion); if found, treat as same node (merge fips_npub/name, don't add). Same dedup on add_node. Plus a one-time cleanup of existing dup DIDs (remove-node the stale one). TEST: after sync, all 3 nodes have identical node set, no two entries share an onion.
**B2 duplicate chat contact** — ROOT-CAUSED (same root as B1). Two federation DIDs (same onion/fips_npub, e.g. "Sapien" dids z6MkoSbN… + z6MkeYMU…) get seeded as TWO mesh contacts: `mesh/mod.rs` `seed_federation_peers_into_mesh()` (~:94) upserts per-pubkey contact_id; frontend `Mesh.vue` `mergeKeyForPeer()` (~:492) keys by DID so two DIDs = two rows. FIX: (backend) in seed, skip a node whose onion was already seeded (HashSet of onions); (frontend) Mesh.vue merge by onion when DIDs differ but onion matches. Fixing B1's onion-dedup largely resolves this too. TEST: one "Sapien" row; `mesh.peers` has one contact for the shared onion.
**B3 peer media won't play** — ROOT-CAUSED. `PeerFiles.vue` `playMedia()`/`loadPreview()` (~:358,:508) fetch the WHOLE file via RPC `content.preview-peer`/`content.download-peer` (`api/rpc/content.rs` :393,:213) which base64-encodes the entire file; frontend makes a Blob URL → browser can't Range-seek → video/large-audio won't play (+ 30/120s timeouts truncate big files). The peer's HTTP `/content/<id>` handler (`api/handler/content.rs` :49) ALREADY supports Range/206 + Accept-Ranges. FIX (bigger): add a local streaming proxy endpoint `/api/peer-content/{onion}/{id}` in `api/handler/mod.rs` that forwards the browser's Range header to the peer's `/content/<id>` (via fips::dial PeerRequest) and streams back 206 + Content-Range + Content-Type; frontend sets `<video>/<audio>` src to that URL (not a blob). TEST: curl Range on the new endpoint → 206 + Content-Range; video seeks/plays.
**B4 cloud my-folders <!doctype/502** — ROOT-CAUSED. `filebrowser-client.ts` `listDirectory()` (:99) does `res.json()` (:106) after only an `res.ok` check; when FileBrowser is ABSENT nginx serves SPA index.html (200, '<!doctype') → JSON crash; when DOWN → 502. FIX (frontend, low-risk): guard res content-type !== application/json → throw typed "FileBrowser unavailable" handled by Cloud.vue/CloudFolder.vue empty-state; same guard in login() (:71) + getUsage() (:215). OPTIONAL nginx: add `error_page 502 503 = @filebrowser_unavailable` returning JSON in the /app/filebrowser/ block (image-recipe/configs/nginx-archipelago.conf ~:411). TEST: stop filebrowser on .116/.198 → Cloud shows friendly state, no doctype crash.
**B14 cloud browse Tor-not-FIPS** — ROOT-CAUSED (nuance). FIPS-first logic WORKS (`fips/dial.rs` send_get :331 tries FIPS, falls back to Tor on 404/5xx; v1.7.94 fix). BUT the 4 content handlers in `api/rpc/content.rs` (browse :297, download :237, download_paid :356, preview :421) capture `_transport` and NEVER call `record_peer_transport()` → UI badge shows Tor/null even when FIPS used. FIX: add `record_peer_transport(data_dir, None, Some(onion), &transport.to_string())` after each successful send_get (storage.rs:84 has the fn). ⚠️ VERIFY on nodes whether FIPS is ACTUALLY used or genuinely falling back to Tor (if genuinely Tor, deeper FIPS-reachability issue beyond recording). TEST: after browse, last_transport = fips (when peer FIPS-reachable).
## INVESTIGATION FINDINGS 2026-06-15 (B6/B7/B12/B13/B15/B16) — cutoff insurance
**B13 Fedimint CSS** — app HTML (docker/fedimint-ui/index.html) uses absolute /assets/* paths; under /app/fedimint/ the browser requests /assets/* which hit the main SPA, not :8175 → unstyled. FIX: nginx sub_filter rewrite (same proven pattern as indeedhub/botfights blocks) in image-recipe/configs/nginx-archipelago.conf (/app/fedimint/ ~:641) + snippets/archipelago-https-app-proxies.conf (~:164) + bootstrap patch for existing nodes. Rewrites href/src/url '/' → '/app/fedimint/'. TEST: curl .../app/fedimint/assets/...css → 200 real CSS.
**B6 ElectrumX archival gate** — electrs needs a NON-pruned full node; install card doesn't warn at a glance. /bitcoin-status returns blockchain_info.pruned. Yellow badge pattern exists (MarketplaceAppCard.vue). FIX (frontend, simple): show a yellow "Requires a full archive Bitcoin node (not pruned)" note on the electrumx card (MarketplaceAppCard.vue ~:53). catalog.json electrumx already has requires.
**B7 ElectrumX stuck loader** — sync overlay gated by electrsSync (useElectrsSync.ts syncing = status!=='synced'); if status never flips to 'synced' (stale/crash) the overlay blocks the UI forever. AppSessionFrame.vue:44 iframe gate `!electrsSync`. FIX (frontend): fail-open — allow iframe when electrsSync?.stale (and add a timeout in useElectrsSync.ts so a slow/stale status stops blocking after ~5min).
**B15 bitcoin sync UI lag** — Home.vue:485 polls every 30s. FIX: faster bitcoin refresh (~5-10s) (separate interval for bitcoin vs system stats).
**B16 bitcoin status vanishes** — homeStatus.ts refreshBitcoin clears/leaves bitcoinAvailable null on a failed/transitional poll → HomeSystemCard.vue:60 v-if hides the card. FIX: retain last-known bitcoinAvailable on transient failure + show an "Updating…" badge instead of disappearing.
**B12 mempool not connecting** — stacks.rs:1278 + apps/mempool-api/manifest.yml:50 hardcode CORE_RPC_HOST=bitcoin-knots; on nodes running bitcoin-core (not knots) mempool-api gets getaddrinfo ENOTFOUND bitcoin-knots. Also ELECTRUM_HOST=electrumx absent on pruned nodes (docs/CONTAINER_LIFECYCLE_HANDOFF.md:654). FIX: detect which bitcoin container runs (knots vs core) + set CORE_RPC_HOST dynamically; qualify the mempool stack so it doesn't half-start without electrumx. Backend (stacks.rs) — medium risk, test on .116.
- 2026-06-15 (cont. 2): **B15 ✅** (poll 30s→10s) + **B7 ✅** (ElectrumX loader fail-open on stale) — committed `c0d41cf8`, type-check green. **B6 PARTIAL** (pruned gate already works; no-node-present half deferred). Fanned out investigations for B6/B7/B12/B13/B15/B16 — all root-caused with fix plans in FINDINGS above.
- **DEFERRED with ready plans (need a backend build + careful patch, or UI test, or live repro):** B13 (fedimint CSS — nginx sub_filter asset rewrite; bootstrap exact-match patch is fragile, do carefully), B12 (mempool host — dynamic bitcoin-knots/core detect in stacks.rs), B16 (bitcoin status retain — UI-test to avoid stale-as-live), B6 no-node-present half, B14b (FIPS net depth), B22/B23 (need live repro).
- **NEXT options:** (a) continue backend batch B13+B12 (one build); (b) do UI confirms on .116/.198 + cut v1.7.97-alpha with the ~10 committed fixes (LND incident + cloud/federation/mesh).
- **Committed fixes awaiting .97:** B5, B1, B2, B4, B14, B21, B3, B15, B7 (+ B6 pruned-gate already live). All on vps2 main; NOT on fleet yet.
## Progress log
- 2026-06-15: tracker created. v1.7.96-alpha shipped. All 19 bugs filed as Gitea issues #8#26. vps2 feature issues (G#3/5/6) deferred (no new features).
- 2026-06-15: **B5 (LND CORS) ✅ DONE** — root-caused, both fixes implemented, verified on .116/.198/.103 (harness 4/4 each), committed `1db720af`, pushed to vps2 main. Will bundle into .97 (Gitea #12 to close on .97 ship).
- Validation nodes: .116 + .198 (pw ThisIsWeb54321@). Runtime is podman (docker not in non-interactive PATH). Sideload binary → /usr/local/bin/archipelago + restart (containers survive on these nodes).
- 2026-06-15 (cont.): **B1,B2,B4 ✅** dedup+guard — committed `ed493106`, unit-tested 2/2, live .198 healthy. **B14 ✅** transport recording — committed `1c6dc153` (after build-repair: used private `crate::federation::storage::` path → E0603; fixed to re-exported `crate::federation::`). **B21 ✅** Tor/FIPS pill — committed `0801dd66`. All pushed to vps2 main; builds verified EXIT 0.
- **Discovered B14b** (FIPS reaches only ~4/15 peers; rest genuinely Tor) and **B21** (pill) during the block.
- ⚠️ LESSON: a backgrounded build "completed" notification does NOT mean success — grep the EXIT code before committing (a broken commit reached main once; repaired by 1c6dc153; no release cut from it → fleet unaffected).
- **NEXT: B3 (peer media streaming — big), then B14b (FIPS reachability), then app-specific (B6,B7,B9B13,B15B19).** None deployed to fleet yet — all on vps2 main awaiting the .97 release after full .116/.198 + UI verification.
## New backlog issues filed 2026-06-16 (this session)
- #32 Tor chat: message stuck on spinner though peers received it (task #8)
- #33 Message toast: click-to-open chat + close icon (task #9)
- #34 Local UI images never rebuild on source change — orchestrator gap (task #7); blocks OTA of bitcoin-ui relay + fedimint CSS to existing fleet
- #35 Paid 10% video previews unplayable — truncated MP4 (task #6)
NOTE: bitcoin RPC relay UI + fedimint guardian CSS now LIVE on .116 (image rebuilds); .198 deploy in progress. Bitcoin app launches host-net UI at <node>:8334 (not /app/bitcoin-ui/ proxy).
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# lnd-cors-test.sh — assert the LND "connect your wallet" endpoints return
# correct CORS headers for the cross-origin call from the LND UI app (:18083).
#
# Bug B5: /lnd-connect-info duplicated ACAO on some nodes; /proxy/lnd/v1/* 401
# carries no ACAO fleet-wide. Browser blocks both.
#
# Usage: ./lnd-cors-test.sh <node-host> (e.g. 192.168.1.116 or 100.102.169.103)
# Exit 0 = all assertions pass.
set -uo pipefail
HOST="${1:?usage: lnd-cors-test.sh <node-host>}"
ORIGIN="http://${HOST}:18083"
BASE="http://${HOST}"
PASS=0; FAIL=0
say() { printf '%s\n' "$*"; }
ok() { PASS=$((PASS+1)); say " PASS: $1"; }
bad() { FAIL=$((FAIL+1)); say " FAIL: $1"; }
# Count ACAO header lines (case-insensitive) in a header dump.
acao_count() { grep -ci '^access-control-allow-origin:' <<<"$1"; }
acao_value() { grep -i '^access-control-allow-origin:' <<<"$1" | head -1 | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r'; }
say "== B5 LND CORS — node ${HOST} (origin ${ORIGIN}) =="
# 1) /lnd-connect-info — exactly ONE ACAO, value == origin
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/lnd-connect-info" 2>/dev/null)
N=$(acao_count "$H"); V=$(acao_value "$H")
[ "$N" = "1" ] && ok "/lnd-connect-info has exactly 1 ACAO header" || bad "/lnd-connect-info ACAO count=$N (want 1)"
[ "$V" = "$ORIGIN" ] && ok "/lnd-connect-info ACAO value == origin" || bad "/lnd-connect-info ACAO='$V' (want '$ORIGIN')"
# 2) /proxy/lnd/v1/getinfo — ACAO present even on 401 (unauth)
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/proxy/lnd/v1/getinfo" 2>/dev/null)
N=$(acao_count "$H")
[ "$N" -ge 1 ] && ok "/proxy/lnd/v1/getinfo has ACAO (even unauth)" || bad "/proxy/lnd/v1/getinfo missing ACAO (count=$N)"
[ "$N" -le 1 ] || bad "/proxy/lnd/v1/getinfo duplicate ACAO (count=$N)"
# 3) /proxy/lnd/v1/channels — same
H=$(curl -s -m 8 -D - -o /dev/null -H "Origin: ${ORIGIN}" "${BASE}/proxy/lnd/v1/channels" 2>/dev/null)
N=$(acao_count "$H")
[ "$N" = "1" ] && ok "/proxy/lnd/v1/channels has exactly 1 ACAO" || bad "/proxy/lnd/v1/channels ACAO count=$N (want 1)"
say ""
say "== ${HOST}: ${PASS} passed, ${FAIL} failed =="
[ "$FAIL" -eq 0 ]