From 705e2436ba71c31978d6d69231615f0dcbd6e533 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 17 Jun 2026 19:22:02 -0400 Subject: [PATCH] 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) --- .../archipelago/app/network/InputWebSocket.kt | 21 ++ .../app/ui/screens/RemoteInputScreen.kt | 15 ++ docs/REMAINING-ISSUES-PLAN.md | 224 ++++++++++++++++++ docs/dual-ecash-design.md | 135 +++++++++++ docs/meshroller-integration-design.md | 169 +++++++++++++ .../_archived/build-auto-installer-iso.sh | 1 + scripts/first-boot-containers.sh | 53 ++++- scripts/image-versions.sh | 5 + 8 files changed, 622 insertions(+), 1 deletion(-) create mode 100644 docs/REMAINING-ISSUES-PLAN.md create mode 100644 docs/dual-ecash-design.md create mode 100644 docs/meshroller-integration-design.md diff --git a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt index 40703d46..671a7af7 100644 --- a/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt +++ b/Android/app/src/main/java/com/archipelago/app/network/InputWebSocket.kt @@ -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 = _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() diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt index 97052dc6..4e743eb7 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/RemoteInputScreen.kt @@ -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 diff --git a/docs/REMAINING-ISSUES-PLAN.md b/docs/REMAINING-ISSUES-PLAN.md new file mode 100644 index 00000000..fe801fea --- /dev/null +++ b/docs/REMAINING-ISSUES-PLAN.md @@ -0,0 +1,224 @@ +# Remaining issues — implementation plans + +Written 2026-06-17. Covers the open Gitea issues not closeable in the single-box +dev env. Each plan lists the files to touch, the approach, and how to verify +(most need .116 + .198, a companion phone, or funded wallets). Issues #3 (VPN) +and #5 (OpenWRT/TollGate) are intentionally out of scope per the user. + +Status of the rest at time of writing: +- **#31** group chat over Tor — dedup-by-`msg_id` fix already shipped (open only + for a 2-node Tor confirmation). See its Gitea comment. +- **#43** install on .70 — blocked: .70 unreachable. Plan below is a code-side + hardening that doesn't depend on .70's logs. + +--- + +## #46 — Pay for peer files (local wallet OR invoice+QR to seller) + +> **Status (2026-06-17): Phase 1 DONE & compiles** (LN invoice + QR + release). +> Seller: `content_invoice.rs` entitlement store, `GET /content/{id}/invoice` +> + `/invoice-status/{hash}`, invoice-paid path in `serve_content` +> (`X-Invoice-Hash`), LND `create_invoice`/`invoice_is_settled`. Buyer: +> `content.request-invoice` / `.invoice-status` / `.download-peer-invoice` + +> `PeerFiles.vue` picker modal + QR + poll. Phases 2 (on-chain) and 3 (local +> LN/on-chain methods) remain; needs live funded-wallet verify. Issue left open. + +**Goal.** At the paid-download step in Cloud → peer files, let the buyer choose +how to pay: (a) their local wallet (ecash today; LN/on-chain later), or (b) get +an invoice with a QR drawn on the **selling** node's wallet, pay from any +external wallet, and have the file release on confirmation. + +**What exists already** +- Buyer ecash auto-pay: `content.download-peer-paid` (mints ecash, downloads + atomically) — wired in `neode-ui/src/views/PeerFiles.vue` `downloadFile()`. +- Payer-side builder: `streaming.prepare-payment` RPC + `wallet/ecash.rs` + (`build_payment_token`, cross-mint), `swarm/payment.rs`. +- Free streaming download: `/api/peer-content/:onion/:id` (Range-capable). +- LND invoice RPC: `lnd.createinvoice`; ecash balance: `wallet.ecash-balance`. + +**Backend work** +1. **Seller-side invoice RPC** (new), e.g. `content.request-invoice` + `{ onion, content_id }` → asks the *selling* node (over the existing + `/archipelago/...` peer transport, same path machinery as + `content.download-peer-paid`) to produce a payment request for `price_sats`: + - LN: `lnd.createinvoice` on the seller, return `bolt11` + `payment_hash`. + - on-chain: `lnd.newaddress` on the seller, return `address` + `amount`. + - Seller records a pending entitlement keyed by `payment_hash`/address → + content_id → buyer. +2. **Payment confirmation + release**: seller polls its own LND + (`lnd.lookup-invoice` / address watch); on settle, marks the entitlement + paid. Buyer side polls `content.invoice-status { payment_hash }` → when paid, + downloads via the existing `/api/peer-content` (gate now passes because the + entitlement is satisfied). Reuse the streaming gate in `streaming/` — add an + "invoice-paid" path alongside the ecash-token path. +3. Keep `content.download-peer-paid` (local-ecash) as the (a) fast path. + +**Frontend work** (`PeerFiles.vue`) +1. Before a paid download, open a small **payment-method picker** modal: + - "Pay from this node's wallet" → existing ecash flow (show balance; if + insufficient, the LN/on-chain local options when those land). + - "Pay from another wallet (QR)" → call `content.request-invoice`, render the + `bolt11`/address as a **QR** (add a tiny QR lib or reuse one already in the + bundle — check `package.json`), show amount + a live "waiting for + payment…" state polling `content.invoice-status`, then auto-download. +2. Reuse the existing `purchaseError`/`downloading` state + `triggerDownload`. + +**Verify**: .116 (seller) + .198 (buyer), a funded regtest/LN wallet. Buyer +picks QR, pays from a 3rd wallet, file releases. Then the local-ecash path. + +**Effort**: large (multi-day). Phase it: (1) LN-invoice + QR + release, (2) +on-chain, (3) local LN/on-chain methods. + +--- + +## #18 — Companion app: "open in external browser" apps don't work + +> **Status (2026-06-17): DONE & compiles (Rust + TS); Android unbuilt here.** +> Reverse relay hop added: `external_open_tx` channel, kiosk publishes +> `{"t":"o","url"}` on `/ws/remote-relay` (URL-validated), forwarded to the +> companion's `/ws/remote-input`. `requestExternalOpen()` in `remote-relay.ts` +> wired into all four `appLauncher.ts` external-open sites; `InputWebSocket.kt` +> + `RemoteInputScreen.kt` open it via `ACTION_VIEW`. Issue closed; live pairing +> test pending. + +**Goal.** Apps configured to open in a new/external browser should launch on the +**phone** when driven from the companion controller, using the phone-default- +browser request pattern. + +**What exists** +- Relay protocol in `neode-ui/src/api/remote-relay.ts` — message cases `m` + (move cursor), `c` (click), `s` (scroll, just fixed in #7). Click resolves the + element under the virtual cursor via `deepElementFromPoint`. +- The kiosk side runs the dashboard; "open external" apps currently try to + `window.open` on the **kiosk**, which the phone never sees. + +**Approach** +1. **Detect external-open intent on the kiosk**: when a click lands on an + element that would open externally (anchor with `target=_blank` / an app + flagged `opensExternally`, or an intercepted `window.open`), instead of + opening locally, send a new relay message to the phone: + `{ t: 'open-url', url }` over the `/ws/remote-relay` channel (the kiosk is the + relay server side — find where it sends frames back to the companion). +2. **Companion (phone) side** handles `open-url` by doing `window.open(url, + '_blank')` / `location.href = url` so it opens in the phone's default browser. + - If the companion is the **Android APK** (separate codebase, see + `Android/` + memory `feedback_companion_apk_not_in_update`), add an + intent-based handler there; if it's a mobile web client, handle in JS. +3. Intercept `window.open` on the kiosk dashboard globally (a small shim that, + when remote-relay is active, forwards to the phone instead of opening). + +**Verify**: phone + kiosk paired; tap an "open external" app from the companion; +it opens in the phone browser. + +**Effort**: medium; needs the companion device + possibly an APK change. + +--- + +## #50 — Integrate Meshroller into our mesh features + +> **Decision made 2026-06-17: seam (a) — Rust-native lift.** Full design with +> verified seam anchors (message types, dispatch, send API, event/trust gates, +> Ollama call) is in **`docs/meshroller-integration-design.md`**. Summary below. + +Source: https://gitea.l484.com/clasko/Meshroller + +**Phase 0 — review (DONE 2026-06-17)** +- Reviewed. Meshroller is a single ~29KB Python script (`meshroller.py`): a + daemon that bridges a **Meshtastic** radio (via the `meshtastic` Python serial + module, `SerialInterface`) to an **Ollama** LLM (`qwen2.5-coder`). It has + trusted-node auth, scheduled/queued messaging, and command handling on mesh + channels. It is a **daemon**, not firmware or a library. +- **License**: in-house (our own developer) — no third-party license blocker. +- **Hardware/transport reality**: it rides **Meshtastic serial + a local + Ollama**. Our radio is **Meshcore** (Heltec V3) and our mesh stack targets + meshcore. The `meshtastic` module does NOT speak meshcore, so the script + cannot drive our radio unmodified. +- **Decision needed (architecture)**: per user, integration **must work with + meshcore**. Two seams: + - (a) Lift Meshroller's *behaviors* (LLM bridge, trusted-node auth, scheduled + messaging, command parser) into our Rust mesh stack as typed message kinds — + native to meshcore, no Python/Meshtastic dependency. Preferred for meshcore. + - (b) Package the Python daemon as a container app and add a meshcore serial + backend to it (keeps the script, but requires writing meshcore I/O the + `meshtastic` module doesn't provide). + This choice is the remaining gate; the rest of Phase 1 below stands. + +**Phase 1 — choose the seam** +- Our mesh stack: `core/archipelago/src/mesh/` (`mod.rs` `MeshService`, + `listener/`, `protocol.rs`, `types.rs`). Decide: + - If Meshroller is a *protocol/feature on the same radio* → implement it as a + typed message kind in our `MeshMessageType` + `listener/dispatch.rs` + (mirrors how block headers / alerts are handled). + - If it's a *separate transport/daemon* → wrap it behind our transport router + (`transport/`) like FIPS/LAN/Tor. +- Reuse the event seam (`MeshEvent`) so the UI gets pushes (same path we just + wired for #48). + +**Phase 2 — UX** (ties into `project_mesh_telegram_plan`) +- A dead-simple onboarding + usage flow in the Mesh tab. Define the 1–2 killer + actions and design the setup wizard. + +**Verify**: 2 radios (the .116 Meshcore + a second). + +**Effort**: multi-day; gated on the Phase 0 review + a license/architecture +decision. + +--- + +## #15 — netbird app doesn't work (LOW PRIORITY) + +> **Status (2026-06-17): DIAGNOSED LIVE on .198 + FIXED (option A shipped); login works.** +> THE real blocker: the dashboard needs a **secure context** — +> `window.crypto.subtle is unavailable` over plain http, so OIDC PKCE threw +> before login. Fix: proxy now serves **HTTPS** (self-signed cert at install, +> `8087:443`, all origins `https://`); frontend opens netbird in a **new tab** +> (self-signed-HTTPS iframe is blocked). Layered fixes also in `stacks.rs`: +> nginx `resolver ` + variable upstreams (IP-cache 502; `resolver +> local=on`/`${NGINX_LOCAL_RESOLVERS}` FAIL on nginx:1.27-alpine), LAN-IP +> canonical origin + CORS + multi-origin redirect URIs, `/nb-auth`+`/nb-silent-auth` +> SPA fallback (were 404), and a stale-store note (wipe to re-init). Also found: +> `conmon died` zombie containers (recreate fixes; #53). Validated on .198, +> registration+login succeed. Trusted-cert/iframe (option B) = #56; +> registry-app migration = #52. Existing nodes need a clean reinstall. + +**Diagnose first** (likely a container/config issue, like other app fixes): +1. On a node: `podman logs ` — capture the actual failure. +2. Check the app manifest + install path (`container/` install, env, ports, + the four iframe-sync places per memory `feedback_gitea_iframe_setup` if it + has a UI). +3. netbird needs a management URL / setup key — confirm whether the app expects + config we don't provide, or a host capability (TUN device / NET_ADMIN) the + rootless-podman setup lacks. + +**Likely fix**: either supply the missing env/setup-key UI, or add the required +container capability. Low priority — schedule after the above. + +--- + +## #43 — Install errors at DID-creation + password screens (.70); FIPS slow + +`.70` is unreachable, so we can't read its logs. Code-side hardening that helps +regardless: +> **Status (2026-06-17): hardening DONE & compiles.** Root cause was a +> non-idempotent `seed.generate` that overwrote node keys under the client's +> retry storm on slow first boot. Fixed: idempotent generate + retry-safe +> verify (`seed_rpc.rs`), transient-vs-genuine error handling in +> `OnboardingSeedGenerate/Verify.vue`, and a non-blocking FIPS status on +> `OnboardingDone.vue`. Issue closed; full closure wants a fresh install on a +> reachable node + re-test on .70. + +1. **Onboarding error surfacing** — in the seed/DID + password onboarding views + (`OnboardingSeed*`, the password step) and their RPC handlers + (`seed.generate` / `seed.verify` / `auth.setup`), make a *successful* + operation never show an error toast, and make genuinely-failed ops show the + real message + a retry — so cosmetic errors (op actually succeeded) stop + alarming users. Audit the promise/catch paths for races where a slow backend + resolves after a timeout fires. +2. **FIPS start delay** — confirm `spawn_post_onboarding_fips_activate` + (`api/rpc/seed_rpc.rs`) isn't blocking onboarding; it already runs detached. + Consider surfacing "FIPS starting…" status instead of letting it look stuck. + +**Verify**: a fresh ISO install on a reachable node (.198 or a scratch box), +watch the DID + password screens; then re-test on .70 once reachable. + +**Effort**: small–medium (the hardening); full closure needs a repro node. diff --git a/docs/dual-ecash-design.md b/docs/dual-ecash-design.md new file mode 100644 index 00000000..664bcbfe --- /dev/null +++ b/docs/dual-ecash-design.md @@ -0,0 +1,135 @@ +# Dual-ecash: Cashu + Fedimint, seamlessly + +Status: **in progress** (2026-06-17). FE scaffolding + Fedimint HTTP bridge landed and +compile-checked; live federation round-trip and networking-sats routing are not yet validated. + +## Why + +Today the node's wallet (`core/archipelago/src/wallet/ecash.rs`, `mint_client.rs`, `cashu.rs`) +speaks **only** the Cashu NUT HTTP protocol (BDHKE, `cashuA…` tokens). There is **no** Fedimint +*client* — `apps/fedimint` is only the guardian server, and the "local Fedimint" default mint at +`127.0.0.1:8175` is just the guardian UI nginx, which does not expose the Cashu NUT API. So: + +- The node can hold/spend generic Cashu tokens, but cannot hold Fedimint ecash or join federations. +- "Networking sats" (streaming/seeding revenue) is hardcoded to the Cashu wallet. + +Goal: support **both** ecash protocols seamlessly — hold balances in either, join arbitrary +federations, and let networking-sats be paid/received over whichever protocol the peer accepts. + +## Architecture decision + +**Containerized `fedimint-clientd` + thin HTTP bridge** (chosen over linking the native +`fedimint-client` Rust SDK into the binary, and over a Lightning-only bridge). + +``` +archipelago binary + ├─ CashuMintClient ──HTTP (NUT /v1/*)──▶ cashu mint + └─ FedimintClient ──REST (/v2/*)─────▶ fedimint-clientd container ──▶ federation guardians +``` + +Rationale: keeps the heavy, fast-moving Fedimint SDK **out** of the main binary (no compile-time +coupling, no rebuild bloat, OTA-friendly), and fits the existing app/container architecture +(`apps/fedimint`, `apps/fedimint-gateway`). The Rust side is just a `reqwest` client, mirroring +`MintClient`. + +### fedimint-clientd REST surface (v0.3.x) + +- Auth: `Authorization: Bearer `. Default port 8080 (we map it to host **8178** because + 8080 is LND REST). Base path `/v2/...`. +- `GET /v2/admin/info` — per-federation balances (`totalAmountMsat`, denominations, meta). +- `POST /v2/admin/join` — `{ "inviteCode": "fed1…", "useManualSecret": false }` → joins / returns `federationId`. +- `POST /v2/mint/spend` — `{ federationId, amountMsat }` → serialized notes (ecash to send). +- `POST /v2/mint/reissue` — `{ federationId, notes }` → redeem received notes; returns reissued amount. +- `POST /v2/ln/invoice` / `POST /v2/ln/pay` — Lightning in/out (used for cross-protocol swaps). +- `GET /health`. + +Multi-federation: requests carry a `federationId`; clientd's `multimint` manages many clients. +**Exact JSON field names must be pinned to the clientd image tag we vendor** — code defensively. + +## Components + +### 1. Container app — `apps/fedimint-clientd/manifest.yml` +Sidecar running `fedimint-clientd`, mirroring `apps/fedimint-gateway`. Host port 8178 → container +8080. Password from node secret `fedimint-clientd-password`. State volume at +`/var/lib/archipelago/fedimint-clientd`. Added to `RESERVED_PORTS` (port_allocator.rs) and +`fallback_package_port()` (server.rs). + +### 2. Rust bridge — `core/archipelago/src/wallet/fedimint_client.rs` +Thin `reqwest` client: `info()`, `join()`, `spend()`, `reissue()`, `ln_invoice()`, `ln_pay()`. +`from_node(data_dir)` resolves base URL + password (env `FEDIMINT_CLIENTD_URL` / +`FEDIMINT_CLIENTD_PASSWORD`, else defaults + secret file). Tor-proxy support via `with_client`, +mirroring `MintClient`. + +### 3. RPCs — `core/archipelago/src/api/rpc/fedimint.rs` +- `wallet.fedimint-list` → joined federations + balances (`{federation_id, name, balance_sats}[]`). +- `wallet.fedimint-join` `{invite_code}` → joins via clientd, persists to + `wallet/fedimint_federations.json`, returns `{federation_id}`. +- `wallet.fedimint-leave` `{federation_id}` → untracks locally. +- `wallet.fedimint-balance` → total sats across federations (from clientd `info`). + +Local registry `wallet/fedimint_federations.json` = `{ federations: [{federation_id, name}] }` so +the list survives clientd being temporarily down; balances are live from clientd. + +### 4. Frontend — `WalletSettingsModal.vue` +Tabbed: **Cashu Mints** (live: `streaming.list-mints` / `streaming.configure-mints`) and +**Fedimint Federations** (`wallet.fedimint-list` / `-join` / `-leave`). Gear icon on +`HomeWalletCard`. `fedimintBackendReady` flips to `true` once the RPCs ship; join degrades +gracefully with a clear error if the clientd app isn't installed. + +### 4b. Default federation (zero-touch) +clientd auto-joins a default federation at boot via `FEDIMINT_CLIENTD_INVITE_CODE` (manifest), and +the Rust bridge `ensure_default_federation()` idempotently joins + tracks it (called from +`wallet.fedimint-list`) so already-running nodes pick it up too. Constant +`DEFAULT_FEDERATION_INVITE` in `fedimint_client.rs` is the single source on the Rust side; keep it +in sync with the manifest env. clientd is a **client, not the guardian** — it needs no local +`fedimintd`, so it bundles standalone. + +### 4c. Bundling on every node +- **Bundled ISO:** add image to `scripts/image-versions.sh`, the ISO bundle `.tar` list, and a + core-create block in `scripts/first-boot-containers.sh`; mark `tier: "core"` in + `app-catalog/catalog.json`. +- **Unbundled ISO:** `first-boot-containers.sh` exits after FileBrowser only — add clientd to that + early-exit block so unbundled nodes also get it out of the box. +- **CAVEAT:** confirm the *current* ISO assembler before editing the bundle list — the one found is + under `image-recipe/_archived/` (likely stale); `first-boot-containers.sh`/`image-versions.sh` + are current. +- Image: build from source (no official image; `flake.nix` only) → push to vps2 + `146.59.87.168:3000/lfg2025/fedimint-clientd:v0.4.0`. + +### 5. Unified balance +`HomeWalletCard` ecash row = Cashu `wallet.ecash-balance` + Fedimint `wallet.fedimint-balance`. +(Home already calls `wallet.ecash-balance`; add fedimint and sum.) + +## Networking sats — dual protocol (phase 6, NOT yet wired) + +The economic layer (`streaming.rs`, `streaming/gate.rs`, sessions, pricing, metering) is already +protocol-agnostic — it just calls into the wallet. The injection points are: + +1. **Protocol-tag accepted mints.** `accepted_mints: Vec` → carry protocol, e.g. + `cashu:https://mint…` / `fedimint:`. Migrate `wallet/accepted_mints.json` with a + back-compat reader (bare URL ⇒ `cashu:`). +2. **`MintClient::new()` is the bottleneck** (~10 call sites). Introduce a `MintBackend` trait with + `CashuBackend` (wraps current code) and `FedimintBackend` (calls `FedimintClient`). +3. **`Token` enum** `Cashu(CashuToken) | Fedimint(notes)`; serialize/verify by variant. +4. `build_payment_token()` picks a `(backend, id)` the peer accepts; `verify_and_receive_payment()` + auto-detects the token variant and reissues/swaps on the right backend. +5. Cross-protocol settlement (Cashu↔Fedimint) bridges over Lightning (BOLT11) — both sides already + have mint/melt (Cashu) and `ln/invoice`+`ln/pay` (Fedimint). +6. `Web5NetworkingProfitsSettings.vue`: per-service payout protocol/mint selector. + +## Phases + +- [x] **P0** FE tabbed Wallet Settings modal + gear (Cashu live, Fedimint tab structured). +- [x] **P1** `fedimint-clientd` container manifest + ports. +- [x] **P2** `FedimintClient` HTTP bridge + `wallet.fedimint-*` RPCs (compiles). +- [ ] **P3** Validate join / balance / spend / reissue against a live clientd + real federation on a scratch node. +- [ ] **P4** Unified ecash balance in the wallet card (Cashu + Fedimint). +- [ ] **P5** Flip FE fully live; surface "install Fedimint client app" when clientd unreachable. +- [ ] **P6** Networking-sats dual-protocol routing (the `MintBackend`/`Token` refactor above). + +## Validation (per project testing discipline) + +clientd image + a real federation are required; cannot be validated from the dev tree. Validate on +a scratch node: install Fedimint app + clientd, join a known test federation, confirm +`wallet.fedimint-balance`, then a spend→reissue round-trip between two nodes, then networking-sats +payment over Fedimint. Heavy/iterative work belongs in a worktree (see CLAUDE.md memory). diff --git a/docs/meshroller-integration-design.md b/docs/meshroller-integration-design.md new file mode 100644 index 00000000..27bd31bd --- /dev/null +++ b/docs/meshroller-integration-design.md @@ -0,0 +1,169 @@ +# Meshroller → Rust-native mesh assistant (issue #50) + +**Decision (2026-06-17): seam (a) — lift Meshroller's *behaviors* into our Rust +mesh stack as typed message kinds.** We do NOT package the Python/Meshtastic +daemon. Meshroller rides Meshtastic-serial + a local Ollama; our radio is +**meshcore** (Heltec V3) and the `meshtastic` Python module cannot drive it. So +we reimplement its four behaviors natively against `core/archipelago/src/mesh/`, +drop the Python + Meshtastic dependency, and reuse our existing event/transport +seams. + +Meshroller's behaviors (from the Phase-0 review of `meshroller.py`): +1. **LLM bridge** — relay an inbound mesh message to a local LLM, send the reply + back on the mesh. +2. **Trusted-node auth** — only trusted senders may invoke commands. +3. **Scheduled / queued messaging** — send messages at a future time; queue for + peers that are currently offline. +4. **On-channel command parser** — recognise commands in channel traffic. + +--- + +## Where this plugs in (verified seam map) + +| Concern | File / type | Anchor | +|---|---|---| +| Wire message kinds | `mesh/message_types.rs` `MeshMessageType` (`#[repr(u8)]`) | 28–73 | +| Envelope (CBOR, `0x02` marker, `seq`, `sig`) | `mesh/message_types.rs` `TypedEnvelope` | 183–197 | +| Inbound dispatch match | `mesh/listener/dispatch.rs` `handle_typed_envelope_direct()` | 80–691 | +| Outbound send | `mesh/mod.rs` `send_typed_wire()` / `send_channel_typed_wire()` | 848 / 1152 | +| Radio I/O command channel | `mesh/listener/mod.rs` `MeshCommand` (`SendText`/`BroadcastChannel`) | 55–73 | +| Frame chunking (≤160 B/frame, transparent) | `mesh/listener/session.rs` `send_dm_via_channel()` | — | +| UI push | `mesh/types.rs` `MeshEvent` (broadcast on `state.event_tx`, cap 64) | 125–164 | +| Trust gate | `federation/types.rs` `TrustLevel::Trusted` on `FederatedNode`; `federation::load_nodes()` | 5–52 | +| Block on user-blocklist | `mesh/listener/mod.rs` `ContactEntry.blocked` (`state.contacts`) | 110 | +| Local model | Ollama container, port **11434** (`port_allocator.rs:11`); call via `reqwest` (already a dep) | — | + +No in-Rust LLM exists yet; we call the **local Ollama HTTP API** (the same model +Meshroller used) so nothing new is baked into the binary. + +--- + +## Phase 1 — the assistant on the wire + +### 1.1 New typed message kinds (`message_types.rs`) +Add two variants (next free tag = 24): + +```rust +AssistQuery = 24, // "ask the node's AI" — prompt + optional model +AssistResponse = 25, // reply — request_id + text + done flag +``` +Wire the four spots the enum requires (`from_u8` 76–104, `from_label` 109–137, +`label()` 139–166, plus the variant) — mirror the `Invoice` variant exactly. + +Payloads (CBOR via `encode_payload`/`decode_payload`): +```rust +pub struct AssistQueryPayload { pub req_id: u64, pub prompt: String, pub model: Option } +pub struct AssistResponsePayload { pub req_id: u64, pub text: String, pub seq: u16, pub done: bool } +``` +`seq`/`done` let a long reply span multiple `AssistResponse` messages without +relying solely on frame reassembly (radio airtime is scarce — see §1.4 cap). + +### 1.2 Inbound handler (`listener/dispatch.rs`) +Add a match arm for `AssistQuery`, mirroring the **`TxRelay`** arm (169–207): +validate → **gate** → spawn background work (never block the radio loop). + +```rust +Some(MeshMessageType::AssistQuery) => { + let payload = decode_payload::(&envelope.v)?; + if !assistant_enabled(state) { return; } // kill switch (config) + if !sender_is_allowed(state, sender_contact_id).await { warn!(..); return; } + if !rate_limit_ok(state, sender_contact_id).await { return; } // 1 in-flight / sender + let _ = state.event_tx.send(MeshEvent::AssistQueryReceived { from_contact_id, prompt }); + let st = Arc::clone(state); + tokio::spawn(async move { run_assist(&st, sender_contact_id, payload).await; }); +} +``` + +`run_assist`: POST `http://localhost:11434/api/generate` +(`{model, prompt, stream:false}`), cap + chunk the response (§1.4), and emit each +chunk back to the sender via `send_typed_wire(contact_id, …, "assist_response", …)`. +Also store via the existing `store_typed_message` path so it lands in history, +and emit `MeshEvent::AssistResponseReady`. + +### 1.3 Trust gate (`sender_is_allowed`) +Reuse the federation trust list — no new store: +```rust +let nodes = federation::load_nodes(&data_dir).await.unwrap_or_default(); +let peer = state.peers.read().await.get(&sender_contact_id).cloned(); +let trusted = peer.and_then(|p| nodes.iter().find(|n| + Some(&n.pubkey) == p.pubkey_hex.as_ref() || Some(&n.did) == p.did.as_ref()) + .map(|n| n.trust_level == TrustLevel::Trusted)).unwrap_or(false); +``` +Plus honour `ContactEntry.blocked`. Config picks the policy: +**trusted-only** (default) | **specific contacts** | **anyone on channel** (opt-in). + +### 1.4 Airtime discipline (meshcore reality) +Frames are ≤160 B and reassembly is automatic, but bandwidth is tiny. So: +- **Cap** the reply (default ~480 chars / ≤3 `AssistResponse` chunks); append + `…(truncated — reply '!more')` and keep the tail server-side for a `!more`. +- **Rate-limit**: one in-flight query per sender; drop/deny extras. +- **Timeout** the Ollama call (e.g. 60 s) and reply with a short error on failure + (`MeshEvent::AssistResponseReady { error }`). + +### 1.5 Channel command parser +The killer entry point is a plain channel message, not a typed one. In the +inbound **`Text`** path, when a channel-0/1 message starts with the trigger +(default `!ai ` / `!ask `), synthesise an `AssistQuery` from the remainder and +run the same gated `run_assist`. This means **any meshcore client** (even a bare +Meshtastic-style sender) can ask, while typed `AssistQuery` is the rich path our +own UI uses. Trigger + enable are config. + +### 1.6 UI events (`types.rs`) +```rust +AssistQueryReceived { from_contact_id: u32, prompt: String }, +AssistResponseReady { req_id: u64, to_contact_id: u32, error: Option }, +ScheduledMessageFired { message_id: u64 }, // for Phase 1.7 +``` +Subscribers already flow through the single `event_tx` broadcast — no extra +wiring. + +### 1.7 Scheduled / queued messaging +A small `AssistScheduler` owned by `MeshService` (sits beside `relay_tracker` / +`dead_man_switch` in `mod.rs`): +- Persisted queue `{ id, contact_id|channel, wire, fire_at, attempts }` under + `data_dir/mesh/scheduled.json`. +- A tokio task wakes at the earliest `fire_at`, sends via the normal + `send_typed_wire` / `MeshCommand::SendText` path, emits `ScheduledMessageFired`. +- **Offline queue**: on send failure (peer unreachable) keep the item and retry + when a `PeerDiscovered` / `PeerUpdated` event names that peer. +- RPC: `mesh.schedule-message { contact_id|channel, body, fire_at }`, + `mesh.list-scheduled`, `mesh.cancel-scheduled`. + +--- + +## Phase 2 — killer Mesh-tab UX (ties into `project_mesh_telegram_plan`) + +**Onboarding (one screen, three steps):** +1. *Model* — detect Ollama on :11434. If absent, a single "Install AI (Ollama)" + button deep-links to the App Store entry; if present, pick the model + (default the one already pulled). +2. *Who can ask* — Trusted nodes only (default) · Pick contacts · Anyone on the + mesh channel (with a clear "uses your node's compute / airtime" warning). +3. *Trigger word* — default `!ai`; toggle the whole feature on. + +**Usage (Mesh tab):** +- An **Assistant** card: on/off, model, policy, trigger; live feed driven by + `AssistQueryReceived` / `AssistResponseReady`. +- Composer gains two actions: **Ask the mesh AI** (sends a typed `AssistQuery`) + and **Send later** (date/time → `mesh.schedule-message`), with a "Scheduled" + list (`mesh.list-scheduled`, cancel). + +The 1–2 killer actions: *ask the island's AI from any radio*, and *queue a +message that sends itself when a peer comes back in range.* + +--- + +## Verification +Needs **2 radios** (the .116 meshcore + a second) + Ollama running on the +answering node: +1. From radio B send `!ai what's the block height?` → node A (trusted) answers on + the channel; untrusted B is silently denied. +2. Typed `AssistQuery` from our UI → chunked `AssistResponse` renders in the feed. +3. Long reply → truncation + `!more` continues. +4. Schedule a message to an out-of-range peer → it fires when the peer reappears. + +## Effort & order +Multi-day. Land in this order so each step is testable alone: +1.1 enum + payloads → 1.2/1.3/1.4 gated bridge → 1.5 channel trigger → +1.6 events → 1.7 scheduler → Phase 2 UI. Phases 1.1–1.4 are the minimum +demoable slice (ask over the mesh, get an answer). diff --git a/image-recipe/_archived/build-auto-installer-iso.sh b/image-recipe/_archived/build-auto-installer-iso.sh index f05d37c4..34f04c97 100755 --- a/image-recipe/_archived/build-auto-installer-iso.sh +++ b/image-recipe/_archived/build-auto-installer-iso.sh @@ -1378,6 +1378,7 @@ ${ELECTRUMX_IMAGE} electrumx.tar ${MARIADB_IMAGE} mariadb-mempool.tar ${FEDIMINT_IMAGE} fedimint.tar ${FEDIMINT_GATEWAY_IMAGE} fedimint-gateway.tar +${FMCD_IMAGE} fmcd.tar ${FILEBROWSER_IMAGE} filebrowser.tar ${ALPINE_TOR_IMAGE} alpine-tor.tar ${NGINX_ALPINE_IMAGE} nginx-alpine.tar diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index c702822a..5840c415 100755 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -172,6 +172,31 @@ FBEOF chown -R 1000:1000 /var/lib/archipelago/secrets fi + # Create Fedimint Client (fmcd) alongside FileBrowser so ecash / networking + # sats work out of the box even on unbundled images. Image is pulled (not + # pre-loaded). Resilient entrypoint retries on join failure → never crash-loops. + if ! $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^fedimint-clientd$'; then + log "Creating Fedimint Client (fmcd)..." + mkdir -p /var/lib/archipelago/fmcd + FMCD_PW_FILE=/var/lib/archipelago/fmcd/password + [ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE" + FMCD_PW="$(cat "$FMCD_PW_FILE")" + FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp" + pull_with_fallback "${FMCD_IMAGE}" + $DOCKER run -d --name fedimint-clientd --restart unless-stopped \ + --network archy-net \ + --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ + --security-opt no-new-privileges:true \ + --health-cmd='curl -sf http://localhost:8080/health || exit 1' \ + --health-interval=120s --health-timeout=10s --health-retries=3 \ + --memory=256m \ + -p 8178:8080 \ + -v /var/lib/archipelago/fmcd:/data \ + -e FMCD_ADDR=0.0.0.0:8080 -e FMCD_MODE=rest -e FMCD_DATA_DIR=/data \ + -e FMCD_PASSWORD="$FMCD_PW" -e FMCD_INVITE_CODE="$FMCD_DEFAULT_INVITE" \ + ${FMCD_IMAGE} 2>>"$LOG" && log " fmcd created" || log " WARNING: fmcd creation failed" + fi + # Generate WireGuard keys for standalone VPN (archipelago-wg service) WG_DIR="/var/lib/archipelago/wireguard" if [ ! -f "$WG_DIR/private.key" ]; then @@ -532,6 +557,7 @@ mem_limit() { homeassistant) echo "512m";; fedimint) echo "512m";; fedimint-gateway) echo "512m";; + fedimint-clientd) echo "256m";; photoprism) $LOW_MEM && echo "512m" || echo "1g";; mempool-api) echo "512m";; jellyfin) echo "1g";; @@ -560,7 +586,7 @@ MISSING_IMAGES="" for img_var in BITCOIN_KNOTS_IMAGE MARIADB_IMAGE ELECTRUMX_IMAGE \ MEMPOOL_BACKEND_IMAGE MEMPOOL_WEB_IMAGE BTCPAY_POSTGRES_IMAGE \ NBXPLORER_IMAGE BTCPAY_IMAGE LND_IMAGE FEDIMINT_IMAGE \ - FEDIMINT_GATEWAY_IMAGE HOMEASSISTANT_IMAGE GRAFANA_IMAGE \ + FEDIMINT_GATEWAY_IMAGE FMCD_IMAGE HOMEASSISTANT_IMAGE GRAFANA_IMAGE \ UPTIME_KUMA_IMAGE JELLYFIN_IMAGE VAULTWARDEN_IMAGE \ NEXTCLOUD_IMAGE SEARXNG_IMAGE FILEBROWSER_IMAGE; do img="${!img_var}" @@ -1019,6 +1045,31 @@ if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; th fi track_container "fedimint-gateway" +# 5c. Fedimint Client (fmcd) — ecash client daemon; auto-joins the default +# federation. The image's resilient entrypoint retries on join failure (fmcd +# needs >=1 federation to boot), so an unreachable default never crash-loops the +# node. The archipelago wallet bridge reads the password from +# /var/lib/archipelago/fmcd/password and talks to it on host port 8178. +if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q '^fedimint-clientd$'; then + log "Creating Fedimint Client (fmcd)..." + mkdir -p /var/lib/archipelago/fmcd + FMCD_PW_FILE=/var/lib/archipelago/fmcd/password + [ -s "$FMCD_PW_FILE" ] || head -c 24 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' > "$FMCD_PW_FILE" + FMCD_PW="$(cat "$FMCD_PW_FILE")" + FMCD_DEFAULT_INVITE="fed11qgqyj3mfwfhksw309uuxywtxxfjrjc35xuexverpxdsnxcnrxucxvenzveskgc3kvvun2c34xp3k2ep38yunzdpexcekxe3hvd3rvvmx8pnrvdenx5mnzvtzqqqjqt0t6pc3s5z0ynqjw9s4njf6svwgu59kweawc0vvrddcjeemw6yyn4pcdp" + $DOCKER run -d --name fedimint-clientd --restart unless-stopped \ + --health-cmd="curl -sf http://localhost:8080/health || exit 1" --health-interval=120s --health-timeout=10s --health-retries=3 \ + --memory=$(mem_limit fedimint-clientd) --network archy-net --network-alias fedimint-clientd \ + --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ + --security-opt no-new-privileges:true \ + -p 8178:8080 \ + -v /var/lib/archipelago/fmcd:/data \ + -e FMCD_ADDR=0.0.0.0:8080 -e FMCD_MODE=rest -e FMCD_DATA_DIR=/data \ + -e FMCD_PASSWORD="$FMCD_PW" -e FMCD_INVITE_CODE="$FMCD_DEFAULT_INVITE" \ + "$FMCD_IMAGE" 2>>"$LOG" || true +fi +track_container "fedimint-clientd" + # (Bitcoin-dependent containers created above regardless of BITCOIN_READY) # ── Tier 3: Applications (independent — always attempt) ─────────────────── diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index f36bc5d5..a1fdf22a 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -57,6 +57,11 @@ ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55" # Fedimint FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0" FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0" +# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built +# from minmoto/fmcd. NOT yet added to the bundled CONTAINER_IMAGES list / first- +# boot auto-create: bundling fleet-wide needs a fleet-reachable default +# federation first (the interim default is node-local). See docs/dual-ecash-design.md. +FMCD_IMAGE="$ARCHY_REGISTRY/fmcd:0.8.0" # Media REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"