Compare commits

..
Author SHA1 Message Date
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
archipelago d462e44453 chore: release v1.7.92-alpha 2026-06-14 09:09:57 -04:00
archipelagoandClaude Opus 4.8 1af583e1ab docs: add third v1.7.92 changelog bullet (What's New backfill) + sync modal
create-release staging requires >=3 curated release-note bullets. The What's
New restoration is itself user-facing, so it's an honest third note; mirror it
into the modal's v1.7.92 block via sync-whats-new.py.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 03:12:56 -04:00
240 changed files with 20837 additions and 1228 deletions
@@ -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()
@@ -63,6 +63,21 @@ fun RemoteInputScreen(onBack: () -> Unit) {
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
+82
View File
@@ -1,5 +1,87 @@
# Changelog
## 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.
- Behind the scenes, the reboot-survival test now confirms the whole system is genuinely healthy after a restart — every app reachable, updates not stuck, core services answering — instead of only checking that containers came back, so update-related problems are caught before shipping.
- Settings → What's New now lists the notes for every recent release again. The screen had quietly fallen several versions behind, so the last eight releases of changes weren't showing up there — they're all back now, and a release check keeps it from drifting again.
## v1.7.91-alpha (2026-06-14)
- Apps you've installed now reliably show their "Open" button again. Some apps — including Jellyfin, BTCPay Server, Fedimint, Gitea and Portainer — were running fine but their launch link sometimes went missing, so there was no way to open them from the home screen. They now open correctly.
- Receiving Bitcoin is more dependable: if the wallet's internal connection details drift after a restart, it now repairs them on its own, and any error it does hit is reported clearly instead of as a generic failure or a misleading "wallet locked" message.
- Installing Bitcoin now sets itself up correctly without manual help — a security credential that could previously be missing and stop Bitcoin from starting is created automatically before it launches.
- The Electrum server app is back on the home screen and can be launched again.
- Behind the scenes, the release now runs an expanded automated test suite before shipping, so these kinds of issues are caught earlier.
## v1.7.90-alpha (2026-06-13)
- Generating a Bitcoin receive address works again — the wallet now requests the correct address type, fixing the "400 Bad Request" error when creating an address.
+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",
+14
View File
@@ -51,6 +51,20 @@ app:
- CACHE_MB=1024
- MAX_SEND=10000000
# The ElectrumX dashboard tile is served by the host-networked companion UI
# (archy-electrs-ui) on port 50002, NOT by this container. Declaring it here
# lets the catalog generator emit electrumx -> 50002 into GENERATED_APP_PORTS
# so the tile resolves a launch URL without relying on the hand-maintained
# override in appSessionConfig.ts (which the generator can clobber). The
# backend only validates this block — it does not proxy/health-check it.
interfaces:
main:
name: Web UI
description: ElectrumX server status and connection details
type: ui
port: 50002
protocol: http
health_check:
type: tcp
endpoint: localhost:50001
+75
View File
@@ -0,0 +1,75 @@
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. Lock down once the default
# federation's reachability model is finalized.
network_policy: open
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.90-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"
+273
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,
)
@@ -140,6 +156,255 @@ 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) => {
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 +455,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()
}
+1 -1
View File
@@ -1,4 +1,4 @@
mod handler;
mod rpc;
pub(crate) mod rpc;
pub use handler::ApiHandler;
+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();
@@ -107,7 +107,7 @@ struct TrustedRelayPeer {
}
#[derive(Debug, Clone)]
struct TxRelayCredentials {
pub(crate) struct TxRelayCredentials {
username: String,
password: String,
}
@@ -648,7 +648,13 @@ async fn txrelay_credentials_available(data_dir: &Path) -> bool {
&& fs::metadata(&client_env_path).await.is_ok()
}
async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
/// Idempotently ensure the tx-relay credential trio exists in the secrets dir:
/// the random password, its derived `rpcauth` line, and the client env file.
/// Bitcoin backend manifests reference `bitcoin-rpc-txrelay-rpcauth` as a
/// required `secret_env`, so this must run before bitcoind starts — otherwise
/// secret resolution hard-fails and the whole Bitcoin stack cascades (the .198
/// failure). Safe to call repeatedly; it only writes what's missing or stale.
pub(crate) async fn ensure_txrelay_credentials(data_dir: &Path) -> Result<TxRelayCredentials> {
let (password_path, rpcauth_path, client_env_path) = txrelay_secret_paths(data_dir);
let password = match read_trimmed(&password_path).await {
Some(value) => value,
+450 -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();
@@ -294,13 +303,21 @@ impl RpcHandler {
fips_npub.is_some()
);
let (response, _transport) =
let (response, transport) =
crate::fips::dial::PeerRequest::new(fips_npub.as_deref(), onion, "/content")
.service(crate::settings::transport::PeerService::PeerFiles)
.timeout(std::time::Duration::from_secs(30))
.send_get()
.await
.context("Failed to connect to peer")?;
// Record which transport actually reached the peer (B14).
let _ = crate::federation::record_peer_transport(
&self.config.data_dir,
None,
Some(onion),
&transport.to_string(),
)
.await;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
@@ -309,11 +326,20 @@ impl RpcHandler {
));
}
let body: serde_json::Value = response
let mut body: serde_json::Value = response
.json()
.await
.context("Failed to parse peer catalog")?;
// Surface the transport that actually reached the peer so the cloud
// browse UI can show a FIPS/Tor pill instead of always assuming Tor (B21).
if let Some(obj) = body.as_object_mut() {
obj.insert(
"transport".to_string(),
serde_json::Value::String(transport.to_string()),
);
}
Ok(body)
}
@@ -353,25 +379,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 +439,381 @@ 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;
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(60))
.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;
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(30))
.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;
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(60))
.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(30))
.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 +843,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,
})
})
+325 -13
View File
@@ -9,9 +9,15 @@ use super::LND_REST_BASE_URL;
impl RpcHandler {
/// Generate a new on-chain Bitcoin address.
pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result<serde_json::Value> {
let (client, macaroon_hex) = self.lnd_client().await?;
let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| {
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: client/macaroon unavailable");
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The Lightning wallet isn't set up on this node yet. Finish wallet setup, then try again.",
)
})?;
let resp = client
let resp = match client
.get(format!("{LND_REST_BASE_URL}/v1/newaddress"))
// LND's REST gateway parses `type` as the AddressType enum by its
// proto name (or integer), NOT the lncli aliases. "p2wkh" is not a
@@ -21,13 +27,26 @@ impl RpcHandler {
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await
.context("LND REST connection failed")?;
{
Ok(resp) => resp,
Err(e) => {
// The .116 case: LND container is up but its REST endpoint isn't
// reachable on the expected port (e.g. published-port drift), so
// the connection is refused. This is NOT a locked wallet — emit a
// distinct code so the UI stops mislabelling it.
tracing::warn!(error = %format!("{e:#}"), "LND newaddress: REST connection failed");
return Err(receive_error(
RECEIVE_REST_UNREACHABLE,
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment.",
));
}
};
let status = resp.status();
let raw_body = resp
.text()
.await
.context("LND address response could not be read")?;
.context("Bitcoin address response could not be read")?;
let body: serde_json::Value = serde_json::from_str(&raw_body).unwrap_or_else(|_| {
serde_json::json!({
"raw": raw_body,
@@ -36,11 +55,9 @@ impl RpcHandler {
if !status.is_success() {
let message = lnd_error_message(&body);
anyhow::bail!(
"Bitcoin address generation failed ({}): {}",
status,
message
);
let code = classify_lnd_address_error(&message);
tracing::warn!(%status, lnd_message = %message, code, "LND newaddress returned an error");
return Err(receive_error(code, default_receive_detail(code)));
}
if let Some(error) = body
@@ -48,14 +65,21 @@ impl RpcHandler {
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
{
anyhow::bail!("Bitcoin address generation failed: {}", error);
let code = classify_lnd_address_error(error);
tracing::warn!(lnd_message = %error, code, "LND newaddress returned an error body");
return Err(receive_error(code, default_receive_detail(code)));
}
let address = body
.get("address")
.and_then(|v| v.as_str())
.filter(|addr| !addr.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("Bitcoin address generation failed: LND did not return a Bitcoin address. The wallet may still be locked, uninitialized, or waiting for Bitcoin to sync."))?
.ok_or_else(|| {
receive_error(
RECEIVE_WALLET_UNINITIALIZED,
"The wallet didn't return an address yet. It may still be unlocking or waiting for Bitcoin to sync — please try again shortly.",
)
})?
.to_string();
Ok(serde_json::json!({ "address": address }))
@@ -127,6 +151,179 @@ 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.
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,
});
let resp = 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")?;
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>,
@@ -528,8 +725,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).
@@ -583,9 +787,75 @@ fn lnd_error_message(body: &serde_json::Value) -> String {
.to_string()
}
// Stable, machine-readable reason codes for receive-address failures. They are
// embedded in the error message as a `[CODE]` token so the frontend
// (neode-ui/src/utils/bitcoinReceive.ts) can show an accurate explanation
// instead of guessing wallet state by substring-matching — which is what made
// .228 report "wallet is locked" when LND's REST was merely unreachable.
//
// Every receive error string starts with "Bitcoin address" so it survives the
// RPC error sanitizer (api/rpc/middleware.rs) unchanged rather than being
// flattened to the generic "Operation failed" message (the .116 symptom).
pub(crate) const RECEIVE_REST_UNREACHABLE: &str = "LND_REST_UNREACHABLE";
pub(crate) const RECEIVE_WALLET_LOCKED: &str = "LND_WALLET_LOCKED";
pub(crate) const RECEIVE_WALLET_UNINITIALIZED: &str = "LND_WALLET_UNINITIALIZED";
pub(crate) const RECEIVE_SYNCING: &str = "LND_SYNCING";
pub(crate) const RECEIVE_LND_ERROR: &str = "LND_ERROR";
/// Build a receive-address error carrying a reason code the UI can map.
fn receive_error(code: &str, detail: &str) -> anyhow::Error {
anyhow::anyhow!("Bitcoin address unavailable [{code}]: {detail}")
}
/// A sensible default human message per code (used for non-UI callers and logs;
/// the frontend renders its own copy from the code).
fn default_receive_detail(code: &str) -> &'static str {
match code {
RECEIVE_REST_UNREACHABLE => {
"The Lightning wallet service isn't reachable yet. It may be starting up or recovering — please try again in a moment."
}
RECEIVE_WALLET_LOCKED => {
"The Lightning wallet is locked. Unlock it (or finish wallet setup), then try again."
}
RECEIVE_WALLET_UNINITIALIZED => {
"The Lightning wallet isn't set up yet. Finish wallet setup, then try again."
}
RECEIVE_SYNCING => {
"The wallet is still syncing with the Bitcoin network. Please try again once it has caught up."
}
_ => "Couldn't generate a Bitcoin address right now. Please try again shortly.",
}
}
/// Classify a non-2xx LND error body/message into a reason code. The wording of
/// LND's REST errors is stable enough to bucket: a locked wallet, an
/// uninitialized wallet, a syncing chain, or some other failure.
fn classify_lnd_address_error(message: &str) -> &'static str {
let m = message.to_lowercase();
if m.contains("locked") || m.contains("unlock") {
RECEIVE_WALLET_LOCKED
} else if m.contains("synchroniz")
|| m.contains("syncing")
|| m.contains("not yet ready")
|| m.contains("in the process of starting")
{
RECEIVE_SYNCING
} else if m.contains("wallet not found")
|| m.contains("not exist")
|| m.contains("uninitialized")
|| m.contains("not initialized")
|| m.contains("create a wallet")
|| m.contains("no wallet")
{
RECEIVE_WALLET_UNINITIALIZED
} else {
RECEIVE_LND_ERROR
}
}
#[cfg(test)]
mod tests {
use super::lnd_error_message;
use super::*;
#[test]
fn lnd_error_message_prefers_message_field() {
@@ -604,4 +874,46 @@ mod tests {
"unknown LND error"
);
}
#[test]
fn classify_locked_wallet() {
assert_eq!(
classify_lnd_address_error("wallet locked, please unlock"),
RECEIVE_WALLET_LOCKED
);
}
#[test]
fn classify_uninitialized_wallet() {
assert_eq!(
classify_lnd_address_error("wallet not found, create a wallet first"),
RECEIVE_WALLET_UNINITIALIZED
);
}
#[test]
fn classify_syncing() {
assert_eq!(
classify_lnd_address_error("server is still in the process of starting"),
RECEIVE_SYNCING
);
}
#[test]
fn classify_unknown_is_generic_error() {
assert_eq!(
classify_lnd_address_error("some other failure"),
RECEIVE_LND_ERROR
);
}
#[test]
fn receive_error_starts_with_sanitizer_safe_prefix_and_embeds_code() {
// Must start with "Bitcoin address" (survives the RPC error sanitizer)
// and carry the [CODE] token the frontend parses.
let err = receive_error(RECEIVE_REST_UNREACHABLE, "unreachable");
let s = format!("{err}");
assert!(s.starts_with("Bitcoin address"), "got: {s}");
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
}
}
@@ -0,0 +1,176 @@
//! 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 = {
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
};
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,
"default_model": DEFAULT_MODEL,
"ollama_detected": ollama_detected,
"claude_available": claude_available,
"models": models,
}))
}
/// 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
};
svc.configure_assistant(enabled, model, trusted_only, backend)
.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,
}))
}
/// 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;
+19 -6
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.
@@ -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;
+29 -21
View File
@@ -32,6 +32,8 @@ fn is_platform_managed_app(app_id: &str) -> bool {
| "fedimint-gateway"
| "indeedhub"
| "immich"
| "fips"
| "fips-ui"
)
}
@@ -750,27 +752,33 @@ pub(super) async fn get_app_config(
None,
None,
),
"mempool-api" => (
vec!["8999:8999".to_string()],
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
"CORE_RPC_HOST=bitcoin-knots".to_string(),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
"DATABASE_USERNAME=mempool".to_string(),
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
],
None,
None,
),
"mempool-api" => {
// CORE_RPC_HOST must resolve to the actual Bitcoin node container —
// bitcoin-knots OR bitcoin-core — else mempool-api can't reach RPC
// on a Core node (B12). Falls back to bitcoin-knots if undetected.
let bitcoin_rpc_host = super::dependencies::detect_bitcoin_rpc_host().await;
(
vec!["8999:8999".to_string()],
vec!["/var/lib/archipelago/mempool:/data".to_string()],
vec![
"MEMPOOL_BACKEND=electrum".to_string(),
"ELECTRUM_HOST=electrumx".to_string(),
"ELECTRUM_PORT=50001".to_string(),
"ELECTRUM_TLS_ENABLED=false".to_string(),
format!("CORE_RPC_HOST={}", bitcoin_rpc_host),
"CORE_RPC_PORT=8332".to_string(),
"CORE_RPC_USERNAME=archipelago".to_string(),
format!("CORE_RPC_PASSWORD={}", rpc_pass),
"DATABASE_ENABLED=true".to_string(),
"DATABASE_HOST=archy-mempool-db".to_string(),
"DATABASE_DATABASE=mempool".to_string(),
"DATABASE_USERNAME=mempool".to_string(),
format!("DATABASE_PASSWORD={}", read_secret("mempool-db-password", "mempoolpass")),
],
None,
None,
)
}
"electrumx" | "mempool-electrs" | "electrs" => {
(
vec!["50001:50001".to_string()],
@@ -84,6 +84,78 @@ pub(super) async fn detect_running_deps() -> Result<RunningDeps> {
})
}
/// Detect the container name of the running Bitcoin node so dependent stacks
/// (mempool) can point CORE_RPC_HOST at the right host. Bitcoin Knots and Bitcoin
/// Core are both reachable on archy-net by their container name — only the name
/// differs (`bitcoin-knots` vs `bitcoin-core`), so hardcoding one breaks the
/// other. Returns the first running BITCOIN_NAMES match; falls back to the
/// default `bitcoin-knots` if none is detected (callers gate on has_bitcoin).
pub(super) async fn detect_bitcoin_rpc_host() -> String {
let out = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::process::Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output(),
)
.await;
if let Ok(Ok(o)) = out {
if o.status.success() {
let running = String::from_utf8_lossy(&o.stdout);
if let Some(name) = pick_bitcoin_host(&running) {
return name;
}
}
}
"bitcoin-knots".to_string()
}
/// Pure host-selection step of [`detect_bitcoin_rpc_host`], split out so it can
/// be unit-tested without a podman runtime. Returns the first `podman ps` line
/// whose trimmed name is one of [`BITCOIN_NAMES`]. (The Quadlet orchestrator
/// mirrors this in `prod_orchestrator::bitcoin_host`.)
fn pick_bitcoin_host(podman_names: &str) -> Option<String> {
podman_names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
}
#[cfg(test)]
mod bitcoin_host_tests {
use super::pick_bitcoin_host;
#[test]
fn picks_knots() {
let ps = "electrumx\nbitcoin-knots\narchy-mempool-db\n";
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-knots"));
}
#[test]
fn picks_core() {
let ps = "lnd\nbitcoin-core\nelectrumx\n";
assert_eq!(pick_bitcoin_host(ps).as_deref(), Some("bitcoin-core"));
}
#[test]
fn picks_plain_bitcoin() {
assert_eq!(pick_bitcoin_host("bitcoin\n").as_deref(), Some("bitcoin"));
}
#[test]
fn none_when_no_bitcoin_node() {
let ps = "electrumx\nlnd\narchy-mempool-db\n";
assert_eq!(pick_bitcoin_host(ps), None);
}
#[test]
fn ignores_substring_matches() {
// A companion UI container must NOT be mistaken for the node itself.
let ps = "archy-bitcoin-ui\nbitcoin-knots-foo\n";
assert_eq!(pick_bitcoin_host(ps), None);
}
}
/// Verify that required dependency services are running before installing an app.
/// Returns an error with a user-friendly message if dependencies are missing.
pub(super) fn check_install_deps(package_id: &str, deps: &RunningDeps) -> Result<()> {
+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(
+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()
]
);
}
}
@@ -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(),
@@ -699,7 +701,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
if !requires_reachable_launch(app_id) {
return Some(url);
}
let Some(port) = url.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()) else {
let Some(port) = launch_url_port(&url) else {
return None;
};
if launch_port_reachable(port).await {
@@ -710,6 +712,23 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
}
}
/// Extract the TCP port from a launch URL's authority.
///
/// The candidate URL can carry a path when it comes from a manifest
/// `interfaces.main` declaration (e.g. `http://localhost:8096/`). A naive
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect::<String>()
.parse::<u16>()
.ok()
}
async fn launch_port_reachable(port: u16) -> bool {
matches!(
tokio::time::timeout(
@@ -788,3 +807,26 @@ fn package_state_str(state: &PackageState) -> &str {
PackageState::Updating => "updating",
}
}
#[cfg(test)]
mod launch_url_port_tests {
use super::launch_url_port;
#[test]
fn parses_port_with_trailing_path() {
// Regression: manifest interfaces.main yields a path-suffixed URL.
// The old rsplit(':') parse produced "8096/" and dropped the URL.
assert_eq!(launch_url_port("http://localhost:8096/"), Some(8096));
assert_eq!(launch_url_port("http://localhost:8175/admin"), Some(8175));
}
#[test]
fn parses_bare_authority_port() {
assert_eq!(launch_url_port("http://localhost:8083"), Some(8083));
}
#[test]
fn rejects_url_without_port() {
assert_eq!(launch_url_port("http://localhost/"), None);
}
}
@@ -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;
@@ -176,6 +176,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 +283,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 {
@@ -297,6 +475,53 @@ async fn wait_for_manifest_host_ports(manifest: &AppManifest, timeout_secs: u64)
Ok(())
}
/// Pure published-port drift check. `port_bindings_json` is the JSON that
/// `podman inspect --format '{{json .HostConfig.PortBindings}}'` emits, e.g.
/// `{"8080/tcp":[{"HostIp":"","HostPort":"18080"}]}`. Returns true only when a
/// manifest container-port is positively published to a *different* host port
/// than the manifest now asks for. Absence of a binding is deliberately NOT
/// treated as drift here — that case is handled by the host-port repair/restart
/// path and by host-networked apps that publish nothing — so we never trigger a
/// destructive recreate on a false positive.
fn host_port_bindings_drifted(
port_bindings_json: &str,
manifest_ports: &[archipelago_container::manifest::PortMapping],
) -> bool {
let parsed: serde_json::Value = match serde_json::from_str(port_bindings_json) {
Ok(v) => v,
Err(_) => return false,
};
let Some(map) = parsed.as_object() else {
return false;
};
for port in manifest_ports {
let proto = if port.protocol.is_empty() {
"tcp"
} else {
port.protocol.as_str()
};
let key = format!("{}/{}", port.container, proto);
let Some(bindings) = map.get(&key).and_then(|b| b.as_array()) else {
// Container-port not currently published — not our case.
continue;
};
if bindings.is_empty() {
continue;
}
let expected = port.host.to_string();
let matches_expected = bindings.iter().any(|b| {
b.get("HostPort")
.and_then(|h| h.as_str())
.map(|h| h == expected)
.unwrap_or(false)
});
if !matches_expected {
return true;
}
}
false
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if podman_socket_accepts_connections(socket_path).await {
@@ -725,6 +950,8 @@ pub struct ProdContainerOrchestrator {
use_quadlet_backends: bool,
#[cfg(test)]
test_disk_gb: Option<u64>,
#[cfg(test)]
test_bitcoin_host: Option<String>,
}
struct FileSecretsProvider {
@@ -734,8 +961,19 @@ struct FileSecretsProvider {
impl SecretsProvider for FileSecretsProvider {
fn read(&self, name: &str) -> std::result::Result<String, ManifestError> {
let path = self.root.join(name);
let data = std::fs::read_to_string(&path).map_err(ManifestError::Io)?;
Ok(data.trim().to_string())
match std::fs::read_to_string(&path) {
Ok(data) => Ok(data.trim().to_string()),
// Name the missing secret explicitly so the failure is actionable
// instead of a bare "IO error: No such file or directory" that hides
// which secret (and which app) is blocked.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Err(ManifestError::Invalid(format!(
"required secret '{name}' is missing (expected at {}); the app cannot start until it is generated",
path.display()
)))
}
Err(e) => Err(ManifestError::Io(e)),
}
}
}
@@ -774,6 +1012,8 @@ impl ProdContainerOrchestrator {
use_quadlet_backends: config.use_quadlet_backends,
#[cfg(test)]
test_disk_gb: None,
#[cfg(test)]
test_bitcoin_host: None,
})
}
@@ -792,6 +1032,7 @@ impl ProdContainerOrchestrator {
secrets_dir: PathBuf::from("/var/lib/archipelago/secrets"),
use_quadlet_backends: false,
test_disk_gb: None,
test_bitcoin_host: None,
}
}
@@ -1030,6 +1271,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
}
@@ -1054,6 +1319,34 @@ impl ProdContainerOrchestrator {
let lock = self.app_lock(&app_id).await;
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);
@@ -1094,6 +1387,22 @@ impl ProdContainerOrchestrator {
self.run_post_start_hooks(&app_id).await?;
return Ok(ReconcileAction::Started);
}
// Published-port drift means the container exists but maps
// its ports to the wrong host ports (e.g. lnd REST stuck on
// host 8080 while the manifest/clients expect 18080, as seen
// on .116). The container is already non-functional, so
// recreate it even for restart-sensitive apps during boot —
// leaving it "untouched" would perpetuate the breakage.
if self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "container published-port drift detected — recreating");
let _ = self.runtime.stop_container(&name).await;
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
}
if self.container_env_drifted(&name, &resolved_manifest).await {
if mode == ReconcileMode::ExistingOnly
&& is_restart_sensitive_app(&app_id)
@@ -1154,8 +1463,12 @@ impl ProdContainerOrchestrator {
// reading it. A Rewritten outcome is fine here — we're
// about to start from a stopped state anyway.
self.prepare_for_start(&resolved_manifest).await?;
if self.container_env_drifted(&name, &resolved_manifest).await {
tracing::info!(app_id = %app_id, container = %name, "stopped container env drift detected — recreating");
if self.container_env_drifted(&name, &resolved_manifest).await
|| self
.container_ports_drifted(&name, &resolved_manifest)
.await
{
tracing::info!(app_id = %app_id, container = %name, "stopped container env/port drift detected — recreating");
let _ = self.runtime.remove_container(&name).await;
self.install_fresh(lm).await?;
return Ok(ReconcileAction::Installed);
@@ -1297,10 +1610,33 @@ impl ProdContainerOrchestrator {
/// Build-or-pull, create, start. Assumes the per-app mutex is already held.
async fn install_fresh(&self, lm: &LoadedManifest) -> Result<()> {
self.ensure_app_secrets(&lm.manifest.app.id).await?;
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
let resolved = lm.manifest.app.container.resolve().ok_or_else(|| {
// Decouple the app image from the shipped manifest: prefer the remote
// app catalog when it covers this app with a same-repo image. This makes
// both the pull below and create_container() below use the catalog tag,
// so an app update no longer requires a binary/runtime release. Falls
// back to the manifest image when the catalog is absent/uncovered.
if let Some(current) = resolved_manifest.app.container.image.clone() {
if let Some(catalog_image) = crate::container::app_catalog::catalog_image_override(
&resolved_manifest.app.id,
&current,
) {
if catalog_image != current {
tracing::info!(
app_id = %resolved_manifest.app.id,
from = %current,
to = %catalog_image,
"app-catalog: overriding manifest image"
);
resolved_manifest.app.container.image = Some(catalog_image);
}
}
}
let resolved = resolved_manifest.app.container.resolve().ok_or_else(|| {
anyhow::anyhow!(
"manifest for {} has invalid container source (neither image nor build)",
lm.manifest.app.id
@@ -1328,7 +1664,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!(
@@ -1339,11 +1675,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"
);
}
}
}
}
}
@@ -2234,9 +2610,45 @@ impl ProdContainerOrchestrator {
host_ip,
host_mdns,
disk_gb,
// Cheap default; resolve_dynamic_env fills the real node name on
// demand (it costs a podman call) only for manifests that use
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
bitcoin_host: "bitcoin-knots".to_string(),
}
}
/// Container name of the running Bitcoin node (`bitcoin-knots` or
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
/// Synchronous `podman ps` to match the surrounding host-fact detection;
/// defaults to `bitcoin-knots` when none is running (B12).
fn bitcoin_host(&self) -> String {
#[cfg(test)]
if let Some(host) = &self.test_bitcoin_host {
return host.clone();
}
// Mirrors api::rpc::package::dependencies (the legacy install path);
// both Bitcoin node variants are reachable on archy-net by name.
const BITCOIN_NAMES: &[&str] = &["bitcoin-knots", "bitcoin-core", "bitcoin"];
let names = Command::new("podman")
.args(["ps", "--format", "{{.Names}}"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default();
names
.lines()
.map(|l| l.trim())
.find(|name| BITCOIN_NAMES.contains(name))
.map(|name| name.to_string())
.unwrap_or_else(|| "bitcoin-knots".to_string())
}
#[cfg(test)]
pub fn set_bitcoin_host_for_test(&mut self, host: &str) {
self.test_bitcoin_host = Some(host.to_string());
}
fn detect_host_ip() -> Option<String> {
let output = Command::new("hostname").arg("-I").output().ok()?;
if !output.status.success() {
@@ -2300,8 +2712,38 @@ impl ProdContainerOrchestrator {
Self::detect_disk_gb()
}
/// Ensure app-specific secrets exist *before* env resolution. The Bitcoin
/// backends reference `bitcoin-rpc-txrelay-rpcauth` as a required
/// `secret_env`; it is normally created by the tx-relay flow, so nodes that
/// never used tx-relay lack it and `resolve_secret_env` hard-fails — taking
/// bitcoind (and everything that depends on it) down. Generating it here,
/// idempotently, lets reconcile/install self-heal that state (the .198 case)
/// instead of cascading. Must be called before every `resolve_dynamic_env`.
async fn ensure_app_secrets(&self, app_id: &str) -> Result<()> {
if cfg!(test) {
return Ok(());
}
if matches!(app_id, "bitcoin-knots" | "bitcoin-core" | "bitcoin") {
crate::api::rpc::bitcoin_relay::ensure_txrelay_credentials(&self.data_dir)
.await
.context("ensuring bitcoin tx-relay credentials")?;
}
Ok(())
}
fn resolve_dynamic_env(&self, manifest: &mut AppManifest) -> Result<()> {
let facts = self.detect_host_facts();
let mut facts = self.detect_host_facts();
// Only pay the podman cost to detect Knots-vs-Core when this manifest
// actually templates the Bitcoin node into its env (mempool — B12).
if manifest
.app
.container
.derived_env
.iter()
.any(|e| e.template.contains("{{BITCOIN_HOST}}"))
{
facts.bitcoin_host = self.bitcoin_host();
}
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
@@ -2443,6 +2885,42 @@ impl ProdContainerOrchestrator {
false
}
/// True when a *running* container publishes a manifest container-port to a
/// different host port than the manifest now asks for (published-port
/// drift). This catches the class of failure seen on .116, where `lnd` was
/// created mapping host 8080 -> container 8080 but the current manifest maps
/// host 18080 -> container 8080, so every in-process REST client (which
/// connects to the manifest port) gets connection-refused forever while the
/// container looks "Up". `container_env_drifted` never inspects ports, and
/// `wait_for_manifest_host_ports` only restarts the stale container (which
/// republishes the wrong mapping), so without this check the drift is
/// self-perpetuating.
async fn container_ports_drifted(&self, name: &str, manifest: &AppManifest) -> bool {
if cfg!(test) {
return false;
}
if manifest.app.ports.is_empty() {
return false;
}
let inspect = tokio::process::Command::new("podman")
.args([
"inspect",
name,
"--format",
"{{json .HostConfig.PortBindings}}",
])
.output()
.await;
let Ok(output) = inspect else {
return false;
};
if !output.status.success() {
return false;
}
let bindings = String::from_utf8_lossy(&output.stdout);
host_port_bindings_drifted(&bindings, &manifest.app.ports)
}
async fn apply_data_uid(&self, manifest: &AppManifest) -> Result<()> {
let Some(uid_gid) = manifest.app.container.data_uid.as_ref() else {
return Ok(());
@@ -2752,6 +3230,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
self.ensure_app_secrets(app_id).await?;
let name = compute_container_name(&lm.manifest);
let mut resolved_manifest = lm.manifest.clone();
self.resolve_dynamic_env(&mut resolved_manifest)?;
@@ -2913,6 +3392,61 @@ mod tests {
use async_trait::async_trait;
use std::sync::Mutex as StdMutex;
fn port(host: u16, container: u16) -> archipelago_container::manifest::PortMapping {
archipelago_container::manifest::PortMapping {
host,
container,
protocol: "tcp".to_string(),
}
}
#[test]
fn port_drift_detected_when_host_port_differs() {
// The .116 case: container publishes container-port 8080 on host 8080,
// but the manifest now asks for host 18080.
let bindings = r#"{"8080/tcp":[{"HostIp":"","HostPort":"8080"}]}"#;
assert!(host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_host_port_matches() {
let bindings = r#"{"8080/tcp":[{"HostIp":"0.0.0.0","HostPort":"18080"}]}"#;
assert!(!host_port_bindings_drifted(bindings, &[port(18080, 8080)]));
}
#[test]
fn no_drift_when_binding_absent() {
// Absence is handled elsewhere (host-port repair / host-networked apps);
// never treat it as drift to avoid a destructive recreate on a false
// positive.
assert!(!host_port_bindings_drifted("{}", &[port(18080, 8080)]));
assert!(!host_port_bindings_drifted("null", &[port(18080, 8080)]));
}
#[test]
fn no_drift_on_unparseable_bindings() {
assert!(!host_port_bindings_drifted(
"not json",
&[port(18080, 8080)]
));
}
#[test]
fn missing_secret_error_names_the_secret() {
use archipelago_container::manifest::SecretsProvider;
let provider = FileSecretsProvider {
root: PathBuf::from("/nonexistent-secrets-dir-xyz"),
};
let err = provider
.read("bitcoin-rpc-txrelay-rpcauth")
.expect_err("missing secret must error");
let msg = err.to_string();
assert!(
msg.contains("bitcoin-rpc-txrelay-rpcauth"),
"error should name the missing secret, got: {msg}"
);
}
/// Instrumented in-memory runtime. Every call is recorded so tests can assert
/// the exact sequence of side effects.
#[derive(Default)]
@@ -3298,6 +3832,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());
@@ -4062,4 +4625,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));
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ mod types;
pub use invites::{accept_invite, create_invite};
#[allow(unused_imports)]
pub use storage::{
add_node, fips_npub_for_onion, load_nodes, record_peer_transport, remove_node, save_nodes,
set_trust_level, update_node,
add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport,
remove_node, save_nodes, set_trust_level, update_node,
};
pub use sync::{build_local_state, deploy_to_peer, sync_with_peer, sync_with_peer_by_did};
pub use types::{AppStatus, FederatedNode, NodeStateSnapshot, TrustLevel};
+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();
+31
View File
@@ -118,6 +118,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 +133,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 +145,27 @@ async fn merge_transitive_peers(
}
continue;
}
// Same physical node advertised under a DIFFERENT did? Match on the
// onion (its stable network identity). Without this, a node that
// appears under two dids (e.g. after a key/did change) gets added
// twice — showing up duplicated in the trusted-node list (B1) and as
// two separate mesh chat contacts (B2). Merge into the existing entry.
let hint_onion = hint.onion.trim_end_matches(".onion");
if !hint_onion.is_empty() {
if let Some(existing) = nodes
.iter_mut()
.find(|n| n.onion.trim_end_matches(".onion") == hint_onion)
{
if existing.fips_npub.is_none() && hint.fips_npub.is_some() {
existing.fips_npub = hint.fips_npub.clone();
}
if existing.name.is_none() && hint.name.is_some() {
existing.name = hint.name.clone();
}
refreshed += 1;
continue;
}
}
nodes.push(FederatedNode {
did: hint.did.clone(),
pubkey: hint.pubkey.clone(),
+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]
+112 -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>> {
@@ -294,13 +359,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 +386,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 +423,19 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.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 +456,19 @@ impl<'a> PeerRequest<'a> {
}
};
let url = format!("{}{}", base, self.path);
let c = client();
let c = client_with_timeout(self.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,368 @@
//! 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::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 },
}
/// 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.
pub(super) async fn run_assist(
prompt: String,
model_override: Option<String>,
req_id: u64,
asker_contact_id: u32,
sender_name: String,
reply: AssistReply,
state: Arc<MeshState>,
) {
let asker = asker_contact_id;
// Trust + block gate.
if !is_sender_allowed(&state, asker).await {
warn!(
from = asker,
name = %sender_name,
"AssistQuery denied — sender not permitted by assistant policy"
);
// 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. With `trusted_only`, requires a
/// federation-Trusted match on the peer's pubkey or DID.
async fn is_sender_allowed(state: &Arc<MeshState>, sender_contact_id: u32) -> bool {
let (pubkey_hex, did) = {
let peers = state.peers.read().await;
match peers.get(&sender_contact_id) {
Some(p) => (p.pubkey_hex.clone(), 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;
}
}
if !state.assistant.read().await.trusted_only {
return true;
}
// Trusted-only: match against the federation trust list.
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())
})
}
/// 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;
}
}
}
/// 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;
}
}
}
/// 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),
}
}
/// 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,45 @@ 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>` on the
// channel is answered by this node's local model when the assistant is on.
// Reply goes back as plain channel text so bare (non-archipelago) clients
// see it. The trust/rate gate lives in run_assist.
if state.assistant.read().await.enabled {
if let Some(prompt) = strip_ai_trigger(text) {
if !prompt.is_empty() {
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 {
super::assist::run_assist(
prompt,
None,
req_id,
contact_id,
name,
super::assist::AssistReply::ChannelText { channel: 0 },
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.
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.
@@ -681,6 +681,80 @@ pub(crate) async fn handle_typed_envelope_direct(
.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,
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),
}
}
_ => {
debug!(
msg_type = ?msg_type,
@@ -697,6 +771,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)) => {
+40 -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,7 +18,6 @@ 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!(
@@ -109,7 +108,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 +121,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 +147,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 +332,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);
+34
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;
@@ -102,6 +103,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 +124,30 @@ 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>>,
}
/// 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,
}
/// Contact metadata kept alongside MeshState.peers. Pinned contacts sort to
@@ -151,8 +178,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 +217,15 @@ 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()),
});
(state, rx, cmd_rx)
}
+23 -2
View File
@@ -363,9 +363,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 +424,22 @@ pub(super) async fn run_mesh_session(
warn!("Failed to send initial advert: {}", e);
}
// Archipelago identity advert (`ARCHY:2:{ed}:{x25519}`): broadcast as channel
// text so peers can bind our radio presence to our DID + keys. The firmware
// advert alone carries the meshcore key (and nothing on Meshtastic), so this
// is what makes trust-gating + encrypted DMs work across BOTH transports.
let identity_advert = super::super::protocol::encode_identity_broadcast(
our_did,
our_ed_pubkey_hex,
our_x25519_pubkey_hex,
);
if let Err(e) = device
.send_channel_text(0, identity_advert.as_bytes())
.await
{
warn!("Failed to broadcast archipelago identity: {}", e);
}
// Fetch existing contacts from the device
refresh_contacts(&mut device, state).await;
@@ -491,6 +507,11 @@ pub(super) async fn run_mesh_session(
} else {
consecutive_write_failures = 0;
}
// Re-broadcast archipelago identity so peers that joined since
// startup (or missed it) can bind our DID/keys.
if let Err(e) = device.send_channel_text(0, identity_advert.as_bytes()).await {
warn!("Failed to re-broadcast archipelago identity: {}", e);
}
refresh_contacts(&mut device, state).await;
}
@@ -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 {
+245 -6
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
@@ -87,6 +89,38 @@ pub(crate) async fn upsert_federation_peer(
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 +133,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 +170,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 +181,26 @@ 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,
}
fn default_assistant_backend() -> String {
"claude".to_string()
}
fn default_true() -> bool {
@@ -149,8 +217,13 @@ 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(),
}
}
}
@@ -200,6 +273,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 +352,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 +366,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 +393,16 @@ 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(),
},
data_dir.to_path_buf(),
);
// Derive X25519 keys from Ed25519 identity
@@ -294,6 +438,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 +459,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 +470,7 @@ impl MeshService {
block_header_cache,
relay_tracker,
dead_man_switch,
scheduler: Arc::new(scheduler::MeshScheduler::load(data_dir).await),
})
}
@@ -390,6 +548,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);
@@ -540,6 +712,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;
@@ -742,15 +918,33 @@ impl MeshService {
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) = {
// 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;
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.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
@@ -1193,6 +1387,51 @@ impl MeshService {
Ok(())
}
/// Current mesh-AI assistant settings (issue #50).
pub async fn assistant_config(&self) -> listener::AssistantConfig {
self.state.assistant.read().await.clone()
}
/// 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>,
) -> 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;
}
}
// 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();
}
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?;
+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
}
+11
View File
@@ -161,4 +161,15 @@ 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>,
},
}
+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"
);
}
}
+90
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,6 +266,39 @@ 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
@@ -508,6 +566,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 +617,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 +832,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 +1318,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 +1449,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()));
}
}
+263
View File
@@ -0,0 +1,263 @@
//! iroh-blobs swarm provider — the DHT Phase 2 engine, gated behind the
//! `iroh-swarm` feature (heavy QUIC dep tree, off by default).
//!
//! Stands up a real iroh node: binds a QUIC [`Endpoint`], opens a persistent
//! blob [`FsStore`] under `data_dir/iroh-blobs`, and serves blobs over the
//! iroh-blobs protocol — so a node that *fetches* content also *seeds* it
//! afterwards. Content is addressed by BLAKE3 ([`Hash`]) and range-verified by
//! iroh on arrival.
//!
//! This provider is an optimization beneath the origin HTTP path: the [`super`]
//! swarm seam falls back to origin whenever [`try_fetch`](IrohProvider::try_fetch)
//! returns `Ok(false)` (no known seeds) or `Err` (transient swarm failure).
//!
//! ## Discovery boundary (Phase 3)
//! Downloading needs the [`EndpointId`]s of peers that hold the hash. That
//! discovery — design Phase 3, *signed Nostr advertisement events* mapping
//! `{content-hash → provider endpoint}` — is injected via [`ProviderDiscovery`].
//! Until it is wired, discovery yields nothing and every fetch defers to origin,
//! so enabling the feature is safe (never worse than today).
use std::path::Path;
use std::str::FromStr;
use std::sync::Arc;
use anyhow::Result;
use async_trait::async_trait;
use iroh::{endpoint::presets, protocol::Router, Endpoint, EndpointId};
use iroh_blobs::{store::fs::FsStore, BlobsProtocol, Hash};
use super::payment::PaymentPolicy;
use super::BlobProvider;
use crate::content_hash::{ContentDigest, HashAlg};
/// Resolves which peers are believed to hold a given content hash.
///
/// Phase 3 (signed Nostr advertisement events) provides the production impl
/// [`NostrSeedDiscovery`]; `None` discovery means "origin-only" — a safe
/// default. The query is async (it hits relays), so the trait is async.
#[async_trait]
pub trait ProviderDiscovery: Send + Sync {
/// Candidate seed endpoints for `hash` (may be empty).
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId>;
}
/// Production [`ProviderDiscovery`]: reads signed seed advertisements from Nostr
/// relays and parses the advertised endpoint-id strings into [`EndpointId`]s.
///
/// Unparseable ids are skipped (an advert from an incompatible/garbage peer must
/// not abort discovery). Reuses the node's existing relay list + Tor proxy.
pub struct NostrSeedDiscovery {
relays: Vec<String>,
tor_proxy: Option<String>,
}
impl NostrSeedDiscovery {
pub fn new(relays: Vec<String>, tor_proxy: Option<String>) -> Self {
Self { relays, tor_proxy }
}
}
#[async_trait]
impl ProviderDiscovery for NostrSeedDiscovery {
async fn providers_for(&self, hash: &Hash) -> Vec<EndpointId> {
let hex = hash.to_hex();
let ids = super::seed_advert::fetch_seed_endpoint_ids(
&self.relays,
self.tor_proxy.as_deref(),
&hex,
)
.await;
ids.into_iter()
.filter_map(|s| match EndpointId::from_str(&s) {
Ok(id) => Some(id),
Err(e) => {
tracing::debug!("swarm: skipping unparseable seed endpoint id {s}: {e}");
None
}
})
.collect()
}
}
/// Fetches content-addressed blobs from the iroh swarm, and seeds what it has.
#[allow(dead_code)] // constructed once Phase 3 discovery is wired into providers()
pub struct IrohProvider {
endpoint: Endpoint,
store: FsStore,
/// Kept alive so the node keeps accepting blob-protocol connections (seeds).
_router: Router,
discovery: Option<Arc<dyn ProviderDiscovery>>,
/// Where pricing/session/wallet state lives — for paid-fetch negotiation.
data_dir: std::path::PathBuf,
/// Willingness to pay swarm peers when fetching. Defaults to
/// [`PaymentPolicy::free`]: never pay (releases/catalog stay free), so a
/// seeder that prices a blob is skipped → origin. A future film fetch can
/// pass a real budget.
pay_policy: PaymentPolicy,
}
#[allow(dead_code)]
impl IrohProvider {
/// Bind an iroh endpoint, open the persistent blob store at
/// `data_dir/iroh-blobs`, and start serving blobs (seed capability).
pub async fn new(
data_dir: &Path,
discovery: Option<Arc<dyn ProviderDiscovery>>,
) -> Result<Self> {
let root = data_dir.join("iroh-blobs");
tokio::fs::create_dir_all(&root).await.ok();
let store = FsStore::load(&root)
.await
.map_err(|e| anyhow::anyhow!("open iroh blob store: {e}"))?;
let endpoint = Endpoint::bind(presets::N0)
.await
.map_err(|e| anyhow::anyhow!("bind iroh endpoint: {e}"))?;
// Serve blobs: a node that fetches a blob can then seed it to others.
// The event sender gates each request through the ecash `streaming` layer
// — free by default, paid only if the operator priced `content-download`
// (Networking Profits → Settings). It also hard-disables peer writes.
let event_sender =
super::paid::gated_event_sender(data_dir.to_path_buf(), (*store).clone());
let blobs = BlobsProtocol::new(&store, Some(event_sender));
// Shape-A paid negotiation rides a second ALPN on the same endpoint so a
// downloader can pay (open a session) before the blob-GET above serves it.
let paid =
super::paid_alpn::PaidBlobsProtocol::new(data_dir.to_path_buf(), (*store).clone());
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(super::paid_alpn::PAID_ALPN, paid)
.spawn();
Ok(Self {
endpoint,
store,
_router: router,
discovery,
data_dir: data_dir.to_path_buf(),
pay_policy: PaymentPolicy::free(),
})
}
/// This node's iroh endpoint id — what Phase 3 advertises as a seed address.
pub fn endpoint_id(&self) -> EndpointId {
self.endpoint.id()
}
/// Import a held PUBLIC blob into the seed store and advertise it on Nostr so
/// other nodes can fetch it from us. Call this only for releases/catalog
/// content (the design's privacy scope) — never private user blobs.
///
/// Importing makes us an actual seed: a node that downloaded a release from
/// the HTTP origin can now serve it to peers over iroh-blobs. The advert maps
/// `blake3_hex → this endpoint id`. Defensive check: the bytes we import must
/// hash to what we advertise, so a path/hash mismatch can never publish a lie.
pub async fn seed_and_advertise(
&self,
path: &Path,
blake3_hex: &str,
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<()> {
let expected = {
let raw = hex::decode(blake3_hex).map_err(|e| anyhow::anyhow!("blake3 hex: {e}"))?;
let arr: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
Hash::from_bytes(arr)
};
let info = self
.store
.blobs()
.add_path(path)
.await
.map_err(|e| anyhow::anyhow!("import blob into seed store: {e}"))?;
if info.hash != expected {
anyhow::bail!(
"imported blob hash {} != advertised {}",
info.hash.to_hex(),
blake3_hex
);
}
super::seed_advert::publish_seed_advert(
identity_dir,
relays,
tor_proxy,
blake3_hex,
&self.endpoint_id().to_string(),
)
.await
}
}
#[async_trait]
impl BlobProvider for IrohProvider {
fn name(&self) -> &str {
"iroh"
}
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool> {
// iroh addresses content by BLAKE3. A sha256-only digest isn't fetchable
// from the swarm — defer to origin.
if digest.alg != HashAlg::Blake3 {
return Ok(false);
}
let raw = hex::decode(&digest.hex).map_err(|e| anyhow::anyhow!("digest hex: {e}"))?;
let arr: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow::anyhow!("blake3 digest must be 32 bytes"))?;
let hash = Hash::from_bytes(arr);
// Who has it? Without discovery (Phase 3) this is empty → origin wins.
let providers = match &self.discovery {
Some(d) => d.providers_for(&hash).await,
None => Vec::new(),
};
if providers.is_empty() {
return Ok(false);
}
// Shape-A: negotiate paid access with each candidate. Best-effort and
// additive — a peer is dropped only if it explicitly requires a payment
// we won't make under `pay_policy` (free by default → priced seeders are
// skipped). Connect/protocol failures keep the peer; the blob-GET gate is
// the real enforcement and a refused GET still falls back to origin.
let mut allowed = Vec::with_capacity(providers.len());
for peer in providers {
if super::paid_alpn::negotiate_access(
&self.endpoint,
&self.data_dir,
peer,
&digest.hex,
&self.pay_policy,
)
.await
{
allowed.push(peer);
}
}
if allowed.is_empty() {
return Ok(false);
}
// Fetch (range-verified by iroh) then export the verified blob to the
// staging path the caller expects. The seam re-verifies the digest.
let downloader = self.store.downloader(&self.endpoint);
downloader
.download(hash, allowed)
.await
.map_err(|e| anyhow::anyhow!("iroh swarm download: {e}"))?;
self.store
.blobs()
.export(hash, dest)
.await
.map_err(|e| anyhow::anyhow!("export blob to staging: {e}"))?;
Ok(true)
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Swarm-assist content fetch — the *transport & swarm* tier of the DHT
//! distribution plan (`docs/dht-distribution-design.md` §4).
//!
//! ## Guiding principle: swarm-assist, origin ALWAYS wins
//! The peer swarm is an optimization layered *above* a proven HTTP path, never
//! in place of it. A node asks each available [`BlobProvider`] (e.g. an
//! iroh-blobs swarm) for content by its [`ContentDigest`]; the first peer that
//! serves bytes which **verify** against the digest wins. If no provider has it
//! — or the swarm is disabled, or every peer is offline — we fall back to the
//! origin HTTP download, which is the guaranteed source of truth. Worst case is
//! exactly today's behaviour.
//!
//! Peer-sourced bytes are UNTRUSTED, so this module verifies them against the
//! content digest before accepting. Origin bytes run through the caller's
//! existing verification (e.g. the SHA-256 gate in `update.rs`).
//!
//! The actual iroh-blobs provider is gated behind the `iroh-swarm` feature
//! (heavy QUIC dep tree); with the feature off, [`providers`] is empty and
//! every fetch goes straight to origin — byte-for-byte today's path.
use std::path::Path;
use std::sync::{Arc, OnceLock};
use anyhow::Result;
use async_trait::async_trait;
use tracing::{debug, info, warn};
use crate::content_hash::ContentDigest;
pub mod payment;
pub mod seed_advert;
#[cfg(feature = "iroh-swarm")]
pub mod iroh_provider;
#[cfg(feature = "iroh-swarm")]
pub mod paid;
#[cfg(feature = "iroh-swarm")]
pub mod paid_alpn;
/// Which source ultimately served the content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FetchSource {
/// A peer in the swarm served (and the bytes verified).
Swarm,
/// The origin HTTP fallback served.
Origin,
}
/// A source that may be able to serve content addressed by its digest.
#[async_trait]
pub trait BlobProvider: Send + Sync {
/// Short name for logging (e.g. "iroh").
fn name(&self) -> &str;
/// Try to fetch the content for `digest` into `dest`.
///
/// * `Ok(true)` — bytes written to `dest` (caller verifies the digest).
/// * `Ok(false)` — this provider does not have the content; try the next.
/// * `Err(_)` — a transient failure; try the next provider.
async fn try_fetch(&self, digest: &ContentDigest, dest: &Path) -> Result<bool>;
}
/// Process-wide swarm runtime, built once at startup by [`init`]. Holding the
/// providers here (rather than rebuilding per download) keeps the iroh endpoint
/// + blob store + protocol router alive for the life of the process, so a node
/// keeps *seeding* between downloads. Empty/inert unless the `iroh-swarm`
/// feature is built AND `swarm_enabled` is set.
struct SwarmRuntime {
providers: Vec<Arc<dyn BlobProvider>>,
/// Context for announcing held public blobs; `None` when seeding is off.
#[cfg(feature = "iroh-swarm")]
announce: Option<AnnounceCtx>,
}
#[cfg(feature = "iroh-swarm")]
struct AnnounceCtx {
iroh: Arc<iroh_provider::IrohProvider>,
relays: Vec<String>,
tor_proxy: Option<String>,
identity_dir: std::path::PathBuf,
}
static RUNTIME: OnceLock<SwarmRuntime> = OnceLock::new();
/// Build the swarm runtime once, at startup. Idempotent: a second call is a
/// no-op (the first registration wins). Safe to call unconditionally — when the
/// `iroh-swarm` feature is absent, or `enabled` is false, it registers an empty
/// runtime so every fetch goes straight to origin (today's path).
///
/// `relays` / `tor_proxy` come from the node's Nostr config and double as the
/// seed-advert transport; `data_dir` hosts the persistent iroh blob store under
/// `data_dir/iroh-blobs` and the node identity under `data_dir/identity`.
pub async fn init(
data_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
enabled: bool,
) -> Result<()> {
if RUNTIME.get().is_some() {
return Ok(());
}
#[cfg(not(feature = "iroh-swarm"))]
{
let _ = (data_dir, relays, tor_proxy);
if enabled {
warn!("swarm: swarm_enabled set but binary built without the `iroh-swarm` feature — staying origin-only");
}
let _ = RUNTIME.set(SwarmRuntime {
providers: Vec::new(),
});
return Ok(());
}
#[cfg(feature = "iroh-swarm")]
{
if !enabled {
info!("swarm: disabled (swarm_enabled=false) — origin-only");
let _ = RUNTIME.set(SwarmRuntime {
providers: Vec::new(),
announce: None,
});
return Ok(());
}
let discovery: Arc<dyn iroh_provider::ProviderDiscovery> = Arc::new(
iroh_provider::NostrSeedDiscovery::new(relays.to_vec(), tor_proxy.map(str::to_string)),
);
let provider = Arc::new(iroh_provider::IrohProvider::new(data_dir, Some(discovery)).await?);
info!(
"swarm: iroh provider active (endpoint {}) — swarm-assist enabled, origin always wins",
provider.endpoint_id()
);
let providers: Vec<Arc<dyn BlobProvider>> = vec![provider.clone()];
let _ = RUNTIME.set(SwarmRuntime {
providers,
announce: Some(AnnounceCtx {
iroh: provider,
relays: relays.to_vec(),
tor_proxy: tor_proxy.map(str::to_string),
identity_dir: data_dir.join("identity"),
}),
});
Ok(())
}
}
/// The ordered list of swarm providers to consult before the origin.
///
/// Empty until [`init`] registers a provider (needs the `iroh-swarm` feature +
/// `swarm_enabled`). While empty, [`fetch_content_addressed`] goes straight to
/// origin — byte-for-byte today's path.
pub fn providers() -> Vec<Arc<dyn BlobProvider>> {
RUNTIME
.get()
.map(|r| r.providers.clone())
.unwrap_or_default()
}
/// Announce that this node now holds a PUBLIC release/catalog blob (addressed by
/// `blake3_hex`, bytes at `path`) so peers can fetch it from us: import it into
/// the seed store and publish a signed Nostr advert. Best-effort and inert
/// unless the iroh provider is active — a failure never affects the install.
///
/// **Scope:** call only for releases/catalog content, never private user blobs.
pub async fn announce_held_blob(_blake3_hex: &str, _path: &Path) {
#[cfg(feature = "iroh-swarm")]
{
let Some(rt) = RUNTIME.get() else { return };
let Some(ctx) = rt.announce.as_ref() else {
return;
};
if let Err(e) = ctx
.iroh
.seed_and_advertise(
_path,
_blake3_hex,
&ctx.identity_dir,
&ctx.relays,
ctx.tor_proxy.as_deref(),
)
.await
{
warn!("swarm: failed to announce held blob {_blake3_hex}: {e}");
}
}
}
/// Fetch content-addressed bytes: swarm-assist, origin always wins.
///
/// Tries each provider in order; the first to write bytes that VERIFY against
/// `digest` wins and returns [`FetchSource::Swarm`]. If none succeed, runs
/// `origin` (the guaranteed HTTP fallback) and returns [`FetchSource::Origin`].
/// A node that obtained bytes from the swarm has, by definition, a verified
/// copy it can itself seed afterwards.
pub async fn fetch_content_addressed<F, Fut>(
digest: &ContentDigest,
providers: &[Arc<dyn BlobProvider>],
dest: &Path,
origin: F,
) -> Result<FetchSource>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<()>>,
{
for provider in providers {
match provider.try_fetch(digest, dest).await {
Ok(true) => match verify_dest(digest, dest).await {
Ok(()) => {
info!("swarm: {} served {} (verified)", provider.name(), digest);
return Ok(FetchSource::Swarm);
}
Err(e) => {
// A peer served bytes that don't match the digest — could be
// corruption or a malicious seed. Discard and try the next
// source; never let unverified peer bytes through.
warn!(
"swarm: {} served bytes failing verification for {}: {} — discarding",
provider.name(),
digest,
e
);
let _ = tokio::fs::remove_file(dest).await;
}
},
Ok(false) => debug!("swarm: {} does not have {}", provider.name(), digest),
Err(e) => debug!("swarm: {} failed for {}: {}", provider.name(), digest, e),
}
}
debug!(
"swarm: no provider served {} — falling back to origin",
digest
);
origin().await?;
Ok(FetchSource::Origin)
}
/// Read `dest` and verify it hashes to `digest`.
async fn verify_dest(digest: &ContentDigest, dest: &Path) -> Result<()> {
let bytes = tokio::fs::read(dest).await?;
digest.verify(&bytes)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
fn digest_of(bytes: &[u8]) -> ContentDigest {
ContentDigest::parse(&format!(
"blake3:{}",
crate::content_hash::blake3_hex(bytes)
))
.unwrap()
}
/// Provider that writes a fixed payload (which may or may not match).
struct FixedProvider {
name: &'static str,
payload: Option<Vec<u8>>,
}
#[async_trait]
impl BlobProvider for FixedProvider {
fn name(&self) -> &str {
self.name
}
async fn try_fetch(&self, _d: &ContentDigest, dest: &Path) -> Result<bool> {
match &self.payload {
Some(p) => {
tokio::fs::write(dest, p).await?;
Ok(true)
}
None => Ok(false),
}
}
}
fn arc(p: FixedProvider) -> Arc<dyn BlobProvider> {
Arc::new(p)
}
#[tokio::test]
async fn swarm_hit_verifies_and_skips_origin() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"hello swarm".to_vec();
let digest = digest_of(&content);
let providers = vec![arc(FixedProvider {
name: "good",
payload: Some(content.clone()),
})];
let origin_ran = AtomicBool::new(false);
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
origin_ran.store(true, Ordering::SeqCst);
tokio::fs::write(&dest, b"from-origin").await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Swarm);
assert!(
!origin_ran.load(Ordering::SeqCst),
"origin must not run on swarm hit"
);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
#[tokio::test]
async fn bad_swarm_bytes_are_discarded_and_origin_wins() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"the real bytes".to_vec();
let digest = digest_of(&content);
// Provider claims a hit but serves tampered bytes.
let providers = vec![arc(FixedProvider {
name: "evil",
payload: Some(b"TAMPERED".to_vec()),
})];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, &content).await?;
Ok(())
})
.await
.unwrap();
assert_eq!(
src,
FetchSource::Origin,
"tampered swarm bytes must not be accepted"
);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
#[tokio::test]
async fn no_providers_goes_straight_to_origin() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"x".to_vec();
let digest = digest_of(&content);
let providers: Vec<Arc<dyn BlobProvider>> = vec![];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, &content).await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Origin);
}
#[tokio::test]
async fn falls_through_providers_in_order() {
let dir = tempfile::tempdir().unwrap();
let dest = dir.path().join("out");
let content = b"second wins".to_vec();
let digest = digest_of(&content);
let providers = vec![
arc(FixedProvider {
name: "miss",
payload: None,
}),
arc(FixedProvider {
name: "hit",
payload: Some(content.clone()),
}),
];
let src = fetch_content_addressed(&digest, &providers, &dest, || async {
tokio::fs::write(&dest, b"origin").await?;
Ok(())
})
.await
.unwrap();
assert_eq!(src, FetchSource::Swarm);
assert_eq!(tokio::fs::read(&dest).await.unwrap(), content);
}
}
+194
View File
@@ -0,0 +1,194 @@
//! Paid swarm serving — gate the iroh-blobs provider through the ecash
//! `streaming` payment layer (DHT distribution plan, Phase 4 step F).
//!
//! ## Free by default
//! Serving is **free unless the node operator turns it on** in
//! *Networking Profits → Settings* (which enables the `content-download`
//! streaming service). With that service disabled — the shipped default —
//! [`is_authorized`] returns `true` for everyone and behaviour is byte-for-byte
//! the old open seeder. When it is enabled, a peer must hold an active paid
//! session (opened out-of-band via the `streaming.pay` RPC with a Cashu token)
//! before the swarm will serve them; otherwise the request is refused and they
//! fall back to the HTTP origin.
//!
//! ## How it hooks in
//! iroh-blobs 0.103 lets a provider authorize each request: we pass an
//! [`EventSender`] (built here) to `BlobsProtocol::new`, set the [`EventMask`]
//! to intercept connections + GET requests, and answer each one with
//! `Ok(())` (serve) or `Err(AbortReason::Permission)` (refuse). Peer-initiated
//! writes (`push`) are hard-disabled so a peer can never mutate our store.
//!
//! Scope note: today every swarm blob is a public release/app component, so the
//! gate only ever charges if the operator explicitly priced `content-download`.
//! When IndeeHub films land on the same blob layer (Phase 4), they reuse this
//! exact path.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use iroh::EndpointId;
use iroh_blobs::api::blobs::BlobStatus;
use iroh_blobs::api::Store;
use iroh_blobs::provider::events::{
AbortReason, ConnectMode, EventMask, EventResult, EventSender, ObserveMode, ProviderMessage,
RequestMode, ThrottleMode,
};
use iroh_blobs::Hash;
use crate::streaming::gate::{self, GateResult};
/// The streaming pricing service that meters swarm blob serving. Enabling it in
/// the Settings UI is what flips swarm serving from free to paid.
const SERVICE_ID: &str = "content-download";
/// Build the gated [`EventSender`] for `BlobsProtocol` and spawn the task that
/// authorizes each blob GET through the ecash gate.
///
/// `data_dir` locates the pricing/session state; `store` is cloned in to look up
/// blob sizes for metering. The spawned task lives as long as the provider keeps
/// the returned sender alive (i.e. the life of the node).
pub fn gated_event_sender(data_dir: PathBuf, store: Store) -> EventSender {
// Intercept connections + read requests so we can allow/deny per peer & hash.
// `push` (peer writes into our store) is hard-disabled. `throttle`/`observe`
// stay off — we meter coarsely at request time, not per 16 KiB chunk.
let mask = EventMask {
connected: ConnectMode::Intercept,
get: RequestMode::Intercept,
get_many: RequestMode::Intercept,
push: RequestMode::Disabled,
observe: ObserveMode::None,
throttle: ThrottleMode::None,
};
let (sender, mut rx) = EventSender::channel(64, mask);
tokio::spawn(async move {
// connection_id → remote endpoint id, learned at ClientConnected and used
// to key the paying peer's streaming session on each request.
let mut peers: HashMap<u64, Option<EndpointId>> = HashMap::new();
while let Some(msg) = rx.recv().await {
match msg {
ProviderMessage::ClientConnected(m) => {
peers.insert(m.inner.connection_id, m.inner.endpoint_id);
// Accept the connection; gating happens per request.
let _ = m.tx.send(Ok(())).await;
}
ProviderMessage::ConnectionClosed(m) => {
peers.remove(&m.inner.connection_id);
}
ProviderMessage::GetRequestReceived(m) => {
let peer = peers.get(&m.inner.connection_id).copied().flatten();
let hash = m.inner.request.hash;
let verdict = authorize(&data_dir, &store, peer, &hash).await;
let _ = m.tx.send(verdict).await;
}
ProviderMessage::GetManyRequestReceived(m) => {
let peer = peers.get(&m.inner.connection_id).copied().flatten();
// A get-many is all-or-nothing here: authorize on the first hash.
let verdict = match m.inner.request.hashes.first().copied() {
Some(h) => authorize(&data_dir, &store, peer, &h).await,
None => Ok(()),
};
let _ = m.tx.send(verdict).await;
}
ProviderMessage::PushRequestReceived(m) => {
// Disabled in the mask; refuse defensively if one ever arrives.
let _ = m.tx.send(Err(AbortReason::Permission)).await;
}
// Notify-only variants, observe and throttle: nothing to gate.
_ => {}
}
}
});
sender
}
/// Authorize one blob GET, returning the iroh [`EventResult`]
/// (`Ok(())` = serve, `Err(Permission)` = refuse).
async fn authorize(
data_dir: &Path,
store: &Store,
peer: Option<EndpointId>,
hash: &Hash,
) -> EventResult {
// Cost = full blob size (coarse, request-time metering). If we don't hold the
// complete blob there's nothing to meter — let iroh serve what it can.
let size = match store.blobs().status(*hash).await {
Ok(BlobStatus::Complete { size }) => size,
_ => 0,
};
let peer_id = peer
.map(|e| e.to_string())
.unwrap_or_else(|| "anonymous".to_string());
if is_authorized(data_dir, &peer_id, size).await {
Ok(())
} else {
Err(AbortReason::Permission)
}
}
/// Pure allow/deny decision (no iroh types) — unit-testable without a live node.
async fn is_authorized(data_dir: &Path, peer_id: &str, size: u64) -> bool {
match gate::check_gate(data_dir, peer_id, SERVICE_ID, None, size).await {
// Service disabled (the default) → free for everyone. Or the peer holds an
// active paid session with remaining allotment.
Ok(GateResult::ServiceUnavailable)
| Ok(GateResult::Allowed { .. })
| Ok(GateResult::PaidAndAllowed { .. }) => true,
// Metered + no/exhausted session: the peer must pay out-of-band first
// (streaming.pay) before the swarm serves them — they fall back to origin.
Ok(_) => false,
// Never let a payment-layer fault break content distribution: fail OPEN
// (serve free) and log. Availability beats revenue when something breaks.
Err(e) => {
tracing::warn!("paid-gate: check errored ({e}); serving free");
true
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::streaming::pricing::{self, Metric, PricingConfig, ServicePricing};
fn content_download(enabled: bool) -> PricingConfig {
PricingConfig {
services: vec![ServicePricing {
service_id: SERVICE_ID.to_string(),
name: "Content Downloads".to_string(),
metric: Metric::Bytes,
step_size: 1_048_576,
price_per_step: 1,
min_steps: 0,
enabled,
description: String::new(),
accepted_mints: vec![],
}],
}
}
#[tokio::test]
async fn free_when_service_disabled_by_default() {
let dir = tempfile::tempdir().unwrap();
// No pricing file → defaults → content-download disabled → free for all.
assert!(is_authorized(dir.path(), "peer-a", 1_000_000).await);
}
#[tokio::test]
async fn free_when_service_explicitly_disabled() {
let dir = tempfile::tempdir().unwrap();
pricing::save_pricing(dir.path(), &content_download(false))
.await
.unwrap();
assert!(is_authorized(dir.path(), "peer-a", 1_048_576).await);
}
#[tokio::test]
async fn denied_when_metered_and_peer_has_not_paid() {
let dir = tempfile::tempdir().unwrap();
pricing::save_pricing(dir.path(), &content_download(true))
.await
.unwrap();
// Enabled service + no session/token → the swarm refuses; peer uses origin.
assert!(!is_authorized(dir.path(), "peer-b", 1_048_576).await);
}
}
+314
View File
@@ -0,0 +1,314 @@
//! Shape-A paid-blobs negotiation ALPN (`archy/paid-blobs/1`) — the on-wire
//! exchange that lets a downloader pay a seeder *before* fetching a gated blob
//! (DHT distribution plan §1, "shape A"). Gated behind `iroh-swarm`.
//!
//! ## Why a side ALPN
//! iroh-blobs carries the raw bytes; this tiny request/grant protocol rides a
//! second ALPN on the *same* endpoint so a downloader can discover the price and
//! deliver an ecash token first. The token opens a metered `streaming` session
//! keyed by the downloader's endpoint id — exactly the session the blob-GET gate
//! ([`super::paid`]) already checks. Same endpoint → same session → the GET is
//! then served.
//!
//! ```text
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: None }
//! B ◀─────────────────────── A PaymentRequired { price, accepted_mints }
//! B: auto_pay_token(...) ── builds a cashuA token (cross-mint aware)
//! B ──(archy/paid-blobs/1)──▶ A PaidRequest { want: H, token: Some(t) }
//! B ◀─────────────────────── A Granted (session now exists on A)
//! B ──(iroh-blobs ALPN)─────▶ A GET H → served (gate sees the session)
//! ```
//!
//! ## North star: origin always wins, releases stay free
//! Negotiation is **best-effort and additive**. A peer that doesn't speak this
//! ALPN, or any connect/protocol error, is treated as "proceed" — the blob-GET
//! gate is the real enforcement, and a denied GET just falls back to origin.
//! With the default [`PaymentPolicy::free`] a downloader never sends a token, so
//! a seeder that prices a blob is simply skipped → origin. Only films (a future
//! caller with a real budget) will actually pay.
use std::path::{Path, PathBuf};
use anyhow::Result;
use iroh::endpoint::Connection;
use iroh::protocol::{AcceptError, ProtocolHandler};
use iroh::{Endpoint, EndpointAddr, EndpointId};
use iroh_blobs::api::blobs::BlobStatus;
use iroh_blobs::api::Store;
use iroh_blobs::Hash;
use serde::{Deserialize, Serialize};
use super::payment::PaymentPolicy;
use crate::streaming::gate::{self, GateResult};
/// ALPN for the paid-blobs negotiation protocol.
pub const PAID_ALPN: &[u8] = b"archy/paid-blobs/1";
/// The streaming service that meters swarm blob serving (same id as [`super::paid`]).
const SERVICE_ID: &str = "content-download";
/// Cap on a single negotiation message (JSON). Requests/responses are tiny.
const MAX_MSG: usize = 64 * 1024;
/// A downloader's ask for one content-addressed blob, optionally with payment.
#[derive(Debug, Serialize, Deserialize)]
struct PaidRequest {
/// BLAKE3 hex of the wanted blob.
want: String,
/// A `cashuA` token, present on the paying retry.
#[serde(skip_serializing_if = "Option::is_none")]
token: Option<String>,
}
/// The seeder's verdict.
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum PaidResponse {
/// Fetch away — free, or a paid session is now active for this peer.
Granted,
/// Payment needed before serving. The downloader may pay and retry.
PaymentRequired {
price_sats: u64,
accepted_mints: Vec<String>,
},
/// Refused (bad request, insufficient/failed payment).
Denied { reason: String },
}
// ── Serve side ─────────────────────────────────────────────────────────────
/// Accept-side handler for [`PAID_ALPN`]. Registered on the provider's `Router`
/// alongside the iroh-blobs protocol.
#[derive(Clone)]
pub struct PaidBlobsProtocol {
data_dir: PathBuf,
store: Store,
}
impl std::fmt::Debug for PaidBlobsProtocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PaidBlobsProtocol").finish()
}
}
impl PaidBlobsProtocol {
pub fn new(data_dir: PathBuf, store: Store) -> Self {
Self { data_dir, store }
}
/// Decide the verdict for a request from `peer`. Mirrors [`super::paid`]'s
/// policy: free when the service is disabled (default) or the peer holds an
/// active session; payment-required when metered and unpaid; fail-OPEN
/// (Granted) on an internal gate error so a fault never blocks distribution.
async fn decide(&self, peer: &str, req: &PaidRequest) -> PaidResponse {
let size = self.blob_size(&req.want).await;
match gate::check_gate(&self.data_dir, peer, SERVICE_ID, req.token.as_deref(), size).await {
Ok(GateResult::ServiceUnavailable)
| Ok(GateResult::Allowed { .. })
| Ok(GateResult::PaidAndAllowed { .. }) => PaidResponse::Granted,
Ok(GateResult::PaymentRequired {
minimum_sats,
pricing,
..
}) => PaidResponse::PaymentRequired {
price_sats: minimum_sats,
accepted_mints: pricing.accepted_mints,
},
Ok(GateResult::InsufficientPayment {
provided_sats,
minimum_sats,
}) => PaidResponse::Denied {
reason: format!("insufficient payment: {provided_sats} < {minimum_sats} sats"),
},
Ok(GateResult::PaymentFailed { reason }) => PaidResponse::Denied { reason },
// Availability beats revenue: a gate fault serves free, matching the
// blob-GET gate's fail-open behaviour.
Err(e) => {
tracing::warn!("paid-alpn: gate errored ({e}); granting free");
PaidResponse::Granted
}
}
}
/// Full size of a held blob (for metering); 0 if we don't hold it complete.
async fn blob_size(&self, blake3_hex: &str) -> u64 {
let Ok(raw) = hex::decode(blake3_hex) else {
return 0;
};
let Ok(arr) = <[u8; 32]>::try_from(raw.as_slice()) else {
return 0;
};
match self.store.blobs().status(Hash::from_bytes(arr)).await {
Ok(BlobStatus::Complete { size }) => size,
_ => 0,
}
}
}
impl ProtocolHandler for PaidBlobsProtocol {
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
let peer = connection.remote_id().to_string();
// One bi-stream per request (a paying downloader opens a second one).
loop {
let (mut send, mut recv) = match connection.accept_bi().await {
Ok(s) => s,
// Connection closed by the peer — normal end of negotiation.
Err(_) => break,
};
let buf = recv
.read_to_end(MAX_MSG)
.await
.map_err(AcceptError::from_err)?;
let response = match serde_json::from_slice::<PaidRequest>(&buf) {
Ok(req) => self.decide(&peer, &req).await,
Err(e) => PaidResponse::Denied {
reason: format!("bad request: {e}"),
},
};
let bytes = serde_json::to_vec(&response).map_err(AcceptError::from_err)?;
send.write_all(&bytes)
.await
.map_err(AcceptError::from_err)?;
send.finish().map_err(AcceptError::from_err)?;
}
Ok(())
}
}
// ── Fetch side ───────────────────────────────────────────────────────────────
/// Negotiate access to `blake3_hex` from `peer` before fetching. Returns whether
/// the caller should proceed to download from this peer.
///
/// Best-effort: any connect/protocol failure returns `true` (proceed — the
/// blob-GET gate is the real enforcement, and a denied GET falls back to origin).
/// Returns `false` only when the seeder explicitly requires a payment we won't or
/// can't make under `policy`.
pub async fn negotiate_access(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> bool {
match negotiate_inner(endpoint, data_dir, peer, blake3_hex, policy).await {
Ok(proceed) => proceed,
Err(e) => {
tracing::debug!(
"paid-alpn: negotiation with {peer} failed ({e}) — proceeding (gate decides)"
);
true
}
}
}
async fn negotiate_inner(
endpoint: &Endpoint,
data_dir: &Path,
peer: EndpointId,
blake3_hex: &str,
policy: &PaymentPolicy,
) -> Result<bool> {
let conn = endpoint.connect(EndpointAddr::new(peer), PAID_ALPN).await?;
// First ask with no token.
let resp = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: None,
},
)
.await?;
match resp {
PaidResponse::Granted => Ok(true),
PaidResponse::Denied { .. } => Ok(false),
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
// Build a token within budget (cross-mint aware); None ⇒ use origin.
match super::payment::auto_pay_token(data_dir, policy, &accepted_mints, price_sats)
.await?
{
None => Ok(false),
Some(token) => {
let resp2 = exchange(
&conn,
&PaidRequest {
want: blake3_hex.to_string(),
token: Some(token),
},
)
.await?;
Ok(matches!(resp2, PaidResponse::Granted))
}
}
}
}
}
/// One request/response round trip on a fresh bi-stream.
async fn exchange(conn: &Connection, req: &PaidRequest) -> Result<PaidResponse> {
let (mut send, mut recv) = conn.open_bi().await?;
send.write_all(&serde_json::to_vec(req)?).await?;
send.finish()?;
let buf = recv.read_to_end(MAX_MSG).await?;
Ok(serde_json::from_slice(&buf)?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_round_trips_and_omits_absent_token() {
let req = PaidRequest {
want: "abcd".into(),
token: None,
};
let json = serde_json::to_string(&req).unwrap();
assert!(
!json.contains("token"),
"absent token must be omitted: {json}"
);
let back: PaidRequest = serde_json::from_str(&json).unwrap();
assert_eq!(back.want, "abcd");
assert!(back.token.is_none());
}
#[test]
fn request_with_token_round_trips() {
let req = PaidRequest {
want: "ff".into(),
token: Some("cashuAbc".into()),
};
let back: PaidRequest =
serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
assert_eq!(back.token.as_deref(), Some("cashuAbc"));
}
#[test]
fn response_tagged_serialization() {
let granted = serde_json::to_string(&PaidResponse::Granted).unwrap();
assert_eq!(granted, r#"{"status":"granted"}"#);
let pr = serde_json::to_string(&PaidResponse::PaymentRequired {
price_sats: 7,
accepted_mints: vec!["https://m".into()],
})
.unwrap();
let back: PaidResponse = serde_json::from_str(&pr).unwrap();
match back {
PaidResponse::PaymentRequired {
price_sats,
accepted_mints,
} => {
assert_eq!(price_sats, 7);
assert_eq!(accepted_mints, vec!["https://m".to_string()]);
}
other => panic!("expected PaymentRequired, got {other:?}"),
}
}
}
+167
View File
@@ -0,0 +1,167 @@
//! Fetch-side auto-pay — the *downloader's* decision layer for paid swarm
//! content (plan §1 "fetch side" + §2a cross-mint).
//!
//! When a swarm seeder gates a blob behind payment (its `PaymentRequired`
//! advertises a price and a set of `accepted_mints`), a downloading node uses
//! this layer to decide whether to pay and, if so, to build a `cashuA` token
//! denominated in one of the seeder's accepted mints — auto-swapping across
//! mints when needed (see [`crate::wallet::ecash::build_payment_token`]).
//!
//! ## North star: origin always wins
//! Paying is strictly an optimization. If the price is over budget, the wallet
//! can't cover it, no trusted mint is reachable, or a swap would cost too much,
//! this layer returns `None` and the caller falls back to the free HTTP origin —
//! exactly today's path. A wallet/mint problem must never block content.
//!
//! ## Scope / what's NOT here
//! This builds the *token*; it does not yet carry it to the seeder. The on-wire
//! exchange (a downloader presenting the token to a paid seeder, then streaming
//! the blob) is the in-band paid-blobs ALPN — "shape (A)" in the design doc —
//! which is deferred. Today's seeder side (`swarm::paid`) only allow/deny-gates
//! iroh-blobs requests; once shape (A) lands, the provider's fetch path calls
//! [`auto_pay_token`] on a `PaymentRequired` and retries with the token.
use std::path::Path;
use anyhow::Result;
use tracing::debug;
use crate::wallet::ecash;
/// A downloader's willingness to pay swarm peers for a single fetch.
#[derive(Debug, Clone, Copy)]
pub struct PaymentPolicy {
/// Maximum total sats to spend for this content. `0` disables paying
/// entirely (origin-only) — the safe default.
pub budget_sats: u64,
/// Maximum cross-mint swap fee tolerated when we must swap into the
/// seeder's mint. Ignored when we already hold the right mint.
pub max_fee_sats: u64,
}
impl PaymentPolicy {
/// The default: never pay, always use the free origin. The production caller
/// is the deferred in-band paid-blobs ALPN (shape A); used by tests today.
#[allow(dead_code)]
pub fn free() -> Self {
Self {
budget_sats: 0,
max_fee_sats: 0,
}
}
/// A budget-capped policy.
pub fn with_budget(budget_sats: u64, max_fee_sats: u64) -> Self {
Self {
budget_sats,
max_fee_sats,
}
}
/// Whether a seeder's `price_sats` is worth paying under this policy. A zero
/// price is treated as "not a real paid request" (use origin / free path).
pub fn affords(&self, price_sats: u64) -> bool {
price_sats > 0 && price_sats <= self.budget_sats
}
}
/// Decide whether to pay a seeder `price_sats`, and if so build a `cashuA` token
/// denominated in one of its `accepted_mints` (auto-swapping if needed).
///
/// * `Ok(Some(token))` — pay the seeder with this token.
/// * `Ok(None)` — decline (over budget, unpayable, or swap too costly);
/// the caller should fall back to the free origin.
///
/// Never returns `Err` for a wallet/mint problem: those degrade to `Ok(None)`
/// so a payment failure can never block content.
pub async fn auto_pay_token(
data_dir: &Path,
policy: &PaymentPolicy,
accepted_mints: &[String],
price_sats: u64,
) -> Result<Option<String>> {
if !policy.affords(price_sats) {
debug!(
"auto-pay: price {} sats over budget {} (or zero) — using origin",
price_sats, policy.budget_sats
);
return Ok(None);
}
match ecash::build_payment_token(data_dir, accepted_mints, price_sats, policy.max_fee_sats)
.await
{
Ok(token) => Ok(Some(token)),
Err(e) => {
// Unpayable within balance/trust/fee — not an error, just decline.
debug!("auto-pay: declined ({}) — falling back to origin", e);
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_policy_never_affords() {
let p = PaymentPolicy::free();
assert!(!p.affords(1));
assert!(!p.affords(0));
}
#[test]
fn budget_policy_affordability() {
let p = PaymentPolicy::with_budget(100, 5);
assert!(p.affords(100)); // exactly at budget
assert!(p.affords(1));
assert!(!p.affords(101)); // over budget
assert!(!p.affords(0)); // zero price is never a real paid request
}
#[tokio::test]
async fn over_budget_declines_without_touching_wallet() {
let tmp = tempfile::tempdir().unwrap();
// Price exceeds budget → None, and no wallet/mint interaction occurs.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(50, 5),
&["https://seeder.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn zero_budget_is_origin_only() {
let tmp = tempfile::tempdir().unwrap();
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::free(),
&["https://seeder.example.com".into()],
10,
)
.await
.unwrap();
assert!(out.is_none());
}
#[tokio::test]
async fn unpayable_within_budget_declines_gracefully() {
let tmp = tempfile::tempdir().unwrap();
// Within budget, but empty wallet + untrusted seeder mint → build fails;
// auto_pay degrades to None (origin) rather than erroring.
let out = auto_pay_token(
tmp.path(),
&PaymentPolicy::with_budget(1000, 10),
&["https://untrusted.example.com".into()],
100,
)
.await
.unwrap();
assert!(out.is_none());
}
}
+233
View File
@@ -0,0 +1,233 @@
//! Phase 3 discovery — signed Nostr "seed advertisement" events.
//!
//! A node that holds a PUBLIC release / app-image blob (addressed by BLAKE3)
//! announces "I can seed hash H from iroh endpoint E" as a signed, NIP-33
//! addressable Nostr event. **Scope is releases/catalog content ONLY** — never
//! private user blobs (decided 2026-06-16): smallest privacy surface, covers
//! the OTA + app-install use-cases. Discovery queries these events to find
//! swarm seeds for a hash; the iroh provider then dials those endpoints.
//!
//! Event shape (NIP-33 addressable, kind [`ARCHIPELAGO_SEED_KIND`]):
//! - `d` tag = blake3 hex of the content → one current advert per (author, hash)
//! - content = `{"v":1,"endpoint_id":"<iroh endpoint id>"}`
//! - author pubkey = the node's seed-derived Nostr identity (signs the event)
//!
//! Endpoint ids stay opaque strings here so this protocol layer builds/parses/
//! publishes/queries WITHOUT the heavy iroh dep; only the `iroh-swarm`
//! discovery glue parses the string into an `iroh::EndpointId`.
// The publish/query path that calls these lives behind `iroh-swarm` (it needs
// the node's iroh EndpointId), so in the default build they're exercised only
// by unit tests — allow them to stand without a production caller.
#![allow(dead_code)]
use std::path::Path;
use std::time::Duration;
use nostr_sdk::{Event, EventBuilder, Filter, Keys, Kind, Tag};
use serde::{Deserialize, Serialize};
/// How long to wait for relay connects / event fetches. Matches the rest of the
/// Nostr discovery path so the swarm never stalls the download longer than node
/// discovery already might.
const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(15);
/// NIP-33 addressable kind for Archipelago seed advertisements.
/// Distinct from the node-discovery app-data kind (30078).
pub const ARCHIPELAGO_SEED_KIND: u16 = 30081;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AdvertContent {
v: u8,
endpoint_id: String,
}
/// Build the (unsigned) advertisement event for `blake3_hex` served from
/// `endpoint_id`. Sign with the node's Nostr key (`.sign_with_keys()` /
/// `.sign()`) or publish via `client.send_event_builder()`.
pub fn advertisement_builder(blake3_hex: &str, endpoint_id: &str) -> EventBuilder {
let content = serde_json::to_string(&AdvertContent {
v: 1,
endpoint_id: endpoint_id.to_string(),
})
.expect("serialize advert content");
EventBuilder::new(Kind::Custom(ARCHIPELAGO_SEED_KIND), content)
.tag(Tag::identifier(blake3_hex.to_string()))
}
/// Filter matching all current seed advertisements for `blake3_hex` (one per
/// advertising node; NIP-33 latest-replaces per author).
pub fn advertisement_filter(blake3_hex: &str) -> Filter {
Filter::new()
.kind(Kind::Custom(ARCHIPELAGO_SEED_KIND))
.identifier(blake3_hex.to_string())
}
/// Extract the advertised endpoint id from an event, or `None` if it is the
/// wrong kind or malformed.
pub fn parse_endpoint_id(event: &Event) -> Option<String> {
if event.kind != Kind::Custom(ARCHIPELAGO_SEED_KIND) {
return None;
}
serde_json::from_str::<AdvertContent>(&event.content)
.ok()
.map(|c| c.endpoint_id)
.filter(|s| !s.is_empty())
}
/// Collect the unique advertised endpoint ids across a set of events, skipping
/// malformed ones. Order-preserving, de-duplicated.
pub fn endpoint_ids_from_events<'a>(events: impl IntoIterator<Item = &'a Event>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for ev in events {
if let Some(id) = parse_endpoint_id(ev) {
if seen.insert(id.clone()) {
out.push(id);
}
}
}
out
}
/// Query `relays` for the current seed advertisements for `blake3_hex` and
/// return the de-duplicated endpoint-id strings (opaque here; the `iroh-swarm`
/// glue parses them into `iroh::EndpointId`).
///
/// Best-effort by design: an empty relay list, a connect timeout, or a fetch
/// failure all yield an empty list — never an error. The swarm seam treats "no
/// providers" as "use origin", so discovery problems can only ever degrade to
/// today's HTTP path, never block it.
pub async fn fetch_seed_endpoint_ids(
relays: &[String],
tor_proxy: Option<&str>,
blake3_hex: &str,
) -> Vec<String> {
if relays.is_empty() {
return Vec::new();
}
// Query anonymously — discovery reads public adverts and must not link the
// query back to this node's seed identity.
let anon = Keys::generate();
let client = match crate::nostr_discovery::build_nostr_client(anon, tor_proxy) {
Ok(c) => c,
Err(e) => {
tracing::warn!("seed-advert: build relay client failed: {e}");
return Vec::new();
}
};
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
.await
.is_err()
{
tracing::warn!("seed-advert: relay connect timed out, continuing anyway");
}
let events = client
.fetch_events(advertisement_filter(blake3_hex), RELAY_FETCH_TIMEOUT)
.await
.map(|e| e.to_vec())
.unwrap_or_default();
client.disconnect().await;
endpoint_ids_from_events(events.iter())
}
/// Publish a signed advertisement — "this node can seed `blake3_hex` from
/// `endpoint_id`" — to `relays`, signed with the node's seed-derived Nostr key.
///
/// **Caller must restrict this to PUBLIC releases/catalog blobs** (the design's
/// privacy scope, decided 2026-06-16) — never private user content. Best-effort:
/// relay failures are logged, not fatal, since seeding is an optimization.
pub async fn publish_seed_advert(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
blake3_hex: &str,
endpoint_id: &str,
) -> anyhow::Result<()> {
if relays.is_empty() {
return Ok(());
}
let keys = crate::nostr_discovery::load_or_create_nostr_keys(identity_dir).await?;
let client = crate::nostr_discovery::build_nostr_client(keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(RELAY_CONNECT_TIMEOUT, client.connect())
.await
.is_err()
{
tracing::warn!("seed-advert: publish relay connect timed out, continuing anyway");
}
let _ = client
.send_event_builder(advertisement_builder(blake3_hex, endpoint_id))
.await;
client.disconnect().await;
tracing::info!("seed-advert: announced {blake3_hex} seedable from {endpoint_id}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_sign_parse_roundtrip() {
let keys = Keys::generate();
let hash = "a".repeat(64);
let endpoint = "node-example-endpoint-id";
let event = advertisement_builder(&hash, endpoint)
.sign_with_keys(&keys)
.unwrap();
assert_eq!(event.kind, Kind::Custom(ARCHIPELAGO_SEED_KIND));
assert_eq!(parse_endpoint_id(&event).as_deref(), Some(endpoint));
}
#[test]
fn filter_targets_the_hash_dtag_and_kind() {
let hash = "b".repeat(64);
let json = serde_json::to_string(&advertisement_filter(&hash)).unwrap();
assert!(json.contains(&hash), "filter must target the hash d-tag");
assert!(
json.contains("30081"),
"filter must constrain the seed kind"
);
}
#[test]
fn parse_rejects_wrong_kind_and_empty_endpoint() {
let keys = Keys::generate();
let wrong_kind = EventBuilder::new(Kind::Custom(1), "{}")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(parse_endpoint_id(&wrong_kind), None);
let empty_endpoint = advertisement_builder(&"c".repeat(64), "")
.sign_with_keys(&keys)
.unwrap();
assert_eq!(parse_endpoint_id(&empty_endpoint), None);
}
#[test]
fn dedups_endpoint_ids_across_events() {
let a = Keys::generate();
let b = Keys::generate();
let hash = "d".repeat(64);
let e1 = advertisement_builder(&hash, "endpoint-A")
.sign_with_keys(&a)
.unwrap();
let e2 = advertisement_builder(&hash, "endpoint-A")
.sign_with_keys(&b)
.unwrap();
let e3 = advertisement_builder(&hash, "endpoint-B")
.sign_with_keys(&b)
.unwrap();
let ids = endpoint_ids_from_events([&e1, &e2, &e3]);
assert_eq!(
ids,
vec!["endpoint-A".to_string(), "endpoint-B".to_string()]
);
}
}
+71
View File
@@ -0,0 +1,71 @@
//! The fleet's pinned **release-root** trust anchor.
//!
//! Every node ships the release-root *public* key. Signed manifests and the app
//! catalog must be signed by the corresponding private key (derived once, in
//! the signing ceremony, via `seed::derive_release_root_ed25519`). Pinning the
//! key in the binary is what makes a swapped-in mirror key detectable.
//!
//! Until the ceremony runs against the real release master seed, the pinned
//! constant is `None`. While `None`, signature verification still runs and
//! still rejects tampered documents, but it cannot enforce signer *identity*
//! (see `signed_doc::SignatureStatus::anchored`). Set
//! `ARCHY_RELEASE_ROOT_PUBKEY` (64-char hex) to pin a key at runtime for
//! staging/test fleets before the constant is baked in.
use ed25519_dalek::VerifyingKey;
/// Hex of the pinned Ed25519 release-root public key (32 bytes / 64 hex chars).
///
/// TODO(dht Phase 0): bake the real value here after the signing ceremony.
/// Generate it with: `scripts/release-root-ceremony.sh pubkey`.
pub const RELEASE_ROOT_PUBKEY_HEX: Option<&str> = None;
const ENV_OVERRIDE: &str = "ARCHY_RELEASE_ROOT_PUBKEY";
/// Resolve the pinned release-root public key, if any.
///
/// Runtime env override wins over the baked-in constant so a test fleet can pin
/// a ceremony key without a rebuild. Malformed values are ignored (treated as
/// "not pinned") rather than crashing the node.
pub fn release_root_pubkey() -> Option<VerifyingKey> {
if let Ok(hex_str) = std::env::var(ENV_OVERRIDE) {
if let Some(key) = parse_pubkey_hex(hex_str.trim()) {
return Some(key);
}
tracing::warn!(
"{} is set but not a valid 32-byte hex Ed25519 key; ignoring",
ENV_OVERRIDE
);
}
RELEASE_ROOT_PUBKEY_HEX.and_then(parse_pubkey_hex)
}
fn parse_pubkey_hex(s: &str) -> Option<VerifyingKey> {
let bytes = hex::decode(s).ok()?;
let arr: [u8; 32] = bytes.as_slice().try_into().ok()?;
VerifyingKey::from_bytes(&arr).ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unset_constant_is_none() {
// Default build ships no pinned anchor yet.
assert!(RELEASE_ROOT_PUBKEY_HEX.is_none());
}
#[test]
fn parses_valid_hex() {
let key = ed25519_dalek::SigningKey::from_bytes(&[9u8; 32]).verifying_key();
let parsed = parse_pubkey_hex(&hex::encode(key.to_bytes())).unwrap();
assert_eq!(parsed.as_bytes(), key.as_bytes());
}
#[test]
fn rejects_malformed_hex() {
assert!(parse_pubkey_hex("nothex").is_none());
assert!(parse_pubkey_hex("abcd").is_none());
}
}
+87
View File
@@ -0,0 +1,87 @@
//! Canonical JSON for signing — a pragmatic subset of RFC 8785 (JCS).
//!
//! Signatures are computed over a *byte-exact* serialization so that a verifier
//! reproduces the same preimage the signer hashed. We guarantee:
//!
//! * object keys recursively sorted (lexicographic by Rust `str` ordering,
//! i.e. Unicode scalar value — matches JCS for the ASCII keys we use),
//! * no insignificant whitespace,
//! * arrays preserved in order.
//!
//! We do NOT implement JCS number canonicalization (ECMAScript shortest-form).
//! Archipelago manifests/catalogs carry only integers, strings, bools, arrays
//! and objects, for which `serde_json`'s output is already unambiguous. If a
//! float ever enters a signed document this must be hardened (or rejected).
//! `contains_float()` lets callers enforce that invariant.
use serde_json::Value;
/// Serialize `value` to canonical JSON bytes (sorted keys, compact).
///
/// Rebuilds every object through a `BTreeMap` so the result is independent of
/// the `serde_json/preserve_order` feature being toggled on anywhere in the
/// dependency graph.
pub fn to_canonical_bytes(value: &Value) -> Vec<u8> {
let canonical = canonicalize(value);
// serde_json never fails to serialize a Value it produced.
serde_json::to_vec(&canonical).expect("canonical JSON serialization")
}
/// Reject documents that contain a float anywhere — they are not safely
/// canonicalizable under this implementation.
pub fn contains_float(value: &Value) -> bool {
match value {
Value::Number(n) => n.as_i64().is_none() && n.as_u64().is_none(),
Value::Array(items) => items.iter().any(contains_float),
Value::Object(map) => map.values().any(contains_float),
_ => false,
}
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
// BTreeMap gives deterministic key ordering on serialize.
let sorted: std::collections::BTreeMap<String, Value> = map
.iter()
.map(|(k, v)| (k.clone(), canonicalize(v)))
.collect();
serde_json::to_value(sorted).expect("canonical object")
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn key_order_does_not_change_bytes() {
let a = json!({"b": 1, "a": 2, "c": {"z": 1, "y": 2}});
let b = json!({"c": {"y": 2, "z": 1}, "a": 2, "b": 1});
assert_eq!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn output_is_sorted_and_compact() {
let v = json!({"b": 1, "a": [3, 2, 1]});
assert_eq!(to_canonical_bytes(&v), br#"{"a":[3,2,1],"b":1}"#.to_vec());
}
#[test]
fn array_order_is_preserved() {
let a = json!([1, 2, 3]);
let b = json!([3, 2, 1]);
assert_ne!(to_canonical_bytes(&a), to_canonical_bytes(&b));
}
#[test]
fn detects_floats() {
assert!(contains_float(&json!({"x": 1.5})));
assert!(contains_float(&json!([1, 2, 0.1])));
assert!(!contains_float(&json!({"x": 12345, "y": "s", "z": [1, 2]})));
}
}
+54
View File
@@ -0,0 +1,54 @@
//! `did:key` <-> Ed25519 public key, mirroring the encoding already used by
//! `identity_manager` so release-root DIDs are interchangeable with node DIDs.
//!
//! Format: `did:key:z<base58btc(0xed01 || 32-byte-pubkey)>`
//! (`0xed01` is the multicodec varint prefix for an Ed25519 public key.)
use anyhow::{anyhow, Context, Result};
use ed25519_dalek::VerifyingKey;
const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
/// Encode an Ed25519 public key as a `did:key` string.
pub fn did_key_for_ed25519(key: &VerifyingKey) -> String {
let mut bytes = Vec::with_capacity(34);
bytes.extend_from_slice(&ED25519_MULTICODEC);
bytes.extend_from_slice(key.as_bytes());
format!("did:key:z{}", bs58::encode(bytes).into_string())
}
/// Decode a `did:key` string into an Ed25519 verifying key.
pub fn ed25519_pubkey_from_did_key(did: &str) -> Result<VerifyingKey> {
let z_part = did
.strip_prefix("did:key:z")
.ok_or_else(|| anyhow!("invalid did:key format: {}", did))?;
let decoded = bs58::decode(z_part)
.into_vec()
.context("invalid base58 in did:key")?;
if decoded.len() != 34 || decoded[0..2] != ED25519_MULTICODEC {
return Err(anyhow!("not an Ed25519 did:key (bad multicodec prefix)"));
}
let arr: [u8; 32] = decoded[2..].try_into().expect("length checked above");
VerifyingKey::from_bytes(&arr).context("invalid Ed25519 public key in did:key")
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
#[test]
fn roundtrip() {
let key = SigningKey::from_bytes(&[3u8; 32]).verifying_key();
let did = did_key_for_ed25519(&key);
assert!(did.starts_with("did:key:z6Mk"), "got {}", did);
let back = ed25519_pubkey_from_did_key(&did).unwrap();
assert_eq!(key.as_bytes(), back.as_bytes());
}
#[test]
fn rejects_non_ed25519() {
assert!(ed25519_pubkey_from_did_key("did:key:zQ3shazz").is_err());
assert!(ed25519_pubkey_from_did_key("not-a-did").is_err());
}
}
+23
View File
@@ -0,0 +1,23 @@
//! Authenticity layer for the DHT distribution plan (Phase 0).
//!
//! Content addressing (SHA-256 today, BLAKE3 later) proves downloaded bytes are
//! *intact*. It does not prove they were *authorized*. This module adds the
//! missing half: detached Ed25519 signatures over canonical JSON, verified
//! against a pinned **release-root** trust anchor.
//!
//! Layout:
//! * [`anchor`] — the pinned release-root public key (+ env override).
//! * [`canonical`] — deterministic JSON serialization for signing.
//! * [`did`] — `did:key` <-> Ed25519 public key.
//! * [`signed_doc`]— detached sign/verify over a signed document.
//!
//! The release-root *private* key is publisher-only and derived in the signing
//! ceremony via [`crate::seed::derive_release_root_ed25519`]; fleet nodes only
//! ever hold the public key.
pub mod anchor;
pub mod canonical;
pub mod did;
pub mod signed_doc;
pub use signed_doc::{verify_detached, SignatureStatus};
+200
View File
@@ -0,0 +1,200 @@
//! Detached Ed25519 signatures over canonical JSON documents.
//!
//! A *signed document* is any JSON object carrying two reserved top-level
//! fields:
//!
//! * `signed_by` — the signer's `did:key` (Ed25519), e.g. the release-root.
//! * `signature` — hex-encoded Ed25519 signature over the canonical JSON of
//! the document with **both** reserved fields removed.
//!
//! Removing the fields before canonicalizing makes the signature *detached*:
//! the signer signs the payload, then attaches the proof, without a
//! chicken-and-egg dependency on the signature's own bytes.
//!
//! Authenticity ≠ integrity. Content addressing (SHA-256/BLAKE3 in the
//! manifest) proves the bytes are intact; this signature proves *we authorized
//! them*. The DHT plan requires both.
use anyhow::{anyhow, bail, Context, Result};
use ed25519_dalek::{Signature, Signer, SigningKey};
use serde_json::Value;
use super::anchor;
use super::canonical;
use super::did;
pub const SIGNATURE_FIELD: &str = "signature";
pub const SIGNED_BY_FIELD: &str = "signed_by";
/// Outcome of inspecting a document for a detached signature.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignatureStatus {
/// No `signature` field present. Caller decides whether to accept
/// (during the migration window we still accept unsigned documents).
Unsigned,
/// Signature verified. `anchored` is true when `signed_by` matched the
/// pinned release-root anchor (full authenticity); false means the
/// signature is internally consistent but the signer key is not yet
/// pinned, so it only proves the document wasn't tampered relative to its
/// own claimed key.
Verified { signer_did: String, anchored: bool },
}
/// Verify a document's detached signature *if present*.
///
/// Returns `Ok(Unsigned)` when there is no signature. Returns `Ok(Verified)`
/// when a present signature checks out. Returns `Err` when a signature is
/// present but malformed, fails verification, or names a signer that
/// contradicts the pinned anchor — callers MUST reject the document on `Err`.
pub fn verify_detached(doc: &Value) -> Result<SignatureStatus> {
let obj = doc
.as_object()
.ok_or_else(|| anyhow!("signed document must be a JSON object"))?;
let signature_hex = match obj.get(SIGNATURE_FIELD) {
None | Some(Value::Null) => return Ok(SignatureStatus::Unsigned),
Some(Value::String(s)) => s,
Some(_) => bail!("`{}` must be a string", SIGNATURE_FIELD),
};
let signed_by = obj
.get(SIGNED_BY_FIELD)
.and_then(Value::as_str)
.ok_or_else(|| {
anyhow!(
"signed document has `{}` but no `{}`",
SIGNATURE_FIELD,
SIGNED_BY_FIELD
)
})?;
let signer = did::ed25519_pubkey_from_did_key(signed_by)
.with_context(|| format!("invalid `{}` did:key", SIGNED_BY_FIELD))?;
// If the fleet has a pinned release-root, the signer MUST be it. This is
// what stops a mirror from swapping in its own keypair and re-signing.
let anchored = match anchor::release_root_pubkey() {
Some(pinned) => {
if pinned != signer {
bail!("signed_by does not match the pinned release-root anchor");
}
true
}
None => false,
};
let signature = parse_signature_hex(signature_hex)?;
let preimage = signing_preimage(obj)?;
signer
.verify_strict(&preimage, &signature)
.map_err(|_| anyhow!("release-root signature verification failed"))?;
Ok(SignatureStatus::Verified {
signer_did: signed_by.to_string(),
anchored,
})
}
/// Produce a detached signature for `payload` (the document WITHOUT the
/// reserved fields). Used by the signing ceremony and round-trip tests.
/// Returns `(signature_hex, signed_by_did)`.
pub fn sign_detached(key: &SigningKey, payload: &Value) -> Result<(String, String)> {
let obj = payload
.as_object()
.ok_or_else(|| anyhow!("payload must be a JSON object"))?;
if obj.contains_key(SIGNATURE_FIELD) || obj.contains_key(SIGNED_BY_FIELD) {
bail!("payload must not already contain reserved signature fields");
}
let preimage = signing_preimage(obj)?;
let signature = key.sign(&preimage);
let did = did::did_key_for_ed25519(&key.verifying_key());
Ok((hex::encode(signature.to_bytes()), did))
}
/// Canonical bytes the signature covers: the object minus the reserved fields.
fn signing_preimage(obj: &serde_json::Map<String, Value>) -> Result<Vec<u8>> {
let mut payload = obj.clone();
payload.remove(SIGNATURE_FIELD);
payload.remove(SIGNED_BY_FIELD);
let value = Value::Object(payload);
if canonical::contains_float(&value) {
bail!("signed documents must not contain floating-point numbers");
}
Ok(canonical::to_canonical_bytes(&value))
}
fn parse_signature_hex(s: &str) -> Result<Signature> {
let bytes = hex::decode(s).context("signature is not valid hex")?;
let arr: [u8; 64] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("signature must be 64 bytes, got {}", bytes.len()))?;
Ok(Signature::from_bytes(&arr))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn test_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn sign_into(key: &SigningKey, mut doc: Value) -> Value {
let (sig, did) = sign_detached(key, &doc).unwrap();
let obj = doc.as_object_mut().unwrap();
obj.insert(SIGNED_BY_FIELD.into(), json!(did));
obj.insert(SIGNATURE_FIELD.into(), json!(sig));
doc
}
#[test]
fn unsigned_document_reports_unsigned() {
let doc = json!({"schema": 1, "apps": {}});
assert_eq!(verify_detached(&doc).unwrap(), SignatureStatus::Unsigned);
}
#[test]
fn roundtrip_verifies() {
let signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
match verify_detached(&signed).unwrap() {
// No anchor pinned in the default test build → anchored == false.
SignatureStatus::Verified { anchored, .. } => assert!(!anchored),
other => panic!("expected Verified, got {:?}", other),
}
}
#[test]
fn signature_survives_key_reordering() {
// Re-emitting the document with shuffled keys must not break the sig.
let signed = sign_into(&test_key(), json!({"b": 2, "a": 1}));
let reparsed: Value =
serde_json::from_str(&serde_json::to_string(&signed).unwrap()).unwrap();
assert!(matches!(
verify_detached(&reparsed).unwrap(),
SignatureStatus::Verified { .. }
));
}
#[test]
fn tampered_payload_is_rejected() {
let mut signed = sign_into(&test_key(), json!({"schema": 1, "n": 42}));
signed
.as_object_mut()
.unwrap()
.insert("n".into(), json!(43));
assert!(verify_detached(&signed).is_err());
}
#[test]
fn missing_signed_by_is_rejected() {
let doc = json!({"schema": 1, "signature": "00"});
assert!(verify_detached(&doc).is_err());
}
#[test]
fn float_payload_cannot_be_signed() {
assert!(sign_detached(&test_key(), &json!({"x": 1.5})).is_err());
}
}
+235 -7
View File
@@ -202,6 +202,106 @@ pub async fn save_mirrors(data_dir: &Path, mirrors: &[UpdateMirror]) -> Result<(
Ok(())
}
// ─── Update/app fetch source (origin vs DHT swarm) ──────────────────────────
//
// User-selectable per node, persisted in `data_dir/update-source.json`. This is
// the live-testing switch: keep `Origin` (default) to pull releases/app blobs
// purely over HTTP from the configured mirrors — the known-good path — or flip
// to `Swarm` on a test node to exercise the DHT (iroh swarm-assist), knowing the
// origin still always wins as fallback. Independent of the compile-time
// `iroh-swarm` feature and the `swarm_enabled` config: if the swarm engine isn't
// present, `Swarm` simply has no peers to consult and behaves like `Origin`.
const UPDATE_SOURCE_FILE: &str = "update-source.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum UpdateSource {
/// HTTP origin/mirrors only. The safe default and the universal fallback.
#[default]
Origin,
/// Try DHT swarm peers first for content-addressed blobs, origin always wins.
Swarm,
}
fn default_true() -> bool {
true
}
/// Node-level swarm preferences, persisted together in `update-source.json`.
/// Two independent switches:
/// - `source`: where THIS node fetches (origin vs swarm). Default origin.
/// - `provide_dht`: whether this node SEEDS/serves blobs to peers. Default on
/// (opt-out) so the swarm has providers; nodes that don't want to serve can
/// turn it off without affecting how they fetch.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
struct SwarmPrefs {
#[serde(default)]
source: UpdateSource,
#[serde(default = "default_true")]
provide_dht: bool,
}
impl Default for SwarmPrefs {
fn default() -> Self {
Self {
source: UpdateSource::default(),
provide_dht: true,
}
}
}
fn update_source_path(data_dir: &Path) -> std::path::PathBuf {
data_dir.join(UPDATE_SOURCE_FILE)
}
async fn load_swarm_prefs(data_dir: &Path) -> SwarmPrefs {
match fs::read_to_string(update_source_path(data_dir)).await {
Ok(s) => serde_json::from_str::<SwarmPrefs>(&s).unwrap_or_default(),
Err(_) => SwarmPrefs::default(),
}
}
async fn save_swarm_prefs(data_dir: &Path, prefs: &SwarmPrefs) -> Result<()> {
fs::create_dir_all(data_dir)
.await
.with_context(|| format!("mkdir {}", data_dir.display()))?;
let path = update_source_path(data_dir);
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(prefs).context("serialize swarm prefs")?;
fs::write(&tmp, json)
.await
.with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(())
}
/// Load the node's selected fetch source. Missing/corrupt file → `Origin`.
pub async fn load_update_source(data_dir: &Path) -> UpdateSource {
load_swarm_prefs(data_dir).await.source
}
/// Persist the node's selected fetch source (preserving `provide_dht`).
pub async fn save_update_source(data_dir: &Path, source: UpdateSource) -> Result<()> {
let mut prefs = load_swarm_prefs(data_dir).await;
prefs.source = source;
save_swarm_prefs(data_dir, &prefs).await
}
/// Whether this node seeds/serves blobs to peers. Default true (opt-out).
pub async fn load_provide_dht(data_dir: &Path) -> bool {
load_swarm_prefs(data_dir).await.provide_dht
}
/// Persist whether this node provides to the swarm (preserving `source`).
pub async fn save_provide_dht(data_dir: &Path, provide: bool) -> Result<()> {
let mut prefs = load_swarm_prefs(data_dir).await;
prefs.provide_dht = provide;
save_swarm_prefs(data_dir, &prefs).await
}
/// Parse a manifest URL and return its `scheme://host[:port]` prefix.
/// Used by `rewrite_manifest_origins` so a manifest fetched from a
/// mirror points component downloads back at the same mirror rather
@@ -263,6 +363,11 @@ pub struct ComponentUpdate {
pub download_url: String,
pub sha256: String,
pub size_bytes: u64,
/// DHT Phase 1: BLAKE3 content address (bare hex or `"blake3:<hex>"`), the
/// iroh-native, range-verifiable hash. Optional during the migration
/// window — when present it is verified ALONGSIDE the mandatory SHA-256.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub blake3: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -538,12 +643,19 @@ pub async fn load_state(data_dir: &Path) -> Result<UpdateState> {
Ok(state)
}
/// Marker written only after EVERY component has downloaded and verified.
/// Distinguishes a complete, install-ready staging from the partial files a
/// resumable-but-failed download leaves behind.
const STAGED_COMPLETE_MARKER: &str = ".download-complete";
async fn has_staged_update(data_dir: &Path) -> bool {
let staging_dir = data_dir.join("update-staging");
let Ok(mut entries) = fs::read_dir(&staging_dir).await else {
return false;
};
matches!(entries.next_entry().await, Ok(Some(_)))
// A *complete* staged update carries the marker. A partial/failed download
// leaves component files (kept for resume) but no marker, so it reads as
// "not staged" — the state self-heal then clears update_in_progress and the
// UI returns to Download instead of stranding the user on Install.
fs::metadata(data_dir.join("update-staging").join(STAGED_COMPLETE_MARKER))
.await
.is_ok()
}
pub async fn save_state(data_dir: &Path, state: &UpdateState) -> Result<()> {
@@ -783,6 +895,23 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
DOWNLOAD_BYTES.store(0, Ordering::Relaxed);
DOWNLOAD_PROGRESS_AT.store(now_ms(), Ordering::Relaxed);
// Consult swarm peers only when the node has opted into DHT mode. In Origin
// mode (default) this stays empty so every component goes straight to the
// HTTP origin — instant, no-rebuild fallback while live-testing the swarm.
let update_source = load_update_source(data_dir).await;
let provide_dht = load_provide_dht(data_dir).await;
let swarm_providers = if update_source == UpdateSource::Swarm {
crate::swarm::providers()
} else {
Vec::new()
};
if update_source == UpdateSource::Swarm {
info!(
providers = swarm_providers.len(),
"Update source = DHT swarm (origin still wins as fallback)"
);
}
for component in &manifest.components {
if is_canceled() {
DOWNLOAD_TOTAL.store(0, Ordering::Relaxed);
@@ -791,7 +920,57 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
}
info!(name = %component.name, url = %component.download_url, "Downloading component");
let dest = staging_dir.join(&component.name);
download_component_resumable(&client, component, &dest, downloaded).await?;
// DHT Phase 2: when the manifest pins a BLAKE3 digest, route the fetch
// through the swarm seam (swarm-assist, origin always wins). With no
// providers registered (iroh-swarm feature off) this is identical to
// calling the resumable HTTP origin directly — same bytes, now
// content-addressed. A swarm hit is BLAKE3-verified inside the seam;
// we still enforce the mandatory SHA-256 gate on peer bytes here and
// re-fetch from origin if a (consistency-broken) peer slips through.
let digest = component.blake3.as_deref().and_then(|b| {
let s = b.trim();
let normalized = if s.contains(':') {
s.to_string()
} else {
format!("blake3:{s}")
};
crate::content_hash::ContentDigest::parse(&normalized).ok()
});
if let Some(digest) = digest {
let client_ref = &client;
let dest_ref = &dest;
let source = crate::swarm::fetch_content_addressed(
&digest,
&swarm_providers,
&dest,
move || async move {
download_component_resumable(client_ref, component, dest_ref, downloaded).await
},
)
.await?;
if source == crate::swarm::FetchSource::Swarm {
let bytes = tokio::fs::read(&dest).await?;
if crate::content_hash::sha256_hex(&bytes) != component.sha256 {
warn!(
name = %component.name,
"swarm bytes passed BLAKE3 but failed the SHA-256 manifest gate — re-fetching from origin"
);
let _ = tokio::fs::remove_file(&dest).await;
download_component_resumable(&client, component, &dest, downloaded).await?;
}
}
// This is a PUBLIC release blob and it just passed both the BLAKE3 and
// SHA-256 gates — announce that we can now seed it to peers. Gated on
// the node's "provide to swarm" preference (default on); best-effort,
// inert unless the iroh swarm is active, and never blocks the install.
// Independent of fetch source: an origin-fetching node can still seed.
if provide_dht {
crate::swarm::announce_held_blob(&digest.hex, &dest).await;
}
} else {
download_component_resumable(&client, component, &dest, downloaded).await?;
}
downloaded += component.size_bytes;
DOWNLOAD_BYTES.store(downloaded, Ordering::Relaxed);
info!(
@@ -801,7 +980,10 @@ pub async fn download_update(data_dir: &Path) -> Result<DownloadProgress> {
);
}
// Mark update as downloaded
// Mark update as downloaded. Write the completion marker FIRST so a crash
// between the two can't leave update_in_progress=true without the marker
// (which the self-heal would then clear, harmlessly forcing a re-download).
let _ = fs::write(staging_dir.join(STAGED_COMPLETE_MARKER), b"1").await;
let mut state = load_state(data_dir).await?;
state.update_in_progress = true;
save_state(data_dir, &state).await?;
@@ -983,6 +1165,25 @@ async fn download_component_resumable(
.context("read staging file for hash check")?;
let hash = hex::encode(Sha256::digest(&bytes));
if hash == component.sha256 {
// DHT Phase 1: if the manifest also pins a BLAKE3 digest, it must
// match too. SHA-256 stays the mandatory gate during migration;
// BLAKE3 is the hash the iroh swarm will fetch/verify by, so a
// present-but-wrong BLAKE3 means the bytes aren't swarm-consistent
// — treat it like a SHA mismatch and re-download.
if let Some(b3) = component.blake3.as_deref() {
let expected = b3.trim().strip_prefix("blake3:").unwrap_or(b3.trim());
let actual = crate::content_hash::blake3_hex(&bytes);
if !actual.eq_ignore_ascii_case(expected) {
let _ = tokio::fs::remove_file(dest).await;
last_err = Some(anyhow::anyhow!(
"BLAKE3 mismatch for {}: expected {}, got {}",
component.name,
expected,
actual
));
continue;
}
}
return Ok(());
}
// SHA mismatch — the file on disk is garbage. Nuke it and
@@ -1507,9 +1708,26 @@ pub async fn run_update_scheduler(data_dir: std::path::PathBuf) {
// Check every hour; act based on schedule setting
let mut tick = interval(Duration::from_secs(3600));
// Refresh the app catalog once at startup so per-app "update available"
// badges appear without waiting for the first hourly tick.
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
debug!(
"Update scheduler: initial app-catalog refresh failed: {}",
e
);
}
loop {
tick.tick().await;
// App-catalog refresh is INDEPENDENT of the OTA schedule below: it only
// populates per-app update availability (the "Update" button still has
// to be clicked — nothing auto-applies). Best-effort; on failure the
// previously cached catalog stays in place (origin-always-wins).
if let Err(e) = crate::container::app_catalog::refresh_catalog(&data_dir).await {
debug!("Update scheduler: app-catalog refresh failed: {}", e);
}
let state = match load_state(&data_dir).await {
Ok(s) => s,
Err(e) => {
@@ -1648,6 +1866,7 @@ mod tests {
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/archipelago".into(),
sha256: "x".into(),
size_bytes: 1,
blake3: None,
},
ComponentUpdate {
name: "frontend".into(),
@@ -1656,6 +1875,7 @@ mod tests {
download_url: "https://git.tx1138.com/lfg2025/archy/raw/branch/main/releases/v1.7.26-alpha/frontend.tar.gz".into(),
sha256: "y".into(),
size_bytes: 2,
blake3: None,
},
],
};
@@ -1855,6 +2075,13 @@ mod tests {
tokio::fs::write(staging.join("archipelago"), b"staged")
.await
.unwrap();
// A *complete* staged update carries the .download-complete marker;
// without it has_staged_update() reads the staging as partial and the
// load_state self-heal clears update_in_progress (see #26). This test
// simulates a complete staging, so write the marker.
tokio::fs::write(staging.join(STAGED_COMPLETE_MARKER), b"1")
.await
.unwrap();
let state = UpdateState {
current_version: "1.0.0".to_string(),
last_check: Some("2025-06-15T12:00:00Z".to_string()),
@@ -1869,6 +2096,7 @@ mod tests {
download_url: "https://example.com/binary".to_string(),
sha256: "abc123".to_string(),
size_bytes: 5000,
blake3: None,
}],
}),
update_in_progress: true,
+887 -4
View File
@@ -10,7 +10,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
const WALLET_FILE: &str = "wallet/ecash.json";
const MINTS_FILE: &str = "wallet/accepted_mints.json";
@@ -106,6 +106,18 @@ impl WalletState {
.sum()
}
/// Spendable (unspent, unreserved) balance grouped by mint URL.
pub fn spendable_by_mint(&self) -> Vec<(String, u64)> {
use std::collections::BTreeMap;
let mut by_mint: BTreeMap<String, u64> = BTreeMap::new();
for p in &self.proofs {
if !p.spent && !p.reserved {
*by_mint.entry(p.mint_url.clone()).or_default() += p.proof.amount;
}
}
by_mint.into_iter().collect()
}
/// Select unspent proofs that cover at least `amount` sats from a specific mint.
/// Returns selected proofs and any overpayment amount.
pub fn select_proofs(&self, mint_url: &str, amount: u64) -> Option<(Vec<usize>, u64)> {
@@ -352,10 +364,232 @@ pub async fn melt_tokens(data_dir: &Path, quote_id: &str, bolt11: &str) -> Resul
Ok(quote.amount)
}
/// Create a cashuA token string to send to a peer.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
// ── Cross-mint settlement (plan §2a / phasing F2) ──────────────────────────
//
// The wallet data model is already multi-mint (proofs, balances and selection
// are all keyed by mint URL). What was hardcoded to the home mint is the
// convenience layer. These `*_at` helpers parameterize that layer by target
// mint, and `swap_between_mints` moves value across mints over Lightning so a
// node holding tokens on mint A can pay a seeder that only accepts mint B.
/// How long to wait for a target mint's Lightning invoice to settle before
/// claiming the freshly-minted tokens.
const SWAP_CLAIM_TIMEOUT_SECS: u64 = 60;
/// Poll interval while waiting for the invoice to settle.
const SWAP_CLAIM_POLL_SECS: u64 = 2;
/// Whether we trust a mint enough to swap value *into* it (or accept its tokens).
///
/// The local Fedimint (home mint) is always trusted; any other mint must be on
/// the configured accepted-mints allow-list. Comparison ignores a trailing
/// slash so advertised URLs match stored ones. See plan §2a "Mint trust list".
pub async fn is_mint_trusted(data_dir: &Path, mint_url: &str) -> Result<bool> {
let norm = |s: &str| s.trim_end_matches('/').to_string();
let target = norm(mint_url);
if target == norm(&default_mint_url()) {
return Ok(true);
}
let accepted = load_accepted_mints(data_dir).await?;
Ok(accepted.mints.iter().any(|m| norm(m) == target))
}
/// All-in cost of a swap, relative to the amount actually delivered.
///
/// `total_paid` is what the source mint charges (melt amount + LN fee reserve);
/// `amount_delivered` is what lands on the target mint. The difference is the
/// fee the user pays to move value across mints.
fn swap_fee(total_paid: u64, amount_delivered: u64) -> u64 {
total_paid.saturating_sub(amount_delivered)
}
/// Move `amount_sats` of value from mint `from_mint` to mint `to_mint` over
/// Lightning, returning the amount claimed on the target mint.
///
/// Flow (Cashu/Fedimint settle over BOLT11):
/// 1. `mint_quote` on the target → a Lightning invoice to pay.
/// 2. `melt_quote` on the source → the cost in source tokens (amount + fee).
/// 3. Fee-cap check: refuse if the all-in fee exceeds `max_fee_sats`.
/// 4. Select source proofs and `melt` them to pay the target's invoice.
/// 5. Once the invoice settles, `mint` (claim) the tokens on the target.
///
/// Crash-safety: the source spend is persisted *before* the claim, so a crash
/// between paying and claiming never double-spends — at worst the target tokens
/// are left unclaimed (reconcilable from the mint quote id). Idempotent resume
/// is phasing step 3 (deferred).
pub async fn swap_between_mints(
data_dir: &Path,
from_mint: &str,
to_mint: &str,
amount_sats: u64,
max_fee_sats: u64,
) -> Result<u64> {
if amount_sats == 0 {
anyhow::bail!("swap amount must be greater than zero");
}
let norm = |s: &str| s.trim_end_matches('/').to_string();
if norm(from_mint) == norm(to_mint) {
anyhow::bail!("swap source and target mints are identical");
}
if !is_mint_trusted(data_dir, to_mint).await? {
anyhow::bail!(
"target mint '{}' is not in the trusted/accepted mint list",
to_mint
);
}
let from = MintClient::new(from_mint)?;
let to = MintClient::new(to_mint)?;
// 1. Mint quote on the target → invoice to pay.
let mint_quote = to
.mint_quote(amount_sats)
.await
.with_context(|| format!("requesting mint quote at target mint {}", to_mint))?;
// 2. Melt quote on the source for that invoice → cost in source tokens.
let melt_quote = from
.melt_quote(&mint_quote.request)
.await
.with_context(|| format!("requesting melt quote at source mint {}", from_mint))?;
let total_needed = melt_quote.amount + melt_quote.fee_reserve;
let fee = swap_fee(total_needed, amount_sats);
// 3. Fee-cap check — caller falls back to free origin if too expensive.
if fee > max_fee_sats {
anyhow::bail!(
"swap fee {} sats exceeds cap {} sats (need {} on {} to deliver {} on {})",
fee,
max_fee_sats,
total_needed,
from_mint,
amount_sats,
to_mint
);
}
// 4. Select source proofs and melt them to pay the target invoice.
let mut wallet = load_wallet(data_dir).await?;
let mint_url = wallet.mint_url.clone();
let (indices, _overpayment) =
wallet
.select_proofs(from_mint, total_needed)
.ok_or_else(|| {
anyhow::anyhow!(
"insufficient balance on {}: need {} sats, have {} sats",
from_mint,
total_needed,
wallet.balance_for_mint(from_mint)
)
})?;
let proofs: Vec<Proof> = indices
.iter()
.map(|&i| wallet.proofs[i].proof.clone())
.collect();
if let Err(e) = from.melt_tokens(&melt_quote.quote, &proofs).await {
// The pay leg never completed — record the route failure so future
// payments can prefer a route with a track record.
record_swap_failure(data_dir, from_mint, to_mint).await;
return Err(e).with_context(|| {
format!(
"melting source proofs at {} to pay target invoice",
from_mint
)
});
}
// Persist the spend BEFORE claiming so a crash can't double-spend, and
// journal the in-flight swap so the claim can be resumed after a crash.
wallet.mark_spent(&indices);
wallet.record_tx(
TransactionType::Melt,
total_needed,
&format!(
"Cross-mint swap {}→{}: paid {} sats (fee {})",
from_mint, to_mint, total_needed, fee
),
from_mint,
to_mint,
);
save_wallet(data_dir, &wallet).await?;
add_pending_swap(
data_dir,
PendingSwap {
from_mint: from_mint.to_string(),
to_mint: to_mint.to_string(),
amount_sats,
melt_quote_id: melt_quote.quote.clone(),
mint_quote_id: mint_quote.quote.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
},
)
.await?;
// 5. Wait for the invoice to settle, then claim the minted tokens.
wait_for_mint_quote_paid(&to, &mint_quote.quote).await?;
let result = to
.mint_tokens(&mint_quote.quote, amount_sats)
.await
.with_context(|| format!("claiming minted tokens at target mint {}", to_mint))?;
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
let mut wallet = load_wallet(data_dir).await?;
wallet.add_proofs(to_mint, result.proofs);
wallet.record_tx(
TransactionType::Mint,
minted,
&format!(
"Cross-mint swap {}→{}: claimed {} sats",
from_mint, to_mint, minted
),
to_mint,
from_mint,
);
save_wallet(data_dir, &wallet).await?;
// Swap fully settled — clear the journal entry and credit the route.
remove_pending_swap(data_dir, &mint_quote.quote).await?;
record_swap_success(data_dir, from_mint, to_mint).await;
debug!(
"Cross-mint swap complete: {} → {} delivered {} sats (fee {})",
from_mint, to_mint, minted, fee
);
Ok(minted)
}
/// Poll a mint quote until its Lightning invoice is paid (state `PAID`/`ISSUED`),
/// or time out. The melt above pays the invoice; the target mint sees it settle
/// shortly after.
async fn wait_for_mint_quote_paid(client: &MintClient, quote_id: &str) -> Result<()> {
let deadline = SWAP_CLAIM_TIMEOUT_SECS / SWAP_CLAIM_POLL_SECS.max(1);
for _ in 0..deadline.max(1) {
let status = client.mint_quote_status(quote_id).await?;
match status.state.as_str() {
"PAID" | "ISSUED" => return Ok(()),
_ => tokio::time::sleep(std::time::Duration::from_secs(SWAP_CLAIM_POLL_SECS)).await,
}
}
anyhow::bail!(
"target mint invoice for quote {} did not settle within {}s",
quote_id,
SWAP_CLAIM_TIMEOUT_SECS
)
}
/// Create a cashuA token string to send to a peer, drawing from the home mint.
pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
let mint_url = load_wallet(data_dir).await?.mint_url;
send_token_at(data_dir, &mint_url, amount_sats).await
}
/// Create a cashuA token denominated in a specific mint's tokens.
///
/// Used by the payer-side cross-mint flow: after `swap_between_mints` lands value
/// on the seeder's accepted mint, we send a token from *that* mint so the seeder
/// only ever receives its own mint's proofs (see plan §2a, payer-side swap).
pub async fn send_token_at(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string();
// Select proofs covering the amount
let (indices, overpayment) = wallet
@@ -422,6 +656,336 @@ pub async fn send_token(data_dir: &Path, amount_sats: u64) -> Result<String> {
Ok(token_str)
}
// ── Payer-side payment builder (plan §2a step 2) ───────────────────────────
//
// Given a seeder's advertised `accepted_mints`, pick the cheapest way to pay:
// spend tokens we already hold on an accepted mint (no fee), else swap value
// into a *trusted* accepted mint and pay from there. If neither is possible
// within budget, decline so the caller falls back to free origin.
/// How a payment of a given amount can be satisfied across our mints.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaymentPlan {
/// We already hold enough on this accepted mint — pay directly, no swap fee.
Direct { mint_url: String },
/// Swap value from `from_mint` into the trusted accepted `to_mint`, then pay.
Swap { from_mint: String, to_mint: String },
/// No single mint can cover the amount (caller should use free origin).
Insufficient,
}
/// Decide how to pay `amount` sats to a seeder, given what we hold and which
/// mints the seeder accepts.
///
/// - `holdings`: our spendable `(mint_url, balance)` pairs (verbatim URLs).
/// - `accepted`: the seeder's `(mint_url, trusted)` pairs, where `trusted`
/// means the mint is on our swap-into allow-list (`is_mint_trusted`).
/// - Direct beats Swap (no fee). A Direct target needs no trust (we already
/// hold those tokens); a Swap target must be trusted. The home mint is
/// preferred as a tie-break for both legs (lowest friction).
///
/// Pure and synchronous so it can be unit-tested without a live mint. It does
/// not know swap fees; `swap_between_mints` enforces the fee cap and bails (→
/// origin fallback) if the chosen source can't cover amount + fee.
fn plan_payment(
holdings: &[(String, u64)],
accepted: &[(String, bool)],
amount: u64,
) -> PaymentPlan {
let norm = |s: &str| s.trim_end_matches('/').to_string();
let home = norm(&default_mint_url());
let held = |mint: &str| -> u64 {
holdings
.iter()
.filter(|(m, _)| norm(m) == norm(mint))
.map(|(_, b)| *b)
.sum()
};
// 1. Direct: any accepted mint we already hold enough on. Prefer home.
let mut direct: Vec<&(String, bool)> =
accepted.iter().filter(|(m, _)| held(m) >= amount).collect();
direct.sort_by_key(|(m, _)| norm(m) != home); // home (false) sorts first
if let Some((mint, _)) = direct.first() {
return PaymentPlan::Direct {
mint_url: mint.clone(),
};
}
// 2. Swap: a trusted accepted target + a source we hold that covers `amount`.
let mut targets: Vec<&(String, bool)> =
accepted.iter().filter(|(_, trusted)| *trusted).collect();
targets.sort_by_key(|(m, _)| norm(m) != home);
if let Some((to_mint, _)) = targets.first() {
// Largest source we hold that isn't the target itself.
let from = holdings
.iter()
.filter(|(m, b)| norm(m) != norm(to_mint) && *b >= amount)
.max_by_key(|(_, b)| *b);
if let Some((from_mint, _)) = from {
return PaymentPlan::Swap {
from_mint: from_mint.clone(),
to_mint: to_mint.clone(),
};
}
}
PaymentPlan::Insufficient
}
/// Build a cashuA token to pay a seeder `amount_sats`, denominated in one of the
/// seeder's `accepted_mints`. Auto-swaps across mints (up to `max_fee_sats`) when
/// we don't already hold the right mint. Returns the token string ready to send.
///
/// Errors (caller should fall back to free origin) when no accepted mint is
/// reachable within balance, no trusted swap target exists, or the swap exceeds
/// the fee cap.
pub async fn build_payment_token(
data_dir: &Path,
accepted_mints: &[String],
amount_sats: u64,
max_fee_sats: u64,
) -> Result<String> {
if amount_sats == 0 {
anyhow::bail!("payment amount must be greater than zero");
}
if accepted_mints.is_empty() {
anyhow::bail!("seeder advertised no accepted mints");
}
// Annotate each accepted mint with whether we trust swapping into it.
let mut accepted: Vec<(String, bool)> = Vec::with_capacity(accepted_mints.len());
for m in accepted_mints {
let trusted = is_mint_trusted(data_dir, m).await?;
accepted.push((m.clone(), trusted));
}
// Prefer swap targets with a liquidity track record. plan_payment's stable
// sort keeps the home mint first; within the rest, this orders by how
// reliably we've reached each target before (best routes first).
let liq = load_swap_liquidity(data_dir).await;
accepted.sort_by_key(|(m, _)| std::cmp::Reverse(target_liquidity_score(&liq, m)));
let holdings = load_wallet(data_dir).await?.spendable_by_mint();
match plan_payment(&holdings, &accepted, amount_sats) {
PaymentPlan::Direct { mint_url } => {
debug!(
"Payment plan: direct from {} for {} sats",
mint_url, amount_sats
);
send_token_at(data_dir, &mint_url, amount_sats).await
}
PaymentPlan::Swap { from_mint, to_mint } => {
debug!(
"Payment plan: swap {}→{} then pay {} sats (fee cap {})",
from_mint, to_mint, amount_sats, max_fee_sats
);
swap_between_mints(data_dir, &from_mint, &to_mint, amount_sats, max_fee_sats).await?;
send_token_at(data_dir, &to_mint, amount_sats).await
}
PaymentPlan::Insufficient => anyhow::bail!(
"cannot pay {} sats: no accepted mint covers it within balance/trust",
amount_sats
),
}
}
// ── F2 step 3 — hardening: idempotent swap resume + liquidity cache ─────────
const PENDING_SWAPS_FILE: &str = "wallet/pending_swaps.json";
const SWAP_LIQUIDITY_FILE: &str = "wallet/swap_liquidity.json";
/// An in-flight cross-mint swap, journaled the moment the source proofs are
/// melted (paid) so a crash before the target claim can be resumed instead of
/// silently losing the value. Removed once the target tokens are claimed.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PendingSwap {
pub from_mint: String,
pub to_mint: String,
pub amount_sats: u64,
/// Source-mint melt quote id (the leg already paid).
pub melt_quote_id: String,
/// Target-mint mint quote id (the leg to claim).
pub mint_quote_id: String,
pub created_at: String,
}
async fn load_pending_swaps(data_dir: &Path) -> Result<Vec<PendingSwap>> {
let path = data_dir.join(PENDING_SWAPS_FILE);
if !path.exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(&path).await?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
async fn save_pending_swaps(data_dir: &Path, swaps: &[PendingSwap]) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir).await?;
let path = data_dir.join(PENDING_SWAPS_FILE);
fs::write(&path, serde_json::to_string_pretty(swaps)?).await?;
Ok(())
}
async fn add_pending_swap(data_dir: &Path, swap: PendingSwap) -> Result<()> {
let mut all = load_pending_swaps(data_dir).await?;
all.push(swap);
save_pending_swaps(data_dir, &all).await
}
async fn remove_pending_swap(data_dir: &Path, mint_quote_id: &str) -> Result<()> {
let mut all = load_pending_swaps(data_dir).await?;
all.retain(|s| s.mint_quote_id != mint_quote_id);
save_pending_swaps(data_dir, &all).await
}
/// Resume any swaps that were interrupted between paying the source mint and
/// claiming the target tokens. For each pending swap, ask the target mint about
/// the mint quote:
/// - `PAID` → claim now (the value was paid but never claimed). Reclaimed.
/// - `ISSUED` → already claimed on a prior run; just drop the journal entry.
/// - else → leave it (the invoice hasn't settled yet; retry next time).
///
/// Returns the total sats reclaimed. Safe to call repeatedly (idempotent): a
/// quote is only minted once, and `ISSUED` quotes are never re-claimed.
pub async fn resume_pending_swaps(data_dir: &Path) -> Result<u64> {
let pending = load_pending_swaps(data_dir).await?;
let mut reclaimed = 0u64;
for swap in pending {
let to = match MintClient::new(&swap.to_mint) {
Ok(c) => c,
Err(e) => {
warn!(
"resume_pending_swaps: bad target mint {}: {}",
swap.to_mint, e
);
continue;
}
};
let status = match to.mint_quote_status(&swap.mint_quote_id).await {
Ok(s) => s,
Err(e) => {
debug!(
"resume_pending_swaps: status check failed for {}: {} — leaving pending",
swap.mint_quote_id, e
);
continue;
}
};
match status.state.as_str() {
"PAID" => match to.mint_tokens(&swap.mint_quote_id, swap.amount_sats).await {
Ok(result) => {
let minted: u64 = result.proofs.iter().map(|p| p.amount).sum();
let mut wallet = load_wallet(data_dir).await?;
wallet.add_proofs(&swap.to_mint, result.proofs);
wallet.record_tx(
TransactionType::Mint,
minted,
&format!(
"Resumed cross-mint swap {}→{}: claimed {} sats",
swap.from_mint, swap.to_mint, minted
),
&swap.to_mint,
&swap.from_mint,
);
save_wallet(data_dir, &wallet).await?;
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
record_swap_success(data_dir, &swap.from_mint, &swap.to_mint).await;
reclaimed += minted;
info!(
"Resumed interrupted swap {}→{}: reclaimed {} sats",
swap.from_mint, swap.to_mint, minted
);
}
Err(e) => warn!(
"resume_pending_swaps: claim failed for {}: {}",
swap.mint_quote_id, e
),
},
"ISSUED" => {
// Already claimed on a previous run — drop the journal entry.
remove_pending_swap(data_dir, &swap.mint_quote_id).await?;
}
other => debug!(
"resume_pending_swaps: quote {} state {} — leaving pending",
swap.mint_quote_id, other
),
}
}
Ok(reclaimed)
}
/// Success/failure counts for a single (from → to) swap route.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct RouteStat {
successes: u64,
failures: u64,
}
/// Per-mint-pair liquidity cache: which swap routes have actually worked, so the
/// payer can prefer routes with a track record over ones that keep failing.
#[derive(Debug, Default, Serialize, Deserialize)]
struct SwapLiquidity {
/// Keyed by `"<from>|<to>"` (normalized URLs).
routes: std::collections::BTreeMap<String, RouteStat>,
}
fn route_key(from_mint: &str, to_mint: &str) -> String {
format!(
"{}|{}",
from_mint.trim_end_matches('/'),
to_mint.trim_end_matches('/')
)
}
async fn load_swap_liquidity(data_dir: &Path) -> SwapLiquidity {
let path = data_dir.join(SWAP_LIQUIDITY_FILE);
match fs::read_to_string(&path).await {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => SwapLiquidity::default(),
}
}
async fn save_swap_liquidity(data_dir: &Path, liq: &SwapLiquidity) {
let dir = data_dir.join("wallet");
let _ = fs::create_dir_all(&dir).await;
if let Ok(content) = serde_json::to_string_pretty(liq) {
let _ = fs::write(data_dir.join(SWAP_LIQUIDITY_FILE), content).await;
}
}
/// Record that a swap route succeeded (best-effort; never fails the caller).
async fn record_swap_success(data_dir: &Path, from_mint: &str, to_mint: &str) {
let mut liq = load_swap_liquidity(data_dir).await;
liq.routes
.entry(route_key(from_mint, to_mint))
.or_default()
.successes += 1;
save_swap_liquidity(data_dir, &liq).await;
}
/// Record that a swap route failed (best-effort; never fails the caller).
async fn record_swap_failure(data_dir: &Path, from_mint: &str, to_mint: &str) {
let mut liq = load_swap_liquidity(data_dir).await;
liq.routes
.entry(route_key(from_mint, to_mint))
.or_default()
.failures += 1;
save_swap_liquidity(data_dir, &liq).await;
}
/// Liquidity score for reaching `to_mint` from any source: net successes across
/// all routes ending at this target. Higher = a more reliable destination.
fn target_liquidity_score(liq: &SwapLiquidity, to_mint: &str) -> i64 {
let suffix = format!("|{}", to_mint.trim_end_matches('/'));
liq.routes
.iter()
.filter(|(k, _)| k.ends_with(&suffix))
.map(|(_, s)| s.successes as i64 - s.failures as i64)
.sum()
}
/// Receive a cashuA token from a peer — swaps proofs at the mint for fresh ones.
pub async fn receive_token(data_dir: &Path, token_str: &str) -> Result<u64> {
// Handle legacy format for backwards compatibility
@@ -936,4 +1500,323 @@ mod tests {
fn test_default_mint_url() {
assert_eq!(default_mint_url(), "http://127.0.0.1:8175");
}
#[test]
fn test_swap_fee() {
assert_eq!(swap_fee(105, 100), 5);
// Defensive: never underflow if mint quotes oddly.
assert_eq!(swap_fee(100, 100), 0);
assert_eq!(swap_fee(90, 100), 0);
}
#[tokio::test]
async fn test_is_mint_trusted_home_always() {
let tmp = TempDir::new().unwrap();
// Home mint is trusted even with no accepted-mints file.
assert!(is_mint_trusted(tmp.path(), &default_mint_url())
.await
.unwrap());
// Trailing slash on the home URL still matches.
assert!(is_mint_trusted(tmp.path(), "http://127.0.0.1:8175/")
.await
.unwrap());
}
#[tokio::test]
async fn test_is_mint_trusted_respects_accepted_list() {
let tmp = TempDir::new().unwrap();
save_accepted_mints(
tmp.path(),
&AcceptedMints {
mints: vec![default_mint_url(), "https://mint.example.com".into()],
},
)
.await
.unwrap();
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com")
.await
.unwrap());
// Normalized comparison ignores a trailing slash.
assert!(is_mint_trusted(tmp.path(), "https://mint.example.com/")
.await
.unwrap());
// A mint not on the list is not trusted.
assert!(!is_mint_trusted(tmp.path(), "https://evil.example.com")
.await
.unwrap());
}
#[tokio::test]
async fn test_swap_between_mints_rejects_identical() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"http://127.0.0.1:8175/",
100,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("identical"));
}
#[tokio::test]
async fn test_swap_between_mints_rejects_untrusted_target() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"https://untrusted.example.com",
100,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("trusted"));
}
#[tokio::test]
async fn test_swap_between_mints_rejects_zero_amount() {
let tmp = TempDir::new().unwrap();
let err = swap_between_mints(
tmp.path(),
&default_mint_url(),
"https://mint.example.com",
0,
10,
)
.await
.unwrap_err();
assert!(err.to_string().contains("greater than zero"));
}
#[test]
fn test_spendable_by_mint_groups_and_excludes() {
let mut wallet = WalletState::default();
wallet.add_proofs(
"http://mint-a",
vec![
Proof {
amount: 10,
id: "k".into(),
secret: "s1".into(),
c: "c".into(),
},
Proof {
amount: 5,
id: "k".into(),
secret: "s2".into(),
c: "c".into(),
},
],
);
wallet.add_proofs(
"http://mint-b",
vec![Proof {
amount: 7,
id: "k".into(),
secret: "s3".into(),
c: "c".into(),
}],
);
wallet.proofs[1].spent = true; // exclude the 5 on mint-a
let by_mint = wallet.spendable_by_mint();
assert_eq!(
by_mint,
vec![
("http://mint-a".to_string(), 10),
("http://mint-b".to_string(), 7)
]
);
}
#[test]
fn test_plan_payment_direct_prefers_home() {
let home = default_mint_url();
let holdings = vec![(home.clone(), 100), ("https://other".into(), 100)];
// Both accepted; home should win the tie-break.
let accepted = vec![("https://other".into(), true), (home.clone(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct { mint_url: home }
);
}
#[test]
fn test_plan_payment_direct_only_accepted_mint() {
let holdings = vec![("https://a".into(), 100), ("https://b".into(), 100)];
// We hold both, but the seeder only accepts b.
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct {
mint_url: "https://b".into()
}
);
}
#[test]
fn test_plan_payment_swaps_into_trusted_target() {
// We hold value on A; seeder accepts only B (trusted) which we don't hold.
let holdings = vec![("https://a".into(), 100)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Swap {
from_mint: "https://a".into(),
to_mint: "https://b".into()
}
);
}
#[test]
fn test_plan_payment_refuses_untrusted_swap_target() {
// Seeder accepts only B, but B is not trusted → no swap, insufficient.
let holdings = vec![("https://a".into(), 100)];
let accepted = vec![("https://b".into(), false)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Insufficient
);
}
#[test]
fn test_plan_payment_insufficient_when_no_single_source_covers() {
// Total 60 across two mints, but neither alone covers 50+ for a swap and
// we hold neither accepted mint directly.
let holdings = vec![("https://a".into(), 30), ("https://c".into(), 30)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Insufficient
);
}
#[test]
fn test_plan_payment_direct_beats_swap() {
// We hold the accepted mint directly AND could swap — Direct must win.
let home = default_mint_url();
let holdings = vec![("https://b".into(), 100), (home.clone(), 100)];
let accepted = vec![("https://b".into(), true)];
assert_eq!(
plan_payment(&holdings, &accepted, 50),
PaymentPlan::Direct {
mint_url: "https://b".into()
}
);
}
#[tokio::test]
async fn test_build_payment_token_rejects_empty_mints() {
let tmp = TempDir::new().unwrap();
let err = build_payment_token(tmp.path(), &[], 100, 10)
.await
.unwrap_err();
assert!(err.to_string().contains("no accepted mints"));
}
#[tokio::test]
async fn test_build_payment_token_insufficient_falls_through() {
let tmp = TempDir::new().unwrap();
// Empty wallet, untrusted seeder mint → cannot pay (caller uses origin).
let err = build_payment_token(tmp.path(), &["https://seeder.example.com".into()], 100, 10)
.await
.unwrap_err();
assert!(err.to_string().contains("cannot pay"));
}
#[test]
fn test_route_key_normalizes_trailing_slash() {
assert_eq!(route_key("https://a/", "https://b/"), "https://a|https://b");
assert_eq!(route_key("https://a", "https://b"), "https://a|https://b");
}
#[tokio::test]
async fn test_pending_swaps_roundtrip_and_remove() {
let tmp = TempDir::new().unwrap();
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
add_pending_swap(
tmp.path(),
PendingSwap {
from_mint: "https://a".into(),
to_mint: "https://b".into(),
amount_sats: 100,
melt_quote_id: "melt-1".into(),
mint_quote_id: "mint-1".into(),
created_at: "2026-06-17T00:00:00Z".into(),
},
)
.await
.unwrap();
let loaded = load_pending_swaps(tmp.path()).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].mint_quote_id, "mint-1");
remove_pending_swap(tmp.path(), "mint-1").await.unwrap();
assert!(load_pending_swaps(tmp.path()).await.unwrap().is_empty());
}
#[tokio::test]
async fn test_resume_pending_swaps_empty_is_noop() {
let tmp = TempDir::new().unwrap();
assert_eq!(resume_pending_swaps(tmp.path()).await.unwrap(), 0);
}
#[tokio::test]
async fn test_liquidity_cache_records_and_scores() {
let tmp = TempDir::new().unwrap();
// Two routes into B succeed; one into C fails.
record_swap_success(tmp.path(), "https://a", "https://b").await;
record_swap_success(tmp.path(), "https://x", "https://b").await;
record_swap_failure(tmp.path(), "https://a", "https://c").await;
let liq = load_swap_liquidity(tmp.path()).await;
// B reached successfully from two sources → net +2; trailing slash tolerant.
assert_eq!(target_liquidity_score(&liq, "https://b/"), 2);
// C only failed → net -1.
assert_eq!(target_liquidity_score(&liq, "https://c"), -1);
// Unknown target → neutral 0.
assert_eq!(target_liquidity_score(&liq, "https://unknown"), 0);
}
#[tokio::test]
async fn test_build_payment_token_prefers_liquid_target() {
let tmp = TempDir::new().unwrap();
// Trust two non-home mints; hold value on a source mint for both.
save_accepted_mints(
tmp.path(),
&AcceptedMints {
mints: vec![
default_mint_url(),
"https://liquid".into(),
"https://dry".into(),
],
},
)
.await
.unwrap();
// Give "https://liquid" a track record so it should be preferred.
record_swap_success(tmp.path(), "https://src", "https://liquid").await;
// Seeder accepts both non-home mints; we only hold "https://src".
let accepted = vec![
("https://dry".into(), true),
("https://liquid".into(), true),
];
let holdings = vec![("https://src".to_string(), 1000u64)];
// Mirror build_payment_token's ordering step, then plan.
let liq = load_swap_liquidity(tmp.path()).await;
let mut ordered = accepted.clone();
ordered.sort_by_key(|(m, _): &(String, bool)| {
std::cmp::Reverse(target_liquidity_score(&liq, m))
});
match plan_payment(&holdings, &ordered, 100) {
PaymentPlan::Swap { to_mint, .. } => assert_eq!(to_mint, "https://liquid"),
other => panic!("expected swap into liquid target, got {:?}", other),
}
}
}
@@ -0,0 +1,289 @@
//! Thin HTTP bridge to the `fedimint-clientd` sidecar container.
//!
//! Keeps the heavy, fast-moving Fedimint client SDK OUT of this binary: the
//! `fedimint-clientd` daemon (in `apps/fedimint-clientd`) holds the federation
//! clients and ecash notes; we just speak its REST API (`/v2/*`, Bearer auth),
//! mirroring how [`super::mint_client::MintClient`] speaks the Cashu NUT API.
//!
//! See `docs/dual-ecash-design.md`. Endpoint/JSON shapes target fedimint-clientd
//! v0.3.x and must be pinned to the vendored image tag.
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::Path;
use tokio::fs;
use tracing::debug;
const CLIENTD_TIMEOUT_SECS: u64 = 15;
const CLIENTD_HEAVY_TIMEOUT_SECS: u64 = 60;
/// Default host port the `fedimint-clientd` container is mapped to (its own
/// default 8080 collides with LND REST, so the manifest maps it to 8178).
const DEFAULT_CLIENTD_URL: &str = "http://127.0.0.1:8178";
/// Federation joined out-of-the-box on every node. The fmcd container also
/// auto-joins this at boot (`FMCD_INVITE_CODE` in the manifest); keep in sync.
///
/// The preferred default federation (guardian on .116, iroh transport).
/// Validated: fmcd 0.8.2 joins it (federation_id 2debd071…73b76884). iroh does
/// NAT traversal, so it's reachable fleet-wide — the right fleet default.
/// CAVEAT: iroh is experimental and the connection can be flaky (esp. NAT
/// hairpin when fmcd runs on .116 itself reaching .116's own WAN IP); validate
/// reliability from a separate node. ensure_default_federation is best-effort.
/// See docs/dual-ecash-design.md.
pub const DEFAULT_FEDERATION_INVITE: &str = "fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp";
/// One joined federation, persisted locally so the list survives clientd being
/// temporarily down. Balances are always read live from clientd.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JoinedFederation {
pub federation_id: String,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct FederationRegistry {
pub federations: Vec<JoinedFederation>,
}
const REGISTRY_FILE: &str = "wallet/fedimint_federations.json";
pub async fn load_registry(data_dir: &Path) -> Result<FederationRegistry> {
let path = data_dir.join(REGISTRY_FILE);
if !path.exists() {
return Ok(FederationRegistry::default());
}
let content = fs::read_to_string(&path)
.await
.context("Failed to read fedimint federation registry")?;
Ok(serde_json::from_str(&content).unwrap_or_default())
}
pub async fn save_registry(data_dir: &Path, reg: &FederationRegistry) -> Result<()> {
let dir = data_dir.join("wallet");
fs::create_dir_all(&dir)
.await
.context("Failed to create wallet dir")?;
let content = serde_json::to_string_pretty(reg).context("Failed to serialize registry")?;
fs::write(data_dir.join(REGISTRY_FILE), content)
.await
.context("Failed to write fedimint federation registry")?;
Ok(())
}
/// Idempotently ensure the node has joined the default federation and that it
/// is tracked in the local registry. Best-effort: silently no-ops if clientd
/// isn't installed/running yet. Joining is idempotent on the clientd side.
pub async fn ensure_default_federation(data_dir: &Path) -> Result<()> {
let client = match FedimintClient::from_node(data_dir).await {
Ok(c) => c,
Err(_) => return Ok(()), // clientd not configured yet
};
let federation_id = match client.join(DEFAULT_FEDERATION_INVITE).await {
Ok(id) => id,
Err(e) => {
debug!("default federation autojoin skipped: {e}");
return Ok(());
}
};
let mut reg = load_registry(data_dir).await?;
if !reg
.federations
.iter()
.any(|f| f.federation_id == federation_id)
{
reg.federations.push(JoinedFederation {
federation_id,
name: None,
});
save_registry(data_dir, &reg).await?;
}
Ok(())
}
/// HTTP client for a `fedimint-clientd` instance.
pub struct FedimintClient {
base_url: String,
password: String,
client: reqwest::Client,
}
impl FedimintClient {
pub fn new(base_url: &str, password: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(CLIENTD_HEAVY_TIMEOUT_SECS))
.build()
.context("Failed to build HTTP client for fedimint-clientd")?;
Ok(Self::with_client(base_url, password, client))
}
pub fn with_client(base_url: &str, password: &str, client: reqwest::Client) -> Self {
Self {
base_url: base_url.trim_end_matches('/').to_string(),
password: password.to_string(),
client,
}
}
/// Resolve URL + password from env / node secret, with sane defaults.
/// URL: `FEDIMINT_CLIENTD_URL` else the default mapped port.
/// Password: `FEDIMINT_CLIENTD_PASSWORD` else `<data_dir>/fedimint-clientd/password`.
pub async fn from_node(data_dir: &Path) -> Result<Self> {
let base_url =
std::env::var("FMCD_URL").unwrap_or_else(|_| DEFAULT_CLIENTD_URL.to_string());
let password = match std::env::var("FMCD_PASSWORD") {
Ok(p) if !p.is_empty() => p,
_ => {
let secret = data_dir.join("fmcd").join("password");
fs::read_to_string(&secret)
.await
.map(|s| s.trim().to_string())
.context(
"Fedimint client not configured (no FMCD_PASSWORD and no \
fmcd/password secret). Install the Fedimint client app.",
)?
}
};
Self::new(&base_url, &password)
}
fn auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
// fmcd uses HTTP Basic auth with a fixed username `fmcd`.
req.basic_auth("fmcd", Some(&self.password))
}
async fn post(&self, path: &str, body: serde_json::Value) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.post(&url))
.json(&body)
.send()
.await
.with_context(|| format!("fedimint-clientd POST {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn get(&self, path: &str) -> Result<serde_json::Value> {
let url = format!("{}{}", self.base_url, path);
let resp = self
.auth(self.client.get(&url))
.timeout(std::time::Duration::from_secs(CLIENTD_TIMEOUT_SECS))
.send()
.await
.with_context(|| format!("fedimint-clientd GET {path} failed (is it running?)"))?;
Self::parse(resp, path).await
}
async fn parse(resp: reqwest::Response, path: &str) -> Result<serde_json::Value> {
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("fedimint-clientd {path} returned {status}: {text}");
}
if text.is_empty() {
return Ok(serde_json::json!({}));
}
serde_json::from_str(&text)
.with_context(|| format!("fedimint-clientd {path} returned non-JSON: {text}"))
}
/// `GET /v2/admin/info` — per-federation holdings keyed by federationId.
pub async fn info(&self) -> Result<serde_json::Value> {
self.get("/v2/admin/info").await
}
/// `POST /v2/admin/join` — join a federation by invite code; returns its federationId.
pub async fn join(&self, invite_code: &str) -> Result<String> {
let res = self
.post(
"/v2/admin/join",
serde_json::json!({ "inviteCode": invite_code, "useManualSecret": false }),
)
.await?;
let id = res
.get("thisFederationId")
.or_else(|| res.get("federationId"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
match id {
Some(id) => {
debug!("joined fedimint federation {id}");
Ok(id)
}
// Older/newer clientd may return the full info map; fall back to info().
None => self.latest_federation_id().await,
}
}
/// Total balance across all joined federations, in sats.
pub async fn total_balance_sats(&self) -> Result<u64> {
let info = self.info().await?;
Ok(sum_msat(&info) / 1000)
}
/// Balance of one federation in sats (0 if unknown).
pub async fn federation_balance_sats(&self, federation_id: &str) -> Result<u64> {
let info = self.info().await?;
let msat = info
.get(federation_id)
.and_then(federation_msat)
.unwrap_or(0);
Ok(msat / 1000)
}
/// `POST /v2/mint/spend` — prepare notes to send (ecash), in msat. Returns serialized notes.
pub async fn spend(&self, federation_id: &str, amount_sats: u64) -> Result<String> {
let res = self
.post(
"/v2/mint/spend",
serde_json::json!({
"federationId": federation_id,
"amountMsat": amount_sats * 1000,
"allowOverpay": true,
"timeout": 3600,
"includeInvite": false,
}),
)
.await?;
res.get("notes")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!("fedimint spend: no notes in response"))
}
/// `POST /v2/mint/reissue` — redeem received notes; returns reissued sats.
pub async fn reissue(&self, federation_id: &str, notes: &str) -> Result<u64> {
let res = self
.post(
"/v2/mint/reissue",
serde_json::json!({ "federationId": federation_id, "notes": notes }),
)
.await?;
let msat = res
.get("amountMsat")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow::anyhow!("fedimint reissue: no amountMsat in response"))?;
Ok(msat / 1000)
}
async fn latest_federation_id(&self) -> Result<String> {
let info = self.info().await?;
info.as_object()
.and_then(|m| m.keys().next_back().cloned())
.ok_or_else(|| anyhow::anyhow!("joined federation but clientd reported none"))
}
}
fn federation_msat(entry: &serde_json::Value) -> Option<u64> {
entry
.get("totalAmountMsat")
.or_else(|| entry.get("totalMsat"))
.and_then(|v| v.as_u64())
}
fn sum_msat(info: &serde_json::Value) -> u64 {
info.as_object()
.map(|m| m.values().filter_map(federation_msat).sum())
.unwrap_or(0)
}
+1
View File
@@ -4,5 +4,6 @@
pub mod bdhke;
pub mod cashu;
pub mod ecash;
pub mod fedimint_client;
pub mod mint_client;
pub mod profits;
+15 -2
View File
@@ -858,6 +858,11 @@ pub struct HostFacts {
/// `/` if the data partition is not yet mounted). Drives the
/// prune-vs-full-node decision in bitcoin-knots custom_args.
pub disk_gb: u64,
/// Container name of the running Bitcoin node — `bitcoin-knots` or
/// `bitcoin-core` — so dependents (mempool's CORE_RPC_HOST) reach the
/// right host. Both are reachable on archy-net by their container name;
/// only the name differs. Falls back to `bitcoin-knots` when undetected.
pub bitcoin_host: String,
}
impl HostFacts {
@@ -868,13 +873,14 @@ impl HostFacts {
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-knots".to_string(),
}
}
}
/// Supported placeholder names in `DerivedEnv::template`. Keep in sync
/// with `HostFacts`. Centralized so validation and rendering agree.
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB"];
const DERIVED_PLACEHOLDERS: &[&str] = &["HOST_IP", "HOST_MDNS", "DISK_GB", "BITCOIN_HOST"];
fn validate_derived_template(key: &str, template: &str) -> Result<(), ManifestError> {
// Walk `{{NAME}}` occurrences and ensure each NAME is recognized.
@@ -957,7 +963,8 @@ impl ContainerConfig {
.template
.replace("{{HOST_IP}}", &facts.host_ip)
.replace("{{HOST_MDNS}}", &facts.host_mdns)
.replace("{{DISK_GB}}", &facts.disk_gb.to_string());
.replace("{{DISK_GB}}", &facts.disk_gb.to_string())
.replace("{{BITCOIN_HOST}}", &facts.bitcoin_host);
format!("{}={}", e.key, value)
})
.collect()
@@ -1463,6 +1470,10 @@ app:
key: "INFO".to_string(),
template: "{{HOST_IP}}-{{DISK_GB}}".to_string(),
},
DerivedEnv {
key: "CORE_RPC_HOST".to_string(),
template: "{{BITCOIN_HOST}}".to_string(),
},
],
secret_env: vec![],
data_uid: None,
@@ -1471,11 +1482,13 @@ app:
host_ip: "192.168.1.116".to_string(),
host_mdns: "archi-thinkpad.local".to_string(),
disk_gb: 2000,
bitcoin_host: "bitcoin-core".to_string(),
};
let out = c.resolve_derived_env(&facts);
assert_eq!(out[0], "FM_API_URL=ws://archi-thinkpad.local:8174");
assert_eq!(out[1], "INFO=192.168.1.116-2000");
assert_eq!(out[2], "CORE_RPC_HOST=bitcoin-core");
}
struct MapSecretsProvider {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 987 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 976 KiB

After

Width:  |  Height:  |  Size: 869 KiB

+15 -1
View File
@@ -5,10 +5,24 @@ server {
proxy_intercept_errors on;
error_page 500 502 503 504 = @wait_page;
# Serve our own wait-page/icon assets locally first, but fall back to the
# real fedimint guardian (:8177) for ITS bundled /assets/*.css|js. Without
# the fallback, the guardian UI's stylesheets resolve to this local root,
# 404, and the app renders unstyled (B13 fixed the local icon; this fixes
# the guardian UI's own CSS).
location /assets/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=3600" always;
try_files $uri =404;
try_files $uri @guardian_assets;
}
location @guardian_assets {
proxy_pass http://127.0.0.1:8177;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
+19
View File
@@ -0,0 +1,19 @@
FROM git.tx1138.com/lfg2025/nginx:1.27.4-alpine
# Static site content.
COPY index.html /usr/share/nginx/html/
#
# FIPS UI talks only to the archipelago RPC on 127.0.0.1:5678, using the
# browser's own archipelago session — there is NO per-node secret to
# substitute, so (unlike bitcoin-ui) the nginx config is baked straight
# into the image rather than bind-mounted/rendered at container-create.
COPY nginx.conf /etc/nginx/conf.d/default.conf
#
# Run nginx as root to avoid chown failures in rootless Podman user
# namespaces. The rest of the nginx image is unchanged.
RUN sed -i 's/^user nginx;/user root;/' /etc/nginx/nginx.conf && \
mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp \
/var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp \
/var/cache/nginx/scgi_temp
EXPOSE 8336
ENTRYPOINT []
CMD ["nginx", "-g", "daemon off;"]

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