Compare commits

..
154 Commits
Author SHA1 Message Date
archipelagoandClaude Opus 4.8 cebbde7bde fix(ui): square mobile file tiles, files scroll clearance, apps-tab swipe guard
- Apps tab: a horizontal swipe that starts on an app icon no longer flips the
  top tab — it lets the app-page scroll / icon tap win (swipe empty space to
  change tab). Fixes the swipe conflict with two pages of apps.
- Files: file cover tiles are forced square on mobile (aspect driven by CSS,
  not a Tailwind arbitrary class) so the grid is uniform and tappable.
- Files: scroll container gets bottom safe-area + tab-bar padding so the last
  row clears the mobile back button / bottom nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:57:51 -04:00
archipelagoandClaude Opus 4.8 a0b80dd27d fix(mesh): authenticate !ai over LoRa via federation-twin binding + signed Text
A !ai (or any typed message) from a trusted, federated node was denied when
it arrived over the radio. The radio half of a node that is also a federation
peer carried no archipelago identity (identity adverts are no longer broadcast
on the public channel), so the trusted_only gate and signature verification
had no key to check the asker against — and the same node showed up as two
contacts (a radio twin + a federation twin).

- bind_federation_twins(): correlate a radio contact with its federation twin
  by exact, case-insensitive advert_name and copy the federation peer's
  arch_pubkey_hex/did/x25519 onto the radio record. Called from
  upsert_federation_peer and refresh_contacts. Ambiguous names (held by >1
  federation peer) are skipped. This is only a CANDIDATE key — security is
  unchanged: the inbound envelope signature must still verify against it.
- send_message now signs the typed Text envelope (new_signed) so a radio !ai
  authenticates against the bound key. A meshcore node merely named like a
  trusted node cannot forge the signature, so it is still denied.

Receiver-side verification (handle_typed_envelope_direct) and federation-trust
matching (is_sender_allowed) already existed; this supplies the missing key
binding and signature. Also resolves the radio/federation duplicate-contact
display for same-named nodes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:57:50 -04:00
Dorian 839da80e0b chore(android): update companion apk download 2026-06-19 18:50:39 +01:00
DorianandClaude Opus 4.8 f0e9343d74 fix(android): drop white-wrapping round PNG, single SVG-matched icon ring
Revert to a pure adaptive icon (the bare round PNG was getting legacy-wrapped
onto a white circle by the launcher). One ring only, in the foreground, using
the SVG's dark #000->#666 gradient on a plain dark tile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:50:34 +01:00
Dorian bf6d98195e chore(android): update companion apk download 2026-06-19 18:40:39 +01:00
DorianandClaude Opus 4.8 846b2d9646 fix(android): match icon ring to logo.svg gradient (#000->#666)
Revert the brightened grey->white ring back to the original logo.svg gradient
(black->#666, stroke 22.8834) on both the round PNG icon and the adaptive
foreground.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:40:37 +01:00
Dorian 6df776b25a chore(android): update companion apk download 2026-06-19 18:32:00 +01:00
DorianandClaude Opus 4.8 1074f89c47 feat(android): true-circle round launcher icon (PNG badge)
Render the full circular badge (bright grey->white ring + grid) to round-icon
PNGs at all densities and drop the adaptive round XML, so launchers that use
round icons show a real edge-to-edge circle instead of a mask-cropped coin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:31:57 +01:00
Dorian 726cc132af chore(android): update companion apk download 2026-06-19 18:26:59 +01:00
DorianandClaude Opus 4.8 078c1793a9 fix(android): fit full badge (ring + grid) inside icon safe zone
Scale the whole badge to ~0.64 so the bold grey->white ring isn't clipped at
the edge by the launcher mask; bigger, brighter ring. Background is plain dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:26:54 +01:00
Dorian b83e2c2f37 chore(android): update companion apk download 2026-06-19 18:26:34 +01:00
DorianandClaude Opus 4.8 a2fa57456d fix(android): scale icon badge into safe zone so the ring is visible
The ring at 0.96 sat in the adaptive-icon bleed zone (outer ~18dp cropped by the
launcher), so only the grid showed. Scale badge + grid to 0.68 so the ring lands
at the edge of the visible circle, and brighten it to grey->white.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:26:32 +01:00
Dorian 64937df8a2 chore(android): update companion apk download 2026-06-19 18:12:41 +01:00
DorianandClaude Opus 4.8 6527e66c07 fix(android): visible metallic icon ring at circle edge
Move the badge ring into the background layer (brightened grey->white so it
reads on #0A0A0A) at ~0.96 so it sits at the masked-circle edge; foreground is
just the white grid. Also honor SHIP_COMPANION in the pre-push hook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 18:12:38 +01:00
DorianandClaude Opus 4.8 07b611d07d chore(android): add companion APK auto-publish hook + script
scripts/publish-companion-apk.sh builds the debug APK and refreshes the served
download neode-ui/public/packages/archipelago-companion.apk.zip; .githooks/pre-push
runs it on every push to main that touches Android. Enable per clone with
  git config core.hooksPath .githooks

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:53:38 +01:00
Dorian dcedf9582a chore(android): update companion apk download 2026-06-19 17:46:44 +01:00
DorianandClaude Opus 4.8 f2c420d9c0 feat(android): app icon gradient ring border + companion publish script
Adaptive icon foreground now draws the full badge (black→grey gradient ring +
white grid) scaled to ~0.94 so the ring reads as a clean border at the circle
edge. Adds ship-companion.sh: builds the debug APK and publishes it to
neode-ui/public/packages/archipelago-companion.apk.zip, then commits + pushes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 17:46:41 +01:00
DorianandClaude Opus 4.8 68cd1c120a fix(android): translucent glass DARK controller so backdrop shows through
The controller body/face were opaque, so the synthwave backdrop only peeked
out above/below the controller. Make the DARK palette surfaces translucent
(body/face/inlay) and drop the opaque shadow platform + the gradient's forced
0.95 alpha, so the backdrop reads through the controller as glass. CLASSIC
palette stays solid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:52:02 +01:00
DorianandClaude Opus 4.8 993f30456f feat(neode-ui): instant press feedback + launching spinner on app icons
Tapping a dashboard app icon now scales it down immediately (CSS :active)
and shows a per-icon spinner until the app overlay opens, so the tap is
acknowledged even while the app session spins up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:21:48 +01:00
DorianandClaude Opus 4.8 aa95e42383 feat(android): circular logo, synthwave backgrounds, glass modal, server names + UX fixes
- New circular badge logo (ic_logo) on Intro + Connect screens; launcher
  icon rebuilt as dark circle + white grid.
- Reddish synthwave backdrop (bg-intro-2) behind Intro, Connect, and the
  remote/gamepad (edge-to-edge with a light scrim); controllers no longer
  paint an opaque fill over it.
- Server name: added to ServerEntry/prefs, the Connect form, the modal
  add-form, and saved-server rows; removal now matches by connection
  identity (rename- and legacy-format-safe).
- NESMenu modal restyled to glassmorphism #0A0A0A with centered, larger
  fields. Connect-form glass cards given a darker base for legibility.
- Intro title/subtitle set to #FAFAFA.
- Deleting the last server clears the active server and returns to Connect.
- D-pad auto-repeat initial delay raised to 500ms so a tap sends one key
  (fixes doubled nav sound).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:21:48 +01:00
archipelagoandClaude Opus 4.8 75e470bfa4 fix(mesh): mesh-preferred message routing with FIPS/Tor fallback
Messages to a federated peer that is out of LoRa range (e.g. on another
continent) were dropped into the radio with no fallback, or hung on a dead
FIPS path before reaching Tor — so they never arrived.

- Route a radio contact over the federation transport (FIPS->Tor) when it is
  the same node as a federated peer (known archipelago identity -> onion) AND
  it is not currently reachable over the radio. Reachable radio peers stay on
  the mesh (preferred); oversized/file envelopes still always take federation.
- Resolve the onion via the archipelago identity key (arch_pubkey_hex), not
  the firmware routing key, so a radio contact maps to its nodes.json onion.
- Add .fips_timeout(8s) to the federation message POST so an unreachable FIPS
  overlay fast-fails to Tor (~3-5s) instead of burning the 120s budget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:09:14 -04:00
archipelagoandClaude Opus 4.8 0ac67f5092 fix(ui): companion QR absolute 146 URL + Dashboard swipe type guard
- Companion app QR encoded a relative path (/packages/...apk.zip) which
  can't resolve when scanned by a phone. Point it at the absolute 146
  release-server URL so the download works from any device.
- Dashboard tab-swipe: guard tabs[next] (noUncheckedIndexedAccess) so the
  frontend type-checks/builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00
archipelagoandClaude Opus 4.8 837cc02812 fix(federation): reliable symmetric auto-federation across LAN/Tor/FIPS
Federated nodes failed to converge to full-mesh across the LAN<->Tailscale
boundary: nodes were invisible to peers, sync 'took ages'/timed out, and
names only updated on a manual sync. Onions were healthy in both directions
(~3-5s); the failures were app-layer.

- B: federation dials fast-fail a dead FIPS path via .fips_timeout(6s) in
  sync_with_peer + notify_join, so the Tor fallback isn't stuck behind the
  full 30s FIPS budget when LAN and remote peers share no FIPS path.
- A: notify_join (peer-joined) now spawns with retries+backoff instead of a
  single awaited best-effort POST, so the join RPC returns instantly (no
  'Request timeout') and the inviter reliably learns the joiner (was
  asymmetric).
- C: new 90s periodic federation auto-sync (none existed) so renamed nodes
  and roster changes propagate without a manual Sync click.
- self-heal: each auto-sync re-asserts membership to any peer that doesn't
  list us back, converging the fleet to full-mesh and healing pre-existing
  asymmetry with no manual re-joins.

Validated live across 7 nodes: a previously fleet-invisible node became
fully meshed automatically (logs: 'auto-sync ... reasserted=1',
'peer-joined ... delivered').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00
archipelagoandClaude Opus 4.8 1bce694ebb feat(ui): mobile mesh tabs, AIUI-style audio player, cloud grid + map fixes
UI (this session):
- Global audio player now scales the whole interface into the space above it
  on desktop (sidebar + main) and docks directly above the tab bar on mobile;
  it stays visible while navigating.
- Mesh mobile redesign: floating Chat / BTC / Dead Man / AI / Map tab strip
  with a single fixed, internally-scrolling pane (page no longer scrolls);
  tabs hide while a conversation is open; floating back button; collapsible
  Device panel (starts collapsed); keyboard-aware conversation sizing via
  VisualViewport so the chat sits just above the keyboard.
- Cloud file grid: uniform 4/3 card heights (folders + images match).
- Swipe left/right switches tabs on the Apps and Web5 screens.
- Map tool fills its pane (no bottom gap); fix skewed Share Location toggle
  on mobile (global min-height rule was deforming the switch).
- Trim redundant helper copy from the mesh AI tab.

Also bundles pre-existing in-progress work that was already in the tree:
mesh listener/session + wallet + container + bitcoin-status backend changes,
docker UI updates, and assorted other UI tweaks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00
archipelagoandClaude Opus 4.8 c4855526fe feat(wallet): wire fmcd as core app + dual-ecash receive
Fedimint never appeared in Wallet > Settings > Fedimint because the
fmcd (fedimint-clientd) sidecar was never installed: ensure_default_
federation() needs the fmcd password to reach the daemon, found none,
and silently no-oped, leaving the registry empty.

- prod_orchestrator: add fedimint-clientd to the baseline auto-install
  set so it self-heals onto every node and auto-joins the default
  federation; generate the fmcd-password secret before secret_env
  resolves.
- fedimint_client: ensure_fmcd_password (random hex, 0600) shared with
  the container's secret_env; from_node reads the same secret (legacy
  fmcd/password kept as fallback); reissue_into_any redeems received
  notes into the first joined federation that accepts them.
- wallet.ecash-receive: dual-token — cashu* tokens redeem at the mint,
  anything else is reissued via fmcd; returns the kind + federation_id.
- UI: receive box advertises "Cashu or Fedimint" and reports which kind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00
archipelagoandClaude Opus 4.8 298595069d fix(mesh): native Meshtastic unicast DMs + driver-level E2E status
Meshtastic DMs were falling back to a channel broadcast, so every node
on the LoRa channel saw a "direct" message. Send a directed MeshPacket
(to = node num, decoded from the synthetic pubkey's node-id bytes)
instead — the Meshtastic analog of the meshcore CMD_SEND_TXT_MSG fix.
DMs now reach only the recipient; firmware auto-PKC-encrypts them
end-to-end once NodeInfo keys are exchanged.

Capture E2E status at the driver level (no shared-type/UI change):
- learn each peer's real Curve25519 key from User.public_key (field 8)
  and inbound MeshPacket.public_key (16), kept in a side-map separate
  from the synthetic routing key so unicast routing is untouched
- detect inbound MeshPacket.pki_encrypted (17) to tell a true E2E DM
  from a channel-PSK fallback
- peer_is_pkc_capable() seam for a future mesh-tab E2E badge

Hot-swap preserved: no dispatched MeshRadioDevice signature or the
shared ParsedContact changed, so meshcore and meshtastic stay
interchangeable behind the listener.

Adds tests/multinode/meshtastic.sh, a two/three-radio on-air parity
harness (detect, discover, DM round-trip, DM privacy, channel
broadcast, typed envelope, reachability).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:52:26 -04:00
DorianandClaude Opus 4.8 f636c5d505 fix(neode-ui): float connection banners as overlay
The offline/reconnecting banners were in-flow (mx-6 mt-6) and pushed the whole
dashboard down when shown. Teleport them to <body> as a fixed, top-centered
overlay with a fade/slide transition and safe-area inset, so they no longer
shift layout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:40:50 +01:00
DorianandClaude Opus 4.8 0f43870e6c chore(android): give debug build a .debug app id
applicationIdSuffix=".debug" + versionNameSuffix so a debug/test build
installs alongside the release app instead of failing on signature mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:40:50 +01:00
DorianandClaude Opus 4.8 d1fbcd9b0a feat(neode-ui): route "open in browser" through native bridge in companion app
When ArchipelagoNative is present (the Android companion app), openInNewTab()
now calls openInApp(url) so non-iframeable apps open in the in-app WebView
instead of a suppressed window.open popup. Falls back to window.open in a
plain mobile browser. Logic only; no visual change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:28:48 +01:00
DorianandClaude Opus 4.8 b5a9deb815 feat(android): open non-iframeable apps in in-app webview + webview perf
The kiosk's "Open in new tab" used window.open(..., 'noopener,noreferrer'),
which the WebView suppresses, so launching apps that can't be iframed did
nothing. Route such node apps (same host) into a local in-app WebView overlay
instead, keeping the kiosk view alive underneath; genuinely external links
still go to the system browser. Wired through onCreateWindow,
shouldOverrideUrlLoading, and a new ArchipelagoNative.openInApp() bridge.

Perf (no visual change): enable setOffscreenPreRaster to stop scroll
checkerboarding, and enable WebView remote debugging on debuggable builds
for chrome://inspect profiling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 11:28:48 +01:00
archipelagoandClaude Opus 4.8 d0ca53501c feat(ui): cloud folder zoom transition on path change
Re-key FileGrid on the current folder path and wrap it in a cloud-zoom
Transition so the depth/zoom animation replays at every folder level; the
header + breadcrumb nav stay fixed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:40:16 -04:00
archipelagoandClaude Opus 4.8 790da4bd0f fix(wallet): Minibits default Cashu mint, resilient peer-file invoices, named default federation
- Cashu default mint was the local Fedimint guardian (:8175), wrongly surfacing
  a Fedimint URL in the Cashu mints list. Default is now Minibits
  (https://mint.minibits.cash/Bitcoin) — Cashu and Fedimint are distinct
  protocols (Fedimint lives under its own tab).
- Peer-file (buy) invoice creation: retry the LND REST call (3× / 400ms) so a
  transient LND-REST blip (swap pressure / just-restarted / TLS race) no longer
  hard-fails as an opaque 503, and surface the real error chain ({:#}) in the
  response + logs instead of a generic "Failed to create invoice".
- Autojoined default federation now shows a friendly name ("Archipelago
  Federation") in the Fedimint tab instead of a bare federation id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:23:56 -04:00
archipelagoandClaude Opus 4.8 cc2e055e09 fix(bitcoin,ui): RAM-aware dbcache to stop swap-thrash 502s + snappier status + icon placeholder
Sizes bitcoind -dbcache to host RAM (~1/16, floor 300MB, cap 4096) instead of a
fixed 2048/4096. A multi-GB UTXO cache on an 8GB node running the full app stack
pushed memory past physical RAM and triggered system-wide swap thrash: the disk
saturated, bitcoind could not answer its own RPC, and the dashboard backend's
sqlite reads stalled — surfacing as fleet-wide /rpc/v1 502s and a blank Bitcoin
UI. Applied in scripts/container-specs.sh (reconciler path) and the config.rs
bitcoin-core path.

Bitcoin status cache now polls every 5s (was 10/15) with an 8s timeout (was 20s)
and fetches the four RPCs concurrently, so the cached snapshot tracks bitcoind's
responsive windows during IBD and the UI stops dwelling on "reconnecting...".

Unifies the divergent discover AppGrid/FeaturedApps image-error handlers onto the
canonical placeholder fallback so missing app icons render the placeholder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:14:47 -04:00
archipelagoandClaude Opus 4.8 549c6180a2 chore(ui): sync What's New modal for v1.8.00-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:12:12 -04:00
archipelagoandClaude Opus 4.8 ec644ab90f docs: changelog v1.8.00-alpha — mesh DM privacy, contact import/search/reachability
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:10:29 -04:00
archipelagoandClaude Opus 4.8 f0fdc23cc9 feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search
- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel
  broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the
  public channel, and decodable by stock clients. Plain text (split, not MC-chunked)
  to non-archipelago contacts; typed envelopes to archy peers.
- !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0.
- Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is
  added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert.
- clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of
  blocklisting; blocking filter removed entirely. Wiped contacts return when reachable.
- Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows
  a reachability dot.
- Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button.
- send_message routes stock contacts as plain native text (fixes garbled envelopes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 08:08:52 -04:00
archipelagoandClaude Opus 4.8 9f2edf6b7a docs: changelog for v1.8.00-alpha (carry forward v1.7.99 features + mesh/fedimint fixes)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 04:20:10 -04:00
archipelagoandClaude Opus 4.8 3a21243be7 fix(mesh,ui,fedimint): mesh-AI chat trigger + transport-aware reply, stop ARCHY:2 public-channel spam, AI allowlist + model dropdown, Fedimint client manifest, settings reorder, chat scroll
- mesh: stop broadcasting ARCHY:2 identity on the public channel (startup + every advert tick); receive path still parses inbound. No more public-channel spam.
- mesh assistant: trigger on !ai/!ask typed in 1:1 chat (was only the dead AssistQuery path + bare channel text); route the reply transport-aware via MeshService::send_message (Tor for federation peers, LoRa for radio) through a new AssistChatReply event consumed at the server layer — fixes replies never reaching federation askers.
- mesh assistant: per-contact !ai allowlist (allowed_contacts) bypassing trusted_only; config + RPC + is_sender_allowed.
- fedimint-clientd manifest: network_policy open -> bridge (invalid value made the loader skip the whole manifest, so fmcd never ran and federations never joined/listed).
- ui: AI panel — Claude model dropdown (Haiku/Sonnet/Opus presets) + allowlist contact picker.
- ui: Settings — App Updates + App Registry moved under Account.
- ui: mesh chat — overscroll-behavior: contain so chat scroll no longer bleeds to the contacts panel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 03:33:37 -04:00
archipelago 2a017623e9 chore: release v1.7.99-alpha 2026-06-18 01:00:24 -04:00
archipelagoandClaude Opus 4.8 b59c74adfe test(ui): register $ver global in vitest setup
Component tests mounted without main.ts's bootstrap, so the $ver global
template helper (app.config.globalProperties.$ver = displayVersion) was
undefined — AppSidebar/AppHeroSection/MarketplaceAppCard tests failed with
"_ctx.$ver is not a function", blocking the release gate's ui-unit-tests
stage. Add a vitest setup file that mirrors main.ts via config.global.mocks
and wire it into vitest.config.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:52:48 -04:00
archipelagoandClaude Opus 4.8 371be4a69c chore: sync What's New modal for v1.7.99-alpha
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:53:08 -04:00
archipelagoandClaude Opus 4.8 83bb589ea6 style: cargo fmt for v1.7.99-alpha release gate
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:50:46 -04:00
archipelago 144c4a2872 docs: changelog for v1.7.99-alpha 2026-06-17 19:48:20 -04:00
archipelago 5b2a11b8c7 Merge meshroller-50: mesh-AI assistant (#50) into release train 2026-06-17 19:22:11 -04:00
archipelagoandClaude Opus 4.8 705e2436ba chore(ops,docs): first-boot containers, image versions, design docs, android remote-input
- first-boot-containers + image-versions for fmcd/fedimint
- dual-ecash, meshroller-integration, and remaining-issues design docs
- Android remote-input two-finger scroll + external-open handling

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:22:02 -04:00
archipelagoandClaude Opus 4.8 87769cbfbf feat(ui): dual-ecash wallet settings, buy-peer-files, seed backup, assorted fixes
- Tabbed Wallet Settings modal (Cashu + Fedimint) and dual-balance wallet card
- Buy a peer's paid file (ecash / node Lightning / on-chain / external QR)
- Recovery-phrase reveal + backup section; onboarding seed retry resilience
- NetBird HTTPS launch, remote-control two-finger scroll + external-open
- Shared BackButton, single-v version label, mesh Bitcoin header toggles

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:42 -04:00
archipelagoandClaude Opus 4.8 bd567cd165 feat(wallet,content,seed): Fedimint dual-ecash, paid content streaming, seed ceremony
- Fedimint ecash alongside Cashu: fedimint-clientd (fmcd) HTTP bridge,
  fedimint_client, fedimint RPC, wallet wiring
- Paid peer content: content invoices + streaming content server + content RPCs
- Seed-phrase ceremony/reveal RPCs and CLI ceremony tool
- LND wallet, mesh status/messaging, app-stack (netbird HTTPS), and
  decoupled-update wiring; Fedimint Client core app in catalog

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:21:07 -04:00
archipelagoandClaude Opus 4.8 7a76d32e4b feat(mesh): mesh-AI assistant scheduler + config panel (#50)
Adds the assistant scheduler, MeshAssistantPanel UI, and the remaining
config-RPC / live-toggle / Ollama-detect wiring on top of Phase 1.x.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 19:19:32 -04:00
archipelagoandClaude Opus 4.8 0947ecee11 feat(mesh): assistant config RPCs + live toggle + Ollama detect (#50)
Phase 2 backend. AssistantConfig is now live-updatable (RwLock) so the UI
toggle applies without a listener restart. New RPCs:
- mesh.assistant-status  -> {enabled, model, trusted_only, default_model,
  ollama_detected, models[]} (probes local Ollama :11434/api/tags)
- mesh.assistant-configure -> set enabled/model/trusted_only live + persist

MeshService::assistant_config / configure_assistant. Compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:29:36 -04:00
archipelagoandClaude Opus 4.8 ef601c6d26 feat(mesh): wire ARCHY identity broadcast for trust over both radios (#50)
The ARCHY:2 identity broadcast (DID + ed25519 + x25519) was unwired dead
code on both send and receive. Wiring it lets a radio peer prove its
archipelago identity, so the assistant's trusted-only gate (and encrypted
DMs) work over meshcore AND Meshtastic — the latter otherwise only exposes
synthetic node keys.

- session.rs: broadcast ARCHY:2 as channel text at startup + each advert tick
- frames.rs: parse inbound ARCHY:2 on the channel path, dedupe-keyed by
  archipelago pubkey (federation_peer_contact_id) so it MERGES with the
  federation-seeded peer instead of duplicating; self-echo guarded
- threads our_x25519_secret into handle_channel_payload (was reserved)

Reuses the existing handle_identity_received verifier (ed/x25519 consistency
check + shared-secret derivation). Compiles clean. Needs a live 2-radio test
before trusting trusted-only over radio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 18:20:12 -04:00
archipelagoandClaude Opus 4.8 87d0d53205 feat(mesh): assistant Phase 1.5 — !ai channel trigger (issue #50)
A plain '!ai <q>' / '!ask <q>' on the channel is now answered by the node's
local model and broadcast back as plain text, so ANY client (bare meshcore
or Meshtastic) can ask. Generalised run_assist with an AssistReply target:
Typed chunks to a peer (archipelago UI path) vs plain channel-text (bare
clients). Trust/rate gate unchanged; asker identity is separate from reply
mode. Works over both radios.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:59:03 -04:00
archipelagoandClaude Opus 4.8 d8d014bfd9 feat(mesh): mesh-AI assistant — Phase 1.1-1.4 (issue #50)
Rust-native lift of Meshroller's LLM bridge. Adds typed AssistQuery/
AssistResponse mesh messages, a trust-gated inbound handler that answers
with the node's local Ollama model, and airtime discipline (reply cap,
chunking, one in-flight query per asker). Works over both meshcore and
Meshtastic radios via the existing MeshRadioDevice abstraction.

- message_types: AssistQuery=24 / AssistResponse=25 + payloads
- listener/assist.rs: run_assist (gate -> Ollama -> chunked reply)
- listener/dispatch.rs: AssistQuery/AssistResponse arms
- MeshConfig: assistant_enabled / assistant_model / assistant_trusted_only
- MeshState: AssistantConfig + data_dir + in-flight guard

Compiles clean (cargo check). Off by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:41:15 -04:00
archipelagoandClaude Opus 4.8 c10f2ac22e fix(apps): rename 'Websites' tab to 'Services' (#51)
Headless containers (databases, APIs, backends without a UI) belong in a
tab labelled 'Services', not 'Websites'. The categorisation logic already
routes UI-less packages there (built under #45); this finishes the rename
of the user-facing label across Apps, Marketplace, Discover and the mobile
nav, and makes 'services' the canonical tab state/query param. Old
?tab=websites bookmarks still resolve (back-compat acceptor kept).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:56:36 -04:00
archipelagoandClaude Opus 4.8 3ca1fadfea chore: reconcile Cargo.lock after DHT merge
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:50:25 -04:00
archipelagoandClaude Opus 4.8 7c458ede8e Merge agent-trust-wip (DHT Phases 0–4) into main
Integrates the DHT/peer-distribution line with the v1.7.98-alpha release
fixes:
- Phase 0 signed-catalog trust + release-root key (KAT-pinned)
- Phase 1 BLAKE3 content addressing alongside SHA-256
- Phase 2 swarm-assist fetch seam (origin always wins) + iroh-blobs
  provider — heavy iroh deps stay behind the off-by-default `iroh-swarm`
  feature, so the default build/deploy is unaffected
- Phase 3 signed Nostr seed-advertisement + discovery glue + paid swarm
  serving + "Networking Profits" Settings page
- Phase 4 paid swarm streaming (cross-mint ecash, Shape-A paid ALPN,
  streaming.prepare-payment), also iroh-swarm-gated

Conflicts resolved: seed.rs (kept release-root KAT tests), update.rs
(comment-only, OTA logic identical), Cargo.lock (regenerated against the
merged Cargo.toml). Default-feature build is clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:50:06 -04:00
archipelagoandClaude Opus 4.8 27a6199939 feat(dht): Phase 4 — paid swarm streaming (cross-mint ecash + Shape-A ALPN)
Fetch-side auto-pay decision layer (payment.rs), Shape-A paid-blobs
negotiation ALPN (paid_alpn.rs), cross-mint ecash swap + payer auto-swap
builder + idempotent resume/liquidity cache (ecash.rs), and the
streaming.prepare-payment RPC. All gated behind the iroh-swarm feature
(off by default). 91/91 tests pass, both build configs clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:36:31 -04:00
archipelagoandClaude Opus 4.8 2c93e25faf fix(mesh): satisfy strict index access in federationContactId (#39 build)
Destructure the first 4 pubkey bytes into typed locals so vue-tsc's
noUncheckedIndexedAccess doesn't fail the build (the bytes.length<4 guard
doesn't narrow per-element access). No behaviour change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 07:06:08 -04:00
archipelagoandClaude Opus 4.8 d4c0587df0 fix(health): IndeeHub API waits for MinIO before restart (#41)
The IndeeHub API needs MinIO (object storage) up to serve, but the
health monitor's dependency map listed only postgres + redis, so it
would restart the API while MinIO was still starting — the "recovers
only after 1-2 container restarts" symptom. Add indeedhub-minio to the
API's deps; MinIO has no deps of its own so the monitor restarts it
first, no deadlock. (First-start ordering in the stack definition is a
deeper, separate follow-up.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:33:04 -04:00
archipelagoandClaude Opus 4.8 ab56054aeb fix(federation): remove-node also purges the mesh contact/thread (#2)
federation.remove-node only edited nodes.json, so a removed/renamed node
(e.g. a stale "Arch HP") lingered in the mesh chat list with its old
thread. Capture the node's pubkey before removal, then purge its
synthetic mesh peer, shared secret, messages, presence, and persisted
contact entry via the new mesh::purge_federation_peer. Combined with the
#42 name refresh, stale federation contacts can now be fully cleaned from
a node.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:12:56 -04:00
archipelagoandClaude Opus 4.8 d2d2b9dd68 fix(apps): classify by declared UI — UI apps to My Apps, headless to Websites (#45)
Per the rule that only front-end apps with a UI belong in "My Apps"
(databases/backends/headless go to Websites), make the manifest's
interfaces.main.ui the deciding signal. isWebsitePackage now treats any
package that declares a UI as an app even when it isn't in the curated
APP_CATEGORY_MAP, and falls through headless LAN-reachable packages to
Websites. Additive — service-by-name infra and curated known apps are
unchanged, so no currently-correct app moves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:09:46 -04:00
archipelagoandClaude Opus 4.8 56752ebfc0 fix(identity): Node npub in Web5 Identities matches Settings (#49)
Settings shows the node-level Nostr key (HKDF derive_node_nostr_key,
read via node.nostr-pubkey) while Web5 > Identities showed the identity
record's own key — the mirrored "Node" identity stores nostr=None and
seed identities use a different BIP-32 NIP-06 key, so the two surfaces
disagreed.

Resolve the node-level Nostr key once in identity.list and override it
onto whichever identity record is the node's own (ed25519 == server_info
.pubkey). Display-only — no stored key is rewritten, so it self-applies
to existing nodes with no migration and the discovery identity is
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 06:03:25 -04:00
archipelagoandClaude Opus 4.8 6de8173d18 fix(mesh): refresh federation chat names + roster after sync without restart (#42)
A peer accepted via invite is seeded into the mesh peer table with
name=None, so it shows as "Archipelago <pubkey8>" in chat. Federation
sync later learns the real name (update_node_state writes it to
nodes.json) and discovers transitive peers (merge_transitive_peers),
but nothing pushed those into the live mesh peer table — the chat list
stayed stale until the next mesh restart, and transitive peers never
appeared as contacts at all.

Add RpcHandler::refresh_federation_mesh_peers() (re-runs the idempotent,
onion-deduped seed_federation_peers_into_mesh) and call it after every
periodic sync cycle (server.rs) and after the manual federation.sync-all
RPC. Names now correct themselves and the full roster meshes within a
sync cycle, no restart needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 05:52:41 -04:00
archipelagoandClaude Opus 4.8 1f3b03bc6d docs(dht): Phase 4 plan (paid streaming/relay/IndeeHub + cross-mint) + RESUME update
phase4-streaming-ecash-plan.md: design for ecash-paid swarm transport, paying
across different mints (§2a, Lightning-bridged swaps), networking-through-nodes
relay, and an IndeeHub "Archipelago" content source. Records the resolved
iroh-blobs paid-serving spike. dht-RESUME.md: task #12 + step F marked done.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:48:18 -04:00
archipelagoandClaude Opus 4.8 75b78325e4 feat(web5): Networking Profits → Settings page for paid services
Adds a Settings control to the Networking Profits card that opens a new page
where the operator controls what their node charges sats for and how much.
Drives the existing streaming.list-services / streaming.configure-service RPCs;
"free everything" is the default (all priced services ship disabled, surfaced
with a reassurance banner). New route web5/networking-profits + common.settings
i18n (en/es).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:48:00 -04:00
archipelagoandClaude Opus 4.8 be3ebd7fe0 feat(dht): Phase 3 discovery glue + paid swarm serving
Phase 3 wiring (task #12):
- NostrSeedDiscovery: async ProviderDiscovery that queries relays for signed
  seed adverts and parses endpoint ids (swarm/iroh_provider.rs, seed_advert.rs).
- seed_and_advertise publish path; dep-free fetch/publish helpers reuse the
  node's Nostr identity (build_nostr_client/load_or_create_nostr_keys made
  pub(crate)).
- swarm::init builds the IrohProvider once into a OnceLock runtime; providers()
  returns it; announce_held_blob() is called from update.rs after a release
  component passes both hash gates.
- config swarm_enabled (ARCHIPELAGO_SWARM_ENABLED, default off); server.rs init.

Paid swarm serving (Phase 4 step F):
- swarm/paid.rs gates the iroh-blobs provider through streaming::gate,
  intercepting connect + GET (peer push hard-disabled). Free by default
  (content-download service disabled); denies unpaid peers when enabled;
  fails open on internal error so a payment fault never blocks distribution.
  Wired into IrohProvider::new.

All iroh code behind the iroh-swarm feature; the default build is inert.
Default build clean; --features iroh-swarm: 11/11 swarm tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 04:47:18 -04:00
archipelagoandClaude Opus 4.8 06cf80d4a2 fix(apps): classify Bitcoin Core as an app, not a website (#8, #9)
bitcoin-core was missing from APP_CATEGORY_MAP, so isKnownApp() was false and
isWebsitePackage() fell through to 'has a runtime LAN address'. Once the running
container's LAN address (the bitcoind RPC port :8332) showed up ~a minute after
launch, Bitcoin Core was reclassified as a website: it dropped out of the Apps
tab and search, moved under Websites, and launching it opened :8332 (raw RPC)
instead of the :8334 custom UI that Knots opens.

Add 'bitcoin-core': 'money' alongside bitcoin-knots/bitcoin-ui so isKnownApp is
true, isWebsitePackage is false, and launchAppNow routes through openSession ->
resolveAppUrl (:8334 custom UI). Fixes search, category, and the launch URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:43:29 -04:00
archipelagoandClaude Opus 4.8 1ea3f8d65c fix(mesh): message federation contacts without a radio (fixes 'Missing contact_id')
Messaging a federation-only peer (e.g. 'Arch Dev') failed with 'Missing
contact_id'. The UI gave federation-only rows a *negative* placeholder
contact_id derived from a DID hash, but the backend parses contact_id as u64,
so a negative value deserialized to None. The negative id also never matched
the positive federation-synthetic id that federation-routed messages are stored
under, so those threads looked empty.

- Frontend: derive the SAME positive federation-synthetic id the backend uses
  (federationContactId mirrors federation_peer_contact_id) so mesh.send accepts
  it and messages thread correctly.
- Backend: send_typed_wire now resolves a federation-synthetic contact_id from
  nodes.json when it isn't in the live mesh peer table (radio-less node),
  instead of bailing 'Unknown federation peer'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:24:34 -04:00
archipelagoandClaude Opus 4.8 e456c9701b fix(peer-files): stream large cloud downloads + surface real errors (#30, #38)
Large peer downloads (~178MB) failed with a generic 'Operation failed', and
the download path had three stacked problems:

- The FIPS reqwest client used a hard-coded 20s total timeout regardless of the
  caller's .timeout(), so a big transfer over the mesh aborted at 20s before
  the Tor fallback could help. Honor the per-request timeout (client_with_timeout).
- The peer-content proxy buffered the whole file into node memory via
  resp.bytes() before sending a byte, and capped the transfer at 60s. Stream
  the body through with hyper::Body::wrap_stream (constant memory) and raise the
  timeout to 900s; bump the nginx peer-content read timeout to match.
- Free downloads pulled the file as base64 over RPC, doubling it in node memory
  and the browser — fatal for large files. Download free files by streaming
  from /api/peer-content straight to disk, after a 1-byte Range probe that
  surfaces the real reason (peer offline on mesh and Tor) instead of a generic
  failure. Paid downloads now return the real error through the {error} channel
  the UI already displays.

Adds the reqwest 'stream' feature for bytes_stream().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:10:21 -04:00
archipelagoandClaude Opus 4.8 3aea8c5bfa fix(orchestrator): rebuild local UI images when source changes (#34)
The prod orchestrator only checked whether a build-image tag was *present*
before deciding to skip the build. The local UI images (bitcoin-ui, lnd-ui,
electrs-ui) COPY a built neode-ui dist, so a UI update changed the source but
left the old tag in place and the new UI never shipped.

Gate the build on a content fingerprint of the build context (sorted relative
path + length + mtime, SHA-256) recorded in a per-tag stamp under data_dir.
Rebuild whenever the fingerprint differs from the one that produced the
existing image; podman's own COPY-layer cache keeps a no-op rebuild cheap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 03:09:56 -04:00
archipelagoandClaude Opus 4.8 f14829542b docs(dht): RESUME checkpoint — state, next steps, build/worktree rules
Single source of truth for picking the DHT work back up after a restart:
worktree/branch rules, all phase commits, the exact next task (#12 Phase 3
glue), build-time facts, and the Phase 0 go-live ceremony.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:18:00 -04:00
archipelagoandClaude Opus 4.8 1843739e0c fix(install): restart stack containers that crash on first start (#25)
Apps could fail install when a stack member exited on its first start
because a dependency (db/redis/the bitcoin node) was not ready yet — a
transient crash, not a broken install. wait_for_stack_containers now
restarts each exited/dead container up to 3 times before declaring the
install failed; the runtime supervisor keeps it alive afterwards.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:14:09 -04:00
archipelagoandClaude Opus 4.8 9fa56a8274 feat(dht): Phase 3 core — signed Nostr seed-advertisement protocol
The discovery wire format that feeds the swarm's ProviderDiscovery seam: a
node announces 'I seed blake3 H from iroh endpoint E' as a signed NIP-33
addressable Nostr event. Scope is releases/catalog content ONLY (decided
2026-06-16) — never private user blobs.

- swarm/seed_advert.rs: kind 30081, d-tag = blake3 hex (one current advert
  per author+hash, latest-replaces), content {"v":1,"endpoint_id":...}.
  advertisement_builder / advertisement_filter / parse_endpoint_id /
  endpoint_ids_from_events (dedup). Endpoint ids stay opaque strings so the
  protocol is dep-light + unit-testable on the default build.

4/4 tests pass (sign->parse roundtrip, filter targeting, reject wrong-kind/
empty, dedup across nodes).

Next (task #12): gated NostrSeedDiscovery glue (query relays, parse ids ->
iroh::EndpointId), publish path, wire swarm::providers().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:13:35 -04:00
archipelagoandClaude Opus 4.8 082946aa30 feat(dht): Phase 2 engine — real iroh-blobs provider behind iroh-swarm
Pulls iroh 1.0 + iroh-blobs 0.103 as OPTIONAL deps under the iroh-swarm
feature and implements a real BlobProvider over them. Verified: the full
iroh QUIC dep tree (260 pkgs) resolves and compiles against the pinned
bitcoin/nostr-sdk/reqwest-rustls stack; the provider compiles against the
0.103/1.0 API.

- swarm/iroh_provider.rs: IrohProvider::new binds a QUIC Endpoint, opens a
  persistent FsStore (data_dir/iroh-blobs), and serves blobs via the
  iroh-blobs protocol/Router — a node that fetches also SEEDS. try_fetch
  maps ContentDigest -> iroh Hash, asks discovery for seed EndpointIds, then
  downloader.download(hash, providers) (range-verified) + export to staging.
- ProviderDiscovery trait: the seam Phase 3 (signed Nostr advertisement
  events) fills. discovery=None -> no seeds -> origin-only, so enabling the
  feature is never worse than today.
- Default build untouched: iroh is optional, the module is cfg-gated, and
  providers() stays empty until Phase 3 wires discovery in.

Build: cargo build --features iroh-swarm succeeds (dev). Default build +
44 swarm/update/content_hash/blobs tests unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:33:31 -04:00
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 2523c9e3dd feat(dht): Phase 2 — swarm-assist fetch seam, origin always wins
Lands the transport/swarm orchestration layer (the iroh engine attaches
later, behind a flag). The seam is fully exercised today with the origin
HTTP path; with no swarm providers registered the behaviour is byte-for-byte
identical to before.

- swarm/mod.rs: BlobProvider trait + fetch_content_addressed() — tries each
  provider in order, VERIFIES peer-sourced bytes against the content digest
  before accepting (untrusted seeds can't inject tampered bytes), falls back
  to the origin closure if none serve. Returns Swarm|Origin.
- Cargo: iroh-swarm feature (off by default; heavy QUIC dep tree attaches
  here). providers() is empty until enabled → every fetch hits origin.
- update.rs: components with a BLAKE3 digest route through the seam, using
  the existing resumable HTTP downloader as the origin fallback; a swarm hit
  is re-checked against the mandatory SHA-256 manifest gate (re-fetch from
  origin on any disagreement). Components without blake3 take the original
  path untouched.

44/44 swarm/update/content_hash/blobs tests pass (incl. swarm hit/miss,
tampered-bytes-rejected→origin, fall-through ordering).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:38:19 -04:00
archipelagoandClaude Opus 4.8 f0cb91ed76 feat(dht): Phase 1 — BLAKE3 content addressing alongside SHA-256
Adds the iroh-native, range-verifiable hash next to the incumbent SHA-256
so the swarm can later fetch/verify by BLAKE3 with the registry/origin as
fallback. Non-breaking: SHA-256 stays the mandatory gate; BLAKE3 is verified
only when present.

- content_hash.rs: HashAlg + ContentDigest (parse/verify '<alg>:<hex>'
  multihash strings), blake3_hex/sha256_hex; BLAKE3 known-answer test
- update.rs: ComponentUpdate.blake3 (serde-default); verified ALONGSIDE
  SHA-256 in the resumable download loop, re-download on mismatch
- blobs.rs: BlobMeta.blake3 computed on put (on-disk path stays
  SHA-256-keyed for back-compat; advertises the future swarm address)

Drive-by: fix a pre-existing stale test (test_save_and_load_state_roundtrip)
that never wrote the .download-complete marker #26 requires, so load_state's
self-heal cleared update_in_progress. Unrelated to BLAKE3 — surfaced by
running the full update:: suite.

40/40 content_hash/update/blobs tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 13:05:27 -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
archipelagoandClaude Opus 4.8 27f11bf85a feat(trust): wire Phase 0 signed-catalog verification + pin release-root KAT
Completes the parked trust module and wires it into the live build:
- main.rs: register `mod trust`
- app_catalog::fetch_one: verify the release-root detached signature when
  present (verify against raw JSON so forward-compat fields stay in the
  signed preimage); accept unsigned during the migration window, hard-reject
  a present-but-bad signature so a tampering mirror can't pass altered bytes
- seed: pin release-root Ed25519 known-answer test (priv+pub) for the
  signing ceremony / pinned-anchor / external-verifier cross-check
- signed_doc: drop unused import

20/20 Phase 0 unit tests pass (trust::canonical/did/signed_doc/anchor,
seed release-root, app_catalog). Crate compiles clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 12:40:57 -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
archipelago 0fef808671 wip(trust): park agent's signed-manifest module + release-root key off main
Moved here so main stays clean for the v1.7.98 release. Contains the trust/
module (canonical.rs, did.rs, signed_doc.rs) + seed::derive_release_root_ed25519.
Not wired into the build yet. Continue this work on this branch.
2026-06-16 11:22:24 -04:00
archipelago ee46a856de docs(whats-new): sync v1.7.98-alpha block 2026-06-16 11:19:08 -04:00
archipelago b037a121d0 docs(changelog): curate v1.7.98-alpha notes 2026-06-16 11:19:00 -04:00
archipelagoandClaude Opus 4.8 4c4cf6d8b4 docs(dht): peer-distributed content design (iroh swarm + signed manifests)
Captures the verified 2026-06-16 design: swarm-assist/origin-always-wins,
iroh-blobs as the swarm engine, BLAKE3 addressing, signed Nostr/release-root
authenticity, and the Phase 0-4 plan. Foundation doc for the dht branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 11:15:47 -04:00
archipelagoandClaude Opus 4.8 45ac9be965 fix(kiosk): cap chromium resources + drop GPU rasterization when headless (#36)
The kiosk chromium pinned ~92% of a core (software-compositing spin from
--enable-gpu-rasterization on a GPU-less/headless node), saturating the machine
and starving the backend + container builds — it caused the .198 receive timeout
and the deploy storms.

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

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

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

cargo check clean.

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

type-check clean.

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

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

cargo check + vue-tsc clean.

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

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

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

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

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

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

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

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

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

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

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

type-check clean; useMessageToast tests 11/11.

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

type-check clean; useAudioPlayer tests 10/10.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:36:12 -04:00
archipelagoandClaude Opus 4.8 9d3347463a docs: record v1.7.91 + v1.7.92 published; What's New gate; .116 nginx fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 09:20:35 -04:00
271 changed files with 23022 additions and 1690 deletions
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Keep the served companion APK in sync with main on every push.
#
# When a push to main includes Android changes, rebuild the APK, refresh
# neode-ui/public/packages/archipelago-companion.apk.zip, commit it, and ask
# you to push again (so the refreshed APK rides along in the same push).
#
# Enable once per clone: git config core.hooksPath .githooks
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
# ship-companion.sh already (re)published the APK for this push — don't redo it.
[ -n "${SHIP_COMPANION:-}" ] && exit 0
PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW=""
while read -r _local_ref local_sha remote_ref remote_sha; do
if [ "${remote_ref##*/}" = "main" ]; then
PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha"
fi
done
[ "$PUSH_MAIN" = "1" ] || exit 0
# Loop-break: if the tip is already the auto APK commit, let the push proceed.
case "$(git log -1 --pretty=%s)" in
*"companion APK"*) exit 0 ;;
esac
# Only rebuild when this push actually touches the Android app.
ZEROS="0000000000000000000000000000000000000000"
if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then
ANDROID_CHANGED=1
elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then
ANDROID_CHANGED=0
else
ANDROID_CHANGED=1
fi
[ "$ANDROID_CHANGED" = "1" ] || exit 0
bash scripts/publish-companion-apk.sh || exit 0
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
if git diff --cached --quiet -- "$DEST"; then
exit 0 # APK unchanged — nothing to do
fi
git commit -q -m "chore(android): update companion APK download [skip ci]"
echo "" >&2
echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2
exit 1
+6
View File
@@ -20,6 +20,12 @@ android {
}
buildTypes {
debug {
// Separate app ID so a debug/test build installs alongside the
// release app instead of colliding on signature.
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
@@ -18,7 +18,11 @@ data class ServerEntry(
val useHttps: Boolean,
val port: String = "",
val password: String = "",
val name: String = "",
) {
/** Label to show in lists — the user-given name, or the address if unnamed. */
fun displayName(): String = name.ifBlank { address }
fun toUrl(): String {
val scheme = if (useHttps) "https" else "http"
val portSuffix = if (port.isNotBlank()) ":$port" else ""
@@ -31,7 +35,9 @@ data class ServerEntry(
return "$scheme://$address$portSuffix"
}
fun serialize(): String = "$address|$useHttps|$port|$password"
// name is the trailing field so entries saved before naming existed
// (4 fields) still deserialize, with name defaulting to "".
fun serialize(): String = "$address|$useHttps|$port|$password|$name"
companion object {
fun deserialize(raw: String): ServerEntry? {
@@ -42,6 +48,7 @@ data class ServerEntry(
useHttps = parts[1].toBooleanStrictOrNull() ?: false,
port = parts.getOrElse(2) { "" },
password = parts.getOrElse(3) { "" },
name = parts.getOrElse(4) { "" },
)
}
}
@@ -53,6 +60,7 @@ class ServerPreferences(private val context: Context) {
private val activeHttpsKey = booleanPreferencesKey("active_https")
private val activePortKey = stringPreferencesKey("active_port")
private val activePasswordKey = stringPreferencesKey("active_password")
private val activeNameKey = stringPreferencesKey("active_name")
private val savedServersKey = stringSetPreferencesKey("saved_servers")
private val introSeenKey = booleanPreferencesKey("intro_seen")
@@ -63,6 +71,7 @@ class ServerPreferences(private val context: Context) {
useHttps = prefs[activeHttpsKey] ?: false,
port = prefs[activePortKey] ?: "",
password = prefs[activePasswordKey] ?: "",
name = prefs[activeNameKey] ?: "",
)
}
@@ -81,6 +90,7 @@ class ServerPreferences(private val context: Context) {
prefs[activeHttpsKey] = server.useHttps
prefs[activePortKey] = server.port
prefs[activePasswordKey] = server.password
prefs[activeNameKey] = server.name
}
addSavedServer(server)
}
@@ -91,6 +101,7 @@ class ServerPreferences(private val context: Context) {
prefs.remove(activeHttpsKey)
prefs.remove(activePortKey)
prefs.remove(activePasswordKey)
prefs.remove(activeNameKey)
}
}
@@ -104,7 +115,16 @@ class ServerPreferences(private val context: Context) {
suspend fun removeSavedServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
val current = prefs[savedServersKey] ?: emptySet()
prefs[savedServersKey] = current - server.serialize()
// Match by connection identity (address/port/scheme) rather than the
// exact serialized string, so a rename — or the legacy 4-field format
// saved before names existed — still removes the right entry.
prefs[savedServersKey] = current.filterNot { raw ->
val e = ServerEntry.deserialize(raw)
e != null &&
e.address == server.address &&
e.port == server.port &&
e.useHttps == server.useHttps
}.toSet()
}
}
@@ -35,6 +35,13 @@ class InputWebSocket(
/** Player ID for arcade mode (0 = broadcast, 1 = P1, 2 = P2) */
var playerId: Int = 0
/**
* Invoked when the kiosk asks us to open a URL in the phone's default
* browser ({"t":"o","url":"…"}). "Open in external browser" apps can't be
* usefully opened on the kiosk, so the kiosk forwards them here.
*/
var onExternalOpen: ((String) -> Unit)? = null
private val _state = MutableStateFlow(ConnectionState.DISCONNECTED)
val state: StateFlow<ConnectionState> = _state
@@ -127,6 +134,20 @@ class InputWebSocket(
reconnectAttempt = 0
}
override fun onMessage(webSocket: WebSocket, text: String) {
// The only inbound message we act on is an external-open request
// forwarded from the kiosk: {"t":"o","url":"https://…"}.
try {
val obj = org.json.JSONObject(text)
if (obj.optString("t") == "o") {
val url = obj.optString("url")
if (url.startsWith("http://") || url.startsWith("https://")) {
onExternalOpen?.invoke(url)
}
}
} catch (_: Exception) {}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
_state.value = ConnectionState.ERROR
scheduleReconnect()
@@ -108,7 +108,9 @@ private fun Btn(icon: ImageVector, key: String, onDir: (String) -> Unit) {
.pointerInput(key) {
detectTapGestures(onPress = {
p = true; onDir(key)
job = scope.launch { delay(350); while (true) { onDir(key); delay(100) } }
// 500ms initial delay so a normal tap sends one key, not two
// (a touch tap often exceeds 350ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(key); delay(100) } }
tryAwaitRelease(); p = false; job?.cancel()
})
},
@@ -83,13 +83,16 @@ val ClassicPalette = NESPalette(
inlayBg = Color(0xFF080808), inlayBorder = Color(0xFF999999),
)
// Glassmorphism-black (OS design): translucent dark surfaces so the backdrop
// shows through the controller, subtle white-alpha borders, translucent-white
// buttons. Accents come from each button's ring.
val DarkPalette = NESPalette(
body = NES.DarkBody, face = NES.DarkFace, ridge = NES.DarkRidge,
label = NES.DarkLabel, labelMuted = NES.DarkLabelMuted,
dpad = Color(0xFF080808), dpadHi = Color(0xFF141418),
btn = NES.DarkButtonMain, btnPress = NES.DarkButtonMainPress,
capsule = Color(0xFF121216), capsulePress = Color(0xFF0A0A0C),
inlayBg = Color(0xFF060608), inlayBorder = Color(0xFF444448),
body = Color(0xA6121216), face = Color(0x8C0E0E12), ridge = Color(0x14FFFFFF),
label = Color(0xFF9A9A9A), labelMuted = Color(0xFF777777),
dpad = Color(0xFF202024), dpadHi = Color(0xFF33333A),
btn = Color(0x14FFFFFF), btnPress = Color(0x0AFFFFFF),
capsule = Color(0x12FFFFFF), capsulePress = Color(0x08FFFFFF),
inlayBg = Color(0x990A0A0A), inlayBorder = Color(0x1FFFFFFF),
)
fun paletteFor(style: ControllerStyle) = if (style == ControllerStyle.CLASSIC) ClassicPalette else DarkPalette
@@ -113,20 +116,10 @@ fun NESController(
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C)) // Slightly lighter than black for shadow visibility
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
) {
// Shadow platform
Box(
modifier = Modifier
.fillMaxWidth(0.86f)
.aspectRatio(2.3f)
.padding(top = 6.dp)
.clip(RoundedCornerShape(18.dp))
.background(Color(0xFF000000)),
)
// Controller body
Box(
Modifier
@@ -135,7 +128,7 @@ fun NESController(
.shadow(32.dp, RoundedCornerShape(16.dp), ambientColor = Color(0xFF000000), spotColor = Color(0xFF000000))
.clip(RoundedCornerShape(16.dp))
.background(
Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f)))
Brush.verticalGradient(listOf(c.body, c.body))
)
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(16.dp)),
) {
@@ -193,13 +186,13 @@ fun NESController(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// C on top (white)
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 44.dp) { onKey("c") }
// C on top
GlassFaceBtn("C", Color(0xFFBBBBBB), 44.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
// B + A on bottom row
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 44.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 44.dp) { onKey("a") }
GlassFaceBtn("B", Color(0xFF60A5FA), 44.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 44.dp) { onKey("a") }
}
}
}
@@ -264,7 +257,9 @@ fun OnePointDPad(c: NESPalette, size: Dp, onDir: (String) -> Unit) {
}
activeDir = dir; onDir(dir)
job?.cancel()
job = scope.launch { delay(300); while (true) { onDir(dir); delay(90) } }
// 500ms initial delay so a normal tap sends one key, not
// two (a touch tap often exceeds 300ms → doubled nav sound).
job = scope.launch { delay(500); while (true) { onDir(dir); delay(90) } }
tryAwaitRelease()
job?.cancel(); activeDir = null
},
@@ -375,6 +370,28 @@ fun ColorBtn(color: Color, pressColor: Color, sz: Dp = 48.dp, onClick: () -> Uni
}
}
/** Glass face button — dark translucent fill, colored ring + letter (OS style) */
@Composable
fun GlassFaceBtn(label: String, accent: Color, sz: Dp = 44.dp, onClick: () -> Unit) {
var p by remember { mutableStateOf(false) }
Box(
Modifier
.size(sz)
.clip(CircleShape)
.background(
Brush.verticalGradient(
if (p) listOf(Color.White.copy(alpha = 0.05f), Color.White.copy(alpha = 0.02f))
else listOf(Color.White.copy(alpha = 0.10f), Color.White.copy(alpha = 0.03f))
)
)
.border(1.5.dp, accent.copy(alpha = if (p) 0.95f else 0.55f), CircleShape)
.pointerInput(Unit) { detectTapGestures(onPress = { p = true; onClick(); tryAwaitRelease(); p = false }) },
contentAlignment = Alignment.Center,
) {
Text(label, color = accent.copy(alpha = if (p) 1f else 0.85f), fontSize = 16.sp, fontWeight = FontWeight.Bold)
}
}
/** START/SELECT capsule */
@Composable
fun CapsuleBtn(label: String, c: NESPalette, w: Dp = 64.dp, h: Dp = 28.dp, onClick: () -> Unit) {
@@ -3,6 +3,8 @@ package com.archipelago.app.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
@@ -34,17 +36,35 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.archipelago.app.data.ServerEntry
import com.archipelago.app.ui.theme.BitcoinOrange
import com.archipelago.app.ui.theme.ControllerStyle
import com.archipelago.app.ui.theme.NES
import com.archipelago.app.ui.theme.SurfaceDark
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/** NES-styled modal menu — dark blue panel with white borders */
// Glassmorphism palette (OS design): near-black surfaces, subtle white borders,
// Bitcoin-orange accent.
private val PanelBg = SurfaceDark // #0A0A0A
private val PanelBorder = Color.White.copy(alpha = 0.12f)
private val RowBg = Color.White.copy(alpha = 0.05f)
private val RowBorder = Color.White.copy(alpha = 0.08f)
private val FieldBg = Color.White.copy(alpha = 0.04f)
private val PANEL_R = 20.dp
private val ROW_R = 14.dp
private val ROW_H = 54.dp
private val FIELD_H = 58.dp
/** Glassmorphism modal menu — #0A0A0A surface, subtle white borders. */
@Composable
fun NESMenu(
visible: Boolean,
@@ -66,7 +86,9 @@ fun NESMenu(
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) { onDismiss() },
contentAlignment = Alignment.Center,
) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
AnimatedVisibility(visible = visible, enter = fadeIn() + scaleIn(initialScale = 0.95f), exit = fadeOut() + scaleOut(targetScale = 0.95f)) {
MenuPanel(servers, activeServer, isGamepadMode, controllerStyle, onDismiss, onSelectServer, onAddServer, onRemoveServer, onToggleMode, onToggleStyle, onBackToWebView)
}
}
}
}
@@ -86,29 +108,45 @@ private fun MenuPanel(
onBackToWebView: (() -> Unit)?,
) {
var showAdd by remember { mutableStateOf(false) }
var nm by remember { mutableStateOf("") }
var addr by remember { mutableStateOf("") }
var pwd by remember { mutableStateOf("") }
fun submit() {
if (addr.isNotBlank()) {
onAddServer(ServerEntry(addr, false, password = pwd, name = nm))
nm = ""; addr = ""; pwd = ""; showAdd = false
}
}
Column(
modifier = Modifier
.widthIn(max = 360.dp)
.clip(RoundedCornerShape(4.dp))
.background(NES.MenuPanel)
.border(3.dp, NES.MenuBorder, RoundedCornerShape(4.dp))
.widthIn(max = 420.dp)
.padding(horizontal = 20.dp)
.clip(RoundedCornerShape(PANEL_R))
.background(PanelBg)
.border(1.dp, PanelBorder, RoundedCornerShape(PANEL_R))
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {}
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
.padding(22.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
// Title
Text("- MENU -", color = NES.MenuText, fontSize = 14.sp, fontWeight = FontWeight.Bold, letterSpacing = 4.sp,
modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.Center)
Spacer(Modifier.height(4.dp))
Text(
"Menu",
color = TextPrimary,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(2.dp))
// Servers
servers.forEach { server ->
val active = server.serialize() == activeServer?.serialize()
MenuItem(
label = (if (active) "\u25B6 " else " ") + server.address,
label = server.displayName(),
selected = active,
onClick = { onSelectServer(server) },
onRemove = { onRemoveServer(server) },
@@ -116,69 +154,70 @@ private fun MenuPanel(
}
if (servers.isEmpty()) {
Text(" NO SERVERS", color = NES.MenuMuted, fontSize = 11.sp, modifier = Modifier.padding(vertical = 4.dp))
Text("No servers", color = TextMuted, fontSize = 14.sp, modifier = Modifier.padding(vertical = 4.dp))
}
// Add server
if (showAdd) {
Column(
Modifier.fillMaxWidth().background(Color.Black.copy(alpha = 0.3f)).padding(8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(ROW_R))
.background(FieldBg)
.border(1.dp, RowBorder, RoundedCornerShape(ROW_R))
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = { Text("192.168.1.100", color = NES.MenuMuted, fontSize = 11.sp) },
modifier = Modifier.fillMaxWidth().height(48.dp), singleLine = true,
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
colors = nesFieldColors(),
shape = RoundedCornerShape(2.dp),
GlassField(
value = nm, onValueChange = { nm = it },
placeholder = "Name (optional)",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
GlassField(
value = addr, onValueChange = { addr = it.trim() },
placeholder = "192.168.1.100",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
GlassField(
value = pwd, onValueChange = { pwd = it },
placeholder = { Text("PASSWORD", color = NES.MenuMuted, fontSize = 11.sp) },
modifier = Modifier.weight(1f).height(48.dp), singleLine = true,
placeholder = "Password",
modifier = Modifier.weight(1f),
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = {
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
}),
textStyle = androidx.compose.ui.text.TextStyle(color = NES.MenuText, fontSize = 12.sp),
colors = nesFieldColors(),
shape = RoundedCornerShape(2.dp),
keyboardActions = KeyboardActions(onGo = { submit() }),
)
Box(
Modifier.size(48.dp).clip(RoundedCornerShape(2.dp)).background(NES.MenuSelected)
.clickable {
if (addr.isNotBlank()) { onAddServer(ServerEntry(addr, false, password = pwd)); addr = ""; pwd = ""; showAdd = false }
},
Modifier.size(FIELD_H).clip(RoundedCornerShape(12.dp)).background(BitcoinOrange.copy(alpha = 0.15f))
.border(1.dp, BitcoinOrange.copy(alpha = 0.4f), RoundedCornerShape(12.dp))
.clickable { submit() },
contentAlignment = Alignment.Center,
) { Text("OK", color = NES.MenuText, fontSize = 10.sp, fontWeight = FontWeight.Bold) }
) { Text("OK", color = BitcoinOrange, fontSize = 14.sp, fontWeight = FontWeight.Bold) }
}
}
} else {
MenuItem(label = " ADD SERVER", onClick = { showAdd = true })
MenuItem(label = "Add Server", labelColor = BitcoinOrange, onClick = { showAdd = true })
}
Spacer(Modifier.height(2.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(NES.MenuBorder.copy(alpha = 0.3f)))
Box(Modifier.fillMaxWidth().height(1.dp).background(PanelBorder))
Spacer(Modifier.height(2.dp))
// Mode toggle
MenuItem(
label = if (isGamepadMode) " SWITCH TO KEYBOARD" else " SWITCH TO GAMEPAD",
label = if (isGamepadMode) "Switch to Keyboard" else "Switch to Gamepad",
onClick = onToggleMode,
)
// Style toggle
MenuItem(
label = if (controllerStyle == ControllerStyle.CLASSIC) " STYLE: CLASSIC" else " STYLE: DARK",
label = if (controllerStyle == ControllerStyle.CLASSIC) "Style: Classic" else "Style: Dark",
onClick = onToggleStyle,
)
// Back to dashboard
if (onBackToWebView != null) {
MenuItem(label = " BACK TO DASHBOARD", onClick = onBackToWebView)
MenuItem(label = "Back to Dashboard", onClick = onBackToWebView)
}
}
}
@@ -187,32 +226,69 @@ private fun MenuPanel(
private fun MenuItem(
label: String,
selected: Boolean = false,
labelColor: Color = TextPrimary,
onClick: () -> Unit,
onRemove: (() -> Unit)? = null,
) {
Row(
Modifier
.fillMaxWidth()
.height(32.dp)
.background(if (selected) NES.MenuSelected.copy(alpha = 0.15f) else Color.Transparent)
.height(ROW_H)
.clip(RoundedCornerShape(ROW_R))
.background(if (selected) BitcoinOrange.copy(alpha = 0.12f) else RowBg)
.border(1.dp, if (selected) BitcoinOrange.copy(alpha = 0.4f) else RowBorder, RoundedCornerShape(ROW_R))
.clickable { onClick() }
.padding(horizontal = 8.dp),
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, color = if (selected) NES.MenuSelected else NES.MenuText, fontSize = 11.sp, fontWeight = FontWeight.Medium)
Text(
label,
color = if (selected) BitcoinOrange else labelColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
)
if (onRemove != null) {
Text("\u2715", color = NES.MenuMuted, fontSize = 10.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp))
Text(
"",
color = TextMuted,
fontSize = 16.sp,
modifier = Modifier.clickable { onRemove() }.padding(horizontal = 8.dp),
)
}
}
}
/** Glass text field with centered input text. */
@Composable
private fun nesFieldColors() = OutlinedTextFieldDefaults.colors(
focusedBorderColor = NES.MenuBorder,
unfocusedBorderColor = NES.MenuMuted,
cursorColor = NES.MenuText,
focusedTextColor = NES.MenuText,
unfocusedTextColor = NES.MenuText,
)
private fun GlassField(
value: String,
onValueChange: (String) -> Unit,
placeholder: String,
modifier: Modifier = Modifier,
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions.Default,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
placeholder = {
Text(placeholder, color = TextMuted, fontSize = 15.sp, modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center)
},
modifier = modifier.fillMaxWidth().height(FIELD_H),
singleLine = true,
visualTransformation = visualTransformation,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
textStyle = TextStyle(color = TextPrimary, fontSize = 16.sp, textAlign = TextAlign.Center),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = BitcoinOrange,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
}
@@ -50,7 +50,6 @@ fun NESPortraitController(
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C))
.twoFingerHold(onMenu)
.padding(horizontal = 40.dp, vertical = 24.dp),
contentAlignment = Alignment.Center,
@@ -62,7 +61,7 @@ fun NESPortraitController(
.fillMaxSize()
.shadow(28.dp, RoundedCornerShape(20.dp), ambientColor = Color.Black, spotColor = Color.Black)
.clip(RoundedCornerShape(20.dp))
.background(Brush.verticalGradient(listOf(c.body, c.body.copy(alpha = 0.95f))))
.background(Brush.verticalGradient(listOf(c.body, c.body)))
.border(1.dp, Color.White.copy(alpha = if (isClassic) 0.08f else 0.04f), RoundedCornerShape(20.dp)),
) {
// Top highlight
@@ -119,11 +118,11 @@ fun NESPortraitController(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ColorBtn(Color(0xFF888888), Color(0xFFAAAAAA), 46.dp) { onKey("c") }
GlassFaceBtn("C", Color(0xFFBBBBBB), 46.dp) { onKey("c") }
Spacer(Modifier.height(6.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ColorBtn(Color(0xFF3B82F6), Color(0xFF60A5FA), 46.dp) { onKey("b") }
ColorBtn(Color(0xFFEA580C), Color(0xFFFB923C), 46.dp) { onKey("a") }
GlassFaceBtn("B", Color(0xFF60A5FA), 46.dp) { onKey("b") }
GlassFaceBtn("A", Color(0xFFF7931A), 46.dp) { onKey("a") }
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
@@ -41,7 +42,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
@@ -67,26 +68,45 @@ fun IntroScreen(onContinue: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
contentAlignment = Alignment.Center,
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the title/buttons stay legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.55f),
Color.Black.copy(alpha = 0.35f),
Color.Black.copy(alpha = 0.75f),
),
)
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.fillMaxWidth()
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Wide pixel-art logo
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.size(160.dp)
.alpha(logoAlpha.value),
colorFilter = ColorFilter.tint(Color.White),
)
Spacer(modifier = Modifier.height(48.dp))
@@ -102,7 +122,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text(
text = stringResource(R.string.welcome_title),
style = MaterialTheme.typography.headlineLarge,
color = TextPrimary,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
)
@@ -111,7 +131,7 @@ fun IntroScreen(onContinue: () -> Unit) {
Text(
text = stringResource(R.string.welcome_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = TextMuted,
color = Color(0xFFFAFAFA),
textAlign = TextAlign.Center,
lineHeight = 26.sp,
)
@@ -2,6 +2,7 @@ package com.archipelago.app.ui.screens
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,13 +25,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import com.archipelago.app.R
import com.archipelago.app.data.ServerPreferences
import com.archipelago.app.network.ConnectionState
import com.archipelago.app.network.InputWebSocket
@@ -58,11 +63,26 @@ fun RemoteInputScreen(onBack: () -> Unit) {
var isGamepadMode by remember { mutableStateOf(true) }
var showModal by remember { mutableStateOf(false) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.CLASSIC) }
var controllerStyle by remember { mutableStateOf(ControllerStyle.DARK) }
var playerId by remember { mutableStateOf(0) } // 0 = broadcast, 1 = P1, 2 = P2
val ws = remember { InputWebSocket(scope) }
// When the kiosk forwards an "open in external browser" app, launch it in
// the phone's default browser.
DisposableEffect(ws) {
ws.onExternalOpen = { url ->
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply { addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) }
context.startActivity(intent)
} catch (_: Exception) {}
}
onDispose { ws.onExternalOpen = null }
}
fun togglePlayer() {
playerId = when (playerId) { 0 -> 1; 1 -> 2; else -> 0 }
ws.playerId = playerId
@@ -98,9 +118,31 @@ fun RemoteInputScreen(onBack: () -> Unit) {
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF0C0C0C))
.windowInsetsPadding(WindowInsets.safeDrawing),
.background(Color(0xFF0C0C0C)),
) {
// Reddish synthwave backdrop behind the controller
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Light scrim — the controller body provides its own contrast, so keep
// this subtle and let the backdrop show through around it.
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.4f),
Color.Black.copy(alpha = 0.25f),
Color.Black.copy(alpha = 0.45f),
),
)
),
)
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when {
isGamepadMode && isLandscape -> NESController(
style = controllerStyle,
@@ -159,6 +201,7 @@ fun RemoteInputScreen(onBack: () -> Unit) {
}
),
)
}
NESMenu(
visible = showModal,
@@ -173,7 +216,20 @@ fun RemoteInputScreen(onBack: () -> Unit) {
onAddServer = { server ->
scope.launch { prefs.addSavedServer(server); if (activeServer == null) prefs.setActiveServer(server) }
},
onRemoveServer = { server -> scope.launch { prefs.removeSavedServer(server) } },
onRemoveServer = { server ->
scope.launch {
prefs.removeSavedServer(server)
// Deleting the last server leaves nothing to control — drop the
// active server and return to the Connect screen.
val remaining = savedServers.count { it.serialize() != server.serialize() }
if (remaining == 0) {
ws.disconnect()
prefs.clearActiveServer()
showModal = false
onBack()
}
}
},
onToggleMode = { isGamepadMode = !isGamepadMode; showModal = false },
onToggleStyle = {
controllerStyle = if (controllerStyle == ControllerStyle.CLASSIC) ControllerStyle.DARK else ControllerStyle.CLASSIC
@@ -55,6 +55,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
@@ -97,6 +98,7 @@ fun ServerConnectScreen(
val scope = rememberCoroutineScope()
val keyboard = LocalSoftwareKeyboardController.current
var name by remember { mutableStateOf("") }
var address by remember { mutableStateOf("") }
var port by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
@@ -132,12 +134,33 @@ fun ServerConnectScreen(
Box(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
.background(SurfaceBlack),
) {
// Reddish synthwave backdrop
Image(
painter = painterResource(id = R.drawable.bg_synthwave),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Dark scrim so the form stays legible over the art
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 0.6f),
Color.Black.copy(alpha = 0.45f),
Color.Black.copy(alpha = 0.8f),
),
)
),
)
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing)
.verticalScroll(state = rememberScrollState())
.drawWithContent { drawContent() }
.padding(horizontal = 24.dp)
@@ -145,14 +168,11 @@ fun ServerConnectScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Wide logo
// Circular badge logo
Image(
painter = painterResource(id = R.drawable.ic_logo_wide),
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = "Archipelago",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
colorFilter = ColorFilter.tint(Color.White),
modifier = Modifier.size(96.dp),
)
Spacer(modifier = Modifier.height(4.dp))
@@ -178,6 +198,7 @@ fun ServerConnectScreen(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
@@ -190,6 +211,34 @@ fun ServerConnectScreen(
.padding(20.dp),
) {
Column {
OutlinedTextField(
value = name,
onValueChange = {
name = it
errorMessage = null
},
label = { Text(stringResource(R.string.server_name_label)) },
placeholder = { Text(stringResource(R.string.server_name_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Next,
),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color.White.copy(alpha = 0.3f),
unfocusedBorderColor = Color.White.copy(alpha = 0.12f),
cursorColor = Color.White,
focusedLabelColor = Color.White.copy(alpha = 0.7f),
unfocusedLabelColor = TextMuted,
focusedTextColor = TextPrimary,
unfocusedTextColor = TextPrimary,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = address,
onValueChange = {
@@ -275,7 +324,7 @@ fun ServerConnectScreen(
keyboardActions = KeyboardActions(
onGo = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password))
connect(ServerEntry(address, useHttps, port, password, name))
},
),
colors = OutlinedTextFieldDefaults.colors(
@@ -345,7 +394,7 @@ fun ServerConnectScreen(
text = if (isConnecting) stringResource(R.string.connecting) else stringResource(R.string.connect),
onClick = {
keyboard?.hide()
connect(ServerEntry(address, useHttps, port, password))
connect(ServerEntry(address, useHttps, port, password, name))
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@@ -391,6 +440,7 @@ private fun SavedServerItem(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Color.Black.copy(alpha = 0.6f))
.background(
Brush.verticalGradient(
colors = listOf(
@@ -414,9 +464,15 @@ private fun SavedServerItem(
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(text = server.address, style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (server.port.isNotBlank()) {
Text(text = "Port ${server.port}", style = MaterialTheme.typography.labelMedium, color = TextMuted)
Text(text = server.displayName(), style = MaterialTheme.typography.bodyMedium, color = TextPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis)
val secondary = buildString {
if (server.name.isNotBlank()) append(server.address)
if (server.port.isNotBlank()) {
if (isNotEmpty()) append(":${server.port}") else append("Port ${server.port}")
}
}
if (secondary.isNotBlank()) {
Text(text = secondary, style = MaterialTheme.typography.labelMedium, color = TextMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -18,6 +18,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -28,8 +29,11 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.CloudOff
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -41,8 +45,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.archipelago.app.R
@@ -51,7 +57,67 @@ import com.archipelago.app.ui.theme.SurfaceBlack
import com.archipelago.app.ui.theme.TextMuted
import com.archipelago.app.ui.theme.TextPrimary
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply {
// Required when launching from a non-Activity/binder thread
// (the JS bridge below can run off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
/** True when [url] points at the same host as the connected Archipelago node
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
* and should stay inside the app rather than bouncing out to the browser. */
private fun isSameHost(url: String, base: String): Boolean {
return try {
val a = android.net.Uri.parse(url).host ?: return false
val b = android.net.Uri.parse(base).host ?: return false
a.equals(b, ignoreCase = true)
} catch (_: Exception) {
false
}
}
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
* These are tuned for SPA performance and parity with the mobile browser;
* none of them alter how a page renders visually. */
@SuppressLint("SetJavaScriptEnabled")
private fun WebView.applyArchipelagoSettings() {
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
// show blank checkerboarding — the single biggest scroll-smoothness win and
// a major part of the "feels slower than the browser" gap. (API 23+)
settings.setOffscreenPreRaster(true)
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
builtInZoomControls = false
cacheMode = WebSettings.LOAD_DEFAULT
allowContentAccess = true
allowFileAccess = false
}
// chrome://inspect profiling on debuggable builds only — lets us measure the
// real in-page bottleneck rather than guess. No effect on release builds.
val debuggable = 0 != (context.applicationInfo.flags and
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
}
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
@Composable
fun WebViewScreen(
serverUrl: String,
@@ -63,7 +129,12 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
BackHandler(enabled = webView?.canGoBack() == true) {
// A node app that refused iframing, opened in a local WebView overlay.
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) }
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
webView?.goBack()
}
@@ -132,20 +203,6 @@ fun WebViewScreen(
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
fun openExternalUrl(url: String) {
try {
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
).apply {
// Required when launching from a non-Activity/binder
// thread (the JS bridge below runs off the UI thread).
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (_: Exception) {}
}
WebView(context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
@@ -159,19 +216,8 @@ fun WebViewScreen(
cookieManager.setAcceptCookie(true)
cookieManager.setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
databaseEnabled = true
mediaPlaybackRequiresUserGesture = false
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
useWideViewPort = true
loadWithOverviewMode = true
setSupportZoom(false)
builtInZoomControls = false
cacheMode = WebSettings.LOAD_DEFAULT
allowContentAccess = true
allowFileAccess = false
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
// Let JS open windows without a synchronous user-gesture
// chain; without this, window.open() from a Vue click
@@ -179,18 +225,35 @@ fun WebViewScreen(
javaScriptCanOpenWindowsAutomatically = true
}
// Deterministic bridge for "open in the phone's browser".
// The web UI calls window.ArchipelagoNative.openExternal(url)
// when present (companion app), falling back to window.open
// in a plain mobile browser. This avoids relying on the
// window.open → onCreateWindow path, which noopener/noreferrer
// can suppress in the WebView.
val webViewRef = this
// Decide where an outbound URL goes:
// - same host as the node → in-app WebView overlay
// (this is the "open in browser" target for apps the
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
}
}
// JS bridge. The web UI calls:
// window.ArchipelagoNative.openExternal(url) — host-routed
// window.ArchipelagoNative.openInApp(url) — force in-app
// Falls back to window.open in a plain mobile browser.
addJavascriptInterface(
object {
@android.webkit.JavascriptInterface
fun openExternal(url: String) {
webViewRef.post { openExternalUrl(url) }
webViewRef.post { routeOutbound(url) }
}
@android.webkit.JavascriptInterface
fun openInApp(url: String) {
webViewRef.post { inAppUrl = url }
}
},
"ArchipelagoNative",
@@ -252,10 +315,10 @@ fun WebViewScreen(
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return false
// Keep navigation within the Archipelago server
// Keep kiosk navigation (same origin incl. port) in place
if (url.startsWith(serverUrl)) return false
// Open external URLs in the system browser
openExternalUrl(url)
// Same node (other port) → in-app; external → browser
routeOutbound(url)
return true
}
}
@@ -265,7 +328,9 @@ fun WebViewScreen(
loadProgress = newProgress
}
// Handle window.open() — open in system browser
// window.open() — e.g. the kiosk's "Open in new tab"
// for an app that can't be iframed. Capture the target
// URL via a throwaway WebView and route it ourselves.
override fun onCreateWindow(
view: WebView?,
isDialog: Boolean,
@@ -283,12 +348,12 @@ fun WebViewScreen(
request: WebResourceRequest?,
): Boolean {
val url = request?.url?.toString() ?: return true
openExternalUrl(url)
routeOutbound(url)
return true
}
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
if (url != null) openExternalUrl(url)
if (url != null) routeOutbound(url)
view?.stopLoading()
}
}
@@ -350,6 +415,140 @@ fun WebViewScreen(
)
}
// In-app browser overlay for non-iframeable node apps. Rendered last
// so it sits above the kiosk WebView, which stays alive underneath.
inAppUrl?.let { target ->
InAppBrowser(
url = target,
serverUrl = serverUrl,
onClose = { inAppUrl = null },
)
}
}
}
}
/**
* Lightweight in-app browser used when the kiosk hands off an app that can't be
* shown in an iframe. Loads the app in a local WebView with a minimal top bar
* (close + title + escalate-to-real-browser). Same-host navigation stays here;
* any genuinely external link escapes to the phone's browser.
*/
@SuppressLint("SetJavaScriptEnabled")
@Composable
private fun InAppBrowser(
url: String,
serverUrl: String,
onClose: () -> Unit,
) {
val context = LocalContext.current
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var progress by remember { mutableIntStateOf(0) }
var loading by remember { mutableStateOf(true) }
// Back: walk the in-app history first, then close the overlay.
BackHandler {
val b = browser
if (b != null && b.canGoBack()) b.goBack() else onClose()
}
Column(
modifier = Modifier
.fillMaxSize()
.background(SurfaceBlack)
.windowInsetsPadding(WindowInsets.safeDrawing),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onClose) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.close),
tint = TextPrimary,
)
}
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
color = TextPrimary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
Icon(
imageVector = Icons.Default.OpenInBrowser,
contentDescription = stringResource(R.string.open_in_browser),
tint = TextMuted,
)
}
}
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
LinearProgressIndicator(
progress = { progress / 100f },
modifier = Modifier.fillMaxWidth(),
color = BitcoinOrange,
trackColor = SurfaceBlack,
)
}
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
applyArchipelagoSettings()
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
progress = newProgress
}
override fun onReceivedTitle(view: WebView?, t: String?) {
if (!t.isNullOrBlank()) title = t
}
}
webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
loading = true
}
override fun onPageFinished(view: WebView?, u: String?) {
loading = false
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
openExternalUrl(ctx, u)
return true
}
}
browser = this
loadUrl(url)
}
},
)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Plain dark tile. The badge (single SVG-matched ring + grid) is in the
foreground so there's exactly one ring. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#030202"
android:fillColor="#0A0A0A"
android:pathData="M0,0h108v108H0z" />
</vector>
@@ -1,45 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago pixel-art "A" logo — scaled 90% and centered -->
<!-- Complete Archipelago badge (gradient ring + white grid) scaled to ~0.64 so
the whole coin — including the ring — sits inside the adaptive safe zone and
is never clipped. Ring is bold + bright (grey→white) to read on #0A0A0A. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
android:viewportWidth="752"
android:viewportHeight="752">
<group
android:pivotX="512"
android:pivotY="512"
android:scaleX="0.55"
android:scaleY="0.55">
android:pivotX="376"
android:pivotY="376"
android:scaleX="0.72"
android:scaleY="0.72">
<!-- Row 1: 4 blocks -->
<path android:fillColor="#FFFFFF" android:pathData="M357.614,318h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,318h72.082v70.936h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,318h71.007v70.936h-71.007z" />
<!-- Ringed circle -->
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
<!-- Row 2: 2 blocks (right side) -->
<path android:fillColor="#FFFFFF" android:pathData="M595.379,396.46h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,396.46h72.083v72.011h-72.083z" />
<!-- Row 3: 6 blocks (full width) -->
<path android:fillColor="#FFFFFF" android:pathData="M278,475.994h72.083v72.012h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,475.994h72.082v72.012h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,475.994h71.007v72.012h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,475.994h72.083v72.012h-72.083z" />
<!-- Row 4: 4 blocks (sides only — the "A" gap) -->
<path android:fillColor="#FFFFFF" android:pathData="M278,555.529h72.083v70.936h-72.083z" />
<path android:fillColor="#FFFFFF" android:pathData="M357.614,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,555.529h71.007v70.936h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M673.917,555.529h72.083v70.936h-72.083z" />
<!-- Row 5: 4 blocks (bottom) -->
<path android:fillColor="#FFFFFF" android:pathData="M357.614,633.989h71.007v72.011h-71.007z" />
<path android:fillColor="#FFFFFF" android:pathData="M436.152,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M515.766,633.989h72.082v72.011h-72.082z" />
<path android:fillColor="#FFFFFF" android:pathData="M595.379,633.989h71.007v72.011h-71.007z" />
<!-- White Archipelago pixel grid -->
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</group>
</vector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Archipelago circular badge logo (from logo.svg):
dark circle with a black→grey gradient ring + white pixel-grid mark. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="120dp"
android:height="120dp"
android:viewportWidth="752"
android:viewportHeight="752">
<!-- Ringed circle (circle converted to a path; stroke carries the gradient) -->
<path
android:fillColor="#0A0A0A"
android:strokeWidth="22.8834"
android:pathData="M11.441,375.669a364.227,364.227 0 1,0 728.454,0a364.227,364.227 0 1,0 -728.454,0z">
<aapt:attr name="android:strokeColor">
<gradient
android:type="linear"
android:startX="751.337"
android:startY="751.338"
android:endX="0"
android:endY="0">
<item android:offset="0" android:color="#FF000000" />
<item android:offset="1" android:color="#FF666666" />
</gradient>
</aapt:attr>
</path>
<!-- White Archipelago pixel grid -->
<path
android:fillColor="#FFFFFF"
android:pathData="M253.805,278.37V222.28H309.853V278.37H253.805ZM315.797,278.37V222.28H372.694V278.37H315.797ZM378.639,278.37V222.28H435.536V278.37H378.639ZM441.481,278.37V222.28H497.529V278.37H441.481ZM441.481,341.259V284.319H497.529V341.259H441.481ZM503.473,341.259V284.319H560.37V341.259H503.473ZM190.963,404.148V347.208H247.86V404.148H190.963ZM253.805,404.148V347.208H309.853V404.148H253.805ZM315.797,404.148V347.208H372.694V404.148H315.797ZM378.639,404.148V347.208H435.536V404.148H378.639ZM441.481,404.148V347.208H497.529V404.148H441.481ZM503.473,404.148V347.208H560.37V404.148H503.473ZM190.963,466.187V410.097H247.86V466.187H190.963ZM253.805,466.187V410.097H309.853V466.187H253.805ZM441.481,466.187V410.097H497.529V466.187H441.481ZM503.473,466.187V410.097H560.37V466.187H503.473ZM253.805,529.076V472.136H309.853V529.076H253.805ZM315.797,529.076V472.136H372.694V529.076H315.797ZM378.639,529.076V472.136H435.536V529.076H378.639ZM441.481,529.076V472.136H497.529V529.076H441.481Z" />
</vector>
@@ -21,4 +21,8 @@
<string name="retry">Retry</string>
<string name="remote_input">Remote Control</string>
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
<string name="close">Close</string>
<string name="open_in_browser">Open in browser</string>
<string name="server_name_label">Server Name (optional)</string>
<string name="server_name_placeholder">My Archipelago</string>
</resources>
+10
View File
@@ -0,0 +1,10 @@
<svg width="752" height="752" viewBox="0 0 752 752" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="375.668" cy="375.669" r="364.227" fill="#0A0A0A" stroke="url(#paint0_linear_877_1990)" stroke-width="22.8834"/>
<path d="M253.805 278.37V222.28H309.853V278.37H253.805ZM315.797 278.37V222.28H372.694V278.37H315.797ZM378.639 278.37V222.28H435.536V278.37H378.639ZM441.481 278.37V222.28H497.529V278.37H441.481ZM441.481 341.259V284.319H497.529V341.259H441.481ZM503.473 341.259V284.319H560.37V341.259H503.473ZM190.963 404.148V347.208H247.86V404.148H190.963ZM253.805 404.148V347.208H309.853V404.148H253.805ZM315.797 404.148V347.208H372.694V404.148H315.797ZM378.639 404.148V347.208H435.536V404.148H378.639ZM441.481 404.148V347.208H497.529V404.148H441.481ZM503.473 404.148V347.208H560.37V404.148H503.473ZM190.963 466.187V410.097H247.86V466.187H190.963ZM253.805 466.187V410.097H309.853V466.187H253.805ZM441.481 466.187V410.097H497.529V466.187H441.481ZM503.473 466.187V410.097H560.37V466.187H503.473ZM253.805 529.076V472.136H309.853V529.076H253.805ZM315.797 529.076V472.136H372.694V529.076H315.797ZM378.639 529.076V472.136H435.536V529.076H378.639ZM441.481 529.076V472.136H497.529V529.076H441.481Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_877_1990" x1="751.337" y1="751.338" x2="0" y2="0.000976562" gradientUnits="userSpaceOnUse">
<stop/>
<stop offset="1" stop-color="#666666"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
#
# Build the Android companion app and publish it as the served download
# (neode-ui/public/packages/archipelago-companion.apk.zip), then commit + push.
#
# Use this INSTEAD of `git push` when shipping the companion app, so the
# downloadable APK on the node always matches what's on main.
#
# ./Android/ship-companion.sh
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
export JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk@17}"
export ANDROID_HOME="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
APK="Android/app/build/outputs/apk/debug/app-debug.apk"
DEST="neode-ui/public/packages/archipelago-companion.apk.zip"
echo "==> Building debug APK"
( cd Android && ./gradlew :app:assembleDebug --console=plain -q )
[ -f "$APK" ] || { echo "ERROR: APK not found at $APK" >&2; exit 1; }
echo "==> Publishing -> $DEST"
mkdir -p "$(dirname "$DEST")"
rm -f "$DEST"
( cd "$(dirname "$APK")" && zip -j -q "$ROOT/$DEST" "$(basename "$APK")" )
git add "$DEST"
if git diff --cached --quiet; then
echo "==> Nothing to commit (working tree + APK unchanged)"
else
git commit -q -m "chore(android): update companion apk download"
echo "==> Committed"
fi
echo "==> Pushing $(git branch --show-current)"
# SHIP_COMPANION lets the pre-push guard know the APK was just refreshed.
SHIP_COMPANION=1 git push origin "$(git branch --show-current)"
echo "==> Done — companion APK published and pushed."
+97
View File
@@ -1,5 +1,102 @@
# Changelog
## v1.8.00-alpha (2026-06-18)
Polishes the mesh AI assistant and Fedimint, on top of all the v1.7.99 features (kept listed below so you can still see what's new).
- The off-grid mesh radio no longer posts cryptic identity codes to the shared public channel. Your node was announcing a line starting with "ARCHY:" to the public channel about once a minute, which everyone else on that channel saw as spam; that broadcast has been removed.
- You can now use your node's AI assistant straight from a normal chat. Send "!ai <your question>" in a direct message to an AI-enabled node and the answer comes right back in the same conversation — whether your message travelled over the internet or the LoRa radio. Before, the reply could be sent on the wrong path and never arrive.
- The Mesh AI Assistant panel is easier to set up: pick the Claude model from a dropdown (Haiku, Sonnet, or Opus) instead of typing it, and add specific contacts to an "always allow" list so chosen people can use "!ai" even when the assistant is set to trusted-nodes-only.
- Fedimint federations show up in Wallet Settings again. The Fedimint client app wasn't starting because of a configuration error, so the federation your node auto-joins never appeared; the client is fixed and runs again.
- In Settings, "App Updates" and "App Registry" now sit directly under your Account section for quicker access.
- In Mesh chat, scrolling the conversation no longer also scrolls the contact list behind it.
- Mesh direct messages are now private and end-to-end encrypted to the recipient — they're sent as real radio DMs instead of being broadcast on the public channel, so other people on the mesh no longer see them, and the answer arrives intact (even on standard meshcore phone apps).
- You can now message standard meshcore apps (like the phone companion) and they can message you — text shows up readable on both sides, and your node's AI answers come back as a private reply rather than on the public channel.
- New contacts you hear on the radio are added automatically, so people show up in your Peers list without any extra steps.
- "Clear All" now actually removes contacts (rather than hiding them forever); a contact comes back on its own the next time it's in range. Each contact also shows a reachability dot so you can see who's currently reachable.
- The Peers list has a search box (with a clear button) to quickly filter your contacts by name, DID, npub, or key.
All the v1.7.99-alpha features are included as well:
- Your node can now hold Fedimint ecash as well as Cashu, with tabbed Wallet Settings for each and both balances shown side by side on the home wallet card.
- You can buy files shared by another node right from their cloud, paying from this node's ecash, your Lightning wallet, on-chain, or by scanning a Lightning QR with any outside wallet.
- Your node can act as an AI assistant on the off-grid mesh: peers ask by starting a message with "!ai" and get an answer back over the radio, with a panel to turn it on or off.
- You can view your node's 24-word recovery phrase any time from Settings, behind a password (and 2FA) confirmation and a tap-to-show blur.
- Setting up a brand-new node is smoother: it waits and retries quietly instead of flashing errors, and shows a gentle "securing your private connection…" status that turns to "ready" on its own.
- The NetBird VPN app now logs in (it's served over HTTPS and opens in a browser tab).
- Phone remote-control of a node's screen now supports two-finger scrolling inside apps, and external-browser apps open on your phone.
- You can choose whether your node shares Bitcoin block headers over the mesh, and your choices are remembered.
- Version numbers display cleanly everywhere (no more doubled "v"), and "Back" buttons look and behave consistently across desktop and mobile.
- For advanced testing, Settings includes an optional update & app source choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode, with the trusted origin remaining the default.
## v1.7.99-alpha (2026-06-17)
- Your node can now hold Fedimint ecash as well as Cashu. Wallet Settings now has tabbed sections for each: keep your list of trusted Cashu mints, or paste a Fedimint invite code to join a federation, and the home wallet card shows both your Cashu and Fedimint balances side by side. A new "Fedimint Client" app in the catalog powers the federation side.
- You can now buy files shared by another node, right from their cloud. When you open a peer's paid file you get a simple "Buy this file" picker with several ways to pay — instantly from this node's ecash balance, from your node's own Lightning wallet, on-chain from your node, or by scanning a Lightning QR code with any outside wallet. Once payment settles, the file downloads automatically.
- Your node can now act as an AI assistant on the off-grid mesh radio network. If your node has a local AI model available (via Ollama), other people on the mesh can ask it a question by starting their message with "!ai" and get an answer back over the radio — handy where there's no internet. A new Mesh assistant panel lets you turn this on or off and shows whether a local AI model was detected.
- You can now view your node's 24-word recovery phrase whenever you need it. Settings has a new "Recovery phrase" option that, after you confirm your password (and 2FA code if you use one), reveals the words behind a tap-to-show blur with a copy button — so you can write them down and store them safely offline.
- Setting up a brand-new node is smoother and less alarming. If the node is still starting up while you generate or confirm your recovery phrase, it now quietly waits and retries instead of flashing a scary error, and offers a clear "Try again" button only when something genuinely goes wrong. The final setup screen also shows a gentle "securing your private connection…" status that turns to "ready" on its own, so you can tell the encrypted transport is coming up rather than stuck.
- The NetBird VPN app now actually logs in. It was failing to reach its sign-in screen because the dashboard needs a secure (HTTPS) connection that wasn't being provided; the node now serves it over HTTPS and opens it in a browser tab, so the login flow completes.
- When you use your phone to remote-control a node's attached screen, two-finger scrolling now works inside apps and panels, not just the main page. And tapping an app that's meant to open in an external browser now hands the link to your phone to open there, instead of trying to open it on the (often unattended) attached display.
- You can now choose whether your node shares Bitcoin block headers over the mesh. The Mesh Bitcoin panel has new switches to announce headers to peers and to accept headers from them, and your choices are remembered.
- Version numbers now display cleanly everywhere. In a few places the interface was showing a doubled "v" (like "vv1.7.98"); it now always shows a single, tidy version label.
- The "Back" buttons throughout the cloud and other detail screens now look and behave consistently on both desktop and mobile, including when browsing another node's files.
- For advanced testing, Settings now includes an optional "update & app source" choice between the usual trusted origin and an experimental peer-to-peer (DHT swarm) mode that pulls updates and app content from other nodes first, falling back to the origin automatically. The trusted origin remains the default.
## v1.7.98-alpha (2026-06-16)
- Apps that crash now recover on their own. Multi-part apps like Immich and IndeedHub could have one of their pieces stop and stay stopped until the whole node was rebooted; the node now checks every couple of minutes and restarts any crashed piece automatically (while still leaving apps you deliberately stopped alone).
- The on-screen kiosk display can no longer slow the whole node down. On machines without a graphics chip the kiosk browser could spin a CPU core at full tilt, starving everything else (including the wallet, which then timed out); it's now capped and uses lighter rendering on those machines.
- If an update download fails, you're taken back to the Download button to retry, instead of being stranded on an Install button for an update that didn't actually finish downloading.
- Your node's identity is clearer and always visible: Settings now shows your Node DID on every node (it previously only appeared if your browser had cached it) plus your node's npub, both with copy buttons. There's also a terminal tool to cryptographically prove all your node's keys come from your one seed phrase.
- The "all nodes over Tor" group chat sends quickly now — the "sending" spinner clears as soon as the reachable nodes have the message, instead of hanging on a slow or offline node.
- Message notifications now have a close button and open the relevant chat when tapped.
- The encrypted mesh transport (FIPS) turns itself on automatically after setup — no button to press — and connects to peers more reliably (it retries and keeps connections warm), so node-to-node features use the fast path more often instead of falling back to Tor.
- Your chat history with other nodes is saved reliably and now encrypted on disk, so it survives restarts and updates and can't be read from a stolen drive (only clearing chat removes it).
- Peer media shows a "connecting" loader before a video or audio file plays, and audio errors are accurate instead of blaming File Browser.
- The Fedimint app now displays with its proper styling, and the Connected Nodes screen stays compact — it shows a few nodes and scrolls, you can tap a node to jump to it in Federation, or tap Message to open its chat.
- App updates can now arrive on their own without waiting for a full system release, so individual apps can be improved and shipped faster.
## v1.7.97-alpha (2026-06-16)
- The Bitcoin sync status on the home screen no longer disappears for a moment when it refreshes. If the node was briefly busy, the panel used to vanish and pop back; it now stays put and simply shows "Updating…" until the next reading arrives, while a genuinely stopped node still correctly shows as not running.
- Bitcoin sync progress on the home screen now updates more promptly, so the percentage and block height keep pace with the node instead of lagging behind.
- The Lightning wallet "connect your wallet" screen loads its details and QR code again across all nodes, instead of failing to fetch them.
- Your list of trusted nodes is now clean: the same node no longer appears several times under different names, and removed nodes stay removed. In chat, a node that previously showed up as two separate contacts now appears just once.
- Browsing another node's cloud is smoother: music and video files from a peer now preview and play properly (including seeking partway through), and the connection now shows a small badge telling you whether it's using the fast encrypted mesh or the slower Tor network.
- Opening "My Folders" in the cloud now shows a clear, friendly message when the file app isn't running, instead of a confusing error.
- The Electrum server app opens on its own once it's ready, instead of sometimes leaving a loading spinner stuck on top of the screen.
- The Fedimint app now displays with its proper styling and icons, instead of appearing unstyled with a missing image.
- The Mempool app now connects to your Bitcoin node whether the node is Bitcoin Core or Bitcoin Knots, instead of only working with one of them.
- Nodes start up cleanly after a reboot. On some boots the node's main service was trying to start before its data drive had finished mounting, so it failed and retried about twenty times over roughly five minutes — showing a wall of "Failed to start" messages — before finally coming up. It now waits for the data drive to be ready first, so it starts on the first try.
- The background images throughout the interface now load faster — they've been made significantly smaller with no loss of quality.
## v1.7.96-alpha (2026-06-15)
- The screen attached to your node now shows the normal Archipelago interface and your dashboard after you sign in, instead of a separate, stripped-down grid of app icons that could appear in its place. That extra screen has been removed so the attached display matches what you see everywhere else.
- On a brand-new node, the attached screen now walks through the same welcome and setup steps you'd see on a phone or laptop, and shows the normal sign-in screen once the node is set up — so the on-device display always matches the rest of the interface.
- When adding a FIPS network anchor, you can now choose whether it connects over TCP (for a public anchor reached across the internet) or UDP (for one on your local network), instead of it always assuming the local-network option.
- Behind the scenes, a new automated two-node test now exercises real node-to-node features — browsing another node's shared files and handling a removed node — against live nodes before each release, so node-to-node problems are caught earlier.
## v1.7.95-alpha (2026-06-15)
- Browsing another node's shared files now works over the fast encrypted mesh. Opening a peer's cloud could fail with a generic "Operation failed" message because the request for their file list wasn't permitted over the mesh and came back as "not found" — and it never retried over Tor. The mesh now serves the file list directly, and if a peer can't answer over the mesh the node automatically falls back to Tor instead of giving up.
- Nodes you remove from your federation now stay removed. Previously a deleted node could quietly come back the next time you synced with another node that still listed it. Removed nodes are now remembered as removed and won't reappear on their own — only if you add them back yourself.
- The app credentials pop-up now appears as a normal centred box with a dimmed background over the whole screen, instead of stretching to fill the entire screen.
## v1.7.94-alpha (2026-06-15)
- Your node now joins the private encrypted mesh network on its own. A wrong built-in setting meant nodes were quietly never reaching the shared mesh meeting point, so everything between nodes fell back to the slower Tor network. Every node now connects to the mesh automatically on startup, so node-to-node features like file sharing use the faster encrypted mesh first and only fall back to Tor when a peer is genuinely offline. (Confirmed live: a node with its mesh setting wiped re-connected to the mesh by itself within a second of starting.)
- You can now bring the mesh networking software up to the latest stable version straight from the node, with one action — it fetches the new version, checks it's genuine before installing, and restarts the mesh on its own. (Confirmed live end to end: a node on an older build was upgraded to the current stable release and rejoined the mesh automatically.)
- The Lightning wallet screen connects again on nodes where it was showing a "failed to fetch" error instead of your balance and channels. The wallet app and the node now talk to each other correctly, and the connection quietly repairs itself if its details drift after a restart.
## v1.7.93-alpha (2026-06-14)
- Receiving Bitcoin and Lightning works again on nodes where the Lightning wallet was stuck locked. After some updates the wallet could come back locked with a password the node no longer had, so "generate a receive address" kept failing with a "wallet is locked" message that nothing could clear. The node now detects this and repairs itself automatically.
- Each node now secures its Lightning wallet with its own unique, randomly generated password instead of a shared built-in one, and remembers it safely so the wallet unlocks on its own after every restart or update — no more getting stuck locked.
- If a wallet is found locked with an unrecoverable password, the node rebuilds it cleanly so Bitcoin and Lightning start working again. (On these early-access nodes the wallet holds no funds, so nothing is lost — a wallet locked with an unknown password was already inaccessible.)
- The self-repair was validated end to end on live nodes: a stuck, locked wallet was detected, rebuilt, and came back unlocked on its own, and stayed unlocked across restarts.
## v1.7.92-alpha (2026-06-14)
- The Electrum server app no longer flashes a "can't connect, try again" error over its loading screen while it's still catching up. If ElectrumX is building its index or waiting on the Bitcoin node, you now just see the sync progress, and the app opens on its own once it's ready.
+12
View File
@@ -290,6 +290,18 @@
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
"id": "fedimint-clientd",
"title": "Fedimint Client",
"version": "0.8.0",
"description": "Fedimint ecash client daemon (fmcd). Lets your node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.",
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.0",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
"id": "fedimint-gateway",
"title": "Fedimint Gateway",
+78
View File
@@ -0,0 +1,78 @@
app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
# fmcd built from source (github.com/minmoto/fmcd v0.8.0, fedimint-client
# 0.8.2 — iroh-capable). No usable upstream image exists, so we build + push
# this to the node registry. Pin the tag to match the REST shapes coded in
# core/archipelago/src/wallet/fedimint_client.rs (validated against 0.8.2).
image: 146.59.87.168:3000/lfg2025/fmcd:0.8.0
pull_policy: if-not-present
network: archy-net
# No entrypoint override: the image's resilient `fmcd-run` launcher loops
# fmcd and retries on join failure (fmcd needs >=1 federation to boot), so an
# unreachable default never crash-loops. All config comes from FMCD_* env
# below. Nodes can join more federations via wallet.fedimint-join.
secret_env:
- key: FMCD_PASSWORD
secret_file: fmcd-password
data_uid: "1000:1000"
# NOTE: this is a CLIENT, not the guardian — it does not require the local
# `fedimint` app. It joins external federations (default below), so it can be
# bundled standalone on every node.
dependencies:
- storage: 2Gi
resources:
cpu_limit: 1
memory_limit: 1Gi
disk_limit: 2Gi
security:
capabilities: []
readonly_root: true
# NOT isolated: fmcd needs outbound UDP + Mainline DHT (port 6881) + iroh
# relays to reach iroh-transport federations. `bridge` gives NAT'd outbound
# (UDP/DHT/iroh hole-punch all work) plus the published 8178→8080 port the
# wallet bridge targets. ("open" is not a valid policy — it made the loader
# skip this whole manifest, so fmcd never ran and federations never joined.)
# Lock down once the default federation's reachability model is finalized.
network_policy: bridge
ports:
# fmcd REST bound to 8080 in-container; 8080 collides with LND REST on the
# host, so map to 8178. The Rust bridge targets http://127.0.0.1:8178.
- host: 8178
container: 8080
protocol: tcp
volumes:
# Same dir the first-boot bundled path uses + where the wallet bridge reads
# the password (/var/lib/archipelago/fmcd/password) — keep install paths aligned.
- type: bind
source: /var/lib/archipelago/fmcd
target: /data
options: [rw]
environment:
- FMCD_ADDR=0.0.0.0:8080
- FMCD_MODE=rest
- FMCD_DATA_DIR=/data
# Default federation joined out-of-the-box (guardian on .116, iroh
# transport; validated to join with fmcd 0.8.2). iroh does NAT traversal so
# it's reachable fleet-wide. Keep in sync with DEFAULT_FEDERATION_INVITE in
# core/.../wallet/fedimint_client.rs. CAVEAT: iroh is experimental — validate
# join reliability from a real second node before relying on auto-bundle.
- FMCD_INVITE_CODE=fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp
health_check:
type: http
endpoint: http://localhost:8080
path: /health
interval: 30s
timeout: 5s
retries: 3
+42
View File
@@ -0,0 +1,42 @@
app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
:8336, and reverse-proxies /rpc/v1 to the archipelago backend on
127.0.0.1:5678. All FIPS controls (status, seed anchors, reconnect,
restart, and stable-channel daemon updates) go through the existing
fips.* RPC methods, authenticated by the browser's own archipelago
session — there is no separate secret to manage.
container:
build:
context: /opt/archipelago/docker/fips-ui
dockerfile: Dockerfile
tag: localhost/fips-ui:local
resources:
memory_limit: 128Mi
security:
readonly_root: false
network_policy: host
# Host networking: nginx listens on 8336 directly on the host IP and
# proxies to 127.0.0.1:5678 (the archipelago RPC). `ports:` is
# intentionally empty because host networking bypasses port mapping.
ports: []
volumes: []
environment: []
health_check:
type: http
endpoint: http://127.0.0.1:8336
path: /
interval: 30s
timeout: 5s
retries: 3
+6 -1
View File
@@ -8,6 +8,12 @@ app:
image: git.tx1138.com/lfg2025/mempool-backend:v3.0.0
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
# Core — resolved at apply time from host facts (B12). Hardcoding either
# breaks mempool's RPC connection on the other.
derived_env:
- key: CORE_RPC_HOST
template: "{{BITCOIN_HOST}}"
secret_env:
- key: CORE_RPC_PASSWORD
secret_file: bitcoin-rpc-password
@@ -47,7 +53,6 @@ app:
- ELECTRUM_HOST=electrumx
- ELECTRUM_PORT=50001
- ELECTRUM_TLS_ENABLED=false
- CORE_RPC_HOST=bitcoin-knots
- CORE_RPC_PORT=8332
- CORE_RPC_USERNAME=archipelago
- DATABASE_ENABLED=true
+3077 -91
View File
File diff suppressed because it is too large Load Diff
+19 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.92-alpha"
version = "1.7.99-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]
@@ -9,6 +9,16 @@ authors = ["Archipelago Team"]
name = "archipelago"
path = "src/main.rs"
[features]
default = []
# DHT Phase 2: iroh-blobs peer swarm engine. OFF by default — it pulls a heavy
# QUIC dependency tree, so it ships behind a flag for PoC/measurement on a
# scratch node before any fleet rollout. With the flag off, swarm::providers()
# is empty and every fetch goes straight to the origin HTTP path (today's
# behaviour). Attach the optional iroh / iroh-blobs deps to this feature when
# wiring the IrohProvider.
iroh-swarm = ["dep:iroh", "dep:iroh-blobs"]
[dependencies]
# Core dependencies
tokio = { version = "1", features = ["full"] }
@@ -42,6 +52,7 @@ archipelago-performance = { path = "../performance" }
# Authentication
bcrypt = "0.15"
sha2 = "0.10.9"
blake3 = "1"
hmac = "0.12.1"
uuid = { version = "1.0", features = ["v4"] }
regex = "1.10"
@@ -64,7 +75,7 @@ serde_yaml = "0.9"
# HTTP client (for LND REST proxy, Tor SOCKS for peer messaging)
# Uses rustls-tls for cross-compilation (no OpenSSL dependency)
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls"] }
reqwest = { version = "0.11", default-features = false, features = ["json", "socks", "rustls-tls", "stream"] }
# Nostr (node discovery + NIP-44 encrypted peer handshake)
nostr-sdk = { version = "0.44", features = ["nip04", "nip44"] }
@@ -106,6 +117,12 @@ sd-notify = "0.4"
# Trait objects for async methods (container orchestrator trait, Step 4)
async-trait = "0.1"
# DHT Phase 2: iroh-blobs peer swarm engine. OPTIONAL — only pulled in by the
# `iroh-swarm` feature (off by default). Heavy QUIC dep tree; kept behind the
# flag so the default fleet build is unaffected until the PoC is measured.
iroh = { version = "1", optional = true }
iroh-blobs = { version = "0.103", optional = true }
[dev-dependencies]
tokio-test = "0.4"
tempfile = "3.10"
+280 -1
View File
@@ -66,6 +66,21 @@ impl ApiHandler {
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
// Extract a paid-entitlement gate token from X-Invoice-Hash (Lightning)
// or X-Onchain-Address (on-chain) — both authorize the download if this
// node issued+settled them, and both resolve against the same shared
// entitlement store keyed by the token string (#46).
let invoice_hash = headers
.get("x-invoice-hash")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
.or_else(|| {
headers
.get("x-onchain-address")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
});
// Extract federation peer DID from X-Federation-DID header
let peer_did = headers
.get("x-federation-did")
@@ -82,6 +97,7 @@ impl ApiHandler {
&config.data_dir,
content_id,
payment_token.as_deref(),
invoice_hash.as_deref(),
peer_did.as_deref(),
range,
)
@@ -130,7 +146,9 @@ impl ApiHandler {
Ok(content_server::ServeResult::Forbidden) => Ok(build_response(
StatusCode::FORBIDDEN,
"application/json",
hyper::Body::from(r#"{"error":"Access denied — federation peer required"}"#),
hyper::Body::from(
r#"{"error":"This file is shared with the host's federation peers only. Federate with that node (exchange invites) so it recognizes you, then try again."}"#,
),
)),
Ok(content_server::ServeResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
@@ -140,6 +158,259 @@ impl ApiHandler {
}
}
/// Seller side (#46): mint a Lightning invoice for a paid catalog item so a
/// buyer can pay from any external wallet. Path: GET /content/{id}/invoice.
/// Records a pending entitlement keyed by the invoice's payment hash.
pub(super) async fn handle_content_invoice(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/invoice"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let item = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => i,
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
let price_sats = match &item.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
// Not a paid item — no invoice to issue.
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
));
}
};
let memo = format!("Archipelago peer file {content_id}");
match self
.rpc_handler
.create_invoice(price_sats as i64, &memo)
.await
{
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await;
let body = serde_json::json!({
"bolt11": bolt11,
"payment_hash": payment_hash,
"price_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
Ok(_) => Ok(build_response(
StatusCode::INTERNAL_SERVER_ERROR,
"application/json",
hyper::Body::from(r#"{"error":"Invoice missing payment hash"}"#),
)),
Err(e) => {
// Surface the FULL error chain ({:#}) — the generic top-level
// message hid the real cause (e.g. the LND REST connection
// failing), which made this 503 undiagnosable.
tracing::warn!("content invoice creation failed: {e:#}");
let body = serde_json::json!({
"error": format!("Could not create invoice: {e:#}")
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether a previously-issued invoice has settled.
/// Path: GET /content/{id}/invoice-status/{payment_hash}. On settlement the
/// entitlement is marked paid so the buyer can then download the file.
pub(super) async fn handle_content_invoice_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, payment_hash) = match rest.split_once("/invoice-status/") {
Some((id, hash)) => (id, hash),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || payment_hash.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The hash must be one we issued for exactly this content item.
match crate::content_invoice::lookup(payment_hash).await {
Some((cid, _)) if cid == content_id => {}
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Seller side (#46): issue a fresh on-chain address for a paid catalog item
/// so a buyer can pay on-chain. Path: GET /content/{id}/onchain. Records a
/// pending entitlement keyed by the address; price doubles as expected amount.
pub(super) async fn handle_content_onchain(&self, path: &str) -> Result<Response<hyper::Body>> {
let content_id = path
.strip_prefix("/content/")
.and_then(|s| s.strip_suffix("/onchain"))
.unwrap_or("");
if content_id.is_empty() || !is_valid_app_id(content_id) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid content ID"),
));
}
let catalog = content_server::load_catalog(&self.config.data_dir)
.await
.unwrap_or_default();
let price_sats = match catalog.items.iter().find(|i| i.id == content_id) {
Some(i) => match &i.access {
content_server::AccessControl::Paid { price_sats } => *price_sats,
_ => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"application/json",
hyper::Body::from(r#"{"error":"Item is not paid"}"#),
))
}
},
None => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
hyper::Body::from("Content not found"),
))
}
};
match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending(&address, content_id, price_sats).await;
let body = serde_json::json!({
"address": address,
"amount_sats": price_sats,
});
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
_ => {
let body = serde_json::json!({
"error": "Could not generate an on-chain address (is the wallet ready?)"
});
Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
}
}
/// Seller side (#46): report whether an on-chain payment to a previously-
/// issued address has arrived (>= price, >= 1 conf). Path:
/// GET /content/{id}/onchain-status/{address}. Marks the entitlement paid.
pub(super) async fn handle_content_onchain_status(
&self,
path: &str,
) -> Result<Response<hyper::Body>> {
let rest = path.strip_prefix("/content/").unwrap_or("");
let (content_id, address) = match rest.split_once("/onchain-status/") {
Some((id, addr)) => (id, addr),
None => {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
))
}
};
if content_id.is_empty() || !is_valid_app_id(content_id) || address.is_empty() {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid request"),
));
}
// The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(address).await {
Some((cid, price)) if cid == content_id => price,
_ => {
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown address"}"#),
))
}
};
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid });
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(serde_json::to_vec(&body).unwrap_or_default()),
))
}
/// Serve a degraded preview of paid content (blurred image or first 2% of video).
pub(super) async fn handle_content_preview(
path: &str,
@@ -190,6 +461,14 @@ impl ApiHandler {
.body(hyper::Body::from(bytes))
.unwrap())
}
Ok(content_server::PreviewResult::PreviewUnavailable) => Ok(Response::builder()
.status(StatusCode::UNSUPPORTED_MEDIA_TYPE)
.header("Content-Type", "text/plain")
.header("X-Content-Preview", "unavailable")
.body(hyper::Body::from(
"Preview unavailable for this media (needs re-encoding)",
))
.unwrap()),
Ok(content_server::PreviewResult::NotFound) | Err(_) => Ok(build_response(
StatusCode::NOT_FOUND,
"text/plain",
+117 -9
View File
@@ -44,6 +44,11 @@ pub struct ApiHandler {
session_store: SessionStore,
/// Broadcast channel for relaying companion app input to remote browsers.
input_relay_tx: broadcast::Sender<String>,
/// Reverse broadcast channel: the kiosk browser publishes "open this URL
/// externally" requests here, and the companion (phone) socket forwards them
/// to the phone's default browser. Lets "open in external browser" apps —
/// which the kiosk can't usefully open itself — launch on the controller.
external_open_tx: broadcast::Sender<String>,
/// Content-addressed blob store for attachments shared over mesh/federation.
blob_store: Arc<BlobStore>,
/// Our own node pubkey (hex) — used to self-sign debug/test capabilities.
@@ -71,6 +76,7 @@ impl ApiHandler {
.await?,
);
let (input_relay_tx, _) = broadcast::channel(64);
let (external_open_tx, _) = broadcast::channel(16);
// Derive a blob-store capability key from the node's Ed25519 signing
// key. SHA-256 domain-separated so rotating the identity rotates
@@ -100,6 +106,7 @@ impl ApiHandler {
metrics_store,
session_store,
input_relay_tx,
external_open_tx,
blob_store,
self_pubkey_hex,
})
@@ -202,6 +209,27 @@ impl ApiHandler {
.unwrap()
}
/// A 401 that still carries CORS headers, for endpoints fetched
/// cross-origin by same-node app UIs (e.g. the LND wallet UI on its own
/// port). Without the ACAO header the browser surfaces an opaque CORS
/// error instead of the 401, so the app can't tell it just needs auth.
/// `origin` is the already-validated reflect value from `app_cors_origin`
/// (empty string when the origin isn't allowed → no CORS header added).
fn unauthorized_cors(origin: &str) -> Response<hyper::Body> {
let body = serde_json::json!({ "error": "Unauthorized" });
let body_bytes = serde_json::to_vec(&body).unwrap_or_default();
let mut builder = Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("Content-Type", "application/json")
.header("Vary", "Origin");
if !origin.is_empty() {
builder = builder
.header("Access-Control-Allow-Origin", origin)
.header("Access-Control-Allow-Credentials", "true");
}
builder.body(hyper::Body::from(body_bytes)).unwrap()
}
/// Allowed CORS origins derived from the config host IP.
fn allowed_origins(&self) -> Vec<String> {
let mut origins = vec![
@@ -256,6 +284,45 @@ impl ApiHandler {
}
}
/// CORS origin to echo for same-node app → backend calls (e.g. the LND
/// wallet UI, served on its own APP_PORTS port). Such apps share the node's
/// host but use a different port, so the strict allowlist (`host_ip`, no
/// port) rejects them and the browser gets no `Access-Control-Allow-Origin`
/// header ("blocked by CORS policy"). Reflect the Origin when its host
/// matches the request's own `Host` header — i.e. the app lives on the same
/// address the node is being reached by, which transparently covers the LAN
/// IP, the Tailscale IP, localhost, and the `.onion` address without needing
/// to enumerate them. Auth is still enforced by the session cookie; this
/// only authorizes the browser to *read* the reply. Returns "" (no echoed
/// origin) when there is no match.
fn app_cors_origin(&self, headers: &hyper::HeaderMap) -> String {
if let Some(origin) = self.validate_origin(headers) {
return origin;
}
let Some(origin) = headers.get("origin").and_then(|v| v.to_str().ok()) else {
return String::new();
};
// host portion (no scheme, no port) of an `scheme://host[:port]` value
let host_of = |s: &str| -> Option<String> {
let after_scheme = s.split_once("://").map(|(_, r)| r).unwrap_or(s);
let host_port = after_scheme.split('/').next().unwrap_or(after_scheme);
let host = host_port
.rsplit_once(':')
.map(|(h, _)| h)
.unwrap_or(host_port);
(!host.is_empty()).then(|| host.to_string())
};
let origin_host = host_of(origin);
let req_host = headers
.get(hyper::header::HOST)
.and_then(|v| v.to_str().ok())
.and_then(host_of);
match (origin_host, req_host) {
(Some(o), Some(r)) if o == r => origin.to_string(),
_ => String::new(),
}
}
pub async fn handle_request(&self, req: Request<hyper::Body>) -> Result<Response<hyper::Body>> {
let path = req.uri().path().to_string();
let method = req.method().clone();
@@ -265,9 +332,10 @@ impl ApiHandler {
let mut builder = Response::builder()
.status(StatusCode::NO_CONTENT)
.header("Vary", "Origin");
if let Some(origin) = self.validate_origin(req.headers()) {
let preflight_origin = self.app_cors_origin(req.headers());
if !preflight_origin.is_empty() {
builder = builder
.header("Access-Control-Allow-Origin", &origin)
.header("Access-Control-Allow-Origin", &preflight_origin)
.header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
.header("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
.header("Access-Control-Allow-Credentials", "true");
@@ -295,7 +363,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-input — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_input(req, self.input_relay_tx.clone()).await;
return Self::handle_remote_input(
req,
self.input_relay_tx.clone(),
self.external_open_tx.subscribe(),
)
.await;
}
// Remote relay WebSocket — browser receives companion input events
@@ -304,7 +377,12 @@ impl ApiHandler {
tracing::warn!("401 WebSocket /ws/remote-relay — session invalid or missing");
return Ok(Self::unauthorized());
}
return Self::handle_remote_relay(req, self.input_relay_tx.subscribe()).await;
return Self::handle_remote_relay(
req,
self.input_relay_tx.subscribe(),
self.external_open_tx.clone(),
)
.await;
}
// Convert body to bytes for non-WS routes
@@ -419,6 +497,22 @@ impl ApiHandler {
Self::handle_content_preview(p, &self.config).await
}
// Lightning-invoice peer-file sale (#46): mint invoice / poll settlement
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/invoice") => {
self.handle_content_invoice(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.contains("/invoice-status/") => {
self.handle_content_invoice_status(p).await
}
// On-chain peer-file sale (#46): issue address / poll for payment
(Method::GET, p) if p.starts_with("/content/") && p.contains("/onchain-status/") => {
self.handle_content_onchain_status(p).await
}
(Method::GET, p) if p.starts_with("/content/") && p.ends_with("/onchain") => {
self.handle_content_onchain(p).await
}
// Content serving — peers access shared content over Tor (no session auth)
(Method::GET, p) if p.starts_with("/content/") => {
Self::handle_content_request(p, &headers, &self.config).await
@@ -448,7 +542,8 @@ impl ApiHandler {
// No backend auth check here because the LND UI iframe fetches this
// endpoint and the session cookie flow is validated at the nginx layer.
(Method::GET, "/lnd-connect-info") => {
Self::handle_lnd_connect_info(self.rpc_handler.clone()).await
let origin = self.app_cors_origin(&headers);
Self::handle_lnd_connect_info(self.rpc_handler.clone(), &origin).await
}
// Container logs — requires session
@@ -460,13 +555,26 @@ impl ApiHandler {
Self::handle_container_logs_http(self.rpc_handler.clone(), path, &origin).await
}
// LND proxy — requires session
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
// Peer content streaming proxy — Range-streams a peer's media file
// so <video>/<audio> can seek/play (B3). Same-origin, session-gated.
(Method::GET, p) if p.starts_with("/api/peer-content/") => {
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let origin = self.validate_origin(&headers).unwrap_or_default();
Self::handle_lnd_proxy(path, &origin).await
self.handle_peer_content_stream(p, &headers).await
}
// LND proxy — requires session. The LND wallet UI calls this
// cross-origin from its own app port, so even the 401 must carry
// CORS headers; otherwise the browser reports a bare CORS failure
// ("No 'Access-Control-Allow-Origin' header") instead of a
// readable 401 the UI can act on.
(Method::GET, path) if path.starts_with("/proxy/lnd/") => {
let origin = self.app_cors_origin(&headers);
if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized_cors(&origin));
}
Self::handle_lnd_proxy(self.rpc_handler.clone(), path, &origin).await
}
// DWN health — unauthenticated
@@ -19,6 +19,8 @@ impl ApiHandler {
signature: Option<String>,
#[serde(default)]
encrypted: bool,
#[serde(default)]
msg_id: Option<String>,
}
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
from_pubkey: None,
@@ -26,6 +28,7 @@ impl ApiHandler {
message: None,
signature: None,
encrypted: false,
msg_id: None,
});
if let (Some(from), Some(msg)) = (incoming.from_pubkey.as_ref(), incoming.message.as_ref())
{
@@ -152,7 +155,13 @@ impl ApiHandler {
let clean_from = sanitize_html(from);
let clean_msg = sanitize_html(&plaintext);
let clean_name = incoming.from_name.as_deref().map(sanitize_html);
node_msg::store_received(&clean_from, &clean_msg, clean_name.as_deref()).await;
node_msg::store_received(
&clean_from,
&clean_msg,
clean_name.as_deref(),
incoming.msg_id.as_deref(),
)
.await;
}
Ok(build_response(
StatusCode::OK,
+121 -13
View File
@@ -99,33 +99,61 @@ impl ApiHandler {
pub(super) async fn handle_lnd_connect_info(
rpc: std::sync::Arc<super::super::rpc::RpcHandler>,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
// The LND wallet UI is served on its own APP_PORTS origin and fetches
// this cross-origin, so it needs the CORS headers echoed back.
let cors = |builder: hyper::http::response::Builder| {
builder
.header("Access-Control-Allow-Origin", cors_origin)
.header("Access-Control-Allow-Credentials", "true")
.header("Vary", "Origin")
};
match rpc.handle_lnd_connect_info().await {
Ok(val) => {
let body = serde_json::to_vec(&val).unwrap_or_default();
Ok(build_response(
StatusCode::OK,
"application/json",
hyper::Body::from(body),
))
Ok(cors(
Response::builder()
.status(StatusCode::OK)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(body))
.unwrap_or_else(|_| Response::new(hyper::Body::from("{}"))))
}
Err(e) => Ok(Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json")
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
Err(e) => Ok(cors(
Response::builder()
.status(StatusCode::INTERNAL_SERVER_ERROR)
.header("Content-Type", "application/json"),
)
.body(hyper::Body::from(
serde_json::json!({"error": e.to_string()}).to_string(),
))
.unwrap()),
}
}
pub(super) async fn handle_lnd_proxy(
rpc: Arc<RpcHandler>,
path: &str,
cors_origin: &str,
) -> Result<Response<hyper::Body>> {
let suffix = path.strip_prefix("/proxy/lnd").unwrap_or("/");
let url = format!("{LND_REST_BASE_URL}{suffix}");
match reqwest::get(&url).await {
// LND REST serves a self-signed cert and requires the admin macaroon.
// A bare reqwest::get() uses the default client, which rejects the
// self-signed cert (TLS verify error -> 502 "failing to fetch") and
// sends no macaroon. Use the shared authenticated client instead — the
// same one lnd.getinfo and the wallet RPCs use.
let request = match rpc.lnd_client().await {
Ok((client, macaroon_hex)) => client
.get(&url)
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.map_err(anyhow::Error::from),
Err(e) => Err(e),
};
match request {
Ok(resp) => {
let status = resp.status().as_u16();
let headers = resp.headers().clone();
@@ -157,4 +185,84 @@ 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);
// Generous overall timeout: this endpoint serves both seek/Range
// playback (small, finishes fast) and full-file downloads of large
// media (#38). 60s was too tight for a multi-hundred-MB transfer over
// Tor and aborted the download mid-stream.
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(900));
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 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);
}
}
// Stream the peer's body straight through instead of buffering
// the whole file into memory (#38). For a 178MB download the old
// `resp.bytes().await` allocated the entire file on the node
// before sending a byte; `wrap_stream` forwards chunks as they
// arrive, with constant memory.
Ok(builder
.body(hyper::Body::wrap_stream(resp.bytes_stream()))
.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()),
)),
}
}
}
@@ -211,6 +211,7 @@ impl ApiHandler {
pub(super) async fn handle_remote_input(
req: Request<hyper::Body>,
relay_tx: broadcast::Sender<String>,
mut external_open_rx: broadcast::Receiver<String>,
) -> Result<Response<hyper::Body>> {
// Extract optional player ID from query string: /ws/remote-input?p=1
let player_id: Option<u8> = req
@@ -266,6 +267,19 @@ impl ApiHandler {
break;
}
}
// Forward kiosk "open this URL externally" requests down to
// the companion so the link opens in the phone's browser.
ext = external_open_rx.recv() => {
match ext {
Ok(text) => {
if tx.send(Message::Text(text)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => {}
}
}
msg = rx.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
@@ -11,9 +11,16 @@ use super::ApiHandler;
impl ApiHandler {
/// WebSocket endpoint for browser clients to receive relayed companion input.
/// The browser's remote-relay.ts dispatches these as DOM keyboard/mouse events.
///
/// The kiosk also uses this socket in the *reverse* direction: when an "open
/// in external browser" app is launched, the kiosk can't usefully open it
/// itself, so it sends `{"t":"o","url":"https://…"}` here. We validate the
/// URL and publish it on `external_open_tx`, which the companion (phone)
/// socket forwards so the link opens in the phone's default browser.
pub(super) async fn handle_remote_relay(
req: Request<hyper::Body>,
mut relay_rx: broadcast::Receiver<String>,
external_open_tx: broadcast::Sender<String>,
) -> Result<Response<hyper::Body>> {
let (response, ws_fut_opt) = hyper_ws_listener::create_ws(req)
.map_err(|e| anyhow::anyhow!("WebSocket upgrade failed: {}", e))?;
@@ -63,10 +70,20 @@ impl ApiHandler {
Err(broadcast::error::RecvError::Closed) => break,
}
}
// Handle client-side messages (pong, close)
// Handle client-side messages (pong, close, open-url requests)
client_msg = rx.next() => {
match client_msg {
Some(Ok(Message::Pong(_))) | Some(Ok(Message::Ping(_))) => {}
Some(Ok(Message::Text(text))) => {
// The only kiosk→server message we accept is an
// external-open request: {"t":"o","url":"https://…"}.
if let Some(url) = parse_open_url(&text) {
debug!("Relaying external-open to companion: {}", url);
let _ = external_open_tx.send(
format!(r#"{{"t":"o","url":{}}}"#, json_string(&url))
);
}
}
Some(Ok(Message::Close(_))) | None => break,
_ => {}
}
@@ -81,3 +98,29 @@ impl ApiHandler {
Ok(response)
}
}
/// Parse a kiosk `{"t":"o","url":"…"}` external-open request, returning the URL
/// only if it's a well-formed http(s) URL. Anything else (other message tags,
/// non-http schemes like `javascript:`/`file:`, malformed JSON) is rejected so a
/// compromised kiosk page can't push arbitrary URIs to the phone.
fn parse_open_url(text: &str) -> Option<String> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
if v.get("t").and_then(|t| t.as_str()) != Some("o") {
return None;
}
let url = v.get("url").and_then(|u| u.as_str())?.trim();
if url.len() > 2048 {
return None;
}
let lower = url.to_ascii_lowercase();
if lower.starts_with("http://") || lower.starts_with("https://") {
Some(url.to_string())
} else {
None
}
}
/// Serialize a string as a JSON string literal (with surrounding quotes).
fn json_string(s: &str) -> String {
serde_json::Value::String(s.to_string()).to_string()
}
+13
View File
@@ -33,6 +33,19 @@ impl RpcHandler {
tracing::info!("[onboarding] login successful");
// Best-effort: heal a LOCKED LND wallet created with an unknown/legacy
// password by rotating it onto the per-node secret, using the password
// the user just authenticated with as a candidate. Non-blocking so login
// is never slowed or broken when LND isn't installed / already unlocked.
let candidate = password.to_string();
tokio::spawn(async move {
match crate::container::lnd::migrate_locked_wallet(&[candidate]).await {
Ok(true) => tracing::info!("[login] LND wallet healed / auto-unlocked"),
Ok(false) => {} // not locked, or seed-recovery required
Err(e) => tracing::debug!("[login] LND wallet migration skipped: {e}"),
}
});
// Ensure NostrVPN config exists — covers the case where onboardingComplete
// was never called (e.g., user took the "already set up" shortcut).
let data_dir = self.config.data_dir.clone();
+476 -17
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();
@@ -251,6 +260,20 @@ impl RpcHandler {
}));
}
// A 403 carries an actionable reason in its JSON body (e.g. "shared with
// the host's federation peers only — federate first"). Surface that to
// the user instead of a bare "Peer returned: 403 Forbidden".
if response.status() == reqwest::StatusCode::FORBIDDEN {
let status = response.status();
let body: serde_json::Value = response.json().await.unwrap_or_default();
let msg = body
.get("error")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("Peer returned: {status}"));
return Err(anyhow::anyhow!(msg));
}
if !response.status().is_success() {
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
}
@@ -294,13 +317,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 +340,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,25 +393,49 @@ 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) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(120))
.send_get()
.await
.context("Failed to connect to peer")?;
// Surface a real reason instead of the generic sanitized error (#30):
// the dial already tries FIPS/mesh then falls back to Tor, so a failure
// here means the peer is genuinely unreachable on both transports.
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Payment-Token", token_str)
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("paid peer download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
// 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
return Err(anyhow::anyhow!(
"Payment rejected by peer — token may have been insufficient or invalid"
));
return Ok(serde_json::json!({
"error": "Payment rejected by peer — the token may have been insufficient or invalid."
}));
}
if !response.status().is_success() {
return Err(anyhow::anyhow!("Peer returned: {}", response.status()));
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
@@ -389,6 +453,393 @@ impl RpcHandler {
}))
}
/// Buyer side (#46): ask the selling node to mint a Lightning invoice for a
/// paid item so the buyer can pay from any external wallet. Returns the
/// bolt11 invoice + payment hash to render as a QR and poll for settlement.
pub(super) async fn handle_content_request_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Minting a bolt11 is a tiny request/response — keep it snappy. Cap the
// FIPS attempt hard so a cold overlay can't burn the whole budget, and
// give Tor a short-but-real window (onion circuits need a few seconds).
let path = format!("/content/{}/invoice", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-invoice dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not create an invoice ({}).", response.status())
}));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse invoice response")?;
Ok(body)
}
/// Buyer side (#46): poll the selling node for invoice settlement.
pub(super) async fn handle_content_invoice_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let payment_hash = params
.get("payment_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Payment hash is hex from the seller; keep it strictly hex so it's safe
// to interpolate into the request path.
if payment_hash.is_empty()
|| payment_hash.len() > 128
|| !payment_hash.chars().all(|c| c.is_ascii_hexdigit())
{
return Err(anyhow::anyhow!("Invalid payment_hash"));
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Settlement poll — runs repeatedly, so each call must be quick. Fast-fail
// FIPS and keep a short Tor window; an unreachable peer just reads as
// "not yet paid" and the UI polls again.
let path = format!("/content/{}/invoice-status/{}", content_id, payment_hash);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
{
Ok(v) => v,
Err(_) => {
// Treat an unreachable peer as "not yet paid" so the UI keeps polling.
return Ok(serde_json::json!({ "paid": false, "unreachable": true }));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false }));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse invoice-status response")?;
Ok(body)
}
/// Buyer side (#46): download a paid item after the invoice settled, passing
/// the payment hash so the seller's content gate releases the file.
pub(super) async fn handle_content_download_peer_invoice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let payment_hash = params
.get("payment_hash")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing payment_hash"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid payment_hash"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Invoice-Hash", payment_hash.to_string())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("invoice download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({
"error": "Seller has not registered this payment yet — wait for settlement and retry."
}));
}
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
}))
}
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
pub(super) async fn handle_content_request_onchain(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
// Issuing an address is a tiny request/response — fast-fail FIPS, short
// Tor window (same budget shape as the invoice path, #6).
let path = format!("/content/{}/onchain", content_id);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.timeout(std::time::Duration::from_secs(25))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("request-onchain dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline."
}));
}
};
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Seller could not provide an address ({}).", response.status())
}));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse onchain response")?;
Ok(body)
}
/// Buyer side (#46): poll the selling node for on-chain payment detection.
pub(super) async fn handle_content_onchain_status(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let address = params
.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
// Bitcoin addresses are alphanumeric; keep strictly so for safe path use.
if address.is_empty()
|| address.len() > 100
|| !address.chars().all(|c| c.is_ascii_alphanumeric())
{
return Err(anyhow::anyhow!("Invalid address"));
}
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}/onchain-status/{}", content_id, address);
let (response, _transport) =
match crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, &path)
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(15))
.fips_timeout(std::time::Duration::from_secs(6))
.send_get()
.await
{
Ok(v) => v,
Err(_) => return Ok(serde_json::json!({ "paid": false, "unreachable": true })),
};
if !response.status().is_success() {
return Ok(serde_json::json!({ "paid": false }));
}
let body: serde_json::Value = response
.json()
.await
.context("Failed to parse onchain-status response")?;
Ok(body)
}
/// Buyer side (#46): download a paid item after the on-chain payment was
/// detected, passing the address so the seller's content gate releases it.
pub(super) async fn handle_content_download_peer_onchain(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let onion = params
.get("onion")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing onion address"))?;
let content_id = params
.get("content_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing content_id"))?;
let address = params
.get("address")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing address"))?;
if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address"));
}
if address.is_empty() || !address.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err(anyhow::anyhow!("Invalid address"));
}
let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
let path = format!("/content/{}", content_id);
let (response, transport) = match crate::fips::dial::PeerRequest::new(
fips_npub.as_deref(),
onion,
&path,
)
.service(crate::settings::transport::PeerService::PeerFiles)
.header("X-Federation-DID", local_did)
.header("X-Onchain-Address", address.to_string())
.timeout(std::time::Duration::from_secs(900))
.send_get()
.await
{
Ok(v) => v,
Err(e) => {
tracing::warn!("onchain download dial failed for {}: {:#}", onion, e);
return Ok(serde_json::json!({
"error": "Could not reach the peer over mesh or Tor — it may be offline. Please try again."
}));
}
};
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({
"error": "Seller has not registered this payment yet — wait for confirmation and retry."
}));
}
if !response.status().is_success() {
return Ok(serde_json::json!({
"error": format!("Peer returned an error ({}).", response.status())
}));
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
use base64::Engine;
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(serde_json::json!({
"data": encoded,
"size": bytes.len(),
}))
}
/// Fetch a preview of paid content from a peer (no payment required).
pub(super) async fn handle_content_preview_peer(
&self,
@@ -418,13 +869,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!(
@@ -33,6 +33,7 @@ impl RpcHandler {
"seed.restore" => self.handle_seed_restore(params).await,
"seed.save-encrypted" => self.handle_seed_save_encrypted(params).await,
"seed.status" => self.handle_seed_status().await,
"seed.reveal" => self.handle_seed_reveal(params).await,
// Container orchestration (for Archipelago-managed containers)
"container-install" => self.handle_container_install(params).await,
@@ -55,6 +56,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,
@@ -236,6 +238,11 @@ impl RpcHandler {
"wallet.ecash-receive" => self.handle_wallet_ecash_receive(params).await,
"wallet.ecash-history" => self.handle_wallet_ecash_history().await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
"wallet.fedimint-join" => self.handle_wallet_fedimint_join(params).await,
"wallet.fedimint-leave" => self.handle_wallet_fedimint_leave(params).await,
"wallet.fedimint-balance" => self.handle_wallet_fedimint_balance().await,
// Container registries
"registry.list" => self.handle_registry_list().await,
@@ -249,6 +256,7 @@ impl RpcHandler {
"streaming.configure-service" => self.handle_streaming_configure_service(params).await,
"streaming.toggle-service" => self.handle_streaming_toggle_service(params).await,
"streaming.pay" => self.handle_streaming_pay(params).await,
"streaming.prepare-payment" => self.handle_streaming_prepare_payment(params).await,
"streaming.discover" => self.handle_streaming_discover().await,
"streaming.usage" => self.handle_streaming_usage(params).await,
"streaming.session" => self.handle_streaming_session(params).await,
@@ -268,6 +276,16 @@ impl RpcHandler {
"content.browse-peer" => self.handle_content_browse_peer(params).await,
"content.download-peer" => self.handle_content_download_peer(params).await,
"content.download-peer-paid" => self.handle_content_download_peer_paid(params).await,
"content.request-invoice" => self.handle_content_request_invoice(params).await,
"content.invoice-status" => self.handle_content_invoice_status(params).await,
"content.download-peer-invoice" => {
self.handle_content_download_peer_invoice(params).await
}
"content.request-onchain" => self.handle_content_request_onchain(params).await,
"content.onchain-status" => self.handle_content_onchain_status(params).await,
"content.download-peer-onchain" => {
self.handle_content_download_peer_onchain(params).await
}
"content.preview-peer" => self.handle_content_preview_peer(params).await,
// DWN (Decentralized Web Node)
@@ -379,6 +397,11 @@ impl RpcHandler {
"mesh.deadman-status" => self.handle_mesh_deadman_status().await,
"mesh.deadman-configure" => self.handle_mesh_deadman_configure(params).await,
"mesh.deadman-checkin" => self.handle_mesh_deadman_checkin().await,
"mesh.assistant-status" => self.handle_mesh_assistant_status().await,
"mesh.assistant-configure" => self.handle_mesh_assistant_configure(params).await,
"mesh.schedule-message" => self.handle_mesh_schedule_message(params).await,
"mesh.list-scheduled" => self.handle_mesh_list_scheduled().await,
"mesh.cancel-scheduled" => self.handle_mesh_cancel_scheduled(params).await,
"mesh.test-send" => self.handle_mesh_test_send(params).await,
// Transport layer (unified routing)
@@ -470,6 +493,11 @@ impl RpcHandler {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_test_mirror(&p).await
}
"update.get-source" => self.handle_update_get_source().await,
"update.set-source" => {
let p = params.unwrap_or(serde_json::json!({}));
self.handle_update_set_source(&p).await
}
"update.apply" => self.handle_update_apply().await,
"update.git-apply" => self.handle_update_git_apply().await,
"update.rollback" => self.handle_update_rollback().await,
@@ -30,6 +30,25 @@ impl RpcHandler {
mesh::upsert_federation_peer(&svc.shared_state(), pubkey_hex, did, name).await;
}
}
/// Re-seed every federation node from disk into the mesh peer table so the
/// chat list reflects what the latest federation sync learned — display
/// names (landed in `nodes.json` by `update_node_state` when a peer
/// announces its name) and transitively-discovered peers (merged by
/// `merge_transitive_peers`) — WITHOUT waiting for a mesh restart.
///
/// Without this, a peer accepted via invite (seeded with `name = None`)
/// stays "Archipelago <pubkey8>" in chat until the next restart even after
/// sync has learned its real name, and transitive peers never appear as
/// chat contacts at all. `seed_federation_peers_into_mesh` is idempotent
/// and dedups by onion, so calling it after each sync is safe.
/// Best-effort: silently no-ops when mesh is off.
pub(crate) async fn refresh_federation_mesh_peers(&self) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
mesh::seed_federation_peers_into_mesh(&svc.shared_state(), &self.config.data_dir).await;
}
}
}
impl RpcHandler {
@@ -243,9 +262,31 @@ impl RpcHandler {
.ok_or_else(|| anyhow::anyhow!("Missing 'did' parameter"))?;
validate_did(did)?;
// Capture the node's pubkey before removal so we can also purge its
// synthetic mesh contact/thread (#2) — remove_node only touches
// nodes.json, which would otherwise leave a stale chat contact behind.
let removed_pubkey = federation::load_nodes(&self.config.data_dir)
.await
.ok()
.and_then(|nodes| nodes.into_iter().find(|n| n.did == did).map(|n| n.pubkey));
let nodes = federation::remove_node(&self.config.data_dir, did).await?;
info!(did = %did, "Removed node from federation");
if let Some(pubkey) = removed_pubkey.filter(|p| !p.is_empty()) {
let svc = self.mesh_service.read().await;
if let Some(svc) = svc.as_ref() {
let contact_id = mesh::federation_peer_contact_id(&pubkey);
mesh::purge_federation_peer(
&svc.shared_state(),
contact_id,
&pubkey,
&self.config.data_dir,
)
.await;
}
}
Ok(serde_json::json!({
"removed": true,
"nodes_remaining": nodes.len(),
@@ -341,6 +382,10 @@ impl RpcHandler {
}
}
// Push any names/roster the sync just learned into the live mesh peer
// table so the chat list updates without a restart (#42).
self.refresh_federation_mesh_peers().await;
Ok(serde_json::json!({
"synced": synced,
"failed": failed,
@@ -533,6 +578,19 @@ impl RpcHandler {
return Ok(serde_json::json!({ "accepted": true, "already_known": true }));
}
// Respect operator removal: a peer the operator deleted must not
// silently re-join via a stale invite. The tombstone is only cleared
// by an explicit local action (manually adding the node or accepting
// an incoming invite) — not by a remote-triggered join.
if federation::load_removed_dids(&self.config.data_dir)
.await
.unwrap_or_default()
.contains(did)
{
info!(peer_did = %did, "Ignoring peer-joined for a removed (tombstoned) DID");
return Ok(serde_json::json!({ "accepted": false, "removed": true }));
}
let node = FederatedNode {
did: did.to_string(),
pubkey: pubkey.to_string(),
+131
View File
@@ -0,0 +1,131 @@
//! Fedimint ecash RPCs — bridge to the `fedimint-clientd` sidecar.
//!
//! Companion to the Cashu wallet RPCs in [`super::wallet`]. Joining/holding
//! Fedimint ecash is delegated to the clientd container via
//! [`crate::wallet::fedimint_client::FedimintClient`]; here we expose the
//! node's JSON-RPC surface and keep a local registry of joined federations so
//! the list survives clientd being temporarily unreachable.
//!
//! See `docs/dual-ecash-design.md`.
use super::RpcHandler;
use crate::wallet::fedimint_client::{self, FedimintClient, JoinedFederation};
use anyhow::Result;
impl RpcHandler {
/// `wallet.fedimint-list` — joined federations with live balances.
pub(super) async fn handle_wallet_fedimint_list(&self) -> Result<serde_json::Value> {
// Best-effort: make sure the default federation is joined/tracked.
let _ = fedimint_client::ensure_default_federation(&self.config.data_dir).await;
let reg = fedimint_client::load_registry(&self.config.data_dir).await?;
// Live balances are best-effort: if clientd is down we still return the
// tracked federations (with 0 balance) rather than failing the call.
let info = match FedimintClient::from_node(&self.config.data_dir).await {
Ok(client) => client.info().await.ok(),
Err(_) => None,
};
let federations: Vec<serde_json::Value> = reg
.federations
.iter()
.map(|f| {
let balance_sats = info
.as_ref()
.and_then(|i| i.get(&f.federation_id))
.and_then(|e| {
e.get("totalAmountMsat")
.or_else(|| e.get("totalMsat"))
.and_then(|v| v.as_u64())
})
.map(|msat| msat / 1000)
.unwrap_or(0);
serde_json::json!({
"federation_id": f.federation_id,
"name": f.name,
"balance_sats": balance_sats,
})
})
.collect();
Ok(serde_json::json!({ "federations": federations }))
}
/// `wallet.fedimint-join` — join a federation by invite code.
pub(super) async fn handle_wallet_fedimint_join(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let invite_code = params
.get("invite_code")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing invite_code"))?;
let client = FedimintClient::from_node(&self.config.data_dir).await?;
let federation_id = client.join(invite_code).await?;
// Try to label it from the federation meta (best-effort).
let name = client.info().await.ok().and_then(|i| {
i.get(&federation_id)
.and_then(|e| e.get("meta"))
.and_then(|m| {
m.get("federation_name")
.or_else(|| m.get("federation_expiry_timestamp"))
})
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
if !reg
.federations
.iter()
.any(|f| f.federation_id == federation_id)
{
reg.federations.push(JoinedFederation {
federation_id: federation_id.clone(),
name,
});
fedimint_client::save_registry(&self.config.data_dir, &reg).await?;
}
Ok(serde_json::json!({ "federation_id": federation_id }))
}
/// `wallet.fedimint-leave` — stop tracking a federation locally.
pub(super) async fn handle_wallet_fedimint_leave(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let federation_id = params
.get("federation_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing federation_id"))?;
let mut reg = fedimint_client::load_registry(&self.config.data_dir).await?;
let before = reg.federations.len();
reg.federations.retain(|f| f.federation_id != federation_id);
let removed = reg.federations.len() != before;
if removed {
fedimint_client::save_registry(&self.config.data_dir, &reg).await?;
}
Ok(serde_json::json!({ "removed": removed }))
}
/// `wallet.fedimint-balance` — total sats across all joined federations.
pub(super) async fn handle_wallet_fedimint_balance(&self) -> Result<serde_json::Value> {
// Soft-fail to zero when clientd isn't installed/running, so the unified
// wallet balance still renders from the Cashu side.
let balance_sats = match FedimintClient::from_node(&self.config.data_dir).await {
Ok(client) => client.total_balance_sats().await.unwrap_or(0),
Err(_) => 0,
};
Ok(serde_json::json!({ "balance_sats": balance_sats }))
}
}
+8 -6
View File
@@ -115,10 +115,12 @@ impl RpcHandler {
} else if !after.key_present {
"no_seed_key"
} else if after.authenticated_peer_count == 0 {
// Daemon is up with a key but hasn't authenticated any
// peers — almost always outbound UDP/8668 dropped by the
// local firewall/router, or the anchor itself being down.
"no_outbound_udp_or_anchor_down"
// Daemon is up with a key but hasn't authenticated any peers —
// almost always the outbound connection to the anchor being
// dropped by the local firewall/router, or the anchor itself
// being down. The public anchor is reached over TCP/8443 (not
// UDP/8668 — that endpoint is dead).
"no_outbound_or_anchor_down"
} else {
"peers_but_no_anchor"
};
@@ -126,8 +128,8 @@ impl RpcHandler {
"connected" => "An anchor is reachable.",
"daemon_down" => "The FIPS daemon didn't come back up — check the FIPS service on this host.",
"no_seed_key" => "No seed-derived FIPS key on disk. Re-run the onboarding unlock step.",
"no_outbound_udp_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router / ISP might be blocking outbound UDP 8668, or every configured anchor could be down. Add a reachable peer in Seed Anchors.",
"no_outbound_or_anchor_down" =>
"Daemon is running but no peers handshook. Your router or ISP may be blocking the outbound connection to the mesh anchor (TCP port 8443), or every configured anchor is down. The public anchor is added automatically — if it still won't connect, add another reachable peer in Seed Anchors.",
"peers_but_no_anchor" =>
"Mesh has peers but none of them are anchors we recognise. Add your cluster's anchor in Seed Anchors.",
_ => "",
@@ -14,10 +14,39 @@ impl RpcHandler {
let manager = IdentityManager::new(&self.config.data_dir).await?;
let (identities, default_id) = manager.list().await?;
// #49: The canonical node Nostr key is the node-level HKDF key
// (`derive_node_nostr_key`) that Settings and Nostr discovery both use
// via `node.nostr-pubkey`. The mirrored "Node" identity stores
// nostr=None, and seed identities use a different BIP-32 NIP-06 key, so
// the "Node" entry in Web5 > Identities disagreed with Settings. Resolve
// the node-level key once and override it onto whichever identity record
// is the node's own (its ed25519 matches `server_info.pubkey`), so both
// surfaces always show the same npub. Display-only — no key is rewritten.
let identity_dir = self.config.data_dir.join("identity");
let node_nostr_hex = crate::nostr_discovery::get_nostr_pubkey(&identity_dir)
.await
.ok();
let node_nostr_npub = node_nostr_hex.as_ref().and_then(|h| {
nostr_sdk::PublicKey::from_hex(h)
.ok()
.and_then(|pk| pk.to_bech32().ok())
});
let (snapshot, _) = self.state_manager.get_snapshot().await;
let node_pubkey_hex = snapshot.server_info.pubkey.clone();
let items: Vec<serde_json::Value> = identities
.into_iter()
.map(|id| {
let is_default = default_id.as_deref() == Some(&id.id);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
let (nostr_pubkey, nostr_npub) = if is_node {
(
node_nostr_hex.clone().or(id.nostr_pubkey),
node_nostr_npub.clone().or(id.nostr_npub),
)
} else {
(id.nostr_pubkey, id.nostr_npub)
};
serde_json::json!({
"id": id.id,
"name": id.name,
@@ -26,8 +55,8 @@ impl RpcHandler {
"did": id.did,
"created_at": id.created_at,
"is_default": is_default,
"nostr_pubkey": id.nostr_pubkey,
"nostr_npub": id.nostr_npub,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"profile": id.profile,
})
})
+264 -3
View File
@@ -151,6 +151,250 @@ impl RpcHandler {
}
/// Create a Lightning invoice.
/// Create a Lightning invoice and return `(bolt11, payment_hash_hex)`.
///
/// Shared helper used by both the `lnd.createinvoice` RPC and the seller-side
/// peer-file invoice flow (#46). LND returns `r_hash` as base64; we re-encode
/// it as hex so it can be used as a stable lookup key and passed in URLs.
/// Whether LND reports it's synced to its Bitcoin chain backend. Used to
/// fail invoice minting FAST with a clear reason while the node's Bitcoin
/// backend is still in initial block download — otherwise the `/v1/invoices`
/// POST hangs for the full client timeout (×3 retries ≈ 45s) and surfaces as
/// an opaque failure. `getinfo` answers in ~2s even mid-IBD. Returns
/// `Some(false)` only when LND is reachable AND explicitly not synced;
/// `None` when we couldn't tell (let the mint attempt proceed and report its
/// own error rather than guess "syncing").
pub(crate) async fn lnd_chain_synced(&self) -> Option<bool> {
let (client, macaroon_hex) = self.lnd_client().await.ok()?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/getinfo"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.ok()?;
let body: serde_json::Value = resp.json().await.ok()?;
body.get("synced_to_chain").and_then(|v| v.as_bool())
}
/// Error returned when the node can't mint a Lightning invoice because its
/// Bitcoin backend is still syncing. Kept as one string so every invoice
/// entry point surfaces the same clear, user-facing reason.
fn syncing_invoice_err() -> anyhow::Error {
anyhow::anyhow!(
"Your Bitcoin node is still syncing — Lightning invoices are unavailable until it finishes. Try again once the node is fully synced."
)
}
pub(crate) async fn create_invoice(
&self,
amount_sats: i64,
memo: &str,
) -> Result<(String, String)> {
if amount_sats < 0 {
return Err(anyhow::anyhow!("Amount must be non-negative"));
}
if memo.len() > 639 {
return Err(anyhow::anyhow!("Memo too long (max 639 bytes)"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let invoice_body = serde_json::json!({
"value": amount_sats.to_string(),
"memo": memo,
});
// LND's REST endpoint can briefly drop/reset connections under load
// (swap pressure, just-restarted, TLS handshake races), which used to
// hard-fail the buy-file invoice with an opaque 503. Retry on a
// CONNECTION error with short backoff so a transient blip doesn't
// surface as a payment failure. A *timeout* is NOT retried: it means LND
// accepted the connection but isn't answering the mint (e.g. a degraded
// node), and retrying just multiplies the wait (3×15s ≈ 45s) — fail
// after the first hang and let the caller surface the real reason.
let mut last_err: Option<anyhow::Error> = None;
let mut resp = None;
for attempt in 0..3u32 {
match client
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body)
.send()
.await
{
Ok(r) => {
resp = Some(r);
break;
}
Err(e) => {
let timed_out = e.is_timeout();
last_err = Some(anyhow::anyhow!(
"LND REST send failed (attempt {}): {e}",
attempt + 1
));
if timed_out {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
}
}
}
let resp = match resp {
Some(r) => r,
None => {
// If LND is reachable but explicitly not synced to chain, say so —
// it's the most common reason a just-restored/syncing node can't
// mint. Otherwise surface the underlying transport error.
if self.lnd_chain_synced().await == Some(false) {
return Err(Self::syncing_invoice_err());
}
return Err(last_err.unwrap_or_else(|| {
anyhow::anyhow!("Failed to reach LND REST to create invoice")
}));
}
};
let status = resp.status();
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice response")?;
if !status.is_success() {
let msg = body
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("Unknown error");
return Err(anyhow::anyhow!("Failed to create invoice: {}", msg));
}
let payment_request = body
.get("payment_request")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
// r_hash is base64 in LND's REST response — convert to hex.
use base64::Engine;
let payment_hash_hex = body
.get("r_hash")
.and_then(|v| v.as_str())
.and_then(|b64| base64::engine::general_purpose::STANDARD.decode(b64).ok())
.map(hex::encode)
.unwrap_or_default();
Ok((payment_request, payment_hash_hex))
}
/// Look up an invoice by hex payment hash; true if it has settled.
pub(crate) async fn invoice_is_settled(&self, payment_hash_hex: &str) -> Result<bool> {
if payment_hash_hex.is_empty() || hex::decode(payment_hash_hex).is_err() {
return Err(anyhow::anyhow!("Invalid payment hash"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
// LND REST: GET /v1/invoice/{r_hash_str} where r_hash_str is hex.
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{payment_hash_hex}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to look up invoice")?;
if !resp.status().is_success() {
return Ok(false);
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse invoice lookup response")?;
let settled = body
.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
|| body.get("state").and_then(|v| v.as_str()) == Some("SETTLED");
Ok(settled)
}
/// Generate a fresh on-chain receive address (seller side, #46).
pub(crate) async fn new_onchain_address(&self) -> Result<String> {
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to get new address")?;
if !resp.status().is_success() {
return Err(anyhow::anyhow!("LND newaddress failed: {}", resp.status()));
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse newaddress response")?;
body.get("address")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("LND newaddress returned no address"))
}
/// True if an on-chain payment of >= `min_sats` to `address` has been seen
/// with at least one confirmation (seller side, #46). Conservative on
/// purpose: requires a confirmation + exact-address + sufficient-amount so a
/// file sale is never released on an unconfirmed (reorg-able) tx.
pub(crate) async fn onchain_received(&self, address: &str, min_sats: u64) -> Result<bool> {
if address.is_empty() {
return Err(anyhow::anyhow!("Empty address"));
}
let (client, macaroon_hex) = self.lnd_client().await?;
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/transactions"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("Failed to list transactions")?;
if !resp.status().is_success() {
return Ok(false);
}
let body: serde_json::Value = resp
.json()
.await
.context("Failed to parse transactions response")?;
let i64_field = |tx: &serde_json::Value, k: &str| -> i64 {
tx.get(k)
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<i64>().ok())
.or_else(|| tx.get(k).and_then(|v| v.as_i64()))
.unwrap_or(0)
};
let txs = body
.get("transactions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
for tx in &txs {
if i64_field(tx, "num_confirmations") < 1 {
continue;
}
if i64_field(tx, "amount") < min_sats as i64 {
continue;
}
let pays_addr = tx
.get("dest_addresses")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|a| a.as_str() == Some(address)))
.unwrap_or(false)
|| tx
.get("output_details")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.any(|o| o.get("address").and_then(|a| a.as_str()) == Some(address))
})
.unwrap_or(false);
if pays_addr {
return Ok(true);
}
}
Ok(false)
}
pub(in crate::api::rpc) async fn handle_lnd_createinvoice(
&self,
params: Option<serde_json::Value>,
@@ -183,13 +427,23 @@ impl RpcHandler {
"memo": memo,
});
let resp = client
let resp = match client
.post(format!("{LND_REST_BASE_URL}/v1/invoices"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.json(&invoice_body)
.send()
.await
.context("Failed to create invoice")?;
{
Ok(r) => r,
Err(e) => {
// A hung/failed mint while LND is explicitly not synced to chain
// gets a clear, user-facing reason instead of an opaque error.
if self.lnd_chain_synced().await == Some(false) {
return Err(Self::syncing_invoice_err());
}
return Err(anyhow::anyhow!(e).context("Failed to create invoice"));
}
};
let status = resp.status();
let body: serde_json::Value = resp
@@ -552,8 +806,15 @@ impl RpcHandler {
let entropy_b64 = base64::engine::general_purpose::STANDARD.encode(entropy);
entropy.zeroize();
// Use the per-node secret as the LND wallet password (NOT the
// caller-supplied one) so the unattended boot path can auto-unlock this
// wallet. The wallet stays recoverable from the Archipelago seed via the
// derived entropy above. This unifies both init paths on one password
// source — the divergence here is what left wallets locked fleet-wide.
let _ = wallet_password; // accepted for API compat; superseded by the per-node secret
let node_wallet_pw = crate::container::lnd::ensure_wallet_password().await?;
let wallet_password_b64 =
base64::engine::general_purpose::STANDARD.encode(wallet_password.as_bytes());
base64::engine::general_purpose::STANDARD.encode(node_wallet_pw.as_bytes());
// Call LND REST API to initialize wallet with derived entropy.
// LND must be running but NOT yet initialized (no existing wallet).
@@ -0,0 +1,189 @@
//! Mesh-AI assistant RPCs (issue #50): read/update the local assistant config
//! and report whether a local Ollama is available (for the install deep-link).
use super::super::RpcHandler;
use anyhow::Result;
use std::time::Duration;
/// Default model when the node hasn't picked one (kept in sync with the mesh
/// assistant handler's `DEFAULT_MODEL`).
const DEFAULT_MODEL: &str = "qwen2.5-coder";
impl RpcHandler {
/// mesh.assistant-status — current settings + local Ollama availability.
pub(in crate::api::rpc) async fn handle_mesh_assistant_status(
&self,
) -> Result<serde_json::Value> {
let (cfg, denied_askers) = {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
(svc.assistant_config().await, svc.assistant_denied_askers().await)
};
let (ollama_detected, models) = detect_ollama().await;
let claude_available =
tokio::fs::metadata(self.config.data_dir.join("secrets/claude-api-key"))
.await
.is_ok();
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
"default_model": DEFAULT_MODEL,
"ollama_detected": ollama_detected,
"claude_available": claude_available,
"models": models,
"denied_askers": denied_askers,
}))
}
/// mesh.assistant-configure — update assistant settings live.
/// Params: `enabled?: bool`, `trusted_only?: bool`,
/// `model?: string|null` (string sets, null clears to default, absent leaves).
pub(in crate::api::rpc) async fn handle_mesh_assistant_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let enabled = params.get("enabled").and_then(|v| v.as_bool());
let trusted_only = params.get("trusted_only").and_then(|v| v.as_bool());
let backend = params
.get("backend")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// model: key present + string => set; present + null => clear; absent => leave
let model = if let Some(v) = params.get("model") {
Some(v.as_str().map(|s| s.to_string()))
} else {
None
};
// allowed_contacts: present + array => replace the allowlist (pubkey hex
// strings); absent => leave unchanged.
let allowed_contacts = params
.get("allowed_contacts")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|e| e.as_str().map(|s| s.to_string()))
.collect::<Vec<String>>()
});
svc.configure_assistant(enabled, model, trusted_only, backend, allowed_contacts)
.await?;
let cfg = svc.assistant_config().await;
Ok(serde_json::json!({
"enabled": cfg.enabled,
"model": cfg.model,
"trusted_only": cfg.trusted_only,
"backend": cfg.backend,
"allowed_contacts": cfg.allowed_contacts,
}))
}
/// mesh.schedule-message — queue a message to send at a future time.
/// Params: `body: string`, `fire_at: i64` (unix secs), and one of
/// `contact_id: u32` (DM) or `channel: u8` (broadcast).
pub(in crate::api::rpc) async fn handle_mesh_schedule_message(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let p = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let body = p
.get("body")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("body is required"))?
.to_string();
let fire_at = p
.get("fire_at")
.and_then(|v| v.as_i64())
.ok_or_else(|| anyhow::anyhow!("fire_at (unix seconds) is required"))?;
let contact_id = p
.get("contact_id")
.and_then(|v| v.as_u64())
.map(|v| v as u32);
let channel = p.get("channel").and_then(|v| v.as_u64()).map(|v| v as u8);
if contact_id.is_none() && channel.is_none() {
anyhow::bail!("either contact_id or channel is required");
}
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let msg = svc
.scheduler
.add(contact_id, channel, body, fire_at)
.await?;
Ok(serde_json::to_value(msg)?)
}
/// mesh.list-scheduled — list queued messages (sorted by fire time).
pub(in crate::api::rpc) async fn handle_mesh_list_scheduled(
&self,
) -> Result<serde_json::Value> {
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let messages = svc.scheduler.list().await;
Ok(serde_json::json!({ "messages": messages }))
}
/// mesh.cancel-scheduled — remove a queued message by id.
pub(in crate::api::rpc) async fn handle_mesh_cancel_scheduled(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let id = params
.as_ref()
.and_then(|p| p.get("id"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let cancelled = svc.scheduler.cancel(id).await?;
Ok(serde_json::json!({ "cancelled": cancelled }))
}
}
/// Probe the local Ollama HTTP API; return (detected, model_names).
async fn detect_ollama() -> (bool, Vec<String>) {
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(c) => c,
Err(_) => return (false, Vec::new()),
};
match client.get("http://localhost:11434/api/tags").send().await {
Ok(resp) if resp.status().is_success() => {
let json: serde_json::Value = resp.json().await.unwrap_or_default();
let models = json
.get("models")
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| {
m.get("name")
.and_then(|n| n.as_str())
.map(|s| s.to_string())
})
.collect()
})
.unwrap_or_default();
(true, models)
}
_ => (false, Vec::new()),
}
}
@@ -110,6 +110,18 @@ impl RpcHandler {
if let Some(name) = params.get("advert_name").and_then(|v| v.as_str()) {
config.advert_name = Some(name.to_string());
}
if let Some(announce) = params
.get("announce_block_headers")
.and_then(|v| v.as_bool())
{
config.announce_block_headers = announce;
}
if let Some(receive) = params
.get("receive_block_headers")
.and_then(|v| v.as_bool())
{
config.receive_block_headers = receive;
}
mesh::save_config(&self.config.data_dir, &config).await?;
@@ -124,6 +136,8 @@ impl RpcHandler {
"configured": true,
"enabled": config.enabled,
"device_path": config.device_path,
"announce_block_headers": config.announce_block_headers,
"receive_block_headers": config.receive_block_headers,
}))
}
}
+1
View File
@@ -1,3 +1,4 @@
mod assistant;
mod bitcoin_ops;
mod messaging;
mod safety;
+51 -36
View File
@@ -5,26 +5,39 @@ use anyhow::Result;
impl RpcHandler {
/// mesh.status — Get mesh radio status, device info, and peer count.
pub(in crate::api::rpc) async fn handle_mesh_status(&self) -> Result<serde_json::Value> {
// Block-header send/receive prefs live in MeshConfig; surface them in
// status so the UI toggles (issue #28) can show the persisted state.
let config = mesh::load_config(&self.config.data_dir).await?;
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let mut value = if let Some(svc) = service.as_ref() {
let status = svc.status().await;
Ok(serde_json::to_value(status)?)
serde_json::to_value(status)?
} else {
// No service running — return basic config + device detection
let config = mesh::load_config(&self.config.data_dir).await?;
let devices = mesh::detect_devices().await;
Ok(serde_json::json!({
serde_json::json!({
"enabled": config.enabled,
"device_connected": false,
"device_type": "unknown",
"device_path": config.device_path,
"channel_name": config.channel_name.unwrap_or_else(|| "archipelago".to_string()),
"channel_name": config.channel_name.clone().unwrap_or_else(|| "archipelago".to_string()),
"detected_devices": devices,
"peer_count": 0,
"messages_sent": 0,
"messages_received": 0,
}))
})
};
if let Some(obj) = value.as_object_mut() {
obj.insert(
"announce_block_headers".into(),
config.announce_block_headers.into(),
);
obj.insert(
"receive_block_headers".into(),
config.receive_block_headers.into(),
);
}
Ok(value)
}
/// mesh.peers — List discovered mesh peers.
@@ -245,43 +258,45 @@ impl RpcHandler {
if let Some(svc) = service.as_ref() {
let state = svc.state();
// Snapshot the firmware pubkeys we currently know about, then
// add them to the radio-contact blocklist. MeshCore's on-device
// contact table is persistent and reads back stale rows on the
// next refresh_contacts, so without this step `clear-all` only
// wipes the app view for a few seconds before the old entries
// reappear. The blocklist is also saved to disk so the filter
// survives a restart.
let firmware_pubkeys: Vec<String> = state
// NOTE: `clear-all` intentionally does NOT build a radio-contact
// blocklist. Permanently ignoring firmware contacts meant a cleared
// peer could never return even when it re-advertised (it also broke
// re-pairing a phone after a clear). Real per-contact blocking will
// be a separate, explicit feature. Here we just wipe the app-side
// view and ALSO clear any blocklist left over from older builds, so
// previously-hidden contacts can re-appear when next heard. The
// firmware's own contact table is the source of truth on refresh.
{
let mut set = state.radio_contact_blocklist.write().await;
set.clear();
}
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await;
// Actually DELETE each radio contact from the firmware table (via
// CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next
// refresh. They come back only when they re-advertise (reachable).
// Federation-synthetic peers (high contact_id bit) aren't firmware
// contacts, so skip those.
let firmware_pubkeys: Vec<[u8; 32]> = state
.peers
.read()
.await
.values()
.filter_map(|p| {
// Federation-synthetic peers have their contact_id in the
// high half of u32 and carry the archipelago key — those
// aren't firmware contacts and must not go on the list.
if p.contact_id & 0x8000_0000 != 0 {
None
} else {
p.pubkey_hex.clone()
}
.filter(|p| p.contact_id & 0x8000_0000 == 0)
.filter_map(|p| p.pubkey_hex.as_deref())
.filter_map(|h| hex::decode(h).ok())
.filter(|b| b.len() == 32)
.map(|b| {
let mut k = [0u8; 32];
k.copy_from_slice(&b);
k
})
.collect();
{
let mut set = state.radio_contact_blocklist.write().await;
for pk in &firmware_pubkeys {
set.insert(pk.clone());
}
for pk in firmware_pubkeys {
let _ = state
.send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk })
.await;
}
let persisted: Vec<String> = state
.radio_contact_blocklist
.read()
.await
.iter()
.cloned()
.collect();
let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await;
state.peers.write().await.clear();
state.messages.write().await.clear();
@@ -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 }))
}
+1
View File
@@ -9,6 +9,7 @@ mod credentials;
mod dispatcher;
mod dwn;
mod federation;
mod fedimint;
mod fips;
mod handshake;
mod identity;
+62 -29
View File
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint-gateway"
| "indeedhub"
| "immich"
| "fips"
| "fips-ui"
)
}
@@ -347,13 +349,37 @@ fn http_probe_cmd(url: &'static str) -> &'static str {
}
}
/// Bitcoin UTXO cache (`-dbcache`) in MB, sized to host RAM.
///
/// A fixed large dbcache on a small box pushes bitcoind + the ~20 app
/// containers past physical RAM and triggers system-wide swap thrash: the
/// disk saturates, bitcoind can't answer its own RPC, and the dashboard
/// backend's sqlite reads stall — surfacing as /rpc/v1 502s and a blank
/// Bitcoin UI. Budget ~1/16 of RAM for the cache (floor 300 MB — bitcoind's
/// own default is 450 — cap 4096 MB), mirroring scripts/container-specs.sh.
pub(super) fn bitcoin_dbcache_mb() -> u64 {
let total_mb = std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|c| {
c.lines()
.find_map(|l| l.strip_prefix("MemTotal:"))
.and_then(|v| v.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
})
.map(|kb| kb / 1024)
.unwrap_or(16000); // assume a comfortable host if /proc/meminfo is unreadable
(total_mb / 16).clamp(300, 4096)
}
/// Get per-app memory limit.
pub(super) fn get_memory_limit(app_id: &str) -> &'static str {
match app_id {
// Heavy apps. Bitcoin: dbcache uses ~4GB; the daemon also needs
// headroom for mempool + connection buffers + script-verifier
// memory + I/O. 4g caused OOM-cascades during IBD. 8g is the
// floor; ideally this would be host-RAM aware (next pass).
// Heavy apps. Bitcoin: dbcache is now host-RAM-aware (see
// bitcoin_dbcache_mb), so the daemon's footprint scales with the box.
// This cgroup cap is an upper bound for mempool + connection buffers +
// script-verifier memory + I/O; a tight cap (4g) previously caused
// OOM-cascades during IBD, so keep 8g as a generous ceiling rather
// than a tight limit — swap thrash is prevented at the dbcache layer.
"bitcoin" | "bitcoin-core" | "bitcoin-knots" => "8g",
// ElectrumX indexing spikes above its cache size due Python,
// RocksDB, socket buffers, and reorg/history work. Keep cache
@@ -672,9 +698,10 @@ pub(super) async fn get_app_config(
// RPC is reachable from the bitcoin-ui companion container.
//
// Sync-speed flags:
// -dbcache=4096 — UTXO set cache; 4GB is the sweet spot before
// diminishing returns. Container has --memory=8g now so
// there's headroom for mempool + connections.
// -dbcache — UTXO set cache, sized to host RAM via
// bitcoin_dbcache_mb() (see there). A fixed 4GB cache swap-
// thrashed small nodes into fleet-wide 502s; ~1/16 of RAM
// keeps headroom for mempool + connections + the app stack.
// -par=0 — use all available cores for script
// verification (defaults to NCPU-1 capped at 16). Was
// effectively pinned at 2 by --cpus=2 (now removed).
@@ -687,7 +714,7 @@ pub(super) async fn get_app_config(
"-rpcport=8332".to_string(),
"-printtoconsole=1".to_string(),
"-datadir=/home/bitcoin/.bitcoin".to_string(),
"-dbcache=4096".to_string(),
format!("-dbcache={}", bitcoin_dbcache_mb()),
"-par=0".to_string(),
"-maxconnections=125".to_string(),
]),
@@ -750,27 +777,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<()> {
+245 -33
View File
@@ -434,6 +434,13 @@ async fn wait_for_stack_containers(
containers: &[&str],
timeout_secs: u64,
) -> Result<()> {
// A container can exit on its first start because a dependency (db, redis,
// the bitcoin node) was not quite ready — a transient crash, not a broken
// install. Restart each exited container a bounded number of times before
// declaring the install failed (#25). The runtime supervisor keeps it alive
// afterwards, but we want a healthy state by the time install returns.
const MAX_RESTARTS: u32 = 3;
let mut restarts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let mut pending = Vec::new();
@@ -449,20 +456,41 @@ async fn wait_for_stack_containers(
match state.as_str() {
"running" => {}
"exited" | "dead" => {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited. Logs:\n{}",
stack_name,
container,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install. Logs: {}",
stack_name,
container,
logs.chars().take(500).collect::<String>()
));
let attempts = restarts.entry(container.to_string()).or_insert(0);
if *attempts < MAX_RESTARTS {
*attempts += 1;
install_log(&format!(
"INSTALL RESTART: {} - container {} exited, restart attempt {}/{}",
stack_name, container, *attempts, MAX_RESTARTS
))
.await;
let _ = podman_stack_output(
&["start", container],
PODMAN_STACK_PROBE_TIMEOUT,
)
.await;
pending.push(format!(
"{}=restarting({}/{})",
container, *attempts, MAX_RESTARTS
));
} else {
let logs = stack_container_logs(container, 40).await;
install_log(&format!(
"INSTALL CRASH: {} - container {} exited after {} restarts. Logs:\n{}",
stack_name,
container,
MAX_RESTARTS,
logs.chars().take(1000).collect::<String>()
))
.await;
return Err(anyhow::anyhow!(
"{} container {} exited after install ({} restarts). Logs: {}",
stack_name,
container,
MAX_RESTARTS,
logs.chars().take(500).collect::<String>()
));
}
}
other => pending.push(format!("{}={}", container, other)),
}
@@ -1152,6 +1180,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 +1306,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",
@@ -1776,14 +1807,22 @@ impl RpcHandler {
let host_ip = detect_netbird_public_host_ip()
.await
.unwrap_or_else(|| self.config.host_ip.clone());
write_netbird_config_files(&host_ip).await?;
// Create the network FIRST so we can read back the gateway it was
// assigned — that gateway is Podman's aardvark DNS, which the proxy's
// nginx needs as an explicit `resolver` to re-resolve container names
// (issue #15: without it nginx caches a container IP and 502s forever
// once that IP changes on restart/reboot).
let _ = podman_stack_status(
&["network", "create", "netbird-net"],
PODMAN_STACK_PROBE_TIMEOUT,
)
.await;
let resolver_ip = netbird_net_resolver_ip().await;
write_netbird_config_files(&host_ip, &self.config.host_ip, &resolver_ip).await?;
ensure_netbird_tls_cert(&host_ip).await?;
let mut server_cmd = tokio::process::Command::new("podman");
server_cmd.args([
"run",
@@ -1821,6 +1860,10 @@ impl RpcHandler {
"netbird-dashboard",
"--network",
"netbird-net",
// Explicit alias so the proxy can always resolve `netbird-dashboard`
// via Podman DNS — don't rely on implicit container-name aliasing.
"--network-alias",
"netbird-dashboard",
"--restart=unless-stopped",
"--env-file",
"/var/lib/archipelago/netbird/dashboard.env",
@@ -1837,10 +1880,16 @@ impl RpcHandler {
"--network",
"netbird-net",
"--restart=unless-stopped",
// 8087 publishes the TLS listener — netbird's dashboard requires a
// secure context (window.crypto.subtle / OIDC PKCE), issue #15.
"-p",
"8087:80",
"8087:443",
"-v",
"/var/lib/archipelago/netbird/nginx.conf:/etc/nginx/conf.d/default.conf:ro",
"-v",
"/var/lib/archipelago/netbird/tls.crt:/etc/nginx/tls.crt:ro",
"-v",
"/var/lib/archipelago/netbird/tls.key:/etc/nginx/tls.key:ro",
NETBIRD_PROXY_IMAGE,
]);
run_required_stack_command("netbird", "create unified proxy", &mut proxy_cmd).await?;
@@ -1885,9 +1934,104 @@ async fn read_or_generate_b64_secret(name: &str) -> String {
secret
}
async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
let public_origin = format!("http://{}:8087", host_ip);
/// Read the gateway of the `netbird-net` bridge. Podman runs its aardvark DNS
/// resolver on this address, so nginx can use it as an explicit `resolver` to
/// re-resolve container names at request time. Falls back to Podman's usual
/// first-pool gateway if the inspect fails (best effort — config is rewritten
/// on every (re)install).
async fn netbird_net_resolver_ip() -> String {
let out = tokio::process::Command::new("podman")
.args([
"network",
"inspect",
"netbird-net",
"--format",
"{{range .Subnets}}{{.Gateway}}{{end}}",
])
.output()
.await;
if let Ok(o) = out {
let gw = String::from_utf8_lossy(&o.stdout).trim().to_string();
if !gw.is_empty() && gw.parse::<std::net::IpAddr>().is_ok() {
return gw;
}
}
"10.89.0.1".to_string()
}
/// Generate a self-signed TLS cert for the netbird proxy if absent. The
/// dashboard needs a secure context (window.crypto.subtle / OIDC PKCE), so the
/// proxy serves HTTPS; a self-signed cert is sufficient (the user accepts it
/// once when opening netbird in a tab). SAN covers the LAN IP plus
/// localhost/127.0.0.1 so it's valid however the box is reached locally.
async fn ensure_netbird_tls_cert(host_ip: &str) -> Result<()> {
let dir = "/var/lib/archipelago/netbird";
let crt = format!("{dir}/tls.crt");
let key = format!("{dir}/tls.key");
if tokio::fs::metadata(&crt).await.is_ok() && tokio::fs::metadata(&key).await.is_ok() {
return Ok(());
}
let _ = tokio::fs::create_dir_all(dir).await;
let san = format!("subjectAltName=IP:{host_ip},IP:127.0.0.1,DNS:localhost");
let status = tokio::process::Command::new("openssl")
.args([
"req",
"-x509",
"-newkey",
"rsa:2048",
"-nodes",
"-keyout",
&key,
"-out",
&crt,
"-days",
"3650",
"-subj",
&format!("/CN={host_ip}"),
"-addext",
&san,
])
.status()
.await
.context("failed to run openssl for netbird TLS cert")?;
if !status.success() {
anyhow::bail!("openssl failed to generate netbird TLS cert");
}
Ok(())
}
async fn write_netbird_config_files(host_ip: &str, lan_ip: &str, resolver_ip: &str) -> Result<()> {
// netbird's dashboard uses window.crypto.subtle (OIDC PKCE), which browsers
// only expose in a SECURE context — so the proxy serves HTTPS and every
// origin here is https (issue #15: over plain http the dashboard threw
// "window.crypto.subtle is unavailable" and never reached login).
let public_origin = format!("https://{}:8087", host_ip);
let server_origin = format!("http://{}:8086", host_ip);
// A single box is reached via several addresses. Allow the OIDC login flow
// to redirect back to whichever origin the user actually used, otherwise
// post-login lands on the wrong host and the dashboard shows
// "Unauthenticated" (issue #15). The browser-side CORS is handled in the
// nginx proxy; this covers the redirect-URI allow-list.
let lan_origin = format!("https://{}:8087", lan_ip);
let mut redirect_origins = vec![public_origin.clone()];
if lan_origin != public_origin {
redirect_origins.push(lan_origin);
}
let dashboard_redirect_uris = redirect_origins
.iter()
.flat_map(|o| {
[
format!(" - \"{o}/nb-auth\""),
format!(" - \"{o}/nb-silent-auth\""),
]
})
.collect::<Vec<_>>()
.join("\n");
let dashboard_logout_uris = redirect_origins
.iter()
.map(|o| format!(" - \"{o}/\""))
.collect::<Vec<_>>()
.join("\n");
let relay_secret = read_or_generate_b64_secret("netbird-relay-auth-secret").await;
let encryption_key = read_or_generate_b64_secret("netbird-store-encryption-key").await;
let config = format!(
@@ -1907,10 +2051,9 @@ async fn write_netbird_config_files(host_ip: &str) -> Result<()> {
localAuthDisabled: false
signKeyRefreshEnabled: false
dashboardRedirectURIs:
- "{public_origin}/nb-auth"
- "{public_origin}/nb-silent-auth"
{dashboard_redirect_uris}
dashboardPostLogoutRedirectURIs:
- "{public_origin}/"
{dashboard_logout_uris}
cliRedirectURIs:
- "http://localhost:53000/"
store:
@@ -1944,12 +2087,23 @@ LETSENCRYPT_DOMAIN=none
let nginx_conf = format!(
r#"server {{
listen 80;
listen 443 ssl;
server_name _;
# Route browser API/auth through the host-published server port. Rootless
# Podman can give netbird-server a new container IP on restart while nginx
# keeps an old resolved address, which breaks login with 502s.
# netbird's dashboard needs a secure context (window.crypto.subtle for OIDC
# PKCE), so the proxy terminates TLS with a self-signed cert (issue #15).
ssl_certificate /etc/nginx/tls.crt;
ssl_certificate_key /etc/nginx/tls.key;
# Rootless Podman can hand a container a new IP across restarts/reboots.
# nginx resolves a literal upstream name ONCE at startup and caches it, so
# after the IP moves every request 502s with "host unreachable" (issue #15,
# observed live on .198: nginx pinned to a dead netbird-dashboard IP). Fix:
# point `resolver` at the netbird-net gateway (Podman's aardvark DNS) and
# use VARIABLE upstreams, which forces nginx to re-resolve the container
# names at request time. Everything is reached container-to-container by
# name so nothing depends on host-published ports either.
resolver {resolver_ip} valid=10s ipv6=off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@@ -1958,24 +2112,60 @@ LETSENCRYPT_DOMAIN=none
proxy_http_version 1.1;
location ~ ^/(relay|ws-proxy/) {{
proxy_pass http://host.containers.internal:8086;
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 1d;
}}
location ~ ^/(api|oauth2)(/|$) {{
proxy_pass http://host.containers.internal:8086;
# The dashboard is a SPA whose API/OIDC base URL is baked at build time
# to one host:port. A single box is reached via several addresses (LAN
# IP, Tailscale 100.x, hostname), so those fetches are cross-origin and
# the browser blocks them with no Access-Control-Allow-Origin (issue
# #15, observed live on .198). Reflect the caller's Origin so the
# self-hosted management/OIDC API is reachable from any of them, and
# answer the CORS preflight here.
if ($request_method = OPTIONS) {{
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
add_header Access-Control-Max-Age 86400 always;
add_header Content-Length 0;
return 204;
}}
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, Accept" always;
set $nb_server netbird-server;
proxy_pass http://$nb_server:80;
}}
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService|management\.ProxyService)/ {{
grpc_pass grpc://netbird-server:80;
set $nb_server netbird-server;
grpc_pass grpc://$nb_server:80;
grpc_read_timeout 1d;
grpc_send_timeout 1d;
}}
# OIDC callback routes are client-side SPA routes with NO prebuilt page in
# the dashboard bundle, so proxying them straight through 404s which
# crashes the dashboard's auth init and shows "Unauthenticated" with dead
# buttons (issue #15, confirmed live on .198: /nb-auth + /nb-silent-auth
# returned 404). Serve the dashboard's index.html at these paths (URL
# unchanged) so react-oidc boots and completes the login / silent-SSO.
location ~ ^/(nb-auth|nb-silent-auth) {{
set $nb_dashboard netbird-dashboard;
rewrite ^.*$ /index.html break;
proxy_pass http://$nb_dashboard:80;
}}
location / {{
proxy_pass http://netbird-dashboard:80;
set $nb_dashboard netbird-dashboard;
proxy_pass http://$nb_dashboard:80;
}}
}}
@@ -1996,10 +2186,32 @@ async fn detect_netbird_public_host_ip() -> Option<String> {
.await
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
let ips: Vec<&str> = stdout
.split_whitespace()
.find(|ip| ip.starts_with("100.") && ip.contains('.'))
.map(str::to_string)
.filter(|s| s.contains('.'))
.collect();
// Prefer the LAN address as the canonical origin — that's what users browse
// to on the local network. Baking the Tailscale 100.x address here broke
// LAN access with cross-origin/redirect mismatches (issue #15). Tailscale
// (100.64.0.0/10 CGNAT) is only a fallback for nodes with no LAN IP.
let is_private_lan = |ip: &str| {
ip.starts_with("192.168.")
|| ip.starts_with("10.")
|| (ip.starts_with("172.")
&& ip
.split('.')
.nth(1)
.and_then(|o| o.parse::<u8>().ok())
.map(|o| (16..=31).contains(&o))
.unwrap_or(false))
};
if let Some(lan) = ips.iter().find(|ip| is_private_lan(ip)) {
return Some(lan.to_string());
}
ips.iter()
.find(|ip| ip.starts_with("100."))
.map(|s| s.to_string())
}
#[cfg(test)]
+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.
+135 -14
View File
@@ -60,6 +60,30 @@ impl RpcHandler {
/// Generate a new 24-word BIP-39 mnemonic, derive and persist node keys.
/// Returns the words for the user to write down.
pub(in crate::api::rpc) async fn handle_seed_generate(&self) -> Result<serde_json::Value> {
// Serialize concurrent / retried generate calls. The web client aborts
// at 15s and retries internally (up to 3x), and the onboarding view
// re-fires every 4s while the server is still booting on slow first-boot
// hardware. Without this guard each hit would mint a brand-new seed and
// overwrite the node keys mid-flight, leaving the words shown to the user
// out of sync with what `seed.verify` expects — the classic "error at the
// DID-creation screen". Holding the lock across the whole op fully
// serializes them.
let mut state = ONBOARDING_MNEMONIC.lock().await;
// Idempotent fast-path: a fresh pending mnemonic already exists, so the
// node keys are already on disk. Return the SAME words rather than
// regenerating, so every retry yields a consistent result.
if let Some(existing) = state.as_ref() {
if existing.created_at.elapsed() < MNEMONIC_TTL {
let words: Vec<String> = existing
.words
.split_whitespace()
.map(str::to_string)
.collect();
return Ok(serde_json::json!({ "words": words }));
}
}
let (mnemonic, seed) = crate::seed::MasterSeed::generate()?;
// Derive and write node Ed25519 key.
@@ -89,16 +113,14 @@ impl RpcHandler {
// the onboarding RPC returns immediately.
spawn_post_onboarding_fips_activate(self.config.data_dir.clone());
let words: Vec<&str> = mnemonic.words().collect();
let words: Vec<String> = mnemonic.words().map(str::to_string).collect();
// Hold mnemonic in memory for the verify step.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = Some(OnboardingMnemonicState {
words: mnemonic.to_string(),
created_at: std::time::Instant::now(),
});
}
// Hold mnemonic in memory for the verify step. We already own the lock
// guard (`state`) from the top of the function, so just write through it.
*state = Some(OnboardingMnemonicState {
words: mnemonic.to_string(),
created_at: std::time::Instant::now(),
});
Ok(serde_json::json!({
"words": words,
@@ -149,11 +171,13 @@ impl RpcHandler {
let nostr_keys = crate::seed::derive_node_nostr_key(&seed)?;
let nostr_npub = nostr_keys.public_key().to_bech32().unwrap_or_default();
// Clear mnemonic from memory now that it's verified.
{
let mut state = ONBOARDING_MNEMONIC.lock().await;
*state = None;
}
// Intentionally DO NOT clear the mnemonic here. The web client aborts
// slow requests at 15s and retries internally; if we wiped it on the
// first (successful) verify, a retried request would fail with
// "No pending seed generation or session expired" even though the user
// did everything right. The mnemonic is bounded by MNEMONIC_TTL (10 min)
// and is overwritten on the next generate, so leaving it makes verify
// idempotent without meaningfully widening the in-memory window.
// Save the encrypted seed for convenience backup.
// Use empty passphrase placeholder — the real encrypted save happens via seed.save-encrypted.
@@ -290,4 +314,101 @@ impl RpcHandler {
"next_index": next_index,
}))
}
/// Reveal the node's 24-word recovery phrase after onboarding. Heavily
/// gated, because this is the keys to the whole node:
/// - requires a full authenticated session (enforced upstream: this
/// method is NOT in the public auth whitelist),
/// - re-verifies the login password,
/// - requires a valid TOTP code when 2FA is enabled (replay-protected),
/// - decrypts `identity/master_seed.enc` with the backup passphrase
/// (defaults to the login password when the user used the same value).
/// The words are returned to the caller only and never logged.
pub(in crate::api::rpc) async fn handle_seed_reveal(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or_default();
let mut password = params
.get("password")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if password.is_empty() {
anyhow::bail!("Password is required to reveal the recovery phrase");
}
// Nothing to reveal if this node never stored an encrypted seed.
if !crate::seed::seed_exists(&self.config.data_dir) {
anyhow::bail!(
"This node has no encrypted seed backup, so the recovery phrase \
cannot be shown. It was only displayed once during setup."
);
}
// 1) Re-authenticate with the login password.
if !self.auth_manager.verify_password(&password).await? {
password.zeroize();
anyhow::bail!("Incorrect password");
}
// 2) Require a valid 2FA code when TOTP is enabled (replay-protected).
if self.auth_manager.is_totp_enabled().await.unwrap_or(false) {
let code = params
.get("code")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if code.is_empty() {
password.zeroize();
anyhow::bail!("A 2FA code is required to reveal the recovery phrase");
}
let totp_data = self
.auth_manager
.get_totp_data()
.await?
.ok_or_else(|| anyhow::anyhow!("2FA is enabled but no TOTP data found"))?;
let secret = crate::totp::decrypt_secret(&totp_data, &password)
.context("Could not unlock 2FA with this password")?;
match crate::totp::verify_code(&secret, &code, &totp_data.used_steps)? {
Some(step) => {
// Record the used step for replay protection, pruning old ones.
let mut data = totp_data;
data.used_steps.push(step);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let cutoff = (now / 30) - 10; // ~5 minutes
data.used_steps.retain(|s| *s > cutoff);
let _ = self.auth_manager.update_totp(data).await;
}
None => {
password.zeroize();
anyhow::bail!("Invalid 2FA code");
}
}
}
// 3) Decrypt the stored seed. The backup passphrase may differ from the
// login password, so accept an explicit one and fall back to the
// password when the user used the same value for both.
let passphrase = params
.get("passphrase")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let secret_phrase = passphrase.unwrap_or_else(|| password.clone());
let reveal = crate::seed::load_seed_encrypted(&self.config.data_dir, &secret_phrase).await;
password.zeroize();
let mnemonic = reveal.map_err(|_| {
anyhow::anyhow!(
"Could not decrypt the saved seed. If you set a separate backup \
passphrase during setup, enter that passphrase."
)
})?;
let words: Vec<String> = mnemonic.words().map(|w| w.to_string()).collect();
let word_count = words.len();
Ok(serde_json::json!({ "words": words, "word_count": word_count }))
}
}
+58
View File
@@ -205,6 +205,64 @@ impl RpcHandler {
}
}
/// Build a payment token for a remote seeder (payer side, cross-mint aware).
///
/// Given the seeder's advertised `accepted_mints` and `price_sats`, builds a
/// `cashuA` token denominated in one of those mints — paying directly if we
/// already hold the right mint, else auto-swapping into a trusted accepted
/// mint (within `max_fee_sats`). If the price is over `budget_sats`, the
/// wallet can't cover it, or the swap is too costly, returns `declined` so
/// the caller falls back to the free origin (origin always wins).
pub(super) async fn handle_streaming_prepare_payment(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let accepted_mints: Vec<String> = params
.get("accepted_mints")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let price_sats = params
.get("price_sats")
.or_else(|| params.get("amount_sats"))
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("Missing price_sats"))?;
// Default budget = the asked price (willing to pay exactly what's quoted).
let budget_sats = params
.get("budget_sats")
.and_then(|v| v.as_u64())
.unwrap_or(price_sats);
let max_fee_sats = params
.get("max_fee_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let policy = crate::swarm::payment::PaymentPolicy::with_budget(budget_sats, max_fee_sats);
match crate::swarm::payment::auto_pay_token(
&self.config.data_dir,
&policy,
&accepted_mints,
price_sats,
)
.await?
{
Some(token) => Ok(serde_json::json!({
"status": "ready",
"token": token,
"paid_sats": price_sats,
})),
None => Ok(serde_json::json!({
"status": "declined",
"message": "payment declined (over budget, unpayable, or swap too costly) — use free origin",
})),
}
}
/// Discover available streaming services (pricing info).
/// This is the unauthenticated discovery endpoint.
pub(super) async fn handle_streaming_discover(&self) -> Result<serde_json::Value> {
+48
View File
@@ -253,6 +253,54 @@ impl RpcHandler {
Ok(serde_json::json!({ "mirrors": list }))
}
/// Report the node's swarm prefs (fetch source + whether it provides to the
/// swarm) plus swarm capability, so the UI can show whether DHT mode is
/// actually usable on this build.
pub(super) async fn handle_update_get_source(&self) -> Result<serde_json::Value> {
let source = update::load_update_source(&self.config.data_dir).await;
let provide_dht = update::load_provide_dht(&self.config.data_dir).await;
let source_str = match source {
update::UpdateSource::Origin => "origin",
update::UpdateSource::Swarm => "swarm",
};
Ok(serde_json::json!({
"source": source_str,
// Whether this node seeds/serves blobs to peers (default true).
"provide_dht": provide_dht,
// Compiled with the iroh swarm engine? If false, "swarm" mode has no
// peers and silently behaves like origin.
"swarm_available": cfg!(feature = "iroh-swarm"),
// Runtime swarm-assist gate from config (ARCHIPELAGO_SWARM_ENABLED).
"swarm_enabled": self.config.swarm_enabled,
}))
}
/// Update the node's swarm prefs. Params (both optional, at least one):
/// `{ source?: "origin" | "swarm", provide?: bool }`.
pub(super) async fn handle_update_set_source(
&self,
params: &serde_json::Value,
) -> Result<serde_json::Value> {
let mut touched = false;
if let Some(s) = params.get("source").and_then(|v| v.as_str()) {
let source = match s {
"origin" => update::UpdateSource::Origin,
"swarm" => update::UpdateSource::Swarm,
_ => anyhow::bail!("source must be \"origin\" or \"swarm\""),
};
update::save_update_source(&self.config.data_dir, source).await?;
touched = true;
}
if let Some(provide) = params.get("provide").and_then(|v| v.as_bool()) {
update::save_provide_dht(&self.config.data_dir, provide).await?;
touched = true;
}
if !touched {
anyhow::bail!("expected \"source\" and/or \"provide\"");
}
self.handle_update_get_source().await
}
/// Add a mirror to the end of the list. Params: `{ url, label? }`.
/// Duplicates (same URL) are replaced rather than added twice.
pub(super) async fn handle_update_add_mirror(
+25 -2
View File
@@ -1,7 +1,14 @@
use super::RpcHandler;
use crate::wallet::{ecash, profits};
use crate::wallet::{ecash, fedimint_client, profits};
use anyhow::Result;
/// A Cashu token (NUT-00 `cashuA`/`cashuB`, or our legacy `cashuSend_` form)
/// always starts with `cashu`. Fedimint ecash notes never do, so a non-`cashu`
/// string is routed to the Fedimint reissue path.
fn is_cashu_token(token: &str) -> bool {
token.trim_start().starts_with("cashu")
}
impl RpcHandler {
pub(super) async fn handle_wallet_ecash_balance(&self) -> Result<serde_json::Value> {
let wallet = ecash::load_wallet(&self.config.data_dir).await?;
@@ -129,11 +136,27 @@ impl RpcHandler {
let token = params
.get("token")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("Missing token"))?;
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
// Dual-ecash: one "Receive ecash" box accepts either a Cashu token
// (redeemed at the mint) or Fedimint notes (reissued via the fmcd
// sidecar). Detect by prefix and route accordingly.
if is_cashu_token(token) {
let amount = ecash::receive_token(&self.config.data_dir, token).await?;
return Ok(serde_json::json!({
"received_sats": amount,
"kind": "cashu",
}));
}
let (amount, federation_id) =
fedimint_client::reissue_into_any(&self.config.data_dir, token).await?;
Ok(serde_json::json!({
"received_sats": amount,
"kind": "fedimint",
"federation_id": federation_id,
}))
}
+52 -23
View File
@@ -13,14 +13,32 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::RwLock;
use tracing::{debug, warn};
const CACHE_REFRESH_SECS: u64 = 10;
const CACHE_ERROR_BACKOFF_SECS: u64 = 15;
// Poll frequently and recover fast so the cached snapshot tracks bitcoind's
// responsive windows during IBD. During heavy block-connection, getblockchaininfo
// can block briefly; a slow 10s/15s/20s cadence let one missed poll age the
// snapshot past the UI's 30s "stale" threshold, so the UI dwelled on
// "reconnecting…" long after bitcoind was answering again. Tight cadence + short
// timeout keeps last-known state fresh and clears the stale banner promptly.
const CACHE_REFRESH_SECS: u64 = 5;
const CACHE_ERROR_BACKOFF_SECS: u64 = 5;
// Grace window before a failing poll marks the snapshot "stale" for the UI.
// On a busy / swap-thrashing node (e.g. .198) getblockchaininfo intermittently
// exceeds the RPC timeout, so a single missed poll is normal and must NOT flip
// the UI to "reconnecting…". Only after the cached snapshot is genuinely old —
// several polls failed in a row — do we surface the banner.
const STALE_GRACE_MS: u64 = 20_000;
#[derive(Debug, Clone, Serialize)]
pub struct BitcoinNodeStatus {
pub ok: bool,
pub stale: bool,
pub updated_at_ms: u64,
// Server-computed age of the snapshot, filled in at serve time. The browser
// must not derive this itself (Date.now() - updated_at_ms) because that
// compares the browser clock against this node's clock — any skew made a
// fresh snapshot look stale and the "reconnecting…" banner never cleared.
pub age_ms: u64,
pub error: Option<String>,
pub blockchain_info: Option<serde_json::Value>,
pub network_info: Option<serde_json::Value>,
@@ -34,6 +52,7 @@ impl Default for BitcoinNodeStatus {
ok: false,
stale: false,
updated_at_ms: 0,
age_ms: 0,
error: Some("Connecting to Bitcoin node...".to_string()),
blockchain_info: None,
network_info: None,
@@ -122,7 +141,11 @@ pub fn spawn_status_cache() {
if cached.blockchain_info.is_some() {
cached.ok = false;
cached.stale = true;
// Only flip to "stale" once the last good snapshot is older
// than the grace window. A brief RPC gap on a busy node keeps
// showing last-known state silently instead of a banner flicker.
let snapshot_age_ms = now_ms().saturating_sub(cached.updated_at_ms);
cached.stale = snapshot_age_ms > STALE_GRACE_MS;
cached.error = Some(friendly_transient_error(true, &err_msg));
} else {
*cached = BitcoinNodeStatus {
@@ -142,40 +165,46 @@ pub fn spawn_status_cache() {
}
pub async fn get_bitcoin_status() -> BitcoinNodeStatus {
cache().read().await.clone()
let mut status = cache().read().await.clone();
// Compute age here (server clock only) so the browser never has to subtract
// across clocks. A successful snapshot serves age_ms ≈ 0 → the UI clears the
// "reconnecting…" banner on its very next poll regardless of browser-clock skew.
if status.updated_at_ms > 0 {
status.age_ms = now_ms().saturating_sub(status.updated_at_ms);
}
status
}
async fn fetch_bitcoin_status() -> Result<BitcoinNodeStatus> {
// 12s (not 8s): on a swap-thrashing node getblockchaininfo can answer slowly
// but correctly; too tight a timeout turned working-but-slow polls into
// failures and tripped the "reconnecting…" banner. Stays under STALE_GRACE_MS.
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.timeout(Duration::from_secs(12))
.build()
.context("build Bitcoin status HTTP client")?;
let blockchain_info = bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([]))
.await
.context("getblockchaininfo")?;
let network_info = bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([]))
.await
.context("getnetworkinfo")
.ok();
let index_info = bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([]))
.await
.context("getindexinfo")
.ok();
let zmq_notifications = bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([]))
.await
.context("getzmqnotifications")
.ok();
// Fetch all four calls concurrently: getblockchaininfo gates freshness, so a
// slow auxiliary call (network/index/zmq) must not delay the snapshot or block
// the next refresh. Only getblockchaininfo failing marks the status stale.
let (blockchain_info, network_info, index_info, zmq_notifications) = tokio::join!(
bitcoin_rpc_call(&client, "getblockchaininfo", serde_json::json!([])),
bitcoin_rpc_call(&client, "getnetworkinfo", serde_json::json!([])),
bitcoin_rpc_call(&client, "getindexinfo", serde_json::json!([])),
bitcoin_rpc_call(&client, "getzmqnotifications", serde_json::json!([])),
);
let blockchain_info = blockchain_info.context("getblockchaininfo")?;
Ok(BitcoinNodeStatus {
ok: true,
stale: false,
updated_at_ms: now_ms(),
age_ms: 0,
error: None,
blockchain_info: Some(blockchain_info),
network_info,
index_info,
zmq_notifications,
network_info: network_info.ok(),
index_info: index_info.ok(),
zmq_notifications: zmq_notifications.ok(),
})
}
+7
View File
@@ -25,6 +25,12 @@ pub const MAX_BLOB_SIZE: u64 = 64 * 1024 * 1024;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlobMeta {
pub cid: String,
/// DHT Phase 1: BLAKE3 hash of the content (iroh-native swarm address).
/// The on-disk path stays SHA-256-keyed (`cid`) for back-compat; this
/// advertises the hash a peer swarm can fetch/range-verify by. Absent in
/// legacy metadata written before Phase 1.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
pub size: u64,
pub mime: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -88,6 +94,7 @@ impl BlobStore {
let cid = hex::encode(hasher.finalize());
let meta = BlobMeta {
cid: cid.clone(),
blake3: Some(crate::content_hash::blake3_hex(bytes)),
size: bytes.len() as u64,
mime: mime.to_string(),
filename,
+225 -4
View File
@@ -30,8 +30,22 @@ const DOCTOR_SH_PATH: &str = "/home/archipelago/archy/scripts/container-doctor.s
const DOCTOR_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-doctor.service";
const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
// Kiosk hardening (#36): keep the deployed unit + launcher in sync with the
// repo so the CPU/memory cap and the GPU-vs-headless flag selection reach
// already-installed nodes via OTA, not just fresh ISOs.
const KIOSK_SERVICE: &str = include_str!("../../../image-recipe/configs/archipelago-kiosk.service");
const KIOSK_LAUNCHER: &str =
include_str!("../../../image-recipe/configs/archipelago-kiosk-launcher.sh");
const KIOSK_SERVICE_PATH: &str = "/etc/systemd/system/archipelago-kiosk.service";
const KIOSK_LAUNCHER_PATH: &str = "/usr/local/bin/archipelago-kiosk-launcher";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
/// HTTP-only nodes, in which case the bootstrap loop skips it.
const NGINX_HTTPS_SNIPPET_PATH: &str = "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
@@ -41,6 +55,41 @@ const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backe
const NGINX_BITCOIN_STATUS_BLOCK: &str = "\n location /bitcoin-status {\n proxy_pass http://127.0.0.1:5678/bitcoin-status;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block that lacks the `/proxy/lnd/` proxy. Nodes
/// flashed before 2026-04-10 shipped an nginx config without this block, so the
/// browser's wallet fetches to `/proxy/lnd/*` fell through to the SPA
/// index.html and got HTML back instead of JSON ("failing to fetch"). Kept in
/// sync with the canonical block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles auth + CORS\n location /proxy/lnd/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_connect_timeout 10s;\n proxy_read_timeout 10s;\n proxy_send_timeout 5s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// Inserted into every server block lacking the peer-content streaming proxy.
/// Without it, the browser's `<video>`/`<audio>` Range requests to
/// `/api/peer-content/*` fall through to the SPA index.html (HTML, no Range)
/// and peer media won't play (B3). Forwards Cookie (session auth) + Range and
/// disables buffering so streaming works. Kept in sync with the canonical
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\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 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
/// nodes converge to the same config fresh ISOs ship with.
const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';\n }\n location /app/fedimint-gateway/ {";
/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
/// at all (older than the main conf's), and the directive that follows it varies
/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
/// proxying there) and insert the reroot set right after it — directive order
/// inside a location block is irrelevant to nginx. Idempotent via the same
/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '</head>' '<script src=\"/nostr-provider.js\"></script></head>';";
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -476,6 +525,92 @@ async fn write_root_if_needed(path: &str, content: &str) -> Result<bool> {
Ok(true)
}
const ARCHIPELAGO_SERVICE_PATH: &str = "/etc/systemd/system/archipelago.service";
const MOUNT_REQUIRE_LINE: &str = "RequiresMountsFor=/var/lib/archipelago";
/// B17 self-heal: ensure the installed archipelago.service waits for the data
/// volume to mount before it starts. On production nodes `/var/lib/archipelago`
/// (the app data dir AND podman's graphroot) is a separate device-mapper volume;
/// without a mount dependency the service can start before `var-lib-archipelago.mount`,
/// write to the bare mountpoint on rootfs, fail every podman call, exit, and be
/// restarted every 5s until the volume mounts (~5 min of "[FAILED] Failed to start"
/// on cold boots). Fresh ISOs already ship the directive; this heals already-deployed
/// nodes. The change is boot-ordering only — it takes effect on the NEXT reboot, so we
/// never restart the running service here. Idempotent; no-op if the unit is absent
/// (dev runs) or already patched. Harmless when the data dir is on rootfs (systemd maps
/// the requirement to the always-mounted root).
pub async fn ensure_archipelago_mount_ordering() {
let current = match fs::read_to_string(ARCHIPELAGO_SERVICE_PATH).await {
Ok(c) => c,
Err(e) => {
tracing::debug!(
"mount-ordering self-heal: {} not readable ({}) — skipping",
ARCHIPELAGO_SERVICE_PATH,
e
);
return;
}
};
if current.contains(MOUNT_REQUIRE_LINE) {
return; // already healed
}
// Insert the directive into the [Unit] section, immediately before [Service].
let Some(idx) = current.find("\n[Service]") else {
tracing::warn!(
"mount-ordering self-heal: no [Service] section in {} — skipping",
ARCHIPELAGO_SERVICE_PATH
);
return;
};
let mut patched = String::with_capacity(current.len() + MOUNT_REQUIRE_LINE.len() + 96);
patched.push_str(&current[..idx]);
patched.push_str("\n# B17: start only after the data volume (+ podman graphroot) is mounted\n");
patched.push_str(MOUNT_REQUIRE_LINE);
patched.push_str(&current[idx..]);
match write_root_if_needed(ARCHIPELAGO_SERVICE_PATH, &patched).await {
Ok(true) => {
info!(
"B17: added '{}' to archipelago.service (effective next reboot)",
MOUNT_REQUIRE_LINE
);
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
tracing::warn!("B17 self-heal: daemon-reload failed: {:#}", e);
}
}
Ok(false) => {}
Err(e) => tracing::warn!("B17 mount-ordering self-heal failed: {:#}", e),
}
}
/// #36 self-heal: keep the kiosk unit + launcher current on already-deployed
/// nodes so the CPU/memory cap (a runaway chromium was saturating the node and
/// starving the backend) and the GPU-vs-headless flag selection arrive via OTA.
/// No-op on nodes without the kiosk installed; only restarts the kiosk if it's
/// actually running (so it never re-enables an operator-disabled kiosk).
pub async fn ensure_kiosk_hardened() {
if fs::metadata(KIOSK_SERVICE_PATH).await.is_err() {
return; // kiosk not installed on this node
}
let svc_changed = write_root_if_needed(KIOSK_SERVICE_PATH, KIOSK_SERVICE)
.await
.unwrap_or(false);
let launcher_changed = write_root_if_needed(KIOSK_LAUNCHER_PATH, KIOSK_LAUNCHER)
.await
.unwrap_or(false);
if launcher_changed {
let _ = host_sudo(&["chmod", "+x", KIOSK_LAUNCHER_PATH]).await;
}
if svc_changed || launcher_changed {
if let Err(e) = host_sudo(&["systemctl", "daemon-reload"]).await {
warn!("kiosk hardening: daemon-reload failed: {:#}", e);
}
// try-restart only restarts a currently-active unit — leaves a stopped/
// disabled kiosk alone.
let _ = host_sudo(&["systemctl", "try-restart", "archipelago-kiosk.service"]).await;
info!("kiosk: applied resource cap + GPU-flag hardening (#36)");
}
}
/// Patch the nginx site config to add missing backend proxy blocks. Older ISO
/// configs shipped individual per-endpoint `location` blocks, so missing
/// endpoints silently fell through to the SPA `index.html` and the frontend
@@ -496,7 +631,11 @@ async fn run_nginx() -> Result<bool> {
let mut changed = false;
let mut patched_paths = Vec::<PathBuf>::new();
for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
for path in [
NGINX_CONF_PATH,
NGINX_ENABLED_CONF_PATH,
NGINX_HTTPS_SNIPPET_PATH,
] {
let candidate = Path::new(path);
if !candidate.exists() {
debug!("{} missing — skipping nginx bootstrap", path);
@@ -514,18 +653,100 @@ async fn run_nginx() -> Result<bool> {
Ok(changed)
}
/// Reflective CORS add_headers that older configs placed inside the
/// `/lnd-connect-info` location. The backend now sets a validated
/// `Access-Control-Allow-Origin` for that endpoint (api/handler/proxy.rs), so
/// leaving these in nginx emits a DUPLICATE header ("contains multiple values
/// … but only one is allowed") and the LND wallet UI's cross-origin fetch is
/// rejected. Stripped during nginx bootstrap so the backend solely owns CORS.
const NGINX_LND_DUP_CORS: &str = " add_header Access-Control-Allow-Origin $http_origin always;\n add_header Access-Control-Allow-Credentials \"true\" always;\n";
async fn patch_nginx_conf(path: &str) -> Result<bool> {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("read {}", path))?;
let missing_app_catalog = !content.contains("location /api/app-catalog");
let missing_bitcoin_status = !content.contains("location /bitcoin-status");
if !missing_app_catalog && !missing_bitcoin_status {
// Each "missing" flag is gated on the splice anchor actually being present,
// so an included snippet that legitimately has none of these endpoints (the
// HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
// every boot — it falls through to the fedimint heal alone.
let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
|| content.contains(" location /electrs-status {");
let missing_app_catalog = content
.contains(" # DWN endpoints — peer access over Tor (no auth)")
&& !content.contains("location /api/app-catalog");
let missing_bitcoin_status = content.contains(" location /electrs-status {")
&& !content.contains("location /bitcoin-status");
let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
// B13: fedimint block present but lacking the asset-rewrite sub_filters.
let needs_fedimint_css = content.contains("location /app/fedimint/")
&& !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
&& !has_lnd_dup_cors
&& !needs_fedimint_css
{
return Ok(false);
}
let mut patched = content.clone();
if has_lnd_dup_cors {
// Drop the redundant nginx-side CORS headers so the backend's single
// validated Access-Control-Allow-Origin is the only one returned.
patched = patched.replace(NGINX_LND_DUP_CORS, "");
}
if needs_fedimint_css {
// Style A (main conf): the block already injects nostr-provider, so swap
// its single-sub_filter tail for the full asset-rewrite set. No-op if the
// node's fedimint block doesn't match OLD.
patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
// Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
// so insert the reroot set after the unique :8175 proxy_pass. Guarded on
// the marker so it can never double-apply after Style A already healed.
if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
patched = patched.replace(NGINX_FEDIMINT_SNIPPET_ANCHOR, NGINX_FEDIMINT_SNIPPET_INSERT);
}
}
if missing_lnd_proxy {
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
// back to `/electrs-status` (since 2026-03-08) for even older configs.
// Both appear once per archipelago server block, so the block is added
// to every server block that proxies to the backend.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if !patched.contains(anchor) {
warn!("nginx conf missing lnd-connect-info/electrs-status anchor — skipping /proxy/lnd patch");
} else {
let replacement = format!("{}{}", NGINX_LND_PROXY_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
}
}
if missing_peer_content {
// Same anchoring as the LND proxy: prepend the block to every server
// block so /api/peer-content/* reaches the backend instead of the SPA.
let anchor = if patched.contains(" location /lnd-connect-info {") {
" location /lnd-connect-info {"
} else {
" location /electrs-status {"
};
if patched.contains(anchor) {
let replacement = format!("{}{}", NGINX_PEER_CONTENT_BLOCK, anchor);
patched = patched.replace(anchor, &replacement);
} else {
warn!("nginx conf missing anchor — skipping /api/peer-content patch");
}
}
if missing_bitcoin_status {
let anchor = " location /electrs-status {";
if !patched.contains(anchor) {
+141
View File
@@ -0,0 +1,141 @@
//! Release-root signing ceremony — the publisher-side counterpart to
//! `trust::anchor`. Run as a subcommand of the same binary so it reuses the
//! exact key derivation (`seed::derive_release_root_ed25519`) and canonical
//! signing (`trust::signed_doc::sign_detached`) the fleet verifies against.
//!
//! Usage (the mnemonic is read from the `RELEASE_MASTER_MNEMONIC` env var or
//! stdin — never an argv so it stays out of shell history / `ps`):
//!
//! ```text
//! archipelago ceremony gen
//! Generate a fresh 24-word release master mnemonic and print it plus the
//! derived release-root pubkey + did. Back the mnemonic up OFFLINE.
//!
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony pubkey
//! Print the release-root pubkey hex (for ARCHY_RELEASE_ROOT_PUBKEY /
//! trust::anchor::RELEASE_ROOT_PUBKEY_HEX) and the signer did:key.
//!
//! RELEASE_MASTER_MNEMONIC="word1 …" archipelago ceremony sign <file.json>
//! Sign a JSON document (e.g. releases/app-catalog.json) in place: insert
//! `signature` + `signed_by` over the canonical form, matching exactly
//! what `trust::verify_detached` recomputes on every node.
//! ```
use anyhow::{bail, Context, Result};
use ed25519_dalek::SigningKey;
use crate::seed::{self, MasterSeed};
use crate::trust::{did, signed_doc};
const ENV_MNEMONIC: &str = "RELEASE_MASTER_MNEMONIC";
/// True if argv selects the ceremony subcommand. Checked before any server init.
pub fn is_ceremony_invocation() -> bool {
std::env::args().nth(1).as_deref() == Some("ceremony")
}
/// Entry point for `archipelago ceremony …`. Returns Ok(()) on success; the
/// caller (main) should exit without starting the server.
pub fn run() -> Result<()> {
let sub = std::env::args().nth(2).unwrap_or_default();
match sub.as_str() {
"gen" => cmd_gen(),
"pubkey" => cmd_pubkey(),
"sign" => {
let file = std::env::args()
.nth(3)
.context("usage: archipelago ceremony sign <file.json>")?;
cmd_sign(&file)
}
other => {
bail!(
"unknown ceremony subcommand {:?}; expected gen | pubkey | sign <file>",
other
)
}
}
}
fn cmd_gen() -> Result<()> {
let (mnemonic, seed) = MasterSeed::generate().context("generate mnemonic")?;
let key = seed::derive_release_root_ed25519(&seed).context("derive release-root")?;
eprintln!("⚠ Back this mnemonic up OFFLINE. It is the ONLY way to re-derive");
eprintln!(" the release-root signing key. Anyone with it can sign for the fleet.\n");
println!("RELEASE_MASTER_MNEMONIC=\"{}\"", mnemonic);
print_key(&key);
Ok(())
}
fn cmd_pubkey() -> Result<()> {
let key = load_release_root_key()?;
print_key(&key);
Ok(())
}
fn cmd_sign(path: &str) -> Result<()> {
let key = load_release_root_key()?;
let body = std::fs::read_to_string(path).with_context(|| format!("read {path}"))?;
let mut value: serde_json::Value =
serde_json::from_str(&body).with_context(|| format!("parse {path} as JSON"))?;
{
let obj = value
.as_object_mut()
.context("document root must be a JSON object")?;
// Re-sign cleanly: drop any prior signature so the preimage matches.
obj.remove("signature");
obj.remove("signed_by");
}
let (signature, signed_by) =
signed_doc::sign_detached(&key, &value).context("sign document")?;
let obj = value.as_object_mut().expect("checked above");
obj.insert("signature".into(), serde_json::Value::String(signature));
obj.insert(
"signed_by".into(),
serde_json::Value::String(signed_by.clone()),
);
let pretty = serde_json::to_string_pretty(&value).context("serialize signed document")?;
let tmp = format!("{path}.tmp");
std::fs::write(&tmp, format!("{pretty}\n")).with_context(|| format!("write {tmp}"))?;
std::fs::rename(&tmp, path).with_context(|| format!("rename {tmp} -> {path}"))?;
eprintln!("✓ signed {path}");
eprintln!(" signed_by: {signed_by}");
Ok(())
}
/// Derive the release-root signing key from the mnemonic in env/stdin.
fn load_release_root_key() -> Result<SigningKey> {
let phrase = read_mnemonic()?;
let (_mnemonic, seed) = MasterSeed::from_mnemonic_words(phrase.trim())
.context("invalid release master mnemonic")?;
seed::derive_release_root_ed25519(&seed).context("derive release-root")
}
/// Read the mnemonic from `RELEASE_MASTER_MNEMONIC` or, if unset, stdin.
fn read_mnemonic() -> Result<String> {
if let Ok(v) = std::env::var(ENV_MNEMONIC) {
if !v.trim().is_empty() {
return Ok(v);
}
}
use std::io::Read;
eprintln!("Paste the release master mnemonic, then Ctrl-D:");
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("read mnemonic from stdin")?;
if buf.trim().is_empty() {
bail!("no mnemonic provided (set {ENV_MNEMONIC} or pipe it on stdin)");
}
Ok(buf)
}
fn print_key(key: &SigningKey) {
let vk = key.verifying_key();
println!("RELEASE_ROOT_PUBKEY_HEX={}", hex::encode(vk.to_bytes()));
println!("signed_by_did={}", did::did_key_for_ed25519(&vk));
}
+14
View File
@@ -70,6 +70,13 @@ pub struct Config {
/// on .228 + .198. See `project_v1_7_52_phase3_quadlet_design`.
#[serde(default)]
pub use_quadlet_backends: bool,
/// DHT swarm-assist (Phase 3): when true AND the binary was built with the
/// `iroh-swarm` feature, stand up an iroh-blobs provider that fetches release
/// blobs peer-to-peer (origin always wins) and seeds them via signed Nostr
/// adverts. Off by default; with the feature absent this is inert. Reuses
/// `nostr_relays` + `nostr_tor_proxy` for discovery transport.
#[serde(default)]
pub swarm_enabled: bool,
}
impl Config {
@@ -182,6 +189,12 @@ impl Config {
config.nostr_tor_proxy = if s.is_empty() { None } else { Some(s) };
}
// DHT swarm-assist (Phase 3). Opt-in: only takes effect when the binary
// was also built with the `iroh-swarm` feature; otherwise inert.
if let Ok(v) = std::env::var("ARCHIPELAGO_SWARM_ENABLED") {
config.swarm_enabled = parse_truthy_env(&v);
}
// Phase 3.2 of v1.7.52. Truthy values (1, true, yes, on — case-insensitive)
// route backend installs through the Quadlet path without requiring a
// config.json edit + archipelago.service restart (which would trigger
@@ -241,6 +254,7 @@ impl Default for Config {
],
nostr_tor_proxy: Some("127.0.0.1:9050".into()),
use_quadlet_backends: false,
swarm_enabled: false,
}
}
}
@@ -0,0 +1,372 @@
//! 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)?;
// DHT Phase 0 authenticity: verify the release-root signature when present.
// We verify against the raw JSON (the exact bytes the publisher signed),
// not a re-serialization of the typed struct, so unknown forward-compat
// fields stay part of the signed preimage. Unsigned catalogs are still
// accepted during the migration window — same trust level as today's
// manifest — but a *present* signature that fails is a hard reject so a
// tampering mirror cannot pass off altered bytes.
let raw: serde_json::Value = serde_json::from_str(&body)?;
match crate::trust::verify_detached(&raw)? {
crate::trust::SignatureStatus::Unsigned => {
debug!("app-catalog: unsigned (accepted during migration window)");
}
crate::trust::SignatureStatus::Verified {
signer_did,
anchored,
} => {
if anchored {
info!(
"app-catalog: release-root signature verified ({})",
signer_did
);
} else {
warn!(
"app-catalog: signature self-consistent but release-root anchor \
not pinned ({}); cannot confirm signer identity",
signer_did
);
}
}
}
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()
]
);
}
}
+15 -5
View File
@@ -102,8 +102,15 @@ const LND_UI: &[CompanionSpec] = &[CompanionSpec {
],
pre_start: None,
bind_mounts: &[],
ports: &[(18083, 80)],
host_network: false,
// Host networking so the app's own nginx can proxy the archipelago backend
// same-origin (127.0.0.1:5678), exactly like fips-ui / electrs-ui. The
// previous bridge + 18083→80 mapping forced the browser to fetch the
// backend cross-origin from the app's port, which depended on the host
// nginx route + a CORS Origin/Host match and broke on http-only nodes
// (e.g. .116: blank fields, QR "failed to fetch"). The app's nginx now
// listens on 18083 directly (NOT 80 — that would collide with host nginx).
ports: &[],
host_network: true,
}];
const ELECTRS_UI: &[CompanionSpec] = &[CompanionSpec {
@@ -439,12 +446,15 @@ mod tests {
}
#[test]
fn lnd_ui_uses_port_mapping_not_host_port_80() {
fn lnd_ui_uses_host_network_for_same_origin_backend_proxy() {
// lnd-ui is host-networked (its nginx listens on 18083 directly) so the
// app can proxy the archipelago backend same-origin instead of fetching
// it cross-origin from its app port — see the spec comment for why.
let spec = &LND_UI[0];
let u = build_unit(spec, "localhost/lnd-ui:latest");
assert_eq!(u.name, "archy-lnd-ui");
assert!(matches!(u.network, NetworkMode::Bridge(ref n) if n == "bridge"));
assert_eq!(u.ports, vec![(18083, 80, "tcp".into())]);
assert!(matches!(u.network, NetworkMode::Host));
assert!(u.ports.is_empty());
}
#[test]
@@ -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(),
@@ -363,6 +365,13 @@ fn get_app_metadata(app_id: &str) -> AppMetadata {
repo: "https://github.com/fedimint/fedimint".to_string(),
tier: "",
},
"fedimint-clientd" | "fmcd" => AppMetadata {
title: "Fedimint Client".to_string(),
description: "Fedimint ecash client daemon (fmcd) — lets your node hold Fedimint ecash and join federations".to_string(),
icon: "/assets/img/app-icons/fedimint.png".to_string(),
repo: "https://github.com/minmoto/fmcd".to_string(),
tier: "",
},
"morphos" | "morphos-server" => AppMetadata {
title: "Morphos".to_string(),
description: "Self-hosted file converter".to_string(),
@@ -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))
+345 -56
View File
@@ -11,7 +11,16 @@ use crate::update::host_sudo;
pub const DEFAULT_DATA_DIR: &str = "/var/lib/archipelago/lnd";
pub const DEFAULT_CONF_PATH: &str = "/var/lib/archipelago/lnd/lnd.conf";
const LND_REST_BASE_URL: &str = "https://127.0.0.1:18080";
pub const WALLET_PASSWORD: &str = "hellohello";
/// Per-node LND wallet password file (random, 0600). Replaces the old
/// fleet-wide hardcoded constant: each node's wallet password is now unique,
/// high-entropy, and recorded here so the unattended boot path can auto-unlock.
const WALLET_PASSWORD_SECRET: &str = "/var/lib/archipelago/secrets/lnd-wallet-password";
/// Legacy fleet-wide wallet password (builds that hardcoded it). Kept ONLY as an
/// unlock fallback so wallets created by those builds still open; new wallets
/// never use it, and the login-path migration rotates away from it.
const LEGACY_WALLET_PASSWORD: &str = "hellohello";
#[derive(Debug, Clone)]
pub struct EnsurePaths {
@@ -79,15 +88,125 @@ pub async fn ensure_wallet_initialized() -> Result<()> {
if file_exists_as_root(admin_macaroon).await && lnd_getinfo_ready(admin_macaroon).await {
return Ok(());
}
unlock_existing_wallet().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
match unlock_existing_wallet().await? {
true => {
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
false => {
// Every candidate password was actively rejected: this wallet was
// created with a password this node no longer has, so it can never
// auto-unlock unattended. Alpha nodes hold no real funds and a wallet
// locked with an unknown password is already inaccessible, so wipe +
// recreate it on the per-node secret to self-heal at boot.
recreate_wallet_destructively().await?;
wait_for_admin_macaroon(admin_macaroon).await?;
return Ok(());
}
}
}
init_wallet_via_rest().await?;
wait_for_admin_macaroon(admin_macaroon).await
}
/// LND data subdirectories holding wallet + channel + graph state. Removing them
/// returns LND to a NON_EXISTING wallet state. Funds-bearing data lives here too,
/// so deletion is destructive — only done once the wallet is already unrecoverable.
const LND_STATE_DIRS: &[&str] = &[
"/var/lib/archipelago/lnd/data/chain",
"/var/lib/archipelago/lnd/data/graph",
];
/// Podman container name for the core LND app (see `compute_container_name`:
/// non-UI core apps keep their bare id). LND runs as a plain bridge-network
/// container, not a Quadlet unit, so it is restarted via `podman`, not systemctl.
const LND_CONTAINER: &str = "lnd";
/// Archipelago data dir (default; not overridden in prod). Holds the
/// `user-stopped.json` that gates health-monitor auto-restart.
const ARCHY_DATA_DIR: &str = "/var/lib/archipelago";
/// Destroy an unrecoverable LND wallet and recreate a fresh one keyed to the
/// per-node secret. Suppresses health-monitor auto-restart for the wipe window,
/// stops LND, deletes its wallet/chain/graph state as root, restarts it, waits
/// for NON_EXISTING, then inits a fresh wallet. Destructive — only called when no
/// candidate password can open the existing wallet.
async fn recreate_wallet_destructively() -> Result<()> {
tracing::warn!(
"[lnd] wallet is locked with an unknown password and cannot auto-unlock; \
wiping and recreating it on the per-node secret (DESTRUCTIVE)"
);
// The health monitor restarts any container it sees stopped; mark LND
// user-stopped so it doesn't re-launch (and re-open the wallet) mid-wipe.
// Always cleared below so LND auto-recovers normally afterwards.
let data_dir = std::path::Path::new(ARCHY_DATA_DIR);
crate::crash_recovery::mark_user_stopped(data_dir, LND_CONTAINER).await;
let result = wipe_and_reinit_wallet().await;
crate::crash_recovery::clear_user_stopped(data_dir, LND_CONTAINER).await;
result
}
async fn wipe_and_reinit_wallet() -> Result<()> {
podman_user_scoped(&["stop", LND_CONTAINER])
.await
.context("stopping lnd before wallet wipe")?;
for dir in LND_STATE_DIRS {
let status = host_sudo(&["rm", "-rf", dir])
.await
.with_context(|| format!("removing {dir}"))?;
if !status.success() {
anyhow::bail!("removing {dir} exited with {status}");
}
}
podman_user_scoped(&["start", LND_CONTAINER])
.await
.context("restarting lnd after wallet wipe")?;
wait_for_wallet_state("NON_EXISTING").await?;
init_wallet_via_rest().await
}
/// Run `podman <args>` inside a transient `systemd-run --user --scope`, matching
/// how the orchestrator/health-monitor manage rootless containers (keeps the
/// container out of the archipelago service's cgroup).
async fn podman_user_scoped(args: &[&str]) -> Result<()> {
let out = tokio::process::Command::new("systemd-run")
.args(["--user", "--scope", "--quiet", "--collect", "podman"])
.args(args)
.output()
.await
.with_context(|| format!("systemd-run --user --scope podman {}", args.join(" ")))?;
if !out.status.success() {
anyhow::bail!(
"podman {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
/// Poll `/v1/state` until LND reports `target`, or time out after ~120s.
async fn wait_for_wallet_state(target: &str) -> Result<()> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(5))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
for _ in 0..120 {
if wallet_state(&client).await.as_deref() == Some(target) {
return Ok(());
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND did not reach state {target} after wallet wipe")
}
async fn file_exists_as_root(path: &str) -> bool {
if std::path::Path::new(path).exists() {
return true;
@@ -121,11 +240,96 @@ async fn read_file_as_root(path: &str) -> Result<Vec<u8>> {
}
}
async fn unlock_existing_wallet() -> Result<()> {
/// Read the per-node wallet password from the secrets file, if present.
/// Never generates one — absence means "fall back to legacy / not set yet".
async fn read_wallet_password() -> Option<String> {
let bytes = fs::read(WALLET_PASSWORD_SECRET).await.ok()?;
let pw = String::from_utf8_lossy(&bytes).trim().to_string();
(!pw.is_empty()).then_some(pw)
}
/// Return the per-node wallet password, generating and persisting a fresh
/// 256-bit one (base64, 0600) if none exists. Use ONLY when creating a NEW
/// wallet — calling it merely to unlock an existing wallet would record a
/// password that doesn't match it.
pub(crate) async fn ensure_wallet_password() -> Result<String> {
if let Some(pw) = read_wallet_password().await {
return Ok(pw);
}
use rand::RngCore;
let mut raw = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut raw);
let pw = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
let path = std::path::Path::new(WALLET_PASSWORD_SECRET);
if let Some(dir) = path.parent() {
fs::create_dir_all(dir)
.await
.with_context(|| format!("creating {}", dir.display()))?;
}
fs::write(path, &pw)
.await
.with_context(|| format!("writing {WALLET_PASSWORD_SECRET}"))?;
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).await;
Ok(pw)
}
/// Candidate passwords to try when unlocking an EXISTING wallet, in order: the
/// per-node secret (current scheme) first, then the legacy constant so wallets
/// created by older builds still open.
async fn unlock_password_candidates() -> Vec<String> {
let mut v = Vec::new();
if let Some(pw) = read_wallet_password().await {
v.push(pw);
}
v.push(LEGACY_WALLET_PASSWORD.to_string());
v
}
/// Outcome of a single unlock attempt — lets the caller fail fast on a wrong
/// password (no point retrying) vs keep waiting for LND to come up.
enum UnlockAttempt {
Unlocked,
WrongPassword,
NotReady,
}
/// One unlock POST, no internal retry. Distinguishes "invalid passphrase"
/// (WrongPassword — try the next candidate, don't retry) from transient
/// not-ready / connection errors (NotReady — worth retrying).
async fn try_unlock_once(client: &reqwest::Client, password: &str) -> UnlockAttempt {
let body = serde_json::json!({
"wallet_password": base64::engine::general_purpose::STANDARD.encode(password)
});
match client
.post(format!("{LND_REST_BASE_URL}/v1/unlockwallet"))
.json(&body)
.send()
.await
{
Ok(resp) => {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() || text.contains("already unlocked") {
UnlockAttempt::Unlocked
} else if text.contains("invalid passphrase") {
UnlockAttempt::WrongPassword
} else {
UnlockAttempt::NotReady
}
}
Err(_) => UnlockAttempt::NotReady,
}
}
/// Unlock an existing wallet. Ok(true) = unlocked; Ok(false) = every candidate
/// password was actively rejected (unrecoverable — caller should recreate);
/// Err = transient (LND not ready / timeout — caller should retry, NOT wipe).
async fn unlock_existing_wallet() -> Result<bool> {
unlock_existing_wallet_via_rest().await
}
async fn unlock_existing_wallet_via_rest() -> Result<()> {
async fn unlock_existing_wallet_via_rest() -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
@@ -133,57 +337,130 @@ async fn unlock_existing_wallet_via_rest() -> Result<()> {
.build()
.context("building LND REST client")?;
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
match post_lnd_unlocker_json::<serde_json::Value>(
&client,
"/v1/unlockwallet",
serde_json::json!({ "wallet_password": wallet_password }),
)
.await
.context("unlocking existing LND wallet")?
{
UnlockerResponse::Value(_) | UnlockerResponse::WalletAlreadyExists => Ok(()),
let candidates = unlock_password_candidates().await;
// Retry only while LND's unlocker isn't ready yet. If every candidate is
// *actively rejected* (invalid passphrase), retrying can't help — fail fast
// with a clear message instead of hanging the boot path for 60s+ (the wallet
// was created with a password this node doesn't have → migration/recovery).
for _ in 0..60 {
let mut all_rejected = true;
for pw in &candidates {
match try_unlock_once(&client, pw).await {
UnlockAttempt::Unlocked => return Ok(true),
UnlockAttempt::WrongPassword => {}
UnlockAttempt::NotReady => all_rejected = false,
}
}
if all_rejected {
tracing::warn!(
"[lnd] none of the {} candidate password(s) unlock the wallet — it was created \
with a password this node does not have",
candidates.len()
);
return Ok(false);
}
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
anyhow::bail!("LND wallet unlock timed out waiting for the unlocker to become ready")
}
/// Current LND wallet state via the unauthenticated `/v1/state` endpoint
/// (NON_EXISTING / LOCKED / UNLOCKED / RPC_ACTIVE / …). None if unreachable.
async fn wallet_state(client: &reqwest::Client) -> Option<String> {
let resp = client
.get(format!("{LND_REST_BASE_URL}/v1/state"))
.send()
.await
.ok()?;
let v: serde_json::Value = resp.json().await.ok()?;
v.get("state")
.and_then(|s| s.as_str())
.map(|s| s.to_string())
}
/// ChangePassword via WalletUnlocker (wallet must be LOCKED). Both passwords are
/// base64-encoded. Ok(true) = current accepted and rotated; Ok(false) = current
/// rejected (wrong password — try the next candidate); Err = transport/other.
async fn change_wallet_password(
client: &reqwest::Client,
current: &str,
new: &str,
) -> Result<bool> {
let body = serde_json::json!({
"current_password": base64::engine::general_purpose::STANDARD.encode(current),
"new_password": base64::engine::general_purpose::STANDARD.encode(new),
});
let resp = client
.post(format!("{LND_REST_BASE_URL}/v1/changepassword"))
.json(&body)
.send()
.await
.context("calling LND changepassword")?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if status.is_success() {
Ok(true)
} else if text.contains("invalid passphrase") {
Ok(false)
} else {
anyhow::bail!("LND changepassword returned {status}: {text}")
}
}
#[allow(dead_code)]
async fn unlock_existing_wallet_via_lncli() -> Result<()> {
let mut last_err = None;
for _ in 0..60 {
let mut cmd = tokio::process::Command::new("podman");
cmd.args(["exec", "-i", "lnd", "lncli", "unlock", "--stdin"]);
cmd.stdin(std::process::Stdio::piped());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
/// Best-effort migration of a LOCKED wallet onto the per-node secret. Called at
/// login, when the onboarding password is available as a candidate. If the
/// per-node secret already opens the wallet, just unlock. Otherwise try each
/// candidate as the CURRENT password and ChangePassword it to a fresh per-node
/// secret so all future boots auto-unlock. Ok(true) = healed/unlocked;
/// Ok(false) = not locked, or no candidate worked (seed-recovery required).
pub(crate) async fn migrate_locked_wallet(candidates: &[String]) -> Result<bool> {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(std::time::Duration::from_secs(20))
.danger_accept_invalid_certs(true)
.build()
.context("building LND REST client")?;
let mut child = cmd.spawn().context("spawning lncli wallet unlock")?;
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
stdin
.write_all(format!("{}\n", WALLET_PASSWORD).as_bytes())
.await
.context("writing lncli password")?;
}
let out = child
.wait_with_output()
.await
.context("waiting for lncli")?;
if out.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let msg = format!("{stderr}{stdout}");
if msg.contains("wallet already unlocked") || msg.contains("already unlocked") {
return Ok(());
}
last_err = Some(msg);
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
// Only act on a wallet that is actually LOCKED.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(false);
}
anyhow::bail!(
"lncli wallet unlock failed: {}",
last_err.unwrap_or_else(|| "unknown error".to_string())
)
// If the per-node secret already opens it, nothing to rotate — just unlock.
if let Some(secret) = read_wallet_password().await {
if matches!(
try_unlock_once(&client, &secret).await,
UnlockAttempt::Unlocked
) {
return Ok(true);
}
}
// The wallet's new password becomes the per-node secret (generate if absent).
let new_secret = ensure_wallet_password().await?;
// ChangePassword requires LOCKED; bail out if a prior step already unlocked.
if wallet_state(&client).await.as_deref() != Some("LOCKED") {
return Ok(true);
}
for cand in candidates {
if cand.is_empty() || *cand == new_secret {
continue;
}
match change_wallet_password(&client, cand, &new_secret).await {
Ok(true) => {
tracing::info!("[lnd-migrate] rotated locked wallet onto the per-node secret");
return Ok(true);
}
Ok(false) => continue, // wrong current password — try next candidate
Err(e) => tracing::debug!("[lnd-migrate] changepassword error: {e}"),
}
}
tracing::warn!(
"[lnd-migrate] no candidate password opened the wallet — seed-recovery required"
);
Ok(false)
}
#[derive(Debug, Deserialize)]
@@ -225,7 +502,8 @@ async fn init_wallet_via_rest() -> Result<()> {
anyhow::bail!("LND genseed returned no seed words");
}
let wallet_password = base64::engine::general_purpose::STANDARD.encode(WALLET_PASSWORD);
let wallet_password =
base64::engine::general_purpose::STANDARD.encode(ensure_wallet_password().await?);
let req = InitWalletRequest {
wallet_password,
cipher_seed_mnemonic: seed.cipher_seed_mnemonic,
@@ -239,7 +517,9 @@ async fn init_wallet_via_rest() -> Result<()> {
.context("initializing LND wallet")?
{
UnlockerResponse::Value(_) => {}
UnlockerResponse::WalletAlreadyExists => unlock_existing_wallet().await?,
UnlockerResponse::WalletAlreadyExists => {
unlock_existing_wallet().await?;
}
}
Ok(())
@@ -450,7 +730,16 @@ mod tests {
}
#[test]
fn wallet_password_is_valid_for_lncli() {
assert!(WALLET_PASSWORD.len() > 8);
fn legacy_wallet_password_is_valid_for_lncli() {
// Legacy fallback must still be a valid lncli passphrase (>8 chars).
assert!(LEGACY_WALLET_PASSWORD.len() > 8);
}
#[tokio::test]
async fn unlock_candidates_always_include_legacy_fallback() {
// With no per-node secret on disk in the test env, candidates fall back
// to the legacy constant so old wallets still open.
let cands = unlock_password_candidates().await;
assert!(cands.iter().any(|p| p == LEGACY_WALLET_PASSWORD));
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod app_catalog;
pub mod bitcoin_ui;
pub mod boot_reconciler;
pub mod companion;
@@ -70,6 +70,10 @@ fn is_required_baseline_app(app_id: &str) -> bool {
| "mempool"
| "archy-mempool-db"
| "filebrowser"
// fmcd: bundled on every node so the wallet's Fedimint side works
// out of the box (auto-joins the default federation). Self-heals if
// removed, like the other baseline services.
| "fedimint-clientd"
)
}
@@ -176,6 +180,70 @@ pub fn compute_container_name(manifest: &AppManifest) -> String {
}
}
/// Fingerprint a local build context so a changed source tree (e.g. a rebuilt
/// `neode-ui` dist copied into `docker/<ui>/`) forces an image rebuild even
/// when the image tag already exists (#34). Walks the context directory and
/// hashes each file's relative path, length, and mtime.
///
/// Metadata-only by design: it's cheap enough to recompute on every reconcile,
/// and podman's own COPY-layer cache still skips the actual layer work when the
/// file *content* is unchanged, so a spurious mtime bump costs almost nothing.
/// Returns `None` if the context can't be read (caller falls back to building).
fn fingerprint_build_context(context: &Path) -> Option<String> {
use sha2::{Digest, Sha256};
let mut entries: Vec<(String, u64, i128)> = Vec::new();
let mut stack = vec![context.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = std::fs::read_dir(&dir).ok()?;
for entry in rd.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
stack.push(path);
continue;
}
let rel = path
.strip_prefix(context)
.unwrap_or(&path)
.to_string_lossy()
.into_owned();
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as i128)
.unwrap_or(0);
entries.push((rel, meta.len(), mtime));
}
}
// Sort so the hash is independent of directory-walk order.
entries.sort();
let mut hasher = Sha256::new();
for (rel, len, mtime) in &entries {
hasher.update(rel.as_bytes());
hasher.update(b"\0");
hasher.update(len.to_le_bytes());
hasher.update(mtime.to_le_bytes());
}
Some(hex::encode(hasher.finalize()))
}
/// Path of the stamp file recording the build-context fingerprint that produced
/// the currently-built image for `tag`. Keyed by a filesystem-safe form of the
/// tag so distinct UI images don't collide.
fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf {
let safe: String = tag
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
data_dir
.join(".image-build")
.join(format!("{safe}.fingerprint"))
}
async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
let uid = uid_gid
.split_once(':')
@@ -219,6 +287,120 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> {
))
}
/// App-agnostic, userns-mapping-proof volume-ownership repair for a RUNNING
/// container.
///
/// For each writable bind mount, write-probe as the container's own process
/// user; if it can't write, `chown -R` from INSIDE the container (`podman exec`
/// as root) to that service uid:gid. Because the chown runs in the container's
/// user namespace, podman translates it to the correct host owner regardless of
/// the rootless idmap — so there is NO host-side UID guessing, and it works for
/// compose stacks (no manifest / `data_uid` needed) exactly as for registry apps.
/// This is the durable replacement for the per-app hardcoded host chowns.
///
/// Drift-checked via the write-probe, so it only `chown`s when the volume is
/// actually unwritable — cheap enough to call on every reconcile. Best-effort:
/// returns true if it repaired something; never fails reconcile (a degraded app
/// must not block the loop). See the immich EACCES crash-loop (.198, 2026-06-17).
async fn ensure_running_container_ownership(name: &str) -> bool {
async fn podman_stdout(args: &[&str]) -> Option<String> {
let out = tokio::process::Command::new("podman")
.args(args)
.output()
.await
.ok()?;
if !out.status.success() {
return None;
}
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
// The uid:gid the container's main process actually runs as.
let uid = match podman_stdout(&["exec", name, "id", "-u"]).await {
Some(u) if !u.is_empty() => u,
_ => return false, // can't exec (no shell / not running) — nothing to do
};
let gid = podman_stdout(&["exec", name, "id", "-g"])
.await
.filter(|g| !g.is_empty())
.unwrap_or_else(|| uid.clone());
// Writable bind-mount destinations only.
let dests = match podman_stdout(&[
"inspect",
name,
"--format",
"{{range .Mounts}}{{if eq .Type \"bind\"}}{{if .RW}}{{.Destination}}\n{{end}}{{end}}{{end}}",
])
.await
{
Some(d) => d,
None => return false,
};
let mut repaired = false;
for dest in dests.lines().map(str::trim).filter(|d| !d.is_empty()) {
// Never touch system / socket bind mounts.
if dest == "/"
|| dest.starts_with("/proc")
|| dest.starts_with("/sys")
|| dest.starts_with("/dev")
|| dest.starts_with("/run")
|| dest.starts_with("/etc")
|| dest.ends_with(".sock")
{
continue;
}
// Drift check: can the service user write here already?
let probe = format!(
"t=\"{dest}/.archy-wtest.$$\"; touch \"$t\" 2>/dev/null && rm -f \"$t\" 2>/dev/null"
);
let writable = tokio::process::Command::new("podman")
.args(["exec", name, "sh", "-c", &probe])
.output()
.await
.map(|o| o.status.success())
.unwrap_or(false);
if writable {
continue;
}
// Repair inside the container's userns — podman maps to the right host uid.
let chown = tokio::process::Command::new("podman")
.args([
"exec",
"-u",
"0",
name,
"chown",
"-R",
&format!("{uid}:{gid}"),
dest,
])
.output()
.await;
match chown {
Ok(o) if o.status.success() => {
repaired = true;
tracing::warn!(
container = %name, dest, uid = %uid,
"repaired unwritable volume ownership (in-container chown)"
);
}
Ok(o) => tracing::warn!(
container = %name, dest,
"volume ownership repair failed: {}",
String::from_utf8_lossy(&o.stderr).trim()
),
Err(e) => {
tracing::warn!(container = %name, dest, "volume ownership repair errored: {e}")
}
}
}
repaired
}
async fn wait_for_host_port(port: u16, timeout_secs: u64) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
@@ -772,6 +954,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 +1016,8 @@ impl ProdContainerOrchestrator {
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
#[cfg(test)]
test_bitcoin_host: None,
})
}
@@ -850,6 +1036,7 @@ impl ProdContainerOrchestrator {
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false,
test_disk_gb: None,
test_bitcoin_host: None,
}
}
@@ -1088,6 +1275,30 @@ impl ProdContainerOrchestrator {
}
}
}
// App-agnostic volume-ownership self-heal. Sweep EVERY running container
// (registry/manifest apps AND legacy compose stacks like immich) and
// repair any that can't write their bind mounts — the durable, app-
// agnostic replacement for per-app hardcoded host chowns. Drift-checked,
// so steady state is just cheap in-container write-probes; only a broken
// volume is chowned (in-userns, mapping-proof) and its container
// restarted to recover. Fixes the class of EACCES crash-loops fleet-wide
// and self-heals existing nodes after OTA. (immich .198, 2026-06-17.)
if let Ok(containers) = self.runtime.list_containers().await {
for c in containers
.iter()
.filter(|c| matches!(c.state, ContainerState::Running))
{
if ensure_running_container_ownership(&c.name).await {
tracing::info!(container = %c.name, "volume ownership repaired during reconcile — restarting to recover");
let _ = tokio::process::Command::new("podman")
.args(["restart", &c.name])
.output()
.await;
}
}
}
report
}
@@ -1113,6 +1324,33 @@ impl ProdContainerOrchestrator {
let _guard = lock.lock().await;
self.ensure_app_secrets(&app_id).await?;
// Don't fight the Bitcoin-implementation switch: bitcoin-core and
// bitcoin-knots share port 8332, so if the *other* variant is already
// running the inactive one can never start — the reconciler would just
// churn "address already in use" and report a reconcile failure. Skip
// it, mirroring the health monitor's same skip. (#47)
if let Some(conflict) = match app_id.strip_prefix("archy-").unwrap_or(app_id.as_str()) {
"bitcoin-core" => Some("bitcoin-knots"),
"bitcoin-knots" | "bitcoin" => Some("bitcoin-core"),
_ => None,
} {
if let Ok(list) = self.runtime.list_containers().await {
let other_running = list.iter().any(|c| {
c.name.strip_prefix("archy-").unwrap_or(c.name.as_str()) == conflict
&& matches!(c.state, ContainerState::Running)
});
if other_running {
tracing::debug!(
app_id = %app_id,
conflict,
"skipping reconcile — the other Bitcoin implementation is running"
);
return Ok(ReconcileAction::NoOp);
}
}
}
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let name = compute_container_name(&lm.manifest);
@@ -1380,7 +1618,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
@@ -1408,7 +1668,7 @@ impl ProdContainerOrchestrator {
.to_string_lossy()
.into_owned();
}
let already = match self.runtime.image_exists(&bcfg.tag).await {
let exists = match self.runtime.image_exists(&bcfg.tag).await {
Ok(exists) => exists,
Err(err) => {
tracing::warn!(
@@ -1419,11 +1679,51 @@ impl ProdContainerOrchestrator {
false
}
};
if !already {
// Presence alone isn't enough: the local UI images (bitcoin-ui,
// lnd-ui, electrs-ui) COPY a built `neode-ui` dist, so a UI
// update changes the source but leaves the old tag in place.
// Rebuild whenever the build context's fingerprint differs from
// the one that produced the existing image (#34). podman's
// COPY-layer cache keeps the rebuild cheap when content is
// actually unchanged.
let fingerprint = fingerprint_build_context(Path::new(&bcfg.context));
let stamp_path = build_fingerprint_stamp_path(&self.data_dir, &bcfg.tag);
let stale = match &fingerprint {
Some(current) => match tokio::fs::read_to_string(&stamp_path).await {
Ok(prev) => prev.trim() != current,
// No stamp recorded → treat as stale so we rebuild and
// capture the fingerprint going forward.
Err(_) => true,
},
// Couldn't fingerprint the context — don't skip on staleness.
None => true,
};
if !exists || stale {
if exists && stale {
tracing::info!(
image = %bcfg.tag,
context = %bcfg.context,
"build context changed since last build; rebuilding image"
);
}
self.runtime
.build_image(&bcfg)
.await
.with_context(|| format!("build_image {}", bcfg.tag))?;
// Record the fingerprint that this image was built from so
// the next reconcile skips the build until the source moves.
if let Some(current) = &fingerprint {
if let Some(parent) = stamp_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
if let Err(err) = tokio::fs::write(&stamp_path, current).await {
tracing::warn!(
image = %bcfg.tag,
error = %err,
"failed to write build fingerprint stamp"
);
}
}
}
}
}
@@ -2314,9 +2614,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() {
@@ -2396,11 +2732,29 @@ impl ProdContainerOrchestrator {
.await
.context("ensuring bitcoin tx-relay credentials")?;
}
if app_id == "fedimint-clientd" {
// The fmcd container's secret_env (fmcd-password) and the wallet
// bridge both read this; generate it before secret_env resolves.
crate::wallet::fedimint_client::ensure_fmcd_password(&self.secrets_dir)
.await
.context("ensuring fmcd password secret")?;
}
Ok(())
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut facts = self.detect_host_facts();
// Only pay the podman cost to detect Knots-vs-Core when this manifest
// actually templates the Bitcoin node into its env (mempool — B12).
if manifest
.app
.container
.derived_env
.iter()
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
{
facts.bitcoin_host = self.bitcoin_host();
}
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
@@ -3489,6 +3843,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());
@@ -4253,4 +4636,37 @@ app:
let calls = rt.calls();
assert!(calls.iter().any(|c| c == "create_container:lnd:offset=0"));
}
#[test]
fn fingerprint_build_context_detects_source_changes() {
let tmp = tempfile::TempDir::new().unwrap();
let ctx = tmp.path();
std::fs::write(ctx.join("Dockerfile"), "FROM nginx\n").unwrap();
std::fs::create_dir_all(ctx.join("assets")).unwrap();
std::fs::write(ctx.join("assets/app.js"), b"v1").unwrap();
let a = fingerprint_build_context(ctx).expect("fingerprint");
// Recomputing over the same tree is stable.
let b = fingerprint_build_context(ctx).expect("fingerprint");
assert_eq!(a, b, "fingerprint must be stable for an unchanged tree");
// Changing a COPYed source file (different length) changes the fingerprint.
std::fs::write(ctx.join("assets/app.js"), b"v2-longer").unwrap();
let c = fingerprint_build_context(ctx).expect("fingerprint");
assert_ne!(a, c, "changed source file must change the fingerprint");
}
#[test]
fn build_fingerprint_stamp_path_sanitizes_tag() {
let p = build_fingerprint_stamp_path(
Path::new("/var/lib/archipelago"),
"localhost/bitcoin-ui:local",
);
assert_eq!(
p,
PathBuf::from(
"/var/lib/archipelago/.image-build/localhost_bitcoin_ui_local.fingerprint"
)
);
}
}
+149
View File
@@ -0,0 +1,149 @@
//! Content hashing for the DHT distribution plan's *integrity & addressing*
//! tier (`docs/dht-distribution-design.md` §4).
//!
//! SHA-256 is the incumbent: it keys `blobs.rs` and verifies OTA components
//! today. BLAKE3 is introduced **alongside** it because iroh-blobs addresses
//! and *range-verifies* content by BLAKE3 — essential for resumable downloads
//! and HLS streaming. During the migration window both may be present; SHA-256
//! stays mandatory and BLAKE3 is verified when supplied.
//!
//! Digests are written multihash-style as `"<alg>:<hex>"`, e.g.
//! `"blake3:ab12…"` / `"sha256:cd34…"`, matching the app-catalog `digest` field.
//! Both algorithms emit 32-byte (64-hex-char) digests.
use anyhow::{anyhow, bail, Context, Result};
use sha2::{Digest, Sha256};
const DIGEST_LEN: usize = 32;
/// Supported content-hash algorithms.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HashAlg {
Sha256,
Blake3,
}
impl HashAlg {
pub fn as_str(self) -> &'static str {
match self {
HashAlg::Sha256 => "sha256",
HashAlg::Blake3 => "blake3",
}
}
}
/// Hex-encoded SHA-256 of `bytes`.
pub fn sha256_hex(bytes: &[u8]) -> String {
hex::encode(Sha256::digest(bytes))
}
/// Hex-encoded BLAKE3 of `bytes`.
pub fn blake3_hex(bytes: &[u8]) -> String {
blake3::hash(bytes).to_hex().to_string()
}
/// A parsed `"<alg>:<hex>"` content digest.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContentDigest {
pub alg: HashAlg,
/// Lowercase hex, validated to the algorithm's length.
pub hex: String,
}
impl ContentDigest {
/// Parse a multihash-style `"<alg>:<hex>"` string.
pub fn parse(s: &str) -> Result<Self> {
let (alg_part, hex_part) = s
.split_once(':')
.ok_or_else(|| anyhow!("digest must be '<alg>:<hex>', got: {}", s))?;
let alg = match alg_part {
"sha256" => HashAlg::Sha256,
"blake3" => HashAlg::Blake3,
other => bail!("unsupported hash algorithm: {}", other),
};
let raw = hex::decode(hex_part).context("digest hex is invalid")?;
if raw.len() != DIGEST_LEN {
bail!(
"{} digest must be {} bytes, got {}",
alg.as_str(),
DIGEST_LEN,
raw.len()
);
}
Ok(Self {
alg,
hex: hex_part.to_ascii_lowercase(),
})
}
/// Compute the digest of `bytes` under this digest's algorithm.
pub fn compute_hex(&self, bytes: &[u8]) -> String {
match self.alg {
HashAlg::Sha256 => sha256_hex(bytes),
HashAlg::Blake3 => blake3_hex(bytes),
}
}
/// Verify `bytes` hash to this digest. Errors (does not panic) on mismatch.
pub fn verify(&self, bytes: &[u8]) -> Result<()> {
let actual = self.compute_hex(bytes);
if actual.eq_ignore_ascii_case(&self.hex) {
Ok(())
} else {
bail!(
"{} mismatch: expected {}, got {}",
self.alg.as_str(),
self.hex,
actual
)
}
}
}
impl std::fmt::Display for ContentDigest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}", self.alg.as_str(), self.hex)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn digest_lengths_are_32_bytes() {
assert_eq!(sha256_hex(b"hi").len(), 64);
assert_eq!(blake3_hex(b"hi").len(), 64);
}
#[test]
fn blake3_known_answer() {
// BLAKE3 of the empty input — RFC/reference vector.
assert_eq!(
blake3_hex(b""),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
}
#[test]
fn parse_roundtrip() {
let d = ContentDigest::parse(&format!("blake3:{}", blake3_hex(b"x"))).unwrap();
assert_eq!(d.alg, HashAlg::Blake3);
assert_eq!(d.to_string(), format!("blake3:{}", blake3_hex(b"x")));
}
#[test]
fn verify_accepts_and_rejects() {
let d = ContentDigest::parse(&format!("sha256:{}", sha256_hex(b"payload"))).unwrap();
assert!(d.verify(b"payload").is_ok());
assert!(d.verify(b"tampered").is_err());
}
#[test]
fn parse_rejects_bad_input() {
assert!(ContentDigest::parse("nocolon").is_err());
assert!(ContentDigest::parse("md5:abcd").is_err());
assert!(ContentDigest::parse("blake3:nothex").is_err());
assert!(ContentDigest::parse("blake3:ab").is_err()); // too short
}
}
+80
View File
@@ -0,0 +1,80 @@
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46).
//!
//! When a buyer asks to pay for a paid catalog item with an external wallet (as
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
//! invoice on its own LND and records a pending entitlement here, keyed by the
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
//! for settlement; once the seller's LND confirms the invoice is settled we mark
//! the entitlement paid, and the content gate (`content_server::serve_content`)
//! then releases the file to anyone presenting that payment hash.
//!
//! State is in-memory and bounded by a TTL. If the seller restarts before the
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
//! because an unpaid invoice represents no money.
use std::collections::HashMap;
use std::sync::LazyLock;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
/// How long a pending/paid entitlement is retained. Generous enough for a human
/// to pay an invoice and download, short enough to keep the map small.
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Clone)]
struct Entitlement {
content_id: String,
price_sats: u64,
paid: bool,
created_at: Instant,
}
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// Drop expired entries. Caller must hold the lock.
fn prune(map: &mut HashMap<String, Entitlement>) {
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
}
/// Record a freshly-minted invoice as a pending (unpaid) entitlement.
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.insert(
payment_hash.to_string(),
Entitlement {
content_id: content_id.to_string(),
price_sats,
paid: false,
created_at: Instant::now(),
},
);
}
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
pub async fn mark_paid(payment_hash: &str) {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
if let Some(e) = map.get_mut(payment_hash) {
e.paid = true;
}
}
/// The content_id + price an entitlement was issued for, if still live.
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.get(payment_hash)
.map(|e| (e.content_id.clone(), e.price_sats))
}
/// True if `payment_hash` is a paid entitlement for exactly `content_id`.
/// This is the gate the content server consults to release a file.
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
map.get(payment_hash)
.map(|e| e.paid && e.content_id == content_id)
.unwrap_or(false)
}
+128 -4
View File
@@ -198,6 +198,7 @@ pub async fn serve_content(
data_dir: &Path,
id: &str,
payment_token: Option<&str>,
invoice_hash: Option<&str>,
peer_did: Option<&str>,
range: Option<ByteRange>,
) -> Result<ServeResult> {
@@ -236,12 +237,24 @@ pub async fn serve_content(
// Check access control
match &item.access {
AccessControl::Paid { price_sats } => {
// Verify payment token
// Two ways to satisfy payment:
// (a) a valid ecash token (the local-wallet fast path), or
// (b) a Lightning-invoice payment hash this node issued and has
// since confirmed settled (the "pay from any wallet" path, #46).
let mut authorized = false;
if let Some(token) = payment_token {
if !verify_payment_token(data_dir, token, *price_sats).await {
return Ok(ServeResult::PaymentRequired(*price_sats));
if verify_payment_token(data_dir, token, *price_sats).await {
authorized = true;
}
} else {
}
if !authorized {
if let Some(hash) = invoice_hash {
if crate::content_invoice::is_paid_for(hash, id).await {
authorized = true;
}
}
}
if !authorized {
return Ok(ServeResult::PaymentRequired(*price_sats));
}
}
@@ -317,10 +330,63 @@ pub enum PreviewResult {
BlurPreview(Vec<u8>, String),
/// Truncated preview for paid video (first ~2% of bytes).
TruncatedPreview(Vec<u8>, String, u64),
/// A preview can't be produced for this media without re-encoding (e.g. a
/// non-faststart MP4 whose moov atom is at the end, so a byte prefix won't
/// play). The UI shows its "preview unavailable" overlay instead of a
/// broken player. (#35)
PreviewUnavailable,
/// Content not found.
NotFound,
}
/// Scan an MP4's top-level boxes and report whether `moov` appears before
/// `mdat` ("faststart"). Returns `Some(true)` if faststart (a byte prefix is
/// playable), `Some(false)` if the media data precedes the index (a prefix
/// will NOT play), or `None` if neither box is found / the file isn't parseable
/// as ISO-BMFF (caller falls back to the legacy prefix behavior).
async fn mp4_is_faststart(path: &std::path::Path) -> Option<bool> {
use tokio::io::{AsyncReadExt, AsyncSeekExt, SeekFrom};
let mut f = tokio::fs::File::open(path).await.ok()?;
let file_len = f.metadata().await.ok()?.len();
let mut pos: u64 = 0;
// Bound the walk so a malformed file can't spin forever.
for _ in 0..1024 {
if pos.saturating_add(8) > file_len {
return None;
}
f.seek(SeekFrom::Start(pos)).await.ok()?;
let mut hdr = [0u8; 8];
if f.read_exact(&mut hdr).await.is_err() {
return None;
}
let mut size = u32::from_be_bytes([hdr[0], hdr[1], hdr[2], hdr[3]]) as u64;
let btype = &hdr[4..8];
let mut header_len = 8u64;
if size == 1 {
// 64-bit extended size.
let mut ext = [0u8; 8];
if f.read_exact(&mut ext).await.is_err() {
return None;
}
size = u64::from_be_bytes(ext);
header_len = 16;
} else if size == 0 {
// Box runs to EOF — it's the last one.
size = file_len.saturating_sub(pos);
}
match btype {
b"moov" => return Some(true), // index before media → faststart
b"mdat" => return Some(false), // media before index → not faststart
_ => {}
}
if size < header_len {
return None; // malformed
}
pos = pos.checked_add(size)?;
}
None
}
/// Serve a preview of content by ID. For paid content, returns degraded previews:
/// - Images: full file with X-Content-Preview: blur (frontend applies CSS blur)
/// - Videos: first 2% of file bytes (minimum 512KB for codec headers)
@@ -358,6 +424,26 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result<PreviewR
);
Ok(PreviewResult::BlurPreview(bytes, item.mime_type.clone()))
} else if mime.starts_with("video/") || mime.starts_with("audio/") {
// A byte-prefix preview only plays if the container's index is at
// the front. For MP4/MOV that means the `moov` atom must precede
// `mdat` (faststart). Non-faststart files have moov at the end, so
// a 10% prefix is an unplayable truncated MP4 (#35) — report it as
// unavailable rather than streaming bytes that hang the player.
let is_isobmff = mime == "video/mp4"
|| mime == "video/quicktime"
|| matches!(
file_path.extension().and_then(|e| e.to_str()),
Some("mp4") | Some("m4v") | Some("mov") | Some("m4a")
);
if is_isobmff && mp4_is_faststart(&file_path).await == Some(false) {
debug!(
"Paid {} '{}' is a non-faststart MP4 (moov after mdat) — no playable prefix preview",
if mime.starts_with("video/") { "video" } else { "audio" },
id
);
return Ok(PreviewResult::PreviewUnavailable);
}
// Serve first 10% of video/audio, minimum 512KB for codec headers
let metadata = fs::metadata(&file_path)
.await
@@ -431,3 +517,41 @@ async fn verify_payment_token(data_dir: &Path, token: &str, required_sats: u64)
}
}
}
#[cfg(test)]
mod faststart_tests {
use super::*;
fn box_hdr(size: u32, typ: &[u8; 4]) -> Vec<u8> {
let mut v = size.to_be_bytes().to_vec();
v.extend_from_slice(typ);
v
}
#[tokio::test]
async fn detects_faststart_moov_before_mdat() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("fast.mp4");
let mut data = Vec::new();
data.extend(box_hdr(16, b"ftyp"));
data.extend([0u8; 8]);
data.extend(box_hdr(8, b"moov"));
data.extend(box_hdr(8, b"mdat"));
tokio::fs::write(&p, &data).await.unwrap();
assert_eq!(mp4_is_faststart(&p).await, Some(true));
}
#[tokio::test]
async fn detects_non_faststart_mdat_before_moov() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("slow.mp4");
let mut data = Vec::new();
data.extend(box_hdr(16, b"ftyp"));
data.extend([0u8; 8]);
data.extend(box_hdr(16, b"mdat"));
data.extend([0u8; 8]);
data.extend(box_hdr(8, b"moov"));
tokio::fs::write(&p, &data).await.unwrap();
assert_eq!(mp4_is_faststart(&p).await, Some(false));
}
}
+51 -5
View File
@@ -254,11 +254,57 @@ pub(crate) async fn notify_join(
"params": params,
});
let _ = crate::fips::dial::PeerRequest::new(remote_fips_npub, remote_onion, "/rpc/v1")
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.send_json(&body)
.await;
// Deliver the notification in the BACKGROUND with retries, and return
// immediately. Two reasons:
// 1. The join RPC must not block on this. Awaiting a cold FIPS overlay
// (no shared FIPS path between LAN and remote/Tailscale peers) stalled
// the whole join until FIPS timed out, surfacing as "Request timeout"
// in the UI even though the local membership was already saved.
// 2. If this single best-effort POST failed, the inviter never learned
// about us → asymmetric federation (they couldn't see us). Retrying in
// the background until it lands makes federation converge to symmetric.
// `fips_timeout` fast-fails a dead FIPS path so the Tor fallback (which
// answers an onion in ~3-5s) is reached quickly on each attempt.
let remote_onion = remote_onion.to_string();
let remote_fips_npub = remote_fips_npub.map(|s| s.to_string());
tokio::spawn(async move {
// ~5 attempts with linear backoff: 0s, 10s, 20s, 30s, 40s — covers a
// peer that is briefly unreachable (restarting, publishing its onion)
// without hammering it.
for attempt in 1..=5u32 {
let res = crate::fips::dial::PeerRequest::new(
remote_fips_npub.as_deref(),
&remote_onion,
"/rpc/v1",
)
.service(crate::settings::transport::PeerService::Federation)
.timeout(std::time::Duration::from_secs(30))
.fips_timeout(std::time::Duration::from_secs(6))
.send_json(&body)
.await;
match res {
Ok((resp, transport)) if resp.status().is_success() => {
tracing::info!(
attempt,
transport = %transport,
"peer-joined notification delivered to inviter"
);
return;
}
Ok((resp, _)) => tracing::warn!(
attempt,
status = %resp.status(),
"peer-joined notification rejected; will retry"
),
Err(e) => tracing::warn!(attempt, error = %e, "peer-joined notification failed; will retry"),
}
tokio::time::sleep(std::time::Duration::from_secs(10 * attempt as u64)).await;
}
tracing::warn!(
onion = %remote_onion,
"peer-joined notification gave up after retries — peer may not see us until next sync"
);
});
Ok(())
}
+5 -2
View File
@@ -12,10 +12,13 @@ mod types;
// Re-export all public items so `crate::federation::*` continues to work.
pub use invites::{accept_invite, create_invite};
// Crate-internal: used by the periodic federation auto-sync to re-assert
// membership to peers that don't list us back (asymmetry self-heal).
pub(crate) use invites::notify_join;
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
remove_node, save_nodes, set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+57 -6
View File
@@ -117,9 +117,12 @@ fn expire_stale(requests: &mut Vec<PendingPeerRequest>) {
/// or `None` if the request was deduplicated or rate-limited.
///
/// Dedup rule: if the same (from_nostr_pubkey, from_did) already has a
/// `Pending` entry, do not insert a second one — the user will see the
/// existing row and act on that. Otherwise count `Pending` entries per
/// pubkey and reject anything beyond `MAX_PENDING_PER_PUBKEY`.
/// `Pending` OR `Approved` entry, do not insert a second one. Including
/// `Approved` is what stops an already-approved peer from re-spawning a fresh
/// pending row every time their request re-syncs (the reported "approve, Poll
/// Now, see approved + a new pending" loop). `Rejected` is intentionally NOT
/// matched so a previously-rejected peer can still ask again later. Otherwise
/// count `Pending` entries per pubkey and reject beyond `MAX_PENDING_PER_PUBKEY`.
pub async fn insert_inbound(
data_dir: &Path,
from_nostr_pubkey: String,
@@ -131,13 +134,13 @@ pub async fn insert_inbound(
let mut requests = load_pending(data_dir).await?;
expire_stale(&mut requests);
let already_pending = requests.iter().any(|r| {
let already_handled = requests.iter().any(|r| {
r.from_nostr_pubkey == from_nostr_pubkey
&& r.from_did == from_did
&& matches!(r.state, PendingState::Pending)
&& matches!(r.state, PendingState::Pending | PendingState::Approved)
&& !r.outbound
});
if already_pending {
if already_handled {
save_pending(data_dir, &requests).await?;
return Ok(None);
}
@@ -271,6 +274,54 @@ mod tests {
assert!(r2.is_none(), "duplicate Pending request should be ignored");
}
#[tokio::test]
async fn test_approved_request_does_not_respawn_pending() {
// Regression for the "approve → Poll Now → approved + a fresh pending"
// loop: once a request is Approved, a re-synced inbound for the same
// peer must NOT create a new Pending row.
let dir = tempfile::tempdir().unwrap();
let r1 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap()
.expect("first insert stored");
set_state(dir.path(), &r1.id, PendingState::Approved)
.await
.unwrap();
let r2 = insert_inbound(
dir.path(),
"npk1".into(),
"npub1".into(),
"did:key:zABC".into(),
None,
None,
)
.await
.unwrap();
assert!(
r2.is_none(),
"an already-approved peer must not re-spawn a pending request"
);
let pending = load_pending(dir.path()).await.unwrap();
assert_eq!(
pending
.iter()
.filter(|r| matches!(r.state, PendingState::Pending))
.count(),
0,
"no Pending rows should remain after approval + re-sync"
);
}
#[tokio::test]
async fn test_rate_limit() {
let dir = tempfile::tempdir().unwrap();
+183 -1
View File
@@ -10,6 +10,9 @@ use super::types::{FederatedNode, FederationInvite, NodeStateSnapshot, TrustLeve
pub(crate) const FEDERATION_DIR: &str = "federation";
pub(crate) const NODES_FILE: &str = "nodes.json";
pub(crate) const INVITES_FILE: &str = "invites.json";
/// Tombstones: DIDs the operator explicitly removed. Kept so transitive
/// federation discovery can't silently re-add a peer they deleted.
pub(crate) const REMOVED_FILE: &str = "removed-nodes.json";
/// Top-level file structures.
#[derive(Debug, Default, Serialize, Deserialize)]
@@ -17,6 +20,17 @@ pub(crate) struct NodesFile {
pub(crate) nodes: Vec<FederatedNode>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct RemovedFile {
pub(crate) removed: Vec<RemovedNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RemovedNode {
pub(crate) did: String,
pub(crate) removed_at: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub(crate) struct InvitesFile {
pub(crate) outgoing: Vec<FederationInvite>,
@@ -44,7 +58,43 @@ pub async fn load_nodes(data_dir: &Path) -> Result<Vec<FederatedNode>> {
.await
.context("Failed to read federation nodes")?;
let file: NodesFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.nodes)
Ok(dedup_nodes_by_onion(file.nodes))
}
/// Collapse entries that share an onion. An onion is a node's stable, unique
/// network identity, so two entries with the same onion are the SAME physical
/// node lingering under two dids (e.g. after a did/key change). Returning both
/// duplicates the node in the trusted-node list (B1) and the chat list (B2).
/// Keep the first occurrence and merge any missing fips_npub/name/last_state
/// from the duplicates into it, then drop them. Non-destructive to disk; the
/// deduped list persists the next time nodes are saved (add/sync).
fn dedup_nodes_by_onion(nodes: Vec<FederatedNode>) -> Vec<FederatedNode> {
use std::collections::HashMap;
let mut by_onion: HashMap<String, usize> = HashMap::new();
let mut out: Vec<FederatedNode> = Vec::with_capacity(nodes.len());
for node in nodes {
let key = node.onion.trim_end_matches(".onion").to_string();
if key.is_empty() {
out.push(node);
continue;
}
if let Some(&idx) = by_onion.get(&key) {
let kept = &mut out[idx];
if kept.fips_npub.is_none() {
kept.fips_npub = node.fips_npub;
}
if kept.name.is_none() {
kept.name = node.name;
}
if kept.last_state.is_none() {
kept.last_state = node.last_state;
}
continue;
}
by_onion.insert(key, out.len());
out.push(node);
}
out
}
/// Look up a federated peer's FIPS npub given their onion address.
@@ -114,6 +164,9 @@ pub async fn add_node(data_dir: &Path, node: FederatedNode) -> Result<Vec<Federa
if exists {
anyhow::bail!("Node with DID {} is already federated", node.did);
}
// Explicitly (re-)adding a node clears any prior tombstone so the
// operator can intentionally bring back a previously removed peer.
let _ = untombstone_did(data_dir, &node.did).await;
nodes.push(node);
save_nodes(data_dir, &nodes).await?;
Ok(nodes)
@@ -127,9 +180,70 @@ pub async fn remove_node(data_dir: &Path, did: &str) -> Result<Vec<FederatedNode
anyhow::bail!("No federated node with DID {}", did);
}
save_nodes(data_dir, &nodes).await?;
// Tombstone the DID so transitive federation discovery (a still-federated
// peer advertising this DID as one of *its* trusted peers) can't silently
// re-add it. Best-effort: a failed tombstone write must not fail the
// remove the operator asked for.
let _ = tombstone_did(data_dir, did).await;
Ok(nodes)
}
/// Load the set of tombstoned (operator-removed) DIDs.
pub async fn load_removed_dids(data_dir: &Path) -> Result<std::collections::HashSet<String>> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(std::collections::HashSet::new());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read removed nodes")?;
let file: RemovedFile = serde_json::from_str(&content).unwrap_or_default();
Ok(file.removed.into_iter().map(|r| r.did).collect())
}
/// Record a DID as removed. Idempotent.
pub async fn tombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let dir = ensure_dir(data_dir).await?;
let path = dir.join(REMOVED_FILE);
let mut file: RemovedFile = if path.exists() {
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default()
} else {
RemovedFile::default()
};
if !file.removed.iter().any(|r| r.did == did) {
file.removed.push(RemovedNode {
did: did.to_string(),
removed_at: chrono::Utc::now().to_rfc3339(),
});
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
/// Clear a DID's tombstone (operator explicitly re-added it).
pub async fn untombstone_did(data_dir: &Path, did: &str) -> Result<()> {
let path = data_dir.join(FEDERATION_DIR).join(REMOVED_FILE);
if !path.exists() {
return Ok(());
}
let mut file: RemovedFile =
serde_json::from_str(&fs::read_to_string(&path).await.unwrap_or_default())
.unwrap_or_default();
let before = file.removed.len();
file.removed.retain(|r| r.did != did);
if file.removed.len() != before {
let content = serde_json::to_string_pretty(&file).context("serialize removed nodes")?;
fs::write(&path, content)
.await
.context("Failed to write removed nodes")?;
}
Ok(())
}
pub async fn set_trust_level(
data_dir: &Path,
did: &str,
@@ -236,6 +350,44 @@ mod tests {
}
}
#[test]
fn test_dedup_nodes_by_onion_collapses_same_onion() {
// Two entries share an onion (same physical node under two dids) — must
// collapse to one, keeping the first did and merging fips_npub/name (B1/B2).
let mut dup = make_node("did:key:zDUP", "shared.onion");
dup.fips_npub = Some("npub1merged".to_string());
dup.name = Some("Sapien".to_string());
let nodes = vec![
make_node("did:key:zKEEP", "shared.onion"),
dup,
make_node("did:key:zOTHER", "other.onion"),
];
let out = dedup_nodes_by_onion(nodes);
assert_eq!(out.len(), 2, "two distinct onions remain");
let kept = out.iter().find(|n| n.onion == "shared.onion").unwrap();
assert_eq!(kept.did, "did:key:zKEEP", "keeps first did for the onion");
assert_eq!(
kept.fips_npub.as_deref(),
Some("npub1merged"),
"merges fips_npub from the dropped duplicate"
);
assert_eq!(
kept.name.as_deref(),
Some("Sapien"),
"merges name from the dup"
);
}
#[test]
fn test_dedup_onion_suffix_insensitive() {
// The ".onion" suffix must not affect the match.
let nodes = vec![
make_node("did:key:z1", "abc"),
make_node("did:key:z2", "abc.onion"),
];
assert_eq!(dedup_nodes_by_onion(nodes).len(), 1);
}
#[tokio::test]
async fn test_load_nodes_empty_when_no_file() {
let dir = tempfile::tempdir().unwrap();
@@ -287,6 +439,36 @@ mod tests {
assert!(result.is_err());
}
#[tokio::test]
async fn test_remove_tombstones_and_readd_clears_it() {
let dir = tempfile::tempdir().unwrap();
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
// No tombstones yet.
assert!(load_removed_dids(dir.path()).await.unwrap().is_empty());
// Removing tombstones the DID so transitive discovery won't re-add it.
remove_node(dir.path(), "did:key:z1").await.unwrap();
let removed = load_removed_dids(dir.path()).await.unwrap();
assert!(
removed.contains("did:key:z1"),
"removed DID must be tombstoned"
);
// Explicitly re-adding clears the tombstone (intentional re-federate).
add_node(dir.path(), make_node("did:key:z1", "a.onion"))
.await
.unwrap();
assert!(
!load_removed_dids(dir.path())
.await
.unwrap()
.contains("did:key:z1"),
"explicit re-add must clear the tombstone"
);
}
#[tokio::test]
async fn test_set_trust_level() {
let dir = tempfile::tempdir().unwrap();
+37
View File
@@ -33,6 +33,12 @@ pub async fn sync_with_peer(
.header("X-Federation-Sig", signature)
.header("X-Federation-Timestamp", timestamp)
.timeout(std::time::Duration::from_secs(30))
// Fast-fail a cold/unreachable FIPS overlay (common between LAN and
// remote/Tailscale peers that share no FIPS path) so the Tor fallback —
// which answers an onion in ~3-5s — isn't stuck behind the full 30s FIPS
// budget. Without this, a state sync to a FIPS-unreachable peer "took
// ages" and join/sync appeared to time out even though Tor was healthy.
.fips_timeout(std::time::Duration::from_secs(6))
.send_json(&body)
.await
.context("Failed to reach federated peer")?;
@@ -118,6 +124,12 @@ async fn merge_transitive_peers(
return Ok(());
}
let mut nodes = super::storage::load_nodes(data_dir).await?;
// Tombstoned DIDs: peers the operator explicitly removed. Never re-add
// them via transitive discovery, or deleted (e.g. stale test) nodes
// reappear on the next sync with any peer that still lists them.
let removed = super::storage::load_removed_dids(data_dir)
.await
.unwrap_or_default();
let mut added = 0u32;
let mut refreshed = 0u32;
@@ -127,6 +139,10 @@ async fn merge_transitive_peers(
if hint.did == source_did || hint.did == local_did {
continue;
}
// Skip anything the operator deliberately removed.
if removed.contains(&hint.did) {
continue;
}
if let Some(existing) = nodes.iter_mut().find(|n| n.did == hint.did) {
// Already known — just refresh fips_npub if we didn't have one.
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
@@ -135,6 +151,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(),
+76 -13
View File
@@ -28,12 +28,38 @@ use tokio::process::Command;
/// On-disk filename under `data_dir/`.
const SEED_ANCHORS_FILE: &str = "seed-anchors.json";
/// Public anchor (`fips.v0l.io`) carried as a default seed for fresh
/// installs — the one the upstream daemon dials anyway. Operators can
/// remove it from the UI once their own cluster has independent anchors.
/// Public anchor (`fips.v0l.io`) carried as a default seed for every
/// node — it bootstraps DHT routing so a fresh node isn't isolated.
/// Operators can remove it from the UI once their own cluster has
/// independent anchors (removal persists, see `load`/`remove`).
///
/// IMPORTANT transport details, learned the hard way (see git history /
/// the 2026-06-15 debugging on .116):
/// - The anchor answers ONLY on **TCP port 8443**. UDP 8668 is dead
/// (host pings on both IP families but never completes a UDP FIPS
/// handshake). `fips/config.rs` always knew this; the old default
/// here (`fips.v0l.io:8668`/udp) silently never connected fleet-wide.
/// - We use the **IPv4 literal** rather than the `fips.v0l.io` hostname
/// on purpose: the hostname resolves IPv6-first, but the daemon binds
/// its transports IPv4-only (`0.0.0.0:8443`), so a v6 target makes the
/// daemon fail to send the handshake with `EAFNOSUPPORT (os error 97)`.
/// An IPv4 literal sidesteps the resolver entirely.
pub const DEFAULT_PUBLIC_ANCHOR_NPUB: &str =
"npub1zv58cn7v83mxvttl70w5fwjwuclfmntv9cnmv5wmz2nzz88u5urqvdx96n";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "fips.v0l.io:8668";
pub const DEFAULT_PUBLIC_ANCHOR_ADDR: &str = "185.18.221.160:8443";
pub const DEFAULT_PUBLIC_ANCHOR_TRANSPORT: &str = "tcp";
/// The default public anchor as a ready-to-apply `SeedAnchor`. Carried
/// implicitly by `load()` on nodes that have never edited their anchor
/// list, so every node dials it without operator action.
pub fn default_public_anchor() -> SeedAnchor {
SeedAnchor {
npub: DEFAULT_PUBLIC_ANCHOR_NPUB.to_string(),
address: DEFAULT_PUBLIC_ANCHOR_ADDR.to_string(),
transport: DEFAULT_PUBLIC_ANCHOR_TRANSPORT.to_string(),
label: "Public anchor (fips.v0l.io)".to_string(),
}
}
/// One seed-anchor entry. `address` must be directly dialable (IP or
/// resolvable hostname + UDP port); `transport` is one of "udp", "tcp",
@@ -60,12 +86,15 @@ fn anchors_path(data_dir: &Path) -> PathBuf {
data_dir.join(SEED_ANCHORS_FILE)
}
/// Load the seed-anchor list. Returns an empty list if the file
/// doesn't exist yet — a first-boot node with no operator config.
/// Load the seed-anchor list. A node that has never edited its anchor
/// list (no file yet) gets the default public anchor so it can bootstrap
/// the mesh out of the box. Once the operator edits anchors — including
/// removing the default — a file exists and is authoritative, so removal
/// persists and we never silently re-add it.
pub async fn load(data_dir: &Path) -> Result<Vec<SeedAnchor>> {
let path = anchors_path(data_dir);
if !path.exists() {
return Ok(Vec::new());
return Ok(vec![default_public_anchor()]);
}
let bytes = tokio::fs::read(&path)
.await
@@ -121,11 +150,27 @@ pub async fn remove(data_dir: &Path, npub: &str) -> Result<Vec<SeedAnchor>> {
/// `fipsctl connect` is idempotent-ish: calling it for an already-
/// connected peer is a no-op at the protocol layer, so re-applying on
/// a timer is safe. Returns a list of per-anchor results for logging.
///
/// Invoked through `sudo -n`: the upstream daemon's control socket
/// (`/run/fips/control.sock`) is owned `root:fips` 0660, and the
/// archipelago service user is not in the `fips` group, so a bare
/// `fipsctl connect` fails with EACCES. This matches the privileged
/// `sudo -n fipsctl show peers` call in `service::peer_connectivity_summary`.
/// Without it, seed anchors persist to disk but never actually dial,
/// leaving `anchor_connected=false` and every peer dial falling back to
/// a slow Tor timeout.
pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
let mut results = Vec::with_capacity(anchors.len());
for anchor in anchors {
let out = Command::new("fipsctl")
.args(["connect", &anchor.npub, &anchor.address, &anchor.transport])
let out = Command::new("sudo")
.args([
"-n",
"fipsctl",
"connect",
&anchor.npub,
&anchor.address,
&anchor.transport,
])
.output()
.await;
let result = match out {
@@ -138,7 +183,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
npub: anchor.npub.clone(),
ok: false,
message: format!(
"fipsctl exited {}: {}",
"sudo fipsctl connect exited {}: {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
),
@@ -146,7 +191,7 @@ pub async fn apply(anchors: &[SeedAnchor]) -> Vec<ApplyResult> {
Err(e) => ApplyResult {
npub: anchor.npub.clone(),
ok: false,
message: format!("fipsctl launch failed: {}", e),
message: format!("sudo fipsctl launch failed: {}", e),
},
};
if result.ok {
@@ -185,10 +230,28 @@ mod tests {
}
#[tokio::test]
async fn load_missing_returns_empty() {
async fn load_missing_seeds_default_public_anchor() {
// A node that has never edited its anchor list should still get
// the public anchor so it can bootstrap the mesh out of the box.
let dir = tempfile::tempdir().unwrap();
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty());
assert_eq!(got, vec![default_public_anchor()]);
// ...and the default must be the TCP/8443 form, not the dead udp:8668.
assert_eq!(got[0].transport, "tcp");
assert!(got[0].address.ends_with(":8443"));
}
#[tokio::test]
async fn removing_default_persists_as_empty() {
// Once the operator removes the default, a file exists and is
// authoritative — we must not silently re-seed it on next load.
let dir = tempfile::tempdir().unwrap();
let list = remove(dir.path(), DEFAULT_PUBLIC_ANCHOR_NPUB)
.await
.unwrap();
assert!(list.is_empty());
let got = load(dir.path()).await.unwrap();
assert!(got.is_empty(), "default must stay removed once edited");
}
#[tokio::test]
+136 -24
View File
@@ -34,6 +34,17 @@ use tokio::net::UdpSocket;
/// path filter can restrict the exposed surface.
pub const PEER_PORT: u16 = 5679;
/// Whether a FIPS-side HTTP status should trigger a fall-back to Tor in
/// `Auto` mode. A `404` over FIPS often means the peer's mesh listener
/// doesn't expose that path (e.g. a peer on an older build with a stricter
/// `is_peer_allowed_path`), and `5xx` is a server-side error — both are
/// worth retrying over Tor, which reaches a different (less-filtered) route.
/// Success, redirects, and other 4xx (auth / bad request) are authoritative
/// and are returned as-is so we neither mask real errors nor double latency.
fn fips_should_fall_back(status: reqwest::StatusCode) -> bool {
status == reqwest::StatusCode::NOT_FOUND || status.is_server_error()
}
/// DNS suffix appended to a peer's bech32 npub.
pub const FIPS_DNS_SUFFIX: &str = "fips";
@@ -82,17 +93,71 @@ 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 {
client_with_timeout(Duration::from_secs(20))
}
/// FIPS client with a caller-chosen overall request timeout. The static 20s
/// `client()` budget is fine for catalog browses and short calls, but a large
/// content download (#38) needs the per-request timeout the caller asked for —
/// otherwise a 178MB transfer is aborted at 20s and the whole download fails
/// before the Tor fallback ever gets a chance. The generous `connect_timeout`
/// is preserved so a cold hole-punched path still gets time to establish.
pub fn client_with_timeout(timeout: Duration) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(20))
.connect_timeout(Duration::from_secs(5))
.timeout(timeout)
.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>> {
@@ -243,6 +308,14 @@ pub struct PeerRequest<'a> {
pub path: &'a str,
pub headers: Vec<(&'a str, String)>,
pub timeout: std::time::Duration,
/// Optional shorter cap on the FIPS *attempt* only. When set, a cold or hung
/// FIPS overlay fails fast within this budget so the Tor fallback still gets
/// its full `timeout` — without it, a stuck FIPS dial can consume the whole
/// caller budget (e.g. a 60s frontend RPC) and the request "times out" even
/// though Tor would have answered (#6, the Pay-with-QR invoice request).
/// `None` keeps the legacy behavior (FIPS uses the full `timeout`), which a
/// large content download needs so its long FIPS transfer isn't truncated.
pub fips_timeout: Option<std::time::Duration>,
pub service: Option<crate::settings::transport::PeerService>,
}
@@ -254,10 +327,26 @@ impl<'a> PeerRequest<'a> {
path,
headers: Vec::new(),
timeout: std::time::Duration::from_secs(30),
fips_timeout: None,
service: None,
}
}
/// Cap the FIPS attempt to a shorter budget than the overall `timeout`, so a
/// cold/hung overlay path fails fast and the Tor fallback keeps its full
/// budget. Use on short request/response calls (invoice, status); leave
/// unset for large downloads that legitimately need a long FIPS transfer.
pub fn fips_timeout(mut self, t: std::time::Duration) -> Self {
self.fips_timeout = Some(t);
self
}
/// Timeout to apply to the FIPS attempt — the explicit cap if set, else the
/// overall request timeout.
fn fips_attempt_timeout(&self) -> std::time::Duration {
self.fips_timeout.unwrap_or(self.timeout)
}
/// Tie this request to a user-configurable service preference. If
/// the user has set that service to `Fips` or `Tor`, the builder
/// respects it.
@@ -294,13 +383,22 @@ impl<'a> PeerRequest<'a> {
let pref = self.preference().await;
// FIPS-only or Auto: try FIPS first.
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_post_json(body).await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_post_json(body).await? {
Some(resp) => {
// Use the FIPS reply unless it's one a Tor retry could
// fix (404 path-not-served / 5xx) and we're allowed to
// fall back. FIPS-only never falls back.
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_post_json(body).await?;
@@ -312,13 +410,19 @@ impl<'a> PeerRequest<'a> {
use crate::settings::transport::TransportPref;
let pref = self.preference().await;
if matches!(pref, TransportPref::Auto | TransportPref::Fips) {
if let Some(resp) = self.try_fips_get().await? {
return Ok((resp, crate::transport::TransportKind::Fips));
}
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
match self.try_fips_get().await? {
Some(resp) => {
if pref == TransportPref::Fips || !fips_should_fall_back(resp.status()) {
return Ok((resp, crate::transport::TransportKind::Fips));
}
}
None => {
if pref == TransportPref::Fips {
anyhow::bail!(
"User set transport preference to FIPS only, but peer is unreachable over FIPS"
);
}
}
}
}
let resp = self.send_tor_get().await?;
@@ -343,15 +447,19 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.post(&url).json(body);
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)
}
}
@@ -372,15 +480,19 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.fips_attempt_timeout());
let mut rb = c.get(&url);
for (k, v) in &self.headers {
rb = rb.header(*k, v);
}
match rb.send().await {
match send_with_retry(rb).await {
Ok(r) => Ok(Some(r)),
Err(e) => {
tracing::debug!("FIPS GET {} failed: {}, falling back to Tor", url, e);
tracing::debug!(
"FIPS GET {} failed after retry: {}, falling back to Tor",
url,
e
);
Ok(None)
}
}
+57
View File
@@ -33,6 +33,63 @@ pub mod service;
pub mod update;
use serde::{Deserialize, Serialize};
/// Auto-activate FIPS with no user interaction. Once seed onboarding has
/// materialised the fips key, install the daemon config + start the service if
/// it isn't already up. Idempotent and best-effort: FIPS is the preferred
/// transport and should come up on its own — the UI "Activate" button is now a
/// manual fallback, not a requirement. No-op pre-onboarding (no key yet) or
/// when the service is already active.
pub async fn ensure_activated(data_dir: &std::path::Path) {
let identity_dir = identity_dir_from(data_dir);
if !identity_dir.join("fips_key").exists() {
return; // pre-onboarding: nothing to activate yet
}
if dial::is_service_active().await {
return; // already up
}
tracing::info!("FIPS inactive — auto-activating (no user interaction needed)");
if let Err(e) = config::install(&identity_dir).await {
tracing::warn!("FIPS auto-activate: config install failed: {:#}", e);
return;
}
if let Err(e) = service::activate(SERVICE_UNIT).await {
tracing::warn!("FIPS auto-activate: service activate failed: {:#}", e);
return;
}
tracing::info!("FIPS auto-activated");
}
/// Spawn the FIPS supervisor: every 45s it (1) auto-activates FIPS if onboarding
/// is done but the service is down — so it comes up with zero user interaction,
/// and (2) keeps hole-punched paths to known federation peers warm, so on-demand
/// dials land on FIPS instead of falling back to Tor. Warms peers concurrently
/// so one slow/offline peer doesn't delay the rest.
pub fn spawn_fips_supervisor(data_dir: std::path::PathBuf) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(std::time::Duration::from_secs(45));
loop {
tick.tick().await;
// Bring FIPS up on its own once onboarding has materialised the key.
ensure_activated(&data_dir).await;
if !dial::is_service_active().await {
continue;
}
let nodes = crate::federation::load_nodes(&data_dir)
.await
.unwrap_or_default();
let mut handles = Vec::new();
for node in nodes {
if let Some(npub) = node.fips_npub.clone() {
handles.push(tokio::spawn(async move { dial::warm_path(&npub).await }));
}
}
for h in handles {
let _ = h.await;
}
}
});
}
use std::path::{Path, PathBuf};
/// Systemd unit name supervised by archipelago.
+332 -87
View File
@@ -1,156 +1,401 @@
//! User-triggered FIPS upgrade from the upstream default branch.
//! User-triggered FIPS upgrade from upstream GitHub releases.
//!
//! Flow (no auto-update, no background polling — user clicks a button):
//! 1. Query GitHub for the upstream repo's default branch, then the
//! latest commit on it. (jmcorgan/fips default is `master`, not
//! `main` — we resolve it dynamically so a future rename Just Works.)
//! 2. Compare with the installed daemon version reported by
//! `fipsctl --version`. If identical, report "up to date".
//! 3. Fetch the built .deb artefact for that commit + its SHA256.
//! 4. SHA256-verify the download.
//! 5. `sudo dpkg -i` the .deb, `sudo systemctl restart` the service.
//! 1. Query GitHub for the latest *stable* release of `jmcorgan/fips`
//! (`/releases/latest` returns the newest non-prerelease, non-draft
//! tag, so release candidates like `v0.4.0-rc1` are skipped).
//! 2. Compare its tag (e.g. `v0.3.0`) with the installed daemon version
//! reported by `fipsctl --version`. A dev/pre-release build of the
//! same number (`0.3.0-dev`) counts as older than the released tag.
//! 3. Pick the Debian package asset matching the host architecture
//! (`fips_<ver>_amd64.deb` / `_arm64.deb`) plus `checksums-linux.txt`.
//! 4. Download both, SHA256-verify the .deb against the checksums file.
//! 5. `sudo dpkg -i` the verified .deb, then restart the active fips unit.
//!
//! The artefact URL / SHA256 source is not yet fixed — upstream doesn't
//! publish stable release assets for per-commit builds. This module
//! currently implements steps 12 (the "is there anything newer?" query)
//! and stubs out 35 so the RPC/UI can wire through. The apply path
//! returns a clear "not yet available" error until the artefact source
//! is decided.
//! Upstream began publishing tagged releases with `.deb` artefacts and
//! `checksums-linux.txt` (verified present as of v0.1.0 → v0.4.0-rc1), so
//! the apply path is fully wired against those assets.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::{service, UPSTREAM_REPO};
const GITHUB_API: &str = "https://api.github.com";
const USER_AGENT: &str = "archipelago-fips-updater";
/// Result of `check_update()` — what the dashboard renders.
/// Result of `check()` — what the dashboard renders.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCheck {
/// Currently installed daemon version (from `fipsctl --version`).
pub current: Option<String>,
/// Short SHA of the latest commit on upstream `main`.
pub latest_commit: String,
/// True when the installed version string does not mention the latest SHA.
/// Tag of the latest stable upstream release, e.g. `v0.3.0`.
pub latest_version: String,
/// True when the installed version is older than `latest_version`.
pub update_available: bool,
/// Release channel this check tracked. Currently always "stable".
pub channel: String,
/// Browser download URL of the architecture-matched .deb for the
/// latest release, when one exists (informational; apply() re-resolves).
pub asset_url: Option<String>,
/// Human-readable note for the UI.
pub notes: String,
}
/// Query GitHub for the latest commit on the upstream default branch and
/// compare to the installed version. Never errors on "no package installed"
/// — that is itself a valid state where an update is available.
/// One GitHub release as we consume it.
#[derive(Debug, Clone, Deserialize)]
struct Release {
tag_name: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
draft: bool,
#[serde(default)]
assets: Vec<Asset>,
}
#[derive(Debug, Clone, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Build HTTP client")
}
/// Debian architecture string for the host (`amd64` / `arm64`). Returns
/// the raw `std::env::consts::ARCH` for anything we don't map, so the
/// asset lookup simply finds nothing and surfaces a clear error.
fn deb_arch() -> &'static str {
match std::env::consts::ARCH {
"x86_64" => "amd64",
"aarch64" => "arm64",
other => other,
}
}
/// Query GitHub for the latest stable release and compare to the installed
/// version. Never errors on "no package installed" — that is itself a valid
/// state where an update is available.
pub async fn check() -> Result<UpdateCheck> {
let current = service::daemon_version().await.ok();
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(15))
.build()
.context("Build HTTP client")?;
let branch = fetch_default_branch(&client).await?;
let latest = fetch_head_sha(&client, &branch).await?;
let short = latest.chars().take(7).collect::<String>();
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
let update_available = match &current {
Some(v) => !v.contains(&short),
Some(v) => version_is_older(v, &release.tag_name),
None => true,
};
let asset_url = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.map(|a| a.browser_download_url.clone());
let notes = if update_available {
format!(
"Upstream {} is at {}; installed: {}",
branch,
short,
"Update available: {} (installed: {})",
release.tag_name,
current.as_deref().unwrap_or("not installed")
)
} else {
format!("Up to date ({} @ {})", branch, short)
format!("Up to date ({})", release.tag_name)
};
Ok(UpdateCheck {
current,
latest_commit: short,
latest_version: release.tag_name,
update_available,
channel: "stable".to_string(),
asset_url,
notes,
})
}
/// Apply the update. Stubbed pending a stable artefact source for
/// per-commit builds of the `fips` debian package. When this is wired
/// up it must: download → SHA256-verify → `sudo dpkg -i` → restart.
/// Download, verify, and install the latest stable FIPS release, then
/// restart the daemon. Steps: resolve release → match .deb for this arch
/// → download .deb + checksums → SHA256-verify → `sudo dpkg -i` → restart.
pub async fn apply() -> Result<()> {
anyhow::bail!(
"FIPS auto-apply not yet wired — upstream does not publish stable \
per-commit .deb artefacts for main. Upgrade manually for now: \
`git pull && cargo deb && sudo dpkg -i target/debian/fips_*.deb`."
)
}
let client = http_client()?;
let release = fetch_latest_stable(&client).await?;
async fn fetch_default_branch(client: &reqwest::Client) -> Result<String> {
let url = format!("{}/repos/{}", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
let deb = release
.assets
.iter()
.find(|a| is_deb_for_arch(&a.name))
.ok_or_else(|| {
anyhow::anyhow!(
"release {} has no .deb for architecture {}",
release.tag_name,
deb_arch()
)
})?;
let checksums = release
.assets
.iter()
.find(|a| a.name == "checksums-linux.txt")
.ok_or_else(|| {
anyhow::anyhow!("release {} has no checksums-linux.txt", release.tag_name)
})?;
// Download the .deb (bytes) and the checksums (text).
let deb_bytes = client
.get(&deb.browser_download_url)
.send()
.await
.context("GitHub repo API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub repo API returned {}", resp.status());
}
let body: serde_json::Value = resp.json().await.context("Parse repo JSON")?;
body.get("default_branch")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub repo response missing default_branch"))
}
async fn fetch_head_sha(client: &reqwest::Client, branch: &str) -> Result<String> {
let url = format!("{}/repos/{}/commits/{}", GITHUB_API, UPSTREAM_REPO, branch);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.context("download .deb")?
.error_for_status()
.context(".deb download HTTP error")?
.bytes()
.await
.context("read .deb body")?;
let checksums_text = client
.get(&checksums.browser_download_url)
.send()
.await
.context("GitHub commits API")?;
if !resp.status().is_success() {
.context("download checksums")?
.error_for_status()
.context("checksums download HTTP error")?
.text()
.await
.context("read checksums body")?;
// Verify SHA256 against the checksums manifest (sha256sum format:
// "<hex>␠␠<filename>"). The filename column may include a leading
// "*" (binary mode) or a path prefix, so match on the basename.
let expected = checksums_text
.lines()
.filter_map(|line| {
let mut parts = line.split_whitespace();
let hash = parts.next()?;
let name = parts.next()?.trim_start_matches('*');
let base = name.rsplit('/').next().unwrap_or(name);
(base == deb.name).then(|| hash.to_lowercase())
})
.next()
.ok_or_else(|| anyhow::anyhow!("checksums-linux.txt has no entry for {}", deb.name))?;
let actual = {
let mut hasher = Sha256::new();
hasher.update(&deb_bytes);
hex::encode(hasher.finalize())
};
if actual != expected {
anyhow::bail!(
"GitHub commits API returned {} for branch {}",
resp.status(),
branch
"SHA256 mismatch for {}: expected {}, got {}",
deb.name,
expected,
actual
);
}
let body: serde_json::Value = resp.json().await.context("Parse commits JSON")?;
body.get("sha")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("GitHub commits response missing sha field"))
// Stage the verified .deb in /tmp (shared with the host — the
// service runs with PrivateTmp=no) and install it.
let dest = std::env::temp_dir().join(&deb.name);
tokio::fs::write(&dest, &deb_bytes)
.await
.with_context(|| format!("write {}", dest.display()))?;
// Run dpkg via `systemd-run` rather than `sudo dpkg` directly. The
// archipelago service runs under `ProtectSystem=strict`, so `/usr`
// and `/var/lib/dpkg` are read-only *inside the service's mount
// namespace* — and a `sudo` child inherits that namespace, so a
// bare `sudo dpkg -i` fails with "Read-only file system" on the
// dpkg database. `systemd-run` asks PID 1 to launch the command in
// a fresh transient scope outside our sandbox, where the real
// (writable) host filesystem is visible. `--wait` blocks until it
// finishes and propagates the exit status; `--pipe` forwards
// dpkg's output; `--collect` reaps the unit even on failure.
//
// dpkg flags, both load-bearing for this package specifically:
// --force-confold: the fips package ships conffiles under
// /etc/fips that archipelago rewrites at install time, so dpkg
// hits an interactive "keep/replace?" conffile prompt. With our
// closed stdin that aborts the configure step ("EOF on stdin at
// conffile prompt") and leaves the package half-unpacked
// (status `iU`), which `fips.status` then reports as
// `installed:false`. confold = keep our managed config, no prompt.
// --force-downgrade: ISO/dev nodes carry `0.3.0-dev-1`, which dpkg
// orders as NEWER than the stable tag `0.3.0` (a trailing
// `-dev` sorts above the bare release). Moving a dev build onto
// the stable line is therefore a dpkg "downgrade"; without this
// flag dpkg warns and exits non-zero. Our own version_is_older()
// gate already decided this is the wanted direction.
// DEBIAN_FRONTEND=noninteractive belt-and-suspenders against any
// other maintainer-script prompt.
let dpkg = tokio::process::Command::new("sudo")
.args([
"-n",
"systemd-run",
"--collect",
"--wait",
"--quiet",
"--pipe",
"--",
"env",
"DEBIAN_FRONTEND=noninteractive",
"dpkg",
"--force-confold",
"--force-downgrade",
"-i",
])
.arg(&dest)
.output()
.await
.context("sudo systemd-run dpkg -i failed to launch")?;
// Best-effort cleanup regardless of dpkg result.
let _ = tokio::fs::remove_file(&dest).await;
if !dpkg.status.success() {
anyhow::bail!(
"dpkg -i {} exited {}: {}",
deb.name,
dpkg.status,
String::from_utf8_lossy(&dpkg.stderr).trim()
);
}
// Restart whichever fips unit is supervising the daemon so the new
// binary takes over.
let unit = service::active_unit().await;
service::restart(unit)
.await
.with_context(|| format!("restart {} after install", unit))?;
Ok(())
}
/// `/releases/latest` returns the most recent non-prerelease, non-draft
/// release. We still re-check the flags defensively in case the endpoint
/// or repo settings change.
async fn fetch_latest_stable(client: &reqwest::Client) -> Result<Release> {
let url = format!("{}/repos/{}/releases/latest", GITHUB_API, UPSTREAM_REPO);
let resp = client
.get(&url)
.header("Accept", "application/vnd.github+json")
.send()
.await
.context("GitHub releases/latest API")?;
if !resp.status().is_success() {
anyhow::bail!("GitHub releases/latest API returned {}", resp.status());
}
let release: Release = resp.json().await.context("Parse release JSON")?;
if release.draft || release.prerelease {
anyhow::bail!(
"releases/latest returned a {} release ({})",
if release.draft { "draft" } else { "prerelease" },
release.tag_name
);
}
Ok(release)
}
fn is_deb_for_arch(name: &str) -> bool {
name.starts_with("fips_") && name.ends_with(&format!("_{}.deb", deb_arch()))
}
/// Parse the leading `MAJOR.MINOR.PATCH` triple from a version string,
/// plus whether a pre-release suffix (`-dev`, `-rc1`, …) follows it.
fn parse_version(s: &str) -> Option<((u64, u64, u64), bool)> {
// Take the first whitespace token, drop a leading 'v'.
let tok = s.split_whitespace().next().unwrap_or(s);
let tok = tok.strip_prefix('v').unwrap_or(tok);
// Split off any pre-release / build suffix.
let (core, rest) = match tok.find(|c: char| c == '-' || c == '+') {
Some(i) => (&tok[..i], &tok[i..]),
None => (tok, ""),
};
let mut it = core.split('.');
let major = it.next()?.parse::<u64>().ok()?;
let minor = it.next().unwrap_or("0").parse::<u64>().ok()?;
let patch = it.next().unwrap_or("0").parse::<u64>().ok()?;
let has_prerelease = rest.starts_with('-');
Some(((major, minor, patch), has_prerelease))
}
/// True when `installed` is strictly older than release tag `latest`.
/// Same numeric triple but `installed` carries a pre-release suffix while
/// `latest` doesn't ⇒ installed is older (e.g. `0.3.0-dev` < `v0.3.0`).
/// If either side can't be parsed, fall back to "differs ⇒ update".
fn version_is_older(installed: &str, latest: &str) -> bool {
match (parse_version(installed), parse_version(latest)) {
(Some((ic, ipre)), Some((lc, lpre))) => {
if ic != lc {
ic < lc
} else {
// Equal cores: a pre-release is older than the final release.
ipre && !lpre
}
}
_ => {
// Unparseable: be conservative — offer the update unless the
// installed string already mentions the latest tag.
!installed.contains(latest.trim_start_matches('v'))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_apply_returns_clear_stub_error() {
let err = apply().await.unwrap_err().to_string();
assert!(
err.contains("not yet wired"),
"apply() should return an explicit not-yet-wired error, got: {}",
err
);
#[test]
fn test_deb_arch_maps_known() {
// On the host running tests this is whatever the test arch is;
// just assert it returns a non-empty, lowercase token.
let a = deb_arch();
assert!(!a.is_empty());
assert_eq!(a, a.to_lowercase());
}
#[test]
fn test_version_older() {
assert!(version_is_older("0.3.0-dev (rev abc123)", "v0.3.0"));
assert!(version_is_older("0.2.1", "v0.3.0"));
assert!(version_is_older("0.3.0-rc1", "v0.3.0"));
assert!(!version_is_older("0.3.0", "v0.3.0"));
assert!(!version_is_older("0.4.0", "v0.3.0"));
assert!(!version_is_older("0.3.1", "v0.3.0"));
}
#[test]
fn test_parse_version() {
assert_eq!(parse_version("v0.3.0"), Some(((0, 3, 0), false)));
assert_eq!(parse_version("0.3.0-dev (rev x)"), Some(((0, 3, 0), true)));
assert_eq!(parse_version("0.4.0-rc1"), Some(((0, 4, 0), true)));
assert_eq!(parse_version("1.2"), Some(((1, 2, 0), false)));
}
#[test]
fn test_is_deb_for_arch() {
let arch = deb_arch();
assert!(is_deb_for_arch(&format!("fips_0.3.0_{}.deb", arch)));
assert!(!is_deb_for_arch("fips_0.3.0_someotherarch.deb"));
assert!(!is_deb_for_arch("checksums-linux.txt"));
assert!(!is_deb_for_arch(&format!(
"fips-0.3.0-linux-{}.tar.gz",
arch
)));
}
#[test]
fn test_update_check_serialises() {
let uc = UpdateCheck {
current: Some("0.2.0-abc1234".to_string()),
latest_commit: "def5678".to_string(),
current: Some("0.3.0-dev".to_string()),
latest_version: "v0.3.0".to_string(),
update_available: true,
channel: "stable".to_string(),
asset_url: Some("https://example/fips_0.3.0_amd64.deb".to_string()),
notes: "test".to_string(),
};
let json = serde_json::to_string(&uc).unwrap();
assert!(json.contains("latest_commit"));
assert!(json.contains("latest_version"));
assert!(json.contains("update_available"));
assert!(json.contains("stable"));
}
}
+6 -2
View File
@@ -76,8 +76,12 @@ fn container_dependencies(name: &str) -> &'static [&'static str] {
"fedimint" => &["bitcoin"],
"fedimint-gateway" => &["bitcoin", "fedimint"],
// IndeedHub stack
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis"],
// IndeedHub stack. The API needs MinIO (object storage) up before it
// can serve — without listing it the health monitor would restart the
// API while MinIO was still coming up, which is the "needs 1-2 restarts
// to recover" symptom (#41). MinIO has no deps of its own, so the
// monitor restarts it independently first; no deadlock.
"indeedhub-api" => &["indeedhub-postgres", "indeedhub-redis", "indeedhub-minio"],
"indeedhub" => &["indeedhub-api"],
"indeedhub-relay" => &["indeedhub-postgres"],
"indeedhub-ffmpeg" => &["indeedhub-api"],
+52
View File
@@ -33,9 +33,12 @@ mod bitcoin_rpc;
mod bitcoin_status;
mod blobs;
mod bootstrap;
mod ceremony;
mod config;
mod constants;
mod container;
mod content_hash;
mod content_invoice;
mod content_server;
mod crash_recovery;
mod credentials;
@@ -64,9 +67,12 @@ mod server;
mod session;
mod settings;
mod state;
mod storage_crypto;
mod streaming;
mod swarm;
mod totp;
mod transport;
mod trust;
mod update;
mod vpn;
mod wallet;
@@ -81,6 +87,13 @@ use server::Server;
#[tokio::main]
async fn main() -> Result<()> {
// Release-root signing ceremony: a publisher-side subcommand of the same
// binary. Handle it before any server/tracing init so its stdout stays
// clean (machine-readable KEY=VALUE lines) and it never touches node state.
if ceremony::is_ceremony_invocation() {
return ceremony::run();
}
let startup_start = std::time::Instant::now();
crash_recovery::init_start_time();
@@ -271,6 +284,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 +313,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 +353,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)",
@@ -0,0 +1,489 @@
//! Mesh-AI assistant (issue #50) — answers `AssistQuery` messages with this
//! node's local LLM and sends the reply back over the mesh.
//!
//! This is the Rust-native lift of Meshroller's "LLM bridge": a trusted peer
//! asks a question over meshcore, an internet/compute-bearing node runs it
//! through a local model (Ollama) and streams the answer back in capped,
//! ordered chunks. Airtime is scarce, so the reply is length-capped and each
//! asker is limited to one in-flight query.
use super::super::message_types::{self, AssistResponsePayload, MeshMessageType};
use super::super::types::MeshEvent;
use super::bitcoin::send_to_peer;
use super::{MeshCommand, MeshState};
use crate::federation::TrustLevel;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use tracing::{info, warn};
/// Local Ollama generate endpoint (same host the Ollama app binds).
const OLLAMA_URL: &str = "http://localhost:11434/api/generate";
/// Default model when the node hasn't configured one (matches Meshroller).
const DEFAULT_MODEL: &str = "qwen2.5-coder";
/// Anthropic Messages API (called with the shared proxy token).
const CLAUDE_URL: &str = "https://api.anthropic.com/v1/messages";
/// Default Claude model — Haiku 4.5: fast + cheap, ideal for short mesh answers.
const CLAUDE_DEFAULT_MODEL: &str = "claude-haiku-4-5-20251001";
/// Max time to wait on the model before giving up.
const OLLAMA_TIMEOUT: Duration = Duration::from_secs(60);
/// Hard cap on answer length sent over the radio — keeps airtime sane.
const MAX_REPLY_CHARS: usize = 480;
/// Characters of answer text per `AssistResponse` chunk.
const CHUNK_CHARS: usize = 160;
/// Tighter cap for plain-text channel replies (bare `!ai` clients) — these
/// aren't reassembled by an archipelago UI, so keep them to a couple frames.
const CHANNEL_REPLY_CHARS: usize = 200;
/// Where an answer should go.
pub(super) enum AssistReply {
/// Typed `AssistResponse` chunks addressed to one peer — the archipelago
/// UI path (rich, reassembled, correlated by `req_id`).
Typed { contact_id: u32 },
/// Plain-text broadcast on a mesh channel — the bare `!ai` path, so any
/// client (including non-archipelago meshcore/Meshtastic nodes) sees it.
ChannelText { channel: u8 },
/// Normal `Text` chat bubble sent back into the 1:1 thread — the
/// archipelago `!ai`-in-chat path. The asker typed `!ai …` as a regular
/// direct message, so the answer lands inline in that same conversation
/// (encrypted, peer-addressed) rather than as a separate widget.
ChatText { contact_id: u32 },
/// Plain-text NATIVE direct message back to the asker's radio contact —
/// the bare `!ai` path for a stock meshcore client (e.g. a phone). The
/// answer goes as a real unicast DM (not a public-channel broadcast), so
/// only the asker sees it and a stock client can read it.
RadioDm { dest_prefix: [u8; 6] },
}
/// Entry point: gate the query, run the model, send the answer back via the
/// requested reply path. Spawned off the radio loop so it never blocks.
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_assist(
prompt: String,
model_override: Option<String>,
req_id: u64,
asker_contact_id: u32,
sender_name: String,
// Whether the asker's message was cryptographically authenticated (a
// verified signature, or arrival over the federation transport). Required
// for any identity-based allow under `trusted_only`/the allowlist.
authenticated: bool,
reply: AssistReply,
state: Arc<MeshState>,
) {
let asker = asker_contact_id;
// Trust + block gate.
if !is_sender_allowed(&state, asker, authenticated).await {
warn!(
from = asker,
name = %sender_name,
"AssistQuery denied — sender not permitted by assistant policy"
);
// Record who was turned away so the operator can find + allow them from
// the UI (the silent-on-wire denial otherwise only shows in the journal).
record_denied(&state, asker, &sender_name).await;
// Silent on the wire (no airtime spent on denials); surface to the UI.
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: Some("denied".to_string()),
});
return;
}
// One in-flight query per asker.
{
let mut inflight = state.assist_inflight.write().await;
if !inflight.insert(asker) {
warn!(
from = asker,
"AssistQuery dropped — asker already has one in flight"
);
return;
}
}
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistQueryReceived {
from_contact_id: asker,
prompt: prompt.clone(),
});
let (backend, configured_model) = {
let a = state.assistant.read().await;
(a.backend.clone(), a.model.clone())
};
let is_claude = backend == "claude";
let default_model = if is_claude {
CLAUDE_DEFAULT_MODEL
} else {
DEFAULT_MODEL
};
let model = model_override
.or(configured_model)
.unwrap_or_else(|| default_model.to_string());
info!(from = asker, req_id, backend = %backend, model = %model, "Answering AI query over mesh");
let result = if is_claude {
call_claude(&state.data_dir, &model, &prompt).await
} else {
call_ollama(&model, &prompt).await
};
match result {
Ok(answer) => {
send_reply(&state, &reply, req_id, &answer).await;
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: None,
});
}
Err(e) => {
warn!(req_id, "AI query failed: {}", e);
send_failure(&state, &reply, req_id, "AI unavailable").await;
let _ = state
.event_tx
.send(super::super::types::MeshEvent::AssistResponseReady {
req_id,
to_contact_id: asker,
error: Some(e.to_string()),
});
}
}
state.assist_inflight.write().await.remove(&asker);
}
/// Whether `sender_contact_id` may invoke the assistant under the node's policy.
///
/// Always denies user-blocked contacts. Identity-based allows (the per-contact
/// allowlist and the federation-Trusted match) require `authenticated == true` —
/// i.e. the asker's message carried a signature that verified against its known
/// key (or it arrived over the federation transport, which verifies upstream).
/// A bare radio packet can CLAIM any key or DID, so without that proof the
/// allowlist and trust list are spoofable; only the explicit "anyone on the
/// mesh" policy (`trusted_only == false`) admits an unauthenticated asker.
async fn is_sender_allowed(
state: &Arc<MeshState>,
sender_contact_id: u32,
authenticated: bool,
) -> bool {
let (pubkey_hex, did) = {
let peers = state.peers.read().await;
match peers.get(&sender_contact_id) {
// Match identity on the bound archipelago key (stable, advert/
// federation-verified), not the firmware routing key.
Some(p) => (p.identity_pubkey_hex().map(|s| s.to_string()), p.did.clone()),
None => (None, None),
}
};
// Never answer a user-blocked contact, regardless of policy.
if let Some(ref pk) = pubkey_hex {
if state
.contacts
.read()
.await
.get(pk)
.map(|c| c.blocked)
.unwrap_or(false)
{
return false;
}
}
// Explicit per-contact allowlist: a listed pubkey may ask regardless of the
// trusted_only policy — but only when the message is authenticated, so a
// spoofed packet claiming an allowlisted key can't slip through.
if authenticated {
if let Some(ref pk) = pubkey_hex {
let allowed = state.assistant.read().await.allowed_contacts.clone();
if allowed.iter().any(|a| a.eq_ignore_ascii_case(pk)) {
return true;
}
}
}
if !state.assistant.read().await.trusted_only {
return true;
}
// Trusted-only from here: an unauthenticated asker can never match the trust
// list (it could otherwise just claim a trusted node's public key/DID).
if !authenticated {
return false;
}
// Match against the federation trust list by the asker's verified archipelago
// pubkey or DID (a radio peer gets these from its signed identity advert).
let nodes = crate::federation::load_nodes(&state.data_dir)
.await
.unwrap_or_default();
nodes.iter().any(|n| {
n.trust_level == TrustLevel::Trusted
&& (Some(&n.pubkey) == pubkey_hex.as_ref() || Some(&n.did) == did.as_ref())
})
}
/// Newest-first cap on the denied-asker buffer — enough to surface the people
/// who recently tried, without unbounded growth from a spammer.
const MAX_DENIED_ASKERS: usize = 25;
/// Record a turned-away `!ai` asker so the UI can offer a one-click "Allow".
/// Dedupes by contact id (moves an existing entry to the front and refreshes its
/// timestamp/name) so repeated denials from one device don't flood the list.
async fn record_denied(state: &Arc<MeshState>, asker_contact_id: u32, sender_name: &str) {
// Capture the bound archipelago identity key (NOT the firmware routing key):
// one-click "Allow" adds this to the allowlist, which the gate matches on the
// archipelago key. A peer with no advert has no arch key → None → the UI shows
// "no key" (only the "anyone on the mesh" policy can admit it).
let pubkey_hex = {
let peers = state.peers.read().await;
peers
.get(&asker_contact_id)
.and_then(|p| p.arch_pubkey_hex.clone())
};
let entry = super::DeniedAsker {
contact_id: asker_contact_id,
name: sender_name.to_string(),
pubkey_hex,
at: chrono::Utc::now().to_rfc3339(),
};
let mut denied = state.assist_denied.write().await;
denied.retain(|d| d.contact_id != asker_contact_id);
denied.push_front(entry);
denied.truncate(MAX_DENIED_ASKERS);
}
/// Cap the answer to `MAX_REPLY_CHARS`, appending a marker when truncated.
/// Returns (text_to_send, was_truncated).
fn cap_reply(answer: &str) -> (String, bool) {
let trimmed = answer.trim();
if trimmed.chars().count() <= MAX_REPLY_CHARS {
return (trimmed.to_string(), false);
}
let capped: String = trimmed.chars().take(MAX_REPLY_CHARS).collect();
(format!("{capped}…(truncated)"), true)
}
/// Send a successful answer via the requested reply path.
async fn send_reply(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, answer: &str) {
match reply {
AssistReply::Typed { contact_id } => {
let (text, _) = cap_reply(answer);
send_typed_chunks(state, *contact_id, req_id, &text).await;
}
AssistReply::ChannelText { channel } => {
let text = cap_channel(answer);
send_channel_text(state, *channel, &text).await;
}
AssistReply::ChatText { contact_id } => {
let (text, _) = cap_reply(answer);
send_chat_text(state, *contact_id, &text).await;
}
AssistReply::RadioDm { dest_prefix } => {
let text = cap_channel(answer);
let _ = state
.send_cmd(MeshCommand::SendNativeText {
dest_pubkey_prefix: *dest_prefix,
payload: text.into_bytes(),
})
.await;
}
}
}
/// Send a failure notice via the requested reply path.
async fn send_failure(state: &Arc<MeshState>, reply: &AssistReply, req_id: u64, msg: &str) {
match reply {
AssistReply::Typed { contact_id } => {
let payload = AssistResponsePayload {
req_id,
text: String::new(),
seq: 0,
done: true,
error: Some(msg.to_string()),
};
send_typed_response(state, *contact_id, &payload).await;
}
AssistReply::ChannelText { channel } => {
send_channel_text(state, *channel, &format!("AI: {msg}")).await;
}
AssistReply::ChatText { contact_id } => {
send_chat_text(state, *contact_id, &format!("AI: {msg}")).await;
}
AssistReply::RadioDm { dest_prefix } => {
let _ = state
.send_cmd(MeshCommand::SendNativeText {
dest_pubkey_prefix: *dest_prefix,
payload: format!("AI: {msg}").into_bytes(),
})
.await;
}
}
}
/// Split the answer into ordered `AssistResponse` chunks and send each back to
/// the asker on the encrypted, peer-addressed path (archipelago UI path).
async fn send_typed_chunks(state: &Arc<MeshState>, dest_contact_id: u32, req_id: u64, text: &str) {
let chars: Vec<char> = text.chars().collect();
let chunks: Vec<String> = if chars.is_empty() {
vec![String::new()]
} else {
chars
.chunks(CHUNK_CHARS)
.map(|c| c.iter().collect())
.collect()
};
let last = chunks.len().saturating_sub(1);
for (i, chunk) in chunks.into_iter().enumerate() {
let payload = AssistResponsePayload {
req_id,
text: chunk,
seq: i as u16,
done: i == last,
error: None,
};
send_typed_response(state, dest_contact_id, &payload).await;
}
}
/// Encode an `AssistResponse` payload and send it to a peer.
async fn send_typed_response(
state: &Arc<MeshState>,
dest_contact_id: u32,
payload: &AssistResponsePayload,
) {
let bytes = match message_types::encode_payload(payload) {
Ok(b) => b,
Err(e) => {
warn!("Failed to encode AssistResponse: {}", e);
return;
}
};
let envelope = message_types::TypedEnvelope::new(MeshMessageType::AssistResponse, bytes);
match envelope.to_wire() {
Ok(wire) => send_to_peer(state, dest_contact_id, wire).await,
Err(e) => warn!("Failed to encode AssistResponse envelope: {}", e),
}
}
/// Send the answer back into the 1:1 chat thread as a normal chat bubble.
/// Used for the `!ai`-in-chat path. We emit an `AssistChatReply` event rather
/// than sending here, because the reply must be routed transport-aware:
/// `!ai` can arrive over LoRa OR over federation (Tor), and only
/// `MeshService::send_message` (which owns the signing key + Tor client) knows
/// to POST over the peer's onion for a federation-synthetic contact_id. The
/// radio-only path used to drop the reply for federation askers — the answer
/// showed on the answering node but never reached the asker. A server-layer
/// consumer fulfils this event via `send_message`, which also records the
/// Sent bubble and allocates the seq.
async fn send_chat_text(state: &Arc<MeshState>, contact_id: u32, text: &str) {
let _ = state.event_tx.send(MeshEvent::AssistChatReply {
contact_id,
text: text.to_string(),
});
}
/// Broadcast a plain-text answer on a channel for bare `!ai` clients.
async fn send_channel_text(state: &Arc<MeshState>, channel: u8, text: &str) {
let _ = state
.send_cmd(MeshCommand::BroadcastChannel {
channel,
payload: text.as_bytes().to_vec(),
})
.await;
}
/// Cap a plain-text channel reply to a couple of frames.
fn cap_channel(answer: &str) -> String {
let trimmed = answer.trim();
if trimmed.chars().count() <= CHANNEL_REPLY_CHARS {
return format!("AI: {trimmed}");
}
let capped: String = trimmed.chars().take(CHANNEL_REPLY_CHARS).collect();
format!("AI: {capped}")
}
/// Call the local Ollama model and return the generated text.
async fn call_ollama(model: &str, prompt: &str) -> anyhow::Result<String> {
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
let body = serde_json::json!({
"model": model,
"prompt": prompt,
"stream": false,
});
let resp = client.post(OLLAMA_URL).json(&body).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Ollama returned HTTP {}", resp.status());
}
let json: serde_json::Value = resp.json().await?;
let text = json
.get("response")
.and_then(|r| r.as_str())
.unwrap_or("")
.to_string();
if text.trim().is_empty() {
anyhow::bail!("Ollama returned an empty response");
}
Ok(text)
}
/// Call Claude via the Anthropic Messages API using the node's shared proxy
/// token at `secrets/claude-api-key`. Keeps answers short for radio airtime.
async fn call_claude(data_dir: &Path, model: &str, prompt: &str) -> anyhow::Result<String> {
let key = tokio::fs::read_to_string(data_dir.join("secrets/claude-api-key"))
.await
.map_err(|_| anyhow::anyhow!("Claude API key not configured on this node"))?;
let key = key.trim();
if key.is_empty() {
anyhow::bail!("Claude API key is empty");
}
let client = reqwest::Client::builder().timeout(OLLAMA_TIMEOUT).build()?;
let body = serde_json::json!({
"model": model,
"max_tokens": 512,
"system": "You answer questions over a low-bandwidth radio mesh. Reply in at most two short sentences. No markdown, no preamble.",
"messages": [{ "role": "user", "content": prompt }],
});
let resp = client
.post(CLAUDE_URL)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
let txt = resp.text().await.unwrap_or_default();
anyhow::bail!(
"Claude API HTTP {}: {}",
status,
txt.chars().take(180).collect::<String>()
);
}
let json: serde_json::Value = resp.json().await?;
// `content` is an array of blocks; take the first text block.
let text = json
.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| {
arr.iter()
.find_map(|b| b.get("text").and_then(|t| t.as_str()))
})
.unwrap_or("")
.to_string();
if text.trim().is_empty() {
anyhow::bail!("Claude returned an empty response");
}
Ok(text)
}
@@ -445,7 +445,9 @@ async fn encrypt_for_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: &
/// Send raw wire bytes to a specific peer by contact_id.
/// Encrypts directed messages via ratchet or shared secret when available.
/// Falls back to channel 0 broadcast (plaintext) if peer's pubkey is unknown.
async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
/// `pub(super)` so sibling handlers (e.g. the AI assistant) can reply on the
/// same encrypted, peer-addressed path the relay handlers use.
pub(super) async fn send_to_peer(state: &Arc<MeshState>, contact_id: u32, typed_wire: Vec<u8>) {
let peers = state.peers.read().await;
if let Some(peer) = peers.get(&contact_id) {
if let Some(ref pk) = peer.pubkey_hex {
@@ -352,6 +352,59 @@ pub(super) async fn store_plain_message(
state.store_message(msg.clone()).await;
state.status.write().await.messages_received += 1;
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
// Mesh-AI assistant (issue #50): a plain `!ai`/`!ask <question>` is answered
// by this node's local model when the assistant is on. The trust/rate gate
// lives in run_assist. The reply goes back as a private NATIVE DM to the
// asker whenever we know its radio pubkey (so it does NOT land on the public
// channel and a stock meshcore client can read it); we only fall back to a
// channel reply if the sender has no resolvable pubkey (rare).
if state.assistant.read().await.enabled {
if let Some(prompt) = strip_ai_trigger(text) {
if !prompt.is_empty() {
let reply = {
let peers = state.peers.read().await;
peers
.get(&contact_id)
.and_then(|p| p.pubkey_hex.clone())
.filter(|h| h.len() >= 12)
.and_then(|h| hex::decode(&h[..12]).ok())
.filter(|b| b.len() == 6)
.map(|b| {
let mut pre = [0u8; 6];
pre.copy_from_slice(&b);
super::assist::AssistReply::RadioDm { dest_prefix: pre }
})
.unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 })
};
let req_id = state.next_id().await;
let prompt = prompt.to_string();
let name = peer_name.to_string();
let st = Arc::clone(state);
tokio::spawn(async move {
// A bare plain-text channel `!ai` carries no signature, so it
// is NOT authenticated — under trusted_only it'll be denied,
// and it can only be answered under the "anyone" policy.
super::assist::run_assist(
prompt, None, req_id, contact_id, name, false, reply, st,
)
.await;
});
}
}
}
}
/// Recognise a `!ai`/`!ask ` command prefix (case-insensitive) and return the
/// trimmed question after it, or `None` if the text isn't an AI command.
pub(super) fn strip_ai_trigger(text: &str) -> Option<&str> {
let t = text.trim_start();
for p in ["!ai ", "!ask "] {
if t.len() >= p.len() && t[..p.len()].eq_ignore_ascii_case(p) {
return Some(t[p.len()..].trim());
}
}
None
}
/// Handle a received identity broadcast from a peer.
@@ -436,11 +489,18 @@ pub(super) async fn handle_identity_received(
advert_name: format!("Archy-{}", &did[8..16.min(did.len())]),
did: Some(did.to_string()),
pubkey_hex: Some(ed_pubkey_hex.to_string()),
// The advert signature was verified above, so this is an authenticated
// archipelago identity. Bind it separately so a later refresh_contacts
// (which rewrites pubkey_hex to the firmware routing key) can't drop it.
arch_pubkey_hex: Some(ed_pubkey_hex.to_string()),
x25519_pubkey: Some(x25519_bytes),
rssi: Some(rssi),
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
last_advert: 0,
// We just heard this peer's identity advert, so it's reachable.
reachable: true,
};
let is_new = {
+123 -3
View File
@@ -83,14 +83,22 @@ pub(crate) async fn handle_typed_envelope_direct(
sender_name: &str,
envelope: TypedEnvelope,
) {
// Verify envelope signature if present, using the sender's known Ed25519 key
// Verify the envelope signature (if present) against the sender's known
// Ed25519 key, and record whether the sender is cryptographically
// authenticated. A federation peer (synthetic high-half contact_id) arrived
// over the Tor relay, which verifies the sender signature upstream before
// injecting here, so it counts as authenticated. This flag gates the
// identity-based `!ai` allows (allowlist / federation-trust) downstream.
let mut authenticated = sender_contact_id >= crate::mesh::FEDERATION_CONTACT_ID_BASE;
if envelope.sig.is_some() {
let peer_pubkey = state
.peers
.read()
.await
.get(&sender_contact_id)
.and_then(|p| p.pubkey_hex.as_ref())
// Verify against the bound archipelago identity key, not the
// firmware routing key — only the former is what the peer signs with.
.and_then(|p| p.identity_pubkey_hex())
.and_then(|hex_str| hex::decode(hex_str).ok())
.and_then(|bytes| {
if bytes.len() == 32 {
@@ -103,7 +111,9 @@ pub(crate) async fn handle_typed_envelope_direct(
});
if let Some(vk) = peer_pubkey {
match envelope.verify_signature(&vk) {
Ok(true) => {}
Ok(true) => {
authenticated = true;
}
Ok(false) => {
warn!(
peer = sender_contact_id,
@@ -679,6 +689,112 @@ pub(crate) async fn handle_typed_envelope_direct(
Some(envelope.seq),
)
.await;
// Mesh-AI assistant (issue #50): a `!ai`/`!ask <question>` typed in
// the normal 1:1 chat triggers this node's assistant, with the
// answer sent back as a chat bubble in the same thread. The typed
// DM carries the peer's federation identity (via sender_contact_id),
// so the `trusted_only` gate in run_assist resolves correctly —
// unlike the bare channel-text path, which only knows the radio key.
if state.assistant.read().await.enabled {
if let Some(prompt) = super::decode::strip_ai_trigger(&text) {
if !prompt.is_empty() {
let req_id = state.next_id().await;
let prompt = prompt.to_string();
let name = sender_name.to_string();
let cid = sender_contact_id;
let st = Arc::clone(state);
tokio::spawn(async move {
super::assist::run_assist(
prompt,
None,
req_id,
cid,
name,
authenticated,
super::assist::AssistReply::ChatText { contact_id: cid },
st,
)
.await;
});
}
}
}
}
Some(MeshMessageType::AssistQuery) => {
match message_types::decode_payload::<message_types::AssistQueryPayload>(&envelope.v) {
Ok(query) => {
if !state.assistant.read().await.enabled {
debug!(
from = sender_contact_id,
"AssistQuery ignored — assistant disabled on this node"
);
return;
}
info!(
from = sender_contact_id,
req_id = query.req_id,
"AI query received over mesh"
);
let json = payload_to_json(&query);
store_typed_message(
state,
sender_contact_id,
sender_name,
&query.prompt,
"assist_query",
json,
Some(envelope.seq),
)
.await;
// Run the model + reply off the radio loop. Typed query →
// typed chunked reply back to the asking peer.
let assist_state = Arc::clone(state);
let name = sender_name.to_string();
tokio::spawn(async move {
super::assist::run_assist(
query.prompt,
query.model,
query.req_id,
sender_contact_id,
name,
authenticated,
super::assist::AssistReply::Typed {
contact_id: sender_contact_id,
},
assist_state,
)
.await;
});
}
Err(e) => warn!("Failed to decode AssistQuery payload: {}", e),
}
}
Some(MeshMessageType::AssistResponse) => {
match message_types::decode_payload::<message_types::AssistResponsePayload>(&envelope.v)
{
Ok(resp) => {
let display = resp
.error
.clone()
.map(|e| format!("AI error: {e}"))
.unwrap_or_else(|| resp.text.clone());
let json = payload_to_json(&resp);
store_typed_message(
state,
sender_contact_id,
sender_name,
&display,
"assist_response",
json,
Some(envelope.seq),
)
.await;
}
Err(e) => warn!("Failed to decode AssistResponse payload: {}", e),
}
}
_ => {
@@ -697,6 +813,10 @@ async fn dispatch_block_header(
sender_name: &str,
state: &Arc<MeshState>,
) {
// Respect the receive toggle (issue #28): nodes can opt out of inbound headers.
if !state.receive_block_headers {
return;
}
// Compact binary format: height(8) + hash(32) + timestamp(4)
match super::super::bitcoin_relay::decode_compact_block_header(&envelope.v) {
Ok((height, hash_hex, timestamp)) => {
+65 -6
View File
@@ -3,8 +3,8 @@
use super::super::message_types::TypedEnvelope;
use super::super::protocol;
use super::decode::{
is_mc_chunk_frame, resolve_peer, store_plain_message, try_base64_typed, try_chunk_reassemble,
try_decrypt_base64, try_decrypt_ratchet_base64,
handle_identity_received, is_mc_chunk_frame, resolve_peer, store_plain_message,
try_base64_typed, try_chunk_reassemble, try_decrypt_base64, try_decrypt_ratchet_base64,
};
use super::dispatch::handle_typed_message;
use super::MeshState;
@@ -18,13 +18,37 @@ pub(super) async fn handle_frame(
state: &Arc<MeshState>,
our_x25519_secret: &[u8; 32],
) -> bool {
let _ = our_x25519_secret; // reserved for future per-frame decryption
match frame.code {
protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => {
info!(
code = frame.code,
data_len = frame.data.len(),
"Contact discovery event — refreshing contacts"
);
// Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte
// pubkey of a node we just heard. If it isn't already a contact,
// add it to the firmware table so it shows up immediately — no
// flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already
// added by the firmware, so we skip it.)
if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 {
let mut pubkey = [0u8; 32];
pubkey.copy_from_slice(&frame.data[..32]);
let pk_hex = hex::encode(pubkey);
let known = state
.peers
.read()
.await
.values()
.any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str()));
if !known {
let _ = state
.send_cmd(super::MeshCommand::AddContact {
pubkey,
name: String::new(),
})
.await;
}
}
return true; // Signal caller to fetch contacts
}
@@ -109,7 +133,8 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v3_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload).await;
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
}
}
Err(e) => warn!("Failed to parse v3 channel message: {}", e),
@@ -121,7 +146,8 @@ pub(super) async fn handle_frame(
match protocol::parse_channel_msg_v1_raw(&frame.data) {
Ok((channel_idx, payload)) => {
if !payload.is_empty() {
handle_channel_payload(state, channel_idx, &payload).await;
handle_channel_payload(state, channel_idx, &payload, our_x25519_secret)
.await;
}
}
Err(e) => warn!("Failed to parse channel message: {}", e),
@@ -146,7 +172,12 @@ pub(super) async fn handle_frame(
/// local mesh peer pubkeys (or we can't tell), the inner payload is
/// dispatched through the direct-message path so it lands in the right
/// chat. Otherwise it's handled as a normal channel text/typed message.
async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload: &[u8]) {
async fn handle_channel_payload(
state: &Arc<MeshState>,
channel_idx: u8,
payload: &[u8],
our_x25519_secret: &[u8; 32],
) {
// DM-via-channel wrapper (text form): the channel text carries an
// ASCII "@DM:<base64>" token somewhere in the body. We locate the
// marker anywhere in the payload (the firmware auto-prepends the
@@ -326,6 +357,34 @@ async fn handle_channel_payload(state: &Arc<MeshState>, channel_idx: u8, payload
return;
}
// Archipelago identity broadcast (`ARCHY:`): upsert the sender's real
// archipelago identity (DID + ed25519 + x25519) so trust-gating and
// encrypted DMs work over BOTH meshcore and Meshtastic — the latter
// otherwise only exposes synthetic node keys. Keyed by the archipelago
// pubkey (federation_peer_contact_id) so it MERGES with the federation-
// seeded peer instead of creating a duplicate chat thread. Not stored as
// a chat message.
if let Ok(text) = std::str::from_utf8(payload) {
if let Some((did, ed_hex, x_hex)) = super::super::protocol::parse_identity_broadcast(text) {
// Ignore our own identity echoed back by the radio/channel.
if ed_hex.eq_ignore_ascii_case(&state.our_ed_pubkey_hex) {
return;
}
let contact_id = super::super::federation_peer_contact_id(&ed_hex);
handle_identity_received(
contact_id,
0,
&did,
&ed_hex,
&x_hex,
state,
our_x25519_secret,
)
.await;
return;
}
}
// Regular channel broadcast (not DM-wrapped)
let chan_contact_id = u32::MAX - (channel_idx as u32);
let chan_name = format!("Channel {}", channel_idx);
+79
View File
@@ -7,6 +7,7 @@
//! - Reconnects on device disconnect
//! - Manages peer cache and message store
mod assist;
mod bitcoin;
mod decode;
pub(crate) mod dispatch;
@@ -62,6 +63,14 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Send PLAIN text as one or more native meshcore DMs to a stock client
/// (e.g. a phone). Long text is split into multiple readable plain messages
/// — never MC-chunked — because stock clients can't reassemble archy's
/// chunk framing. Used for chat/AI replies to non-archipelago contacts.
SendNativeText {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// Broadcast pre-encoded binary on a mesh channel.
BroadcastChannel {
channel: u8,
@@ -70,6 +79,16 @@ pub enum MeshCommand {
SendAdvert,
/// Re-fetch contact list from the radio device.
RefreshContacts,
/// Delete a contact from the firmware table (clear-all / unreachable wipe).
RemoveContact {
pubkey: [u8; 32],
},
/// Import/add a heard advert as a firmware contact so it shows up without
/// needing a flood advert. Name may be empty (firmware fills from advert).
AddContact {
pubkey: [u8; 32],
name: String,
},
}
/// Shared state for the mesh listener, accessible from RPC handlers.
@@ -102,6 +121,8 @@ pub struct MeshState {
pub session_manager: Arc<super::session::SessionManager>,
/// Whether to encrypt directed relay messages (config toggle for rollback).
pub encrypt_relay: bool,
/// Whether to accept inbound Bitcoin block headers from peers (issue #28).
pub receive_block_headers: bool,
/// Last-seen presence heartbeats per peer pubkey hex: (status, last_active_epoch, received_at).
pub presence: RwLock<HashMap<String, (String, u32, u64)>>,
/// Contacts store — alias/notes/pinned/blocked per peer pubkey hex.
@@ -121,6 +142,56 @@ pub struct MeshState {
/// persistent contact table from regenerating rows the user just
/// wiped. Persisted to `mesh-ignored-radio-contacts.json`.
pub radio_contact_blocklist: RwLock<HashSet<String>>,
/// Mesh-AI assistant settings (issue #50): whether this node answers
/// AssistQuery messages with its local LLM, and who may ask. Live-updatable
/// so the UI toggle applies without restarting the listener.
pub assistant: RwLock<AssistantConfig>,
/// Data dir — lets dispatch handlers reach disk-backed stores (e.g. the
/// federation trust list used to gate AI queries) without threading a path
/// through every call.
pub data_dir: std::path::PathBuf,
/// Contact-ids with an AI query currently being answered. Caps each asker to
/// one in-flight query so a peer can't flood the node's compute / airtime.
pub assist_inflight: RwLock<HashSet<u32>>,
/// Recently-denied `!ai` askers (newest first, capped). When `trusted_only`
/// rejects a sender — typically a radio (meshcore) device that presents a
/// firmware key rather than an archipelago DID — we record who tried so the
/// UI can surface them and let the operator one-click allow their key.
/// Silent on the wire (no airtime spent), visible to the operator here.
pub assist_denied: RwLock<VecDeque<DeniedAsker>>,
}
/// A `!ai` asker that the assistant policy turned away. Surfaced to the UI so
/// the operator can add their key to the allowlist without hunting the journal.
#[derive(Debug, Clone, Serialize)]
pub struct DeniedAsker {
/// Meshcore contact id of the asker.
pub contact_id: u32,
/// Best-known display name (advert name) at denial time.
pub name: String,
/// The asker's ed25519 pubkey hex, if known. `None` for a raw radio device
/// that hasn't advertised an archipelago key — such a sender can only be
/// admitted by switching the policy to "anyone", not via the allowlist.
pub pubkey_hex: Option<String>,
/// ISO-8601 timestamp of the (most recent) denial.
pub at: String,
}
/// Mesh-AI assistant configuration, snapshotted from `MeshConfig` at startup.
#[derive(Debug, Clone)]
pub struct AssistantConfig {
/// Answer AssistQuery messages with the local LLM.
pub enabled: bool,
/// Model to use; None → the backend's built-in default.
pub model: Option<String>,
/// Restrict asking to federation-Trusted peers (vs. anyone on the mesh).
pub trusted_only: bool,
/// AI backend: "claude" (shared proxy token) or "ollama" (local model).
pub backend: String,
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai`
/// regardless of `trusted_only`. Empty → only the `trusted_only` policy
/// applies. A user-blocked contact is always denied even if listed here.
pub allowed_contacts: Vec<String>,
}
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
@@ -151,8 +222,11 @@ impl MeshState {
relay_tracker: Option<Arc<super::bitcoin_relay::RelayTracker>>,
stego_mode: super::steganography::SteganographyMode,
encrypt_relay: bool,
receive_block_headers: bool,
session_manager: Arc<super::session::SessionManager>,
our_ed_pubkey_hex: String,
assistant: AssistantConfig,
data_dir: std::path::PathBuf,
) -> (
Arc<Self>,
broadcast::Receiver<MeshEvent>,
@@ -187,11 +261,16 @@ impl MeshState {
chunk_buffer: RwLock::new(HashMap::new()),
session_manager,
encrypt_relay,
receive_block_headers,
presence: RwLock::new(HashMap::new()),
contacts: RwLock::new(HashMap::new()),
our_ed_pubkey_hex,
blob_store: RwLock::new(None),
radio_contact_blocklist: RwLock::new(HashSet::new()),
assistant: RwLock::new(assistant),
data_dir,
assist_inflight: RwLock::new(HashSet::new()),
assist_denied: RwLock::new(VecDeque::new()),
});
(state, rx, cmd_rx)
}
+183 -27
View File
@@ -53,6 +53,43 @@ impl MeshRadioDevice {
}
}
async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> {
match self {
Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await,
}
}
async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
match self {
Self::Meshcore(device) => device.remove_contact(pubkey).await,
Self::Meshtastic(device) => device.remove_contact(pubkey).await,
}
}
async fn add_contact(
&mut self,
pubkey: &[u8; 32],
contact_type: u8,
flags: u8,
out_path_len: u8,
name: &str,
last_advert: u32,
) -> Result<()> {
match self {
Self::Meshcore(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
Self::Meshtastic(device) => {
device
.add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert)
.await
}
}
}
async fn get_contacts(&mut self) -> Result<Vec<super::super::protocol::ParsedContact>> {
match self {
Self::Meshcore(device) => device.get_contacts().await,
@@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:";
/// route inbound DMs to the correct contact_id thread.
pub(super) const DM_V2_MARKER: &str = "@DM2:";
#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast
fn wrap_dm_for_channel(
dest_pubkey_prefix: &[u8; 6],
sender_arch_prefix: &[u8; 6],
@@ -169,6 +207,7 @@ fn wrap_dm_for_channel(
/// `[0u8; 6]` if the stored hex is malformed (which would only happen if a
/// caller constructed `MeshState` with a bad value — empty string yields
/// all-zero, which won't match any real peer on the receiver side).
#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it
fn our_sender_prefix(state: &Arc<MeshState>) -> [u8; 6] {
let mut out = [0u8; 6];
if state.our_ed_pubkey_hex.len() >= 12 {
@@ -195,39 +234,42 @@ async fn send_dm_via_channel(
consecutive_write_failures: &mut u32,
) {
use base64::Engine;
let sender_prefix = our_sender_prefix(state);
// First try a single frame with the raw payload directly wrapped.
// This keeps small plain-text messages at minimal overhead.
let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload);
if single.len() <= 140 {
match device.send_channel_text(0, single.as_bytes()).await {
let _ = state; // native unicast carries no separate sender prefix
// NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the
// contact, NOT a broadcast on the shared public channel. This is the fix
// for the long-standing public-channel pollution — archy used to tunnel
// every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every
// mesh participant saw as spam and (b) stock meshcore clients (e.g. a
// phone) couldn't decode. A native DM is private and decodes everywhere.
// The receive side handles these via the existing RESP_CONTACT_MSG path.
//
// Small payloads send in one frame; larger ones are base64 + MC-chunked
// and reassembled by the receiver (try_chunk_reassemble).
if payload.len() <= 140 {
match device.send_text_msg(dest_pubkey_prefix, payload).await {
Ok(()) => {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
len = payload.len(),
wire_len = single.len(),
"Sent mesh message (DM via channel)"
"Sent mesh DM (native unicast)"
);
}
Err(e) => {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Failed to send DM via channel: {}", e
"Failed to send native DM: {}", e
);
}
}
return;
}
// Payload too large for one wrap — base64 then MC-chunk. Receiver
// reassembles base64 chunks and routes the decoded bytes back through
// the typed-envelope ladder in handle_channel_payload.
let encoded = base64::engine::general_purpose::STANDARD.encode(payload);
static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let chunk_data_size = 80;
let chunk_data_size = 100;
let chunks: Vec<&str> = encoded
.as_bytes()
.chunks(chunk_data_size)
@@ -239,18 +281,20 @@ async fn send_dm_via_channel(
raw_len = payload.len(),
b64_len = encoded.len(),
chunks = total,
"Sending chunked mesh message (DM via channel)"
"Sending chunked mesh DM (native unicast)"
);
let mut any_err = false;
for (idx, chunk) in chunks.iter().enumerate() {
let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk);
let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes());
if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await {
if let Err(e) = device
.send_text_msg(dest_pubkey_prefix, frame.as_bytes())
.await
{
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
chunk = idx,
"Chunk DM-via-channel send failed: {}",
"Chunk native DM send failed: {}",
e
);
any_err = true;
@@ -263,20 +307,72 @@ async fn send_dm_via_channel(
}
}
/// Send PLAIN text to a stock meshcore client as one or more native DMs.
/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock
/// clients can't reassemble it) — if the text exceeds one LoRa frame it is
/// split into multiple readable plain messages on UTF-8 char boundaries.
async fn send_plain_native_text(
device: &mut MeshRadioDevice,
dest_pubkey_prefix: &[u8; 6],
text: &[u8],
consecutive_write_failures: &mut u32,
) {
// Split on char boundaries so we never break a multi-byte UTF-8 sequence.
const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room
let s = String::from_utf8_lossy(text);
let mut parts: Vec<String> = Vec::new();
let mut cur = String::new();
for ch in s.chars() {
if cur.len() + ch.len_utf8() > FRAME {
parts.push(std::mem::take(&mut cur));
}
cur.push(ch);
}
if !cur.is_empty() || parts.is_empty() {
parts.push(cur);
}
let total = parts.len();
for (idx, part) in parts.iter().enumerate() {
match device
.send_text_msg(dest_pubkey_prefix, part.as_bytes())
.await
{
Ok(()) => {
*consecutive_write_failures = 0;
info!(
dest = %hex::encode(dest_pubkey_prefix),
part = idx + 1,
total,
"Sent plain native DM"
);
}
Err(e) => {
*consecutive_write_failures += 1;
warn!(
failures = *consecutive_write_failures,
"Plain native DM send failed: {}", e
);
break;
}
}
if total > 1 {
tokio::time::sleep(Duration::from_millis(400)).await;
}
}
}
/// Fetch the contacts list from the device and update the peer cache.
async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>) {
match device.get_contacts().await {
Ok(contacts) => {
// Skip firmware contacts the user has explicitly wiped via
// mesh.clear-all. MeshCore keeps its own persistent contact
// table the app can't remove from, so we filter on read to
// keep cleared entries out of the chat list.
let blocklist = state.radio_contact_blocklist.read().await.clone();
// Contact blocking is intentionally NOT applied here. A read-time
// blocklist meant a wiped/re-paired contact could never come back
// even when it re-advertised (it broke phone re-pairing after a
// clear). Per-contact blocking will return later as an explicit,
// user-controlled feature; until then every firmware contact is
// surfaced. `radio_contact_blocklist` is retained but unused.
let mut peers = state.peers.write().await;
for (idx, contact) in contacts.iter().enumerate() {
if blocklist.contains(&contact.public_key_hex) {
continue;
}
let contact_id = idx as u32;
let existing = peers.get(&contact_id);
let peer = super::super::types::MeshPeer {
@@ -284,14 +380,31 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc<MeshState>)
advert_name: contact.advert_name.clone(),
did: existing.and_then(|p| p.did.clone()),
pubkey_hex: Some(contact.public_key_hex.clone()),
// Preserve any archipelago identity bound by an earlier
// identity advert — NEVER overwrite it with the firmware
// contact key, or a signed `!ai` query from this peer would
// fail authentication after the next contact refresh.
arch_pubkey_hex: existing.and_then(|p| p.arch_pubkey_hex.clone()),
x25519_pubkey: existing.and_then(|p| p.x25519_pubkey),
rssi: None,
snr: None,
last_heard: chrono::Utc::now().to_rfc3339(),
hops: 0,
last_advert: contact.last_advert,
// A non-zero path_len means the firmware has a route (direct
// or flood) to this contact — i.e. we can deliver to it.
reachable: contact.path_len != 0,
};
peers.insert(contact_id, peer);
}
// A radio contact that shares an exact advert_name with a known
// federation peer is the same physical node — bind the federation
// peer's archipelago identity onto the radio record so a signed
// `!ai`/typed message over LoRa authenticates (and the contact stops
// showing as a radio/federation duplicate). Security is unchanged:
// the bound key is only a candidate the inbound signature must still
// verify against. See `bind_federation_twins`.
super::super::bind_federation_twins(&mut peers);
drop(peers);
state.update_peer_count().await;
if !contacts.is_empty() {
@@ -363,9 +476,9 @@ pub(super) async fn run_mesh_session(
state: &Arc<MeshState>,
preferred_path: Option<&str>,
our_did: &str,
_our_ed_pubkey_hex: &str,
our_ed_pubkey_hex: &str,
our_x25519_secret: &[u8; 32],
_our_x25519_pubkey_hex: &str,
our_x25519_pubkey_hex: &str,
server_name: Option<&str>,
shutdown: &mut tokio::sync::watch::Receiver<bool>,
cmd_rx: &mut mpsc::Receiver<MeshCommand>,
@@ -424,6 +537,17 @@ pub(super) async fn run_mesh_session(
warn!("Failed to send initial advert: {}", e);
}
// NOTE: Archipelago identity adverts (`ARCHY:2:{ed}:{x25519}`) are intentionally
// NOT broadcast on the shared public channel (channel 0). Doing so spams every
// participant on that channel — including plain Meshtastic/meshcore users who
// just see raw `ARCHY:2:…` text — on startup and again on every advert tick.
// The inbound parser in frames.rs still accepts these from any legacy peer that
// sends them, so trust-binding keeps working when a peer advertises; we simply
// don't pollute the public channel ourselves. A dedicated control channel (or a
// DM-targeted handshake) is the proper transport for this and is tracked
// separately. See encode_identity_broadcast / parse_identity_broadcast.
let _ = (our_did, our_ed_pubkey_hex, our_x25519_pubkey_hex);
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
@@ -491,6 +615,9 @@ pub(super) async fn run_mesh_session(
} else {
consecutive_write_failures = 0;
}
// (Identity re-broadcast on the public channel intentionally
// removed — see the note at session startup. It spammed the
// shared channel every advert tick.)
refresh_contacts(&mut device, state).await;
}
@@ -541,6 +668,18 @@ async fn handle_send_command(
)
.await;
}
MeshCommand::SendNativeText {
dest_pubkey_prefix,
payload,
} => {
send_plain_native_text(
device,
&dest_pubkey_prefix,
&payload,
consecutive_write_failures,
)
.await;
}
MeshCommand::SendRaw {
dest_pubkey_prefix,
payload,
@@ -594,5 +733,22 @@ async fn handle_send_command(
MeshCommand::RefreshContacts => {
refresh_contacts(device, state).await;
}
MeshCommand::RemoveContact { pubkey } => {
if let Err(e) = device.remove_contact(&pubkey).await {
warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e);
} else {
info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact");
}
}
MeshCommand::AddContact { pubkey, name } => {
// type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood
// until a path is learned). last_advert=0 lets the firmware keep its
// own advert timestamp.
if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await {
warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e);
} else {
info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact");
}
}
}
}
+109
View File
@@ -42,6 +42,14 @@ pub struct MeshtasticDevice {
long_name: Option<String>,
short_name: Option<String>,
contacts: HashMap<u32, ParsedContact>,
/// Real Curve25519 public keys, keyed by node-num, as learned from NodeInfo
/// (`User.public_key`) or PKC-encrypted inbound packets (`MeshPacket
/// .public_key`). Kept SEPARATE from `contacts[*].public_key_hex`, which is
/// the synthetic node-num-derived routing key that `send_text_msg` relies
/// on — we must not overwrite that or unicast routing breaks. This map only
/// records which peers are PKC-capable, so we can tell a true end-to-end
/// (PKI) DM from a channel-PSK fallback.
peer_pubkeys: HashMap<u32, Vec<u8>>,
device_path: String,
}
@@ -68,6 +76,7 @@ impl MeshtasticDevice {
long_name: None,
short_name: None,
contacts: HashMap::new(),
peer_pubkeys: HashMap::new(),
device_path: path.to_string(),
})
}
@@ -150,6 +159,52 @@ impl MeshtasticDevice {
.await
}
/// Native Meshtastic unicast DM. Our synthetic Meshtastic pubkeys carry the
/// numeric node-id in their first 4 bytes (little-endian, see
/// `synthetic_pubkey`), so `dest_pubkey_prefix` directly yields the
/// destination node number. We send a directed MeshPacket (`to` = node num)
/// rather than a `BROADCAST_NUM` channel blast — this is the Meshtastic
/// analog of the meshcore `CMD_SEND_TXT_MSG` fix: the message is delivered
/// as a real DM (only the recipient's client surfaces it) instead of
/// polluting the shared primary channel where every node would see it.
///
/// If the prefix decodes to node 0 / broadcast (e.g. a non-Meshtastic
/// synthetic key routed here by mistake), fall back to a channel send so the
/// device interface stays uniform and the message still goes out.
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
let node_num = u32::from_le_bytes([
dest_pubkey_prefix[0],
dest_pubkey_prefix[1],
dest_pubkey_prefix[2],
dest_pubkey_prefix[3],
]);
if node_num == 0 || node_num == BROADCAST_NUM {
return self.send_channel_text(0, msg).await;
}
let text = String::from_utf8_lossy(msg);
let packet = encode_mesh_packet(node_num, TEXT_MESSAGE_APP, text.as_bytes());
self.send_to_radio(&encode_to_radio_variant(TO_RADIO_PACKET, &packet))
.await
}
/// Meshtastic has no meshcore-style contact table; these are no-ops so the
/// device interface stays uniform.
pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> {
Ok(())
}
pub async fn add_contact(
&mut self,
_pubkey: &[u8; 32],
_contact_type: u8,
_flags: u8,
_out_path_len: u8,
_name: &str,
_last_advert: u32,
) -> Result<()> {
Ok(())
}
pub async fn get_contacts(&mut self) -> Result<Vec<ParsedContact>> {
if self.contacts.is_empty() {
self.send_to_radio(&encode_want_config()).await?;
@@ -188,6 +243,19 @@ impl MeshtasticDevice {
Ok(self.handle_from_radio(&frame))
}
/// Whether we've learned `node_num`'s real PKI (Curve25519) key — from a
/// NodeInfo `public_key` or an inbound PKC DM — meaning the firmware can
/// deliver DMs to/from it end-to-end encrypted instead of falling back to
/// the channel PSK. Driver-internal for now; lets a future mesh-tab badge
/// distinguish a true E2E DM from a channel-encrypted one without changing
/// the shared device interface (which would break meshcore hot-swap).
#[allow(dead_code)] // seam: consumed when the mesh-tab E2E badge lands
pub fn peer_is_pkc_capable(&self, node_num: u32) -> bool {
self.peer_pubkeys
.get(&node_num)
.is_some_and(|k| !k.is_empty())
}
pub fn advert_name(&self) -> Option<String> {
self.long_name
.clone()
@@ -260,6 +328,15 @@ impl MeshtasticDevice {
fn update_node_info(&mut self, data: &[u8]) {
if let Some(node) = parse_node_info(data) {
if let Some(pk) = node.public_key.as_ref() {
if self.peer_pubkeys.insert(node.num, pk.clone()).is_none() {
debug!(
node = node.num,
key_len = pk.len(),
"Meshtastic peer is PKC-capable (NodeInfo public_key)"
);
}
}
let key = synthetic_pubkey(node.num);
let name = node
.long_name
@@ -292,6 +369,18 @@ impl MeshtasticDevice {
if Some(from) == self.node_num {
return None;
}
// Record E2E status: a `pki_encrypted` packet (or one carrying the
// sender's `public_key`) proves this DM arrived end-to-end encrypted via
// the PKI, not the shared channel PSK. We learn the sender's key here too
// — but keep it OUT of the routing `public_key_hex` (synthetic) so the
// device interface stays identical to meshcore's and the two remain
// hot-swappable behind the mesh listener.
if let Some(pk) = packet.public_key.as_ref() {
self.peer_pubkeys.entry(from).or_insert_with(|| pk.clone());
}
if packet.pki_encrypted {
debug!(node = from, "Meshtastic DM received end-to-end encrypted (PKI)");
}
let from_key = synthetic_pubkey(from);
self.contacts.entry(from).or_insert_with(|| ParsedContact {
public_key_hex: hex::encode(synthetic_pubkey(from)),
@@ -418,6 +507,7 @@ struct ParsedNode {
long_name: Option<String>,
short_name: Option<String>,
last_heard: Option<u32>,
public_key: Option<Vec<u8>>,
}
fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
@@ -428,6 +518,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
long_name: None,
short_name: None,
last_heard: None,
public_key: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
@@ -440,6 +531,7 @@ fn parse_node_info(data: &[u8]) -> Option<ParsedNode> {
node.id = user.id;
node.long_name = user.long_name;
node.short_name = user.short_name;
node.public_key = user.public_key;
}
}
(5, FieldValue::Fixed32(v)) => node.last_heard = Some(v),
@@ -457,6 +549,7 @@ struct ParsedUser {
id: Option<String>,
long_name: Option<String>,
short_name: Option<String>,
public_key: Option<Vec<u8>>,
}
fn parse_user(data: &[u8]) -> Option<ParsedUser> {
@@ -465,6 +558,7 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
id: None,
long_name: None,
short_name: None,
public_key: None,
};
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
@@ -473,6 +567,9 @@ fn parse_user(data: &[u8]) -> Option<ParsedUser> {
(1, FieldValue::Bytes(b)) => user.id = string_field(b),
(2, FieldValue::Bytes(b)) => user.long_name = string_field(b),
(3, FieldValue::Bytes(b)) => user.short_name = string_field(b),
// User.public_key (field 8): the peer's Curve25519 key. Its presence
// means the radio can PKC-encrypt DMs to this node end-to-end.
(8, FieldValue::Bytes(b)) if !b.is_empty() => user.public_key = Some(b.to_vec()),
_ => {}
}
}
@@ -483,18 +580,28 @@ struct ParsedPacket {
from: Option<u32>,
portnum: u32,
payload: Vec<u8>,
/// MeshPacket.pki_encrypted (field 17): the firmware decrypted this packet
/// with the PKI (Curve25519) key, i.e. it arrived end-to-end encrypted
/// rather than via the shared channel PSK.
pki_encrypted: bool,
/// MeshPacket.public_key (field 16): the sender's key, carried on PKC DMs.
public_key: Option<Vec<u8>>,
}
fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
let mut idx = 0;
let mut from = None;
let mut decoded = None;
let mut pki_encrypted = false;
let mut public_key = None;
while idx < data.len() {
let (field, value, next) = next_field(data, idx)?;
idx = next;
match (field, value) {
(1, FieldValue::Fixed32(v)) => from = Some(v),
(4, FieldValue::Bytes(b)) => decoded = Some(b),
(16, FieldValue::Bytes(b)) if !b.is_empty() => public_key = Some(b.to_vec()),
(17, FieldValue::Varint(v)) => pki_encrypted = v != 0,
_ => {}
}
}
@@ -515,6 +622,8 @@ fn parse_mesh_packet(data: &[u8]) -> Option<ParsedPacket> {
from,
portnum,
payload,
pki_encrypted,
public_key,
})
}
@@ -70,6 +70,12 @@ pub enum MeshMessageType {
/// MCIIXXTT framing) and the peer has no Tor path. Recipient writes the
/// bytes to its local BlobStore on reassembly.
ContentInline = 23,
/// "Ask the node's AI" — a prompt to be answered by the receiving node's
/// local LLM (issue #50). Gated by the assistant config + trust policy.
AssistQuery = 24,
/// Reply to an AssistQuery — a chunk of the LLM's answer, addressed back to
/// the asker by `req_id`. Long answers span multiple chunks (`seq`/`done`).
AssistResponse = 25,
}
impl MeshMessageType {
@@ -99,6 +105,8 @@ impl MeshMessageType {
21 => Some(Self::ChannelInvite),
22 => Some(Self::ContactCard),
23 => Some(Self::ContentInline),
24 => Some(Self::AssistQuery),
25 => Some(Self::AssistResponse),
_ => None,
}
}
@@ -132,6 +140,8 @@ impl MeshMessageType {
"channel_invite" => Some(Self::ChannelInvite),
"contact_card" => Some(Self::ContactCard),
"content_inline" => Some(Self::ContentInline),
"assist_query" => Some(Self::AssistQuery),
"assist_response" => Some(Self::AssistResponse),
_ => None,
}
}
@@ -162,6 +172,8 @@ impl MeshMessageType {
Self::ChannelInvite => "channel_invite",
Self::ContactCard => "contact_card",
Self::ContentInline => "content_inline",
Self::AssistQuery => "assist_query",
Self::AssistResponse => "assist_response",
}
}
}
@@ -407,6 +419,37 @@ pub struct LightningRelayPayload {
pub request_id: u64,
}
/// "Ask the node's AI" request (issue #50). Sent to a peer running a local
/// LLM; answered with one or more `AssistResponsePayload` chunks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistQueryPayload {
/// Asker-chosen id correlating the query with its response chunks.
pub req_id: u64,
/// The natural-language prompt.
pub prompt: String,
/// Optional model override; falls back to the responder's configured model.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
/// One chunk of an AI answer, addressed back to the asker by `req_id`.
/// Airtime is scarce, so long answers are capped and split into ordered chunks.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssistResponsePayload {
pub req_id: u64,
/// This chunk's text.
pub text: String,
/// 0-based chunk index.
#[serde(default)]
pub seq: u16,
/// True on the final chunk.
#[serde(default)]
pub done: bool,
/// Set instead of `text` when the query failed (model unreachable, denied…).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Lightning relay response (proof of payment).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LightningRelayResponsePayload {
+444 -32
View File
@@ -14,6 +14,7 @@ pub mod message_types;
pub mod outbox;
pub mod protocol;
pub mod ratchet;
pub mod scheduler;
pub mod serial;
pub mod session;
pub mod steganography;
@@ -37,6 +38,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
@@ -44,6 +46,12 @@ const MESH_IGNORED_RADIO_FILE: &str = "mesh-ignored-radio-contacts.json";
/// high half of u32 space to avoid collision. Both the receive path
/// (`inject_typed_from_federation`) and the startup pre-seed use this
/// formula so they always produce the same id for the same peer.
/// Mesh contacts at or above this id are synthetic federation peers (the high
/// half of the u32 space). Meshcore radio contacts use the firmware's low-int id
/// space, so this bit cleanly distinguishes "arrived over the authenticated
/// federation transport" from "heard over the radio".
pub(crate) const FEDERATION_CONTACT_ID_BASE: u32 = 0x8000_0000;
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
if bytes.len() < 4 {
@@ -53,6 +61,72 @@ pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
0x8000_0000 | (low & 0x7FFF_FFFF)
}
/// Bind radio (LoRa) contacts to their federation twin's archipelago identity.
///
/// The same physical node commonly appears twice in the peer table: a radio
/// contact (low `contact_id`, firmware routing key only, `arch_pubkey_hex ==
/// None`) and a federation peer (high `contact_id`, `arch_pubkey_hex` set). The
/// radio half carries no archipelago identity because identity adverts are no
/// longer broadcast on the public channel (anti-spam), so the `!ai` trust gate
/// and envelope signature verification have no key to check a radio asker
/// against — a `!ai` from a trusted node over LoRa is therefore denied, and the
/// node shows up as two separate contacts.
///
/// We correlate the two halves by exact, case-insensitive `advert_name` and copy
/// the federation peer's `arch_pubkey_hex`/`did`/`x25519` onto the radio peer.
/// This only supplies a CANDIDATE identity key; it does NOT bypass
/// authentication. A radio envelope must still carry an Ed25519 signature that
/// verifies against this bound key (see `handle_typed_envelope_direct`), so a
/// meshcore node merely *named* like a trusted node cannot impersonate it — it
/// cannot produce the signature. The candidate key comes from the authenticated
/// federation handshake (`nodes.json`), never from anything the radio packet
/// claims. Names held by more than one federation peer are treated as ambiguous
/// and skipped so a duplicate name can't bind the wrong identity.
pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, MeshPeer>) {
// name (lowercased) -> federation identity; `None` marks an ambiguous name
// (seen on more than one federation peer) which we must not bind.
type FedIdentity = (String, Option<String>, Option<[u8; 32]>);
let mut fed_by_name: std::collections::HashMap<String, Option<FedIdentity>> =
std::collections::HashMap::new();
for p in peers.values() {
if p.contact_id < FEDERATION_CONTACT_ID_BASE {
continue;
}
let Some(arch) = p.arch_pubkey_hex.clone() else {
continue;
};
let name = p.advert_name.trim().to_ascii_lowercase();
if name.is_empty() {
continue;
}
fed_by_name
.entry(name)
.and_modify(|e| *e = None) // a second federation peer with this name → ambiguous
.or_insert(Some((arch, p.did.clone(), p.x25519_pubkey)));
}
if fed_by_name.is_empty() {
return;
}
for p in peers.values_mut() {
if p.contact_id >= FEDERATION_CONTACT_ID_BASE || p.arch_pubkey_hex.is_some() {
continue;
}
let name = p.advert_name.trim().to_ascii_lowercase();
if name.is_empty() {
continue;
}
if let Some(Some((arch, did, x25519))) = fed_by_name.get(&name) {
p.arch_pubkey_hex = Some(arch.clone());
if p.did.is_none() {
p.did = did.clone();
}
if p.x25519_pubkey.is_none() {
p.x25519_pubkey = *x25519;
}
}
}
}
/// Upsert a mesh peer record representing a federation node so the UI can
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
/// Existing entries (same contact_id) are updated in place, preserving any
@@ -75,18 +149,61 @@ pub(crate) async fn upsert_federation_peer(
advert_name: display_name,
did: Some(did.to_string()),
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
// Federation peers are authenticated by the Tor relay upstream; their
// archipelago key is known, so bind it as the identity key too.
arch_pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
rssi: existing.as_ref().and_then(|p| p.rssi),
snr: existing.as_ref().and_then(|p| p.snr),
last_heard: chrono::Utc::now().to_rfc3339(),
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0),
// Federation peers are reachable off-radio (Tor/FIPS), so always true.
reachable: true,
};
peers.insert(contact_id, peer);
// A radio twin of this node (same advert_name, no arch identity yet) can now
// inherit this federation peer's archipelago key — so a signed `!ai`/typed
// message arriving over LoRa from it authenticates and the duplicate radio
// contact resolves to the same identity.
bind_federation_twins(&mut peers);
drop(peers);
state.update_peer_count().await;
contact_id
}
/// Purge a federation peer from all live mesh state and persisted contacts so
/// removing a node (federation.remove-node) also clears its chat contact,
/// thread, and any per-contact customisation — otherwise a stale/renamed node
/// (e.g. an old "Arch HP" entry) lingers in the chat list even after it's gone
/// from `nodes.json` (#2). Keyed by the synthetic `contact_id` for the peer
/// table/messages and by `pubkey_hex` for the pubkey-keyed contacts/presence
/// stores.
pub(crate) async fn purge_federation_peer(
state: &Arc<listener::MeshState>,
contact_id: u32,
pubkey_hex: &str,
data_dir: &Path,
) {
state.peers.write().await.remove(&contact_id);
state.shared_secrets.write().await.remove(&contact_id);
state
.messages
.write()
.await
.retain(|m| m.peer_contact_id != contact_id);
state.presence.write().await.remove(pubkey_hex);
let mut contacts = state.contacts.write().await;
if contacts.remove(pubkey_hex).is_some() {
let snapshot = contacts.clone();
drop(contacts);
if let Err(e) = save_mesh_contacts(data_dir, &snapshot).await {
warn!("Failed to persist mesh contacts after purge: {}", e);
}
}
state.update_peer_count().await;
}
/// Load federation nodes from disk and upsert each as a synthetic mesh peer.
/// Called at MeshService startup so the chat list already contains every
/// known federation node — users can share files to them without first
@@ -99,7 +216,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;
}
}
@@ -126,6 +253,10 @@ pub struct MeshConfig {
/// Announce new Bitcoin block headers over mesh (internet-connected nodes only).
#[serde(default)]
pub announce_block_headers: bool,
/// Accept Bitcoin block headers received over mesh from peers. On by default;
/// turn off to ignore inbound headers (the receive half of issue #28).
#[serde(default = "default_true")]
pub receive_block_headers: bool,
/// Steganographic encoding mode for mesh messages (Normal = disabled).
#[serde(default)]
pub steganography_mode: steganography::SteganographyMode,
@@ -133,6 +264,30 @@ pub struct MeshConfig {
/// Set to false to disable encryption for debugging or rollback.
#[serde(default = "default_true")]
pub encrypt_relay_messages: bool,
/// Answer AI queries (AssistQuery) from peers using this node's local LLM
/// (issue #50). Off by default — the node only becomes a mesh AI on opt-in.
#[serde(default)]
pub assistant_enabled: bool,
/// Ollama model used to answer AI queries. None → the built-in default.
#[serde(default)]
pub assistant_model: Option<String>,
/// When true (default), only federation-Trusted peers may ask; when false,
/// any peer on the mesh may ask (spends this node's compute + airtime).
#[serde(default = "default_true")]
pub assistant_trusted_only: bool,
/// Which AI backend answers queries: "claude" (the shared Claude proxy
/// token at secrets/claude-api-key — default for now, works without a
/// local GPU) or "ollama" (a local model on this node).
#[serde(default = "default_assistant_backend")]
pub assistant_backend: String,
/// Per-contact allowlist (ed25519 pubkey hex) permitted to use `!ai` even
/// when `assistant_trusted_only` is on and they aren't federation-Trusted.
#[serde(default)]
pub assistant_allowed_contacts: Vec<String>,
}
fn default_assistant_backend() -> String {
"claude".to_string()
}
fn default_true() -> bool {
@@ -149,8 +304,14 @@ impl Default for MeshConfig {
advert_name: None,
mesh_only_mode: None,
announce_block_headers: false,
receive_block_headers: true,
steganography_mode: steganography::SteganographyMode::Normal,
encrypt_relay_messages: true,
assistant_enabled: false,
assistant_model: None,
assistant_trusted_only: true,
assistant_backend: default_assistant_backend(),
assistant_allowed_contacts: Vec::new(),
}
}
}
@@ -200,6 +361,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> {
@@ -219,6 +440,7 @@ pub struct MeshService {
deadman_handle: Option<tokio::task::JoinHandle<()>>,
block_announcer_handle: Option<tokio::task::JoinHandle<()>>,
presence_handle: Option<tokio::task::JoinHandle<()>>,
scheduler_handle: Option<tokio::task::JoinHandle<()>>,
cmd_rx: Option<tokio::sync::mpsc::Receiver<listener::MeshCommand>>,
// Crypto identity for this node
our_did: String,
@@ -232,6 +454,8 @@ pub struct MeshService {
pub block_header_cache: Arc<BlockHeaderCache>,
pub relay_tracker: Arc<RelayTracker>,
pub dead_man_switch: Arc<DeadManSwitch>,
/// Scheduled / queued outbound mesh messages (issue #50, phase 1.7).
pub scheduler: Arc<scheduler::MeshScheduler>,
}
impl MeshService {
@@ -257,8 +481,17 @@ impl MeshService {
Some(Arc::clone(&relay_tracker)),
config.steganography_mode,
config.encrypt_relay_messages,
config.receive_block_headers,
Arc::clone(&session_manager),
ed_pubkey_hex.to_string(),
listener::AssistantConfig {
enabled: config.assistant_enabled,
model: config.assistant_model.clone(),
trusted_only: config.assistant_trusted_only,
backend: config.assistant_backend.clone(),
allowed_contacts: config.assistant_allowed_contacts.clone(),
},
data_dir.to_path_buf(),
);
// Derive X25519 keys from Ed25519 identity
@@ -294,6 +527,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,
@@ -303,6 +548,7 @@ impl MeshService {
deadman_handle: None,
block_announcer_handle: None,
presence_handle: None,
scheduler_handle: None,
cmd_rx: Some(cmd_rx),
our_did: did.to_string(),
our_ed_pubkey_hex: ed_pubkey_hex.to_string(),
@@ -313,6 +559,7 @@ impl MeshService {
block_header_cache,
relay_tracker,
dead_man_switch,
scheduler: Arc::new(scheduler::MeshScheduler::load(data_dir).await),
})
}
@@ -390,6 +637,20 @@ impl MeshService {
});
self.deadman_handle = Some(dms_handle);
// Scheduled-message task (issue #50, phase 1.7): fires queued messages
// when due, retrying peer DMs until the peer is back in range.
let sched = Arc::clone(&self.scheduler);
let sched_state = Arc::clone(&self.state);
let sched_shutdown = self
.shutdown_tx
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Shutdown channel not initialized"))?
.subscribe();
let sched_handle = tokio::spawn(async move {
scheduler::run_scheduler(sched, sched_state, sched_shutdown).await;
});
self.scheduler_handle = Some(sched_handle);
// Spawn block header announcer (internet-connected nodes only)
if self.config.announce_block_headers {
let bha_state = Arc::clone(&self.state);
@@ -406,6 +667,7 @@ impl MeshService {
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await; // skip first
let mut last_announced_height: u64 = 0;
let mut last_announce_at: Option<std::time::Instant> = None;
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
@@ -423,6 +685,18 @@ impl MeshService {
// Poll Bitcoin Core for latest block
match bitcoin_rpc_getblockcount(&client).await {
Ok(height) if height > last_announced_height => {
// Advance the tip baseline immediately so a fast Bitcoin
// catch-up (a new block every poll) doesn't re-fire each tick.
last_announced_height = height;
// Throttle: at most one announcement per ~9 min. Real ~10 min
// blocks still propagate, but a rapid catch-up can no longer
// flood the shared LoRa channel.
if last_announce_at
.map(|t| t.elapsed() < Duration::from_secs(540))
.unwrap_or(false)
{
continue;
}
if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await {
// Store in cache
let payload = message_types::BlockHeaderPayload {
@@ -468,30 +742,15 @@ impl MeshService {
}
}
}
// Second pass: any peer if no Archy nodes found
if sent == 0 {
for peer in peers.values() {
if sent >= max_peers { break; }
if let Some(ref pk) = peer.pubkey_hex {
if let Ok(pk_bytes) = hex::decode(pk) {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = bha_state.send_cmd(
listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire.clone(),
},
).await;
sent += 1;
}
}
}
}
}
// NOTE: intentionally NO fallback to arbitrary
// peers. Block headers go ONLY to known Archy
// (federated) nodes — never to random meshcore
// devices on the shared public channel.
drop(peers);
last_announced_height = height;
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
if sent > 0 {
last_announce_at = Some(std::time::Instant::now());
info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers");
}
}
Err(e) => warn!("Failed to build block announcement: {}", e),
}
@@ -540,6 +799,10 @@ impl MeshService {
handle.abort();
let _ = handle.await;
}
if let Some(handle) = self.scheduler_handle.take() {
handle.abort();
let _ = handle.await;
}
if let Some(handle) = self.block_announcer_handle.take() {
handle.abort();
let _ = handle.await;
@@ -741,16 +1004,58 @@ impl MeshService {
// over Tor; otherwise the send falls through to LoRa.
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
if is_federation_synthetic || exceeds_lora {
let (peer_pubkey, peer_did) = {
// Mesh-preferred routing with a federation fallback. A normal radio
// contact is delivered over LoRa (preferred — free, local, no internet).
// But if that contact is the same node as a federated peer — we know its
// archipelago identity (`arch_pubkey_hex`) → onion — AND it is NOT
// currently reachable over the radio (out of LoRa range, e.g. a peer on
// another continent), route the message over the federation transport
// (FIPS→Tor) instead of handing it to a radio that physically cannot
// deliver it. Reachable radio peers stay on the mesh; oversized
// envelopes (file shares etc.) always take the federation path.
let radio_federated_unreachable = !is_federation_synthetic
&& !exceeds_lora
&& {
let peers = self.state.peers.read().await;
match peers.get(&contact_id) {
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
None if is_federation_synthetic => {
anyhow::bail!("Unknown federation peer {}", contact_id);
peers
.get(&contact_id)
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false)
};
if is_federation_synthetic || exceeds_lora || radio_federated_unreachable {
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
// but fall back to federation storage for federation-synthetic ids
// that were never seeded into `state.peers` — e.g. a radio-less
// node where the mesh device table is empty. Without this fallback
// chatting a federation contact bails "Unknown federation peer"
// even though we know its onion from nodes.json.
let from_table = {
let peers = self.state.peers.read().await;
peers.get(&contact_id).map(|p| {
// Resolve via the archipelago IDENTITY key (not the firmware
// routing key) — that's what matches the peer's onion entry
// in nodes.json for the federation lookup below.
(
p.arch_pubkey_hex.clone().or_else(|| p.pubkey_hex.clone()),
p.did.clone(),
)
})
};
let (peer_pubkey, peer_did) = match from_table {
Some(v) => v,
None if is_federation_synthetic => {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
match nodes
.iter()
.find(|n| federation_peer_contact_id(&n.pubkey) == contact_id)
{
Some(n) => (Some(n.pubkey.clone()), Some(n.did.clone())),
None => anyhow::bail!("Unknown federation peer {}", contact_id),
}
None => (None, None),
}
None => (None, None),
};
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
@@ -868,7 +1173,14 @@ impl MeshService {
"/archipelago/mesh-typed",
)
.service(crate::settings::transport::PeerService::Messaging)
.timeout(std::time::Duration::from_secs(120));
.timeout(std::time::Duration::from_secs(120))
// Fast-fail a FIPS path the peer isn't reachable on (the common case
// for remote/Tailscale peers that share no FIPS overlay with us) so
// the Tor fallback delivers the message in ~3-5s instead of the send
// hanging on FIPS. FIPS-reachable peers connect in <1s and still use
// it; only an unreachable FIPS path is short-circuited. Matches the
// federation-sync fix. 8s ≈ the FIPS connect_timeout headroom.
.fips_timeout(std::time::Duration::from_secs(8));
match req.send_json(&body).await {
Ok((resp, transport)) if resp.status().is_success() => {
tracing::debug!(contact_id, transport = %transport, "Federation envelope delivered");
@@ -1073,13 +1385,57 @@ impl MeshService {
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
let seq = self.state.next_send_seq(contact_id).await;
// Stock (non-archipelago) radio contacts — e.g. a phone running the
// MeshCore app — can't decode our typed envelope and would render it as
// garbled bytes. Send them the raw text as a plain native DM instead.
// Archipelago peers still get the typed envelope (seq/reply/reaction
// addressing + encryption).
if !self.is_archy_peer(contact_id).await {
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
self.state
.send_cmd(listener::MeshCommand::SendNativeText {
dest_pubkey_prefix: dest_prefix,
payload: text.as_bytes().to_vec(),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
return Ok(self
.record_sent_typed(contact_id, "text", text, None, seq)
.await);
}
// Sign the envelope with our archipelago identity key so the receiver
// can authenticate us over LoRa (it verifies against our bound
// `arch_pubkey_hex`). This is what lets a `!ai` typed in chat to a
// trusted node pass the receiver's `trusted_only` gate over the radio —
// an unsigned radio packet can never authenticate. The signature is
// optional on the wire and ignored by peers that don't know our key, so
// it stays backward compatible. (Federation/Tor sends already sign in
// `send_typed_wire_via_federation`.) `with_seq` is applied after signing
// — seq is not covered by the signature.
let envelope =
TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq);
TypedEnvelope::new_signed(MeshMessageType::Text, text.as_bytes().to_vec(), &self.signing_key)
.with_seq(seq);
let wire = envelope.to_wire()?;
self.send_typed_wire(contact_id, wire, "text", text, None, seq)
.await
}
/// Whether `contact_id` is an archipelago peer (vs a stock meshcore client).
/// Federation-synthetic ids are always archy; radio contacts count as archy
/// only once we've learned their archipelago identity (DID or x25519 key,
/// from federation seeding or an identity exchange). Stock clients have
/// neither, so we send them plain text rather than typed envelopes.
async fn is_archy_peer(&self, contact_id: u32) -> bool {
if contact_id & 0x8000_0000 != 0 {
return true;
}
let peers = self.state.peers.read().await;
peers
.get(&contact_id)
.map(|p| p.did.is_some() || p.x25519_pubkey.is_some())
.unwrap_or(false)
}
/// Record a Sent MeshMessage for a typed envelope that has already been
/// transmitted by the caller. Used by the RPC layer after sending
/// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card
@@ -1193,6 +1549,62 @@ impl MeshService {
Ok(())
}
/// Current mesh-AI assistant settings (issue #50).
pub async fn assistant_config(&self) -> listener::AssistantConfig {
self.state.assistant.read().await.clone()
}
/// Recently-denied `!ai` askers (newest first) so the UI can offer to allow
/// them. Cleared implicitly as new denials rotate older ones out.
pub async fn assistant_denied_askers(&self) -> Vec<listener::DeniedAsker> {
self.state.assist_denied.read().await.iter().cloned().collect()
}
/// Update the mesh-AI assistant settings live (no listener restart) and
/// persist them to the mesh config. `model: Some(None)` clears the override
/// (falls back to the built-in default); `None` leaves a field unchanged.
pub async fn configure_assistant(
&self,
enabled: Option<bool>,
model: Option<Option<String>>,
trusted_only: Option<bool>,
backend: Option<String>,
allowed_contacts: Option<Vec<String>>,
) -> Result<()> {
{
let mut a = self.state.assistant.write().await;
if let Some(e) = enabled {
a.enabled = e;
}
if let Some(m) = model {
a.model = m;
}
if let Some(t) = trusted_only {
a.trusted_only = t;
}
if let Some(b) = backend {
a.backend = b;
}
if let Some(list) = allowed_contacts {
a.allowed_contacts = list;
}
}
// Persist by updating the on-disk config (the in-memory `self.config`
// snapshot stays as-is; the live `state.assistant` is the runtime
// source of truth and is re-seeded from disk on the next start).
let mut cfg = load_config(&self.data_dir).await.unwrap_or_default();
{
let a = self.state.assistant.read().await;
cfg.assistant_enabled = a.enabled;
cfg.assistant_model = a.model.clone();
cfg.assistant_trusted_only = a.trusted_only;
cfg.assistant_backend = a.backend.clone();
cfg.assistant_allowed_contacts = a.allowed_contacts.clone();
}
save_config(&self.data_dir, &cfg).await?;
Ok(())
}
/// Update mesh configuration.
pub async fn configure(&mut self, config: MeshConfig) -> Result<()> {
save_config(&self.data_dir, &config).await?;
+46
View File
@@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A;
/// known" — without this, the firmware silently drops outbound TXT_MSG
/// frames to such contacts.
pub const CMD_RESET_PATH: u8 = 0x0D;
/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware
/// table. 144-byte frame (see `build_add_contact`).
pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09;
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the
/// firmware's persistent table (used by clear-all so wiped contacts actually
/// go away and only return when they re-advertise).
pub const CMD_REMOVE_CONTACT: u8 = 0x0F;
pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B;
pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C;
pub const CMD_SET_TUNING_PARAMS: u8 = 0x15;
@@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec<u8> {
encode_frame(&data)
}
/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from
/// the firmware's persistent contact table.
pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec<u8> {
let mut data = vec![CMD_REMOVE_CONTACT];
data.extend_from_slice(pubkey);
encode_frame(&data)
}
/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body:
/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32]
/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`.
/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard
/// advert on its side too, so an empty name still resolves on get-contacts).
pub fn build_add_contact(
pubkey: &[u8; 32],
contact_type: u8,
flags: u8,
out_path_len: u8,
name: &str,
last_advert: u32,
) -> Vec<u8> {
let mut data = Vec::with_capacity(144);
data.push(CMD_ADD_UPDATE_CONTACT);
data.extend_from_slice(pubkey); // 32
data.push(contact_type); // 1
data.push(flags); // 1
data.push(out_path_len); // 1
data.extend_from_slice(&[0u8; 64]); // out_path (64)
let mut name_buf = [0u8; 32];
let nb = name.as_bytes();
let n = nb.len().min(32);
name_buf[..n].copy_from_slice(&nb[..n]);
data.extend_from_slice(&name_buf); // name (32)
data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4)
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4)
data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4)
encode_frame(&data)
}
/// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message.
pub fn build_sync_next_message() -> Vec<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])
+217
View File
@@ -0,0 +1,217 @@
//! Scheduled / queued mesh messages (issue #50, phase 1.7).
//!
//! A small persisted queue of messages to send at a future time. A background
//! task fires due messages via the listener. A message addressed to a peer that
//! isn't currently in the contact table stays queued and retries on later ticks
//! — i.e. it sends itself when the peer comes back in range.
use super::listener::{MeshCommand, MeshState};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::fs;
use tokio::sync::{watch, RwLock};
use tracing::warn;
const SCHEDULER_FILE: &str = "mesh-scheduled.json";
/// Wake interval for firing due messages.
const TICK_SECS: u64 = 10;
/// Drop a still-undeliverable message after this many attempts (~1h at 10s).
const MAX_ATTEMPTS: u32 = 360;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScheduledMessage {
pub id: u64,
/// Direct-message target (peer contact_id), or None for a channel broadcast.
#[serde(default)]
pub contact_id: Option<u32>,
/// Channel to broadcast on, or None for a direct message.
#[serde(default)]
pub channel: Option<u8>,
pub body: String,
/// Unix seconds when the message becomes due.
pub fire_at: i64,
#[serde(default)]
pub attempts: u32,
}
pub struct MeshScheduler {
path: PathBuf,
queue: RwLock<Vec<ScheduledMessage>>,
next_id: RwLock<u64>,
}
impl MeshScheduler {
pub async fn load(data_dir: &Path) -> Self {
let path = data_dir.join(SCHEDULER_FILE);
let queue: Vec<ScheduledMessage> = match fs::read_to_string(&path).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(),
Err(_) => Vec::new(),
};
let next = queue.iter().map(|m| m.id).max().unwrap_or(0) + 1;
Self {
path,
queue: RwLock::new(queue),
next_id: RwLock::new(next),
}
}
async fn save(&self) -> Result<()> {
let json = {
let q = self.queue.read().await;
serde_json::to_string_pretty(&*q).context("serialize scheduled queue")?
};
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).await.ok();
}
fs::write(&self.path, json)
.await
.context("write scheduled queue")?;
Ok(())
}
pub async fn add(
&self,
contact_id: Option<u32>,
channel: Option<u8>,
body: String,
fire_at: i64,
) -> Result<ScheduledMessage> {
let id = {
let mut n = self.next_id.write().await;
let id = *n;
*n += 1;
id
};
let msg = ScheduledMessage {
id,
contact_id,
channel,
body,
fire_at,
attempts: 0,
};
self.queue.write().await.push(msg.clone());
self.save().await?;
Ok(msg)
}
pub async fn list(&self) -> Vec<ScheduledMessage> {
let mut v = self.queue.read().await.clone();
v.sort_by_key(|m| m.fire_at);
v
}
pub async fn cancel(&self, id: u64) -> Result<bool> {
let removed = {
let mut q = self.queue.write().await;
let before = q.len();
q.retain(|m| m.id != id);
q.len() != before
};
if removed {
self.save().await?;
}
Ok(removed)
}
}
/// Background loop: every `TICK_SECS`, fire any due messages.
pub async fn run_scheduler(
scheduler: Arc<MeshScheduler>,
state: Arc<MeshState>,
mut shutdown: watch::Receiver<bool>,
) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(TICK_SECS));
loop {
tokio::select! {
_ = interval.tick() => fire_due(&scheduler, &state).await,
_ = shutdown.changed() => {
if *shutdown.borrow() { return; }
}
}
}
}
async fn fire_due(scheduler: &Arc<MeshScheduler>, state: &Arc<MeshState>) {
let now = chrono::Utc::now().timestamp();
let due: Vec<ScheduledMessage> = scheduler
.queue
.read()
.await
.iter()
.filter(|m| m.fire_at <= now)
.cloned()
.collect();
if due.is_empty() {
return;
}
let mut delivered: Vec<u64> = Vec::new();
let mut failed: Vec<u64> = Vec::new();
for msg in &due {
if try_send(state, msg).await {
delivered.push(msg.id);
} else {
failed.push(msg.id);
}
}
let mut to_remove = delivered;
{
let mut q = scheduler.queue.write().await;
for m in q.iter_mut() {
if failed.contains(&m.id) {
m.attempts += 1;
if m.attempts >= MAX_ATTEMPTS {
warn!(
id = m.id,
attempts = m.attempts,
"Dropping undeliverable scheduled message"
);
to_remove.push(m.id);
}
}
}
q.retain(|m| !to_remove.contains(&m.id));
}
let _ = scheduler.save().await;
}
/// Hand a due message to the radio. Returns true if it was sent (or should be
/// dropped); false to keep it queued for a later retry (peer not in range yet).
async fn try_send(state: &Arc<MeshState>, msg: &ScheduledMessage) -> bool {
let payload = msg.body.clone().into_bytes();
if let Some(channel) = msg.channel {
return state
.send_cmd(MeshCommand::BroadcastChannel { channel, payload })
.await
.is_ok();
}
if let Some(contact_id) = msg.contact_id {
let pubkey = {
let peers = state.peers.read().await;
peers.get(&contact_id).and_then(|p| p.pubkey_hex.clone())
};
if let Some(pk) = pubkey {
if let Ok(bytes) = hex::decode(&pk) {
if bytes.len() >= 6 {
let mut dest = [0u8; 6];
dest.copy_from_slice(&bytes[..6]);
return state
.send_cmd(MeshCommand::SendText {
dest_pubkey_prefix: dest,
payload,
})
.await
.is_ok();
}
}
}
// Peer unknown / not in range yet — keep queued, retry next tick.
return false;
}
warn!("Scheduled message has neither channel nor contact_id — dropping");
true
}
+59
View File
@@ -206,6 +206,24 @@ impl MeshcoreDevice {
Ok(())
}
/// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact,
/// addressed by the first 6 bytes of its public key. Unlike the
/// `@DM2`-over-channel path, this is a real unicast — it does not appear on
/// the public channel, and a stock meshcore client receives it as a normal
/// DM. The contact must already exist in the firmware table (with a path).
pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> {
let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?;
self.send_raw(&frame_data).await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Direct text send failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Clear the stored routing path for a contact so the firmware flood-
/// routes future messages instead of dropping them when path_len=0.
pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> {
@@ -217,6 +235,47 @@ impl MeshcoreDevice {
Ok(())
}
/// Delete a contact from the firmware's persistent contact table.
pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> {
self.send_raw(&protocol::build_remove_contact(pubkey))
.await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!(
"Remove contact failed: {}",
protocol::parse_error(&frame.data)
);
}
Ok(())
}
/// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT).
/// Used to import a heard advert so it shows up as a contact immediately.
pub async fn add_contact(
&mut self,
pubkey: &[u8; 32],
contact_type: u8,
flags: u8,
out_path_len: u8,
name: &str,
last_advert: u32,
) -> Result<()> {
self.send_raw(&protocol::build_add_contact(
pubkey,
contact_type,
flags,
out_path_len,
name,
last_advert,
))
.await?;
let frame = self.recv_frame_timeout(READ_TIMEOUT).await?;
if frame.code == protocol::RESP_ERR {
anyhow::bail!("Add contact failed: {}", protocol::parse_error(&frame.data));
}
Ok(())
}
/// Get the list of known contacts from the device.
/// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END
pub async fn get_contacts(&mut self) -> Result<Vec<protocol::ParsedContact>> {
+101 -1
View File
@@ -32,8 +32,18 @@ pub struct MeshPeer {
pub advert_name: String,
/// Archipelago DID (did:key:z...) if identity was received.
pub did: Option<String>,
/// Ed25519 public key hex if identity was received.
/// Routing key hex. For a radio (meshcore) peer this is the firmware
/// contact public key used to address outbound DMs; for a federation-
/// seeded peer it is the archipelago ed25519 key. Used for delivery, NOT
/// for authentication — see `arch_pubkey_hex`.
pub pubkey_hex: Option<String>,
/// Verified archipelago ed25519 identity key hex, bound from a signed
/// identity advert (`handle_identity_received`) or federation seeding.
/// Unlike `pubkey_hex`, this is NEVER overwritten by `refresh_contacts`
/// with the firmware routing key, so it stays stable for the `!ai` auth
/// gate, envelope signature verification, and federation-trust matching.
#[serde(default)]
pub arch_pubkey_hex: Option<String>,
/// X25519 public key (32 bytes) for key agreement.
#[serde(skip)]
pub x25519_pubkey: Option<[u8; 32]>,
@@ -45,6 +55,28 @@ pub struct MeshPeer {
pub last_heard: String,
/// Number of hops to reach this peer.
pub hops: u8,
/// Firmware advert timestamp (unix secs) of the contact's last advert, or
/// 0 if unknown. Used to gauge reachability/recency in the UI.
#[serde(default)]
pub last_advert: u32,
/// Best-effort "currently reachable" flag: the radio has a route to this
/// contact (or it's a federation/identity peer reachable off-radio). A
/// contact with no path and no recent advert is shown as unreachable.
#[serde(default)]
pub reachable: bool,
}
impl MeshPeer {
/// The key to use when AUTHENTICATING this peer (`!ai` trust/allowlist,
/// envelope signature verification): the verified archipelago identity key
/// if one is bound, otherwise the routing key. Never use the firmware
/// routing key for auth when an archipelago identity is known — a radio
/// peer's firmware key won't match its `nodes.json` archipelago key.
pub fn identity_pubkey_hex(&self) -> Option<&str> {
self.arch_pubkey_hex
.as_deref()
.or(self.pubkey_hex.as_deref())
}
}
/// Direction of a mesh message.
@@ -161,4 +193,72 @@ pub enum MeshEvent {
payment_hash: Option<String>,
error: Option<String>,
},
/// An AI query arrived from a peer and was accepted for answering (#50).
AssistQueryReceived {
from_contact_id: u32,
prompt: String,
},
/// A local-AI answer finished sending back to the asker (or failed) (#50).
AssistResponseReady {
req_id: u64,
to_contact_id: u32,
error: Option<String>,
},
/// A local-AI answer to a `!ai`-in-chat query, to be delivered back into
/// the 1:1 thread via the transport-aware `MeshService::send_message`
/// (Tor for federation peers, LoRa for radio peers). The mesh listener
/// emits this because it can't route over federation itself — the signing
/// key and Tor client live on MeshService. Consumed at the server layer.
AssistChatReply {
contact_id: u32,
text: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn peer(arch: Option<&str>, routing: Option<&str>) -> MeshPeer {
MeshPeer {
contact_id: 1,
advert_name: "Test".into(),
did: None,
pubkey_hex: routing.map(|s| s.to_string()),
arch_pubkey_hex: arch.map(|s| s.to_string()),
x25519_pubkey: None,
rssi: None,
snr: None,
last_heard: String::new(),
hops: 0,
last_advert: 0,
reachable: false,
}
}
#[test]
fn identity_prefers_bound_archipelago_key_over_firmware_routing_key() {
// A radio peer that sent an identity advert: routing key is the firmware
// contact key, but auth must use the bound archipelago key.
let p = peer(Some("archkey"), Some("firmwarekey"));
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
}
#[test]
fn identity_falls_back_to_routing_key_when_no_advert() {
// A plain peer with no archipelago identity bound: fall back to whatever
// key we have (federation peers carry the arch key in pubkey_hex).
let p = peer(None, Some("firmwarekey"));
assert_eq!(p.identity_pubkey_hex(), Some("firmwarekey"));
assert_eq!(peer(None, None).identity_pubkey_hex(), None);
}
#[test]
fn refresh_style_routing_update_does_not_change_identity() {
// Simulates refresh_contacts: pubkey_hex (routing) is rewritten to a new
// firmware key while arch_pubkey_hex (identity) is preserved.
let mut p = peer(Some("archkey"), Some("firmware-old"));
p.pubkey_hex = Some("firmware-new".into());
assert_eq!(p.identity_pubkey_hex(), Some("archkey"));
}
}
+110 -15
View File
@@ -17,6 +17,10 @@ pub struct IncomingMessage {
/// Sender's node name (for display in group chat).
#[serde(default)]
pub from_name: Option<String>,
/// Sender-assigned unique id for the message. Used to dedup reliably even
/// when a slow-Tor retry/redelivery arrives outside the time window (#31).
#[serde(default)]
pub msg_id: Option<String>,
pub message: String,
pub timestamp: String,
/// "sent" or "received"
@@ -43,18 +47,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,31 +117,62 @@ 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;
}
});
}
}
}
/// Store a received message (called from HTTP handler).
pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&str>) {
pub fn store_received_sync(
from_pubkey: &str,
message: &str,
from_name: Option<&str>,
msg_id: Option<&str>,
) {
let ts = chrono::Utc::now().to_rfc3339();
let mut guard = store().lock().unwrap_or_else(|e| e.into_inner());
// Deduplication: skip if same pubkey + message within last 30 seconds
let dominated = guard.messages.iter().rev().take(20).any(|m| {
m.from_pubkey == from_pubkey
&& m.message == message
&& m.direction == "received"
&& within_seconds(&m.timestamp, &ts, 30)
});
if dominated {
// Deduplication. When the sender supplied a unique id, dedup on
// (from_pubkey, msg_id) across all retained history — this is robust even
// when a slow-Tor redelivery arrives well outside any time window (#31).
// Older senders send no id; fall back to the legacy same-pubkey+message
// within-30s heuristic.
let duplicate = if let Some(id) = msg_id {
guard
.messages
.iter()
.any(|m| m.from_pubkey == from_pubkey && m.msg_id.as_deref() == Some(id))
} else {
guard.messages.iter().rev().take(20).any(|m| {
m.from_pubkey == from_pubkey
&& m.message == message
&& m.direction == "received"
&& within_seconds(&m.timestamp, &ts, 30)
})
};
if duplicate {
return;
}
@@ -95,6 +180,7 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
from_pubkey: from_pubkey.to_string(),
from_onion: None,
from_name: from_name.map(|s| s.to_string()),
msg_id: msg_id.map(|s| s.to_string()),
message: message.to_string(),
timestamp: ts,
direction: "received".to_string(),
@@ -104,8 +190,13 @@ pub fn store_received_sync(from_pubkey: &str, message: &str, from_name: Option<&
persist();
}
pub async fn store_received(from_pubkey: &str, message: &str, from_name: Option<&str>) {
store_received_sync(from_pubkey, message, from_name);
pub async fn store_received(
from_pubkey: &str,
message: &str,
from_name: Option<&str>,
msg_id: Option<&str>,
) {
store_received_sync(from_pubkey, message, from_name, msg_id);
}
/// Store a sent message (for display in Archipelago channel).
@@ -115,6 +206,7 @@ pub fn store_sent(message: &str) {
from_pubkey: "me".to_string(),
from_onion: None,
from_name: None,
msg_id: None,
message: message.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
direction: "sent".to_string(),
@@ -270,6 +362,9 @@ pub async fn send_to_peer(
"message": payload_message,
"timestamp": chrono::Utc::now().to_rfc3339(),
"encrypted": encrypted,
// Unique per-message id so receivers can dedup reliably even across
// slow-Tor retries/redeliveries (#31). Old receivers ignore it.
"msg_id": uuid::Uuid::new_v4().to_string(),
});
if let Some(name) = from_name {
body["from_name"] = serde_json::Value::String(name.to_string());
+2 -2
View File
@@ -27,7 +27,7 @@ const D_TAG: &str = "archipelago-node";
const LEGACY_RELAYS: &[&str] = &["wss://relay.damus.io", "wss://relay.nostr.info"];
/// Load or create Nostr keys (secp256k1) for node discovery.
async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
pub(crate) async fn load_or_create_nostr_keys(identity_dir: &Path) -> Result<Keys> {
let secret_path = identity_dir.join(NOSTR_SECRET_FILE);
let pub_path = identity_dir.join(NOSTR_PUB_FILE);
@@ -78,7 +78,7 @@ async fn load_nostr_keys_if_exists(identity_dir: &Path) -> Result<Option<Keys>>
/// Publish a replaceable event with empty content to overwrite/revoke previously published data.
/// Uses NIP-33: same kind + d-tag + author = latest replaces. Sends to LEGACY_RELAYS only.
/// Requires tor_proxy to avoid leaking IP to relay operators.
fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
pub(crate) fn build_nostr_client(keys: Keys, tor_proxy: Option<&str>) -> Result<Client> {
let client = if let Some(proxy_str) = tor_proxy {
let addr = parse_proxy_addr(proxy_str)
.ok_or_else(|| anyhow::anyhow!("Invalid Nostr Tor proxy: {}", proxy_str))?;
+2
View File
@@ -18,6 +18,7 @@ const RESERVED_PORTS: &[u16] = &[
4080, 8999, 50001, // Mempool stack
23000, // BTCPay
8173, 8174, 8175, // Fedimint
8178, // Fedimint client daemon (fedimint-clientd REST)
8123, // Home Assistant
3000, // Grafana
11434, // Ollama
@@ -28,6 +29,7 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback
8336, // FIPS UI (fips-ui)
];
/// Start of range for allocating web app ports when preferred is taken.
+72
View File
@@ -8,6 +8,8 @@
//! ├── HKDF(seed, "archipelago/node/ed25519/v1") → Node Ed25519 → did:key
//! ├── HKDF(seed, "archipelago/nostr-node/secp256k1/v1") → Node Nostr key
//! ├── HKDF(seed, "archipelago/fips/secp256k1/v1") → FIPS mesh transport key
//! ├── HKDF(seed, "archipelago/release/root/ed25519/v1") → Release-root signing key
//! │ (publisher-only; nodes pin the PUBLIC key — see trust::anchor)
//! ├── HKDF(seed, "archipelago/identity/{i}/ed25519/v1") → Identity i Ed25519
//! ├── BIP-32 m/44'/1237'/0'/0/{i} → Identity i Nostr (NIP-06)
//! ├── BIP-32 m/84'/0'/0' → Bitcoin Core wallet
@@ -34,6 +36,7 @@ const NODE_ED25519_INFO: &[u8] = b"archipelago/node/ed25519/v1";
const NODE_NOSTR_INFO: &[u8] = b"archipelago/nostr-node/secp256k1/v1";
const FIPS_KEY_INFO: &[u8] = b"archipelago/fips/secp256k1/v1";
const LND_ENTROPY_INFO: &[u8] = b"archipelago/lnd/entropy/v1";
const RELEASE_ROOT_ED25519_INFO: &[u8] = b"archipelago/release/root/ed25519/v1";
// ─── MasterSeed ─────────────────────────────────────────────────────────
@@ -88,6 +91,21 @@ pub fn derive_node_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
Ok(SigningKey::from_bytes(&derived))
}
/// Derive the fleet **release-root** Ed25519 signing key.
///
/// This is a *publisher-side* derivation: only the holder of the release master
/// seed runs it (e.g. in the signing ceremony). Fleet nodes never derive this —
/// they pin the corresponding PUBLIC key as a trust anchor (see
/// `crate::trust::anchor`) and use it to verify signed manifests/catalogs.
///
/// Keeping it seed-derived means the signing key is reproducible from a
/// backed-up mnemonic (disaster recovery) rather than a loose key file, and it
/// is domain-separated from every node/identity key by its HKDF info string.
pub fn derive_release_root_ed25519(seed: &MasterSeed) -> Result<SigningKey> {
let derived = hkdf_derive_32(seed.as_bytes(), RELEASE_ROOT_ED25519_INFO)?;
Ok(SigningKey::from_bytes(&derived))
}
/// Derive an identity's Ed25519 signing key by index.
pub fn derive_identity_ed25519(seed: &MasterSeed, index: u32) -> Result<SigningKey> {
let info = format!("archipelago/identity/{}/ed25519/v1", index);
@@ -543,4 +561,58 @@ 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"
);
}
#[test]
fn test_release_root_deterministic_and_domain_separated() {
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let a = derive_release_root_ed25519(&seed).unwrap();
let b = derive_release_root_ed25519(&seed).unwrap();
assert_eq!(
a.verifying_key().as_bytes(),
b.verifying_key().as_bytes(),
"Same mnemonic must produce the same release-root key"
);
// Must NOT collide with the node key — different HKDF domain.
let node = derive_node_ed25519(&seed).unwrap();
assert_ne!(
a.verifying_key().as_bytes(),
node.verifying_key().as_bytes(),
"Release-root key must be domain-separated from the node key"
);
}
#[test]
fn test_release_root_known_answer() {
// KAT pins the derivation so the signing ceremony, the pinned anchor,
// and any external verifier agree on the bytes for a given mnemonic.
let (_, seed) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let key = derive_release_root_ed25519(&seed).unwrap();
assert_eq!(
hex::encode(key.to_bytes()),
"613ab879e5fbd4fcded32bc7ffad662fff1ce0f744c69baa63e7416ffabe7b71",
"release-root private key KAT"
);
assert_eq!(
hex::encode(key.verifying_key().to_bytes()),
"995eaf9188617f0ecbcff9cd44d57adb9aa7dd5f34db2733e97f3e317fb0aba2",
"release-root public key KAT"
);
}
}
+224
View File
@@ -150,6 +150,31 @@ impl Server {
}
}
// DHT swarm-assist (Phase 3): build the iroh provider once at startup so
// release downloads can fetch from peers (origin always wins) and seed
// what they hold. Inert unless built with `iroh-swarm` AND swarm_enabled.
if let Err(e) = crate::swarm::init(
&config.data_dir,
&config.nostr_relays,
config.nostr_tor_proxy.as_deref(),
config.swarm_enabled,
)
.await
{
tracing::warn!("Swarm init (non-fatal, falling back to origin-only): {}", e);
}
// Resume any cross-mint ecash swap interrupted by a previous crash
// (paid the source mint but never claimed the target tokens). Best-effort.
match crate::wallet::ecash::resume_pending_swaps(&config.data_dir).await {
Ok(0) => {}
Ok(reclaimed) => tracing::info!(
"Resumed interrupted cross-mint swaps: reclaimed {} sats",
reclaimed
),
Err(e) => tracing::debug!("resume_pending_swaps (non-fatal): {}", e),
}
// Revoke any previously published Nostr data (runs before publish so revocation is not overwritten)
let identity_dir = config.data_dir.join("identity");
let tor_proxy_revoke = config.nostr_tor_proxy.clone();
@@ -241,12 +266,86 @@ impl Server {
warn!("Mesh service start failed (non-fatal): {}", e);
} else {
info!("📡 Mesh networking started");
// Push mesh peer changes to open WebSockets instantly
// instead of the UI polling every 5s (#48): subscribe to
// mesh events and nudge the data-model revision (debounced)
// so /ws/db clients refetch peers on discovery/update.
let mut rx = mesh_service.state().event_tx.subscribe();
let sm = state_manager.clone();
tokio::spawn(async move {
use tokio::time::{Duration, Instant};
let mut last: Option<Instant> = None;
loop {
match rx.recv().await {
Ok(crate::mesh::MeshEvent::PeerDiscovered(_))
| Ok(crate::mesh::MeshEvent::PeerUpdated(_)) => {
// Debounce advert storms to ~2 Hz.
if last
.map(|t| t.elapsed() < Duration::from_millis(500))
.unwrap_or(false)
{
continue;
}
last = Some(Instant::now());
let (data, _) = sm.get_snapshot().await;
sm.update_data(data).await;
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(
_,
)) => continue,
Err(_) => break, // sender dropped → mesh stopped
}
}
});
}
}
api_handler
.rpc_handler()
.set_mesh_service(mesh_service)
.await;
// Mesh-AI assistant (#50): deliver `!ai`-in-chat answers via
// the transport-aware send path. The listener can't route
// over federation itself (send_message needs the signing key
// + Tor client on MeshService), so it emits AssistChatReply
// and we fulfil it here through the shared MeshService —
// which POSTs over Tor for federation askers and falls back
// to LoRa for radio askers, recording the Sent bubble.
{
let mesh_arc = api_handler.rpc_handler().mesh_service_arc();
let mut reply_rx = {
let guard = mesh_arc.read().await;
guard.as_ref().map(|svc| svc.state().event_tx.subscribe())
};
if let Some(mut rx) = reply_rx.take() {
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(crate::mesh::MeshEvent::AssistChatReply {
contact_id,
text,
}) => {
let guard = mesh_arc.read().await;
if let Some(svc) = guard.as_ref() {
if let Err(e) =
svc.send_message(contact_id, &text).await
{
warn!("AI chat reply send failed: {}", e);
}
}
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(
_,
)) => continue,
Err(_) => break, // sender dropped → mesh stopped
}
}
});
}
}
info!("📡 Mesh service initialized");
}
Err(e) => {
@@ -329,6 +428,99 @@ impl Server {
});
}
// Periodic federation auto-sync. Pulls every federated peer's state on a
// timer so renamed nodes and roster changes propagate WITHOUT a manual
// "Sync" click. Each sync now fast-fails a dead FIPS path and falls back
// to Tor (~3-5s), so a full pass over a handful of peers is quick.
{
let data_dir = config.data_dir.clone();
let state = state_manager.clone();
tokio::spawn(async move {
// Delay the first pass so Tor/onion publishing settles after boot.
tokio::time::sleep(Duration::from_secs(20)).await;
let mut interval = tokio::time::interval(Duration::from_secs(90));
loop {
interval.tick().await;
let nodes = match crate::federation::load_nodes(&data_dir).await {
Ok(n) if !n.is_empty() => n,
_ => continue,
};
let (snap, _) = state.get_snapshot().await;
let local_did =
match crate::identity::did_key_from_pubkey_hex(&snap.server_info.pubkey) {
Ok(d) => d,
Err(_) => continue,
};
let identity_dir = data_dir.join("identity");
let node_identity =
match crate::identity::NodeIdentity::load_or_create(&identity_dir).await {
Ok(id) => id,
Err(_) => continue,
};
// Our own identity, for re-asserting membership to any peer
// that doesn't list us back (asymmetry self-heal, below).
let local_onion = snap.server_info.tor_address.clone().unwrap_or_default();
let local_pubkey = snap.server_info.pubkey.clone();
let local_name = snap.server_info.name.clone();
let local_fips_npub =
crate::identity::fips_npub(&identity_dir).await.unwrap_or(None);
let mut ok = 0usize;
let mut healed = 0usize;
for node in &nodes {
if node.trust_level == crate::federation::TrustLevel::Untrusted {
continue;
}
match crate::federation::sync_with_peer(&data_dir, node, &local_did, |b| {
node_identity.sign(b)
})
.await
{
Ok(state) => {
ok += 1;
// Asymmetry self-heal: if this peer's exported
// trusted list doesn't include us, our original
// peer-joined never landed (e.g. it was sent
// before the reliable-notify fix, or the peer was
// down). Re-assert membership over the now
// FIPS-fast-failing/Tor path so they add us back.
// Without this, a node that joined everyone stays
// invisible to the whole fleet until a manual
// re-add (the "X250-EXP missing everywhere" case).
let they_list_us = state
.federated_peers
.iter()
.any(|h| h.did == local_did);
if !they_list_us && !local_onion.is_empty() {
crate::federation::notify_join(
&node.onion,
node.fips_npub.as_deref(),
&local_did,
&local_onion,
&local_pubkey,
local_fips_npub.as_deref(),
local_name.as_deref(),
|b| node_identity.sign(b),
)
.await
.ok();
healed += 1;
}
}
Err(e) => {
debug!(peer = %node.did, error = %e, "federation auto-sync (non-fatal)")
}
}
}
debug!(
synced = ok,
reasserted = healed,
total = nodes.len(),
"federation auto-sync pass complete"
);
}
});
}
// Initialize container scanner — discovers installed apps from Podman/Docker
{
let scanner = create_docker_scanner(&config).await?;
@@ -508,6 +700,7 @@ impl Server {
{
let data_dir = config.data_dir.clone();
let state = state_manager.clone();
let rpc = api_handler.rpc_handler().clone();
tokio::spawn(async move {
// First run 60s after boot to let onboarding settle.
tokio::time::sleep(Duration::from_secs(60)).await;
@@ -558,6 +751,10 @@ impl Server {
}
tokio::time::sleep(Duration::from_secs(5)).await;
}
// After syncing every peer, push the names/roster just
// learned (into nodes.json) into the live mesh peer table
// so chat contacts refresh without a restart (#42).
rpc.refresh_federation_mesh_peers().await;
}
});
}
@@ -769,6 +966,13 @@ pub fn is_peer_allowed_path(path: &str) -> bool {
| "/archipelago/mesh-typed"
| "/dwn"
| "/transport/inbox"
// Content *catalog* — the peer-browse entry point. This is the
// exact path `/content` (no trailing slash); the prefix match
// below only covers `/content/<id>` item fetches, so without
// this the catalog 404s over the mesh and `content.browse-peer`
// fails with "Peer returned error: 404 Not Found" (and never
// falls back to Tor, since a 404 is a successful HTTP exchange).
| "/content"
)
// Prefix-matched content endpoints (peer file browse + fetch)
|| path.starts_with("/content/")
@@ -1248,6 +1452,7 @@ fn ensure_main_lan_address(pkg: &mut crate::data_model::PackageDataEntry, port:
fn fallback_package_port(app_id: &str) -> Option<u16> {
match app_id {
"fedimint" | "fedimintd" => Some(8175),
"fedimint-clientd" => Some(8178),
"filebrowser" => Some(8083),
"indeedhub" => Some(7778),
"nginx-proxy-manager" => Some(8081),
@@ -1378,6 +1583,25 @@ mod merge_tests {
}
}
#[test]
fn peer_path_filter_allows_content_catalog_and_items() {
// Regression: the content *catalog* is exactly "/content" (no trailing
// slash). It must be reachable over the peer (FIPS) listener, else
// `content.browse-peer` 404s over the mesh. Item fetches are
// "/content/<id>".
assert!(is_peer_allowed_path("/content"), "catalog must be allowed");
assert!(
is_peer_allowed_path("/content/abc123"),
"items must be allowed"
);
assert!(is_peer_allowed_path("/rpc/v1"));
assert!(is_peer_allowed_path("/health"));
// Not on the allow-list → rejected (no broad surface over the mesh).
assert!(!is_peer_allowed_path("/contention"), "must not prefix-leak");
assert!(!is_peer_allowed_path("/"));
assert!(!is_peer_allowed_path("/rpc/v2"));
}
#[test]
fn preserves_transitional_state_on_merge() {
// existing: user initiated a stop, spawn_transitional set Stopping.
+108
View File
@@ -0,0 +1,108 @@
//! At-rest encryption for local state stores (chat messages, mesh contacts).
//!
//! Best-practice envelope, matching `credentials::store`:
//! - **Key**: SHA-256(domain-separator ‖ node identity key). The node key is
//! seed-derived and never leaves the device, so each store is bound to this
//! node's identity — a stolen disk image is unreadable without it, and the
//! per-domain separator means one store's key can't open another.
//! - **Cipher**: ChaCha20-Poly1305 AEAD with a fresh random 96-bit nonce per
//! write (`nonce ‖ ciphertext` on disk). The Poly1305 tag makes it
//! tamper-evident — any on-disk modification fails to open.
//! - **Migration**: legacy plaintext JSON is detected and read transparently,
//! then re-written encrypted on the next save. No data is stranded.
use anyhow::{Context, Result};
use std::path::Path;
/// Domain separators — one per store so keys never overlap.
pub const DOMAIN_MESSAGES: &[u8] = b"archipelago-message-store-v1";
pub const DOMAIN_MESH_CONTACTS: &[u8] = b"archipelago-mesh-contacts-v1";
/// Derive a 32-byte key bound to this node's identity for a given store domain.
pub async fn derive_key(data_dir: &Path, domain: &[u8]) -> Result<[u8; 32]> {
let node_key_path = data_dir.join("identity").join("node_key");
let key_bytes = tokio::fs::read(&node_key_path)
.await
.context("reading node key for at-rest encryption")?;
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(domain);
hasher.update(&key_bytes);
let mut key = [0u8; 32];
key.copy_from_slice(&hasher.finalize());
Ok(key)
}
/// Encrypt `plaintext`, returning `nonce ‖ ciphertext`.
pub fn seal(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
let nonce_bytes: [u8; 12] = rand::random();
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
let ct = cipher
.encrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
plaintext,
)
.map_err(|e| anyhow::anyhow!("encryption failed: {e}"))?;
let mut out = Vec::with_capacity(12 + ct.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(out)
}
/// Decrypt `nonce ‖ ciphertext`.
pub fn open(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
use chacha20poly1305::aead::{Aead, KeyInit};
if data.len() < 12 {
anyhow::bail!("ciphertext too short");
}
let (nonce, ct) = data.split_at(12);
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
.map_err(|e| anyhow::anyhow!("cipher init: {e}"))?;
cipher
.decrypt(
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
ct,
)
.map_err(|_| anyhow::anyhow!("decryption failed — key mismatch or corruption"))
}
/// Heuristic: does this look like legacy plaintext JSON (starts with `{`/`[`)?
/// Encrypted blobs start with a random nonce byte, so a `{`/`[` first byte is a
/// reliable migration signal.
pub fn is_plaintext_json(raw: &[u8]) -> bool {
matches!(raw.first(), Some(b'{') | Some(b'['))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_round_trips() {
let key = [7u8; 32];
let msg = br#"{"messages":[{"m":"hi"}]}"#;
let sealed = seal(msg, &key).unwrap();
// Encrypted output must NOT be readable plaintext.
assert!(!is_plaintext_json(&sealed));
assert_ne!(&sealed[12..], &msg[..]);
assert_eq!(open(&sealed, &key).unwrap(), msg);
}
#[test]
fn open_fails_on_wrong_key_or_tamper() {
let sealed = seal(b"secret", &[1u8; 32]).unwrap();
assert!(open(&sealed, &[2u8; 32]).is_err());
let mut tampered = sealed.clone();
*tampered.last_mut().unwrap() ^= 0x01;
assert!(open(&tampered, &[1u8; 32]).is_err());
}
#[test]
fn detects_plaintext_vs_ciphertext() {
assert!(is_plaintext_json(b"{\"a\":1}"));
assert!(is_plaintext_json(b"[]"));
assert!(!is_plaintext_json(&seal(b"x", &[3u8; 32]).unwrap()));
}
}

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