docs: move 10 finished/historical docs into docs/archive/ with an index
Session logs, handovers, point-in-time snapshots, the v0.1.0 security audit, stale generated HTML guides, and shipped design docs (three-mode UI, install screens) move out of the living docs/ tree. docs/archive/README.md explains what each was and why it's archived; all in-repo links updated. (docs/UNIFIED-TASK-TRACKER.md links intentionally untouched — it has another agent's uncommitted work in flight.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9c02fbf164
commit
9221d1bfaf
@@ -0,0 +1,151 @@
|
||||
# Handover — fresh-ISO feedback bug-bash (2026-07-02)
|
||||
|
||||
**For: the agent building the next ISO + fleet deploy.** All fixes below are
|
||||
**merged and pushed: gitea-ai main = `f5d24796`** (merge of `c375ecc4`,
|
||||
65 files; branch `iso-feedback-fixes-2026-07-02` also pushed). Source
|
||||
feedback: user's fresh ISO install on a Framework (11th-gen Tiger Lake)
|
||||
machine, node `192.168.1.81` (SSH `archipelago` / `archipelago`).
|
||||
Diagnostic bundle: `/home/archipelago/incoming-logs/node-logs-192.168.1.81/`.
|
||||
|
||||
**⚠️ Known-red tests on main (NOT from this work):** `trust::anchor::
|
||||
unset_constant_is_none` + 2 `trust::signed_doc` tests fail because a prior
|
||||
commit pinned `RELEASE_ROOT_PUBKEY_HEX` without updating them. The signing/
|
||||
audit agent's uncommitted changes in the shared tree fix exactly these —
|
||||
coordinate with them; don't "fix" it independently or you'll collide. This
|
||||
bug-bash branch alone was 898/898 green; merged with main it's 894/898 with
|
||||
only those three.
|
||||
|
||||
## ⚠️ Outstanding user request for the deploy
|
||||
|
||||
- **Change .81's web-UI password to `ThisIsWeb54321@`** — the user forgot the
|
||||
current one. Node was unreachable from .116 during this session (flaky WiFi
|
||||
AP, IP flapped .68↔.81). Do this during deploy (SSH works from the user's
|
||||
machine; `archipelago`/`archipelago`).
|
||||
|
||||
## What changed (by file)
|
||||
|
||||
### Backend (core/archipelago/src) — builds clean, targeted tests pass
|
||||
- `api/handler/websocket.rs` — **subscribe BEFORE initial snapshot** (the
|
||||
"everything needs ctrl-r" root cause: broadcasts in the snapshot→subscribe
|
||||
gap were silently lost; a stale client never learned containers-scanned).
|
||||
- `main.rs` — crash check now runs BEFORE writing the PID marker (**crash
|
||||
recovery had never run on any node** — it always saw its own PID and
|
||||
skipped); tracing default demoted debug→info (journal volume).
|
||||
- `crash_recovery.rs` — PID-reuse guard (`process_is_archipelago`); new
|
||||
**pending-boot-starts registry** (names queued for recovery/reconcile) with
|
||||
writers in `recover_containers` + stack recovery.
|
||||
- `server.rs` — scanner overlays Stopped/Exited → **Restarting** for
|
||||
pending-boot-start ids (user ask: "status should be restarting if they are
|
||||
being restarted"); `SCANNER_RESTARTING` ownership set so scanner-authored
|
||||
Restarting resolves immediately instead of wedging in the 20-min
|
||||
transitional-preserve.
|
||||
- `container/prod_orchestrator.rs` — reconcile pass + `adopt_existing`
|
||||
register/deregister pending boot-starts; LND pre-start hook passes detected
|
||||
`bitcoin_host()` (Knots vs Core) into `lnd::ensure_config`; new
|
||||
`fedimint-clientd` pre-start hook (mkdir + chown 1000:1000 of
|
||||
`/var/lib/archipelago/fmcd` — self-heals the crash-loop).
|
||||
- `container/lnd.rs` — `ensure_config(paths, rpc_pass, bitcoin_host)`;
|
||||
bitcoind.rpchost no longer hardcoded `bitcoin-knots`; drift check rewrites
|
||||
host changes; +unit test `ensure_config_repairs_bitcoin_host_drift`.
|
||||
- `api/rpc/package/dependencies.rs` — bounded **dependency wait**
|
||||
(`wait_for_install_deps`, 36×5s): installed-but-starting deps wait with
|
||||
"Waiting for Bitcoin to start…" on the card; not-installed deps fail fast
|
||||
with `DependencyGateError` marker; +5 unit tests.
|
||||
- `api/rpc/package/install.rs`, `stacks.rs` — call sites wired to
|
||||
`gate_install_deps` (lnd/electrumx/mempool/btcpay).
|
||||
- `api/rpc/package/async_lifecycle.rs` — `DependencyGateError` removes the
|
||||
optimistic entry (**no more phantom "Stopped" LND tile**) + pushes an Error
|
||||
notification with the reason.
|
||||
- `api/rpc/package/progress.rs` — `set_install_message` helper.
|
||||
- `api/rpc/seed_rpc.rs` — `save_pending_seed_encrypted`; seed.restore also
|
||||
stashes the mnemonic; `auth.rs` — **auth.setup persists the encrypted seed
|
||||
backup** (recovery-phrase reveal previously failed on EVERY node because
|
||||
nothing ever wrote `master_seed.enc`).
|
||||
- `api/rpc/middleware.rs` — sanitizer allowlist extended (seed/2FA/auth
|
||||
errors reach the user instead of "Check server logs"); +2 tests.
|
||||
- `bitcoin_status.rs` — friendly status for "connection reset" (bitcoind
|
||||
starting); raw URL/os-error chains no longer shown; +3 tests.
|
||||
- `bootstrap.rs` — journald drop-in self-heal (OTA nodes get log caps);
|
||||
bitcoin.conf printtoconsole heal. (Log-spam agent's work; verified.)
|
||||
- `api/rpc/package/config.rs` — bitcoin args `-printtoconsole=0`.
|
||||
|
||||
### Manifests / scripts / configs
|
||||
- `apps/lnd/manifest.yml` — BITCOIND_HOST now `derived_env {{BITCOIN_HOST}}`.
|
||||
- `apps/bitcoin-knots/manifest.yml`, `apps/bitcoin-core/manifest.yml` —
|
||||
`-printtoconsole=0` (90.6% of the journal was IBD UpdateTip spam;
|
||||
debug.log in the datadir keeps full logs).
|
||||
- `scripts/first-boot-containers.sh` — chown 1000:1000 of
|
||||
`/var/lib/archipelago/fmcd` in BOTH fmcd blocks (root-owned dir was the
|
||||
fedimint-clientd "Permission denied os error 13" crash-loop);
|
||||
printtoconsole=0.
|
||||
- `scripts/container-doctor.sh`, `scripts/reconcile-containers.sh` —
|
||||
printtoconsole=0.
|
||||
- `image-recipe/configs/journald-archipelago.conf` (NEW) — SystemMaxUse=500M,
|
||||
rate limits; baked by ISO builder + bootstrap self-heal.
|
||||
- `image-recipe/configs/nginx-archipelago.conf` — `/assets/` 404s no longer
|
||||
cacheable (the `always` immutable header could pin a missing background for
|
||||
a YEAR); HTTPS block gained the missing `/assets/` location (was silently
|
||||
serving index.html as images).
|
||||
- `image-recipe/configs/archipelago-kiosk.service` — MemoryMax 1500→2800M,
|
||||
MemoryHigh 1200→2200M (kiosk was riding reclaim-throttle = the lag).
|
||||
- `image-recipe/_archived/build-auto-installer-iso.sh` — kiosk launcher/service
|
||||
now spliced from `image-recipe/configs/` at build time (was a stale inline
|
||||
heredoc that force-disabled GPU); **+ `firmware-intel-graphics` +
|
||||
`firmware-amd-graphics`** (Debian trixie split the i915 DMC blobs out of
|
||||
firmware-misc-nonfree; the .81 kernel logged tgl_dmc missing).
|
||||
|
||||
### Frontend (neode-ui) — vue-tsc clean, vitest green
|
||||
- `views/Login.vue` — Enter in field 1 → focus confirm; Enter in confirm →
|
||||
submit; submit button always clickable (shows inline mismatch/length error
|
||||
instead of being silently disabled); errors clear on input; **Restart
|
||||
Onboarding needs a confirming second click** (5s window) — this button is
|
||||
the likely cause of the "onboarding restarted after mismatch" report.
|
||||
+`login.restartConfirm` key in en/es locales.
|
||||
- `stores/sync.ts` — 30s staleness reconciliation (server.get-state) while
|
||||
connected; already-connected fast path now refetches too.
|
||||
- `composables/useContainersScanTimeout.ts` (NEW, +tests) — 20s escape hatch;
|
||||
wired into `Apps.vue` / `Discover.vue` / `Marketplace.vue`; fresh empty node
|
||||
reaches the real "no apps yet" empty state; "Checking…" can never persist.
|
||||
- Backgrounds: 10 heaviest bg JPEGs → **WebP q90** (9.4MB→6.6MB; refs updated
|
||||
in OnboardingWrapper/Dashboard/useRouteTransitions); 7 remaining images
|
||||
stayed JPEG (WebP came out LARGER on those — noisy sources; deliberate).
|
||||
- `public/assets/video/video-intro.mp4` — re-encoded CRF20 (SSIM 0.988) with
|
||||
**+faststart** (moov was at EOF → browser had to download all 15MB before
|
||||
playing = the intro lag). 12.7MB now, streams immediately.
|
||||
- LND icon: stale dist artifact; any fresh `npm run build` ships
|
||||
`app-icons/lnd.png` correctly.
|
||||
|
||||
## Verification done here
|
||||
- `cargo build -p archipelago` + `cargo check` clean; targeted tests
|
||||
(bitcoin_status, middleware sanitize, dep_wait, lnd, crash_recovery,
|
||||
boot_reconciler, bitcoin_host, prod_orchestrator lnd hooks): **52 passed,
|
||||
0 failed**. Full suite: **898 passed, 0 failed, 1 ignored** (22s).
|
||||
- `npm run build` green; dist verified: 10 bg-*.webp present, `lnd.png`
|
||||
icon present, `restartConfirm` string in bundle, optimized faststart
|
||||
video (12,740,782 bytes) in place. Note: main had a latent build breaker
|
||||
(unused template ref in `Web5ConnectedNodes.vue` from commit 8256fde1,
|
||||
vue-tsc TS6133) — fixed here by removing the dead ref/binding; without
|
||||
this fix `npm run build` fails on current main.
|
||||
- vitest: new composable tests + related suites pass.
|
||||
- `bash -n` clean on all touched scripts; nginx conf live-verified by agent
|
||||
(200/404/cache headers on both HTTP+HTTPS blocks).
|
||||
- ISO kiosk splice byte-verified against configs/ by agent simulation.
|
||||
|
||||
## NOT done / left for you
|
||||
1. **Full test-suite run + gate**: run the complete `cargo test` and (after
|
||||
deploy) `tests/lifecycle/run-gate.sh` ON .228 per CLAUDE.md before any tag.
|
||||
2. **Frontend bundle grep before shipping** (per memory/feedback): verify new
|
||||
strings (e.g. `restartConfirm`, `bg-home.webp`) in the built tarball.
|
||||
3. **Diagnostics collector** (`data-dir-listing.txt` = 15MB of podman overlay
|
||||
internals; dmidecode empty) — collector script wasn't found in this repo
|
||||
(likely lives on-node or in the user's collection script); fix when found.
|
||||
4. **podman healthcheck cgroup EPERM spam** (1,250 journal errors, healthchecks
|
||||
unreliable fleet-wide) — real open bug, Quadlet-phase territory, NOT fixed.
|
||||
5. **DP link-training failures on .81** (display corruption) — likely
|
||||
cable/dock/port hardware; firmware fix may help; tell user to try another
|
||||
cable/port if corruption recurs.
|
||||
6. **LoRa/RNode onboarding surface** — never scoped; user may want it as a
|
||||
feature (mesh device-found modal exists only on Mesh page post-login).
|
||||
7. The concurrent audit agent's files (`docs/1.8.0-RELEASE-HARDENING-PLAN.md`,
|
||||
`core/.../trust/*`, parts of `bootstrap.rs`) are ALSO uncommitted here —
|
||||
coordinate before committing; don't mix attribution.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Archipelago Installer — Screen Designs
|
||||
|
||||
Edit these screens to match your vision. I'll implement exactly what you specify.
|
||||
Each screen is what the user sees at that moment on the console (80 columns wide).
|
||||
|
||||
Constraints: bash TUI only (no ncurses). ANSI colors available:
|
||||
- `\033[1;37m` = bold white, `\033[1;33m` = bold yellow/orange
|
||||
- `\033[32m` = green, `\033[31m` = red, `\033[37m` = dim gray
|
||||
- `\033[0m` = reset. Box-drawing chars: ━ ─ │ ╭ ╮ ╰ ╯ ╔ ╗ ╚ ╝ █ ▓ ░ ▌▐
|
||||
- Spinners possible: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ or ◐◓◑◒ or |/-\
|
||||
|
||||
---
|
||||
|
||||
## Screen 1: Welcome / Press Enter
|
||||
|
||||
```
|
||||
(clear screen, centered)
|
||||
|
||||
a r c h i p e l a g o
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
automatic installer
|
||||
|
||||
Press Enter to install | Ctrl+C for shell
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen 2: Detecting Disk
|
||||
|
||||
```
|
||||
a r c h i p e l a g o
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
[1/7] Checking tools .............. ✓
|
||||
[2/7] Detecting disks
|
||||
|
||||
Found: /dev/sda (465.8G) — TOSHIBA MQ01ACF0
|
||||
|
||||
──────────────────────────────────────────
|
||||
|
||||
⚠ All data on /dev/sda will be erased.
|
||||
|
||||
Press Enter to install | Ctrl+C to cancel
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen 3: Installing (progress)
|
||||
|
||||
```
|
||||
a r c h i p e l a g o
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
[1/7] Checking tools .............. ✓
|
||||
[2/7] Detecting disks ............. ✓
|
||||
[3/7] Creating partitions ......... ✓
|
||||
[4/7] Formatting .................. ✓
|
||||
[5/7] Installing system ........... ✓
|
||||
[6/7] Encrypting data partition ◐
|
||||
AES-256-XTS (AES-NI detected)
|
||||
|
||||
──────────────────────────────────────────
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen 4: Bootloader
|
||||
|
||||
```
|
||||
a r c h i p e l a g o
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
[1/7] Checking tools .............. ✓
|
||||
[2/7] Detecting disks ............. ✓
|
||||
[3/7] Creating partitions ......... ✓
|
||||
[4/7] Formatting .................. ✓
|
||||
[5/7] Installing system ........... ✓
|
||||
[6/7] Encrypting data ............. ✓
|
||||
[7/7] Installing bootloader ....... ✓
|
||||
|
||||
──────────────────────────────────────────
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screen 5: Complete
|
||||
|
||||
```
|
||||
a r c h i p e l a g o
|
||||
━━━━━━━━━━━━━━━━━━━━━
|
||||
Installation Complete
|
||||
|
||||
After reboot, open the Web UI from any device:
|
||||
|
||||
http://192.168.1.198
|
||||
|
||||
SSH: ssh archipelago@192.168.1.198
|
||||
Password: archipelago
|
||||
Web Login: password123
|
||||
|
||||
──────────────────────────────────────────
|
||||
|
||||
>>> REMOVE THE USB DRIVE NOW <<<
|
||||
|
||||
Press Enter to reboot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes for Dorian
|
||||
|
||||
- Edit any screen above to match what you want to see
|
||||
- Add/remove steps, change wording, change layout
|
||||
- Specify colors per line if you want (e.g. "this line in yellow")
|
||||
- I can add a spinner animation on the active step
|
||||
- Box-drawing, progress bars, anything bash can render is fair game
|
||||
- Once you're happy with the designs I'll implement them exactly
|
||||
@@ -0,0 +1,26 @@
|
||||
# docs/archive — historical records
|
||||
|
||||
Documents here are **finished history**: completed session logs, handovers,
|
||||
point-in-time status snapshots, security audits of past versions, and design
|
||||
docs whose feature has since shipped. They are kept for provenance and are
|
||||
**not** maintained — nothing in this directory describes the current system.
|
||||
|
||||
For current state, start at:
|
||||
|
||||
- `docs/UNIFIED-TASK-TRACKER.md` — what's open, priority-ordered
|
||||
- `docs/PRODUCTION-MASTER-PLAN.md` — north star and workstream narrative
|
||||
- `docs/architecture.md` — as-built system architecture
|
||||
- `docs/ROADMAP.md` — public-facing roadmap
|
||||
|
||||
| File | What it was | Why archived |
|
||||
|------|-------------|--------------|
|
||||
| `SESSION-1.8.0-OTA-PROGRESS.md` | Session narrative of the 1.8.0 OTA work | Superseded by the unified task tracker |
|
||||
| `HANDOVER-2026-07-02-iso-feedback.md` | One-shot handover for the ISO feedback bug-bash | All fixes merged |
|
||||
| `rust-orchestrator-migration.md` | Design for migrating container lifecycle from bash to Rust | Migration complete — `prod_orchestrator.rs` + `boot_reconciler.rs` are the live system |
|
||||
| `demo-deployment-design.md` | Design for the public demo sandbox | Demo shipped; `docs/demo-build-info.md` is the live ops doc |
|
||||
| `app-registry-status-2026-06-21.md` | Per-app migration snapshot from node .228 @ v1.7.99-alpha | Point-in-time snapshot; headline findings (immich legacy, meshtastic present) no longer true |
|
||||
| `security-code-audit-2026-03.md` | March 2026 security audit of v0.1.0 (33 findings) | Historical record; top findings since remediated (Argon2id, persisted sessions, image verification) |
|
||||
| `architecture-review.html` | Generated interactive architecture guide (2026-03) | Stale generated artifact; describes an early crate/app layout |
|
||||
| `lora-functionality.html` | Generated LoRa/mesh guide (2026-04) | Predates X3DH/double-ratchet, Reticulum transport, and mesh AI |
|
||||
| `INSTALL-SCREENS-DESIGN.md` | Installer screen design solicitation | Installer implemented in `image-recipe/` |
|
||||
| `three-mode-ui-design.md` | Design for the Pro/Easy/Chat three-mode UI | Fully implemented (`stores/uiMode.ts`, `EasyHome.vue`, `Chat.vue`, goals system) |
|
||||
@@ -0,0 +1,344 @@
|
||||
# 1.8.0 OTA Session Progress
|
||||
|
||||
Updated: 2026-06-30
|
||||
|
||||
> **📋 Live day-to-day task tracker: `docs/UNIFIED-TASK-TRACKER.md`.** This doc is kept
|
||||
> as the historical session-by-session log; open items were consolidated into the
|
||||
> unified tracker on 2026-07-01 (several turned out already shipped — see that doc for
|
||||
> current status instead of re-deriving it from the log below).
|
||||
|
||||
---
|
||||
|
||||
## ▶️▶️▶️▶️ LIVE CHECKPOINT 2026-06-30 (evening) — #17 deployed + verified on .198/.228
|
||||
|
||||
**#17 (3ccc / stock-peer E2E pill) is now built, deployed, and live-verified** on `.198` and
|
||||
`.228` only (`.116` skipped per the hardware notice below — its radio is mid-reflash to RNode).
|
||||
|
||||
- Built release binary **sha `b1d695fc626a7382`** from the working tree (`cargo check` +
|
||||
`cargo test -p archipelago mesh::` both green, 99 passed/0 failed/1 ignored, right before
|
||||
building — tree was settled, no collision with the Reticulum agent's concurrent edits).
|
||||
- Deployed via stop/swap/start to `.198` (192.168.1.198) and `.228` (192.168.1.228), sha256
|
||||
confirmed matching on both, `systemctl is-active` = `active` on both (`.228` took its usual
|
||||
~couple-minute convergence — heavy resilience node, unrelated bitcoind/fedimint container
|
||||
startup noise in the logs during that window, no mesh errors).
|
||||
- **Live-verified the actual fix**, not just deploy: on `.198`, `mesh.peers` shows
|
||||
`"advert_name":"Meshtastic 3ccc", "pkc_capable":true`, and `mesh.send` to 3ccc
|
||||
(`contact_id:1128152268`) now returns **`"encrypted":true`** — confirms the
|
||||
`archy || peer_pkc_capable(contact_id)` TX fix is live, not just compiled.
|
||||
- `.228`'s RPC password in memory (`password123`) was stale — user confirmed the correct
|
||||
password is `ThisIsWeb54321@` (same as `.198`/`.116`, i.e. fully unified now). Re-verified via
|
||||
RPC: `mesh.peers` shows 3ccc `pkc_capable:true`, and `mesh.send` to 3ccc returns
|
||||
`"encrypted":true` — #17 confirmed live on `.228` too, not just `.198`.
|
||||
|
||||
**NOT yet done:** push commit to gitea-vps2 (still uncommitted in the working tree, by design —
|
||||
shares the tree with the Reticulum agent's uncommitted work); user on-device confirmation that
|
||||
the E2E pill actually renders in the Mesh UI for 3ccc.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ HARDWARE NOTICE 2026-06-30 (~16:30) — .116's Heltec V3 is being repurposed
|
||||
|
||||
**The Reticulum agent is reflashing .116's Heltec V3 (the board on `/dev/ttyUSB0`, currently
|
||||
.116's live Meshtastic radio) to RNode firmware**, with explicit user approval, to unblock the
|
||||
Reticulum Phase-0 hardware gates (real RNode needed; see `docs/RETICULUM-TRANSPORT-PROGRESS.md`).
|
||||
This was user-confirmed specifically because it takes .116 offline as a Meshtastic radio.
|
||||
|
||||
**Effect on this workstream: do all on-device Meshtastic testing on .198 and .228 only — .116 no
|
||||
longer has a Meshtastic-firmware radio attached once this lands.** `cargo check`/`cargo test
|
||||
-p archipelago` were both confirmed clean (99/99 mesh tests) right before the reflash started, so
|
||||
the earlier "wait for their edit to settle" blocker above is cleared — software-side it's safe to
|
||||
build/test/deploy; only .116's *physical radio role* changed.
|
||||
|
||||
---
|
||||
|
||||
## ▶️▶️▶️ LIVE CHECKPOINT 2026-06-30 (later PM, ~15:50) — READ THIS FIRST IF RESUMING
|
||||
|
||||
**#17 (3ccc / stock-peer E2E pill) is CODE-COMPLETE in the working tree**, isolated
|
||||
to `meshtastic.rs`/`protocol.rs`/`types.rs`/`mod.rs` as planned (no `session.rs`
|
||||
transport-plumbing changes from this side):
|
||||
- `ParsedContact.pkc_capable` (`protocol.rs`) + `MeshPeer.pkc_capable` (`types.rs`),
|
||||
both `#[serde(default)]`/defaulted `false` at every construction site.
|
||||
- `MeshtasticDevice::get_contacts()` now stamps `pkc_capable` per contact from the
|
||||
existing `peer_is_pkc_capable(node_num)` seam (de-`allow(dead_code)`'d).
|
||||
- `listener/session.rs::refresh_contacts` ORs the new value into `MeshPeer.pkc_capable`
|
||||
(capability only grows, never cleared by a transient refresh) — this IS a touch of
|
||||
session.rs, but additive/non-colliding with the Reticulum device-enum match arms
|
||||
already there; did not touch transport plumbing/routing.
|
||||
- `mod.rs::MeshService::send_message` now does `archy || self.peer_pkc_capable(contact_id)`
|
||||
for the Sent-row `encrypted` flag (was `archy`-only before).
|
||||
- Verified via `cargo check -p archipelago --bin archipelago` (clean, exit 0) **before**
|
||||
the other agent's latest edit landed.
|
||||
|
||||
**NOT YET DONE:** rebuild release binary → redeploy 5 nodes → push → user on-device test
|
||||
(same as #16, both still pending live verification).
|
||||
|
||||
**⚠️ BLOCKED right now — do not build/deploy/push until this clears:** the Reticulum
|
||||
agent is actively mid-edit in the *same* working tree. A `cargo test` run right after
|
||||
the clean `cargo check` above failed with a real (but transient, not mine) signature
|
||||
mismatch: `session.rs::auto_detect_and_open` / `run_mesh_session` were observed with a
|
||||
new `device_kind: Option<DeviceType>` param that `listener/mod.rs`'s call site didn't
|
||||
have yet — a normal in-flight snapshot of their work, not a regression to fix here.
|
||||
**Action on resume: re-run `cargo check` first; if it's clean, the other agent's edit
|
||||
has settled and it's safe to proceed to build/test/deploy. If still broken, wait —
|
||||
do not stash, revert, or patch their in-progress session.rs/listener/mod.rs changes**
|
||||
(see memory `feedback_concurrent_agent_tree.md`). Also: building/deploying right now
|
||||
would bundle their not-yet-finished `reticulum.rs` wiring into the binary — confirm
|
||||
with the user before shipping a combined build, since only the meshtastic `#17` piece
|
||||
has been asked for/owned by this session.
|
||||
|
||||
---
|
||||
|
||||
## ▶️▶️ LIVE CHECKPOINT 2026-06-30 (late PM) — READ THIS FIRST
|
||||
|
||||
**Fleet state:** all **5 test nodes** on binary **`38c456b0bacec3c4`** + frontend
|
||||
**`Mesh-CAkPgvLo.js`**, `archipelago` active on each:
|
||||
`.116`, `.198`, `.228` (LAN, archipelago@ + `~/.ssh/archipelago-deploy`),
|
||||
`100.72.136.5`, `100.89.209.89` (Tailscale, same key — installed this session;
|
||||
SSH user `archipelago` / pw `ThisIsWeb54321@`; NOPASSWD sudo on all 5).
|
||||
|
||||
**Shipped this session (commit `12e7990b` on `main`, pushed to gitea-vps2):**
|
||||
- ✅ **#16 public-channel routing** — inbound Meshtastic text to `BROADCAST_NUM`
|
||||
now files under the **public channel thread** (contact_id `u32::MAX - idx`),
|
||||
attributed to its real sender, instead of polluting per-sender DM threads.
|
||||
Directed text (`to == our node`) still routes to the DM thread (regression test
|
||||
`packet_to_inbound_frame_directed_dm_stays_a_contact_message`). `send_channel_text`
|
||||
now sets `MeshPacket.channel` so archy TX's on channel 0 (public).
|
||||
Code: `meshtastic.rs` (`packet_to_inbound_frame`, `parse_mesh_packet` to/channel,
|
||||
`send_channel_text`), `protocol.rs` (`RESP_MESHTASTIC_CHANNEL_TEXT = 0x70`),
|
||||
`listener/frames.rs` (handler + sender attribution), `Mesh.vue` (`senderLabelFor`).
|
||||
Tests green (95 mesh tests). **Pending: user on-device test with the radios.**
|
||||
|
||||
**Push access:** `main` is a PROTECTED branch on gitea-vps2. Direct push uses the
|
||||
dedicated **`ai`** account via remote **`gitea-ai`** (`git push gitea-ai main`).
|
||||
See memory `reference_gitea_ai_push_account.md`.
|
||||
|
||||
**Coordination:** another agent owns **Reticulum** (`reticulum-daemon/` + Rust
|
||||
transport wiring). DO NOT touch `mesh/listener/session.rs` transport plumbing or
|
||||
`mod.rs` routing in ways that collide. Keep #17 work isolated to `meshtastic.rs`
|
||||
RX/TX + (if needed) the sent-row encrypted flag.
|
||||
|
||||
### ✅ CODE-COMPLETE (not yet deployed/tested live) — #17 (3ccc / stock-peer E2E pill)
|
||||
Goal: DMs **to and from** a PKC-capable stock peer (3ccc, NodeInfo public_key
|
||||
key_len=32 confirmed) must show the E2E pill.
|
||||
- **RX side is already correct:** `parse_mesh_packet` reads `public_key` (field 16)
|
||||
+ `pki_encrypted` (field 17) per the MeshPacket proto; the directed-DM RX path
|
||||
promotes to `RESP_CONTACT_MSG_V3_E2E` when `pki_encrypted`. (Verify live.)
|
||||
- **TX bug (root cause) — FIXED:** `mod.rs::send_message` now records the Sent row
|
||||
with `encrypted = archy || peer_pkc_capable(contact_id)`. `peer_is_pkc_capable`
|
||||
(meshtastic.rs) is wired out via `get_contacts()` → `ParsedContact.pkc_capable` →
|
||||
`refresh_contacts` (session.rs) → `MeshPeer.pkc_capable` → `MeshService::peer_pkc_capable`.
|
||||
See the LIVE CHECKPOINT at the top of this file for the exact touch points.
|
||||
- NEXT STEP when resuming: confirm `cargo check` is clean (the other agent's
|
||||
Reticulum work shares this tree and may be mid-edit — see top checkpoint), then
|
||||
rebuild → redeploy 5 nodes → push → user test (same pending step as #16).
|
||||
|
||||
**Remaining open after #17:** #12 (provisioning robustness — HOLD, session.rs churn
|
||||
risks reticulum collision), #8 (Device-tab settings panel + reboot button — RPC
|
||||
`mesh.reboot-radio` already exists), #6 (onboarding modal), #7 (.116 re-verify),
|
||||
#14 (RSSI/SNR per-contact indicator), #15 (peer-location map, POSITION_APP portnum=3).
|
||||
|
||||
---
|
||||
|
||||
## ▶️ RESUME HERE — archy↔archy LoRa (2026-06-30 PM) — READ FIRST
|
||||
|
||||
**Goal:** archy↔archy text over Meshtastic LoRa must DELIVER and show the E2E pill,
|
||||
identical in off-grid and normal mode. Test bed = `.116` / `.198` / `.228` (all EU_868).
|
||||
Don't touch the federation/FIPS path.
|
||||
|
||||
### ✅✅✅ SOLVED 2026-06-30 — archy↔archy LoRa WORKS (delivery + E2E pill + identity)
|
||||
VERIFIED: `.198→.228` directed DM → `.228` row `RECEIVED enc=True peer="Arch Optiplex"`.
|
||||
All three nodes (.116/.198/.228) now hear each other + stock peer 3ccc. Deployed binary
|
||||
**`737b16c3235b`** active on all three. Fix source **COMMITTED as `a57ae388`** on `main`
|
||||
(not yet pushed to gitea-vps2/origin).
|
||||
|
||||
**THE fix (receive stream):** archy ignored `FromRadio.rebooted` (field 8). Every config
|
||||
write reboots the radio → firmware PhoneAPI resets to `STATE_SEND_NOTHING` and stops
|
||||
streaming received packets until the client re-sends `want_config`. archy never did →
|
||||
went deaf to inbound (that's why old messages only arrived after a full restart = fresh
|
||||
want_config). Fix: handle `FROM_RADIO_REBOOTED` → set `pending_reinit` → re-send
|
||||
want_config; plus a 10s keepalive heartbeat (insurance vs 15-min idle serial close) and
|
||||
a pinned `modem_preset=LONG_FAST` so all radios share frequency. Combined with the earlier
|
||||
E2E send fix (plain TEXT_MESSAGE_APP DM, firmware PKC) this closes archy↔archy LoRa.
|
||||
|
||||
**Open follow-ups:** #A surface received msgs under archy identity in all UI views; #6
|
||||
device-onboarding modal; #8 Device-tab settings panel; #7 re-verify .116 in rotation;
|
||||
#12 make modem_preset authoritative + hot-swap re-binding + RX-stall watchdog;
|
||||
#14 signal-strength (RSSI/SNR) indicator per contact (from MeshPacket rx_rssi/rx_snr);
|
||||
#15 map view plotting peer locations where shared (Meshtastic POSITION_APP portnum=3
|
||||
lat/lon). See the resume memory `project_session_resume_2026_06_30_lora.md` for the full
|
||||
task list.
|
||||
|
||||
### (historical) earlier TL;DR — RF-layer suspicion, now RESOLVED by the reboot-recovery fix
|
||||
The **archy software is correct and deployed.** The blocker was at the
|
||||
**radio/RF layer: the three radios are not hearing each other over the air at all.** No
|
||||
amount of archy code change will fix that until the radios actually RF-link. **Resume by
|
||||
testing the radios directly at home (Meshtastic phone app over Bluetooth) — see "DO THIS
|
||||
FIRST AT HOME" below.** ← this turned out to be the want_config resubscribe bug above.
|
||||
|
||||
### What is DONE and deployed (commit pending — see below)
|
||||
- **E2E send fix** (`core/archipelago/src/mesh/mod.rs` `send_message`, ~L1542): archy↔archy
|
||||
plain chat text is now sent as a **native `TEXT_MESSAGE_APP` DM** (firmware PKC-encrypts
|
||||
it E2E), NOT wrapped in our binary typed envelope. Archy peers' Sent rows are marked
|
||||
`encrypted=true` so the pill shows. Rich typed msgs still use `send_typed_wire`. This was
|
||||
the original root-cause fix (envelope-wrapped text silently broke archy↔archy LoRa).
|
||||
- **NEW: software radio-reboot** end-to-end, so a wedged/RX-deaf radio can be rebooted
|
||||
without physical access (and for the Device-tab settings panel the user requested):
|
||||
- `meshtastic.rs`: `reboot(seconds)` driver method + `ADMIN_REBOOT_SECONDS_FIELD = 97`
|
||||
(verified vs meshtastic/protobufs admin.proto — `set_owner=32/set_channel=33/set_config=34`
|
||||
matched our existing constants, confirming the proto read).
|
||||
- `listener/mod.rs`: `MeshCommand::RebootRadio { seconds }`.
|
||||
- `listener/session.rs`: device-enum `reboot()` dispatch (Meshtastic only) + handler arm.
|
||||
- `mesh/mod.rs`: `MeshService::reboot_radio(seconds)`.
|
||||
- `api/rpc/mesh/messaging.rs`: `handle_mesh_reboot_radio` → RPC **`mesh.reboot-radio`**
|
||||
`{seconds?}` (default 2); dispatcher arm in `api/rpc/dispatcher.rs`.
|
||||
- `cargo check` passes. Built release **sha `ba4aed590027690d`** and DEPLOYED + active on
|
||||
`.116/.198/.228`. The RPC works (`{"reboot":true,"seconds":2}`).
|
||||
- ⚠️ **Caveat:** when called, archy logged "Sent Meshtastic radio reboot" but the radio did
|
||||
**not** visibly reboot afterward (no config re-stream). Either field 97 is still off, or
|
||||
newer firmware requires an admin session passkey even over local serial, or the USB serial
|
||||
stayed open through the 2s reboot so no reconnect was logged. **Needs on-device verification.**
|
||||
|
||||
### The hard evidence (why "nothing works")
|
||||
- Directed DM tests `.198→.228` AND `.116→.228` (neither path reflashed): sender logs
|
||||
`Sent plain native DM dest=30d258436d65 part=1 total=1` and RPC returns `sent:true,
|
||||
encrypted:true`, but `.228` logs **nothing** — packet never reaches archy from the radio.
|
||||
- A raw broadcast from `.198` (`mesh.broadcast`) was accepted by its radio but **not heard**
|
||||
by `.228`/`.116`.
|
||||
- In an 8-minute window, **all three nodes received 0 inbound OTA packets from any other node.**
|
||||
Each only logs its OWN once-a-minute `Broadcast Meshtastic NodeInfo advert` + local TX
|
||||
`field=11` queue-status. `.228 mesh.status` = `messages_received:1` total.
|
||||
- `.198`'s radio is alive and transmitting NodeInfo every 60s — so it's not dead; it's that
|
||||
**reception is broken on the receivers.** A radio cannot drop a broadcast AND a unicast to
|
||||
its own node number while config matches, unless it simply isn't on the same airwaves.
|
||||
- archy provisioning is correct & identical across nodes (read back from device): PRIMARY =
|
||||
public LongFast (`name="" psk_len=1`), SECONDARY = `archipelago`, region=3 (EU_868). Admin
|
||||
field constants verified. The send path hands the radio a correct unicast MeshPacket
|
||||
(`to`=node, want_ack, hop_limit=3, plaintext `decoded` for the firmware to PKC-encrypt).
|
||||
|
||||
### PRIME SUSPECT (software-fixable) — modem-preset / frequency mismatch
|
||||
archy only ever writes `region` + `use_preset` and **never explicitly pins `modem_preset`**
|
||||
(it parses region but not preset; `set_lora_region` relies on the LongFast default). If ANY
|
||||
radio has a non-default modem preset / frequency slot persisted (e.g. set via the Meshtastic
|
||||
app, or a different factory default after the `.198` reflash), the radios are on **different
|
||||
airwaves despite identical channel name + region**, and archy would never correct it.
|
||||
|
||||
### DO THIS FIRST AT HOME (decisive, ~2 min, only the user can do it)
|
||||
Open the **Meshtastic phone app over Bluetooth** (works alongside archy's USB serial) on each
|
||||
of `.116/.198/.228` and check:
|
||||
1. Do the 3 nodes **see each other** in the node list (recent "heard")? → if NO, they're not
|
||||
RF-reaching (preset/freq/antenna/range).
|
||||
2. Do all 3 show the **same** Modem preset (LongFast), Region (EU_868), Frequency slot, and
|
||||
the same PRIMARY channel? → any difference = the cause.
|
||||
This single test separates "archy misconfigures the radios" from "radios physically can't
|
||||
reach each other."
|
||||
|
||||
### THEN — the archy fix to apply (if preset/config differs)
|
||||
Make archy **authoritatively write the full LoRaConfig** and force re-provision so all radios
|
||||
converge: in `core/archipelago/src/mesh/meshtastic.rs::set_lora_region` (and its
|
||||
caller/guard `ensure_lora_region` ~L304), explicitly set `modem_preset = LONG_FAST (0)` as a
|
||||
field in the LoRaConfig (it's currently omitted/defaulted), and make the startup provision
|
||||
path rewrite LoRa config when the preset doesn't match, then reboot the radio (use the new
|
||||
`mesh.reboot-radio`). Also verify the `mesh.reboot-radio` actually reboots the radio
|
||||
on-device (the caveat above).
|
||||
|
||||
### TEST RECIPE (works on each node)
|
||||
- RPC helper used this session: a node-side `rpc.sh` that logs in (password
|
||||
`ThisIsWeb54321@`), grabs the `csrf_token` cookie, echoes it as `X-CSRF-Token`, and POSTs to
|
||||
`http://127.0.0.1:5678/rpc/v1`. Recreate it or run archy's RPC directly. Methods:
|
||||
`mesh.peers`, `mesh.status`, `mesh.messages`, `mesh.send {contact_id,message}`,
|
||||
`mesh.broadcast`, `mesh.reboot-radio {seconds}`.
|
||||
- **LoRa contact ids:** `.116=1135977788` (prefix `3ca5b543`), `.198=3677050140` (`db2b551c`),
|
||||
`.228=1129894448` (prefix `30d25843`), stock `3ccc=1128152268`.
|
||||
- **Link health check (run on each node):** look for inbound `from=Some("!...")` lines in
|
||||
`journalctl -u archipelago` that are NOT the node's own `Broadcast ... NodeInfo advert`. If
|
||||
zero across all nodes → RF link is down (the current state).
|
||||
- **E2E success criteria:** send `.198→.228`, the marker appears in `.228` `mesh.messages` as
|
||||
an inbound row with `encrypted:true` / `transport:"lora"`, AND `.116↔.228` likewise.
|
||||
|
||||
### DEPLOY / BUILD RECIPE
|
||||
- Build: from `core/`, `CARGO_TARGET_DIR=/tmp/archy-hotfix-target CARGO_INCREMENTAL=0 cargo
|
||||
build --release -p archipelago --bin archipelago`. (If `rust-lld: undefined hidden symbol`,
|
||||
it's incremental cache — `CARGO_INCREMENTAL=0` fixes it.)
|
||||
- SSH key `~/.ssh/archipelago-deploy` is authorized on `.116/.198/.228`. SSH/UI/RPC password
|
||||
`ThisIsWeb54321@`. Per node: scp the binary, `sudo systemctl stop archipelago` →
|
||||
`kill -9 $(pgrep -x archipelago)` → `install -m0755` to `/usr/local/bin/archipelago` →
|
||||
`systemctl start archipelago`. Verify by `sha256sum` match + `systemctl is-active`.
|
||||
- **Current deployed sha on all 3 = `ba4aed590027690d`** (the reboot-enabled build).
|
||||
|
||||
### Fleet state (as of 2026-06-30 PM)
|
||||
- All 3 nodes on binary `ba4aed59`, active. Off-grid mode currently OFF (`mesh_only:false`).
|
||||
- `.198` radio was reflashed to factory `firmware-heltec-v3-2.7.26` (recovered from corrupt
|
||||
NVS); region EU_868 persists. Its archy identity is NOT re-bound on `.228` (`.228` shows
|
||||
`.198` as raw radio "Meshtastic 551c", `arch_pubkey_hex` absent) because `.228` hasn't heard
|
||||
`.198`'s identity broadcast — a downstream symptom of the dead RF link, not a separate bug.
|
||||
- The radios are powered & each transmitting; they are simply not hearing each other.
|
||||
|
||||
### Deferred UI (after LoRa works)
|
||||
- Device-tab **settings panel** (gear/desktop) — host the "Reboot radio" button there; calls
|
||||
`mesh.reboot-radio`. Scoping done: add to the Mesh.vue actions row (mirrors Broadcast/Off-Grid
|
||||
buttons) + a `rebootRadio()` method in `neode-ui/src/stores/mesh.ts`. See `Mesh.vue` ~L1484
|
||||
actions row and `mesh.ts` ~L373 `broadcastIdentity()` pattern.
|
||||
- Device-onboarding modal (detect plugged-in radio).
|
||||
|
||||
---
|
||||
|
||||
Current scope:
|
||||
- Preserve existing mesh work: E2E indicators, FIPS/Tor transport indicators, typed-message paths, Meshtastic region/channel provisioning, and dirty Meshtastic receive-attempt changes.
|
||||
- Take over the `3ccc` stock Meshtastic peer bug: LoRa text from `3ccc` to Archipelago `.116` does not surface in `mesh.messages`.
|
||||
- Keep release-gate fixes already made in this session.
|
||||
|
||||
Local gate status so far:
|
||||
- `cargo test -p archipelago --bin archipelago`: green, 849/849 after Meshtastic fixes.
|
||||
- `python3 scripts/check-app-catalog-drift.py --release --strict`: green.
|
||||
- `npm run type-check`: green.
|
||||
|
||||
Key changes made so far:
|
||||
- Added cascade uninstall progress truthfulness assertion to `tests/lifecycle/bats/cascade-uninstall.bats`.
|
||||
- Fixed release catalog drift filters and regenerated catalog metadata.
|
||||
- Fixed invalid `apps/fedimint-clientd/manifest.yml` `cpu_limit` schema value.
|
||||
- Updated stale/tight Rust tests without changing production behavior.
|
||||
|
||||
Remaining non-automatable / operational gates:
|
||||
- Workstream B signing is blocked on the offline `RELEASE_MASTER_MNEMONIC`; code + runbook exist, but the publisher must pin/sign the release-root catalog.
|
||||
- Phase-3 Quadlet backend rollout is implemented behind `use_quadlet_backends` and default-off. The gate skip-passes until explicitly enabled on a node; flipping it fleet-wide requires a coordinated flag rollout plus backend reinstall/migration verification.
|
||||
- `.116` read-only `use-quadlet-backends-install.bats`: 6/6 skip-clean; no backend `.container` units, so Phase-3 is not active on that node.
|
||||
- Release metadata still says `1.7.99-alpha` in `releases/manifest.json`; changelog top is `v1.8.00-alpha`. Cutting an actual 1.8.0 OTA requires an explicit version/manifest update.
|
||||
|
||||
Do not discard:
|
||||
- `core/archipelago/src/mesh/listener/decode.rs`
|
||||
- `core/archipelago/src/mesh/listener/session.rs`
|
||||
- `core/archipelago/src/mesh/meshtastic.rs`
|
||||
|
||||
3ccc bug current hypothesis:
|
||||
- The prior attempted Meshtastic fix added a hard stale-packet filter using `rx_time`.
|
||||
- Stock Meshtastic radios without GPS/RTC can report tiny nonzero epoch values until time sync.
|
||||
- That would make live `3ccc` packets look older than 10 minutes and get dropped before `mesh.messages`.
|
||||
- Current patch treats implausibly early `rx_time` values as unknown rather than stale.
|
||||
|
||||
.116 live validation after 2026-06-30 hotfix:
|
||||
- `.116` reachable by SSH; `archipelago` active; `/dev/mesh-radio -> ttyUSB0` attached.
|
||||
- Current canary deploy is commit `b4531bb4`; backend sha
|
||||
`4ab53e539d89679ef664401a9a57996267772fed02327abc2912c3e77543acbf`; frontend bundle
|
||||
`index-YOAeJF7w.js` / `Mesh-BSAo88jN.js`.
|
||||
- `main` pushed to `gitea-vps2`.
|
||||
- RPC on `.116`:
|
||||
- `transport.status` currently reports `mesh_only:false` (off-grid mode is not enabled unless
|
||||
the user toggles it).
|
||||
- `mesh.status` reports Meshtastic connected: `device_type:"meshtastic"`,
|
||||
`self_node_id:1135977788`, `peer_count:13`.
|
||||
- Recent `.116` -> `3ccc` sent rows are stored with real 2026 timestamps and `transport:"lora"`.
|
||||
- UI/backend fixes included in `b4531bb4`:
|
||||
- `transportLabel("lora")` displays **LoRa**.
|
||||
- mesh sends refetch messages after send so transport pills settle without browser refresh.
|
||||
- off-grid mode blocks the mesh-chat FIPS/Tor federation fallback and forces LoRa-only sends;
|
||||
banner text is `Tor/FIPS disabled - LoRa only`.
|
||||
- empty mesh-chat placeholder opacity reduced.
|
||||
- Meshtastic diagnostics now identify the remaining blocker:
|
||||
- 3ccc NodeInfo is discovered:
|
||||
`Meshtastic peer is PKC-capable (NodeInfo public_key) node=1128152268 key_len=32`.
|
||||
- Bytes from stock Meshtastic text reach `.116`, but the custom parser rejects the packet:
|
||||
`Meshtastic FromRadio.packet did not parse into a decoded MeshPacket len=73 head=0dcc3c3e43153ca5b5432a16df56cbed`.
|
||||
- Non-text packets decode and are ignored with port numbers (`portnum=3/4/5`), so the serial
|
||||
read path is alive. Resume inside `core/archipelago/src/mesh/meshtastic.rs::parse_mesh_packet`.
|
||||
- LoRa is therefore **not fully fixed** yet: stock `3ccc` -> `.116` text does not surface in
|
||||
`mesh.messages`, and `.116` -> `3ccc` still needs user-visible confirmation in the Meshtastic app.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Archipelago App Registry — Status Survey
|
||||
|
||||
**Generated:** 2026-06-21 · **Survey node:** .228 (archi resilience node, 14-app) · **Binary:** v1.7.99-alpha
|
||||
|
||||
This document inventories every app in the registry and reports, per app:
|
||||
manifest-based or not · installed on .228 · migration status (Quadlet/legacy) ·
|
||||
automated test coverage / release-gate status.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture context — "manifest-based or not"
|
||||
|
||||
**Every registry app is manifest-based.** That is the core architecture
|
||||
(Pillar 4, *data-driven apps*): install/uninstall needs only the app's
|
||||
`manifest.yml` + catalog entry — no host OS changes, no archipelago binary code
|
||||
per app. The live registry on .228 is **40 loaded manifests**
|
||||
(`Loaded 40 app manifest(s) from disk`).
|
||||
|
||||
The **only** non-manifest runtime units are:
|
||||
|
||||
- **4 companions** — `archy-bitcoin-ui`, `archy-lnd-ui`, `archy-electrs-ui`,
|
||||
`archy-fedimint-ui`. Built from `docker/<name>` contexts via
|
||||
`core/archipelago/src/container/companion.rs`, *not* the manifest registry.
|
||||
- **Stack sub-containers** — `immich_*`, `indeedhub-*`, `netbird-*`. Spawned by
|
||||
their parent manifest app.
|
||||
|
||||
---
|
||||
|
||||
## 2. Migration status (Quadlet-everywhere — Pillar 1)
|
||||
|
||||
"Migrated" = runs as a **Quadlet unit under `user.slice`**, so it survives an
|
||||
`archipelago.service` restart (legacy in-cgroup containers get SIGKILLed on
|
||||
restart and reconciled back).
|
||||
|
||||
On .228 migration is **effectively complete** — every installed app is
|
||||
`QUADLET:running` **except one**:
|
||||
|
||||
| Status | Apps |
|
||||
|---|---|
|
||||
| ✅ Migrated (Quadlet / user.slice) | bitcoin-knots, electrumx, lnd, fedimint, fedimint-clientd, fedimint-gateway, btcpay-server (+archy-btcpay-db, archy-nbxplorer), mempool, mempool-api, archy-mempool-db, indeedhub (+7 sub-containers), netbird (+server, +dashboard), vaultwarden, jellyfin, filebrowser, portainer, botfights, nostr-rs-relay, homeassistant, + 4 companions |
|
||||
| ⚠️ NOT migrated (legacy, service cgroup) | **immich_server** — still in `/system.slice/archipelago.service`. The only legacy holdout. (`immich_postgres`/`immich_redis` are pod members.) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Exhaustive per-app registry table
|
||||
|
||||
| App (registry id) | Manifest | Installed on .228 | Migration | Test coverage |
|
||||
|---|---|---|---|---|
|
||||
| bitcoin-knots | yes | ✅ | QUADLET | **L1 RPC ●**, L2 UI ● |
|
||||
| bitcoin-core | yes | ✗ (shares knots) | — | ◐ regression-gate |
|
||||
| lnd | yes | ✅ | QUADLET | **L1 RPC ●**, L2 ● |
|
||||
| electrumx | yes | ✅ | QUADLET | **L1 RPC ●**, L2 ● |
|
||||
| btcpay-server | yes | ✅ | QUADLET | **L1 RPC ●**, L2 ● |
|
||||
| mempool | yes | ✅ | QUADLET | **L1 RPC ●**, L2 ● |
|
||||
| mempool-api | yes | ✅ | QUADLET | via mempool stack |
|
||||
| archy-mempool-db | yes | ✅ | QUADLET | via mempool stack |
|
||||
| archy-mempool-web | yes | ✗ | — | via mempool stack |
|
||||
| archy-btcpay-db | yes | ✅ | QUADLET | via btcpay stack |
|
||||
| archy-nbxplorer | yes | ✅ | QUADLET | via btcpay stack |
|
||||
| fedimint (Guardian) | yes | ✅ | QUADLET | L1 ◐ container-only, L2 ● |
|
||||
| fedimint-clientd | yes | ✅ | QUADLET | none |
|
||||
| fedimint-gateway | yes | ✅ (this session) | QUADLET | none |
|
||||
| filebrowser | yes | ✅ | QUADLET | L2 probe-only |
|
||||
| indeedhub | yes | ✅ | QUADLET | none |
|
||||
| jellyfin | yes | ✅ | QUADLET | none |
|
||||
| vaultwarden | yes | ✅ | QUADLET | none |
|
||||
| portainer | yes | ✅ | QUADLET | none |
|
||||
| botfights | yes | ✅ | QUADLET | none |
|
||||
| nostr-rs-relay | yes | ✅ | QUADLET | none |
|
||||
| home-assistant | yes | ✅ (container `homeassistant`) | QUADLET | none |
|
||||
| netbird | yes | ✅ (+server, +dashboard) | QUADLET | none |
|
||||
| immich | yes | ✅ | ⚠️ **LEGACY** | none |
|
||||
| grafana | yes | ✗ (unit *activating*, no container) | staged | none |
|
||||
| strfry | yes | ✗ (unit *activating*) | staged | none |
|
||||
| ~~onlyoffice~~ | — | removed 2026-06-21 | — | — |
|
||||
| aiui | yes | ✗ | — | none |
|
||||
| core-lightning | yes | ✗ | — | none |
|
||||
| did-wallet | yes | ✗ | — | none |
|
||||
| gitea | yes | ✗ | — | none |
|
||||
| lightning-stack | yes | ✗ | — | none |
|
||||
| meshtastic | yes | ✗ | — | none |
|
||||
| morphos-server | yes | ✗ | — | none |
|
||||
| nextcloud | yes | ✗ | — | none |
|
||||
| photoprism | yes | ✗ | — | none |
|
||||
| router | yes | ✗ | — | none |
|
||||
| searxng | yes | ✗ | — | none |
|
||||
| uptime-kuma | yes | ✗ | — | none |
|
||||
| bitcoin-ui | yes | runs as companion `archy-bitcoin-ui` | QUADLET (companion) | L3 companions ● |
|
||||
| lnd-ui | yes | runs as companion `archy-lnd-ui` | QUADLET (companion) | L3 companions ● |
|
||||
| electrs-ui | yes | runs as companion `archy-electrs-ui` | QUADLET (companion) | L3 companions ● |
|
||||
| fips-ui | yes | ✗ | — | none |
|
||||
|
||||
Notes:
|
||||
- `home-assistant` (registry id) runs as container **`homeassistant`** — the
|
||||
app-id ≠ container-name. A duplicate `home-assistant.service` quadlet unit
|
||||
sits in *activating*; the live container is `homeassistant` (Up 6 days, healthy).
|
||||
- `grafana` / `strfry` have Quadlet `.container` units but the units are stuck
|
||||
*activating* with **no running container** — staged, not live. Worth a
|
||||
separate investigation.
|
||||
- `onlyoffice` was **removed from the registry on 2026-06-21**.
|
||||
|
||||
---
|
||||
|
||||
## 4. Test-gate reality
|
||||
|
||||
**No app has passed the formal release gate.** The gate is `run-gate.sh` green
|
||||
across the full lifecycle matrix (install / UI reachable / stop / start /
|
||||
restart / reinstall / reboot-survive / archipelago-restart-survive / uninstall),
|
||||
**5× on .228 AND .198**. All 8 release-gate checkboxes in
|
||||
`tests/lifecycle/TESTING.md` are **unchecked (☐)**.
|
||||
|
||||
What exists today:
|
||||
|
||||
| Layer | Status |
|
||||
|---|---|
|
||||
| L0 unit | 631 tests ● green |
|
||||
| L1 RPC | ● for **6 core apps only**: bitcoin-knots, lnd, electrumx, btcpay, mempool, fedimint |
|
||||
| L2 UI | ● dashboard + 7 proxy paths + bitcoin-ui:8334 |
|
||||
| L3 lifecycle survival | companions ● ; backends ◐ (regression-gate only — fails until Phase-3 Quadlet flag flips by default) |
|
||||
| Per-app L1+L2 matrix | **50 of 110 cells** |
|
||||
| L4 browser / L5 chaos / L6 perf | ○ 0 — not started |
|
||||
|
||||
Regression suites added after v1.7.90-alpha (run read-only, abort releases on
|
||||
failure): `bitcoin-receive.bats`, `port-drift.bats`, `secret-completeness.bats`.
|
||||
|
||||
**The other ~30 registry apps have zero automated coverage.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Key gaps
|
||||
|
||||
1. **immich** is the last legacy (in-cgroup) app — migrate to Quadlet to finish Pillar 1.
|
||||
2. **grafana / strfry** Quadlet units stuck *activating* with no container — investigate. (onlyoffice removed 2026-06-21.)
|
||||
3. **fedimint-gateway / fedimint-clientd** (this session) now run but have no lifecycle test coverage.
|
||||
4. The formal **5× release gate has never been green** — it is the blocker for the v1.7.52 tag.
|
||||
|
||||
---
|
||||
|
||||
## 6. This session's changes (2026-06-21)
|
||||
|
||||
- **Generated-secrets system** deployed to .228 (binary + manifests). Self-healing:
|
||||
the root-owned `fedimint-gateway-hash` was regenerated archipelago-owned/readable
|
||||
→ **fedimint-gateway now starts** (gatewayd webserver up on :8176). `fmcd-password`
|
||||
generated for fedimint-clientd.
|
||||
- **Guardian-UI CSS fix** applied on .228: rebuilt the stale `localhost/fedimint-ui:latest`
|
||||
companion image (built 2026-06-12, pre-fix) from the corrected context
|
||||
(`@guardian_assets` proxy fallback to :8177). Guardian's own CSS
|
||||
(`/assets/bootstrap.min.css`, `/assets/style.css`) **404 → 200 text/css**.
|
||||
Root cause: `companion.rs::ensure_image_present` skips rebuild when the
|
||||
`:latest` image already exists, so the context fix never re-baked.
|
||||
|
||||
*Survey method: live `podman` cgroup inspection on .228 + `/opt/archipelago/apps`
|
||||
manifest enumeration + `tests/lifecycle/TESTING.md`.*
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
||||
# Public Demo Deployment — Design
|
||||
|
||||
**Status:** design (2026-06-22)
|
||||
**Goal:** a public, click-to-play demo of the Archipelago UI that **auto-tracks
|
||||
the real code** yet stays **separated** from the private monorepo and its
|
||||
secrets/backend. Deployed via **Portainer**, mock-data driven, with working file
|
||||
storage and a testnet-flavored Bitcoin sandbox so visitors can play freely.
|
||||
|
||||
See also: `neode-ui/mock-backend.js` (existing mock), `docker-compose.demo.yml`
|
||||
(existing demo stack), `MEMORY → reference_neode_ui_dev_testing`,
|
||||
`MEMORY → reference_ovh_168_mirror` (Portainer/registry host).
|
||||
|
||||
---
|
||||
|
||||
## 1. What already exists (the 70%)
|
||||
|
||||
The demo is mostly built. Inventory:
|
||||
|
||||
| Asset | Path | State |
|
||||
|-------|------|-------|
|
||||
| Mock backend (Node/Express + ws) | `neode-ui/mock-backend.js` (~3,862 lines) | 95+ JSON-RPC methods: auth, package lifecycle, Bitcoin/LND wallet, mesh, federation, identity, monitoring, mock filebrowser |
|
||||
| Mock data | `mockData` / `walletState` / `MOCK_FILES` in `mock-backend.js` | rich; 10 pre-installed apps, 30+ marketplace apps, wallet balances, seeded files (Music/Documents/Photos/Videos) |
|
||||
| Demo compose | `docker-compose.demo.yml` | `neode-backend` (mock, `:5959`) + `neode-web` (nginx, `:4848`); header already says "Deploy via Portainer" |
|
||||
| Backend image | `neode-ui/Dockerfile.backend` | Node 22 Alpine → `node mock-backend.js` |
|
||||
| Web image | `neode-ui/Dockerfile.web` | multi-stage `vite build` → nginx |
|
||||
| Demo nginx | `neode-ui/docker/nginx-demo.conf` | proxies `/rpc/v1`, `/ws`, `/app/*` to the mock backend |
|
||||
| Precedent | `indee-demo` Portainer stack | separate stack referencing a **pre-built image** — the pattern we extend |
|
||||
|
||||
**Gaps for a *public* (not dev) demo:** state is global (visitors collide),
|
||||
uploads are no-ops, Bitcoin block height is hardcoded, no CI image pipeline, no
|
||||
separated public deploy repo.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture: source in monorepo, demo ships as images, public repo is thin
|
||||
|
||||
The tension — "must update as I update the real code" **and** "sort of
|
||||
separated" — is resolved by separating at the **deploy layer, not the source
|
||||
layer**.
|
||||
|
||||
```
|
||||
monorepo (private — single source of truth)
|
||||
neode-ui/ + mock-backend.js
|
||||
│ push to main
|
||||
▼
|
||||
CI: build archy-demo-web + archy-demo-backend
|
||||
│ push :demo / :latest
|
||||
▼
|
||||
registry (146.59.87.168:3000 / vps2)
|
||||
│ Portainer webhook / re-pull
|
||||
▼
|
||||
archy-demo (public repo — tiny)
|
||||
docker-compose.yml ──referencing pre-built images──▶ Portainer ▶ demo.<host>
|
||||
.env.example
|
||||
```
|
||||
|
||||
- **Single source of truth = the monorepo.** `neode-ui/` and `mock-backend.js`
|
||||
stay where they are, so the demo tracks real code automatically — no fork to
|
||||
sync, no drift.
|
||||
- **Separation = the public repo never holds source.** `archy-demo` contains only
|
||||
a `docker-compose.yml` (image refs) + `.env.example` + README. No Rust backend,
|
||||
no secrets, no UI source. Safe to make public.
|
||||
- **Auto-update flow:** edit code → push → CI rebuilds demo images → Portainer
|
||||
redeploys. The public compose file is touched rarely (only when service shape
|
||||
changes).
|
||||
|
||||
**Why not a true fork / `git subtree split`?** It works but needs a sync job
|
||||
*and* re-exposes UI source publicly. The image pipeline gives stronger
|
||||
separation (zero source leak) **and** zero manual sync. (Decided 2026-06-22.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Work items
|
||||
|
||||
### 3.1 CI image pipeline
|
||||
- On push to `main` (path filter: `neode-ui/**`), build:
|
||||
- `archy-demo-backend` from `neode-ui/Dockerfile.backend`
|
||||
- `archy-demo-web` from `neode-ui/Dockerfile.web` (`build:docker`)
|
||||
- Tag `:demo` + `:<git-sha>`, push to the registry.
|
||||
- Trigger Portainer redeploy (stack webhook) on success.
|
||||
|
||||
### 3.2 Public `archy-demo` repo
|
||||
- `docker-compose.yml` mirroring `docker-compose.demo.yml` but **`image:`
|
||||
references instead of `build:`** (pull `:demo`, no build context).
|
||||
- `.env.example` (`ANTHROPIC_API_KEY`, `VITE_DEV_MODE=existing`, session TTL,
|
||||
upload quota).
|
||||
- README: one-paragraph "deploy in Portainer → web editor paste / deploy from
|
||||
repo," access on `:4848`.
|
||||
- No source. This is the only public surface.
|
||||
|
||||
### 3.3 Multi-user: per-session sandbox (reset on idle) ⟵ *decided*
|
||||
The biggest code change. Today `mockData` / `walletState` / `MOCK_FILES` are
|
||||
**global singletons** → visitors corrupt each other's view.
|
||||
- Issue a `demo-session` cookie on first hit (the mock already sets a session on
|
||||
login; extend it to anonymous visitors).
|
||||
- Key state by session id: `sessions[sid] = { mockData, walletState, files }`,
|
||||
each **deep-cloned from a pristine seed** on creation.
|
||||
- Reap on idle (e.g. 30 min no activity) + hard cap concurrent sessions; on reap,
|
||||
free memory + temp dir.
|
||||
- RPC dispatch + WS patches resolve the per-session state instead of the global.
|
||||
- Keeps the demo a true playground: install/uninstall/spend freely, reset by
|
||||
reconnecting.
|
||||
|
||||
### 3.4 File storage: persisted per session ⟵ *decided*
|
||||
Today filebrowser upload/delete/rename are 200-OK no-ops.
|
||||
- Back each session with a temp dir (e.g. `/tmp/demo/<sid>/`), seeded from
|
||||
`MOCK_FILES`.
|
||||
- Make `POST/DELETE/PATCH /app/filebrowser/api/resources/*` and `GET …/raw/*`
|
||||
read/write that dir. Enforce a per-session quota (e.g. 50 MB) and reject
|
||||
oversize/odd MIME.
|
||||
- Cleaned when the session is reaped — no standing public writable volume, no real
|
||||
filebrowser container to harden.
|
||||
|
||||
### 3.5 Bitcoin: testnet-flavored mock ⟵ *decided*
|
||||
- Relabel wallet/chain as **testnet/signet**: `tb1q…` addresses, "testnet" chain
|
||||
in `bitcoin.getinfo`, scripted-but-plausible block height + confirmations.
|
||||
- Keep `dev.faucet` as the in-UI "get test sats" button (instant, free).
|
||||
- No real `bitcoind` → no sync, no disk, no public RPC attack surface.
|
||||
- *Future upgrade path:* swap to a real signet node + LND in the stack if we ever
|
||||
want movable real test sats (out of scope now).
|
||||
|
||||
### 3.6 Mock containers / app lifecycle
|
||||
- The mock already simulates `package.install/uninstall/start/stop/restart`
|
||||
asynchronously. For the demo, **force simulation mode** (never touch a real
|
||||
Docker socket — rootless/safe and host-independent). Confirm no path in
|
||||
`mock-backend.js` reaches for a real runtime when `DEMO=1`.
|
||||
|
||||
### 3.7 Mock-data refresh
|
||||
- Update `mockData` static apps + marketplace to current app set/versions, refresh
|
||||
wallet figures, seeded mesh messages, and files so the demo feels current. This
|
||||
is ongoing and rides the same image pipeline.
|
||||
|
||||
---
|
||||
|
||||
## 4. Invariants / guardrails (public exposure)
|
||||
|
||||
- **No real secrets, no real backend, no real Docker socket** in the demo image or
|
||||
public repo. Mock password stays a known demo credential, clearly labeled.
|
||||
- **Per-session isolation** is a hard requirement before going public — without it
|
||||
the demo is unusable for strangers.
|
||||
- **Resource caps:** session count, per-session memory + upload quota, idle reap;
|
||||
the box can't be DoS'd into OOM by upload spam or session churn.
|
||||
- **`ANTHROPIC_API_KEY`** (chat) is injected via Portainer env, never committed;
|
||||
rate-limit / budget-cap demo chat usage.
|
||||
- **Read-only registry creds** for the Portainer host to pull `:demo`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Files / seams
|
||||
|
||||
| Concern | Where |
|
||||
|---------|-------|
|
||||
| Per-session state, file persistence, testnet labels, sim-mode | `neode-ui/mock-backend.js` |
|
||||
| Build contexts (reused as-is) | `neode-ui/Dockerfile.backend`, `neode-ui/Dockerfile.web`, `neode-ui/docker/nginx-demo.conf` |
|
||||
| Demo stack (in-repo, dev) | `docker-compose.demo.yml` (keep `build:`) |
|
||||
| Public stack (new repo) | `archy-demo/docker-compose.yml` (`image:` refs), `.env.example`, README |
|
||||
| CI pipeline | new workflow (path filter `neode-ui/**` → build + push `:demo` → Portainer webhook) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions
|
||||
|
||||
1. **Demo host** — which Portainer instance (OVH `.168`? a dedicated VPS)? Public
|
||||
DNS + TLS for `demo.<domain>`?
|
||||
2. **Registry for `:demo` images** — `146.59.87.168:3000` vs vps2; public-pull or
|
||||
creds baked into Portainer?
|
||||
3. **Session TTL + concurrency cap** — concrete numbers (30 min / N sessions / 50 MB)?
|
||||
4. **Chat in the demo** — enable Claude chat (needs key + budget cap) or stub it?
|
||||
5. **Sync cadence** — rebuild `:demo` on every `neode-ui/**` push, or nightly?
|
||||
@@ -0,0 +1,899 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Archipelago — LoRa & Mesh Functionality Guide</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #000000;
|
||||
--glass-card: rgba(0, 0, 0, 0.65);
|
||||
--glass-dark: rgba(0, 0, 0, 0.35);
|
||||
--glass-darker: rgba(0, 0, 0, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.18);
|
||||
--glass-highlight: rgba(255, 255, 255, 0.22);
|
||||
--glass-blur: 18px;
|
||||
--glass-blur-strong: 24px;
|
||||
--shadow-glass: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-glass-inset: inset 0 1px 0 rgba(255, 255, 255, 0.22);
|
||||
--text: rgba(255, 255, 255, 0.9);
|
||||
--text-muted: rgba(255, 255, 255, 0.6);
|
||||
--accent: #fb923c;
|
||||
--accent-dim: rgba(251, 146, 60, 0.15);
|
||||
--green: #4ade80;
|
||||
--green-dim: rgba(74, 222, 128, 0.15);
|
||||
--red: #ef4444;
|
||||
--red-dim: rgba(239, 68, 68, 0.12);
|
||||
--blue: #3b82f6;
|
||||
--blue-dim: rgba(59, 130, 246, 0.12);
|
||||
--yellow: #facc15;
|
||||
--yellow-dim: rgba(250, 204, 21, 0.12);
|
||||
--purple: #a78bfa;
|
||||
--purple-dim: rgba(167, 139, 250, 0.12);
|
||||
--radius: 16px;
|
||||
--radius-sm: 12px;
|
||||
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
font-family: 'Avenir Next', system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.7;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
nav {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
width: 280px;
|
||||
height: 100vh;
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border-right: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass);
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
z-index: 100;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255,255,255,0.15) transparent;
|
||||
}
|
||||
nav .logo { padding: 0 24px 20px; margin-bottom: 16px; }
|
||||
nav .logo h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 18px; font-weight: 700;
|
||||
color: var(--accent); letter-spacing: -0.02em;
|
||||
}
|
||||
nav .logo p { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
nav .nav-section {
|
||||
padding: 12px 16px 4px;
|
||||
font-size: 10px; font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
nav a {
|
||||
display: block;
|
||||
padding: 6px 24px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: all var(--transition);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
nav a:hover, nav a.active {
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-left-color: var(--accent);
|
||||
}
|
||||
|
||||
main {
|
||||
margin-left: 280px;
|
||||
max-width: 960px;
|
||||
padding: 48px 48px 120px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 28px; font-weight: 700;
|
||||
margin: 64px 0 8px;
|
||||
padding-top: 24px;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h2:first-of-type { margin-top: 0; }
|
||||
h3 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 20px; font-weight: 600;
|
||||
margin: 40px 0 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
h4 {
|
||||
font-size: 16px; font-weight: 600;
|
||||
margin: 24px 0 8px;
|
||||
color: var(--accent);
|
||||
}
|
||||
p { margin: 8px 0 16px; color: var(--text); }
|
||||
ul, ol { margin: 8px 0 16px 24px; color: var(--text); }
|
||||
li { margin: 4px 0; }
|
||||
|
||||
.subtitle {
|
||||
font-size: 15px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero { text-align: center; padding: 48px 0 56px; margin-bottom: 24px; }
|
||||
.hero h1 {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 42px; font-weight: 800;
|
||||
background: linear-gradient(135deg, var(--accent), #f59e0b);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.hero .tagline {
|
||||
font-size: 18px;
|
||||
color: var(--text-muted);
|
||||
margin: 12px auto 0;
|
||||
max-width: 640px;
|
||||
}
|
||||
.hero .meta {
|
||||
margin-top: 20px;
|
||||
display: flex; gap: 16px;
|
||||
justify-content: center; flex-wrap: wrap;
|
||||
}
|
||||
.hero .meta span {
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
background: var(--glass-dark);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.card-sm {
|
||||
background: var(--glass-darker);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
transition: transform var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
.card-sm:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.card-sm h4 { margin: 0 0 6px; font-size: 14px; }
|
||||
.card-sm p { font-size: 13px; color: var(--text-muted); margin: 0; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 11px; font-weight: 600;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.badge-green { background: var(--green-dim); color: var(--green); }
|
||||
.badge-red { background: var(--red-dim); color: var(--red); }
|
||||
.badge-yellow { background: var(--yellow-dim); color: var(--yellow); }
|
||||
.badge-blue { background: var(--blue-dim); color: var(--blue); }
|
||||
.badge-purple { background: var(--purple-dim); color: var(--purple); }
|
||||
.badge-accent { background: var(--accent-dim); color: var(--accent); }
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
margin: 16px 0;
|
||||
font-size: 13px;
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 10px 14px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
td {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
vertical-align: top;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: rgba(255, 255, 255, 0.04); }
|
||||
|
||||
code {
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
}
|
||||
pre {
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
margin: 16px 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
pre code { background: none; padding: 0; color: var(--text); }
|
||||
|
||||
.diagram {
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur-strong));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur-strong));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
margin: 20px 0;
|
||||
overflow-x: auto;
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
white-space: pre;
|
||||
}
|
||||
.diagram .highlight { color: var(--accent); font-weight: 600; }
|
||||
.diagram .green { color: var(--green); }
|
||||
.diagram .blue { color: var(--blue); }
|
||||
.diagram .red { color: var(--red); }
|
||||
.diagram .purple { color: var(--purple); }
|
||||
|
||||
.callout {
|
||||
background: var(--glass-card);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 16px 20px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
border-left: 3px solid;
|
||||
box-shadow: var(--shadow-glass);
|
||||
}
|
||||
.callout-info { border-color: var(--blue); }
|
||||
.callout-warn { border-color: var(--yellow); }
|
||||
.callout-danger { border-color: var(--red); }
|
||||
.callout-success { border-color: var(--green); }
|
||||
.callout-learn {
|
||||
border-color: var(--purple);
|
||||
background: rgba(167, 139, 250, 0.06);
|
||||
position: relative;
|
||||
padding-top: 32px;
|
||||
}
|
||||
.callout-learn::before {
|
||||
content: 'Layman Analogy';
|
||||
position: absolute;
|
||||
top: 10px; left: 20px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--purple);
|
||||
}
|
||||
.callout strong { display: block; margin-bottom: 4px; }
|
||||
|
||||
.score-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.score-card {
|
||||
background: var(--glass-darker);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--shadow-glass), var(--shadow-glass-inset);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
transition: transform var(--transition);
|
||||
}
|
||||
.score-card:hover { transform: translateY(-2px); }
|
||||
.score-card .score {
|
||||
font-family: 'Montserrat', 'Avenir Next', sans-serif;
|
||||
font-size: 28px; font-weight: 800;
|
||||
margin: 4px 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
.score-card .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
margin: 48px 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
nav { display: none; }
|
||||
main { margin-left: 0; padding: 20px; }
|
||||
.hero h1 { font-size: 32px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<h1>Archipelago</h1>
|
||||
<p>LoRa & Mesh Guide</p>
|
||||
</div>
|
||||
|
||||
<div class="nav-section">Overview</div>
|
||||
<a href="#intro">Introduction</a>
|
||||
<a href="#layman">What is LoRa?</a>
|
||||
<a href="#why">Why Archipelago uses it</a>
|
||||
|
||||
<div class="nav-section">Stack</div>
|
||||
<a href="#hardware">Hardware & Firmware</a>
|
||||
<a href="#serial">USB Serial Transport</a>
|
||||
<a href="#wire">Wire Format</a>
|
||||
<a href="#crypto">Encryption Layers</a>
|
||||
<a href="#fragmentation">Fragmentation</a>
|
||||
|
||||
<div class="nav-section">Routing</div>
|
||||
<a href="#dual-transport">Dual Transport</a>
|
||||
<a href="#addressing">Addressing</a>
|
||||
<a href="#synthetic">Federation Contacts</a>
|
||||
|
||||
<div class="nav-section">Messages</div>
|
||||
<a href="#msg-overview">All 23 Types</a>
|
||||
<a href="#msg-text">Text / Reply / Edit</a>
|
||||
<a href="#msg-social">Reactions & Receipts</a>
|
||||
<a href="#msg-content">Content / Files</a>
|
||||
<a href="#msg-bitcoin">Bitcoin & Lightning</a>
|
||||
<a href="#msg-safety">Alerts & Presence</a>
|
||||
<a href="#msg-identity">Identity & Keys</a>
|
||||
|
||||
<div class="nav-section">Operations</div>
|
||||
<a href="#rpc">RPC API</a>
|
||||
<a href="#ui">User Interface</a>
|
||||
<a href="#listener">Listener Loop</a>
|
||||
<a href="#files">File Map</a>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
|
||||
<section class="hero">
|
||||
<h1>LoRa & Mesh Functionality</h1>
|
||||
<p class="tagline">How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.</p>
|
||||
<div class="meta">
|
||||
<span>Meshcore Companion USB</span>
|
||||
<span>Double Ratchet E2E</span>
|
||||
<span>23 Message Types</span>
|
||||
<span>160-byte LoRa Frame</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h2 id="intro">Introduction</h2>
|
||||
<p>This document explains Archipelago's mesh subsystem — the code under <code>core/archipelago/src/mesh/</code> that lets nodes talk to each other over <strong>LoRa radio</strong> instead of (or alongside) the internet. It covers every message type, the transport layer that carries it, the cryptography that protects it, and the code paths that glue it all together.</p>
|
||||
<p>The goal: give you a mental model that works both ways. If you're an engineer, you can read this and know exactly which bytes get put on the wire for a given RPC call. If you're not, the purple "Layman Analogy" boxes translate each piece into familiar metaphors.</p>
|
||||
|
||||
<h2 id="layman">What is LoRa? <span class="badge badge-purple">Layman</span></h2>
|
||||
<div class="callout callout-learn">
|
||||
<strong>Think of LoRa as a whisper that travels 10 kilometers.</strong>
|
||||
Normal Wi-Fi is a shout: loud, fast, lots of data, but only a few rooms away. LoRa is the opposite — a tiny, slow whisper that can cross an entire city because it's so narrow and patient that it slips through walls, trees, and hills. The tradeoff: you can only whisper about <strong>160 bytes</strong> at a time, and each whisper takes a second or two to complete.
|
||||
</div>
|
||||
<p>Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses <em>chirp spread spectrum</em> (CSS). It operates in unlicensed ISM bands (915 MHz in the Americas, 868 MHz in Europe) and trades bandwidth for sensitivity, allowing receivers to decode signals below the noise floor. Typical line-of-sight range is 5–15 km with a simple antenna; data rates are 0.3–50 kbps.</p>
|
||||
<p>Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running <strong>Meshcore firmware</strong>, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.</p>
|
||||
|
||||
<h2 id="why">Why Archipelago uses it</h2>
|
||||
<div class="card-grid">
|
||||
<div class="card-sm">
|
||||
<h4>Off-grid safety</h4>
|
||||
<p>Dead-man switch and emergency alerts reach family without cell coverage.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Censorship resistance</h4>
|
||||
<p>No ISP, no DNS, no TLS termination — just radio waves between nodes.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Bitcoin when internet is down</h4>
|
||||
<p>Relay signed transactions and Lightning payments through on-grid peers.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Truly peer-to-peer chat</h4>
|
||||
<p>Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 id="hardware">Hardware & Firmware</h2>
|
||||
<p>Archipelago expects a Meshcore-compatible radio board plugged into USB. The firmware handles RF, mesh forwarding, and contact management; Archipelago handles encryption, message types, and UI.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Component</th><th>Role</th><th>Examples</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><strong>MCU</strong></td><td>Runs Meshcore firmware, talks USB serial</td><td>ESP32, nRF52840</td></tr>
|
||||
<tr><td><strong>Radio</strong></td><td>Semtech LoRa transceiver</td><td>SX1262, SX1276</td></tr>
|
||||
<tr><td><strong>Board</strong></td><td>MCU + radio + USB + antenna</td><td>Heltec V3, T-Beam, RAK WisBlock, Station G2</td></tr>
|
||||
<tr><td><strong>Firmware</strong></td><td>Mesh routing + Companion USB protocol</td><td>Meshcore</td></tr>
|
||||
<tr><td><strong>Connection</strong></td><td>USB CDC-ACM serial</td><td><code>/dev/mesh-radio</code> (udev symlink), <code>/dev/ttyUSB*</code>, <code>/dev/ttyACM*</code></td></tr>
|
||||
<tr><td><strong>Link params</strong></td><td>115200 baud, 8N1</td><td>Set in <code>mesh/serial.rs</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="callout callout-learn">
|
||||
<strong>It's a modem.</strong> Exactly like a 56k modem from the '90s plugged into your serial port, except the other end of the wire is a radio mesh network instead of a phone line. Archipelago tells it "send this to contact X", and it figures out which radios to hop through.
|
||||
</div>
|
||||
|
||||
<h2 id="serial">USB Serial Transport</h2>
|
||||
<p>Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with <code>'<'</code> and listens for <code>'>'</code>.</p>
|
||||
|
||||
<div class="diagram">Host → Device: <span class="highlight">0x3C</span> '<' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||
Device → Host: <span class="highlight">0x3E</span> '>' │ <span class="blue">len_lo len_hi</span> │ <span class="green">frame_bytes...</span>
|
||||
|
||||
Baud: 115200 Framing: 8N1 Source: mesh/serial.rs</div>
|
||||
|
||||
<p>The frame body is a Meshcore <em>Companion</em> command or response. Archipelago builds these in <code>mesh/protocol.rs</code> and parses replies in <code>mesh/listener/decode.rs</code>.</p>
|
||||
|
||||
<h3>Companion commands Archipelago uses</h3>
|
||||
<table>
|
||||
<thead><tr><th>Code</th><th>Name</th><th>Purpose</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>0x01</code></td><td>APP_START</td><td>Handshake; device returns its node_id and name</td></tr>
|
||||
<tr><td><code>0x02</code></td><td>SEND_TXT_MSG</td><td>Send payload to a contact (targeted by 6-byte pubkey prefix)</td></tr>
|
||||
<tr><td><code>0x03</code></td><td>SEND_CHANNEL_TXT_MSG</td><td>Broadcast on a channel (no specific recipient)</td></tr>
|
||||
<tr><td><code>0x04</code></td><td>GET_CONTACTS</td><td>Pull the device's contact table</td></tr>
|
||||
<tr><td><code>0x06</code></td><td>SET_DEVICE_TIME</td><td>Sync Unix timestamp for message dating</td></tr>
|
||||
<tr><td><code>0x07</code></td><td>SEND_SELF_ADVERT</td><td>Broadcast our identity onto the mesh</td></tr>
|
||||
<tr><td><code>0x08</code></td><td>SET_ADVERT_NAME</td><td>Set our display name</td></tr>
|
||||
<tr><td><code>0x0A</code></td><td>SYNC_NEXT_MESSAGE</td><td>Pop the next queued inbound message</td></tr>
|
||||
<tr><td><code>0x0B</code></td><td>SET_RADIO_PARAMS</td><td>Frequency, spreading factor, bandwidth</td></tr>
|
||||
<tr><td><code>0x0C</code></td><td>SET_RADIO_TX_POWER</td><td>Transmit power (dBm)</td></tr>
|
||||
<tr><td><code>0x38</code></td><td>GET_STATS</td><td>Device statistics</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Responses and push notifications</h3>
|
||||
<p>Responses begin with a status byte. Codes <code>< 0x80</code> are replies to a command we sent; codes <code>>= 0x80</code> are asynchronous push events from the device.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Code</th><th>Name</th><th>Meaning</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>0x00</code></td><td>RESP_OK</td><td>Command accepted</td></tr>
|
||||
<tr><td><code>0x01</code></td><td>RESP_ERR</td><td>Command failed + error code</td></tr>
|
||||
<tr><td><code>0x03</code></td><td>RESP_CONTACT</td><td>One contact entry (32-byte pubkey + metadata)</td></tr>
|
||||
<tr><td><code>0x05</code></td><td>RESP_SELF_INFO</td><td>Our node_id and name after APP_START</td></tr>
|
||||
<tr><td><code>0x10</code></td><td>RESP_CONTACT_MSG_V3</td><td>Direct inbound message (SNR + sender prefix + payload)</td></tr>
|
||||
<tr><td><code>0x11</code></td><td>RESP_CHANNEL_MSG_V3</td><td>Channel broadcast inbound</td></tr>
|
||||
<tr><td><code>0x83</code></td><td>PUSH_MESSAGES_WAITING</td><td>Async: new messages in queue, call SYNC_NEXT_MESSAGE</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="wire">Wire Format — the payload byte 0</h2>
|
||||
<p>Once a frame reaches the message payload, Archipelago looks at the <strong>first byte</strong> to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.</p>
|
||||
|
||||
<div class="diagram"><span class="highlight">0x00</span> Plain text (legacy, unencrypted)
|
||||
<span class="highlight">0x01</span> Identity broadcast (ARCHY:2 / ARCHY:3)
|
||||
<span class="highlight">0x02</span> Typed CBOR envelope (plaintext, used for debug or intra-LAN)
|
||||
<span class="highlight">0xEE</span> Encrypted typed — ChaCha20-Poly1305 w/ static shared secret
|
||||
<span class="highlight">0xDD</span> Ratcheted typed — Double Ratchet, forward-secure</div>
|
||||
|
||||
<p>Markers <code>0xEE</code> and <code>0xDD</code> are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.</p>
|
||||
|
||||
<h3>0xEE — static-key encrypted envelope</h3>
|
||||
<pre><code>[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]</code></pre>
|
||||
<ul>
|
||||
<li>Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.</li>
|
||||
<li>Cipher: ChaCha20-Poly1305 AEAD.</li>
|
||||
<li>Max plaintext: <code>160 − 1 − 12 − 16 = 131</code> bytes (see <code>crypto::MAX_ENCRYPTED_PLAINTEXT</code>).</li>
|
||||
<li>Properties: confidential + authenticated, <em>but</em> compromise of a key decrypts all history.</li>
|
||||
</ul>
|
||||
|
||||
<h3>0xDD — Double Ratchet envelope</h3>
|
||||
<pre><code>[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]</code></pre>
|
||||
<ul>
|
||||
<li>Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).</li>
|
||||
<li>Handles out-of-order delivery via a skipped-keys cache.</li>
|
||||
<li>Properties: forward secrecy + post-compromise recovery. Used for <code>mesh.*</code> chat once a session is established.</li>
|
||||
<li>Implementation: <code>mesh/ratchet.rs</code>, session load/save in <code>mesh/listener/session.rs</code>.</li>
|
||||
</ul>
|
||||
|
||||
<div class="callout callout-learn">
|
||||
<strong>Static key vs. ratchet = a safe vs. a self-shredding envelope.</strong>
|
||||
The <code>0xEE</code> lane is like a locked safe: one key opens everything. The <code>0xDD</code> lane is like handing your friend a new envelope each time, and burning the old one — so even if someone steals next week's key, they can't read last week's messages.
|
||||
</div>
|
||||
|
||||
<h2 id="crypto">Encryption Layers</h2>
|
||||
<p>Three cryptographic primitives combine to produce the <code>0xDD</code> ratchet flow:</p>
|
||||
|
||||
<div class="card-grid">
|
||||
<div class="card-sm">
|
||||
<h4>X25519 ECDH</h4>
|
||||
<p>Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>HKDF-SHA256</h4>
|
||||
<p>Derives root key, chain key, and message key at each ratchet step.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>ChaCha20-Poly1305</h4>
|
||||
<p>Symmetric AEAD used for the actual payload encryption + authentication tag.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>Session bootstrap — X3DH-like handshake</h3>
|
||||
<p>Before the ratchet can start, peers exchange a <strong>PrekeyBundle</strong> (type 5) and a <strong>SessionInit</strong> (type 6). Those two messages are carried by the <code>0xEE</code> static-key envelope, because the ratchet session doesn't exist yet. Once <code>SessionInit</code> is processed, subsequent traffic switches to <code>0xDD</code>. See <code>mesh/x3dh.rs</code>.</p>
|
||||
|
||||
<h2 id="fragmentation">Fragmentation — how a 500-byte message rides a 160-byte pipe</h2>
|
||||
<p>The LoRa frame budget is <strong>160 bytes</strong> (<code>protocol::MAX_MESSAGE_LEN</code>). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.</p>
|
||||
|
||||
<div class="diagram"><span class="highlight">Chunk header</span> ┌──────────┬──────────┬────────────┐
|
||||
│ type (1) │ id (1) │ total (1) │
|
||||
└──────────┴──────────┴────────────┘
|
||||
<span class="highlight">Chunk body</span> Up to 140 bytes of Base64-encoded payload
|
||||
|
||||
Sender: compress → encrypt → split into 140-char chunks
|
||||
→ send with tiny inter-chunk delay
|
||||
Receiver: accumulate by (sender, chunk_id) → reassemble
|
||||
→ decrypt → decompress → dispatch</div>
|
||||
|
||||
<p>For chat messages shorter than 160 bytes, none of this kicks in — the whole thing fits in one frame. For larger payloads (long messages, forwarded content, PSBTs), the sender splits and the receiver joins.</p>
|
||||
|
||||
<div class="callout callout-info">
|
||||
<strong>Escape hatch: federation fallback.</strong> If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago <em>skips LoRa entirely</em> and routes the message over Tor federation instead. See the <code>ContentRef</code> path in <code>rpc/mesh/typed_messages.rs</code>.
|
||||
</div>
|
||||
|
||||
<h2 id="dual-transport">Dual Transport — LoRa + Tor federation</h2>
|
||||
<p>Archipelago treats LoRa and Tor federation as <strong>two lanes of the same highway</strong>. A single chat window may receive some messages over radio and others over onion routing, and the UI doesn't distinguish. The mesh module picks the lane per-message based on the peer type and payload size.</p>
|
||||
|
||||
<div class="diagram"> ┌──────────────────┐
|
||||
│ mesh.send(...) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
┌──────────┴──────────┐
|
||||
│ Is peer synthetic? │
|
||||
└──────────┬──────────┘
|
||||
No │ Yes
|
||||
┌──────────┘ └──────────┐
|
||||
▼ ▼
|
||||
<span class="highlight">LoRa radio</span> <span class="blue">Tor federation</span>
|
||||
(160-byte frame) (unlimited, slower setup)
|
||||
│ │
|
||||
│ if > 160 B && synth ──────┘ (fallback)
|
||||
▼
|
||||
Chunked over LoRa
|
||||
or refused if no fallback</div>
|
||||
|
||||
<h2 id="addressing">Addressing</h2>
|
||||
<ul>
|
||||
<li><strong>Contact ID</strong> — 32-bit handle from Meshcore's contact table. Used by <code>SEND_TXT_MSG</code>.</li>
|
||||
<li><strong>Pubkey prefix</strong> — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.</li>
|
||||
<li><strong>DID / onion</strong> — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="synthetic">Synthetic federation contacts</h2>
|
||||
<p>To let the chat list show federation peers <em>before</em> any message arrives, Archipelago inserts <strong>synthetic contacts</strong> into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (<code>≥ 0x8000_0000</code>), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 id="msg-overview">All 23 Message Types</h2>
|
||||
<p>Every typed message is a CBOR envelope identified by a single <code>MeshMessageType</code> byte. The <strong>Transport</strong> column shows which marker carries it on the wire and which Companion command is used.</p>
|
||||
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>ID</th><th>Type</th><th>Purpose</th><th>Marker</th><th>Cmd</th><th>Chunked?</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td>0</td><td>Text</td><td>Plain chat message</td><td>0xDD</td><td>0x02</td><td>If >160 B</td></tr>
|
||||
<tr><td>1</td><td>Alert</td><td>Emergency / dead-man heartbeat</td><td>0xDD</td><td>0x02/0x03</td><td>No (short)</td></tr>
|
||||
<tr><td>2</td><td>Invoice</td><td>Lightning / BOLT11 invoice</td><td>0xDD</td><td>0x02</td><td>Usually</td></tr>
|
||||
<tr><td>3</td><td>PsbtHash</td><td>Unsigned tx hash for co-signing</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>4</td><td>Coordinate</td><td>GPS location share</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>5</td><td>PrekeyBundle</td><td>X3DH bootstrap (pre-session)</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>6</td><td>SessionInit</td><td>Initial ratchet message</td><td>0xEE</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>7</td><td>BlockHeader</td><td>Bitcoin block height/hash</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||
<tr><td>8</td><td>TxRelay</td><td>Signed Bitcoin tx for on-grid peer to broadcast</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||
<tr><td>9</td><td>TxRelayResponse</td><td>txid or error from the relay peer</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>10</td><td>LightningRelay</td><td>BOLT11 to pay via on-grid peer</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||
<tr><td>11</td><td>LightningRelayResponse</td><td>payment_hash or error</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>12</td><td>TxConfirmation</td><td>Depth update (1/2/3 confs)</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>13</td><td>Reply</td><td>Quoted reply to a previous message</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||
<tr><td>14</td><td>Reaction</td><td>Emoji reaction on MessageKey</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>15</td><td>ReadReceipt</td><td>"Seen up to MessageKey X"</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>16</td><td>Forward</td><td>Re-forwarded original w/ provenance</td><td>0xDD</td><td>0x02</td><td>Yes</td></tr>
|
||||
<tr><td>17</td><td>Edit</td><td>In-place text replacement</td><td>0xDD</td><td>0x02</td><td>If long</td></tr>
|
||||
<tr><td>18</td><td>Delete</td><td>Tombstone for earlier message</td><td>0xDD</td><td>0x02</td><td>No</td></tr>
|
||||
<tr><td>19</td><td>ContentRef</td><td>CID of blob held by sender (file/image)</td><td>0xDD</td><td>0x02 or Tor</td><td>Federation fallback</td></tr>
|
||||
<tr><td>20</td><td>Presence</td><td>Heartbeat + last-activity epoch</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||
<tr><td>21</td><td>ChannelInvite</td><td>Group membership announcement</td><td>0xDD</td><td>0x03</td><td>No</td></tr>
|
||||
<tr><td>22</td><td>ContactCard</td><td>Shareable federation node card</td><td>0xDD</td><td>0x02</td><td>Maybe</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.</p>
|
||||
|
||||
<h2 id="msg-text">Text, Reply, Edit, Delete, Forward</h2>
|
||||
|
||||
<h3>Text (type 0)</h3>
|
||||
<p><strong>Sender path.</strong> <code>rpc.mesh.send</code> → <code>typed_messages::send_text</code> → CBOR-encode the <code>Text{body}</code> variant → ratchet-encrypt → prefix <code>0xDD</code> → if under 160 B, send in one <code>SEND_TXT_MSG</code> frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.</p>
|
||||
|
||||
<h3>Reply (type 13)</h3>
|
||||
<p>Same as Text, but the CBOR envelope carries a <code>MessageKey</code> pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.</p>
|
||||
|
||||
<h3>Edit (type 17)</h3>
|
||||
<p>Envelope contains the original <code>MessageKey</code> plus the new body. Receiver updates its local store in-place and tags the entry "edited".</p>
|
||||
|
||||
<h3>Delete (type 18)</h3>
|
||||
<p>Tombstone only: <code>MessageKey</code> with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.</p>
|
||||
|
||||
<h3>Forward (type 16)</h3>
|
||||
<p>Wraps original <code>{sender_name, original_timestamp, body}</code> so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are <em>almost always</em> chunked.</p>
|
||||
|
||||
<h2 id="msg-social">Reaction, ReadReceipt, Presence</h2>
|
||||
|
||||
<h3>Reaction (type 14)</h3>
|
||||
<p>Envelope: <code>{target: MessageKey, emoji: String}</code>. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see <code>MessageActions</code> in <code>neode-ui</code>).</p>
|
||||
|
||||
<h3>ReadReceipt (type 15)</h3>
|
||||
<p>Envelope: <code>{up_to: MessageKey}</code>. Semantically "I've seen everything up to and including this message." One receipt covers all prior unread, so traffic is O(1) per read burst rather than O(n).</p>
|
||||
|
||||
<h3>Presence (type 20)</h3>
|
||||
<p>Periodic heartbeat carrying <code>{last_activity_epoch}</code>. Broadcast on a channel (<code>SEND_CHANNEL_TXT_MSG</code>, cmd <code>0x03</code>) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.</p>
|
||||
|
||||
<div class="callout callout-learn">
|
||||
<strong>Like a lighthouse beacon.</strong> Presence doesn't go to anyone in particular — it's a flash that everyone in radio range can see. "I'm still here, last active two minutes ago." Cheap and unaddressed.
|
||||
</div>
|
||||
|
||||
<h2 id="msg-content">ContentRef — files and images without bloating the radio</h2>
|
||||
<p>LoRa cannot move a 500 KB image. The <code>ContentRef</code> type (19) solves this by sending only a <strong>pointer</strong> — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.</p>
|
||||
|
||||
<div class="diagram">Sender Receiver
|
||||
────── ────────
|
||||
store blob locally (CID)
|
||||
┌──────────────────────┐
|
||||
│ ContentRef {cid, │ ──ratchet──▶
|
||||
│ mime, size, │ 0xDD
|
||||
│ thumb_hash} │ over LoRa
|
||||
└──────────────────────┘
|
||||
see CID in chat
|
||||
click to fetch
|
||||
┌─────────────────┐
|
||||
│ rpc.mesh.fetch- │
|
||||
│ content(cid) │
|
||||
└────────┬────────┘
|
||||
▼
|
||||
federation (Tor)
|
||||
resolve DID → pull blob</div>
|
||||
|
||||
<div class="callout callout-info">
|
||||
<strong>Resolution bug fix note.</strong> An earlier revision of <code>ContentRef</code> routed the fetch via a name-match on the contact list, which broke when two peers had the same display name. The fix (see commit <code>5f7ebf14</code>) resolves the owning peer by DID and falls back to name-match only if DID lookup fails.
|
||||
</div>
|
||||
|
||||
<h2 id="msg-bitcoin">Bitcoin & Lightning over LoRa</h2>
|
||||
<p>Archipelago uses the mesh as a <strong>Bitcoin transport of last resort</strong>. Signed transactions travel from an offline signer, through the mesh, to a peer with internet, who then rebroadcasts them to the Bitcoin network and reports back.</p>
|
||||
|
||||
<h3>TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)</h3>
|
||||
<div class="diagram">Offline signer On-grid relay peer Bitcoin p2p
|
||||
────────────── ────────────────── ───────────
|
||||
sign tx
|
||||
┌─────────────┐
|
||||
│ TxRelay │ ─ratchet/LoRa▶ decrypt → validate
|
||||
│ {raw_tx} │ broadcast via bitcoind ───▶ mempool
|
||||
└─────────────┘ │
|
||||
▼
|
||||
┌────────────────────────┐
|
||||
◀─ratchet│ TxRelayResponse{txid} │
|
||||
└────────────────────────┘
|
||||
(or {error})
|
||||
|
||||
later, as blocks arrive:
|
||||
┌────────────────────────┐
|
||||
◀─ratchet│ TxConfirmation │
|
||||
│ {txid, depth: 1..3} │
|
||||
└────────────────────────┘</div>
|
||||
|
||||
<p>The binary framing in <code>mesh/bitcoin_relay.rs</code> is intentionally tight — raw binary, not CBOR — to keep a signed 1-input/1-output tx inside one or two 160-byte frames. Confirmation updates are tiny (txid + depth byte) and ride in a single frame.</p>
|
||||
|
||||
<h3>LightningRelay (10) → LightningRelayResponse (11)</h3>
|
||||
<p>Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns <code>payment_hash</code> or an error. Invoices are often long enough to chunk.</p>
|
||||
|
||||
<h3>Invoice (2) and PsbtHash (3)</h3>
|
||||
<p>These are <em>not</em> relays — they're peer-to-peer handoffs. <code>Invoice</code> delivers a BOLT11 to be paid by the recipient. <code>PsbtHash</code> carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.</p>
|
||||
|
||||
<h3>BlockHeader (7)</h3>
|
||||
<p>Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via <code>SEND_CHANNEL_TXT_MSG</code>) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.</p>
|
||||
|
||||
<h2 id="msg-safety">Alerts, Coordinates, Dead-Man</h2>
|
||||
|
||||
<h3>Alert (type 1)</h3>
|
||||
<p>Envelope: <code>{kind, message, sender_contact_id}</code>. Kinds include <code>Emergency</code> and <code>Deadman</code>. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).</p>
|
||||
|
||||
<h3>Dead-man switch</h3>
|
||||
<p>A background task in <code>mesh/alerts.rs</code> sends a <code>Deadman</code> alert on a configurable interval (default 6 hours). If the user doesn't touch the UI within that window, the alert fires automatically and asks chosen recipients to check in. Powered off? The next peer to receive your last heartbeat notices the gap.</p>
|
||||
|
||||
<h3>Coordinate (type 4)</h3>
|
||||
<p>Envelope: <code>{lat, lon, accuracy_m}</code> with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.</p>
|
||||
|
||||
<h3>ChannelInvite (type 21)</h3>
|
||||
<p>Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via <code>SEND_CHANNEL_TXT_MSG</code>.</p>
|
||||
|
||||
<h2 id="msg-identity">Identity, PrekeyBundle, ContactCard</h2>
|
||||
|
||||
<h3>Identity broadcast (marker 0x01, ARCHY:2/3)</h3>
|
||||
<p>The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed <code>0x01</code>. This is how peers discover each other. The payload encodes protocol version (<code>ARCHY:2</code> or <code>ARCHY:3</code>) and the raw pubkey. Carried by <code>CMD_SEND_SELF_ADVERT</code> (<code>0x07</code>).</p>
|
||||
|
||||
<h3>PrekeyBundle (type 5) and SessionInit (type 6)</h3>
|
||||
<p>X3DH handshake. <code>PrekeyBundle</code> advertises a signed prekey; <code>SessionInit</code> consumes it to derive the initial ratchet root key. Both ride on <code>0xEE</code> (static-key encryption), because the ratchet session they're creating doesn't yet exist.</p>
|
||||
|
||||
<h3>ContactCard (type 22)</h3>
|
||||
<p>A shareable card containing <code>{did, onion_address, pubkey, display_name}</code>. When a receiver taps "add" on the card, Archipelago one-click federates with that node over Tor. This is the bridge that lets LoRa-discovered peers become full federation contacts.</p>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2 id="rpc">RPC API — what callers actually invoke</h2>
|
||||
<p>Every user-facing action goes through the RPC dispatcher (<code>api/rpc/dispatcher.rs</code>, lines 287+) and ends in <code>api/rpc/mesh/typed_messages.rs</code>. The tables below show the public surface.</p>
|
||||
|
||||
<h3>Core commands</h3>
|
||||
<table>
|
||||
<thead><tr><th>RPC</th><th>Effect</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>mesh.status</code></td><td>Device info, peer count, enabled state</td></tr>
|
||||
<tr><td><code>mesh.peers</code></td><td>List all discovered peers with RSSI / SNR / hop count</td></tr>
|
||||
<tr><td><code>mesh.messages</code></td><td>Retrieve stored mesh messages</td></tr>
|
||||
<tr><td><code>mesh.send</code></td><td>Send plain text to a specific peer</td></tr>
|
||||
<tr><td><code>mesh.send-channel</code></td><td>Broadcast on a channel</td></tr>
|
||||
<tr><td><code>mesh.broadcast</code></td><td>Mesh-wide announcement</td></tr>
|
||||
<tr><td><code>mesh.configure</code></td><td>Set device params (name, power, channel)</td></tr>
|
||||
<tr><td><code>mesh.debug-dump</code></td><td>Raw state for debugging</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Rich message commands</h3>
|
||||
<table>
|
||||
<thead><tr><th>RPC</th><th>Msg Type</th><th>Notes</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>mesh.send-invoice</code></td><td>Invoice (2)</td><td>Deliver BOLT11 to peer</td></tr>
|
||||
<tr><td><code>mesh.send-coordinate</code></td><td>Coordinate (4)</td><td>Single frame, fixed-point</td></tr>
|
||||
<tr><td><code>mesh.send-alert</code></td><td>Alert (1)</td><td>Emergency or deadman</td></tr>
|
||||
<tr><td><code>mesh.send-content</code></td><td>ContentRef (19)</td><td>Stores blob, sends CID</td></tr>
|
||||
<tr><td><code>mesh.fetch-content</code></td><td>—</td><td>Pulls blob via federation</td></tr>
|
||||
<tr><td><code>mesh.send-psbt</code></td><td>PsbtHash (3)</td><td>Hash only, full PSBT via fetch</td></tr>
|
||||
<tr><td><code>mesh.send-reply</code></td><td>Reply (13)</td><td>Quoted response</td></tr>
|
||||
<tr><td><code>mesh.send-reaction</code></td><td>Reaction (14)</td><td>Emoji</td></tr>
|
||||
<tr><td><code>mesh.send-read-receipt</code></td><td>ReadReceipt (15)</td><td>Cumulative "seen up to"</td></tr>
|
||||
<tr><td><code>mesh.forward-message</code></td><td>Forward (16)</td><td>Wraps original + provenance</td></tr>
|
||||
<tr><td><code>mesh.edit-message</code></td><td>Edit (17)</td><td>In-place text replacement</td></tr>
|
||||
<tr><td><code>mesh.delete-message</code></td><td>Delete (18)</td><td>Tombstone</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 id="ui">User Interface</h2>
|
||||
<p>The Vue side lives under <code>neode-ui/src/views/mesh/</code> with state in <code>stores/mesh.ts</code>. Notable panels:</p>
|
||||
<div class="card-grid">
|
||||
<div class="card-sm">
|
||||
<h4>Mesh chat</h4>
|
||||
<p>Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>MeshBitcoinPanel</h4>
|
||||
<p>UI for TxRelay / LightningRelay submission and confirmation tracking.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>MeshDeadmanPanel</h4>
|
||||
<p>Configure dead-man interval, pick recipients, show last heartbeat time.</p>
|
||||
</div>
|
||||
<div class="card-sm">
|
||||
<h4>Unified inbox</h4>
|
||||
<p>Federation and mesh chats appear side-by-side; the transport is invisible to the user.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 id="listener">Listener loop — how inbound traffic is decoded</h2>
|
||||
<p>A long-running async task in <code>mesh/listener/mod.rs</code> owns the serial device and feeds events into the rest of the system.</p>
|
||||
|
||||
<div class="diagram">loop {
|
||||
event = await serial_read()
|
||||
match event {
|
||||
<span class="green">PUSH_MESSAGES_WAITING</span> → send SYNC_NEXT_MESSAGE until empty
|
||||
<span class="green">RESP_CONTACT_MSG_V3</span> → decode.rs extracts payload
|
||||
→ match first byte:
|
||||
<span class="highlight">0x00</span> plain text
|
||||
<span class="highlight">0x01</span> identity → frames::parse_identity
|
||||
<span class="highlight">0x02</span> typed CBOR plaintext
|
||||
<span class="highlight">0xEE</span> → crypto::decrypt_static
|
||||
<span class="highlight">0xDD</span> → session::load + ratchet::decrypt
|
||||
→ dispatch.rs routes typed msg
|
||||
to chat store / bitcoin relay /
|
||||
alerts / presence / ...
|
||||
<span class="green">RESP_CONTACT</span> → contact list update
|
||||
<span class="green">RESP_SELF_INFO</span> → record our node_id
|
||||
}
|
||||
}</div>
|
||||
|
||||
<p>Chunk reassembly happens in <code>listener/session.rs</code>, keyed by <code>(sender_pubkey_prefix, chunk_id)</code>. Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.</p>
|
||||
|
||||
<h2 id="files">File Map</h2>
|
||||
<table>
|
||||
<thead><tr><th>File</th><th>Size</th><th>Role</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>mesh/mod.rs</code></td><td>52 KB</td><td>Public API, send paths, federation integration</td></tr>
|
||||
<tr><td><code>mesh/protocol.rs</code></td><td>26 KB</td><td>Frame encoding/decoding, command builders</td></tr>
|
||||
<tr><td><code>mesh/serial.rs</code></td><td>15 KB</td><td>USB driver, device detection, handshake</td></tr>
|
||||
<tr><td><code>mesh/crypto.rs</code></td><td>10 KB</td><td>X25519 ECDH, ChaCha20-Poly1305, HKDF</td></tr>
|
||||
<tr><td><code>mesh/ratchet.rs</code></td><td>16 KB</td><td>Double Ratchet implementation</td></tr>
|
||||
<tr><td><code>mesh/message_types.rs</code></td><td>23 KB</td><td>23 typed message discriminators + CBOR schemas</td></tr>
|
||||
<tr><td><code>mesh/bitcoin_relay.rs</code></td><td>17 KB</td><td>TxRelay / LightningRelay binary framing</td></tr>
|
||||
<tr><td><code>mesh/listener/dispatch.rs</code></td><td>29 KB</td><td>Typed-message routing into chat/relay/alerts</td></tr>
|
||||
<tr><td><code>mesh/listener/session.rs</code></td><td>14 KB</td><td>Ratchet session persistence + chunk reassembly</td></tr>
|
||||
<tr><td><code>mesh/x3dh.rs</code></td><td>—</td><td>Prekey / SessionInit bootstrap</td></tr>
|
||||
<tr><td><code>mesh/outbox.rs</code></td><td>—</td><td>Retry queue for unacked sends</td></tr>
|
||||
<tr><td><code>mesh/steganography.rs</code></td><td>—</td><td>Weather/sensor framing for deniable traffic</td></tr>
|
||||
<tr><td><code>api/rpc/mesh/typed_messages.rs</code></td><td>—</td><td>All <code>mesh.*</code> RPC handlers</td></tr>
|
||||
<tr><td><code>neode-ui/src/stores/mesh.ts</code></td><td>14 KB</td><td>Pinia store consumed by all mesh Vue views</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr>
|
||||
|
||||
<h2>Summary scoreboard</h2>
|
||||
<div class="score-grid">
|
||||
<div class="score-card"><div class="score">23</div><div class="label">Message types</div></div>
|
||||
<div class="score-card"><div class="score">160</div><div class="label">Bytes / frame</div></div>
|
||||
<div class="score-card"><div class="score">2</div><div class="label">Transports</div></div>
|
||||
<div class="score-card"><div class="score">5</div><div class="label">Wire markers</div></div>
|
||||
<div class="score-card"><div class="score">~6k</div><div class="label">LoC in mesh/</div></div>
|
||||
<div class="score-card"><div class="score">FS</div><div class="label">Forward-secure</div></div>
|
||||
</div>
|
||||
|
||||
<div class="callout callout-success">
|
||||
<strong>Bottom line.</strong> Archipelago's mesh isn't a chat toy. It's a complete off-grid transport with forward-secure end-to-end encryption, 23 typed message kinds, Bitcoin and Lightning relay, fragmentation, store-and-forward, and a seamless Tor federation fallback. From the user's perspective it looks like iMessage; from the wire's perspective it's a carefully budgeted 160 bytes of ChaCha20 ciphertext riding on a sub-kbps radio link.
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,525 @@
|
||||
# Rust Orchestrator Migration — Design Doc
|
||||
|
||||
Status: **DRAFT — pending user approval**
|
||||
Author: OpenCode session, 2026-04-22
|
||||
Supersedes planning in `docs/bulletproof-containers.md` v1.7.43 slot
|
||||
|
||||
## Problem statement
|
||||
|
||||
Today, the archipelago backend has **no production container orchestrator**. Production containers (bitcoin-knots, lnd, electrumx, btcpay, filebrowser, and the three custom UIs archy-bitcoin-ui / archy-electrs-ui / archy-lnd-ui) are installed by **bash scripts** at first boot (`scripts/first-boot-containers.sh`) and optionally reconciled by another bash script (`scripts/reconcile-containers.sh`) that is **not enabled by default**. The existing `DevContainerOrchestrator` (`core/archipelago/src/container/dev_orchestrator.rs`) is hardcoded to append `-dev` suffixes and gated behind `config.dev_mode`, so it has never managed a production container.
|
||||
|
||||
This design migrates production container management into Rust, under a single orchestrator that owns install, start, stop, restart, upgrade, uninstall, health, and self-healing for every container. The three custom UI containers are the first-class test fixture: they exercise the "build image from local Dockerfile" path (which today doesn't exist in the manifest schema) and their lifecycle was the original failure class the user asked to fix.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Backwards compatibility with `first-boot-containers.sh`: we **delete** it and its systemd unit after verifying Rust parity.
|
||||
- Backwards compatibility with the existing `package-install` RPC’s podman shell-outs: those get rewritten to call the orchestrator.
|
||||
- Registry signature verification: `image_signature` stays optional. Sigstore/cosign integration is out of scope.
|
||||
- Network isolation improvements: existing SecurityPolicy fields stay as-is.
|
||||
- Dev mode removal: `DevContainerOrchestrator` keeps existing behavior for local development; prod code path is separate.
|
||||
|
||||
## Scope of this migration
|
||||
|
||||
In scope:
|
||||
1. Extend `ContainerConfig` schema with a `source:` variant supporting `{type: build, context, dockerfile, tag}` alongside `{type: pull, image, pull_policy}`.
|
||||
2. Extend `ContainerRuntime` trait + `PodmanRuntime` impl with `build_image(...)` and `image_exists(...)`.
|
||||
3. Introduce `ProdContainerOrchestrator` (new type) with identical public surface to `DevContainerOrchestrator` but **no `-dev` suffix**, **no port offset**, **no data-path rewriting**, **no bitcoin_simulator gate**. It is wired into `RpcHandler::orchestrator` in prod (currently `None`).
|
||||
4. Add `AdoptionScan` at orchestrator startup: enumerate `podman ps -a`, match by container name against declared manifests, adopt into orchestrator state without recreating.
|
||||
5. Add `BootReconciler` task spawned from `main.rs` (replacing the commented-out `run_boot_reconciliation` hook). Walks the manifest set on startup and periodically, ensures each is present-and-running, builds/pulls/creates anything missing, logs failures non-silently.
|
||||
6. Ship three manifests in the repo: `apps/bitcoin-ui/manifest.yml`, `apps/electrs-ui/manifest.yml`, `apps/lnd-ui/manifest.yml`. They use the new `source: build` variant pointing at `/opt/archipelago/docker/<name>/`.
|
||||
7. Delete `scripts/first-boot-containers.sh`, `scripts/reconcile-containers.sh`, `scripts/container-specs.sh`, `image-recipe/configs/archipelago-first-boot-containers.service`, `image-recipe/configs/archipelago-reconcile.service`. Remove enablement from ISO builder.
|
||||
|
||||
Out of scope this migration (tracked separately):
|
||||
- Migrating btcpay / mempool / fedimint multi-container stacks to manifests (they currently live in `core/archipelago/src/api/rpc/package/stacks.rs`). They keep working via `package-install` RPC. Phase 2.
|
||||
- Rewriting the 26 existing `apps/*/manifest.yml` files to use the new `source:` schema. They stay on `image:` for now; the schema is **additive and backwards-compatible**.
|
||||
- Re-enabling signature verification; stays todo.
|
||||
|
||||
## Data model changes
|
||||
|
||||
### 1. `ContainerConfig` gets a `source` enum
|
||||
|
||||
File: `core/container/src/manifest.rs:58`
|
||||
|
||||
**Before:**
|
||||
```rust
|
||||
pub struct ContainerConfig {
|
||||
pub image: String,
|
||||
pub image_signature: Option<String>,
|
||||
pub pull_policy: String,
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```rust
|
||||
pub struct ContainerConfig {
|
||||
// Legacy shorthand (backwards compatible with all 26 existing manifests):
|
||||
// if `source` is absent, `image` + `pull_policy` are interpreted as
|
||||
// `source: { type: pull, image, pull_policy }`.
|
||||
#[serde(default)]
|
||||
pub image: String,
|
||||
#[serde(default)]
|
||||
pub image_signature: Option<String>,
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pub pull_policy: String,
|
||||
|
||||
// New: explicit source. If present, overrides the legacy shorthand.
|
||||
#[serde(default)]
|
||||
pub source: Option<ContainerSource>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum ContainerSource {
|
||||
/// Pull an image from a registry.
|
||||
Pull {
|
||||
image: String,
|
||||
#[serde(default)]
|
||||
image_signature: Option<String>,
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pull_policy: String,
|
||||
},
|
||||
/// Build an image from a local Dockerfile.
|
||||
Build {
|
||||
/// Filesystem path to build context, absolute or relative to manifest dir.
|
||||
context: String,
|
||||
/// Dockerfile path relative to context. Defaults to "Dockerfile".
|
||||
#[serde(default = "default_dockerfile")]
|
||||
dockerfile: String,
|
||||
/// Tag to assign to the built image, e.g. "localhost/bitcoin-ui:local".
|
||||
tag: String,
|
||||
/// `--build-arg` key=value pairs.
|
||||
#[serde(default)]
|
||||
build_args: HashMap<String, String>,
|
||||
/// If true, rebuild on every reconcile. If false, only build when tag is missing.
|
||||
#[serde(default)]
|
||||
always_rebuild: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Validation in `AppManifest::validate`:
|
||||
- If `source` is absent AND `image` is empty → error (unchanged rule just rephrased).
|
||||
- If `source` is present, legacy `image` field is ignored with a warning.
|
||||
- `Build::context` must resolve to an existing directory that contains `dockerfile`.
|
||||
|
||||
Tests to add:
|
||||
- Parse a legacy manifest → works, produces `ContainerSource::Pull` at resolution time.
|
||||
- Parse a `source: { type: build, ... }` manifest → works.
|
||||
- Parse a manifest with both legacy `image:` and `source:` → warning logged, `source:` wins.
|
||||
- Parse a manifest with neither → rejected.
|
||||
|
||||
### 2. `ContainerRuntime` trait gets `build_image` + `image_exists`
|
||||
|
||||
File: `core/container/src/runtime.rs:10`
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait ContainerRuntime: Send + Sync {
|
||||
// existing methods unchanged...
|
||||
async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()>;
|
||||
async fn create_container(...) -> Result<()>;
|
||||
// ...
|
||||
|
||||
// NEW:
|
||||
/// Build an image from a local Dockerfile. Returns Ok(()) if the image now
|
||||
/// exists under the given tag (whether newly built or already present and
|
||||
/// `force=false`). Returns Err if the build failed.
|
||||
async fn build_image(
|
||||
&self,
|
||||
context: &Path,
|
||||
dockerfile: &str,
|
||||
tag: &str,
|
||||
build_args: &HashMap<String, String>,
|
||||
force: bool,
|
||||
) -> Result<()>;
|
||||
|
||||
/// Check if an image exists in the local image store.
|
||||
async fn image_exists(&self, tag: &str) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
|
||||
`PodmanRuntime::build_image` shells out:
|
||||
```
|
||||
podman build --tag <tag> \
|
||||
--file <context>/<dockerfile> \
|
||||
--build-arg KEY=VALUE ... \
|
||||
<context>
|
||||
```
|
||||
|
||||
Force-rebuild semantics: if `force=false`, skip when `image_exists(tag) == true`. If `force=true`, always build (podman's own layer cache handles the fast path).
|
||||
|
||||
Tests:
|
||||
- `build_image` happy path on a minimal Dockerfile (using a throwaway context in tmpdir).
|
||||
- `build_image` failure path (nonsense Dockerfile) → Err.
|
||||
- `image_exists` returns false for nonexistent tag.
|
||||
- `image_exists` returns true after `build_image`.
|
||||
|
||||
### 3. Manifest resolution: `ContainerSource::resolve(manifest_dir) -> ResolvedSource`
|
||||
|
||||
New method that turns the raw manifest into something the orchestrator can act on:
|
||||
|
||||
```rust
|
||||
pub enum ResolvedSource {
|
||||
Pull { image: String, signature: Option<String>, pull_policy: PullPolicy },
|
||||
Build { context: PathBuf, dockerfile: String, tag: String, build_args: HashMap<String,String>, always_rebuild: bool },
|
||||
}
|
||||
|
||||
impl ContainerConfig {
|
||||
pub fn resolve(&self, manifest_dir: &Path) -> Result<ResolvedSource> {
|
||||
match &self.source {
|
||||
Some(ContainerSource::Pull { image, image_signature, pull_policy }) => Ok(ResolvedSource::Pull { ... }),
|
||||
Some(ContainerSource::Build { context, dockerfile, tag, build_args, always_rebuild }) => {
|
||||
let abs_context = if Path::new(context).is_absolute() {
|
||||
PathBuf::from(context)
|
||||
} else {
|
||||
manifest_dir.join(context)
|
||||
};
|
||||
Ok(ResolvedSource::Build { context: abs_context, ... })
|
||||
}
|
||||
None => {
|
||||
// Legacy shorthand
|
||||
if self.image.is_empty() {
|
||||
return Err(...);
|
||||
}
|
||||
Ok(ResolvedSource::Pull { image: self.image.clone(), ... })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Runtime architecture
|
||||
|
||||
### `ProdContainerOrchestrator`
|
||||
|
||||
New file: `core/archipelago/src/container/prod_orchestrator.rs`
|
||||
|
||||
```rust
|
||||
pub struct ProdContainerOrchestrator {
|
||||
runtime: Arc<dyn ContainerRuntimeTrait>,
|
||||
manifests_dir: PathBuf, // e.g. /opt/archipelago/apps
|
||||
data_dir: PathBuf, // e.g. /var/lib/archipelago
|
||||
state: Arc<RwLock<OrchestratorState>>,
|
||||
config: Config,
|
||||
}
|
||||
|
||||
struct OrchestratorState {
|
||||
/// app_id → known manifest (loaded from disk at startup, refreshed on reconcile)
|
||||
manifests: HashMap<String, AppManifest>,
|
||||
/// app_id → current known state (from adoption scan or our own ops)
|
||||
containers: HashMap<String, ContainerState>,
|
||||
/// app_id → last install/health/build timestamp
|
||||
last_reconciled: HashMap<String, Instant>,
|
||||
}
|
||||
```
|
||||
|
||||
Public surface mirrors `DevContainerOrchestrator` but **container name = `archy-<app_id>` for UI apps, `<app_id>` for backends, matching existing .116 naming**:
|
||||
|
||||
```rust
|
||||
impl ProdContainerOrchestrator {
|
||||
pub async fn new(config: Config) -> Result<Self> { ... }
|
||||
pub async fn load_manifests(&self) -> Result<()> { /* walks manifests_dir */ }
|
||||
pub async fn adopt_existing(&self) -> Result<AdoptionReport> { /* scans podman ps -a */ }
|
||||
pub async fn reconcile_all(&self) -> Result<ReconcileReport> { /* ensures every manifest has a running container */ }
|
||||
pub async fn install(&self, app_id: &str) -> Result<()> { /* build-or-pull + create + start */ }
|
||||
pub async fn start(&self, app_id: &str) -> Result<()> { ... }
|
||||
pub async fn stop(&self, app_id: &str) -> Result<()> { ... }
|
||||
pub async fn restart(&self, app_id: &str) -> Result<()> { ... }
|
||||
pub async fn remove(&self, app_id: &str, preserve_data: bool) -> Result<()> { ... }
|
||||
pub async fn upgrade(&self, app_id: &str) -> Result<()> { /* re-read manifest, rebuild/pull, recreate */ }
|
||||
pub async fn status(&self, app_id: &str) -> Result<ContainerStatus> { ... }
|
||||
pub async fn list(&self) -> Result<Vec<ContainerStatus>> { ... }
|
||||
pub async fn logs(&self, app_id: &str, lines: u32) -> Result<Vec<String>> { ... }
|
||||
pub async fn health(&self, app_id: &str) -> Result<String> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Container naming rule** (matches `.116` existing fixture so adoption works):
|
||||
- If the manifest has `extensions["container_name"]` → use that verbatim.
|
||||
- Else if the app_id starts with `bitcoin-ui` / `electrs-ui` / `lnd-ui` → `archy-<app_id>`.
|
||||
- Else → `<app_id>`.
|
||||
|
||||
This is codified and tested; no ad-hoc naming in the codebase.
|
||||
|
||||
### `AdoptionScan`
|
||||
|
||||
On orchestrator startup, before any reconcile:
|
||||
|
||||
```rust
|
||||
async fn adopt_existing(&self) -> Result<AdoptionReport> {
|
||||
let all = self.runtime.list_containers().await?; // podman ps -a
|
||||
let mut report = AdoptionReport::default();
|
||||
for c in all {
|
||||
// For each manifest we have loaded, check if the expected container name matches
|
||||
for (app_id, manifest) in self.state.read().await.manifests.iter() {
|
||||
let expected_name = compute_container_name(manifest);
|
||||
if c.name == expected_name {
|
||||
// This container is ours. Record its state.
|
||||
self.state.write().await.containers.insert(app_id.clone(), c.state.clone());
|
||||
report.adopted.push(app_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
```
|
||||
|
||||
No recreate. No touching data volumes. Just "we now know this container belongs to app X and its current state is Y".
|
||||
|
||||
### `BootReconciler`
|
||||
|
||||
New file: `core/archipelago/src/container/boot_reconciler.rs`
|
||||
|
||||
```rust
|
||||
pub struct BootReconciler {
|
||||
orchestrator: Arc<ProdContainerOrchestrator>,
|
||||
interval: Duration, // e.g. 5 minutes
|
||||
shutdown: CancellationToken,
|
||||
}
|
||||
|
||||
impl BootReconciler {
|
||||
pub async fn run_forever(self) {
|
||||
// Initial reconcile immediately (after adoption).
|
||||
let _ = self.orchestrator.reconcile_all().await;
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(self.interval) => {
|
||||
let _ = self.orchestrator.reconcile_all().await;
|
||||
}
|
||||
_ = self.shutdown.cancelled() => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`reconcile_all`:
|
||||
```rust
|
||||
async fn reconcile_all(&self) -> Result<ReconcileReport> {
|
||||
let manifests: Vec<_> = self.state.read().await.manifests.values().cloned().collect();
|
||||
let mut report = ReconcileReport::default();
|
||||
for manifest in manifests {
|
||||
let app_id = &manifest.app.id;
|
||||
match self.ensure_running(&manifest).await {
|
||||
Ok(action) => report.record(app_id, action),
|
||||
Err(e) => {
|
||||
tracing::error!(app_id, error = %e, "Reconcile failed for app");
|
||||
report.failures.push((app_id.clone(), e.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !report.failures.is_empty() {
|
||||
// Surface via WebSocket so the UI can show a banner.
|
||||
self.notify_failures(&report).await;
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
async fn ensure_running(&self, manifest: &AppManifest) -> Result<ReconcileAction> {
|
||||
let name = compute_container_name(manifest);
|
||||
match self.runtime.get_container_status(&name).await {
|
||||
Ok(status) if matches!(status.state, ContainerState::Running) => Ok(ReconcileAction::NoOp),
|
||||
Ok(status) if matches!(status.state, ContainerState::Exited | ContainerState::Stopped) => {
|
||||
self.runtime.start_container(&name).await?;
|
||||
Ok(ReconcileAction::Started)
|
||||
}
|
||||
Ok(_) => Ok(ReconcileAction::NoOp), // Created / Paused — leave alone
|
||||
Err(_) => {
|
||||
// Container doesn't exist. Install it.
|
||||
self.install_fresh(manifest).await?;
|
||||
Ok(ReconcileAction::Installed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_fresh(&self, manifest: &AppManifest) -> Result<()> {
|
||||
let manifest_dir = ...; // directory of manifest.yml
|
||||
let resolved = manifest.app.container.resolve(manifest_dir)?;
|
||||
match resolved {
|
||||
ResolvedSource::Pull { image, signature, .. } => {
|
||||
self.runtime.pull_image(&image, signature.as_deref()).await?;
|
||||
}
|
||||
ResolvedSource::Build { context, dockerfile, tag, build_args, always_rebuild } => {
|
||||
if always_rebuild || !self.runtime.image_exists(&tag).await? {
|
||||
self.runtime.build_image(&context, &dockerfile, &tag, &build_args, always_rebuild).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.runtime.create_container(manifest, &compute_container_name(manifest), 0).await?;
|
||||
self.runtime.start_container(&compute_container_name(manifest)).await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Wire-up in `main.rs`
|
||||
|
||||
File: `core/archipelago/src/main.rs`
|
||||
|
||||
Replace the commented-out `run_boot_reconciliation` block (`main.rs:107-111`) with:
|
||||
|
||||
```rust
|
||||
// Load manifests + adopt existing + start reconciler loop.
|
||||
let orchestrator = Arc::new(ProdContainerOrchestrator::new(config.clone()).await?);
|
||||
orchestrator.load_manifests().await?;
|
||||
let adoption = orchestrator.adopt_existing().await?;
|
||||
tracing::info!(adopted = adoption.adopted.len(), "Container adoption complete");
|
||||
let reconciler = BootReconciler::new(orchestrator.clone(), Duration::from_secs(300), shutdown_token.clone());
|
||||
tokio::spawn(reconciler.run_forever());
|
||||
```
|
||||
|
||||
`RpcHandler` gets the orchestrator regardless of `dev_mode`:
|
||||
```rust
|
||||
// core/archipelago/src/api/rpc/mod.rs:83
|
||||
let orchestrator: Option<Arc<dyn ContainerOrchestrator>> = if config.dev_mode {
|
||||
Some(Arc::new(DevContainerOrchestrator::new(config.clone()).await?))
|
||||
} else {
|
||||
Some(Arc::new(prod_orch.clone()))
|
||||
};
|
||||
```
|
||||
|
||||
Where `ContainerOrchestrator` becomes a trait implemented by both `DevContainerOrchestrator` and `ProdContainerOrchestrator`.
|
||||
|
||||
### First-boot replacement
|
||||
|
||||
There is no separate first-boot code. The reconciler handles it: when the archipelago service starts on a fresh node, `adopt_existing` finds nothing, `reconcile_all` sees no running container for any manifest, and installs each one in dependency order (bitcoin-core first, then everything else). On subsequent boots, adoption finds existing containers and reconcile mostly no-ops.
|
||||
|
||||
**Removes completely**:
|
||||
- `/var/lib/archipelago/.first-boot-containers-done` marker (no longer needed)
|
||||
- `/var/lib/archipelago/.unbundled` handling in first-boot script (becomes a config flag in archipelago.conf if we still need it)
|
||||
- `scripts/first-boot-containers.sh` (1392 lines)
|
||||
- `scripts/reconcile-containers.sh`
|
||||
- `scripts/container-specs.sh`
|
||||
- `image-recipe/configs/archipelago-first-boot-containers.service`
|
||||
- `image-recipe/configs/archipelago-reconcile.service`
|
||||
- Related enable/disable in ISO builder
|
||||
|
||||
## The three UI manifests
|
||||
|
||||
Example: `apps/bitcoin-ui/manifest.yml`
|
||||
|
||||
```yaml
|
||||
app:
|
||||
id: bitcoin-ui
|
||||
name: Bitcoin Knots UI
|
||||
version: 1.0.0
|
||||
description: Custom Archipelago UI for Bitcoin Knots
|
||||
container:
|
||||
source:
|
||||
type: build
|
||||
context: /opt/archipelago/docker/bitcoin-ui
|
||||
dockerfile: Dockerfile
|
||||
tag: localhost/bitcoin-ui:local
|
||||
build_args:
|
||||
BITCOIN_RPC_AUTH: ${BITCOIN_RPC_AUTH} # injected from host-ip.env or secrets
|
||||
always_rebuild: false
|
||||
dependencies:
|
||||
- app_id: bitcoin-core
|
||||
resources:
|
||||
memory_limit: 128Mi
|
||||
security:
|
||||
network_policy: host
|
||||
readonly_root: false
|
||||
ports: [] # host networking
|
||||
volumes: []
|
||||
environment: []
|
||||
health_check:
|
||||
type: http
|
||||
endpoint: http://127.0.0.1:8334
|
||||
path: /
|
||||
interval: 30s
|
||||
extensions:
|
||||
container_name: archy-bitcoin-ui
|
||||
```
|
||||
|
||||
The `extensions.container_name` is how we match the existing running container on .116 for adoption. Same pattern for `electrs-ui` (container_name: `archy-electrs-ui`, port probe 50002) and `lnd-ui` (container_name: `archy-lnd-ui`, port probe 8081).
|
||||
|
||||
**BITCOIN_RPC_AUTH injection**: today `first-boot-containers.sh` `sed`s this value into `nginx.conf` (destructively). In the new world, it's a `--build-arg` — the Dockerfile gets `ARG BITCOIN_RPC_AUTH` and templates `nginx.conf` from a template file. Fixes the "sed destroys the source" bug from the mapping.
|
||||
|
||||
## Migration path (.116 and .228 specifically)
|
||||
|
||||
### .116 (all 3 UIs currently running, adopted from bash install)
|
||||
1. Ship the new archipelago binary with the prod orchestrator.
|
||||
2. On archipelago restart, `adopt_existing` scans `podman ps -a`, sees `archy-bitcoin-ui`, `archy-electrs-ui`, `archy-lnd-ui` already running.
|
||||
3. Matches them against the new manifests by `extensions.container_name`.
|
||||
4. Records state. Reconciler sees them Running → NoOp.
|
||||
5. Manual test: `podman stop archy-bitcoin-ui` → within 5 minutes, reconciler starts it again. `podman rm -f archy-bitcoin-ui` → reconciler rebuilds from `/opt/archipelago/docker/bitcoin-ui/Dockerfile` and re-creates.
|
||||
|
||||
### .228 (no bitcoin-ui, no lnd-ui, has electrs-ui from bash first-boot)
|
||||
1. Ship same binary.
|
||||
2. Adoption finds only `archy-electrs-ui`.
|
||||
3. Reconciler sees `bitcoin-ui` and `lnd-ui` missing → triggers `install_fresh` for each.
|
||||
4. For `bitcoin-ui`: `image_exists("localhost/bitcoin-ui:local")` → false. `build_image(/opt/archipelago/docker/bitcoin-ui, Dockerfile, localhost/bitcoin-ui:local, {BITCOIN_RPC_AUTH: ...}, force=false)`. Then create + start.
|
||||
5. Same for `lnd-ui`.
|
||||
6. Manual test: HTTP probe ports 8334 and 8081 return 200 within ~5 minutes of service restart.
|
||||
|
||||
## Test plan
|
||||
|
||||
Unit tests (Rust, in-process):
|
||||
- `manifest::tests::legacy_image_parses_as_pull_source`
|
||||
- `manifest::tests::explicit_pull_source_parses`
|
||||
- `manifest::tests::explicit_build_source_parses`
|
||||
- `manifest::tests::source_build_requires_tag`
|
||||
- `runtime::tests::build_image_happy_path` (uses a minimal Dockerfile in `tempfile::TempDir`)
|
||||
- `runtime::tests::build_image_failure`
|
||||
- `runtime::tests::image_exists_roundtrip`
|
||||
- `prod_orchestrator::tests::install_fresh_pull`
|
||||
- `prod_orchestrator::tests::install_fresh_build`
|
||||
- `prod_orchestrator::tests::adopt_existing_matches_by_name`
|
||||
- `prod_orchestrator::tests::reconcile_starts_exited_container` (with a mock runtime)
|
||||
- `prod_orchestrator::tests::reconcile_installs_missing_container`
|
||||
- `prod_orchestrator::tests::compute_container_name_ui_apps_prefixed`
|
||||
- `prod_orchestrator::tests::compute_container_name_backend_apps_bare`
|
||||
|
||||
Integration tests (require real podman, run on archy node):
|
||||
- Fresh-install path: wipe containers + images, start archipelago, verify all 3 UIs up within 60s.
|
||||
- Adoption path: containers pre-running, start archipelago, verify no recreate (compare container IDs before/after).
|
||||
- Reconcile-start path: `podman stop archy-bitcoin-ui`, wait, verify restart.
|
||||
- Reconcile-recreate path: `podman rm -f archy-bitcoin-ui`, wait, verify rebuild+recreate.
|
||||
- Rebuild-on-Dockerfile-change path: edit Dockerfile, call `upgrade` RPC, verify image rebuilt and container recreated.
|
||||
|
||||
Chaos matrix (bash + Playwright, the original goal):
|
||||
- For each UI (bitcoin-ui, electrs-ui, lnd-ui) × each event (stop, start, restart, remove+reconcile, SIGKILL, archipelago-service-restart, host-reboot) × each node (.116, .228): assert HTTP 200 + page-title marker returns within 60s of event.
|
||||
|
||||
## Risks + mitigations
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Adoption mismatches and re-creates a container we already had, losing its data | Adoption matches by exact name; `install_fresh` only runs when `get_container_status` returns Err (container doesn't exist), not when it returns Stopped/Exited. Unit tested. |
|
||||
| Build loop: reconciler rebuilds on every tick | `always_rebuild: false` + `image_exists` check. Only rebuilds when image tag is missing OR `upgrade` RPC is called. |
|
||||
| Reconciler runs while user is mid-install via the UI | Orchestrator state has per-app mutex; reconcile waits. Install path takes the same mutex. |
|
||||
| Auto-rollback (v1.7.41) fires during testing | `reconcile_all` is spawned AFTER server is healthy and responding; if it fails, archipelago the service still passes verification. Individual container failures are logged, not fatal. |
|
||||
| Dependency ordering: bitcoin-ui needs BITCOIN_RPC_AUTH which is generated at first boot | Reconciler handles dependency order by reading `manifest.app.dependencies` and installing in topological order. If the dep doesn't exist yet, skip and retry next tick. |
|
||||
| Moving `/opt/archipelago/docker/<name>` content breaks the build context | That path is stable per the ISO builder at `image-recipe/build-auto-installer-iso.sh:1671-1685`. Manifests reference it absolutely. |
|
||||
| Dropping bash scripts breaks existing ISOs in the field | Target release cycle is disposable alpha nodes. For existing alpha nodes (.116, .228) we hot-swap the binary and let the reconciler take over, then the next reboot doesn't need the systemd units; we mask them manually. |
|
||||
| User wants to downgrade to v1.7.42 | Auto-rollback mechanism already handles that; binary swap is reversible. The removed bash scripts are still in git history. |
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. **Schema first**: extend `ContainerConfig` + `ContainerSource` + `resolve()` + validation + unit tests. ~100 LOC Rust + ~80 LOC tests.
|
||||
2. **Runtime**: `build_image` + `image_exists` in trait, `PodmanRuntime`, `DockerRuntime` (can stub), `AutoRuntime`. ~150 LOC + tests with throwaway tempdir Dockerfile.
|
||||
3. **ProdContainerOrchestrator**: new type with `install/start/stop/restart/remove/status/list/logs/health/adopt_existing/reconcile_all/ensure_running/install_fresh`. ~400 LOC + unit tests with mocked runtime.
|
||||
4. **ContainerOrchestrator trait**: abstract over Dev and Prod so `RpcHandler` is polymorphic. ~50 LOC refactor.
|
||||
5. **BootReconciler**: task spawner with loop + cancellation. ~80 LOC + unit tests.
|
||||
6. **main.rs wire-up**: adopt + spawn reconciler. ~20 LOC.
|
||||
7. **3 UI manifests + Dockerfile BITCOIN_RPC_AUTH refactor** (use ARG + template file, not sed). ~60 lines of YAML + ~20 lines of Dockerfile.
|
||||
8. **Remove bash scripts + services**: split into sub-steps because `first-boot-containers.sh` creates 25+ containers (only 3 ported in Step 7) AND does non-container setup (secret gen, UID-mapping chowns, Tor hostnames, WireGuard, firewall, nostr-relay dir):
|
||||
- **8a** (cheap, safe): delete `image-recipe/configs/archipelago-reconcile.{service,timer}` + their ISO-builder touchpoints (the systemd enablement + `cp` into `$WORK_DIR`). `BootReconciler` fully replaces the timer-driven path — no more periodic bash invocation. **Keep** `scripts/reconcile-containers.sh` + `scripts/container-specs.sh` because `core/archipelago/src/api/rpc/package/update.rs` still shells out to reconcile-containers.sh during OTA updates; porting that call site requires manifests for every container it touches (which is Step 8b's scope). Atomic commit, low risk.
|
||||
- **8b** (large, deferred): port the remaining ~25 container creations from `first-boot-containers.sh` into `apps/<id>/manifest.yml` files. One manifest per commit, validated against current bash behavior (ports, volumes, env, deps, health checks, post-create wallet/db bootstrap). Probably 1-2 days of careful porting. Includes `apps/filebrowser/manifest.yml`. Then port `update.rs`'s two `reconcile-containers.sh` call sites to the `ContainerOrchestrator` trait (`upgrade(app_id)`).
|
||||
- **8c** (final, one-way door): rename `first-boot-containers.sh` → `first-boot-setup.sh`, strip out all `$DOCKER run/pull/exec` calls, keep only secret generation + dir prep + Tor/WG/firewall/nostr setup. Rename `archipelago-first-boot-containers.service` → `archipelago-first-boot-setup.service`. Delete `scripts/reconcile-containers.sh` + `scripts/container-specs.sh` (update.rs no longer needs them). Add ISO builder lines to copy `apps/*/manifest.yml` → `/opt/archipelago/apps/`. Full ISO build test on .116 required before commit.
|
||||
9. **Live test on .228**: hot-swap binary, expect 3 UIs to come up within 60s of service restart.
|
||||
10. **Live test on .116**: hot-swap binary, expect zero container recreation + adoption-confirmed log lines.
|
||||
11. **Chaos matrix** on both nodes.
|
||||
|
||||
Each step is a separate commit. Steps 1–6 are independent-enough that they can each have their own test gate.
|
||||
|
||||
## Estimated total
|
||||
|
||||
~1000 LOC Rust added, ~1500 lines bash deleted, ~50 LOC Rust deleted. 8–12 hours of focused work across multiple sessions. No release pressure per user decision.
|
||||
|
||||
## Open questions for user
|
||||
|
||||
1. **Container naming**: I propose `archy-<app_id>` for UIs, `<app_id>` for backends (matches current .116 fixture). Alternative: unify on `archy-<app_id>` for everything and migrate existing backends by renaming at adoption. Which?
|
||||
2. **BITCOIN_RPC_AUTH injection**: the build-arg approach rebuilds the UI image when the auth value changes. Fine during normal operation (rare). Alternative: mount the nginx.conf at runtime as a volume, never bake auth into the image. Which?
|
||||
3. **Reconciler interval**: 5 minutes. Too slow for a dropped container (user sees a broken UI for up to 5 min). Alternative: 30 seconds + more expensive `podman ps` calls. Which?
|
||||
4. **Concurrent reconcile + user install**: per-app mutex is the simple answer. Alternative: a single orchestrator-wide mutex (simpler, slower). Which?
|
||||
5. **Delete bash scripts in this migration, or keep them around as fallback?** I recommend delete (single source of truth), but deleting `first-boot-containers.sh` is a one-way door in terms of field recovery.
|
||||
@@ -0,0 +1,576 @@
|
||||
# Archipelago Security & Code Quality Audit Report
|
||||
|
||||
**Date**: March 2026
|
||||
**Version audited**: 0.1.0
|
||||
**Auditor**: Automated code review (Claude)
|
||||
**Scope**: Authentication, sessions, cryptography, container security, RPC, frontend, custom code vs libraries
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Overall Security Posture: 7.5 / 10
|
||||
|
||||
Archipelago demonstrates a security-conscious design with several production-grade patterns already in place. The project makes defensible choices in cryptography, follows capability-based container hardening, and implements layered authentication with TOTP 2FA. However, gaps remain in image signature verification, some postMessage origin validation, and the use of bcrypt instead of the already-available Argon2id for password hashing.
|
||||
|
||||
For a v0.1.0 self-sovereign personal server, this is a strong foundation. The code reads like it was written by someone who understands the threat model (local network appliance, single admin user, potentially hostile containers).
|
||||
|
||||
### Top 5 Risks (by severity)
|
||||
|
||||
1. **Cosign image verification is a TODO** (`podman_client.rs:84`). Container images are pulled without cryptographic signature checks. A compromised registry or MITM on image pull could inject malicious containers. This is the single largest attack surface.
|
||||
|
||||
2. **`postMessage('*')` wildcard origin in Nostr signer** (`AppSession.vue:490`, `appLauncher.ts:262,306,309`). Responses to NIP-07 signing requests are sent with `'*'` target origin, allowing any window/iframe to intercept signed Nostr events. A malicious app loaded in an adjacent iframe could harvest signatures.
|
||||
|
||||
3. **bcrypt for password hashing instead of Argon2id** (`auth.rs:108,245`). bcrypt is battle-tested but vulnerable to GPU/ASIC acceleration. Argon2id is already a dependency (used in TOTP and backup encryption) and provides memory-hard resistance. Using two different password hashing schemes in the same codebase is also a maintenance smell.
|
||||
|
||||
4. **Sessions are in-memory only** (`session.rs`). All sessions are lost on service restart, forcing all users to re-authenticate. More critically, there is no persistence layer to support session revocation auditing or multi-instance deployments.
|
||||
|
||||
5. **`v-html` used for TOTP QR SVG rendering** (`Settings.vue:286`). The SVG is server-generated, but `v-html` is a known XSS vector. If the QR generation path were ever to include user-controlled input, this would become exploitable.
|
||||
|
||||
### Top 5 Strengths
|
||||
|
||||
1. **TOTP implementation is production-grade**. Envelope encryption (KEK wraps MEK wraps secret), Argon2id key derivation with strong parameters (64 MiB, t=3, p=4), ChaCha20-Poly1305 AEAD, zeroize on drop, replay protection via used time steps, bcrypt-hashed backup codes. This exceeds what most node-OS projects implement.
|
||||
|
||||
2. **Comprehensive rate limiting**. Login rate limiting (5 attempts / 60s per IP) plus per-endpoint rate limiting on 25+ sensitive methods including financial operations, identity creation, backup operations, and federation joins. Configurable windows per method.
|
||||
|
||||
3. **CSRF protection is properly implemented**. Double-submit cookie pattern: `csrf_token` cookie (readable by JS) + `X-CSRF-Token` header validated on every authenticated request. SameSite=Strict on session cookies. HttpOnly on session cookie (not accessible to JS).
|
||||
|
||||
4. **Container security defaults are correct**. `--cap-drop=ALL` with explicit per-app capability add-back, `--security-opt=no-new-privileges:true` on all non-privileged containers, read-only root filesystem where compatible, per-app capability documentation.
|
||||
|
||||
5. **Error sanitization prevents information leakage**. `sanitize_error_message()` strips internal file paths and system details, returning generic errors for anything not in the user-facing prefix allowlist. Path components like `/var/lib/archipelago/` are replaced with `[data]/`.
|
||||
|
||||
### Recommended Actions (ordered by impact)
|
||||
|
||||
1. Implement cosign image verification before any public release. This is a hard requirement for supply chain security.
|
||||
2. Replace `postMessage('*')` with explicit target origins derived from the iframe's `src` URL.
|
||||
3. Migrate password hashing from bcrypt to Argon2id (already a dependency) with a transparent upgrade path on next login.
|
||||
4. Add `DOMPurify.sanitize()` around all `v-html` usage or replace with a component-based SVG renderer.
|
||||
5. Add session persistence (SQLite) to survive restarts and enable audit logging.
|
||||
|
||||
---
|
||||
|
||||
## 2. Session & Auth
|
||||
|
||||
### Password Hashing
|
||||
|
||||
**Current**: `bcrypt` crate with `DEFAULT_COST` (cost factor 12).
|
||||
|
||||
| Property | bcrypt (current) | Argon2id (available) |
|
||||
|----------|-----------------|---------------------|
|
||||
| Algorithm | Blowfish-based, 1999 | Memory-hard, won PHC 2015 |
|
||||
| GPU resistance | Moderate (small state) | Strong (memory-hard) |
|
||||
| Cost factor | `DEFAULT_COST = 12` (~250ms) | m=64MiB, t=3, p=4 (already configured in `totp.rs`) |
|
||||
| ASIC resistance | Weak | Strong |
|
||||
| Ecosystem status | Mature, stable | Modern standard, OWASP recommended |
|
||||
| Already a dependency | No (separate crate) | Yes (`argon2` crate used by `totp.rs` and `backup/identity.rs`) |
|
||||
|
||||
**Finding**: bcrypt at cost 12 is adequate for a local appliance where login attempts are rate-limited. However, Argon2id is already linked into the binary. Using two different password hashing algorithms in the same project increases cognitive overhead and the risk of confusion. The TOTP module already uses Argon2id with well-chosen parameters (64 MiB memory, t=3 iterations, p=4 parallelism).
|
||||
|
||||
**Recommendation**: Migrate to Argon2id on next password change. Store a version tag in `user.json` to allow transparent upgrade: on successful bcrypt login, re-hash with Argon2id and save.
|
||||
|
||||
### Session Tokens
|
||||
|
||||
**Current**: 32 bytes from `rand::random()` (which delegates to `OsRng`/`ChaCha20Rng`), hex-encoded (64 characters). Tokens are hashed with SHA-256 before storage, so the raw token never exists in the session map.
|
||||
|
||||
**Analysis**:
|
||||
- 256 bits of entropy from a CSPRNG: more than sufficient. Brute-forcing 2^256 is infeasible.
|
||||
- SHA-256 hashing of stored tokens: correct. A database leak would not expose session tokens.
|
||||
- Hex encoding doubles the string length but is unambiguous and URL-safe.
|
||||
|
||||
**Verdict**: This is correct and secure for a single-instance appliance. No change needed.
|
||||
|
||||
### Session Storage
|
||||
|
||||
**Current**: In-memory `HashMap<[u8; 32], Session>` behind `Arc<RwLock<>>`.
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| TTL-based expiry | 24 hours inactivity (full), 5 minutes (pending TOTP) |
|
||||
| Max concurrent sessions | 5 (oldest evicted) |
|
||||
| Session rotation on password change | Yes (`rotate()` + `invalidate_all_except()`) |
|
||||
| Cleanup of expired sessions | `cleanup_expired()` available, presumably called periodically |
|
||||
| Persistence across restarts | **No** |
|
||||
| Audit trail | **No** |
|
||||
|
||||
**Risk**: Medium. Session loss on restart is annoying but not a security issue (it forces re-authentication). The lack of audit trail means there is no way to retroactively determine who was authenticated when.
|
||||
|
||||
**Recommendation**: Add SQLite-backed session store when adding multi-user support. For now, the in-memory approach is acceptable.
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
**Current**: Double-submit cookie pattern.
|
||||
|
||||
1. On login, server sets `csrf_token` cookie (SameSite=Strict, readable by JS) and `session` cookie (HttpOnly, SameSite=Strict).
|
||||
2. Frontend reads `csrf_token` from `document.cookie` and sends it as `X-CSRF-Token` header on every RPC call.
|
||||
3. Backend validates `csrf_cookie == csrf_header` on every authenticated request.
|
||||
|
||||
**Analysis**:
|
||||
- SameSite=Strict prevents cross-origin cookie submission entirely in modern browsers.
|
||||
- The double-submit pattern provides defense-in-depth for browsers that do not enforce SameSite.
|
||||
- CSRF token is 32 bytes (256 bits) of randomness -- sufficient.
|
||||
- Secure flag is conditionally set (production only, not dev mode) -- correct.
|
||||
|
||||
**Finding**: The CSRF implementation is sound. One minor note: the CSRF token is generated independently of the session token. This is fine because both are random and the cookie binding ensures they cannot be used cross-session.
|
||||
|
||||
**Verdict**: Correct. No changes needed.
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Login rate limiter**: 5 failures per 60 seconds per IP. Implemented as `Vec<Instant>` per IP with sliding window.
|
||||
|
||||
**Endpoint rate limiter**: Per-method limits on 25+ sensitive endpoints. Examples:
|
||||
|
||||
| Endpoint | Max Requests | Window |
|
||||
|----------|-------------|--------|
|
||||
| `wallet.send` | 5 | 300s |
|
||||
| `lnd.payinvoice` | 10 | 300s |
|
||||
| `identity.create` | 10 | 300s |
|
||||
| `backup.create` | 10 | 600s |
|
||||
| `system.factory-reset` | (not rate-limited) | -- |
|
||||
| `container-install` | 5 | 300s |
|
||||
| `auth.changePassword` | 3 | 300s |
|
||||
| `federation.join` | 5 | 60s |
|
||||
| `update.apply` | 2 | 600s |
|
||||
|
||||
**Coverage gaps**:
|
||||
- `system.factory-reset` is not rate-limited. While it requires authentication, a compromised session could rapidly trigger factory resets. Low practical risk since one reset wipes everything.
|
||||
- `tor.rotate-service` is not rate-limited. Rapid rotation could burn through Tor circuits.
|
||||
- No global rate limit across all endpoints -- only per-method. A compromised session could flood non-limited endpoints.
|
||||
|
||||
**Verdict**: Good coverage for a single-user appliance. The per-method approach is appropriate for the threat model.
|
||||
|
||||
### TOTP 2FA
|
||||
|
||||
**Implementation quality**: Excellent.
|
||||
|
||||
| Feature | Implementation |
|
||||
|---------|---------------|
|
||||
| Secret generation | 20 bytes (160 bits) from `OsRng` |
|
||||
| Secret storage | Encrypted at rest: Argon2id KDF -> ChaCha20-Poly1305 envelope |
|
||||
| Encryption layers | 3: password -> KEK (Argon2id) -> MEK (random) -> secret |
|
||||
| Verification window | Current step +/- 1 (3 steps total, ~90s window) |
|
||||
| Replay protection | Used time steps tracked and rejected |
|
||||
| Code comparison | Constant-time comparison (`constant_time_eq`) |
|
||||
| Backup codes | 8 codes, bcrypt-hashed, one-time use |
|
||||
| Pending session | Max 5 attempts, 5-minute TTL, then forced re-login |
|
||||
| Re-keying | MEK re-encrypted under new password on password change |
|
||||
| Zeroize | KEK, MEK, and raw secret zeroized after use |
|
||||
|
||||
**Finding**: This is a textbook TOTP implementation. The envelope encryption (KEK/MEK pattern) is the same approach used by hardware security modules. The `zeroize` crate ensures secrets do not linger in memory.
|
||||
|
||||
One minor note: `constant_time_eq` is hand-rolled rather than using the `subtle` crate's `ConstantTimeEq`. The implementation is correct (XOR accumulation), but the `subtle` crate is specifically designed to resist compiler optimizations that could break constant-time behavior.
|
||||
|
||||
**Recommendation**: Consider switching to `subtle::ConstantTimeEq` for the TOTP comparison. The current implementation is likely fine in practice, but `subtle` provides stronger guarantees against compiler reordering.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cryptographic Review
|
||||
|
||||
| Component | Our Implementation | Library Alternative | Correct? | Secure? | Verdict |
|
||||
|-----------|-------------------|---------------------|----------|---------|---------|
|
||||
| **Password hashing** | `bcrypt` crate, `DEFAULT_COST` (12) | `argon2` crate (already a dep) | Yes | Adequate | **Migrate to Argon2id**. Already a dependency, memory-hard, OWASP recommended. bcrypt is not broken but Argon2id is strictly better against modern attacks. |
|
||||
| **Session tokens** | `rand::random::<[u8; 32]>()` + hex, SHA-256 stored hash | `tower-sessions` or signed JWTs via `jsonwebtoken` | Yes | Yes | **Keep custom**. 256-bit CSPRNG tokens with hashed storage is textbook. JWTs add complexity and stateless verification is not needed for single-instance. |
|
||||
| **TOTP KDF** | `argon2` crate, Argon2id v0x13, m=64MiB, t=3, p=4 | N/A (already using the right library) | Yes | Yes | **Correct**. Strong parameters that balance security and UX on modest hardware. |
|
||||
| **TOTP encryption** | `chacha20poly1305` crate, KEK/MEK envelope | `age` crate | Yes | Yes | **Keep custom**. The envelope pattern gives us re-keying without re-encrypting the secret. `age` would not support this use case without wrapping. |
|
||||
| **DID signing** | `ed25519-dalek` direct usage | SpruceID `ssi` crate | Yes | Yes | **Keep custom**. Our code is 381 lines, handles did:key + DID Documents + dual-key (Ed25519+secp256k1). `ssi` would add 50+ transitive deps. |
|
||||
| **VC signatures** | Custom Ed25519Signature2020 proof (credentials.rs, 796 lines) | SpruceID `ssi` VC module | Yes | Yes for our proof type | **Keep custom for issuance**. Consider `ssi` only for verifying external VCs with non-Ed25519 proof types. |
|
||||
| **Backup encryption** | Argon2id KDF + ChaCha20-Poly1305 (backup/identity.rs, 132 lines) | `age` crate | Yes | Yes | **Keep custom**. Clean, minimal, well-tested. `age` is simpler API but our code is already simple. |
|
||||
| **Key storage** | Raw bytes in files with `0o600` permissions | `keyring` crate or OS keychain | Yes | Adequate | **Keep current**. File-based is correct for headless Linux server. No desktop environment means no keychain daemon. Permissions are set immediately after key generation. |
|
||||
| **Constant-time comparison** | Hand-rolled XOR accumulation | `subtle` crate `ConstantTimeEq` | Correct logic | Likely | **Consider `subtle`**. Hand-rolled constant-time code can be optimized away by the compiler. `subtle` uses inline assembly barriers. Low risk in practice. |
|
||||
| **CSRF tokens** | `rand::thread_rng().fill()` 32 bytes | N/A | Yes | Yes | **Correct**. `thread_rng()` delegates to `OsRng`-seeded `ChaCha20Rng`. 256 bits is more than sufficient. |
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Argon2 is used correctly in two places** (TOTP and backup) **but not for password hashing**. This is the most obvious inconsistency. The project already pays the compilation cost for `argon2`; using it for password hashing would unify the crypto stack.
|
||||
|
||||
2. **ChaCha20-Poly1305 is used correctly** throughout. Nonces are generated from `OsRng`, key material is zeroized after use, and the AEAD construction prevents both tampering and ciphertext manipulation.
|
||||
|
||||
3. **Ed25519-dalek usage is clean**. Key generation uses `OsRng`, signing and verification are straightforward, the Ed25519-to-X25519 conversion for key agreement is done correctly via `curve25519-dalek`.
|
||||
|
||||
4. **No custom cryptographic primitives**. All cryptographic operations use well-audited Rust crates. The project does not implement any ciphers, hash functions, or key exchange algorithms from scratch. This is the correct approach.
|
||||
|
||||
---
|
||||
|
||||
## 4. Container Security
|
||||
|
||||
### Capability Dropping
|
||||
|
||||
**Default**: `--cap-drop=ALL` applied to all non-privileged containers (`package.rs:265`).
|
||||
|
||||
Per-app capabilities are explicitly added back via `get_app_capabilities()`:
|
||||
|
||||
| App Category | Capabilities Added | Justification |
|
||||
|-------------|-------------------|---------------|
|
||||
| Minimal apps (searxng, filebrowser, etc.) | None | Runs with zero capabilities |
|
||||
| Standard apps (photoprism, grafana) | CHOWN, SETUID, SETGID | Internal user switching |
|
||||
| Bitcoin/Lightning | CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE | Data directory ownership |
|
||||
| Web servers (nginx-proxy-manager, vaultwarden) | CHOWN, SETUID, SETGID, NET_BIND_SERVICE | Binding ports < 1024 |
|
||||
| Tailscale | `--privileged` + NET_ADMIN, NET_RAW | VPN tunnel creation (unavoidable) |
|
||||
|
||||
**Finding**: The capability model is well-designed. Each app gets the minimum capabilities needed. The `--privileged` exception for Tailscale is documented and unavoidable (it needs TUN device access and network namespace manipulation).
|
||||
|
||||
**Risk**: DAC_OVERRIDE grants the ability to bypass file permission checks. This is a broad capability. For Bitcoin/Lightning, it is necessary because the containers need to access data directories with varying ownership. Consider whether `FOWNER` alone would suffice for some of these apps.
|
||||
|
||||
### Read-Only Root Filesystem
|
||||
|
||||
**Current**: `--read-only` is applied to apps listed in `is_readonly_compatible()`:
|
||||
|
||||
- searxng, grafana, filebrowser, mempool-electrs, electrs, nostr-rs-relay, ollama, indeedhub
|
||||
|
||||
**Not read-only**: Bitcoin, LND, Nextcloud, BTCPay, Jellyfin, HomeAssistant, and others.
|
||||
|
||||
With `--read-only`, tmpfs mounts are added for `/tmp` and `/run`:
|
||||
```
|
||||
--tmpfs=/tmp:rw,noexec,nosuid,size=256m
|
||||
--tmpfs=/run:rw,noexec,nosuid,size=64m
|
||||
```
|
||||
|
||||
**Finding**: The `noexec` and `nosuid` flags on tmpfs mounts are a good hardening measure. The list of read-only-compatible apps is conservative, which is the correct approach -- it is better to not break an app than to force read-only on an incompatible container.
|
||||
|
||||
**Recommendation**: Gradually test more apps with `--read-only` and expand the list. Each new addition should be validated by running the app and checking for write failures.
|
||||
|
||||
### No-New-Privileges
|
||||
|
||||
**Current**: `--security-opt=no-new-privileges:true` applied to all non-Tailscale containers (`package.rs:266`).
|
||||
|
||||
**Finding**: Correct. This prevents setuid binaries inside containers from escalating privileges. Combined with `--cap-drop=ALL`, this creates a strong privilege boundary.
|
||||
|
||||
### User Namespace / Non-Root
|
||||
|
||||
**Current**: Podman runs containers with rootless mode when the `archipelago` user is not root (`podman_client.rs:60-66`). However, `podman_async()` uses `sudo podman` (`podman_client.rs:71-73`), which runs containers in the root Podman context.
|
||||
|
||||
**Finding**: The `sudo podman` invocation means containers run in a root context, not rootless. While `--cap-drop=ALL` and `no-new-privileges` provide strong isolation, the containers themselves may run as root (UID 0) inside their namespace. Whether the container process runs as non-root depends on the container image's Dockerfile (e.g., `USER 1000`).
|
||||
|
||||
**Recommendation**: Add `--user` flags to container creation where the upstream image supports it. Audit each container image to determine which ones run as root internally.
|
||||
|
||||
### Image Pinning
|
||||
|
||||
**Current**: The `is_valid_docker_image()` function validates registry origin (docker.io, ghcr.io, localhost) and rejects shell metacharacters. However, there is no enforcement of digest pinning (e.g., `image@sha256:...`).
|
||||
|
||||
Images can be specified with tags (`:latest`, `:v1.0.0`) but tags are mutable -- a registry compromise could replace the image behind a tag.
|
||||
|
||||
**Finding**: No digest pinning is enforced. While registry validation limits the attack surface, a compromised registry account could push malicious images under existing tags.
|
||||
|
||||
**Recommendation**: For the curated app list, pin images to specific digests in the marketplace metadata. Allow tag-based pulls for user-specified images but warn about the risk.
|
||||
|
||||
### Cosign Verification
|
||||
|
||||
**Current**: `podman_client.rs:83-84`:
|
||||
```rust
|
||||
// TODO: Implement cosign verification
|
||||
log::warn!("Signature verification not yet implemented: {}", sig);
|
||||
```
|
||||
|
||||
**Finding**: This is the most significant security gap in the container subsystem. Without signature verification, there is no cryptographic proof that a pulled image was built by the expected author.
|
||||
|
||||
**Risk**: HIGH. This should be implemented before any public release.
|
||||
|
||||
**Recommendation**: Integrate `sigstore-rs` for cosign verification. At minimum, verify signatures for the curated app list. Third-party apps from the decentralized marketplace should also have verifiable signatures (the publisher's Nostr key can serve as the trust anchor).
|
||||
|
||||
### Network Isolation
|
||||
|
||||
**Current**: Containers are placed on either:
|
||||
- `archy-net` (shared network for Bitcoin stack: bitcoin-knots, lnd, electrs, mempool, btcpay, fedimint)
|
||||
- Host network (Tailscale only)
|
||||
- Default isolated network (all other apps)
|
||||
|
||||
**Finding**: The `archy-net` shared network is necessary for the Bitcoin stack to communicate (LND needs Bitcoin RPC, Mempool needs Electrs, etc.). Other apps are properly isolated.
|
||||
|
||||
**Recommendation**: Consider creating separate networks for distinct app clusters (e.g., `btcpay-net` for BTCPay + nbxplorer + postgres) rather than putting everything on `archy-net`. This would limit lateral movement if a single container is compromised.
|
||||
|
||||
### Secrets Injection
|
||||
|
||||
**Current**: Secrets are passed to containers via environment variables (`-e` flag). For example, Bitcoin RPC credentials:
|
||||
```rust
|
||||
"--bitcoind-password".to_string(), "archipelago123".to_string(),
|
||||
```
|
||||
|
||||
**Finding**: Environment variables are visible via `podman inspect` and `/proc/<pid>/environ` on the host. The hardcoded `archipelago123` RPC password is particularly concerning -- it should be randomly generated per installation.
|
||||
|
||||
**Recommendation**:
|
||||
1. Generate random credentials per app installation and store them via the secrets manager.
|
||||
2. Prefer bind-mounting secret files into containers (`--secret` or `-v /path/to/secret:/run/secrets/password:ro`) over environment variables.
|
||||
3. Replace the hardcoded `archipelago123` Bitcoin RPC password with a per-install random password.
|
||||
|
||||
---
|
||||
|
||||
## 5. RPC Security
|
||||
|
||||
### Authentication Enforcement
|
||||
|
||||
**Unauthenticated endpoints** (from `UNAUTHENTICATED_METHODS`):
|
||||
- `auth.login`, `auth.login.totp`, `auth.login.backup` -- login flow
|
||||
- `auth.isOnboardingComplete`, `auth.isSetup` -- setup status checks
|
||||
- `health` -- health check
|
||||
- `backup.restore-identity` -- onboarding restore (before user account exists)
|
||||
- `federation.peer-joined`, `federation.peer-address-changed`, `federation.get-state` -- inter-node RPC
|
||||
|
||||
**Finding**: The unauthenticated endpoint list is reasonable. The federation endpoints are called by peer nodes over Tor and cannot use session cookies -- they are rate-limited instead (10 requests/60s for peer-joined and peer-address-changed, 30 requests/60s for get-state).
|
||||
|
||||
`backup.restore-identity` is unauthenticated by design -- it is used during onboarding before a user account exists. This is the correct approach.
|
||||
|
||||
**Risk**: The federation endpoints accept peer assertions (e.g., "I just joined your federation") without cryptographic authentication beyond the Tor hidden service address. A future improvement would be to require DID-signed payloads for federation RPCs.
|
||||
|
||||
### RBAC
|
||||
|
||||
**Current**: RBAC is implemented and wired into the RPC dispatcher (`mod.rs:249-269`). Three roles are defined:
|
||||
|
||||
| Role | Access |
|
||||
|------|--------|
|
||||
| Admin | Everything |
|
||||
| Viewer | Read-only system/node/container/federation/identity/backup methods + logout |
|
||||
| AppUser | Basic system stats, container listing, health, logout, password change |
|
||||
|
||||
**Finding**: RBAC is operational. The `can_access()` method uses prefix matching (e.g., `method.starts_with("system.")`) which is a reasonable approach for method-based access control.
|
||||
|
||||
**Concern**: The Viewer role grants access to `federation.list` and `dwn.query` but not `dwn.write-message`. This is correct. However, the prefix-matching approach means that if a new method like `system.factory-reset` is added, it would be accessible to Viewers because it starts with `system.`. The current code mitigates this because `system.factory-reset` is not listed in the Viewer's allowed prefixes -- it requires an exact `system.` prefix match, and the Viewer role only allows `method.starts_with("system.")`.
|
||||
|
||||
**Wait** -- actually, `system.factory-reset` does start with `system.`, so Viewers WOULD have access to it under the current RBAC rules.
|
||||
|
||||
**Risk**: MEDIUM. Any new `system.*` method is automatically accessible to Viewers. The Viewer role should use an explicit allowlist rather than prefix matching for the `system.` namespace.
|
||||
|
||||
**Recommendation**: Change Viewer's `system.*` access to an explicit list: `system.stats`, `system.temperature`, `system.disk-status`, etc. Do not allow `system.factory-reset`, `system.shutdown`, `system.reboot`, or `system.disk-cleanup` for Viewers.
|
||||
|
||||
### Input Validation
|
||||
|
||||
Five critical endpoints traced from params to handler:
|
||||
|
||||
1. **`auth.login`**: Password extracted as string from params, passed to `bcrypt::verify()`. No injection risk -- bcrypt operates on byte arrays. Rate-limited.
|
||||
|
||||
2. **`package.install`**: Package ID validated by `validate_app_id()` (lowercase alphanumeric + hyphens, 1-64 chars, no leading hyphen). Docker image validated by `is_valid_docker_image()` (length check, no shell metacharacters, registry allowlist). Both validations are solid.
|
||||
|
||||
3. **`system.factory-reset`**: Requires `confirm: true` parameter. Authenticated and RBAC-checked. No injection risk -- the handler performs fixed system operations.
|
||||
|
||||
4. **`backup.restore-identity`**: Accepts a JSON blob with a base64-encoded encrypted backup. The backup is decrypted with a user-supplied passphrase. Input validation: blob must be valid base64, must contain salt + nonce + ciphertext of minimum length, decrypted key must be exactly 32 bytes. The Argon2id KDF prevents timing attacks on the passphrase.
|
||||
|
||||
5. **`identity.create`**: Accepts optional `label` and `type` parameters. The label is stored as-is in a JSON file. No length validation on the label. This is a low risk since the label is never used in shell commands or HTML rendering, but a maximum length should be enforced.
|
||||
|
||||
### Error Sanitization
|
||||
|
||||
**Current**: `sanitize_error_message()` in `mod.rs:72-104`:
|
||||
|
||||
- User-facing prefixes (Invalid, Missing, Not found, etc.) are passed through with path sanitization.
|
||||
- Path components (`/var/lib/archipelago/`, `/usr/local/bin/`, `/etc/`) are replaced with `[data]/`, `[bin]/`, `[config]/`.
|
||||
- Messages exceeding 200 characters are truncated.
|
||||
- All other errors return: `"Operation failed. Check server logs for details."`
|
||||
|
||||
**Finding**: This is a good approach. The prefix allowlist ensures that validation errors remain actionable for the user while internal errors (stack traces, database errors, file system errors) are hidden.
|
||||
|
||||
**Minor concern**: The `contains()` check (`msg.contains(prefix)`) rather than `starts_with()` means that an internal error message containing the word "Password" anywhere would be passed through. For example, an error like "Failed to read /etc/shadow: Password file locked" would match the "Password" prefix. This is unlikely to leak sensitive information but is worth tightening.
|
||||
|
||||
### Path Traversal
|
||||
|
||||
**Frontend** (`filebrowser-client.ts`): `sanitizePath()` is not present in `rpc-client.ts`, but the filebrowser client strips `..` and `/` from filenames: `name.replace(/\.\./g, '').replace(/\//g, '')`.
|
||||
|
||||
**Backend**: File operations use `PathBuf::join()` which does not normalize `..` components. However, all file paths are constructed from validated app IDs (alphanumeric + hyphens) and fixed directory structures. There is no user-controlled path component that could escape the data directory.
|
||||
|
||||
**Verdict**: Path traversal risk is low. The app ID validation prevents directory traversal in container data paths.
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Security
|
||||
|
||||
### XSS
|
||||
|
||||
**`v-html` usage**: Found in one location:
|
||||
- `Settings.vue:286`: `<div v-html="totpQrSvg" />` -- renders server-generated SVG.
|
||||
|
||||
**Analysis**: The SVG is generated by the `qrcode` crate on the backend and contains only geometric shapes (rects, paths). It does not include any user-controlled content. However, `v-html` bypasses Vue's template escaping entirely.
|
||||
|
||||
**Risk**: LOW currently (SVG is trusted server output), but HIGH if the generation path ever changes.
|
||||
|
||||
**Recommendation**: Replace `v-html` with either:
|
||||
1. An `<img>` tag with a data URI: `<img :src="'data:image/svg+xml;base64,' + btoa(totpQrSvg)" />`
|
||||
2. `DOMPurify.sanitize(totpQrSvg)` before rendering with `v-html`.
|
||||
|
||||
### CSRF
|
||||
|
||||
**Frontend implementation** (`rpc-client.ts:18-21, 42-45`):
|
||||
```typescript
|
||||
function getCsrfToken(): string | null {
|
||||
const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/)
|
||||
return match ? match[1]! : null
|
||||
}
|
||||
```
|
||||
|
||||
The CSRF token is read from cookies and sent as `X-CSRF-Token` header on every RPC call via `fetch()` with `credentials: 'include'`.
|
||||
|
||||
**Finding**: Correctly implemented. The token is scoped to the session (new token issued on login, rotated on password change, expired on logout).
|
||||
|
||||
### Credential Storage (localStorage)
|
||||
|
||||
Audit of all `localStorage.setItem` calls:
|
||||
|
||||
| Key | Content | Risk |
|
||||
|-----|---------|------|
|
||||
| `neode_locale` | Language preference ("en") | None |
|
||||
| `neode-auth` | Boolean flag ("true") | None -- not a credential |
|
||||
| `neode_onboarding_complete` | Boolean flag | None |
|
||||
| `neode_intro_seen` | Boolean flag | None |
|
||||
| `neode_backup_created` | Boolean flag | None |
|
||||
| `neode_did` | DID string (public identifier) | None -- DIDs are public |
|
||||
| `neode_did_state` | DID + KID + pubkey (all public) | None |
|
||||
| `neode_nostr_npub` | Nostr public key (public) | None |
|
||||
| `archipelago-ui-mode` | "easy" / "advanced" | None |
|
||||
| `archipelago-goal-progress` | UI progress state | None |
|
||||
| `archipelago-spotlight-recent` | Recent search items | None |
|
||||
| `federation-view` | Active federation tab | None |
|
||||
| `IDENTITY_KEY + appId` | Nostr identity for app context | **LOW** -- contains public key, not private |
|
||||
| `APPROVED_ORIGINS_KEY` | Set of approved iframe origins | **LOW** -- UI preference |
|
||||
| `DISPLAY_MODE_KEY` | "overlay" / "tab" | None |
|
||||
|
||||
**Finding**: No secrets, passwords, private keys, or session tokens are stored in localStorage. All stored values are either UI preferences or public identifiers. This is correct.
|
||||
|
||||
### iframe postMessage Security
|
||||
|
||||
**Outbound `postMessage('*')` calls** (wildcard target origin):
|
||||
|
||||
1. `AppSession.vue:490,492` -- Nostr signing responses sent to iframe source with `'*'`
|
||||
2. `AppLauncherOverlay.vue:390` -- Escape key event sent to parent with `'*'`
|
||||
3. `appLauncher.ts:262,306,309` -- Nostr signing responses sent to iframe source with `'*'`
|
||||
|
||||
**Inbound origin validation**:
|
||||
|
||||
1. `contextBroker.ts:65` -- Validates `event.origin !== this.allowedOrigin` (properly restrictive)
|
||||
2. `Chat.vue:110` -- Validates against expected AIUI URL origin (properly restrictive)
|
||||
3. `AppSession.vue` and `appLauncher.ts` -- No origin validation on incoming `nostr-request` messages
|
||||
|
||||
**Finding**: The Nostr signer (NIP-07 bridge) accepts signing requests from any iframe without verifying the origin, and sends signed responses back with `'*'` target origin. This means:
|
||||
- Any iframe loaded in the app launcher could request Nostr event signatures.
|
||||
- The signed response could be intercepted by any window.
|
||||
|
||||
**Mitigation**: The user is prompted to approve signing requests (a consent dialog exists in `appLauncher.ts`), and the approved origins list is stored in localStorage. However, the initial request acceptance has no origin check.
|
||||
|
||||
**Risk**: MEDIUM. A malicious app loaded in an iframe could silently request signatures for crafted Nostr events.
|
||||
|
||||
**Recommendation**:
|
||||
1. Validate `event.origin` on incoming `nostr-request` messages against the app's known URL.
|
||||
2. Replace `postMessage(msg, '*')` with `postMessage(msg, expectedOrigin)` for Nostr responses.
|
||||
3. The `AppLauncherOverlay.vue:390` escape event using `'*'` is lower risk since it only sends a UI event to the parent window, but should still use a specific origin.
|
||||
|
||||
### Dependency Audit
|
||||
|
||||
Note: `npm audit` was not run as part of this review (requires network access and node_modules). This should be run separately:
|
||||
```bash
|
||||
cd neode-ui && npm audit
|
||||
```
|
||||
|
||||
The project uses Vue 3, Vite 7, and Pinia -- all actively maintained. The key security-relevant frontend dependencies are:
|
||||
- `fetch` API (native, no third-party HTTP client)
|
||||
- No `eval()` or `new Function()` usage detected
|
||||
- No inline scripts or styles that would conflict with CSP
|
||||
|
||||
---
|
||||
|
||||
## 7. Custom Code vs Libraries
|
||||
|
||||
### Summary Table
|
||||
|
||||
| # | Component | Lines | Quality | Alternative | Verdict |
|
||||
|---|-----------|-------|---------|-------------|---------|
|
||||
| 1 | HTTP Server (`handler.rs`) | 813 | Functional but hand-rolled | `axum` | **Migrate** |
|
||||
| 2 | Session Management (`session.rs`) | 595 | Solid, well-tested | `tower-sessions` | **Keep** (for now) |
|
||||
| 3 | Rate Limiting (`session.rs` + `mod.rs`) | ~120 | Simple, effective | `governor` | **Keep** |
|
||||
| 4 | DID Implementation (`identity.rs`) | 381 | Clean, W3C compliant | SpruceID `ssi` | **Keep** |
|
||||
| 5 | Verifiable Credentials (`credentials.rs`) | 796 | W3C VC 2.0 compliant | SpruceID `ssi` VC | **Keep** (consider `ssi` for external VC verification) |
|
||||
| 6 | did:dht | ~200 | Works via `mainline` | `pkarr` | **Evaluate** |
|
||||
| 7 | DWN Store | ~300 | Skeletal | None mature | **Keep** (deprioritize) |
|
||||
| 8 | WebSocket State Broadcasting | ~200 | Works but full-resync | `json-patch` | **Add library** |
|
||||
| 9 | Form Validation (frontend) | Scattered | Inconsistent | `zod` | **Add library** |
|
||||
| 10 | Container Runtime (`podman_client.rs`) | 410 | Clean abstraction | `bollard` | **Keep** |
|
||||
|
||||
### Detailed Assessments
|
||||
|
||||
**1. HTTP Server (custom `handler.rs` -- 813 lines)**
|
||||
|
||||
The handler manually implements routing, CORS headers, WebSocket upgrade, request body parsing, and response building using raw `hyper 0.14`. This works but is fragile -- every new route requires manual pattern matching, there is no middleware stack, and hyper 0.14 is end-of-life.
|
||||
|
||||
Alternative: `axum` (built by the tokio team on hyper 1.x) provides typed extractors, a middleware stack via `tower`, built-in WebSocket support, and is the de facto standard for Rust web servers.
|
||||
|
||||
**Verdict**: Migrate. This is the highest-impact refactoring item. `axum` would reduce `handler.rs` to approximately 200 lines while adding type safety, automatic request parsing, and tower middleware support. Risk is medium -- the RPC logic is unchanged, only the HTTP glue changes.
|
||||
|
||||
**2. Session Management (custom `session.rs` -- 595 lines including 300+ lines of tests)**
|
||||
|
||||
The session store is ~200 lines of production code with ~370 lines of comprehensive tests. It implements token hashing, TTL expiry, concurrent session limits, session rotation, and pending TOTP sessions with attempt tracking. The code uses `zeroize` for TOTP secrets.
|
||||
|
||||
Alternative: `tower-sessions` with `tower-sessions-sqlx-store` for SQLite-backed persistence.
|
||||
|
||||
**Verdict**: Keep custom for now. The implementation is correct, well-tested, and purpose-built for the two-phase TOTP flow. A library would not handle the pending/full session distinction without significant customization. Migrate to `tower-sessions` only if SQLite persistence or multi-instance deployment is needed.
|
||||
|
||||
**3. Rate Limiting (custom, ~120 lines)**
|
||||
|
||||
Simple in-memory sliding window counters per (method, IP). Not configurable at runtime but the static configuration is well-chosen for each endpoint category.
|
||||
|
||||
Alternative: `governor` crate or `tower::limit::RateLimitLayer`.
|
||||
|
||||
**Verdict**: Keep custom. The implementation is straightforward, correct, and tailored to the per-method needs. `governor` would add a dependency for minimal benefit. Revisit only if distributed rate limiting is needed (multiple backend instances).
|
||||
|
||||
**4. DID Implementation (`identity.rs` -- 381 lines)**
|
||||
|
||||
Clean implementation of `did:key` method using `ed25519-dalek`. Generates W3C DID Core v1.0 compliant DID Documents with Ed25519 verification keys and X25519 key agreement keys. Includes Ed25519-to-X25519 conversion, Nostr secp256k1 dual-key support, and roundtrip tests.
|
||||
|
||||
Alternative: SpruceID `ssi` crate (v0.15.0).
|
||||
|
||||
**Verdict**: Keep custom. The code is ~380 lines, handles exactly the features needed (did:key + dual-key DID Documents), and has good test coverage (12 tests). `ssi` would add 50+ transitive dependencies for features like did:web, did:ethr, did:ion resolution that are not needed. The maintenance burden of 380 lines of well-tested code is far lower than managing a large dependency tree.
|
||||
|
||||
**5. Verifiable Credentials (`credentials.rs` -- 796 lines)**
|
||||
|
||||
W3C VC Data Model 2.0 implementation supporting issuance, verification, revocation, and verifiable presentations. Uses Ed25519Signature2020 proof format.
|
||||
|
||||
Alternative: SpruceID `ssi` VC module.
|
||||
|
||||
**Verdict**: Keep custom for issuance and node-to-node verification. The code handles the one proof type needed for Archipelago's use case (Ed25519Signature2020). Consider `ssi` only if external VC verification is needed (verifying credentials issued by non-Archipelago systems with different proof types like BbsBlsSignature2020 or JsonWebSignature2020).
|
||||
|
||||
**6. did:dht (`did_dht.rs` -- ~200 lines)**
|
||||
|
||||
Implements did:dht resolution via the `mainline` crate (BEP-44 signed DHT records). Includes in-memory caching.
|
||||
|
||||
Alternative: `pkarr` crate (v5.0.3, 550K downloads) -- higher-level abstraction over mainline DHT.
|
||||
|
||||
**Verdict**: Evaluate `pkarr`. If it handles the BEP-44 encoding that is currently done manually, it would reduce code and benefit from upstream maintenance. If it adds unnecessary abstraction, keep custom. The current code is small and works.
|
||||
|
||||
**7. DWN Store (`dwn_store.rs` -- ~300 lines)**
|
||||
|
||||
Basic CRUD operations, filesystem-backed, protocol registration. Skeletal implementation.
|
||||
|
||||
Alternative: No production-ready DWN implementation exists in Rust. The `dwn` crate by unavi-xyz is v0.4.0 with 323 downloads.
|
||||
|
||||
**Verdict**: Keep custom. No viable alternative. Per ADR-011, DWN is deprioritized. The current skeleton is sufficient for the protocol registration feature.
|
||||
|
||||
**8. WebSocket State Broadcasting (`state.rs` -- ~200 lines)**
|
||||
|
||||
Uses tokio broadcast channels to send full state model resyncs on every change. Every WebSocket client receives the entire state JSON on every update.
|
||||
|
||||
Alternative: `json-patch` crate for RFC 6902 JSON diffs. The frontend already includes `fast-json-patch`.
|
||||
|
||||
**Verdict**: Add `json-patch`. This is one of the highest-impact improvements. On a system with 10+ containers and active monitoring, the full-state broadcast can be 50-100 KB per update. JSON patches would reduce this to a few hundred bytes per change. Both the Rust `json-patch` crate and the frontend `fast-json-patch` library are mature and actively maintained.
|
||||
|
||||
**9. Form Validation (manual inline in Vue components)**
|
||||
|
||||
Validation logic is scattered across Vue components with inconsistent patterns. Some forms validate on submit, others on blur, and error messages are not standardized.
|
||||
|
||||
Alternative: `zod` (TypeScript-first schema validation, 40M+ weekly npm downloads).
|
||||
|
||||
**Verdict**: Add `zod`. Centralize validation schemas in `src/types/schemas.ts`. This is critical for the onboarding flow where bad input (weak passphrase, malformed DID) can cause key generation failures. `zod` integrates naturally with TypeScript and can generate types from schemas, reducing duplication.
|
||||
|
||||
**10. Container Runtime Abstraction (`podman_client.rs` -- 410 lines)**
|
||||
|
||||
Clean Podman client that wraps CLI invocations for container lifecycle operations. Handles both JSON array and NDJSON output formats from Podman.
|
||||
|
||||
Alternative: `bollard` crate (Docker/Podman API client, 7M downloads).
|
||||
|
||||
**Verdict**: Keep custom. The current abstraction is clean and purpose-built for the manifest-based approach. `bollard` is Docker-first and would require wrapping for the `AppManifest`-driven container creation. The CLI approach also avoids the Podman socket configuration complexity that `bollard` would require.
|
||||
|
||||
---
|
||||
|
||||
## What To Do Next
|
||||
|
||||
The three most impactful changes from this audit, in priority order:
|
||||
|
||||
1. **Implement cosign image verification** (`podman_client.rs`). Integrate `sigstore-rs` for container image signature verification. This closes the largest supply chain attack surface. Without it, a compromised Docker registry could push malicious images.
|
||||
|
||||
2. **Fix postMessage wildcard origins** (`AppSession.vue`, `appLauncher.ts`). Replace `postMessage(msg, '*')` with targeted origins. Add `event.origin` validation on incoming Nostr signing requests. This prevents malicious iframes from harvesting signed events.
|
||||
|
||||
3. **Migrate password hashing to Argon2id** (`auth.rs`). Add a version field to the user JSON. On login, if the hash is bcrypt, verify with bcrypt, then re-hash with Argon2id and save. This unifies the crypto stack and provides better GPU resistance.
|
||||
|
||||
These three changes address the top three risks identified in this audit and are achievable without architectural changes.
|
||||
@@ -0,0 +1,377 @@
|
||||
# Three-Mode UI System: Easy / Pro / Chat
|
||||
|
||||
## Overview
|
||||
|
||||
Archipelago's UI will support three switchable modes, each targeting a different user experience level:
|
||||
|
||||
| Mode | Label in UI | Target User | What They See |
|
||||
|------|-------------|-------------|---------------|
|
||||
| **Pro** | Pro | Power users, developers, node operators | Current full interface — all services, configs, technical details |
|
||||
| **Easy** | Easy | Complete beginners, non-technical users | Goal-based interface — "Open a Shop", "Store My Photos" |
|
||||
| **Chat** | Chat | Everyone (future) | Conversational AI interface powered by AIUI |
|
||||
|
||||
### Key Principles
|
||||
|
||||
1. **Pro mode is preserved** — the current interface stays exactly as-is and continues to be improved
|
||||
2. **Same URLs** — modes don't change route paths. `/dashboard` shows different content based on mode
|
||||
3. **Cross-surfacing** — Easy mode goals are searchable from Spotlight (Cmd+K) and suggested in Pro mode
|
||||
4. **Persistent preference** — mode choice saved to localStorage + backend UIData
|
||||
|
||||
---
|
||||
|
||||
## How Modes Work
|
||||
|
||||
### Architecture: Conditional Rendering
|
||||
|
||||
Rather than separate route trees (`/easy/home`, `/pro/home`), the mode controls **what renders within existing routes**:
|
||||
|
||||
```
|
||||
Dashboard.vue (shared shell)
|
||||
├── Sidebar → nav items change per mode
|
||||
├── ModeSwitcher → always visible in sidebar
|
||||
└── <RouterView>
|
||||
└── Home.vue (dispatcher)
|
||||
├── <GamerHome /> (Pro mode)
|
||||
├── <EasyHome /> (Easy mode)
|
||||
└── <ChatHome /> (Chat mode)
|
||||
```
|
||||
|
||||
This means:
|
||||
- Auth guards, WebSocket, stores — all shared
|
||||
- URLs never change — bookmarks work regardless of mode
|
||||
- Both modes use the same component library (glass-card, glass-button, etc.)
|
||||
|
||||
### Navigation Per Mode
|
||||
|
||||
**Pro Mode** (current, 7 items):
|
||||
```
|
||||
Home → My Apps → App Store → Cloud → Network → Web5 → Settings
|
||||
```
|
||||
|
||||
**Easy Mode** (simplified, 3 items):
|
||||
```
|
||||
Home → My Services → Settings
|
||||
```
|
||||
|
||||
**Chat Mode** (4 items):
|
||||
```
|
||||
Home → Chat → My Apps → Settings
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Easy Mode: Goal-Based Interface
|
||||
|
||||
### The Problem
|
||||
|
||||
Current interface says: "Here are 20+ services you can install. Figure out which ones you need, install them, configure them to talk to each other."
|
||||
|
||||
Easy mode says: **"What do you want to do?"**
|
||||
|
||||
### Goal Cards (Easy Mode Home)
|
||||
|
||||
When in Easy mode, the Home screen shows goal cards instead of the current 4 technical overview cards:
|
||||
|
||||
```
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ 🏪 Open a Shop │ │ ⚡ Accept Payments │
|
||||
│ │ │ │
|
||||
│ Set up your own │ │ Receive Bitcoin & │
|
||||
│ Bitcoin-powered │ │ Lightning payments │
|
||||
│ online store │ │ │
|
||||
│ │ │ ~30 min • Beginner │
|
||||
│ ~45 min • Beginner │ │ ▸ Start │
|
||||
│ ▸ Start │ └─────────────────────┘
|
||||
└─────────────────────┘
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ 📸 Store My Photos │ │ 📁 Store My Files │
|
||||
│ │ │ │
|
||||
│ Private photo │ │ Personal cloud │
|
||||
│ backup & gallery │ │ storage & sync │
|
||||
│ │ │ │
|
||||
│ ~15 min • Beginner │ │ ~20 min • Beginner │
|
||||
│ ▸ Start │ │ ▸ Start │
|
||||
└─────────────────────┘ └─────────────────────┘
|
||||
┌─────────────────────┐ ┌─────────────────────┐
|
||||
│ ⚡ Lightning Node │ │ 🔑 Create Identity │
|
||||
│ │ │ │
|
||||
│ Run your own │ │ Sovereign DID & │
|
||||
│ Lightning Network │ │ Nostr identity │
|
||||
│ routing node │ │ │
|
||||
│ │ │ ~5 min • Beginner │
|
||||
│ ~40 min • Beginner │ │ ▸ Start │
|
||||
│ ▸ Start │ └─────────────────────┘
|
||||
└─────────────────────┘
|
||||
┌─────────────────────┐
|
||||
│ 💾 Back Up │
|
||||
│ │
|
||||
│ Encrypted backup │
|
||||
│ of your entire │
|
||||
│ node │
|
||||
│ │
|
||||
│ ~10 min • Beginner │
|
||||
│ ▸ Start │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### Goal Workflow Wizard
|
||||
|
||||
Clicking a goal opens a **multi-step wizard** at `/dashboard/goals/:goalId`:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ ← Back to Goals │
|
||||
│ │
|
||||
│ Open a Shop │
|
||||
│ Set up your own Bitcoin-powered online store │
|
||||
│ │
|
||||
│ Step 2 of 4 │
|
||||
│ ═══════════════════════▓▓▓░░░░░░░░░░░░ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ ✅ Step 1: Install Bitcoin Node │ │
|
||||
│ │ Bitcoin Core is running and syncing │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ ⏳ Step 2: Install Lightning Network │ │
|
||||
│ │ Installing LND... [45%] │ │
|
||||
│ │ ████████████████████░░░░░░░░░░░░░ │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ ○ Step 3: Install BTCPay Server │ │
|
||||
│ │ Waiting for Lightning to be ready │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ ○ Step 4: Set Up Your Store │ │
|
||||
│ │ Configure your store name and settings │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ℹ️ Bitcoin needs to sync before Lightning can │
|
||||
│ start. This takes 2-3 days on first run. │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Smart features:**
|
||||
- Steps already satisfied (app running from a previous goal) are auto-completed
|
||||
- Dependency resolution: Bitcoin must be running before LND can start
|
||||
- Real-time progress from WebSocket data patches
|
||||
- `configure` steps open the app in the iframe launcher for the user to complete
|
||||
|
||||
### Goal Definitions
|
||||
|
||||
| Goal | What It Provisions | Estimated Time |
|
||||
|------|-------------------|----------------|
|
||||
| **Open a Shop** | Bitcoin Knots + LND + BTCPay Server | ~45 min |
|
||||
| **Accept Payments** | Bitcoin Knots + LND | ~30 min |
|
||||
| **Store My Photos** | Immich (photo management) | ~15 min |
|
||||
| **Store My Files** | Nextcloud (cloud storage) | ~20 min |
|
||||
| **Run a Lightning Node** | Bitcoin Knots + LND + channel setup | ~40 min |
|
||||
| **Create My Identity** | Built-in DID + Nostr keypair | ~5 min |
|
||||
| **Back Up Everything** | Built-in encrypted backup | ~10 min |
|
||||
|
||||
---
|
||||
|
||||
## Mode Switcher UI
|
||||
|
||||
### Desktop Sidebar
|
||||
|
||||
A compact three-segment toggle sits below the logo, above navigation:
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ 🏝️ Archipelago │
|
||||
│ v0.1.0 │
|
||||
│ │
|
||||
│ ┌──────┬──────┬────┐ │
|
||||
│ │ Easy │ Pro │Chat│ │ ← Mode switcher
|
||||
│ └──────┴──────┴────┘ │
|
||||
│ │
|
||||
│ ○ Home │
|
||||
│ ○ My Apps │ ← Nav items change
|
||||
│ ○ App Store │ per mode
|
||||
│ ○ ... │
|
||||
│ │
|
||||
│ ⚙ Settings │
|
||||
│ ↪ Logout │
|
||||
│ ● Online │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### Settings Page
|
||||
|
||||
Full-width selection cards in a new "Interface Mode" section:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Interface Mode │
|
||||
│ Choose how you want to interact with your node. │
|
||||
│ │
|
||||
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||
│ │ │ │ ██████ │ │ │ │
|
||||
│ │ Easy Mode │ │ Pro Mode │ │ Chat Mode │ │
|
||||
│ │ │ │ (Active) │ │ (Soon) │ │
|
||||
│ │ Goal-based │ │ Full │ │ AI chat │ │
|
||||
│ │ guided │ │ control │ │ interface │ │
|
||||
│ │ setup │ │ of all │ │ │ │
|
||||
│ │ │ │ services │ │ │ │
|
||||
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Uses the existing `.path-option-card` / `.path-option-card--selected` pattern from OnboardingPath.vue.
|
||||
|
||||
### Mobile
|
||||
|
||||
Mode switcher is in Settings only (bottom tab bar has limited space).
|
||||
|
||||
---
|
||||
|
||||
## Cross-Surfacing: Goals Everywhere
|
||||
|
||||
### Spotlight Search (Cmd+K)
|
||||
|
||||
Goals are added to the help tree and appear in search results regardless of mode:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ 🔍 shop │
|
||||
│ │
|
||||
│ Quick Start Goals │
|
||||
│ 🚀 Open a Shop │
|
||||
│ 🚀 Accept Payments │
|
||||
│ │
|
||||
│ Navigate │
|
||||
│ → App Store │
|
||||
│ │
|
||||
│ Actions │
|
||||
│ → Install an App │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Pro Mode Home
|
||||
|
||||
A "Quick Start Goals" section appears at the bottom of Pro mode's Home, giving power users easy access to the guided workflows:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ Quick Start Goals │
|
||||
│ Not sure where to start? Try a guided setup. │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ Open a Shop │ │ Accept │ │ Store Photos │ │
|
||||
│ │ │ │ Payments │ │ │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chat Mode (Placeholder)
|
||||
|
||||
For now, Chat mode shows a placeholder with a disabled input:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 💬 AI Assistant │
|
||||
│ │
|
||||
│ Conversational interface coming soon. │
|
||||
│ Talk to your node, ask questions, and │
|
||||
│ manage everything through natural language. │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────┐ │
|
||||
│ │ What would you like to do? │ │
|
||||
│ └──────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ AIUI integration in development │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When AIUI is integrated, this becomes the conversational interface where users can say things like "Set up a Lightning node" and the system guides them through it via chat.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### UIMode Type
|
||||
|
||||
```typescript
|
||||
type UIMode = 'gamer' | 'easy' | 'chat'
|
||||
```
|
||||
|
||||
Stored in:
|
||||
- `localStorage` as `archipelago-ui-mode` (immediate, works offline)
|
||||
- `UIData.mode` on the backend (synced via WebSocket, persists across devices)
|
||||
|
||||
### Goal Types
|
||||
|
||||
```typescript
|
||||
interface GoalDefinition {
|
||||
id: string // 'open-a-shop'
|
||||
title: string // 'Open a Shop'
|
||||
subtitle: string // 'Accept Bitcoin payments with your own store'
|
||||
icon: string // Icon identifier
|
||||
category: string // 'commerce', 'payments', 'storage', etc.
|
||||
requiredApps: string[] // ['bitcoin-core', 'lnd', 'btcpay-server']
|
||||
steps: GoalStep[] // Sequential steps
|
||||
estimatedTime: string // '~45 minutes'
|
||||
difficulty: 'beginner' | 'intermediate'
|
||||
}
|
||||
|
||||
interface GoalStep {
|
||||
id: string
|
||||
title: string // 'Install Bitcoin Node'
|
||||
description: string
|
||||
appId?: string // Which app this step provisions
|
||||
action: 'install' | 'configure' | 'verify' | 'info'
|
||||
isAutomatic: boolean // Can system do this without user input?
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
| Phase | What | Files Changed | Visible Effect |
|
||||
|-------|------|--------------|----------------|
|
||||
| 1 | Data layer | types, stores, data files | None (foundation) |
|
||||
| 2 | Mode switching | Dashboard, Settings, Router | Mode toggle appears, nav changes |
|
||||
| 3 | Easy mode views | Home refactor, EasyHome, GoalDetail | Easy mode is functional |
|
||||
| 4 | Chat + polish | Chat placeholder, Spotlight goals, Pro goals section | Complete system |
|
||||
|
||||
Each phase deploys independently. Phase 1 is invisible. Phase 2 adds the switcher. Phase 3 makes Easy mode work. Phase 4 polishes everything.
|
||||
|
||||
---
|
||||
|
||||
## File Inventory
|
||||
|
||||
### New Files (10)
|
||||
```
|
||||
src/types/goals.ts — Goal type definitions
|
||||
src/data/goals.ts — Goal catalog (7 goals)
|
||||
src/stores/uiMode.ts — UI mode Pinia store
|
||||
src/stores/goals.ts — Goal progress tracking
|
||||
src/components/ModeSwitcher.vue — Mode toggle widget
|
||||
src/components/GamerHome.vue — Extracted current Home content
|
||||
src/components/EasyHome.vue — Easy mode goal cards
|
||||
src/components/ChatHome.vue — Chat mode home wrapper
|
||||
src/views/GoalDetail.vue — Goal workflow wizard
|
||||
src/views/Chat.vue — Chat placeholder
|
||||
```
|
||||
|
||||
### Modified Files (11)
|
||||
```
|
||||
src/types/api.ts — Add UIMode type + mode field to UIData
|
||||
src/router/index.ts — Add goals/:goalId and chat routes
|
||||
src/views/Dashboard.vue — Computed nav items, ModeSwitcher in sidebar
|
||||
src/views/Home.vue — Mode dispatcher (GamerHome/EasyHome/ChatHome)
|
||||
src/views/Settings.vue — Interface Mode selection section
|
||||
src/data/helpTree.ts — Goals in Spotlight search
|
||||
src/style.css — Mode switcher, goal card, wizard CSS
|
||||
src/stores/app.ts — Sync mode from backend
|
||||
src/api/rpc-client.ts — setUIMode() RPC method
|
||||
src/components/SpotlightSearch.vue — Visual indicator for goal items
|
||||
mock-backend.js — ui.set-mode handler
|
||||
```
|
||||
Reference in New Issue
Block a user