diff --git a/.gitea/workflows/demo-images.yml b/.gitea/workflows/demo-images.yml index c6b58198..ba739508 100644 --- a/.gitea/workflows/demo-images.yml +++ b/.gitea/workflows/demo-images.yml @@ -5,7 +5,7 @@ name: Demo images # code (see demo-deploy/ and docs/demo-deployment-design.md). # # Required repo configuration: -# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025 # vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) # secrets.DEMO_REGISTRY_USER # secrets.DEMO_REGISTRY_TOKEN diff --git a/.gitea/workflows/post-install-tests.yml b/.gitea/workflows/post-install-tests.yml index 7c5c4c86..91c53ffc 100644 --- a/.gitea/workflows/post-install-tests.yml +++ b/.gitea/workflows/post-install-tests.yml @@ -4,13 +4,11 @@ on: workflow_dispatch: inputs: target: - description: 'Target node IP (e.g. 192.168.1.198)' + description: 'Target node IP or hostname' required: true - default: '192.168.1.198' password: - description: 'Node password (or "auto" for fresh install)' + description: 'Node UI password (leave blank to use the NODE_UI_PASSWORD secret)' required: false - default: 'auto' jobs: post-install-tests: @@ -22,33 +20,46 @@ jobs: with: fetch-depth: 1 - - name: Run post-install tests on target + - name: Install SSH key + env: + SSH_KEY: ${{ secrets.NODE_SSH_KEY }} run: | - TARGET="${{ github.event.inputs.target }}" - PASSWORD="${{ github.event.inputs.password }}" - if [ "$PASSWORD" = "auto" ]; then - PASSWORD="testpass123!" + if [ -z "$SSH_KEY" ]; then + echo "ERROR: repository secret NODE_SSH_KEY is not configured." + echo "Post-install tests authenticate by key; password auth is not supported." + exit 1 fi + mkdir -p ~/.ssh && chmod 700 ~/.ssh + printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519 + chmod 600 ~/.ssh/id_ed25519 + + - name: Run post-install tests on target + env: + TARGET: ${{ github.event.inputs.target }} + NODE_PASSWORD: ${{ github.event.inputs.password }} + NODE_UI_PASSWORD: ${{ secrets.NODE_UI_PASSWORD }} + SSH_USER: ${{ vars.NODE_SSH_USER }} + run: | + PASSWORD="${NODE_PASSWORD:-$NODE_UI_PASSWORD}" + if [ -z "$PASSWORD" ]; then + echo "ERROR: no node password supplied (input or NODE_UI_PASSWORD secret)." + exit 1 + fi + USER_NAME="${SSH_USER:-archipelago}" echo "══════════════════════════════════════════" echo "Running post-install tests on $TARGET" echo "══════════════════════════════════════════" - # Copy test script to target and run - sshpass -p 'archipelago' scp -o StrictHostKeyChecking=no \ + scp -o StrictHostKeyChecking=accept-new \ scripts/run-post-install-tests.sh \ - archipelago@${TARGET}:/tmp/run-post-install-tests.sh 2>/dev/null || \ - scp -o StrictHostKeyChecking=no \ - scripts/run-post-install-tests.sh \ - archipelago@${TARGET}:/tmp/run-post-install-tests.sh + "${USER_NAME}@${TARGET}:/tmp/run-post-install-tests.sh" - # Run tests (with sudo for service checks) - sshpass -p 'archipelago' ssh -o StrictHostKeyChecking=no \ - archipelago@${TARGET} \ - "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" 2>/dev/null || \ - ssh -o StrictHostKeyChecking=no \ - archipelago@${TARGET} \ - "sudo bash /tmp/run-post-install-tests.sh '$PASSWORD'" + # Password is passed over stdin, never as an argv the node's process + # list (or this job's log) would expose. + printf '%s' "$PASSWORD" | ssh -o StrictHostKeyChecking=accept-new \ + "${USER_NAME}@${TARGET}" \ + "sudo bash /tmp/run-post-install-tests.sh --password-stdin" frontend-tests: runs-on: ubuntu-latest diff --git a/.githooks/pre-push b/.githooks/pre-push deleted file mode 100755 index c943bc06..00000000 --- a/.githooks/pre-push +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/env bash -# Keep the served companion APK in sync with main on every push. -# -# When a push to main includes Android changes, rebuild the APK, refresh -# neode-ui/public/packages/archipelago-companion.apk, commit it, and ask -# you to push again (so the refreshed APK rides along in the same push). -# -# Enable once per clone: git config core.hooksPath .githooks -set -euo pipefail - -ROOT="$(git rev-parse --show-toplevel)" -cd "$ROOT" - -# ship-companion.sh already (re)published the APK for this push — don't redo it. -[ -n "${SHIP_COMPANION:-}" ] && exit 0 - -PUSH_MAIN=0; RANGE_OLD=""; RANGE_NEW="" -while read -r _local_ref local_sha remote_ref remote_sha; do - if [ "${remote_ref##*/}" = "main" ]; then - PUSH_MAIN=1; RANGE_OLD="$remote_sha"; RANGE_NEW="$local_sha" - fi -done -[ "$PUSH_MAIN" = "1" ] || exit 0 - -# Loop-break: if the tip is already the auto APK commit, let the push proceed. -case "$(git log -1 --pretty=%s)" in - *"companion APK"*) exit 0 ;; -esac - -# Only rebuild when this push actually touches the Android app. -ZEROS="0000000000000000000000000000000000000000" -if [ -z "$RANGE_OLD" ] || [ "$RANGE_OLD" = "$ZEROS" ]; then - ANDROID_CHANGED=1 -elif git diff --quiet "$RANGE_OLD" "$RANGE_NEW" -- Android/ 2>/dev/null; then - ANDROID_CHANGED=0 -else - ANDROID_CHANGED=1 -fi -[ "$ANDROID_CHANGED" = "1" ] || exit 0 - -bash scripts/publish-companion-apk.sh || exit 0 - -DEST="neode-ui/public/packages/archipelago-companion.apk" -if git diff --cached --quiet -- "$DEST"; then - exit 0 # APK unchanged — nothing to do -fi - -git commit -q -m "chore(android): update companion APK download [skip ci]" -echo "" >&2 -echo "▶ Companion APK rebuilt and committed. Run your push again to include it." >&2 -exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cca99ad..ab3b5ada 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,8 +93,31 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Install YAML parser + run: python3 -m pip install --quiet pyyaml + - name: Validate manifests run: | for manifest in apps/*/manifest.yml; do ./scripts/validate-app-manifest.sh --repo-audit "$manifest" done + + # The signed catalog overrides on-disk manifests on every node, so a + # catalog naming a registry host the deployed fleet does not trust breaks + # every install fleet-wide. Blocking, and cheap. + - name: Catalog registry trust floor + run: python3 scripts/check-catalog-registry-trust.py + + # A stale image literal on the fallback install path deploys an old + # image after the manifest has moved on — how a withdrawn, vulnerable + # release gets installed post-fix. Blocking. + - name: Installer image pins + run: python3 scripts/check-installer-image-pins.py + + # Advisory: shows where the release catalog has fallen behind the + # manifests in this repo. Not blocking, because the catalog can only be + # updated through the signing ceremony, so drift is expected between a + # manifest landing and the next signed release. + - name: Catalog drift (advisory) + continue-on-error: true + run: python3 scripts/check-app-catalog-drift.py --catalog releases/app-catalog.json --release diff --git a/.github/workflows/demo-images.yml b/.github/workflows/demo-images.yml index 0471538b..9e0f1733 100644 --- a/.github/workflows/demo-images.yml +++ b/.github/workflows/demo-images.yml @@ -5,7 +5,7 @@ name: Demo images # code (see demo-deploy/ and docs/demo-deployment-design.md). # # Required repo configuration: -# vars.DEMO_REGISTRY e.g. 146.59.87.168:3000/lfg2025 +# vars.DEMO_REGISTRY e.g. source.archipelago-foundation.org/lfg2025 # vars.DEMO_REGISTRY_HOST registry host for docker login (no org suffix) # secrets.DEMO_REGISTRY_USER # secrets.DEMO_REGISTRY_TOKEN diff --git a/.gitignore b/.gitignore index b99a37af..02574536 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,13 @@ coverage/ releases/** !releases/ !releases/manifest.json +# The signed app catalog and the registry trust floor are source, not build +# output: nodes fetch the catalog from this path on main, and the floor is what +# scripts/check-catalog-registry-trust.py checks it against. Both were being +# swallowed by the rule above — app-catalog.json only stayed tracked because it +# predates it. +!releases/app-catalog.json +!releases/registry-trust-floor.json # Image recipe output image-recipe/output/ @@ -85,6 +92,23 @@ scripts/resilience/reports/ .codex-tmp/ .claude/ .pnpm-store/ + +# Key material and local databases — belt-and-braces so a stray key or a +# copied node database can never be committed. Open-source readiness plan, +# Phase 1 item 5: `.claude/settings.local.json` was previously only caught by +# a machine-global ignore rule, which protects one machine and no contributor. +*.key +*.pem +id_rsa* +*.sqlite +*.sqlite3 +*.db + +# ...except the throwaway TLS fixtures the appgate tests compile in via +# include_bytes!. They are documented non-identity material (see that +# directory's README) and are already tracked; the negation stops the rule +# above from silently dropping them if they are ever regenerated. +!core/archipelago/src/appgate/testdata/*.key **/__pycache__/ *.bak @@ -92,3 +116,49 @@ scripts/resilience/reports/ # app/docs asset path with a descriptive filename. Screenshot *.png uploads/ + +# ── Local-only material ───────────────────────────────────────────────────── +# Present on disk, never tracked: everything describing Archipelago's own +# infrastructure or internal development process. The repo is source code and +# guidelines only. Inventory: .local-only/manifest.txt — wipe: .local-only/wipe.sh +/.local-only/ +/.planning/ +/loop/ +/docs/operations-runbook.md +/docs/hotfix-process.md +/docs/PRODUCTION-MASTER-PLAN.md +/docs/UNIFIED-TASK-TRACKER.md +/docs/FIPS-UPTIME-AND-UI-STATE-PLAN.md +/docs/HANDOFF-2026-07-20-fips-peer-files.md +/docs/HANDOFF-2026-07-23-companion-apk-deploy.md +/docs/qr-scanner-snappiness-handover.md +/docs/RETICULUM-TRANSPORT-PROGRESS.md +/docs/combined-test-plan-2026-07-22.md +/docs/pine-voice-release-test-plan.md +/docs/OPEN-SOURCE-READINESS-PLAN.md +/docs/archive/HANDOVER-2026-07-02-iso-feedback.md +/docs/archive/SESSION-1.8.0-OTA-PROGRESS.md +/docs/security/KEY-02-FLEET-ROTATION.md +/docs/security/KEY-03-SIGNING-POSTURE.md +/tests/production-quality/TRACKER.md +/scripts/deploy-config-defaults.sh +/scripts/deploy-tailscale.sh +/scripts/deploy-to-target.sh +/scripts/setup-target-dev.sh +/scripts/setup-aiui-server.sh +/scripts/setup-https-dev.sh +/scripts/debug-frontend.sh +/scripts/node-profile.sh +/scripts/fleet-fips-pair.sh +/scripts/fleet-fips-unpair.sh +/image-recipe/sync-from-live.sh +/docs/security/PHASE-10-VERIFICATION-GUIDE.md +/docs/security/KEY-01-ON-NODE-VERIFICATION.md +/docs/security/KEY-02-ROOTFS-EVIDENCE.md +/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +/image-recipe/INTEGRATION-GUIDE.md +/docs/multinode-testing-plan.md +/docs/bitcoin-version-bulletproof-rollout.md + +# Generated PWA dev output (vite-plugin-pwa) — never a source artifact +neode-ui/dev-dist/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b79b5f6c..00000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "indeedhub"] - path = indeedhub - url = http://146.59.87.168:3000/lfg2025/indeehub.git diff --git a/.planning/APP-PORT-AUTH-GATE.md b/.planning/APP-PORT-AUTH-GATE.md deleted file mode 100644 index 3717af7b..00000000 --- a/.planning/APP-PORT-AUTH-GATE.md +++ /dev/null @@ -1,106 +0,0 @@ -# App-port authentication gate — design - -Item 1 of `RELEASE-1.7.121-TASKS.md`. Opened 2026-08-04. - -> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be -> allowed… it must present the login to access the app with an app icon of what you're -> accessing to confirm, and 2FA if present" — operator, 2026-08-03 -> -> "make sure we fix FIPS, Tor, everything … umbrel definitely shows a port when you go to -> tailscale IP or other + port but demands the node login and 2FA if activated" -> — operator, 2026-08-04 - ---- - -## What we already built, and why it did not close this - -The operator's recollection that FIPS and Tor were "done" is correct — but that work was -about **reachability**, and about restricting the **daemon's own** API. Neither one ever -authenticated an app port. Read together, each transport got a door and none got a lock: - -| Layer | What exists today | What it protects | -| --- | --- | --- | -| `server.rs:1271` `is_peer_allowed_path` | Federated peers hitting the **daemon** port may only reach `/health`, `/rpc/v1`, `/content`, `/blob/`, `/dwn/`, `/transport/inbox`, `/archipelago/*` | The daemon's API surface. **Not app ports.** | -| `fips/app_ports.rs` `APP_LAUNCH_PORTS` | 35 app ports **allowed through** the fips0 firewall | Nothing — it *opens* them | -| `server.rs:1130` `app_port_v6_relay_loop` | Daemon relays mesh v6 → v4 loopback for those same ports | Nothing — it *bridges* them | -| `api/rpc/tor/mod.rs:243` | Per-app `HiddenServicePort 80 → 127.0.0.1:` | Nothing — it *publishes* them to an onion | -| `container/quadlet.rs:261` | `PublishPort=0.0.0.0:{host}:{container}` | Nothing — it binds every interface | - -So the app ports are reachable, by construction, over LAN, Tailscale, FIPS mesh and Tor, -and nothing on any of those paths checks a session. This is the same bug class as the -v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but structural rather than -per-endpoint. - -## The rule this design is built on - -**You cannot gate a socket you do not own.** Every previous fix added a check *beside* the -listener, which is why each one only covered the transport it was written for. The gate -has to *be* the listener. - -## Design - -Port numbers do not change. For an app whose UI port is `P`: - -- **The app binds `127.0.0.1:P` only** (`PublishPort=127.0.0.1:P:`), so it is - no longer reachable from any interface. -- **The gate binds `P` on every external address** — LAN IP, Tailscale IP, fips0 ULA — - and on **`127.0.0.2:P`** for Tor. `127.0.0.2` is a distinct loopback address, so it does - not collide with the app on `127.0.0.1:P`, and it means **no app needs a second port - number**. `torrc` changes to `HiddenServicePort 80 127.0.0.2:P`. -- Upstream for the gate is always `127.0.0.1:P`. - -Because the gate owns the socket, LAN / Tailscale / FIPS / Tor are one code path. There is -no per-transport work, and therefore no transport to forget. - -### Request handling - -1. Read the `session` cookie. Cookies are **host-scoped and port-agnostic**, so the - session minted on the dashboard is presented to `:P` automatically — this is the - same mechanism umbrel's "proxy token" relies on. (Scheme still matters: a `Secure` - cookie will not travel to a plain-HTTP app port. See open questions.) -2. **Valid session** → proxy to `127.0.0.1:P`, passing through `Upgrade` so WebSockets work. -3. **No/invalid session** → serve the login page **on the app port itself**, naming the app - and showing its icon, POSTing back to the same origin. The gate verifies the password, - enforces TOTP when enabled, and sets the session cookie — so logging in at - `:P` also logs you into the dashboard, exactly as umbrel behaves. -4. Non-browser clients get `401` with a JSON body rather than an HTML page. - -### What must NOT be gated - -Non-HTTP ports cannot carry a cookie and must be declared, not discovered: -electrum `50002`, bitcoin p2p `8333`, LND gRPC `10009`/`9735`. These need an explicit -manifest field (`auth: none` + rationale) so the exception list is a `grep`, and they are -a firewall/allowlist question, tracked separately. - -Note `api/rpc/tor/mod.rs:238-240` already special-cases lnd's `9735`/`10009` as -`is_protocol_service` — that distinction is the seed of the manifest field. - -## Deploy traps this walks into - -- **Three copies of every container spec** — `apps//manifest.yml`, - `scripts/container-specs.sh`, `scripts/first-boot-containers.sh`. Changing `PublishPort` - in one leaves fresh installs broken while the node looks fixed. This is exactly what bit - lnd-ui (item 4). **Deduplicating these is arguably a prerequisite, not a follow-up.** -- Changing `PublishPort` drifts every app → one-time recreate fleet-wide. -- The gate must rebind when addresses change (Tailscale up/down, DHCP, fips0 re-key). - Precedent exists: `peer_late_bind_loop` in `server.rs` already does this for fips0. -- Verify **on the node**, not from source. v1.7.120's headline bug was a fix that shipped - in the binary and never reached the running container. - -## Open questions for the operator - -1. **Machine clients.** Umbrel's real-world failure mode: Home Assistant (or any API - client) hitting an app's API has no cookie and breaks. Browser-only, or do we mint - per-app long-lived tokens? -2. **TLS/scheme.** The daemon serves plain HTTP with nginx terminating TLS in front. If the - dashboard is HTTPS and app ports are HTTP, a `Secure` session cookie will not be sent — - the gate would prompt for login every time. Either the gate serves TLS on app ports too, - or app ports are HTTP-only on such nodes. - -## Sequencing - -1. Gate module + login page + proxy, behind an env opt-in. -2. Prove on **one** HTTP app on .228, across all four transports. -3. Dedupe the container-spec declarations. -4. Roll to all HTTP apps; declare the non-HTTP exceptions. -5. Repoint `torrc` at `127.0.0.2`. diff --git a/.planning/HANDOFF.json b/.planning/HANDOFF.json deleted file mode 100644 index 5b91b8e0..00000000 --- a/.planning/HANDOFF.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "version": "1.0", - "timestamp": "2026-08-07T10:02:11.548Z", - "phase": "13", - "phase_name": "aiui-functional-conversational-node-control-and-content-surf", - "phase_dir": ".planning/phases/13-aiui-functional-conversational-node-control-and-content-surf", - "plan": 15, - "task": 6, - "total_tasks": 17, - "status": "paused", - "note": "Task ids below are the OPERATOR's 17-item demo list (see .planning/RESUME-2026-08-07-aiui-surfaces.md), not 13-XX plan tasks. Phase 13's own GSD plans are 14/15 done with only 13-15 (device-close) open.", - "completed_tasks": [ - {"id": 1, "name": "Unify AI Data Access toggles with assistant tool grants", "status": "done", "commit": "55155f2d"}, - {"id": 5, "name": "Cap content.browse-all-peers, rebuilt as Cloud's fan-out", "status": "done", "commit": "75919a20"}, - {"id": 12, "name": "Node certificate settings section container/layout", "status": "done", "commit": "75919a20"}, - {"id": 16, "name": "Populate the content surface for own shared content + rich chat previews", "status": "done", "commit": "9abc1623,b1c5d138", "evidence": "browser: chat:response surfaces=1 songs:2 images:13; heading '13 Images'"}, - {"id": 15, "name": "Content-surface stale title", "status": "done", "commit": "9abc1623", "evidence": "browser: 'Loading...' during turn, '13 Images' after", "caveat": "header-OVERLAP half never reproduced at 1600x950; check a narrow/mobile viewport"}, - {"id": 6, "name": "Settings link when a permission is ungranted", "status": "in_progress", "commit": "9abc1623", "progress": "node refused_categories + broker event + Teleported chrome banner all landed and typecheck clean; NEVER seen in a browser"}, - {"id": 9, "name": "Answer with content + context surfaces, not JUST prose", "status": "in_progress", "commit": "b1c5d138", "progress": "content turns verified in browser; system/network/bitcoin turns still prose-only because only content_list/apps_list are surface-producing"}, - {"id": 10, "name": "AIUI slow background image + console noise", "status": "in_progress", "commit": "b1c5d138", "progress": "web-search CSP spam and its 403 fixed; wavlake/itunes CSP block, 3x403, 2x402, 502, 404, sw.js SSL all still present; slow background image not investigated"} - ], - "remaining_tasks": [ - {"id": 2, "name": "Verify AI grants persist across refresh through the real UI path", "status": "not_started"}, - {"id": 3, "name": "Add app_install / app_uninstall tools behind the confirm gate", "status": "not_started"}, - {"id": 4, "name": "!archy / !ai over mesh must action commands with text responses", "status": "not_started"}, - {"id": 7, "name": "App lifecycle defects (fedimint guardian, BTCPay wipe-reinstall, disappearing apps, chown postgres-btcpay)", "status": "not_started"}, - {"id": 8, "name": "LND UI + filebrowser 401s (session passthrough on node-owned *-ui apps)", "status": "not_started", "hint": "the 3x403 still in the AIUI console may be this same family"}, - {"id": 11, "name": "Cmd/Ctrl+K carries the query into the expanded chat", "status": "not_started", "hint": "whole path read and appears correctly wired incl. cold-frame buffer; REPRODUCE IN A BROWSER before editing code"}, - {"id": 13, "name": "Serve HTTPS dynamically on EVERY address alongside Tailscale", "status": "not_started"}, - {"id": 14, "name": "Nostr signer + service worker over HTTPS", "status": "not_started"}, - {"id": 17, "name": "Cut a clean ISO for the demo (UNBUNDLED=1)", "status": "blocked", "blocked_by": "release binary predates the install.rs SearXNG seed fix"} - ], - "blockers": [ - {"description": "core/target/release/archipelago and the deployed /usr/local/bin/archipelago were both built BEFORE the install.rs SearXNG seed fix (c810b514). They carry every surface/peer fix (verified live) but not the SearXNG one.", "type": "technical", "workaround": "cd core && CARGO_INCREMENTAL=0 cargo build --release -p archipelago (~9 min), verify with: strings core/target/release/archipelago | grep -A2 'limiter: false' should show the formats lines, then redeploy. MUST clear before task 17 (ISO)."}, - {"description": "Existing fleet nodes still have SearXNG JSON disabled — c810b514 only fixes what NEW installs get. Every existing node's AIUI web search returns 403.", "type": "external", "workaround": "Add search.formats [html, json] to /var/lib/archipelago/searxng/settings.yml on each node and restart the app. archi-dev-box is already repaired."}, - {"description": "A concurrent agent is committing to the same branch (1eb75a1e, a7368b8b, 9cf1c122 — a full AIUI security/mission assessment with 6 HIGH/MED findings S1-S6). Stage by path only; never git add -A.", "type": "external", "workaround": "Read .planning/phases/13-.../ASSESSMENT-FIX-PLAN-2026-08-07.md before the next wave; its findings partly overlap tasks 8 and 10."} - ], - "async_jobs": [], - "human_actions_pending": [ - {"action": "Operator device-close for 13-15 check 2 (film content displayed on the surface)", "context": "The old holdout reason was 'no film content exists on this node'. That premise is now stale: peer content demonstrably reaches the surface and the images bucket renders the node's own catalogue. Re-test before asking again.", "blocking": true}, - {"action": "Decide whether system/network/bitcoin turns deserve context surfaces (task 9 remainder)", "context": "Only content_list and apps_list are surface-producing tools today.", "blocking": false} - ], - "decisions": [ - {"decision": "Capture surfaces RAW in loop_::execute_tool's Ok(v) arm, BEFORE wrap_tool_result_if_untrusted", "rationale": "The untrusted boundary exists to stop peer-authored text being read as instructions by the MODEL. This copy goes to a renderer that treats every field as inert data and never re-enters the prompt; wrapping it would leave the UI parsing delimiter noise instead of JSON.", "phase": "13"}, - {"decision": "Gate chat surfaces on media/files in the broker as well as node-side", "rationale": "Mirrors handleContentRequest — this channel carries node data into the iframe, so it is a consent surface and is checked in the host rather than trusting the node's grant check to be the only one. Dropping surfaces never drops the prose answer.", "phase": "13"}, - {"decision": "Archy tabs outrank regex-inferred tabs, ordered by bucket size", "rationale": "setArchyContent put the node grids up and updatePanelFromText then replaced the bar, landing on an AI Brief with the real grid unreachable. Size ordering because a 13-photo/2-track answer opened on Songs and titled itself '2 Songs'.", "phase": "13"}, - {"decision": "Skip client-side web search entirely when embedded in Archy", "rationale": "streamViaArchy sends only the user's text, so the system prompt those results were folded into is never transmitted. It cost a round trip and a CSP console error per turn while its output provably reached no model. Web search for the embedded path belongs node-side with the other tools.", "phase": "13"}, - {"decision": "Keep SearXNG rather than replace it", "rationale": "Operator suspected poor results. Measured live after the JSON fix: 28 results for 'bitcoin halving' from Brave (20) + DuckDuckGo (8). Google self-suspends and Startpage CAPTCHAs, normal for self-hosted and cheap given Brave's independent index. The 403 was the whole problem.", "phase": "13"}, - {"decision": "browse-all-peers accumulates per batch with a between-batch deadline, no outer timeout", "rationale": "Every future is already bounded by PER_PEER_TIMEOUT, so an outer timeout can only discard completed work — which is the exact bug being fixed.", "phase": "13"} - ], - "uncommitted_files": ["neode-ui/shot.tmp.mjs (untracked Playwright driver — deliberately not committed; recreate from the recipe in the resume doc if deleted)"], - "next_action": "Rebuild the release binary (CARGO_INCREMENTAL=0 cargo build --release -p archipelago) to clear the ISO blocker, and while it builds verify task 6's permission banner in a browser by revoking the media grant and asking for content.", - "context_notes": "This box IS archi-dev-box, so everything is testable locally. The session's method was: read code, form a hypothesis, then PROVE it against the live node before changing anything — that is what overturned the previous session's 'peers have no content' conclusion (the operator flagged it as wrong, and they were right: two code bugs produced a number that looked like a fleet outage). Authenticated RPC goes through nginx on 443, NOT ports 7777/8101. The Playwright driver at neode-ui/shot.tmp.mjs installs an addInitScript probe that logs every chat:response with its bucket counts — that probe is what proved the surface pipeline end to end, and it is the fastest way to re-verify after any change." -} diff --git a/.planning/INDEEHUB-CONTENT-INVENTORY-2026-08-07.md b/.planning/INDEEHUB-CONTENT-INVENTORY-2026-08-07.md deleted file mode 100644 index b8518502..00000000 --- a/.planning/INDEEHUB-CONTENT-INVENTORY-2026-08-07.md +++ /dev/null @@ -1,83 +0,0 @@ -# IndeeHub content inventory on archi-dev-box — 2026-08-07 - -Measured, not inferred. Written as a separate file because the surfaces todo and the -resume doc were being edited by a concurrent session at the time. - -## The operator's report - -Asked AIUI "what films are there to watch from my peers", and the assistant replied -that it *"[doesn't] have a tool that lets me query the content library of IndeedHub"* -and told them to open `localhost:7778` themselves. - -Two separate defects sit behind that one answer. **They have different fixes and only -one of them is being worked.** - -## 1. The tool gap — already in flight, do not duplicate - -A concurrent session is adding a `content_list` tool with `own` / `peers` / -`purchased` / `films` scopes, a `SURFACE_TOOLS` list so grid-ready results are -RENDERED rather than narrated, and a test that every advertised scope reaches a real -dispatch handler. Its own comment names this symptom ("it could find no peer -content"). Uncommitted at the time of writing — `assistant/tools.rs`, -`archyBridge.ts`, `contextBroker.ts` and 7 more. - -## 2. The category union is narrower in AIUI than in the broker — SEPARATE, unowned - -The broker serves **ten** categories (`neode-ui/src/types/aiui-protocol.ts`): - - apps system network wallet files media search ai-local notes bitcoin - -AIUI declares **six**, in two places: - - aiui/packages/app/src/composables/useArchy.ts:11 - aiui/packages/app/src/services/archyBridge.ts:8 - → apps system network wallet files bitcoin - -`media`, `search`, `ai-local` and `notes` cannot be requested by AIUI at all — the -string never appears in its source. `contextBroker.fetchAndSanitize` has a working -`case 'media': return this.sanitizeMedia(appStore)` arm on the other side of a door -AIUI cannot open. This is very likely why the model reported having no capability -rather than reporting an empty library. - -Whether `content_list` supersedes this or runs beside it is a real design question: -the content scopes and the context categories are two different channels. Decide it -deliberately rather than letting the union drift further. - -## 3. The library is EMPTY — the part that will not be fixed by either - -Measured on archi-dev-box, with a real Nostr session obtained through the gate -(node-signed NIP-98 → JWT): - -| endpoint | auth | result | -|---|---|---| -| `GET /api/projects` | none | `[]` — **0 items** | -| `GET /api/projects/private` | Bearer (valid nostr-session) | `[]` — **0 items** | -| `GET /api/projects/mine` | Bearer | 404 `"Film not found"` — route does not exist | - -So once the tool lands, the honest answer to "what films are there to watch" **from -this node's own library is still "none"**. Anything the operator sees must come from -the `peers` scope — the federated browse over FIPS with Tor fallback, which is the -slow path the concurrent session just made progressive so it no longer blocks `own`. - -**Do not let a correct "0 results" read as the tool still being broken.** When -verifying the `content_list` work, seed at least one project into IndeeHub first, or -verify against a peer node that has content — otherwise a fully working tool and a -completely broken one produce the same empty grid. That ambiguity is the same trap -recorded for the AI grants ("ungranted" and "empty library" were indistinguishable). - -## Verification recipe (reusable) - -The node can sign a real NIP-98 event itself, so IndeeHub's authenticated API can be -exercised with no browser and no extension: - -1. `auth.login` on `127.0.0.1:5678/rpc/v1` → capture the `session` **and** - `csrf_token` cookies from the Set-Cookie headers (curl's jar drops session cookies). -2. `node.nostr-pubkey` → the node's pubkey. -3. `node.nostr-sign` with a kind-27235 event, tags `[["u", ], ["method","POST"]]`. - **The CSRF header is required for signing** — `node.nostr-pubkey` is exempt, the - sign is not, and it 401s without it. -4. `POST /api/auth/nostr/session` with `Authorization: Nostr ` → JWT. -5. Use `Authorization: Bearer ` for the private endpoints. - -Both the NIP-98 login and the app's own bearer now survive the gate — see -`RESUME-2026-08-06-media-loop.md` item 1. diff --git a/.planning/INGEST-CONFLICTS.md b/.planning/INGEST-CONFLICTS.md deleted file mode 100644 index 703e66c4..00000000 --- a/.planning/INGEST-CONFLICTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Ingest Conflict Report - -Mode: new (fresh bootstrap — no existing .planning/ context to check against) -Precedence: ADR > SPEC > PRD > DOC (no per-doc overrides present) - -## Conflict Detection Report - -### BLOCKERS (0) - -(none) - -### WARNINGS (0) - -(none) - -### INFO (4) - -[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory - Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope - Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene. - -[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults - Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field) - Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md. - -[INFO] ADR numbering gap — ADR-010 absent from ingest set - Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010 - Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record. - -[INFO] Cross-reference graph is acyclic - Found: cross_refs edges: ADR-007 → ADR-003; ADR-009 → docs/app-manifest-spec.md (+ code paths); SPEC → out-of-set docs and code only (app-developer-guide.md, manifest-hooks-design.md, marketplace-protocol.md, core/container/src/manifest.rs, api/rpc/package/stacks.rs) - Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed. diff --git a/.planning/MEDIA-AND-INDEEHUB-SCOPE.md b/.planning/MEDIA-AND-INDEEHUB-SCOPE.md deleted file mode 100644 index b8e5247a..00000000 --- a/.planning/MEDIA-AND-INDEEHUB-SCOPE.md +++ /dev/null @@ -1,127 +0,0 @@ -# Media, IndeeHub & AIUI quality — scope from on-device evidence - -**Written 2026-08-06, end of session.** Every item below was observed on archi-dev-box or -read from source — none is inferred. This is the input for a proper research + plan pass, -not the plan itself. - -## A. The content-card parser is the "idiotic responses" bug - -Operator-visible symptom: asking for Bitcoin films produced good model prose, then cards -that were **wrong**: - -- `Banking on Bitcoin` captioned with *The Rise and Rise of Bitcoin*'s description -- `Cryptopia` captioned with *The Bitcoin Standard*'s -- `Documentaries:` and `Narrative Films:` rendered as if they were titles -- `The Social Network` captioned with *Related Financial/Tech Films:* - -Cause is `updatePanelFromText` (useContentPanel.ts) pairing title *n* with description -*n-1* and not excluding section headers. **The model was not at fault** — the card layer -mangled correct prose. Fix the parser before touching prompts. - -Deeper question for the research pass: a regex over prose is the wrong contract entirely. -The model should return **structured** recommendations (tool call / JSON), and the grid -should render those. D-12 already says node content is the source of truth for these -buckets; text-scraping is the legacy path that should shrink, not be patched forever. - -## B. IndeeHub — three independent faults - -1. **Content source.** Films are `projects` in IndeeHub's NestJS API - (`GET /api/projects` via its own nginx; port 4000 is not host-mapped; `/graphql` is the - SPA catch-all, NOT an API). On this node `/api/projects/count` = **`{"count":0}`** — - the public library is genuinely empty. `content.owned-list` (content.rs) has **no** - IndeeHub linkage; `owned` is Archipelago's own paid-content store. So AIUI has never had - a path to IndeeHub content and would render nothing even if wired. -2. **Signer / auth.** `GET /api/projects/private` → 401 - `{"message":"Cognito authentication is disabled. Use Nostr login."}`. Private films need - a **Nostr session**. `/api/auth/nostr/session` 401s through the gate (see C). An adapter - must therefore authenticate as the user — which lands on the phase's non-negotiable: - keys stay out of the browser and the model, so this belongs node-side behind a capability - grant. Same shape as follow-on Phase C (Nostr first-class). -3. **Relay is down independently.** `/relay` returns **502 direct on loopback**, bypassing - the gate — IndeeHub's own nginx cannot reach the relay container. `wss://relay.damus.io` - also fails from that page. Not a gate fault. - -## C. The app gate breaks apps that own their auth — FLEET-WIDE, highest priority - -Verified: `http://:7778/manifest.json` → **401 + the gate's login HTML**. - -- **A PWA manifest is fetched WITHOUT credentials** unless the tag sets - `crossorigin="use-credentials"`. The cookie is never sent, so the gate 401s it *even when - fully logged in*. This hits **every gated app with a PWA manifest**, not just IndeeHub. -- The app's service worker serves the cached shell, so the SPA boots ("Backend connected at - /api — real mode active") and only then does every network call 401 — which is why it - looks like an app bug rather than a gate bug. -- The gate also intercepts the app's own `/api/auth/nostr/session`, so IndeeHub can never - establish its own session. "Nostr login failed" / "Sovereign identity generation failed" - are all this one cause. - -Same class as the `.125` cookie-strip that broke every companion UI. The gate needs a -stated policy for (a) credential-less subresource fetches the browser sends by design and -(b) app-owned auth endpoints once a valid gate session exists. **Each exemption is a hole in -a security control and needs its own written justification** — do not batch-fix this. - -## D. AI Data Access grants do not survive — wrong storage layer - -`aiPermissions.ts` persists to `localStorage` (`archipelago-ai-permissions`). No logout path -clears it (only SystemDangerZone, by design). **localStorage is per-origin**, and a node has -many: `192.168.63.240`, `100.69.68.39`, `.local`, the Tailscale name. Granting on one -and returning via another shows everything off — which is exactly what "turns them all off" -looks like, and what made a films search look broken tonight. - -These grants are a property of the NODE ("what may the AI read"), not of one browser at one -address. They belong node-side behind an RPC, with localStorage as an offline fallback and a -migration so existing local grants are not silently dropped. - -## E. Also observed, unowned - -- `/api/app-catalog` → **502**, repeatedly, on the dashboard. -- AIUI web search blocked by CSP (`connect-src http://:*/aiui/`) — confirms the - already-recorded 13-09 decision that the web-search setting must drive the CSP node-side. -- `Failed to scroll to index N after 10 attempts` — ChatWindow scroll bug, cosmetic but loud. -- `strfry.png` / `.svg` 404 — missing app icon. - -## Suggested sequencing (to be challenged by the research pass) - -1. **C** — fleet-wide, user-visible, security-critical. Blocks any app with its own login. -2. **D** — one RPC; unblocks every AI content path and stops false "broken" reports. -3. **A** — parser fix now, structured-output contract as the real answer. -4. **B** — needs C and D first; the signer question is a design decision, not a task. - -## Nostr-first framing (per feedback_nostr_first_solutions) - -Worth researching rather than assuming: IndeeHub already speaks Nostr for identity, and the -node already holds Nostr identity material. A single node-side signer serving both the -dashboard and gated apps (NIP-07-style bridge, already precedented by `nostr-provider.js`) -would address B-2, the app-auth half of C, and Phase C's zaps at once. Media identity/ -distribution over Nostr (NIP-94/NIP-71 style events, Blossom for blobs) is the obvious -frame for "all the media types" and should be evaluated against the current -`content.*` RPC model before more sources are bolted onto it. - -## F. Added by operator 2026-08-06, late — not yet started - -- **Cmd/Ctrl+K search → AIUI.** Choosing "search with AIUI" from the command palette must - open the EXPANDED chat with the typed query actually sent to AIUI and answered — today it - does not carry the words through. Wire the palette's query into the chat open path. -- **Mock data types.** Add more mock content types for dev/preview, and make the whole - content path performant (the grid, the fan-out, the panel). -- **Node questions — full coverage.** Every "ask the node about itself" question class should - be answerable: apps, system, network, wallet, bitcoin, files, media, search, ai-local, - notes are the declared `AIContextCategory` set in contextBroker's `fetchAndSanitize`. - Audit each for real coverage rather than a stub. -- **AIUI seed/history as design input.** The operator asked that the solution be researched - from AIUI's own seed/history and the project's Nostr-first ethos, not invented fresh. - -## G. Status of the fixes made 2026-08-06 - -Shipped + deployed to archi-dev-box: content-grid sequence guard (`aac81503`), owned/peer -scopes wired (`11b9cb50`), per-scope permission logging (`7c23505d`), warm-up app status -(`c65ee03a`), scheme-following app frames (`f09ff102`), per-node CA + Settings flow -(`aab74127`), app-port TLS with HTTP on the same socket (`7515166a`), key perms for the -daemon (`1dfd9e72`). - -Shipped, NOT yet deployed: the credential-less allowlist that fixes the IndeeHub -regression — release build was in flight at end of session. Deploy is: -`install -m 755 core/target/release/archipelago /usr/local/bin/archipelago` then -`systemctl restart archipelago` (containers are unaffected — verified, they live in -/user.slice, not the service cgroup), then re-test `curl -o /dev/null -w "%{http_code}" -http://:7778/manifest.json` — expect 200, not 401. diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md deleted file mode 100644 index b645fc09..00000000 --- a/.planning/PROJECT.md +++ /dev/null @@ -1,115 +0,0 @@ -# Archipelago - -## What This Is - -Archipelago is a self-hosted personal-server platform: a Rust daemon (workspace at `core/`) -plus a Vue 3 frontend (`neode-ui/`, built to `web/dist/neode-ui/`) running on Debian nodes -with rootless Podman, managing ~40 declarative, manifest-driven apps (Bitcoin, Lightning, -mesh/LoRa, federation, media, and more). It ships as OTA-updated releases to a live fleet -and is actively shipping v1.7.x alpha releases. This milestone drives it to the -**developer-ready app platform** north star. - -## Core Value - -A third-party developer can publish an app via the signed/decentralized registry and a user -can install it on their node — every app manifest-driven, manifests shipped via the signed -registry (not OTA disk files), all rootless, secure, robust, and 100%-uptime-capable. - -## Current State (brownfield baseline, 2026-07-29) - -- Single-node production gate is **GREEN** (5/5 on .228, 2026-06-23) — that exit criterion is met. -- ~40 apps are manifest-based and Quadlet-migrated; all multi-container stacks use the - orchestrator stack pattern; the legacy per-app installer anti-pattern is deleted. -- Workstream B (registry-distributed manifests) phases 1+2 are code-complete; the signing - ceremony is done (release-root pinned in `anchor.rs`); the fleet flip is not yet authorized. -- Workstream C (marketplace) is design-only (`docs/marketplace-protocol.md`); no tooling or - trust UX built. Developer CLI suite (`archy app …`) does not exist yet. -- Phase-3 Quadlet default-flip is validated opt-in on .228/.198 but not default. -- Declared next exit criteria: the multinode pass (`docs/multinode-testing-plan.md`) and the - remaining workstreams. - -## Requirements - -### Validated - -- ✓ Single-node lifecycle gate green 5× on .228 (install/UI/stop/start/restart/reinstall/ - reboot-survive/daemon-restart-survive/uninstall) — 2026-06-23 -- ✓ Manifest-driven app packaging for all ~40 apps incl. multi-container stacks (workstream A) -- ✓ Signed catalog + release-root signing ceremony (workstream B phases 1+2, code-complete) - -### Active - -See `.planning/REQUIREMENTS.md` — 20 v1 requirements across MNODE / LIFE / REG / SEC / DEV / MKT, -all mapped to phases in `.planning/ROADMAP.md`. - -### Out of Scope - -- Rootful containers, Docker, privileged containers — invariant (ADR-001/ADR-009) -- Per-app Rust installers / OS-level provisioning — the anti-pattern being deleted -- Centralized gatekept app store — decentralized Nostr marketplace instead (ADR-006) -- Web5 DWN spec compliance — deprioritized after TBD shutdown (ADR-011) -- Custom live voice-call protocol — deprioritized per user 2026-07-01; revisit later -- DHT/iroh distribution backbone (workstream D) — design-only, tracker-marked backlog; v2 - -## Context - -- Repo: `core/` Rust workspace (no root Cargo.toml), `neode-ui/` Vue frontend, `apps/` manifests, - `tests/lifecycle/` + `tests/multinode/` gates, `docs/` authoritative plans. -- Authoritative narrative: `docs/PRODUCTION-MASTER-PLAN.md`; day-to-day open list: - `docs/UNIFIED-TASK-TRACKER.md`. Codebase map: `.planning/codebase/ARCHITECTURE.md` + - `.planning/codebase/CONCERNS.md`. -- Known debt informing this milestone (from CONCERNS.md): federation tombstone-write errors - swallowed; reconciler has no flap observability and no failed-unit self-healing; generated - AppArmor profiles are never applied; multinode test harness curl calls lack timeouts; - SPEC validation is narrower than ADR-009's mandates (non-root UID, pinned tags, seccomp). -- Fleet is live and OTA-updated; all destructive verification happens on designated test - nodes per the deploy roster — never uninvited on in-use nodes. - -## Constraints - -- **Security**: Rootless Podman only; manifest-declared secrets (0600, never logged); - mandatory container security defaults enforced at manifest level (ADR-009) -- **Data safety**: Migrations never destroy data — preserve `/var/lib/archipelago/`, - secrets, credentials, ports, adoption container names; always a rollback path -- **Verification**: Real-node verification before any tag; lifecycle gate runs ON the node, - not via RPC; mesh changes need real-RF E2E tests; re-run the gate after orchestrator changes -- **Process**: Commit + push every unit of work (`git push gitea-ai main`); stage by explicit - path; deploy to the dev pair before any OTA; never commit secrets -- **Tech stack**: Rust (Tokio/Hyper, JSON-RPC 2.0) backend; Vue 3 + Pinia frontend; - Quadlet/systemd-user container units; Ed25519-signed release artifacts - -## Key Decisions - - - -All ten ADRs below are **locked** (Status: Accepted; ingest source `docs/adr/*.md`). They are -non-negotiable inputs to planning and cannot be overridden without a new ADR. - -| ID | Decision | Scope | -|----|----------|-------| -| ADR-001 | Podman over Docker — rootless, daemonless, systemd-native; `archy-net` for inter-container DNS | Container runtime | -| ADR-002 | `did:key` (Ed25519) node identity — self-contained, offline-capable; gaps mitigated via federation trust lists | Identity | -| ADR-003 | Nostr relays (NIP-78, kind 30078) for node + app discovery — multi-relay query, 15-min cache, trust scoring, Tor-compatible | Discovery | -| ADR-004 | Tor hidden services for inter-node RPC/control plane — bulk data via registries, not Tor | Federation transport | -| ADR-005 | ChaCha20-Poly1305 + Argon2id (64MB, 3 iter) for backup encryption | Backups | -| ADR-006 | Nostr relays for marketplace discovery — DID-signed manifests, trust tiers (Verified/Community/Unverified), signature verification before install | Marketplace | -| ADR-007 | Bilateral DID federation trust via single-use invite codes; Trusted/Observer/Untrusted levels | Federation trust | -| ADR-008 | Dual keys from one master seed — Ed25519 canonical identity, secp256k1 for Nostr/Bitcoin/Lightning, linked via NIP-05 | Keys | -| ADR-009 | Manifest-level container security enforcement — readonly_root, no_new_privileges, non-root UID, drop-ALL caps, pinned tags, seccomp; overrides explicit + audited | Container security | -| ADR-011 | DWN deprioritized — keep custom `dwn_store.rs`, stop branding as Web5, invest in Nostr + Tor federation instead | Peer data sync | - -(ADR-010 does not exist in the repo — numbering gap, noted in `.planning/INGEST-CONFLICTS.md`.) - - - -Milestone-level decisions: - -| Decision | Rationale | Outcome | -|----------|-----------|---------| -| Milestone version = 1.8.0-alpha | Decided 2026-07-08 per tracker | — Pending ship | -| Workstream D (DHT) deferred to v2 | Design-only, tracker-marked backlog; not needed for north-star metric | — Pending | -| App manifest canonical schema = `core/container/src/manifest.rs` | SPEC self-declares code wins over doc | ✓ Good | -| Phase-3 Quadlet flip gated on multinode gate reporting clean | Prior uncommitted-flip confusion; flip fresh as a 2-line change when gate is clean | — Pending | - ---- -*Last updated: 2026-07-29 after intel ingest (10 ADRs + 1 SPEC) + codebase mapping* diff --git a/.planning/RELEASE-1.7.121-TASKS.md b/.planning/RELEASE-1.7.121-TASKS.md deleted file mode 100644 index 7542bda1..00000000 --- a/.planning/RELEASE-1.7.121-TASKS.md +++ /dev/null @@ -1,554 +0,0 @@ -# Release 1.7.121 — task list - -Opened 2026-08-03, immediately after v1.7.120-alpha shipped. Everything the operator has -asked for since, plus the items v1.7.120 deliberately left open. Ordered by severity. - -Status key: **DONE** (committed) · **READY** (written, not yet committed/tested) · -**OPEN** (not started) · **BLOCKED** (needs an operator decision) - ---- - -## P0 — Security - -### 1. App ports are reachable with no login, on every transport — **OPEN** -> "if I'm logged out I can reach every app port on tailscale and LAN, this can not be -> allowed… it must present the login to access the app with an app icon of what you're -> accessing to confirm, and 2FA if present" — operator, 2026-08-03 - -- Applies to **Tailscale, LAN, Tor, FIPS** alike, and to "ssh access to that port or whatever". -- Required behaviour: an unauthenticated request to any app port serves a **login page - naming and showing the icon of the app being accessed**, then honours **2FA when set**. -- **Research first:** how umbrelOS and StartOS gate app access (operator asked explicitly). - Both are open source — `getumbrel/umbrel` and `Start9Labs/start-os`. Do not guess at - their model; read it. -- This is the same class as the v1.7.120 `/lnd-connect-info` + `/bitcoin-rpc/` leaks, but - **fleet-wide across every app port** rather than two endpoints. Those two were closed by - moving authorisation to the resource; this needs a general gate. -- Scope note: `fips/app_ports.rs` holds the mesh allowlist; `is_peer_allowed_path` in - `server.rs` holds the peer HTTP allowlist. Neither currently authenticates app ports. - -#### Research — umbrelOS (verified from their docs/source, 2026-08-03) - -umbrelOS solves this **architecturally, not per-app**: the app's own port is never -published. Each app gets a sidecar `app_proxy` container that owns the published port and -forwards to the app on the internal network. - -- `containers/app-proxy` is described as *"a transparent HTTP proxy to add authentication - to Umbrel apps"* — **every** HTTP request and WebSocket upgrade passes through it and - has its session token checked. -- Tokens come from a separate `app-auth` service; the proxy talks to it over a local port - (default 2000) with a shared secret (`UMBREL_AUTH_SECRET`). Two JWTs exist: an **API - token** in localStorage (`{loggedIn: true}`) for the dashboard's own API, and a - **proxy token** in an **HttpOnly cookie** (`{proxyToken: true}`) for app access. Both - HS256, 7-day expiry. -- Unauthenticated requests are redirected to the login screen. -- Per-app escape hatches, all env vars on the proxy: `PROXY_AUTH_ADD` (bool, **default - true** — so apps are protected unless opted out), `PROXY_AUTH_WHITELIST` (paths exempt, - e.g. `/public/*`), `PROXY_AUTH_BLACKLIST` (paths that must be authed, e.g. `/admin/*`). -- Known friction worth designing around: apps with their own login (Frigate, and the - `PROXY_AUTH_ADD=false` tracker issue) end up double-authenticating, and non-browser API - clients (Home Assistant hitting an app's API) break because they have no cookie. Any - gate we build needs a story for machine clients, not just browsers. - -**The lesson for us:** the reason umbrel doesn't have this bug class is that there is no -unauthenticated path to bind to in the first place. Our apps publish their own ports -directly, so a gate bolted onto one transport leaves the others open — which is exactly -the shape of the `/lnd-connect-info` + `/bitcoin-rpc/` leaks. The fix likely has to move -the port binding, not just add a check. - -#### Reproduced ON archi-dev-box, 2026-08-03 — baseline before the fix - -No session cookie, over the Tailscale IP `100.69.68.39`: - -``` -port 18083 HTTP 200 LND - Archipelago -port 8334 HTTP 200 -port 8175 HTTP 200 Fedimint Guardian - Archipelago -port 8336 HTTP 200 FIPS Mesh -port 8090 HTTP 200 -port 7777 HTTP 200 -``` - -`ss -tlnp` confirms these are bound `0.0.0.0`, so the same responses are served on the LAN -IP and every other host address. Re-run this exact loop after the fix: every one must -become the login page, and the ports listed as protocol exemptions (item 1b) must be the -*only* ones still answering. - -#### Exposure map — how app ports are reachable TODAY (verified in source, 2026-08-03) - -All four transports converge on `127.0.0.1:`. This is the whole reason the fix -is tractable: it is **one gate, not four**. - -| Transport | Path to the app | Code | -|---|---|---| -| LAN / Tailscale | container publishes the port on the host (`--network host`, so `0.0.0.0:`) — reachable on *every* host IP | `scripts/container-specs.sh`, `first-boot-containers.sh` | -| FIPS mesh | daemon binds `[fips0-ULA]:` and raw-TCP-forwards to `127.0.0.1:` | `server.rs:1130` `app_port_v6_relay_loop` | -| FIPS firewall | `tcp dport { …APP_LAUNCH_PORTS… } accept` drop-in opens them all | `fips/config.rs:274`, `fips/app_ports.rs` | -| Tor | `HiddenServicePort 80 127.0.0.1:` per service | `api/rpc/tor/mod.rs:243` | - -#### Design decision (operator, 2026-08-03) - -**Gate app UIs + bearer tokens; protocol ports exempt.** HTTP app UIs get the login gate -(app name + icon, 2FA honoured). Protocol ports (LND gRPC 10009 + REST, electrum 50002, -bitcoin p2p 8333) stay open but MUST be declared `auth: none` with a rationale in the -manifest, so the exceptions are a grep rather than a discovery — see item 1b. Per-app -long-lived bearer tokens cover machine clients that speak HTTP (Home Assistant). **Zeus -and electrum wallets keep working untouched** — that was the deciding constraint. - -The gate lives in the **daemon**, not a per-app sidecar container (umbrel's `app_proxy` -model): rootless, no extra containers per app, one place to update, and it can reuse the -existing `app_port_v6_relay_loop` rather than fight it. - -#### ⚠️ Trap found while designing — an nft-only gate FAILS OPEN - -The obvious implementation is an nft redirect of inbound app-port traffic to the gate. -But `/etc/fips/fips.nft` is **provisioned out-of-band** and `fips/config.rs:290` treats its -absence as a no-op (`if try_exists("/etc/fips/fips.nft")`). A gate shipped as a `fips.d` -drop-in would therefore be **silently absent on every node without the hardening -baseline** — i.e. it fails open, which is exactly the failure class this item exists to -close. - -Two viable shapes, both fail-closed: -- **(a) Apps bind loopback only**, daemon owns every external bind. Airtight, the true - umbrel model, but requires touching each app's own listen config (nginx.conf etc.). - Note you *cannot* half-do this: while an app holds `0.0.0.0:`, the daemon cannot - bind `:` at all. -- **(b) Daemon owns a dedicated `archipelago-appgate` nft table** with its own - default-deny + redirect, independent of whether `fips.nft` exists, and refuses to start - / alarms loudly if it cannot install it. Non-invasive to apps. - -#### Enabler found — `PortMapping.bind` already does half of (a) - -`core/container/src/manifest.rs:518` — `PortMapping` has a `bind` field, documented as -*"Host address to bind the publish to. Empty = all interfaces (0.0.0.0). Set `127.0.0.1` -to keep a port host-local"*. So for **bridge apps that declare `ports:`**, going -loopback-only is a **manifest edit, not app surgery**, and the daemon can then own the -external bind. That is most of the catalog. - -The exception is **host-networked apps** (`security.network_policy: host` — `lnd-ui`, -`bitcoin-ui`, `electrs-ui`): host networking bypasses port mapping entirely, so `bind` has -no effect and `ports:` is deliberately empty. Those bind whatever their internal nginx -binds. We build those images ourselves, so the fix is a `listen 127.0.0.1:;` change -in each `docker/*-ui/nginx.conf` — still no third-party surgery. - -Watch the rootless trap documented at `manifest.rs:532`: a publish bound to an address the -host cannot actually bind crash-loops the whole unit (took bitcoin down fleet-wide on .228, -2026-07-09). Loopback binds are explicitly always accepted without probing, so this -direction is safe. - -**Tor needs separate handling either way**: the onion connects *from* localhost, so a -redirect that exempts loopback will not catch it. `HiddenServicePort` must be repointed at -the gate, and since that mapping loses the original destination port, each app needs its -own gate port (or an HTTP-level Host mapping). - -#### Primitives that already exist — do NOT build these from scratch - -The gate is mostly assembly, not invention: - -| Need | Existing API | -|---|---| -| Read the session cookie off a request | `session::extract_session_cookie(&HeaderMap) -> Option` (`session.rs:479`) | -| Validate a session | `SessionStore::validate(&token) -> bool` (`session.rs:194`) | -| **Honour 2FA** | Already modelled: `create_pending(totp_secret)` (`:176`) + `upgrade_to_full` (`:247`). A session still pending 2FA **fails `validate()`**, so the gate gets 2FA for free by calling `validate` — no TOTP code in the gate itself | -| **Machine-client bearer tokens** | `device_tokens::create/verify` (`device_tokens.rs:63/:90`) — long-lived, minted from an authenticated session, only the SHA-256 persisted, plaintext returned once. Built for the companion pairing QR; needs **per-app scoping** added for this use | -| Rate limiting | `device_tokens` verification already rides `auth.login`'s limiter | - -So the new code is: the listener/redirect, the app-identification step (which app is this port?), -the login page render (app name + icon), and per-app scoping on `device_tokens`. - -#### Research — StartOS: **DROPPED** (operator, 2026-08-03) - -"don't need the startOS research we decided on a approach already." The umbrelOS read -plus the design decision above settled it; no further prior-art work. - -### 1b. Manifest declaration of unauthenticated ports — **DONE** (`0c4826f8`, pushed) - -`PortMapping` grew `auth` (`session` | `none`, defaulting to **`session`**) and -`auth_rationale`. The default is the protected one, so exposure is now something a -manifest has to ask for rather than something it gets by saying nothing. - -Validation is two-sided: `auth: none` without a rationale is rejected, **and** a -rationale without `auth: none` is rejected — that combination means the author wrote an -exemption and did not get one, and shipping it silently would leave them believing -otherwise. - -**17 ports across 12 apps are exempt**, each with its reason: Lightning p2p (BOLT-8 -noise), LND gRPC 10009 / REST 18080 and CLN gRPC 9835 (macaroon / mutual TLS — this is -what keeps Zeus and remote wallets working), Bitcoin p2p 8333, electrum 50001, the three -Wyoming voice ports, git-over-SSH 2222, and the UDP discovery protocols (mDNS 5353, -SSDP 1900, STUN 3478). **The other 39 published ports now default to gated.** - -Bitcoin RPC 8332 is deliberately *not* exempted: it is already `bind: 127.0.0.1`, so the -gate never sees it, and claiming an exemption it does not need would put a meaningless -line in the audit list. If that bind is ever dropped it fails closed. - -Two corpus tests pin this: every shipped manifest must parse, and the exempt set is -frozen at 17 so the node's unauthenticated surface cannot grow by accident. - -### 1c. The gate itself — **IN PROGRESS** - -`core/archipelago/src/appgate/` — `identity.rs` (port → app id/name/icon, gated vs -exempt, re-read from manifests so a catalog refresh applies without a restart), -`mod.rs` (authorize + login page + TOTP step + reverse proxy), `listener.rs` (binds the -external addresses, sweeps every 60s). - -Design points worth not re-deriving: - -- **It invents no auth policy.** `verify_password`, `totp::decrypt_secret`, - `verify_code` + used-step replay protection, `SessionStore::create/create_pending/ - upgrade_to_full`, and the *same* `LoginRateLimiter` instance as the JSON-RPC path. - Only the transport differs (HTML form vs JSON-RPC), because a browser being redirected - to an app cannot speak JSON-RPC. Sharing the limiter matters: otherwise an attacker - gets a fresh budget of password guesses by moving to an app port. -- **2FA is free.** A session still pending its TOTP step fails `validate()`, so the gate - rejects it without knowing anything about second factors. -- **Cookies ignore port.** The session cookie is host-only with no `Domain`, so one - sign-in covers the dashboard and every app port on the same host. The corollary is - that an app reached on a *different* host — its own onion — is a separate sign-in. -- **401, not a redirect.** A redirect to a login page is indistinguishable from the app - itself redirecting, and machine clients would follow it and parse HTML as their API - response. -- **The gate strips `Cookie` and `Authorization` before proxying.** The app has no use - for the node session and must never be in a position to log or forward it. -- **Machine clients**: `device_tokens` grew `apps: Option>` and - `verify_for_app`. `None` = node-wide (what every existing companion token is — - migrating them by guessing a scope would silently revoke access nobody asked to - revoke); `Some(list)` restricts to those apps. An empty list is rejected rather than - minted, since it would read as "unrestricted" while authorising nothing. - -#### ⚠️ The ordering constraint that shapes the rollout - -A published container port is bound `0.0.0.0:`, which claims **every** host -address. While the app holds that, the gate **cannot** bind `:` at all. -So the gate can only stand in front of an app whose publish has been pinned to loopback -(`bind: 127.0.0.1`) and whose container has been recreated. Gate-first is not possible; -all-apps-at-once would recreate every container on the node simultaneously. - -Therefore the rollout is **per app**, and the gate is built to be honest about being -partially deployed: a port it cannot claim is logged at **warn** every sweep and recorded -in `GateStatus::unprotected`. The failure mode this exists to prevent is a gate that -binds nothing, logs at debug, and reports success while every app stays exactly as open -as before — worse than no gate, because it stops anyone looking. (Same reasoning that -killed the nft drop-in: `/etc/fips/fips.nft` is provisioned out-of-band and its absence -is a silent no-op.) - -**Still open on this item:** pin the 39 gated ports to loopback app-by-app, repoint -`HiddenServicePort` at the gate (Tor connects *from* loopback, so a loopback-exempt -redirect will not catch it, and the mapping loses the original destination port), gate -the FIPS relay path, surface `GateStatus` in the UI, and verify on a real node. - -### 2. Filebrowser ships an insecure default login — **OPEN** -- Change the default credential **without breaking the dashboard's Cloud view**, which - authenticates to filebrowser on the user's behalf. -- Related prior art: FED-07 rotated the shipped Fedimint gateway credential and had to - recreate the running container for it to take effect (`06e0e695`) — the same trap - applies here. - -### 3. Federation trust escalation — **DONE** (`c0cfc72a`, pushed) -Two independent fail-open paths granted `Trusted` without any operator decision: - -- `federation.peer-joined` is **unauthenticated** (middleware no-session list) and - peer-reachable on `/rpc/v1`. Its ed25519 check verifies the caller against **the pubkey - the caller supplied**, so it proves key possession, never authorisation. A join with no - `invite_token` fell through to `TrustLevel::Trusted.min(claimed_trust)`, and - `claimed_trust` defaults to `Trusted` — so anyone able to reach the node could - self-grant Trusted. **Now capped at `Observer`.** -- `merge_transitive_peers` added every peer advertised by a Trusted source as `Trusted`, - making trust viral across the whole federation graph. **Now `Observer`** — which is what - `NodeStateSnapshot.federated_peers`' own doc comment always said it should be - ("adds them as Observers on her side… doesn't auto-promote to Trusted"). The code - contradicted its own spec. -- Added `FederatedNode.trust_source` (`invite` | `uninvited-join` | `transitive-merge` | - `manual`, `None` = pre-existing/unknown) so existing grants are **auditable**. Per - operator decision: existing peers are **left alone, not auto-demoted**. -- `trust_source` is now **surfaced** in `federation.list-nodes` (as an explicit `null` - when unknown, not omitted — "recorded before this was tracked" is the population that - needs review, so the UI must be able to tell it apart from a field it didn't read) and - rendered under the trust dropdown in the node detail modal as "Granted via:". - -### 3b. Granting Trusted must require the node password — **DONE** (uncommitted at time of writing) -> "to make someone trusted must require the node password to generate the code or change -> in the modal dropdown when you click a node" — operator, 2026-08-03 - -Re-authentication on privilege escalation. Both entry points are covered: - -- **Minting a Trusted invite** (`federation.invite`) — gated on the **resolved** level, - which matters because "Link Your Nodes" sends no `trust_level` at all and falls through - to the `Trusted` default. The invite is a bearer grant of Trusted to whoever redeems - it, so minting it *is* the escalation. Observer invites are untouched. -- **Changing a node's level in the UI dropdown** (`federation.set-trust`) — gated only - when the peer is **not already** Trusted, so the dropdown re-emitting its own value - doesn't demand a password for a no-op. - -Demotion is NOT gated: making something less privileged must never be harder than leaving -it, or the safe action becomes the inconvenient one. The operator path stamps -`TrustSource::Manual`; `set_trust_level` grew an `Option` so automatic -adjustments (the discovery-handshake demotion safety net) pass `None` and leave the -recorded provenance alone rather than laundering an `uninvited-join` peer into looking -operator-approved. - -Wiring: the backend is the sole authority on what counts as an escalation — it returns a -`PASSWORD_REQUIRED:` prefixed error, and the UI prompts and retries only on that. The -frontend never pre-judges, so the rule lives in exactly one place. -`TrustPasswordModal.vue` (modelled on `RotateDidModal.vue`) serves both flows. -`NodeDetailModal`'s select now snaps back to the node's real level on change, because a -cancelled or failed promotion would otherwise leave the dropdown displaying a level the -node never accepted. - -**Follow-up, deliberately not done here:** `federation.join` also grants Trusted (when -redeeming someone else's Trusted invite) with no re-auth. It is an explicit operator -paste rather than a UI toggle, and was outside the two entry points specified — but it is -the third way a node reaches Trusted and should be reviewed. - ---- - -## P1 — Correctness the operator hit directly - -### 4. LND UI never updates over OTA — **DONE** (`5088aef5`, pushed) -- `LND_UI_IMAGE` was `lnd-ui:latest` while `BITCOIN_UI_IMAGE` was pinned to - `1.7.119-alpha`. Podman will not re-pull a tag it already holds locally, so nodes kept a - stale lnd-ui forever. **Now pinned to `1.7.119-alpha`.** -- `scripts/first-boot-containers.sh` declared lnd-ui as bridge `-p 18083:80`. That is the - **third copy** of the declaration the UI agent already corrected in - `scripts/container-specs.sh` and `apps/lnd-ui/manifest.yml` — so **fresh installs** still - produced the reproduced `HTTP 000`. **Now `--network host`, ports empty.** -- Root cause worth fixing separately: the same container spec is declared in three places. - -### 5. Federated/peered nodes must message without a LoRa hop first — **OPEN** -> "make it so federated/peered nodes can message without needing to connect on Lora first -> once connected" - -- Investigate the split contact model (radio contact vs federation peer) — there is prior - art in memory: `project_archy_lora_e2e_rootcause` ("split contact model; don't touch - federation") and `mesh::seed_federation_peers_into_mesh` / - `upsert_federation_peer`, which already mirror federation peers into the mesh table. -- Likely the gap is addressing/route selection rather than transport availability. - -### 6. In-app app updates, independent of OTA — **OPEN** -> "we need app update to see updates in the registry, whether UI or not… show the update -> mechanism in the app… a modal and update now / cancel… same in the detail page… the -> update button should show 'see update' and a different graphic for just ui, app, or both -> together. All pushed through the signed-catalog flow." … "This has to show independent of -> OTA updates as a separate pipeline, I think we've done a lot of work on it." - -- **Operator says much of this already exists — research the codebase before building.** - Known groundwork: the signed catalog (`releases/app-catalog.json`, `sign-catalog.sh`), - catalog→manifest runtime reload, `package.update` RPC, `check-app-catalog-drift.py`, - and `scripts/image-versions.sh` pinning. - -#### What already exists (verified in source, 2026-08-03) — the operator was right - -The whole update *pipeline* is built and is already independent of OTA: - -- `package.check-updates` (`api/rpc/package/update.rs:180`) refreshes the signed catalog - and hot-reloads manifests when it changed — no daemon restart, no OTA involved. -- `package.update` (`spawn_package_update`), `package.versions`, `package.set-config` - version pinning, and `execute_update` (stop → pull → remove → recreate → verify). -- Version awareness: `app_catalog::catalog_versions(app_id)` vs `installed_version()` - (`api/rpc/package/set_config.rs:46`). -- Frontend: `AppCard.vue` already renders an Update button off `pkg['available-update']` - (`:48`, `:128`) and emits `update`. - -#### What is actually MISSING (this is the real scope of item 6) - -1. **The UI-vs-app-vs-both distinction does not exist.** `available-update` is a single - version string — nothing classifies whether the change is the app image, its `*-ui` - image, or both. This is the core of the operator's ask ("a different graphic for just - ui, app, or both together") and needs a backend change, not just an icon. - ⚠️ Compounding factor: per `reference_app_ui_delivery_model`, `*-ui` apps are **not in - the signed catalog** at all — so "is there a UI update" cannot be answered from the - catalog today. That gap has to be closed first or the UI half is unanswerable. -2. **The modal** (Update now / Cancel) — the card currently updates on click, no confirm. -3. **The detail-page affordance** — same treatment as the card. -4. **Button copy**: "See update" rather than "Update". - -### 6b. Multiversion for ALL apps + upstream release discovery — **OPEN** (operator, 2026-08-03) -> "we also need a way to provide multiversion support for all apps and it automatically -> pulls the latest versions from the source app repository, safely, and the user can -> choose to update so we aren't always updating manually" - -#### Verified 2026-08-03: the schema and runtime already exist - -This is much less work than it sounds, because the multiversion machinery built for -Bitcoin generalises as data rather than code: - -- `releases/app-catalog.json` entries already support a `versions[]` array of - `{version, image, default?, deprecated?}`. -- Runtime is complete: `catalog_versions(app_id)`, `catalog_default_version`, - `catalog_image_for_version`, `package.versions`, version pinning through - `package.set-config`, and `available_update_for_app` falling back to the - `image-versions.sh` baseline pin. - -**It is populated for 2 of 66 apps** — `bitcoin-core` (9 versions) and `bitcoin-knots` -(5). Every other app carries a single `version`. So "multiversion for all apps" is -primarily a **catalog-generation and image-mirroring job**, not new runtime plumbing. - -#### What has to be built - -1. **Populate `versions[]` fleet-wide.** Extend `scripts/generate-app-catalog.py` to emit - a version list per app instead of a single pin. Needs a per-app policy for how many - historical versions to carry and which is `default` (Bitcoin's list shows the shape, - including `deprecated: true` for old-but-installable). -2. **Mirror the images.** A version in the catalog that is not in our registry is a - broken promise — `package.update` would pull and fail. Use the existing skopeo path - (`feedback_skopeo_source_selection`: prefer `.160`, concurrency ≤ 6). -3. **An upstream release-watcher.** 48 manifests already carry a `repo:` URL under - `metadata`, so there is something to poll (GitHub releases / registry tags). It runs - **off-node**, as part of catalog generation. -4. **Keep the signed catalog as the trust boundary.** This is the whole of "safely": - the watcher **proposes** versions, the offline signing ceremony **admits** them, and - nodes only ever install what the signed catalog carries. A node must never pull - straight from an upstream repo — that would put an unsigned third party inside the - supply chain, which is exactly what the signed-registry model exists to prevent. -5. **The user chooses.** Discovery must never auto-apply. `package.check-updates` already - refreshes and hot-reloads without touching the running containers, so "a new version - exists" and "install it" stay separate — which is also what item 6's modal is for. - -**Sequencing note:** 6b's step 1 and item 6's UI-vs-app classification want the same -thing — `*-ui` images represented in the catalog. Doing that once unblocks both. - ---- - -## P2 — Carried over from v1.7.120 - -### 7. `create-release.sh` commits the manifest BEFORE signing — **OPEN** -Release commit always carries an **unsigned** manifest; nodes fetch it from branch `main` -and refuse to auto-apply. Caught manually this cycle. Fix the ordering so it cannot ship. - -### 8. `gitea-vps2` remote is dead, and is the same server as `gitea-ai` — **OPEN** -Stored token fails auth. `source.archipelago-foundation.org` == `146.59.87.168`, so -`git push gitea-ai` already publishes to the "primary" OTA host. Ties into the existing -"migrate VPS2 IP to domain" todo. - -### 9. Fleet SSH host-key rotation — **BLOCKED** (operator decision) -`archipelago-1`, `archy-x250-beta`, `archipelago` share all three SSH host keys; two also -share a TLS private key. Detection shipped; rotation deliberately not performed. - -### 10. 5× lifecycle gate — **OPEN** -Not run for v1.7.120 (disclosed in its changelog). Needs repeated reboots of a live node. - -### 11. `prod_orchestrator.rs:3181` unreachable code — **OPEN** -`bitcoin_host()` returns unconditionally at :3171, so the podman container-name lookup -below is dead on every path. Pre-existing; spotted in the v1.7.120 build warnings. - ---- - -## Notes for whoever picks this up - -- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. -- AIUI must always be built `VITE_BASE_PATH=/aiui/` (see the memory note); a hand-built - bundle renders a black page. -- Verify security claims on the node, not from the source. v1.7.120's headline bug was a - fix that shipped in the binary and silently never reached the running container. - ---- - -## STATUS 2026-08-04 — what shipped in 1.7.121 and what did not - -### Shipped (committed + pushed) - -| Item | Commit | Verified | -|---|---|---| -| 3. Federation trust escalation | `c0cfc72a` | 42/42 federation tests | -| 3b. Trusted requires node password | `24ce8b39` | 44/44 + 79/79 + vue-tsc | -| 4. lnd-ui OTA pin + host networking | `5088aef5` | — | -| 1b. Manifest `auth:` declarations | `0c4826f8` | 73/73, all 56 manifests parse | -| 1c. App gate (engine + audit) | `0de67ca6` | 23/23 appgate | -| Dashboard backdrop-filter seam | `63d0183d` | 3/3, **live on archi-dev-box** | -| 7. Release refuses unsigned manifest | `cc9e1958` | dry-run: signed/stripped/wrong-signer | -| Gate safety model (`Option`) | `ab2c8b6e` | 75/75 incl. LND wallet-port case | -| Companion rebuild-loop | `719446c0` | podman behaviour proven first | -| 5. Federated peers messageable | `edc9a172` | predicate pinned across device types | - -### The two gate incidents — read before touching the gate again - -Both were ONE mistake: a safety decision read an ABSENT manifest field as a -value. A node's installed manifests always lag the binary, so "absent" is the -normal state, and the daemon acted on instructions no manifest ever gave. - -1. Gating any `session` port regardless of `bind` **published Bitcoin's - loopback-only RPC 8332 on the LAN/Tailscale/IPv6** within seconds of deploy. -2. The `bind`-keyed replacement looked safe (it protected `bind: 127.0.0.1`) - but LND's gRPC 10009 / REST 18080 carry an EMPTY bind — one container - recreate from pinning them to loopback and **breaking Zeus and every remote - wallet**. - -Now structural: `auth_policy()` classifies (undeclared → reported as -unprotected, always safe), `auth_is_declared()` gates action (undeclared → -never acted on). **Silence is not consent.** - -### Proven on the node, empirically, not by reasoning - -- Gate challenge → login → proxy works end to end over LAN and Tailscale. -- **Daemon-side publish rewriting was removed.** Publishes are built in several - places (`podman_client`, `package::install`, `stacks`); patching one covered - one — the strfry recreate went through another and the pin never fired. -- **Disk manifest edits do not apply to catalog-covered apps.** Even - `bind: 127.0.0.1` written into the node's strfry manifest was overridden by - the signed catalog. The catalog re-sign is REQUIRED; there is no shortcut. -- A loopback-bound host port is **unreachable** from a pasta container, so - loopback-pinning the Wyoming ports would break Home Assistant voice. - -### Open for 1.7.122 - -1. **Catalog re-sign** — `bind: 127.0.0.1` + `auth: session` on the ~39 gated - UI ports. This is what turns the gate from auditing into enforcing. Nothing - in code can substitute for it. -2. **Release-root rotation** — branch `rotate-release-root`, key - `did:key:z6Mkfu5LT…DLWT` / `1578adcc…4418`, validated as a real curve point. - **Sign the rotation release with the OLD key**; only the release after it - uses the new one. Re-sign the catalog too. -3. **Wyoming voice ports** (10200/10300/10400) — unauthenticated, and by the - operator's policy they should not be. Correct fix is co-locating Home - Assistant with the pine services on one container network so nothing is - published; needs a node running both. -4. **Item 2** filebrowser default login. **Items 6/6b** app updates + - multiversion (`versions[]` already exists, populated for 2 of 66 apps). -5. **`cargo-test-weekly` times out** at its 1500s cap on a loaded box — raise - the cap or split the stage; it is not a code failure. - -## RESUME HERE — next session - -**Landed this session (both pushed):** -- `c0cfc72a` federation trust escalation (items 3) — 42/42 federation tests green -- `5088aef5` lnd-ui OTA pin + host networking (item 4), and this task file - -**v1.7.120-alpha is SHIPPED** — signed, published, assets verified live. Do not re-cut it. - -### Start with item 3b (password gate) — groundwork already located - -Everything needed to implement it, so the next session does not re-search: - -- **The helper to use:** `self.auth_manager.verify_password(password).await?` — returns - `bool`. Existing callers to copy the shape from: `api/rpc/node.rs:176`, - `api/rpc/totp.rs:18` / `:66` / `:121`. -- **Entry point A — minting a Trusted invite:** `handle_federation_invite`, - `api/rpc/federation/handlers.rs:58`. It reads `trust_level` from params and - **defaults to `TrustLevel::Trusted` at :72**. Gate only when the resolved level is - `Trusted`; leave Observer invites unchanged. -- **Entry point B — the UI dropdown:** `handle_federation_set_trust`, - `api/rpc/federation/handlers.rs:326`, dispatched as `"federation.set-trust"` - (`api/rpc/dispatcher.rs:353`). Its parse is at `:342`. -- **Rule:** gate PROMOTION to Trusted only. Demotion must stay ungated — making something - less privileged must never be harder than leaving it. -- Set `TrustSource::Manual` on the operator path so the audit trail distinguishes a - deliberate grant from the capped automatic ones. -- Frontend will need the password prompt in both places (invite modal, node dropdown). - -### Then item 1 (app ports unauthenticated) — the big one - -Start with the research the operator explicitly asked for: how **umbrelOS** -(`getumbrel/umbrel`) and **StartOS** (`Start9Labs/start-os`) gate app access. Read their -model rather than inventing one. Only then design the gate. - -Give this a fresh session with real context — it is the largest item here and is the same -bug class as the `/lnd-connect-info` + `/bitcoin-rpc/` leaks fixed in v1.7.120, but across -every app port and every transport. - -### Working notes -- A separate agent is doing **AIUI planning with GSD** — do not touch AIUI. -- The shared tree has concurrent agents: stage by explicit path, never `git add -A`. -- Verify security claims **on the node**, not from source. v1.7.120's headline bug was a - fix that shipped in the binary and silently never reached the running container. -- A piped command's exit code is the pipe's, not the script's — redirect to a log file and - read the content. diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md deleted file mode 100644 index 2d09712d..00000000 --- a/.planning/REQUIREMENTS.md +++ /dev/null @@ -1,171 +0,0 @@ -# Requirements: Archipelago (v1.8.0 — Developer-Ready App Platform) - -**Defined:** 2026-07-29 -**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. - -No PRDs existed in the ingest set; these requirements are derived from the master plan's -declared exit criteria (multinode pass + workstreams B/C/F), `.planning/codebase/CONCERNS.md`, -`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from -`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built. - -## v1 Requirements - -### Federation & Mesh Hardening (FED) - -- [ ] **FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op) -- [ ] **FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible -- [ ] **FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason -- [x] **FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist -- [ ] **FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy -- [x] **FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows -- [x] **FED-08**: Lightning invoices created by the wallet embed route hints (LND `private` flag) so nodes whose channels are unannounced can actually receive payments — diagnosed on archy-x250-mad2 2026-07-31, where every wallet-UI invoice had `route_hints: []` and was unroutable; the bug is unconditional and affects any node without a public channel -- [x] **FED-09**: The container doctor does not restart Tor on every run — it recognises Tor's own setgid `2700` hidden-service directory mode as correct rather than "fixing" it to `700` and restarting, a loop that reset Tor every ~5 minutes, starved it of its consensus/HSDir cache (`No more HSDir available to query`), and broke the mesh's Tor fallback entirely; genuinely permissive modes are still corrected, and a restart backoff makes the failure class non-recurring -- [x] **FED-07**: Fedimint gateway never installs with a pre-set password — gateway credentials are generated per-install via manifest-declared `generated_secrets` (or explicitly set by the user), never baked into the image/manifest; existing installs with the default password get a migration path (BLOCKER — default credentials are a security hole) - -### UI Fixes (UIFIX) — user-reported blockers, added 2026-07-30 - -- [ ] **UIFIX-01**: The FIPS/Tor pills on cloud files are kept (never removed by cleanups) and render at mobile widths — on mobile, users can see each file's security/transport state (BLOCKER) -- [x] **UIFIX-02**: The connected-nodes list scrolls at row-matched height — its height tracks the taller right-hand sibling in the row and the inner list scrolls within it, never growing to fit all rows scroll-free (BLOCKER) -- [x] **UIFIX-03**: On short viewports the onboarding confirmation tickbox is discoverably visible — an on-brand affordance (scroll cue, sticky footer, or equivalent) makes it obvious without altering tall-screen appearance (BLOCKER) -- [x] **UIFIX-04**: Paid Files pictures open in the app's lightbox, not a browser tab — consistent with the rest of the app's media UX -- [x] **UIFIX-05**: Picture-in-picture is robust — entering PiP closes the lightbox with a fluid on-brand animation, and an active PiP session survives main-tab changes and video buffering pauses (only an explicit user stop ends it) -- [x] **UIFIX-06**: Surfaces with genuinely slow opens show house-style loader states — no dead-feeling clicks (cached revisits stay spinner-free per PERF-02) - -### UI Performance (PERF) - -- [x] **PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed -- [x] **PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session -- [x] **PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box - -### Multinode Verification (MNODE) - -- [ ] **MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy -- [ ] **MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs) -- [ ] **MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles - -### Lifecycle Perfection (LIFE) - -- [ ] **LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm) -- [ ] **LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping -- [ ] **LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output -- [ ] **LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy -- [ ] **LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar - -### Registry-Distributed Manifests (REG) - -- [ ] **REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback -- [ ] **REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync - -### Security Enforcement (SEC) - -- [ ] **SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides -- [ ] **SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps - -### Developer Tooling (DEV) - -- [ ] **DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules) -- [ ] **DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces -- [ ] **DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`) -- [ ] **DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs - -### Decentralized Marketplace (MKT) - -- [ ] **MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling -- [ ] **MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring -- [ ] **MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed -- [ ] **MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees - -### AIUI — Conversational Node Control & Content Surfaces (AIUI) — added 2026-08-03 - -- [x] **AIUI-01**: Human-language node control — a typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result, over a permissioned tool-calling bridge rather than raw RPC -- [ ] **AIUI-02**: Conversational settings — the system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted -- [x] **AIUI-03**: Content surfaces made real — AIUI's designed-but-empty content views render live node data (peer files, music, IndeeHub movies, owned/paid content); audio belongs to the global bottom-bar player and media streams via Range requests, never base64 blobs -- [x] **AIUI-04**: Sandboxed by construction, permissioned by the user — secrets never reach the browser or the model context; the chat gets an explicit, user-granted, default-closed, revocable capability scope; destructive and identity-touching operations are human-confirmed; tool authority never derives from peer-controlled content (BLOCKER) -- [ ] **AIUI-05**: Delivery and build — AIUI reaches nodes on a delivery path an operator can actually receive updates through, with `VITE_BASE_PATH=/aiui/` enforced by the build script so a hand-built bundle cannot ship a black page -- [ ] **AIUI-06**: Verified on device — in the real embedded iframe on archi-dev-box, mobile included, not only in the local `dev:mock` loop - -## v2 Requirements - -Deferred to a future milestone. Tracked but not in the current roadmap. - -### Distribution Backbone (DIST) - -- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog) - -### Fleet & Hardening (FLEET) - -- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`) -- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage -- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/` -- **FLEET-04**: Dynamic per-app resource rebalancing (cgroup-stats feedback loop) - -## Out of Scope - -| Feature | Reason | -|---------|--------| -| Rootful/privileged containers, Docker | Invariant — ADR-001/ADR-009 | -| Per-app Rust installers / host provisioning | The anti-pattern workstream A deleted | -| Centralized gatekept app store | ADR-006 chose decentralized Nostr marketplace | -| Web5 DWN spec compliance | ADR-011 — deprioritized after TBD shutdown | -| Custom live voice-call protocol | Deprioritized 2026-07-01 per user; no scope decided | - -## Traceability - -Which phases cover which requirements. Updated during roadmap creation. - -| Requirement | Phase | Status | -|-------------|-------|--------| -| FED-01 | Phase 1 | Pending | -| FED-02 | Phase 1 | Pending | -| FED-03 | Phase 1 | Pending | -| FED-04 | Phase 1 | Complete | -| FED-05 | Phase 1 | Pending | -| FED-06 | Phase 1 | Complete | -| FED-07 | Phase 1 | Complete — rotation + recreate verified on archi-dev-box 2026-08-02 | -| FED-08 | Phase 1 | Code complete + unit-pinned; post-OTA check on the user device pending | -| FED-09 | Phase 1 | Complete — 15h Tor uptime / 0 permission-fixes on archi-dev-box; onion-resolution check post-OTA | -| UIFIX-01 | Phase 1 | Pending | -| UIFIX-02 | Phase 1 | Complete | -| UIFIX-03 | Phase 1 | Complete | -| UIFIX-04 | Phase 1 | Complete | -| UIFIX-05 | Phase 1 | Complete | -| UIFIX-06 | Phase 1 | Complete | -| PERF-01 | Phase 2 | Complete | -| PERF-02 | Phase 2 | Complete. 02-11 (`02-FINDINGS.md` § Client-Side Render Cost Root Cause + § Task 3) named and fixed the real cause of Web5/Server's revisit-ms regressions — three leaked background pollers (`useFleetData.ts`, `FipsNetworkCard.vue`, `Web5Monitoring.vue`) armed in `onMounted` and never disarmed once their owning views joined `KEEP_ALIVE_PATHS`, gated to activate/deactivate. Web5 now fixed (275ms, below both its 566ms pre-phase-2 baseline and the 300ms pass bar); Server's regression is closed (574ms, below its 738ms baseline) though not yet under the 300ms stretch target — residual named as real, un-eliminated per-resource reactivation cost, not a new defect | -| PERF-03 | Phase 2 | Complete. 02-11 fixed Fleet's leaked `useFleetData.ts` poll (790ms, down from a 2631ms regression, substantially closing the gap to its 330ms baseline). AppDetails restored to at/near its own baseline (1231ms vs. 1204ms) — residual is the already-documented `useCachedResource` per-mount setup cost, not fixed further. Discover (1389ms) has a SECOND, distinct, evidenced cause found this session (CSS entrance-animation replay on KeepAlive reactivation, `card-stagger`/`showStagger` never removed from the DOM) — named with full profiling/diagnostic evidence but NOT fixed (blast radius spans 5+ files outside this plan's scope, needs its own real-device verification budget) — recommended as a dedicated follow-up. OpenWrtGateway: not measurable this pass (Chromium crash cascading from an unrelated surface); prior numbers stand, confirmed to reflect a real (not empty) disconnected-device UI render, not retracted | -| MNODE-01 | Phase 3 | Pending | -| MNODE-02 | Phase 3 | Pending | -| MNODE-03 | Phase 3 | Pending | -| LIFE-01 | Phase 4 | Pending | -| LIFE-02 | Phase 4 | Pending | -| LIFE-03 | Phase 4 | Pending | -| LIFE-04 | Phase 4 | Pending | -| LIFE-05 | Phase 4 | Pending | -| REG-01 | Phase 5 | Pending | -| REG-02 | Phase 5 | Pending | -| SEC-01 | Phase 6 | Pending | -| SEC-02 | Phase 6 | Pending | -| DEV-01 | Phase 7 | Pending | -| DEV-02 | Phase 7 | Pending | -| DEV-03 | Phase 7 | Pending | -| DEV-04 | Phase 7 | Pending | -| MKT-01 | Phase 8 | Pending | -| MKT-02 | Phase 8 | Pending | -| MKT-03 | Phase 8 | Pending | -| MKT-04 | Phase 8 | Pending | -| AIUI-01 | Phase 13 | Complete | -| AIUI-02 | Phase 13 | Pending | -| AIUI-03 | Phase 13 | Complete | -| AIUI-04 | Phase 13 | Complete | -| AIUI-05 | Phase 13 | Pending | -| AIUI-06 | Phase 13 | Pending | - -**Coverage:** - -- v1 requirements: 35 total -- Mapped to phases: 35 -- Unmapped: 0 - ---- -*Requirements defined: 2026-07-29* -*Last updated: 2026-07-29 — added FED (federation/mesh hardening) and PERF (UI performance) requirement groups; phases renumbered after inserting them as Phases 1–2* diff --git a/.planning/RESUME-2026-08-05-appgate-fixes.md b/.planning/RESUME-2026-08-05-appgate-fixes.md deleted file mode 100644 index 250900e9..00000000 --- a/.planning/RESUME-2026-08-05-appgate-fixes.md +++ /dev/null @@ -1,113 +0,0 @@ -# Resume — 2026-08-05 (app gate, releases .122–.125) - -Paste the block at the bottom into a new session. - -## Where things stand - -- **v1.7.124-alpha is SHIPPED** (signed with the NEW root, published, verified). -- **Signed catalog is LIVE** carrying two hotfixes made after .124: - the repaired bitcoin start script and the fedimint 8175 removal. - Last commit: `4ace62fa`. -- **Release-root rotation is COMPLETE.** .122 was the last release signed with - the old key; .123/.124 and all catalogs use the new one. No override needed. - -## Two bugs I introduced in .124 (both fixed, both instructive) - -1. **Bitcoin vanished from every node.** I put a `#` comment INSIDE the - manifest's folded YAML scalar (`>-`), where `#` is not a comment — it - reaches the shell, and folding joins lines with spaces so it commented out - the `if ... then` while the more-indented `echo` survived, leaving an orphan - `fi`. Container exited instantly; app detection is container-based so the - app disappeared. **Guard added:** `scripts/check-manifest-shell.py` runs - `sh -n` over every embedded manifest script and rejects `#` in these - scalars; wired into `tests/release/run.sh`. -2. **Fedimint crash-looped.** I declared port 8175 on the `fedimint` app so the - gate could name it — but 8175 is served by the separate `archy-fedimint-ui` - companion. The orchestrator then tried to publish 8175 from fedimintd, - collided, and `start_container` failed forever. Removed. **Rule: never - declare a port on an app whose container does not actually serve it.** - -Also: I published an UNSIGNED catalog at one point, which nodes correctly -reject — they silently keep their old cached copy. **Always verify -`'signature' in catalog` on the live URL after publishing.** - -## OPEN TASKS - -1. **indeedhub crash-loop — NOT mine, needs a real fix.** `indeedhub-minio` is - **absent** on `.38` and `.88`, so nginx fails with - `host not found in upstream "minio"` and both `indeedhub` and - `indeedhub-api` exit(1). The stack member never gets created. Look at - `api/rpc/package/stacks.rs` + `dependencies.rs`. -2. **Verify `.38` refetched the signed catalog** and bitcoin-knots starts. - `.88` already did (signed: True, script fixed). -3. **Deploy the .125 build to archi-dev-box for operator confirmation.** - Binary is built at `core/target/release/archipelago` with: app-login page - using the sidebar **A mark** (`favico-black-v2.svg`) not the wordmark; - page pinned to `100svh` + `position:fixed` so mobile stays centred and the - keyboard overlays instead of scrolling; install-version modal icon uses - `object-contain` so non-square icons are not cropped. **Operator has not - seen these yet.** -4. **Cut v1.7.125-alpha** once confirmed. Sign with the **NEW** mnemonic. - -## Traps that cost time today - -- `create-release.sh` says "sign, then re-run" — **re-running regenerates the - manifest and DESTROYS the signature**, and its clean-tree check blocks - anyway. Do steps 7/8 by hand: `git add` version+changelog+manifest → - commit `chore: release vX` → `git tag -a vX` → push main → **push the tag - explicitly** → `git ls-remote --tags` to prove it → `publish-release-assets.sh`. -- The release gate's `cargo-test-weekly` times out on the **compile** after any - version bump. Pre-warm: `CARGO_INCREMENTAL=0 cargo test --manifest-path - core/Cargo.toml -p archipelago --no-run`. -- The frontend version check fails until the in-app **What's New** block for - that version exists (`neode-ui/src/views/settings/AccountInfoSection.vue`) — - that string is what it greps for. -- `generate-app-catalog.py` writes `APP_LAUNCH_PORTS` one-per-line; rustfmt - packs it, so run `cargo fmt` after any catalog sync or the gate fails. -- **Manifest changes reach nodes via the SIGNED CATALOG, not the binary.** A - manifest hotfix needs only a catalog re-sign — no release. - -## Fleet - -SSH: `sshpass -p 'ThisIsWeb54321!' ssh archipelago@` (note the `!`; `@` -is older and still works on some). RPC/node password differs per node — the -`!` one failed RPC login on `.38`. - -- `100.69.68.39` archi-dev-box — dev target -- `100.82.34.38` archipelago-1 -- `100.70.96.88` austin-sapien -- `100.64.204.114` .228 shorty-s — **in real use, treat carefully** - -**Force a catalog refresh on a node:** Settings → App Updates → Check for -updates, or `sudo rm -f /var/lib/archipelago/app-catalog.json && sudo -systemctl restart archipelago`. - -**All fleet nodes were repaired** from `Restart=on-failure` → -`Restart=always`; a node with the old value stays DEAD after an in-process -update (the updater exits cleanly and systemd reads that as success). -`bootstrap::ensure_restart_policy()` now self-heals it. - ---- - -## PASTE THIS INTO THE NEW SESSION - -Resume the archy work from 2026-08-05. Read -`.planning/RESUME-2026-08-05-appgate-fixes.md` and the memory notes -`project_fleet_ota_restart_policy_incident` and -`project_v1_7_121_shipped_appgate` first. - -v1.7.124-alpha is shipped and the signed catalog is live with two hotfixes -(bitcoin start script, fedimint 8175). Four things are open, in order: - -1. Fix the indeedhub crash-loop: `indeedhub-minio` is absent on .38 and .88 so - nginx fails on upstream "minio" and indeedhub + indeedhub-api exit(1). This - one is pre-existing, not from the port work. -2. Verify .38 refetched the signed catalog and bitcoin-knots starts (.88 - already did). -3. Deploy the built .125 binary + frontend to archi-dev-box (100.69.68.39) so - I can confirm the app-login page (A mark, mobile centring, keyboard - behaviour) and the install-modal icon. -4. Then cut v1.7.125-alpha — I sign with the new mnemonic. - -Do not re-run create-release.sh after signing; it destroys the signature — -do the commit/tag/publish steps by hand as the resume doc describes. diff --git a/.planning/RESUME-2026-08-06-media-loop.md b/.planning/RESUME-2026-08-06-media-loop.md deleted file mode 100644 index d3adb68b..00000000 --- a/.planning/RESUME-2026-08-06-media-loop.md +++ /dev/null @@ -1,158 +0,0 @@ -# RESUME — 2026-08-06 night. Fix → deploy → test → fix, in a loop. - -Start here. Read this, then `.planning/MEDIA-AND-INDEEHUB-SCOPE.md` (the evidence), then -`.planning/todos/pending/2026-08-06-open-operational-tasks.md` (everything else open). - -Branch `gsd/phase-13-...` @ `5f343f5e`+, merged with `main`. Working tree clean, pushed. - -## The loop the operator asked for - -For each item below, in order: - -1. Fix on the phase branch. Small, focused commit. -2. Build: `cd core && CARGO_INCREMENTAL=0 cargo build --release -p archipelago` (~7-8 min) - and/or `cd neode-ui && npm run build`, `bash scripts/build-aiui.sh`. -3. Deploy to archi-dev-box (it IS this box — hostname `archi-dev-box`, also `archi-thinkpad`, - LAN `192.168.63.240`, Tailscale `100.69.68.39`): - - frontend: `sudo rsync -a --exclude 'aiui/' --exclude 'archipelago-runtime/' web/dist/neode-ui/ /opt/archipelago/web-ui/` - - AIUI: `sudo rsync -a --delete aiui/packages/app/dist/ /opt/archipelago/web-ui/aiui/` - - binary: `sudo cp -f /usr/local/bin/archipelago /opt/archipelago/rollback/archipelago.bak && sudo install -m 755 core/target/release/archipelago /usr/local/bin/archipelago && sudo systemctl restart archipelago` -4. **Test live with curl before asking the operator to look.** Restarting the daemon does - NOT kill containers — verified: they live in `/user.slice`, the service cgroup holds 3 PIDs. -5. Verify the built bundle actually contains the change (`grep` the dist) — builds no-op silently. -6. Commit, push (`git push gitea-ai HEAD`), then loop. - -**Do not report a fix as done without a live check on the node.** Two bugs tonight only -appeared on deployment: nginx edits went into the ISO template not the running config -(`/etc/nginx/sites-enabled/archipelago`), and the TLS key was root-only while the daemon runs -as `archipelago`. - -## Ordered work list - -### 1. Gate vs app-owned auth (fleet-wide) — ✅ DONE, deployed + verified (`d9592c72`) -Round 1: credential-less allowlist, `manifest.json` 401→200 (`is_credentialless_public_path`). - -Round 2 (2026-08-06 ~19:50) — the actual cause of "Nostr signer doesn't work anywhere". -It was **not** a challenge/interception problem, and no session-aware rule was needed: the -gate was **deleting the app's own `Authorization` header** on every proxied request -(`parts.headers.remove(header::AUTHORIZATION)`, unconditional, `appgate/mod.rs`). IndeeHub -sends `Authorization: Nostr ` to its own `/api/auth/nostr/session`; the header -arrived stripped and its backend answered `401 "Authorization header is missing"`. No signer -could ever satisfy that — which is exactly why a NIP-07 **extension in a tab**, the **iframe** -bridge (`nostr-provider.js`) and **AIUI** all failed at once while the signing was fine. - -Fix: `authorize()` now reports WHICH credential allowed the request. The header is dropped -only when it WAS the gate's own `Bearer `; every other scheme (Nostr, Basic, -an app-issued bearer) is forwarded. Mirrors the surgical cookie strip above it. The -credential-less allowlist still drops it (nothing there needs auth). - -Live proof on archi-dev-box, authenticated with a real gate session: -- before → `401 {"message":"Authorization header is missing"}` -- after → `400 {"message":"Event is not a valid NIP-98 HTTP auth event"}` — identical to - the same POST on loopback, i.e. the signed event now reaches the app -- unauthenticated → still `401` (gate still challenges; boundary intact) - -**Blast radius was much wider than IndeeHub**: 27 apps are gated, and this broke any of them -that authenticate with the `Authorization` header (Vaultwarden, Jellyfin, Nextcloud/WebDAV, -Gitea tokens, Grafana). Same mechanism — not individually retested. - -**End-to-end proof, no browser required** (2026-08-06 ~20:20). The node signed a real -NIP-98 event with its own key via RPC (`auth.login` → `node.nostr-pubkey` → -`node.nostr-sign`, CSRF header required for the sign) and presented it to IndeeHub -**through the gate**, exactly as `nostr-provider.js` does: - -- `POST :7778/api/auth/nostr/session` → **200**, IndeeHub issued a real JWT pair - (`typ: nostr-session` / `nostr-refresh`, `sub` = the node's pubkey). A complete - Nostr login. -- Then the app's OWN bearer token back through the gate — the other half of the fix: - `/api/auth/me` **200**, `/api/projects/private` **200**, `/api/projects` **200**, - each identical to loopback. - -`/api/projects/private` was the endpoint recorded here as unreachable without a Nostr -session; it now answers 200 through the gate. Item 4's private-films path is unblocked. - -Still worth a human pass: a real NIP-07 **browser extension** login (this proved the -transport and the app's acceptance, using the node's key rather than the extension's). - -### 2. AI Data Access grants → node-side — ✅ DONE, deployed + verified (`762c72b4`) -Cause confirmed: `localStorage` is per-ORIGIN and a node answers on several (LAN, -Tailscale, `.local`, hostname), so grants made at one address were simply never set -at another. It also made a working content path look broken — every scope silently returns -nothing without a grant, so "ungranted" and "empty library" are indistinguishable. - -`settings/ai_permissions.rs` (session_policy shape: atomic temp+rename, sanitised on read -AND write, **fails closed** on a corrupt file) + `ai.permissions.get/.set`, absent from the -unauthenticated allowlist. Store seeds from localStorage for instant paint, then reconciles; -**migration pushes local grants UP when the node has none**, so upgrading never silently -revokes what someone already granted. Node wins otherwise, so a revocation on one device -takes effect everywhere. Hydration happens ONCE at broker start — the first attempt did it -per-gate and the existing broker tests caught it by failing on consumed mocks. - -Live proof on archi-dev-box: unauthenticated → 401 (dispatched, not "unknown method"); -set `["media","files","BAD ONE","../etc/passwd"]` → stored `["files","media"]` (malformed -dropped); written to `/var/lib/archipelago/settings/ai_permissions.json` owned by -`archipelago`; **survives `systemctl restart archipelago`** — the actual complaint. -Rust 7/7, store 18/18, broker 23/23. - -NOTE: testing left `media` + `files` GRANTED on archi-dev-box. That is the state the -operator needs for content anyway, but it was set by the test, not by them. - -### 2b. (superseded — original note) -`aiPermissions.ts` uses `localStorage` (`archipelago-ai-permissions`), which is PER-ORIGIN. -A node has many origins, so grants vanish when you switch address/device. Move behind an RPC, -localStorage as offline fallback, migrate existing local grants. This is what made a films -search look broken. - -### 3. Content-card parser (the "idiotic responses") -`updatePanelFromText` in `useContentPanel.ts` pairs title *n* with description *n-1* and -promotes section headers ("Documentaries:") to titles. The model's prose was CORRECT. -Fix the pairing + exclude headers; the real answer is structured model output, not regex. - -### 4. IndeeHub (needs 1 and 2 first) -- Content source: films are `projects`, `GET /api/projects` via its nginx on :7778. - Public count on this node is **0**. Port 4000 is not host-mapped. `/graphql` is the SPA. -- Private films need a **Nostr session** (`/api/projects/private` says Cognito is disabled). -- ~~`/relay` is 502 **direct on loopback**~~ — ✅ FIXED 2026-08-06. Not a networking - problem: DNS resolved (`relay` → 10.89.1.3) and nothing was listening. The relay's volume - `/usr/src/app/db` was owned by **root** while nostr-rs-relay runs as `appuser` (uid 1000), - so it crash-looped on `unable to open database file: .../nostr.db`. Repair (volume was - empty, no data at risk): - `podman unshare chown 1000:1000 ~/.local/share/containers/storage/volumes/indeedhub-relay-data/_data` - then `podman restart indeedhub-relay`. DB v18 built; `/relay` now 200 with its NIP-11 doc. - **Same ownership-bug family still open elsewhere** — the reconciler logs - `reconcile failed app_id=btcpay-server error=chown /var/lib/archipelago/postgres-btcpay failed`. - Worth a sweep: rootless volume dirs created root-owned for non-root container users. - -### 5. Node-side Nostr signer — the highest-leverage piece -Collapses IndeeHub's private auth, the app-auth half of item 1, and Phase C's zaps into one -design, with keys out of the browser and the model (the phase's non-negotiable). Precedent: -`nostr-provider.js` already built for BotFights. **Research from AIUI's seed/history and the -Nostr-first ethos before designing** — operator instruction. - -### 6. Operator's late asks -Cmd/Ctrl+K → "search with AIUI" must open the EXPANDED chat with the query actually sent and -answered. More mock content types. Performance across the content path. Audit all ten -`AIContextCategory` values in `fetchAndSanitize` for real coverage, not stubs. - -### 7. Loose ends observed -`/api/app-catalog` → 502 repeatedly · AIUI web search blocked by CSP (confirms 13-09: the -web-search setting must drive the CSP node-side) · `Failed to scroll to index N` in -ChatWindow · `strfry.png`/`.svg` 404. - -## Phase 13 GSD state - -14/15 plans done. **13-15 only** — device-close, `autonomous: false`, blocking human-verify. -Check 4 (CSP boundary) **PASSED on-device tonight**: BLOCKED in the AIUI frame, GOT 200 from -top. Checks 1, 2, 3 still need the operator in a browser; check 2's peer/owned half now has -code behind it but this node has no film content. -Resume with `/gsd-resume-work`, or `/gsd-plan-phase --research-phase 13` for the research pass. - -## Traps that cost time tonight — do not repeat - -- Verify on the NODE, not from source (nginx template vs `/etc/nginx/sites-enabled/`). -- rustls does NOT check that a key matches its certificate — the explicit pairing check in - `appgate/tls.rs` is load-bearing, not redundant. -- `build-aiui.sh` hangs after a successful build; the dist is complete — kill by PID/timeout. -- AIUI must build with `VITE_BASE_PATH=/aiui/` or you get a black page. -- Never `rm -rf /opt/archipelago/web-ui/*` — it destroys `aiui/`. -- The `.planning` dirs on `main` and the phase branch diverge; merge before deploying both. diff --git a/.planning/RESUME-2026-08-07-aiui-demo.md b/.planning/RESUME-2026-08-07-aiui-demo.md deleted file mode 100644 index d7182420..00000000 --- a/.planning/RESUME-2026-08-07-aiui-demo.md +++ /dev/null @@ -1,98 +0,0 @@ -# RESUME — 2026-08-07. AIUI demo prep + task list. - -**Read this first.** Then `.planning/RESUME-2026-08-06-media-loop.md` (the fix→deploy→test -loop and deploy commands), then `.planning/MEDIA-AND-INDEEHUB-SCOPE.md` (evidence). - -Branch `gsd/phase-13-…` @ `55155f2d`, clean, pushed. Deployed to archi-dev-box. - -**Context: AIUI is being demoed soon and a clean ISO must be cut.** Prioritise #16, #15, -#9 (the surfaces) and #17 (the ISO). - ---- - -## THE TASK LIST — rebuild this in the session task tool on resume - -`[x]` = done, deployed AND verified on the node. - -- [x] **1. Unify AI Data Access toggles with assistant tool grants** — `55155f2d` -- [ ] **2. Verify AI grants persist across refresh through the real UI path** -- [ ] **3. Add `app_install` / `app_uninstall` tools** (behind the 13-08 confirm gate) -- [ ] **4. `!archy` / `!ai` over mesh must action commands** with text responses -- [x] **5. Cap `content.browse-all-peers`** — `75919a20`, then rebuilt as Cloud's fan-out -- [ ] **6. Settings link when a request needs an ungranted permission** — a button, so the - user can decide to enable it, instead of silence or "I have no tool" -- [ ] **7. App lifecycle defects** — fedimint guardian installs but doesn't work; BTCPay - uninstall-with-wipe reinstalls with an account still enabled; Bitcoin Knots and other - apps disappeared; fedimint gateway died at 88%; reconciler `chown postgres-btcpay` - failures (same volume-ownership family as the IndeeHub relay fix) -- [ ] **8. LND UI + filebrowser 401s** — every `:18083/proxy/lnd/*` and - `/app/filebrowser/api/resources/`. Node-owned `*-ui` apps need session passthrough; - memory says that rides DISK manifests because the catalog refuses build-source -- [ ] **9. AIUI must answer with content + context surfaces, not prose** — only 1 of 10 - transcript turns used a surface -- [ ] **10. AIUI slow background image** + console noise (files context timeout, web-search - CSP on every query, `strfry.png`/`.svg` 404, ChatWindow scroll failure, - `/api/app-catalog` 502) -- [ ] **11. Cmd/Ctrl+K → AIUI** must carry the query into the expanded chat -- [x] **12. Node certificate settings section** container/layout — `75919a20` -- [ ] **13. HTTPS dynamically on EVERY address** alongside Tailscale (LAN done; must - re-apply as addresses change, and the bare hostname must resolve) -- [ ] **14. Nostr signer + service worker over HTTPS** (operator: lower priority than AIUI) -- [ ] **15. Content-surface header** — goes UNDER the container's close button, and the left - heading shows the LAST thing searched; should read "Loading…" until it knows -- [ ] **16. Populate the content surface for own shared content + rich chat previews** — - "show me my own shared content" gave a correct prose list (photos, music, APKs, docs, - with sizes and sat prices) while the surface stayed EMPTY. The visuals/layouts exist -- [ ] **17. Cut a clean ISO for the demo** — `UNBUNDLED=1 bash - image-recipe/build-debian-iso.sh` (the default env silently builds the wrong - full-bundle variant); verify the frontend INSIDE the ISO - ---- - -## Shipped this session (all deployed to archi-dev-box) - -| What | Commit | -|---|---| -| Gate stopped deleting apps' `Authorization` header (broke every Nostr signer) | `d9592c72` | -| Gate stopped 401ing credential-less PWA manifest fetches | `8e3e8e9a` | -| AI grants node-side, then unified with the assistant's store | `762c72b4`, `55155f2d` | -| Content cards carried the PREVIOUS item's description | `086d381c` | -| IndeeHub relay 502 (root-owned volume vs uid-1000 user) | (session) | -| `content_list` scope: own\|peers\|purchased\|films + 2 RPCs | (session) | -| Progressive content load (peers no longer block the grid) | `05b459a6` | -| Mesh view TDZ crash | `0a23c994` | -| Peer cap, cert section, LAN HTTPS listener | `75919a20` | - -## Findings that change what to expect - -- **The 16 federated peers are NOT serving content.** FIPS is healthy (anchor connected, 3 - authenticated peers, 4 `fips_ok` dials) but 14 dials fall back and fail, so - `peers_reached: 0` is CORRECT. No AIUI work makes peer films appear until the peers - answer. This is a fleet problem, not a UI one. -- **IndeeHub's catalogue is empty** (`/api/projects/count` = 0). The adapter is wired and - returns `count: 0` honestly. Operator says there is a source called "top documentary - films" — find which endpoint serves it, and whether it needs the Nostr session. -- **Two permission stores existed** for the same ten categories. That, not a persistence - bug, is why toggling Settings never helped the assistant. -- **tailscaled owns `:443`** on tailnet addresses. `listen 443 default_server` binds - 0.0.0.0, fails `EADDRINUSE`, and nginx then keeps the OLD config while the reload reports - success. Bind LAN addresses explicitly. -- Testing set `media` + `files` in both grant stores on archi-dev-box. - -## Phase 13 GSD - -14/15. **13-15 only** (device-close, blocking human-verify). -Operator verified: check 1 ✅ ("it's fine"), check 3 ✅ ("seems fine"), check 4 ✅ (CSP -boundary, BLOCKED in frame / GOT 200 at top). **Check 2 is the holdout** — no film content -exists on this node to display. Then write `13-UAT.md`, fill `13-VALIDATION.md`, close. - -## Traps — do not repeat - -- Verify on the NODE, not from source (nginx template vs `/etc/nginx/sites-enabled/`). -- rustls does NOT check key/cert pairing — the check in `appgate/tls.rs` is load-bearing. -- `build-aiui.sh` hangs AFTER succeeding; dist is complete — kill by timeout. -- AIUI must build with `VITE_BASE_PATH=/aiui/` or you get a black page. -- Never `rm -rf /opt/archipelago/web-ui/*` — it destroys `aiui/`. -- Restarting `archipelago` does NOT kill containers (they live in `/user.slice`; the - service cgroup holds 3 PIDs) — verified, despite the older CLAUDE.md warning. -- Release build is ~8 min. Budget for it. diff --git a/.planning/RESUME-2026-08-07-aiui-surfaces.md b/.planning/RESUME-2026-08-07-aiui-surfaces.md deleted file mode 100644 index 942f3b9e..00000000 --- a/.planning/RESUME-2026-08-07-aiui-surfaces.md +++ /dev/null @@ -1,292 +0,0 @@ -# RESUME — 2026-08-07 (afternoon). AIUI surfaces + demo prep. - -**Read this first.** Then `.planning/RESUME-2026-08-07-aiui-demo.md` (the previous -handoff, still the source for the traps list), then -`.planning/RESUME-2026-08-06-media-loop.md` (the fix→deploy→test loop). - -Branch `gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf` -@ `b1c5d138`. Working tree: clean except `neode-ui/shot.tmp.mjs` (a throwaway -Playwright driver, see "Browser verification" below — delete or keep, it is not -committed). - -**Context: AIUI is being demoed soon and a clean ISO must be cut.** The operator's -priority order is #16, #15, #9, #17. - -**The operator's standing instruction for this work:** fix → test → check the -browser with screenshots → deploy → debug → fix, in a loop, without stopping to -ask questions. And: *"if you think you know better like the peer files you are -completely wrong"* — see "The peer-files correction" below. Do not re-derive the -old conclusion. - ---- - -## THE TASK LIST — rebuild this in the session task tool on resume - -`[x]` = done, deployed AND verified on the node. - -- [x] **1. Unify AI Data Access toggles with assistant tool grants** — `55155f2d` -- [ ] **2. Verify AI grants persist across refresh through the real UI path** -- [ ] **3. Add `app_install` / `app_uninstall` tools** (behind the 13-08 confirm gate) -- [ ] **4. `!archy` / `!ai` over mesh must action commands** with text responses -- [x] **5. Cap `content.browse-all-peers`** — `75919a20`, then rebuilt as Cloud's fan-out -- [~] **6. Settings link when a request needs an ungranted permission** — node + broker - + chrome banner all landed in `9abc1623`; **NOT yet seen in a browser** (needs a - turn that actually hits an ungranted category — try revoking `media` and asking - for content). Finish by confirming the banner renders and its button lands on - the AI Data Access section. -- [ ] **7. App lifecycle defects** — fedimint guardian installs but doesn't work; BTCPay - uninstall-with-wipe reinstalls with an account still enabled; Bitcoin Knots and other - apps disappeared; fedimint gateway died at 88%; reconciler `chown postgres-btcpay` - failures (same volume-ownership family as the IndeeHub relay fix) -- [ ] **8. LND UI + filebrowser 401s** — every `:18083/proxy/lnd/*` and - `/app/filebrowser/api/resources/`. Node-owned `*-ui` apps need session passthrough; - memory says that rides DISK manifests because the catalog refuses build-source. - **Untouched this session.** Note the 403s still in the browser console (below) may - be this same family — worth checking before assuming a separate cause. -- [~] **9. AIUI must answer with content + context surfaces, NOT JUST prose** — - *(operator correction: the prose answer stays, it is wanted; what was missing is - the surfaces alongside it)*. Largely delivered by the work below and verified in a - browser. Remaining: only `content_list`/`apps_list` are surface-producing tools, so - turns about system/network/bitcoin still answer in prose only. Decide whether those - deserve context surfaces too. -- [~] **10. AIUI slow background image + console noise** — web-search CSP spam and the - web-search 403 are FIXED (`b1c5d138`). Still open, all observed live in the console: - wavlake.com + itunes.apple.com blocked by CSP (song cover enrichment — this is the - known 13-09 CSP issue; memory says the decision is that the web-search setting - should drive the CSP, moved node-side), three 403s, two 402 Payment Required, a 502, - a 404, and the sw.js SSL registration failure (self-signed cert). The slow - background image itself was not investigated. -- [ ] **11. Cmd/Ctrl+K → AIUI must carry the query into the expanded chat** — I read the - whole path (`SpotlightSearch.vue:294` → `Chat.vue` `askedAt` watcher → `flushAsk` - → `chat:prefill` → `archyBridge.onPrefill` buffering → `ChatInput.vue:224`) and it - is **fully wired and looks correct**, including the cold-frame buffer. It - deliberately prefills-and-focuses rather than auto-sending. **Not reproduced, not - verified in a browser.** Do that before changing any code — the report may predate - the fix. -- [x] **12. Node certificate settings section** container/layout — `75919a20` -- [ ] **13. HTTPS dynamically on EVERY address** alongside Tailscale (LAN done; must - re-apply as addresses change, and the bare hostname must resolve) -- [ ] **14. Nostr signer + service worker over HTTPS** (operator: lower priority than AIUI) -- [x] **15. Content-surface header** — the stale-title half is DONE and browser-verified - ("Loading…" during the turn, "Nothing found" on an empty result, correct count - after). **The header-overlap half is NOT confirmed:** at 1600×950 the close button - (`absolute top-3 right-3`) does not collide with anything — the tab row already - carries `pr-12` (`ChatPage.vue:38`). I never reproduced the overlap. **Check a - narrow/mobile viewport** before editing CSS; that is the most likely place it bites. -- [x] **16. Populate the content surface for own shared content + rich chat previews** — - DONE and browser-verified. See below. -- [ ] **17. Cut a clean ISO for the demo** — `UNBUNDLED=1 bash - image-recipe/build-debian-iso.sh` (the default env silently builds the wrong - full-bundle variant); verify the frontend INSIDE the ISO. - **BLOCKER: rebuild the release binary first** — see "Binary drift" below. - ---- - -## What shipped this session - -| What | Commit | -|---|---| -| Content surface renders what the assistant found (4 defects) | `9abc1623` | -| SearXNG JSON 403 — AIUI web search never worked | `c810b514` | -| Node content outranks the prose surface; web-search path | `b1c5d138` | - -### `9abc1623` — the content surface, four separate defects, one symptom - -The symptom was always the same: a correct prose answer beside an empty grid. - -1. **The assistant's curated RPC bridge had an arm only for `content.list-mine`.** - `assistant/tools.rs` mapped the `peers`, `purchased` and `films` scopes onto - `content.browse-all-peers` / `content.owned-list` / `content.indeehub-projects` - — three real, dispatcher-registered handlers that `assistant_dispatch_tool` - (`api/rpc/assistant_chat.rs`) had never heard of. Every non-`own` scope died on - its catch-all with "no such handler". **The tool never ran.** Regression test - added: `every_content_scope_reaches_a_real_dispatch_handler`. -2. **`content.browse-all-peers` threw away completed work.** It wrapped the whole - fan-out in one `timeout(overall, ...).unwrap_or_default()`, which DISCARDED - every finished batch the moment the budget expired. One slow peer turned a - partly-successful browse into `peers_reached: 0, peers_unreachable: 16`. Now it - accumulates per batch and checks a deadline between batches, so partial results - always survive; budget 20s → 45s (two batches of 8 at a 10s per-peer timeout had - literally zero headroom). -3. **`assistant.chat` returned only `{ text }`.** The structured tool results were - dropped inside the loop. The turn now carries them through as `surfaces`, - captured RAW in `loop_::execute_tool`'s `Ok(v)` arm — deliberately BEFORE - `wrap_tool_result_if_untrusted`, because that boundary exists to stop peer text - being read as instructions by the MODEL, and this copy goes to a renderer that - treats every field as inert data and never re-enters the prompt. -4. **The adapter classified images as `'excluded'` and dropped them.** A node - sharing mostly photos rendered as an empty grid while AIUI's `panelImages` / - `ImageGrid` sat unused. Images now have a bucket end to end - (`archyContentAdapter` → `contextBroker` → `archyBridge` → `useArchy` → - `useContentPanel`), with the paid-lock and extension-fallback handling that - audio and video already had. - -Also in that commit: the "Loading…" / "Nothing found" panel headings; a system-prompt -paragraph telling the model to call the content tool and summarise rather than -re-list what the cards already show; and `refused_categories` on the chat response so -the trusted chrome can offer the AI settings screen (task 6). - -### `c810b514` — SearXNG JSON was 403, so AIUI web search never worked - -The operator asked whether "Web search via your private SearXNG instance" is true. -**It is true** — `/aiui/api/web-search` proxies to `127.0.0.1:8888/search`, the local -container, and nothing leaves via a third-party API. But SearXNG defaults to -`formats: [html]`, so its JSON API answered **403**, and JSON is the only thing AIUI -speaks. Both seed sites (`scripts/first-boot-containers.sh` and -`api/rpc/package/install.rs`) omitted `search.formats`. - -**Measured after the fix, live:** 28 results for "bitcoin halving", from Brave (20) -and DuckDuckGo (8). Google self-suspends ("access denied") and Startpage hits a -CAPTCHA — normal for a self-hosted instance, and it costs little because Brave runs -its own independent index. **The result quality is fine; the 403 was the whole -problem.** No case for replacing SearXNG on this evidence. - -**Existing nodes need manual repair** (the commit only fixes what new installs get): -add to `/var/lib/archipelago/searxng/settings.yml` -```yaml -search: - formats: - - html - - json -``` -then restart the app. **archi-dev-box is already repaired.** - -### `b1c5d138` — the prose surface was winning, and the web-search path was wrong - -- `setArchyContent` put the node's grids on the tab bar, then `updatePanelFromText` - **replaced** the bar with tabs inferred from the reply text. "show me my own shared - content" landed on an **"AI Brief"** — a prose restatement of the answer already on - the left — with the populated image grid no longer reachable. Guarding the `panel*` - arrays was not enough: they held the right data while the tab bar had discarded the - way to see it. Archy tabs now lead and the title follows the leading tab. -- Archy tabs are ordered by **bucket size**. A node with 13 photos and 2 tracks opened - on Songs and titled itself "2 Songs" for a 15-item answer. -- `searchWeb` hardcoded `/api/web-search` while every other call is built from - `BASE_URL`. Under `/aiui/` it asked the HOST for a path only the AIUI-scoped nginx - location serves → 403 from the node's API gate, plus a CSP refusal. -- The embedded path now skips client-side web search entirely: `streamViaArchy` sends - only the user's text, so the system prompt those results were folded into is never - transmitted. It was a round trip and a console error per turn whose output provably - reached no model. **Web search for the embedded path belongs node-side, next to the - other tools** — that is task #10's "web search through node chat" note. - ---- - -## The peer-files correction — READ THIS - -The previous session concluded: *"Your 16 federated peers are not serving content… -peers_reached: 0 is correct. No AIUI work will make peer films appear until those -peers answer. This is a fleet problem, not a UI one."* - -**That was wrong, and the operator said so.** Measured live on archi-dev-box: - -- A direct `content.browse-all-peers` RPC returned **real peer items** (a peer's - `Music/Architects of Tomorrow.mp3`) on one call and **0 reached / 16 unreachable** - on the very next — the discard-on-timeout bug in defect 2 above. -- Through the assistant after the fixes: **4 of 16 peers reached, 7 items from 2 - peers**, including paid tracks at 10,000 sats. - -The peers were serving content the whole time. Two code bugs (a missing dispatch arm -and a timeout that threw away completed work) produced a number that looked exactly -like a fleet outage. **Do not re-diagnose this as infrastructure.** - ---- - -## Browser verification — how it was done, and how to repeat it - -This box **is** archi-dev-box (`hostname` confirms; LAN `192.168.63.240`, Tailscale -`100.69.68.39`). Everything can be tested locally. - -**Authenticated RPC from the shell** — -`/tmp/claude-1000/.../scratchpad/rpc.mjs` (regenerate if the scratchpad is gone): -POST `https://192.168.63.240/rpc/v1`, `auth.login` with `{"password":"ThisIsWeb54321@"}`, -carry the `session` + `csrf_token` cookies and send `X-CSRF-Token`. -**The API is NOT on 7777 or 8101** — go through nginx on 443. - -**Playwright driver** — `neode-ui/shot.tmp.mjs` (uncommitted, run it from -`neode-ui/`, which is where `playwright` is installed; the aiui package has it only -under pnpm's store). It logs in, opens `/dashboard/chat`, dismisses the Remote -Companion modal (**Escape first — its button uses a curly apostrophe, so -`has-text("I've installed it")` never matches**), finds the `/aiui/` frame, types -into the real composer, and screenshots. It also installs an `addInitScript` probe -that logs every `chat:response` with its surface bucket counts — that probe is what -proved the pipeline end to end. - -**Latest verified run:** -``` -heading before: "16 Images" -heading during turn: "Loading…" -heading after: "13 Images" -[PROBE] chat:response success=true surfaces=1 - detail=[{"tool":"content_list","scope":"own","films":0,"songs":2,"images":13}] -``` -Screenshots in the scratchpad: `13-answered.png` shows prose on the left and a -populated Songs/Images grid on the right — the thing that was empty before. - ---- - -## Binary drift — DO THIS BEFORE THE ISO - -`core/target/release/archipelago` was built BEFORE the `install.rs` SearXNG change, -and the currently deployed `/usr/local/bin/archipelago` is that same binary. It has -all the surface/peer fixes (verified live) but **not** the SearXNG seed fix. - -``` -cd core && CARGO_INCREMENTAL=0 cargo build --release -p archipelago # ~9 min -``` -Confirm it took: `strings core/target/release/archipelago | grep -A2 "limiter: false"` -should now show the `formats` lines. Then deploy per the loop below and cut the ISO. - -## Deploy loop (archi-dev-box = this box) - -``` -# frontend -cd neode-ui && npm run build -sudo rsync -a --exclude 'aiui/' --exclude 'archipelago-runtime/' \ - web/dist/neode-ui/ /opt/archipelago/web-ui/ -# AIUI (build-aiui.sh HANGS after succeeding — wrap in `timeout 540 ... || true` -# and check dist/index.html's mtime; the dist is complete) -bash scripts/build-aiui.sh -sudo rsync -a --delete aiui/packages/app/dist/ /opt/archipelago/web-ui/aiui/ -# binary -sudo cp -f /usr/local/bin/archipelago /opt/archipelago/rollback/archipelago.bak -sudo install -m 755 core/target/release/archipelago /usr/local/bin/archipelago -sudo systemctl restart archipelago -``` - -## Test state - -- **Rust:** `cargo test --bin archipelago assistant::` → **122 passed, 0 failed.** - (`-p archipelago --lib` fails with "no library targets" — it is a bin crate.) -- **neode-ui:** adapter + broker + views → **71 passed, 0 failed.** I also fixed a - **pre-existing** failure in `toolConfirm.test.ts` (it asserted `rpcClient.call` was - never called, but `ContextBroker.start()` hydrates AI permissions over RPC; narrowed - to "no `assistant.*` call", which is the property actually under test). -- **AIUI:** **348 passed, 3 failed — all three PRE-EXISTING**, confirmed by stashing - my changes and re-running. They are `seed-conversations.test.ts` (seed-songs content - types), `seedExtraction.test.ts` (extracts 10 songs), and `useAI.test.ts` - (`webSearch` flag in the request body — this one may now be *related* to the - embedded-mode gate; re-check it, it was failing before but for a different reason). - -## Traps — do not repeat - -- **I removed a running container** by running `podman restart searxng` directly. The - orchestrator owns lifecycle: it saw "stopping" and the container vanished. Recover - with the RPC `container-start` and params **`{"app_id": "..."}`** (not `{"name":...}`). - Do not drive podman by hand for app containers. -- `es.json` reformatted wholesale when edited with `json.dump` (470-line diff). Insert - keys textually, preserving the file's own formatting. -- Verify on the NODE, not from source (nginx template vs `/etc/nginx/sites-enabled/`). -- rustls does NOT check key/cert pairing — the check in `appgate/tls.rs` is load-bearing. -- AIUI must build with `VITE_BASE_PATH=/aiui/` or you get a black page. -- Never `rm -rf /opt/archipelago/web-ui/*` — it destroys `aiui/`. -- Restarting `archipelago` does NOT kill app containers (they live in `/user.slice`). -- Release build is ~9 min. Budget for it. - -## Phase 13 GSD - -14/15. **13-15 only** (device-close, blocking human-verify). Operator verified checks -1, 3 and 4. **Check 2 was the holdout — "no film content exists on this node to -display".** That premise should be re-tested now: peer content demonstrably reaches -the surface, and the images bucket means the node's own catalogue renders too. Then -write `13-UAT.md`, fill `13-VALIDATION.md`, close. diff --git a/.planning/RESUME-2026-08-07-evening.md b/.planning/RESUME-2026-08-07-evening.md deleted file mode 100644 index 1c774f2f..00000000 --- a/.planning/RESUME-2026-08-07-evening.md +++ /dev/null @@ -1,64 +0,0 @@ -# RESUME — 2026-08-07 (session 2, evening). Banner fix, ISO, content truth. - -**Read first if a newer session needs this branch's state.** Supersedes -`RESUME-2026-08-07-aiui-surfaces.md` for what shipped TODAY; that doc's traps -list and deploy loop still apply verbatim. - -Branch `gsd/phase-13-aiui-functional-conversational-node-control-and-content-surf`. - -## Shipped this session (all committed + pushed to gitea-ai, deployed to archi-dev-box) - -| Commit | What | -|---|---| -| `c25fd8b6` | Owner never pays for own files (`serve_content` owner_session) + purchased items serve from local cache w/ Range + own never locked + per-onion peers/purchased normalization | -| `3f22e437` | strict-TS fix for the per-onion grouping (vue-tsc gate; vitest strips types) | -| `1ac08a3e` | Prompt: recommendations check catalogue/peers FIRST + `[[film_ext:…]]` etc. tag vocabulary for knowledge picks; bans the "would you like me to check?" stall | -| `a91bc55d` | W1.1 shapes: IndeeHub films carry `video/mp4` mime hint; `apps_list` wraps `{items:[…]}` at the tool boundary | -| `7d57e2c3` | Panel: per-bucket `archySupplied` replaces the global latch — recommendation previews render in empty buckets, node truth wins filled buckets, 'Nothing found' sticky | -| `c2e71bc7` | W1.2 playback: `usePlayer` plays node `sources[]` first (Wavlake fallback); `FilmDetail` prefers node sources over YouTube | -| `f15e2b50` | First banner fix attempt: `[[needs:]]` marker — **SUPERSEDED, see below** | - -## Live-verified this session (browser/RPC on archi-dev-box) - -- Own paid items: `GET /content/` with session cookie → **200** (were 402). -- "recommend me 10 scifi films" → Films tab + 10 preview cards ("10 Films" heading, "not in library" badges). -- Own shared content turn → `locked: 0`, playable `/content/` URLs. -- Cmd+K → "Talk to AIUI about it" → query lands in the AIUI composer (PREFILL-OK). -- Grants persist across refresh (node-side grants identical before/after reload). - -## IN FLIGHT when this was written - -**#6 banner** (ungranted-permission Settings offer). The 9abc1623 banner never fired -(D-16 hides ungranted tools → model never calls → `refused_categories` always empty). -The marker fix (`f15e2b50`) also failed live: the local model wrote a workaround -narrative, never emitted `[[needs:media]]`. **Final fix (uncommitted at writing):** -disabled tools are LISTED in the prompt under a DISABLED section and stay in the -schema — the model's call hits the execution gate, which records the refusal → -banner fires deterministically, model-independent. The prompt split is UX shaping; -the boundary remains the server-side grant re-check. Tests updated -(`ungranted_tool_only_ever_in_disabled_section`, `disabled_tools_are_listed_as_callable_but_refused`). -**Next steps: tests green → commit → rebuild release binary → deploy → re-run -`neode-ui/verify-banner.tmp.mjs` (revoke media → ask for content → banner → button -lands on #ai-data-access → restore grant).** - -## ISO - -`image-recipe/results/archipelago-installer-1.7.125-alpha-unbundled-x86_64_RC1.iso` -(2.6 GB) + `.sha256`, built 2026-08-07 ~11:16 via `UNBUNDLED=1 bash -image-recipe/build-debian-iso.sh`. First run wedged on transient deb.debian.org -download failures; the retry used the cached rootfs. **Frontend verification inside -the ISO is still owed** (RESUME rule): extract `web-ui/` from the ISO and compare -content-hashed asset filenames against `web/dist/neode-ui/` and -`aiui/packages/app/dist/` — both were built today (08:45 / 09:21) and are what the -builder copies in. NOTE: the ISO predates the DISABLED-section fix; if the demo -needs the banner to fire, re-cut after that binary lands. - -## Verification drivers (uncommitted, in `neode-ui/`) - -- `shot.tmp.mjs` — original chat probe (bucket counts via chat:response). -- `verify-owner.tmp.mjs` — own-content locked-count probe. -- `verify-reco.tmp.mjs` — the "recommend me 10 scifi films" turn. -- `verify-banner.tmp.mjs` — revoke media → chat → `.chat-permission-offer` → click → settings. -- `verify-cmdk.tmp.mjs` — spotlight → AIUI prefill. -- `verify-grants.tmp.mjs` — grants across reload. -All use `https://192.168.63.240`, password `ThisIsWeb54321@`, and run from `neode-ui/`. diff --git a/.planning/RESUME-2026-08-07-late.md b/.planning/RESUME-2026-08-07-late.md deleted file mode 100644 index 03b274ab..00000000 --- a/.planning/RESUME-2026-08-07-late.md +++ /dev/null @@ -1,77 +0,0 @@ -# RESUME — 2026-08-07 (session 2 close, late night). Full-day state. - -**Read first on any resume.** Branch `gsd/phase-13-…` — everything below is -committed AND pushed to gitea-ai. Deployed to archi-dev-box: binary build9 -(/usr/local/bin/archipelago), neode-ui + AIUI in /opt/archipelago/web-ui. - -## Shipped + verified live today (26 commits) - -Highlights, all pushed: owner-never-pays (c25fd8b6), recommendation previews -(1ac08a3e + 7d57e2c3), node-first playback (c2e71bc7), permission banner -deterministic (6815a7d1 + anchor eaf0f073), app_install/app_uninstall behind -confirm gate (7686a486), S6 replay fix, mesh !ai on the shared tool loop -(4361a5cb), mock-free prod bundle + build gate (8329b826 + 3cd210f2), honest -metadata (31fd789b), strfry icon fallback (2787a9bb), web-search 401 + fleet -self-heal (482c4e30), ISO builder newest-AIUI + IPv4 + wgetrc fixes -(e669a3e4 + a2254648 + 34085e30), phase-13 close-out (494400df), chown loop -killed (b9e64eb6), btcpay full-stack wipe (7c7cd76c). - -## ISO - -`image-recipe/results/archipelago-installer-1.7.125-alpha-unbundled-x86_64_RC3.iso` -(2.5G) — BUILT + VERIFIED INSIDE (fresh neode-ui `DFmKTA_D`, clean AIUI -`DKWp4MFh` + webp backdrop, binary has install tools). NOTE: RC3 predates the -chown-loop and btcpay-wipe fixes (b9e64eb6/7c7cd76c) — **recut if the demo -needs those** (the fixes are deploy-level, not first-boot-visible). - -## Test state at close - -- Rust assistant suite: **130/130**. Container suite: **206/206**. Package suite: 53/53. -- neode-ui suites green (adapter 37, broker 25, toolConfirm, audioPlayer 11, appsConfig 13). -- AIUI: 353/356 — the 3 failures are the documented pre-existing seed-fixture ones (W1.7). - -## #7 status (app lifecycle family) - -- **chown loop: FIXED + live-verified** (0 chowns/4min, apps healthy, reconciler active). - Root cause: pre-start hooks for btcpay/fedimint/fmcd chowned unconditionally on every - prepare; prepare re-runs every reconcile touch. Now drift-gated via root stat probe. -- **btcpay wipe: FIXED** (postgres-btcpay + nbxplorer included). Full wipe/reinstall - cycle verification is owed (needs a throwaway btcpay install — don't do it on .228). -- **Open, need failing-node evidence:** fedimint guardian installs-but-doesn't-work, - fedimint gateway dying at 88%, Knots "disappearing again". On archi-dev-box right - now: bitcoin-knots Up+healthy, fedimint-clientd Up, archy-fedimint-ui Up — no - guardian installed here. `/var/lib/archipelago/fedimint.broken-20260423` is an old - remnant. Reproduce on archi-dev-box by installing fedimint guardian via RPC and - watching (its install pulls big images; do it when the box is idle). - -## Open next (operator-visible priority order) - -7c (fedimint/knots evidence) → 8 (LND UI/filebrowser 401s — *-ui session passthrough -via disk manifests) → 4 live-radio smoke → 10 console noise → 9 surfaces decision → -W1.6 (node-side web-search tool + enrichment) → W2.1 (Routstr funding UX). - -## Traps learned today (do not repeat) - -- **Never run the ISO build in parallel with cargo builds** — debootstrap downloads - died under load until the IPv4 pin (a2254648). And `pkill -f build-debian-iso` - self-matches your own shell — use `[b]uild` bracket patterns. -- **The ISO bakes the DEPLOYED binary** (/usr/local/bin/archipelago) — deploy first, - then recut. Verify the frontend INSIDE the ISO by mounting + diffing bundle hashes. -- **shell quoting:** backticks in a double-quoted `git commit -m` get - command-substituted ("stat" was eaten once) — use single-quoted heredoc or avoid - backticks in commit messages. -- **Detach heavy builds with `setsid nohup ... < /dev/null`** — a shell timeout kill - otherwise takes the build down with it (lost build #190 to exactly that). -- **`/home/archipelago/archy` is a SYMLINK on this box** — the daemon's nginx - self-heal skips it by design (dev-laptop guard). Hand-patch this node and let the - fleet self-heal. -- The string-replacement edit tools (Edit) match EXACTLY including indentation — - a partial oldString leaves orphaned tails (brace errors); re-read after editing. - -## Verification drivers (uncommitted, in neode-ui/, all use https://192.168.63.240, pw ThisIsWeb54321@) - -`verify-owner.tmp.mjs` (own locked counts) · `verify-reco.tmp.mjs` (recommendation -turn) · `verify-banner.tmp.mjs` (permission banner, incl. grants revoke/restore) · -`verify-cmdk.tmp.mjs` (spotlight prefill) · `verify-grants.tmp.mjs` (grants across -reload) · `verify-install.tmp.mjs` + `verify-install-rpc.tmp.mjs` (confirm gate) · -`verify-grid*.tmp.mjs` (content grid checks). Playwright runs from neode-ui/. diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md deleted file mode 100644 index d15f63e4..00000000 --- a/.planning/ROADMAP.md +++ /dev/null @@ -1,396 +0,0 @@ -# Roadmap: Archipelago — v1.8.0 Developer-Ready App Platform - -## Overview - -Brownfield milestone starting from a green single-node production gate (5/5 on .228, -2026-06-23). The journey: make federation and mesh rock-solid (node removal, sync, -messaging parity), fix the UI slowness users feel on every tab switch, prove the platform -across the fleet (multinode pass), make the container lifecycle bulletproof (Quadlet -default, self-healing, honest progress, no ghosts), flip manifest distribution from OTA -disk files to the signed registry, harden manifest security enforcement to the full -ADR-009 bar, ship the `archy app` developer CLI, and land the decentralized Nostr -marketplace — ending at the north star: a third-party developer publishes an app via the -signed/decentralized registry and a user installs it on their node. - -## Phases - -**Phase Numbering:** - -- Integer phases (1, 2, 3): Planned milestone work -- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED) - -- [ ] **Phase 1: Federation & Mesh Hardening** - Deep review of federation/fleet + mesh code; node removal sticks, sync converges, mesh messaging behaves identically on demo and real nodes -- [x] **Phase 2: UI Performance** - Tab switches and secondary screens render fast; worst transitions measured and fixed (completed 2026-07-31) -- [ ] **Phase 3: Multinode Verification Pass** - Lifecycle gate green on a second node; cross-node federation/mesh/transport suites pass; federation removal sticks -- [ ] **Phase 4: Lifecycle Perfection & Quadlet Default** - Quadlet backends default, failed-unit self-healing, flap observability, cascade gate, truthful progress -- [ ] **Phase 5: Registry-Distributed Manifests** - Signed catalog carries full manifests; fleet flipped off OTA disk-file distribution -- [ ] **Phase 6: Manifest Security Enforcement** - Validation matches ADR-009 mandates; generated security profiles actually applied -- [ ] **Phase 7: Developer Tooling CLI** - `archy app validate/render/local-install/lifecycle-test` + developer guide -- [ ] **Phase 8: Decentralized Marketplace** - DID-signed publish to Nostr relays, trust-tier discovery, verified third-party install end-to-end -- [ ] **Phase 9: BotFights Platform Upgrade** - Native nostr signer login, one self-contained AI bot-setup prompt, shared public VPS2 match endpoint so all nodes see all fighters, registry updated -- [ ] **Phase 12: Bitcoin Node Settings & Core/Knots Parity** - Every bitcoind option reachable in the UI, Knots-only options gated to Knots, network mode a setting defaulting to Tor -- [ ] **Phase 13: AIUI — Conversational Node Control & Content Surfaces** - Human-language node control and settings in AIUI chat, its designed content surfaces wired to real peer/music/movie data, all inside a user-granted capability sandbox that keeps keys and secrets away from the browser and the model - -## Phase Details - -### Phase 1: Federation & Mesh Hardening - -**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs -**Depends on**: Nothing (first phase) -**Requirements**: FED-01, FED-02, FED-03, FED-04, FED-05, FED-06, FED-07, UIFIX-01, UIFIX-02, UIFIX-03, UIFIX-04, UIFIX-05, UIFIX-06, FED-08, FED-09 -**Success Criteria** (what must be TRUE): - - 1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason - 2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping - 3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator - 4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal) - 5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy - 6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears - 7. Fedimint gateway installs have no pre-set password (BLOCKER, added 2026-07-30): a fresh install generates its gateway credentials per-install via manifest-declared `generated_secrets` (per the repo secrets invariant) or requires the user to set one — never a baked-in default; existing installs carrying the default password are migrated or flagged. NOTE: phase 1's 10 plans predate this criterion — an additional gap plan is required before phase 1 execution completes - 8. The FIPS/Tor pills on cloud files are kept and visible at mobile widths (UIFIX-01, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) - 9. The connected-nodes list scrolls at row-matched height instead of growing to fit (UIFIX-02, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) - 10. The onboarding tickbox is discoverably visible on short viewports via an on-brand affordance (UIFIX-03, BLOCKER, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) - 11. Paid Files pictures open in the app lightbox, not a browser tab (UIFIX-04, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) - 12. Picture-in-picture closes the lightbox with a fluid on-brand animation (UIFIX-05, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) - 13. Genuinely slow opens show loader states (UIFIX-06, added 2026-07-30 — see `.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`; 02-08's flagged timing regressions are the starting inventory) - NOTE for criteria 7–13: all were added after phase 1's 10 plans were written — before phase 1 execution completes, create gap plan(s) covering FED-07 + UIFIX-01..06 (existing desktop visuals must remain untouched per the standing visual-invisibility rule; UIFIX items themselves are user-approved visual changes) - -**Plans**: 11/20 plans executed - -Plans: - -- [x] 01-20-PLAN.md — URGENT wave 1: doctor stops restarting Tor every 5min (mesh Tor fallback) (FED-09) -- [x] 01-19-PLAN.md — URGENT wave 1: wallet invoices embed route hints so private-channel nodes can receive (FED-08) -- [x] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01) -- [x] 01-02-PLAN.md — Demo mesh/federation RPC parity + automated parity harness (FED-04) -- [x] 01-03-PLAN.md — On-brand paid tick: ScreensaverRing badge variant on both success surfaces (FED-06) -- [ ] 01-04-PLAN.md — Lightning identity: own-node URI + meshed Lightning peer discovery (FED-05) -- [ ] 01-05-PLAN.md — Federation sync convergence and operator-visible sync errors (FED-02) -- [ ] 01-06-PLAN.md — Lightning URI on the federation sync payload, sharing default decided (FED-05) -- [ ] 01-07-PLAN.md — Channel-open request messaging over the mesh (FED-05) -- [ ] 01-08-PLAN.md — Channel-open UX: own URI, trusted-node picker, meshed-peer requests (FED-05) -- [ ] 01-09-PLAN.md — Structured federation/mesh review + dev-pair deploy (FED-03) -- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06) - -**Wave 7** *(gap closure — criteria 7–13, added 2026-07-30 after the original 10 plans were written)* - -- [x] 01-11-PLAN.md — No baked-in Fedimint gateway credential: per-install secret on every path (FED-07) -- [x] 01-12-PLAN.md — Connected-nodes list scrolls at row-matched height instead of growing (UIFIX-02) -- [x] 01-13-PLAN.md — On-brand scroll cue makes the onboarding tickbox findable on short screens (UIFIX-03) -- [x] 01-14-PLAN.md — Paid Files open in the app lightbox, with a visible wait and a real error path (UIFIX-04/06) -- [x] 01-15-PLAN.md — PiP hands off from the lightbox and survives tab changes and buffering (UIFIX-05) - -**Wave 8** *(blocked on Wave 7 completion)* - -- [x] 01-16-PLAN.md — Migrate existing installs off the default gateway credential, data preserved (FED-07) -- [ ] 01-17-PLAN.md — FIPS/Tor pills pinned against removal and readable at phone widths (UIFIX-01) - -**Wave 9** *(blocked on Wave 8 completion)* - -- [ ] 01-18-PLAN.md — Six-fix sign-off on archi-dev-box (UIFIX-01/02/03/04/05/06) - -**UI hint**: yes - -### Phase 2: UI Performance - -**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts -**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1) -**Requirements**: PERF-01, PERF-02, PERF-03 -**Success Criteria** (what must be TRUE): - - 1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land - 2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session - 3. Secondary screens open without a blocking full reload; repeat visits are instant - 4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device - -**Plans**: 11/11 plans executed - -Plans: -**Wave 1** - -- [x] 02-01-PLAN.md — Profile every D-09 surface on archi-dev-box and commit the findings doc (PERF-01) - -**Wave 2** *(blocked on Wave 1 completion)* - -- [x] 02-02-PLAN.md — TRACER: KeepAlive host, hook reactivation, app-store tab, refresh indicator (PERF-02) -- [x] 02-03-PLAN.md — Secondary screens: per-item cache, parallel loads, purge on logout (PERF-03) - -**Wave 3** *(blocked on Wave 2 completion)* - -- [x] 02-04-PLAN.md — Keep every main tab alive safely: lifecycle audit + full registration (PERF-02) - -**Wave 4** *(blocked on Wave 3 completion)* - -- [x] 02-05-PLAN.md — Mesh: cache the six fetch groups, bound the D3 graph and Leaflet map (PERF-02) -- [x] 02-06-PLAN.md — Server and Home: cache the uncached fan-out, guarantee wallet freshness (PERF-02) -- [x] 02-07-PLAN.md — Chat/AIUI: stable embed URL + the two D-14 UX defaults (PERF-02) - -**Wave 5** *(blocked on Wave 4 completion)* - -- [x] 02-08-PLAN.md — Dev-pair deploy, on-device re-measure, D-11 pass bar (PERF-01/02/03) - -**Wave 6** *(gap closure — blocked on Wave 5 completion)* - -- [x] 02-09-PLAN.md — Server.vue KeepAlive remount: name the cause, fix it, pin it (PERF-02) - -**Wave 7** *(gap closure — blocked on Wave 6 completion)* - -- [x] 02-10-PLAN.md — Timing-regression verdict: three-way re-measure, clear or name each surface (PERF-02/03) - -**Wave 8** *(gap closure — blocked on Wave 7 completion)* - -- [x] 02-11-PLAN.md — Profile the real cause of the six confirmed regressions, fix what's fixable, re-measure (PERF-02/03) - -**UI hint**: yes - -### Phase 3: Multinode Verification Pass - -**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion -**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide) -**Requirements**: MNODE-01, MNODE-02, MNODE-03 -**Success Criteria** (what must be TRUE): - - 1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node - 2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang - 3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed - 4. The on-air mesh suite passes between two radio-equipped nodes over real RF - -**Plans**: TBD - -### Phase 4: Lifecycle Perfection & Quadlet Default - -**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery -**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean) -**Requirements**: LIFE-01, LIFE-02, LIFE-03, LIFE-04, LIFE-05 -**Success Criteria** (what must be TRUE): - - 1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild - 2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention - 3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible - 4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier - 5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes - -**Plans**: TBD -**UI hint**: yes - -### Phase 5: Registry-Distributed Manifests - -**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change -**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel) -**Requirements**: REG-01, REG-02 -**Success Criteria** (what must be TRUE): - - 1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps) - 2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest - 3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet - -**Plans**: TBD - -### Phase 6: Manifest Security Enforcement - -**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime -**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through) -**Requirements**: SEC-01, SEC-02 -**Success Criteria** (what must be TRUE): - - 1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation - 2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail - 3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored - 4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides) - -**Plans**: TBD - -### Phase 7: Developer Tooling CLI - -**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals -**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set) -**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04 -**Success Criteria** (what must be TRUE): - - 1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install - 2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node - 3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI - 4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required - -**Plans**: TBD - -### Phase 8: Decentralized Marketplace - -**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes -**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6) -**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04 -**Success Criteria** (what must be TRUE): - - 1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling - 2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring - 3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed - 4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end - -**Plans**: TBD -**UI hint**: yes - -## Progress - -**Execution Order:** -Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 -(Phases 1 and 2 are independent and may be worked in parallel.) - -| Phase | Plans Complete | Status | Completed | -|-------|----------------|--------|-----------| -| 1. Federation & Mesh Hardening | 11/20 | In Progress| | -| 2. UI Performance | 11/12 | Complete | 2026-07-31 | -| 3. Multinode Verification Pass | 0/TBD | Not started | - | -| 4. Lifecycle Perfection & Quadlet Default | 0/TBD | Not started | - | -| 5. Registry-Distributed Manifests | 0/TBD | Not started | - | -| 6. Manifest Security Enforcement | 0/TBD | Not started | - | -| 7. Developer Tooling CLI | 0/TBD | Not started | - | -| 8. Decentralized Marketplace | 0/TBD | Not started | - | -| 9. BotFights Platform Upgrade | 7/7 | Executed — awaiting human demo verification | 2026-07-31 | -| 10. Key-Material Hardening | 0/5 | Planned — **priority override, see phase note** | - | -| 11. Wallet Experience & LND UI Parity | 0/TBD | Not started — gated on 10-05's watch-only verdict | - | - -### Phase 9: BotFights Platform Upgrade - -**Goal:** BotFights (app + registry) works great on every node: users sign in with the native nostr signer, a single self-contained AI prompt sets up their bot (replacing the confusing docs page), and every node's instance talks to a shared public match endpoint on VPS2 so all fighters are visible and battle across all nodes. -**Requirements**: BOT-01 native nostr signer login; BOT-02 unified AI bot-setup prompt (one copy-paste prompt, no doc-hopping); BOT-03 public shared match/fighter endpoint hosted on VPS2, node instances federate to it by default; BOT-04 registry/manifest + signed catalog updated and republished for the new version -**Depends on:** Nothing (independent app work; parallelizable with Phases 1–8) -**Plans:** 7 plans - -Plans: - -- [x] 09-01-PLAN.md — Arena reverse-proxy tracer: node instances become thin clients of one shared arena (BOT-03) -- [x] 09-02-PLAN.md — Finish native nostr signer login: JWT-only GET /api/auth/me, bare-pubkey path retired (BOT-01) -- [x] 09-03-PLAN.md — One self-contained AI bot-setup prompt served at /api/docs/prompt (BOT-02) -- [x] 09-04-PLAN.md — Canonical public arena on VPS2 + DNS/TLS via nginx-proxy-manager (BOT-03) -- [x] 09-05-PLAN.md — Build+push botfights:1.2.0, roll the arena, prove cross-instance visibility (BOT-03/BOT-04) -- [x] 09-06-PLAN.md — Manifest 1.2.0 with generated JWT secret + signed catalog republished (BOT-04) -- [x] 09-07-PLAN.md — archi-dev-box deploy + demo rehearsal: real signer login, cloud bot from the prompt (BOT-01/02/03/04) - -### Phase 10: Key-Material Hardening - -**Goal:** Every path that creates, restores, or persists node key material proves the caller is authorized and the material is per-node — closing the three exploitable findings from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`. A node that is already onboarded must refuse to have its identity replaced; a node flashed from the shared rootfs must never share another node's host keys; and the wallet spending key must not exist in cleartext outside the encrypted envelope. -**Requirements**: KEY-01 (F-01, **Critical**) `seed.generate`/`seed.restore` are unauthenticated (`api/rpc/middleware.rs:25`) and `NodeIdentity::from_seed` (`identity.rs:79`) overwrites `node_key`/`nostr_secret`/FIPS key unconditionally — one unauthenticated POST with an attacker-chosen mnemonic hijacks a live node; gate on onboarding-incomplete (the unused `identity.rs:117` `key_exists` guard) + rate-limit; KEY-02 (F-03, **High**) first-boot per-device secret regeneration is fail-open and its completion marker is set even on failure (`image-recipe/_archived/build-auto-installer-iso.sh:1647,:1659,:1663`), over a fleet-shared cached rootfs that bakes SSH host keys + the TLS key — make it fail-closed and retried; KEY-03 (F-13, **High**) the BIP-84 account **private** key is imported into Bitcoin Core's wallet (`api/rpc/bitcoin.rs:203,:229-231`), duplicating the spending key outside the encrypted envelope — move to watch-only descriptors per `docs/security/PSBT-SIGNING-ARCHITECTURE.md`; KEY-04 on-node verification of C-3/C-4/C-6 from the audit's UNVERIFIED checklist (host-key uniqueness across two real nodes, rootfs tar contents on the build host, unauthenticated LAN reachability of the RPC endpoint); KEY-05 (F-10a, **Medium**, added 2026-08-02) **a defaulted RNG cannot be inherited anywhere in the crate**. The audit's F-10 recorded this as 2 call sites; it is **41 raw matches across 15 files** (`session.rs` 16 → 4 prod + 12 test, `pine_ha.rs` 6, `wallet/bdhke.rs` 4 → 2 prod — *Cashu proof secret + blinding factor, genuine key material*, `storage_crypto.rs` 1 — *AEAD nonce*, `mesh/x3dh.rs` 2 — *prekey identifiers, **not** key material, corrected 2026-08-02*, +10 more; full table in the audit's §F-10a. Per-site prod/test classification is KEY-05's Task 1, not an assumption). Nothing is broken today — `rand::random()`/`thread_rng()` are ChaCha12 seeded from `getrandom(2)` — but this is the exact T1 structural shape that produced the 2026-07-30 COLDCARD defect, now with key material in its blast radius. Five layers, all required: (a) **sealed allowlist trait** at key-generation seams (private supertrait, so no other module *or crate* can implement it; exactly one production impl, `OsRng`) — this also retires the `impl rand::CryptoRng for CountingRng` false promise at `seed.rs:656`; (b) **`clippy.toml` `disallowed-methods`** banning `rand::thread_rng`/`rand::random` crate-wide, so enforcement is a compile failure in CI rather than a review convention (no `clippy.toml` exists today; CI already runs clippy); (c) **`cargo-deny`** failing on duplicate `rand` majors — two coexist today, which is the mechanism by which a bump could silently rebind (absorbs R-05); (d) **degenerate-entropy runtime check** before key generation (rejects all-zero / counter-like draws — the one layer that would catch the Coldcard failure *on the device* rather than in review); (e) **persist the CSPRNG-readiness verdict** that `seed.rs:59` already computes and discards, so a node can answer after the fact "was the pool seeded when this key was born?" (absorbs R-09). Supersedes R-13 -**Depends on:** Nothing (independent security work; parallelizable with Phases 1–8). **Priority override: F-01 is Critical and live on every fleet node — this phase should be planned and executed ahead of its numeric position, which reflects append order in a shared roadmap, not sequencing.** -**Plans:** 6 plans - -> **EXECUTION GATE (user instruction, 2026-08-02):** do **not** begin executing this phase until -> (a) the concurrent agent working Phase 1 has finished, and (b) their changes are synced and -> accounted for. Rationale: Phase 10 edits `middleware.rs`, `identity.rs`, `seed_rpc.rs`, -> `bitcoin.rs` and — under KEY-05 — ~15 further files across the same crate that agent is -> actively committing to. Verify a clean tree and a fetched `gitea-ai/main` before starting. -> -> **KEY-05 is planned** as `10-06` (added 2026-08-02). The other 5 plans predate KEY-05 and -> are unchanged by it. `10-06` is wave 2 because it shares `seed.rs` with `10-05` and -> `api/rpc/auth.rs` with `10-01`; see its ``. - -Plans: - -**Wave 1** *(parallel — no shared files)* - -- [ ] 10-01-PLAN.md — Identity-mutating unauthenticated RPCs hard-refuse on a provisioned node, with the byte-identity regression suite (KEY-01) -- [ ] 10-03-PLAN.md — First-boot secret regeneration retries then fails closed, and the rootfs tar ships identity-free (KEY-02/KEY-04 C-4) -- [ ] 10-05-PLAN.md — Delete the Bitcoin Core xprv-import path; make LND's PSBT round trip first-class, tested and honestly documented (KEY-03) - -**Wave 2** *(each blocked on its wave-1 sibling)* - -- [ ] 10-02-PLAN.md — On-node C-6 exposure measurement, live refusal proof, and the fresh-node onboarding non-regression (KEY-01/KEY-04) — depends on 10-01 -- [ ] 10-04-PLAN.md — Fleet detection of image-baked host secrets, guarded one-time rotation, and C-3 two-node verification (KEY-02/KEY-04) — depends on 10-03 -- [ ] 10-06-PLAN.md — A defaulted RNG cannot be inherited anywhere in the crate: sealed allowlist, clippy ban, cargo-deny, degenerate-entropy check, persisted CSPRNG verdict (KEY-05) — depends on 10-01 and 10-05 - -### Phase 11: Wallet Experience & LND UI Parity - -**Goal:** The wallet is something a user chooses and understands, not something that just appears. A first-run wallet screen lets them pick a wallet type and route accordingly; seed handling reuses the SeedQR + seed-words patterns already shipped; and the day-to-day Lightning interface offers what umbrelOS's LND UI offers, so nothing is missing for someone arriving from Umbrel. -**Requirements**: WALLET-01 first-run wallet-type chooser (an intro/initial screen presenting the available wallet types with plain-language trade-offs, routing into the matching setup flow) — the available types depend on Phase 10's `10-05` watch-only verdict, so this requirement is **gated on that evidence**, not on assumption; WALLET-02 seed handling in the wallet flow reuses the existing SeedQR + seed-words components rather than reimplementing them (`neode-ui/src/utils/seedqr.ts`, `OnboardingSeedGenerate.vue`, `SeedRevealPanel.vue`, `WalletScanModal.vue`) — including the standing constraint that the LND aezeed is text-only by design and has no SeedQR; WALLET-03 evidence-based umbrelOS LND UI parity — produce a feature-by-feature comparison matrix from the actual Umbrel interface (researched, not assumed), classify each row as already-shipped / gap / deliberately-not-wanted, and close the gaps worth closing; WALLET-04 the resulting interface is house-style (Teleport-to-body modals, existing design system) and verified on the :8100 dev preview against archi-dev before any deploy; WALLET-05 **the PSBT air-gap round trip is a real, usable flow** — the standard two-scan dance (node displays the unsigned PSBT as an animated QR → offline signer scans and signs → signer displays the signed PSBT → node scans it back with the camera → finalize + broadcast). Three sub-gaps, all verified 2026-08-01: (a) **no UI exists** — `lnd.create-psbt`/`lnd.finalize-psbt` and their `rpc-client.ts:417` wrappers are called by nothing but unit tests; (b) **no animated-QR encoder** — `qrcode`/`qrloop` are dependencies and `useAnimatedQRDecoder.ts` + `WalletScanModal.vue` already handle the *inbound* scan, but nothing encodes a PSBT for display; (c) **format interop is wrong for real signers** — the animated format in use is `qrloop` (Ledger's), while Passport/SeedSigner speak **BC-UR** (`ur:crypto-psbt`) and Coldcard Q speaks **BBQr**; BC-UR is the priority given the existing Passport-Prime-compatible SeedQR work. **WALLET-05 is meaningless until 10-05's watch-only verdict lands** — `lnd.create-psbt` funds from LND's own wallet whose keys LND holds, so until LND is watch-only against the external signer the offline device would produce a signature the node does not need -**Depends on:** **Phase 10** — specifically `10-05`, which produces the evidence-backed verdict on whether LND can be provisioned watch-only against an external signer. WALLET-01's list of offerable wallet types is a direct consequence of that verdict; building the chooser first would mean guessing at what it can offer. `10-05` also deletes the dead Core wallet path, so this phase never has to represent it in the UI. -**Plans:** 0 plans - -**Already shipped — do not rebuild (verified 2026-08-01):** `LightningChannelsPanel.vue`, `SendBitcoinModal.vue`, `ReceiveBitcoinModal.vue`, `WalletScanModal.vue`, `WalletSettingsModal.vue`, `SeedRevealPanel.vue`, `LndSeedBackupPrompt.vue`, `utils/seedqr.ts`, and the channels All/Active/Pending/Closed tabs. The parity matrix (WALLET-03) must start from this inventory so the phase closes real gaps instead of re-implementing existing surfaces. - -Plans: - -- [ ] TBD (run /gsd-plan-phase 11 to break down) - -### Phase 12: Bitcoin Node Settings & Core/Knots Parity - -**Goal:** The Bitcoin node's configuration is something the operator chooses in the UI, not something baked into three shell scripts. Every option umbrelOS surfaces for its Bitcoin app is reachable, the options that exist **only** on Knots are surfaced separately from the ones Core shares, and the node's network mode is a first-class setting whose **default is Tor, not clearnet**. - -**Requirements**: BTCSET-01 **a single source of truth for bitcoind arguments** — today they are hardcoded and duplicated across `scripts/first-boot-containers.sh:666`, `scripts/container-specs.sh:193-202` and `apps/bitcoin-knots/manifest.yml:43`, which is the exact triplication that produced the lnd-ui bridge/host defect (`HTTP 000`, found 2026-08-02); a persisted settings model must replace it, with those three call sites rendering FROM it rather than restating it; BTCSET-02 **network mode is a setting, defaulting to Tor** — Tor / clearnet / both, wired to the archy-net SOCKS listener shipped in `f0494193` via `-onion=:9050` (onion-only) or `-proxy=` (everything), with the operator's 2026-08-02 choice of onion-only as the shipped default for the "both" mode; **inbound onion is out of scope and must be stated as such in the UI** — it needs Tor's ControlPort, deliberately disabled for security, so the node can reach .onion peers but stays unlisted; BTCSET-03 **Core options surfaced** (prune, dbcache, txindex, maxconnections, maxmempool, mempoolexpiry, persistmempool, blocksonly, peerbloomfilters, blockfilterindex, and the rest of the umbrelOS set, researched from `getumbrel/umbrel-bitcoin` rather than assumed); BTCSET-04 **Knots-only options surfaced separately and gated to Knots** (`datacarrier`, `datacarriersize`, `permitbaremultisig`, `rejectparasites`, `maxscriptsize`, the spam-filter family) — offering a Knots-only flag on Core would produce a node that refuses to start, so the gate is a correctness requirement, not a cosmetic one; BTCSET-05 house-style UI verified on the `:8100` dev preview against archi-dev before any deploy, mobile included. - -**The hazard this phase must not get wrong:** several of these options are **not freely reversible**. Turning `txindex` on forces a full reindex; turning `prune` on is destructive to block data and cannot be undone without a full resync; lowering `prune` below what is already pruned is meaningless. Any setting in that class must be labelled, confirmed, and — where it implies hours of resync on a node that is somebody's wallet backend — refused or gated rather than silently applied. Changing any option at all requires a bitcoind restart, which interrupts LND, electrs and the fedimint gateways that depend on it. - -**Depends on:** `f0494193` (the archy-net SOCKS listener) for BTCSET-02's Tor path to exist at all. Independent of Phases 1–11 otherwise. - -**Plans:** 0 plans - -Plans: - -- [ ] TBD (run /gsd-plan-phase 12 to break down) - -### Phase 13: AIUI — Conversational Node Control & Content Surfaces - -**Goal:** AIUI stops being a beautiful shell and becomes the node's conversational front door. Today it is embedded in `neode-ui/src/views/Chat.vue` as an iframe, its D-14 embed defaults are honoured, and its surfaces are designed — but the chat cannot *do* anything to the node, and the content views are not wired to real data. This phase makes it functional in three directions at once: (1) **ask the node in human language and have it act** — the capability Pine already demonstrates through voice becomes reachable from typed chat; (2) **talk to the system's settings** conversationally instead of hunting through screens; (3) **surface the node's content beautifully** — peer files, music, IndeeHub movies, owned/paid content — in the design AIUI already has but does not yet fill. - -**Requirements**: AIUI-01, AIUI-02, AIUI-03, AIUI-04, AIUI-05, AIUI-06 - -**Requirement detail**: - -- **AIUI-01 — human-language node control.** A typed request in AIUI chat ("restart bitcoin", "how much space is left", "who's connected") reaches a real node action and returns a real result. The Pine stack (`core/archipelago/src/api/rpc/pine_status.rs`, `.../package/pine_ha.rs`, the wyoming/Home-Assistant voice pipeline) already proves the intent→action path exists for voice; this requirement is about exposing that capability over a **permissioned tool-calling bridge** the browser can reach — not about handing the chat raw RPC. Whether a text entry point exists today or must be built is the first thing the phase research must settle. -- **AIUI-02 — conversational settings.** The system settings surfaced across neode-ui become reachable by conversation, scoped to what the user has granted. -- **AIUI-03 — content surfaces made real.** AIUI's designed-but-empty content views render live node data: **peer files** (the `/content`, `/content/`, `/api/peer-content//` subsystem and the `content.*` RPCs), **music** (today only a MIME branch and a hardcoded `Music` folder — there is no library domain, so scope must be honest about what "music" means here), **IndeeHub movies**, and owned/paid content. Playback must respect the existing rules: audio belongs to the global bottom-bar player, never the lightbox; media streams via Range requests, never base64 blobs. -- **AIUI-04 — sandboxed by construction, permissioned by the user.** *(see hazard below — this is the gating requirement, not a nice-to-have)* -- **AIUI-05 — delivery and build.** AIUI is a `*-ui` app outside the signed catalog; it reaches nodes on the frontend rsync, which is how the `/assets` 404 happened (fixed in `fbec7006`). A functional AIUI needs a delivery path an operator can actually receive updates through, and the `VITE_BASE_PATH=/aiui/` build requirement pinned so a hand-built bundle cannot ship a black page. -- **AIUI-06 — verified on device**, in the real embedded iframe on archi-dev-box, mobile included — not only in the local `dev:mock` loop. - -**The hazard this phase must not get wrong — an LLM is now touching a node that holds keys.** AIUI runs in the browser and talks to a model. The node holds wallet keys, LND macaroons, Fedimint credentials, node identity and per-app secrets, and Phase 10 is currently hardening exactly that material. So: **secrets never reach the browser or the model context** — the existing pattern where credentials stay server-side and the client gets a scoped token (`app.filebrowser-token`) is the model to follow, not an exception to it. The chat gets an **explicit, user-granted capability scope** — it can reach only what the user has allowed, defaults closed, and the grant is visible and revocable. **Destructive and identity-touching operations are confirmed by the human**, never executed on model say-so alone; the Phase-10 hard-refuse gates and the loopback/auth boundaries must hold with AIUI on the other side of them, not be widened to accommodate it. Prompt injection is in the threat model: peer-supplied content (filenames, descriptions, chat) will enter the model's context, so tool authority must not be derivable from anything a peer controls. Note also the known leak to resolve rather than propagate: `filebrowser-client.ts` puts a JWT in the media URL query string. - -**Depends on:** Independent of Phases 1–12 for its UI and content work. Its security model must not contradict Phase 10 (Key-Material Hardening) — coordinate rather than widen. AIUI's own source lives in a **separate repository** (`git.tx1138.com/lfg2025/AIUI`, branch `development`, cloned at `~/Projects/AIUI`), so this phase spans two repos and needs push access to both. - -**Plans:** 14/15 plans executed - -Plans: - -**Wave 1** *(tracer + the two independent security/spike tracks)* - -- [x] 13-01-PLAN.md — TRACER: typed AIUI chat reaches a real node tool and returns a real result (AIUI-01) -- [x] 13-02-PLAN.md — Close the live unauthenticated model proxies: session-gated Rust forwarder, port-3142 sidecar deleted (AIUI-04) -- [x] 13-03-PLAN.md — Routstr protocol spike + capability coverage matrix (AIUI-01) - -**Wave 2** - -- [x] 13-04-PLAN.md — Music library: one-way entity-model decision + lofty legitimacy gate + tag extraction (AIUI-03) -- [x] 13-05-PLAN.md — Curated tool registry, D-09 authority ceiling, D-16 default-closed grants, conversational settings (AIUI-01/02) -- [x] 13-06-PLAN.md — Content surfaces: ContentItem → Film/Song/Podcast adapter, AIUI grids fed from Archy (AIUI-03) - -**Wave 3** - -- [x] 13-07-PLAN.md — Music index + music.* RPCs + freshness (AIUI-03) -- [x] 13-08-PLAN.md — D-11 confirm gate: node-authored, nonce-bound, rendered in trusted chrome (AIUI-01/04) - -**Wave 4** - -- [x] 13-09-PLAN.md — AIUI delivery: enforced build, pinned commit, live-asset verify, iframe sandbox mechanism (AIUI-04/05) -- [x] 13-10-PLAN.md — D-04 chain: Ollama tool-calling + D-08 node-side history (AIUI-01) -- [x] 13-11-PLAN.md — SongGrid lit from the real library + the m4a/aac/opus/wma share-mime fix (AIUI-03) - -**Wave 5** - -- [x] 13-12-PLAN.md — D-10 untrusted-content boundary + cloud-egress guardrails + rate limiting (AIUI-04) - -**Wave 6** - -- [x] 13-13-PLAN.md — Routstr backend + D-05 hard budget ceiling (AIUI-01) - -**Wave 7** - -- [x] 13-14-PLAN.md — Eval harness: ScriptedBackend suite, EV-01..EV-18, cross-backend parity (AIUI-01/04) - -**Wave 8** - -- [ ] 13-15-PLAN.md — On-device sign-off: archi-dev-box, embedded iframe, desktop + mobile (AIUI-06) - -**Track note (D-13):** the music-library track (13-04 → 13-07 → 13-11) is independent — no plan -on the control or content track depends on any music plan, **and neither does the phase-closing -gate**. 13-15 depends on 13-06, 13-09 and 13-14 only, so there is no path from it to 13-04, -13-07 or 13-11: if the music track slips or is deferred, 13-15 records that at its step 7b and -the control and content work still closes and ships. 13-11 is therefore a terminal plan of the -phase rather than a gate on it. diff --git a/.planning/STATE.md b/.planning/STATE.md deleted file mode 100644 index 3baca6ec..00000000 --- a/.planning/STATE.md +++ /dev/null @@ -1,274 +0,0 @@ ---- -gsd_state_version: 1.0 -milestone: v1.8.0 -milestone_name: milestone -current_phase: 13 -current_phase_name: aiui-functional-conversational-node-control-and-content-surf -status: executing -stopped_at: "2026-08-07. READ .planning/RESUME-2026-08-07-aiui-demo.md FIRST — it carries the 17-item task list to rebuild in the session task tool, what shipped, and the findings. AIUI IS BEING DEMOED SOON + a clean ISO must be cut: prioritise #16 (surface stays empty while chat prints correct prose), #15 (surface header overlaps the close button; left heading shows the LAST search, should say Loading), #9 (surfaces not prose), #17 (ISO, UNBUNDLED=1 or it silently builds the wrong variant). DONE+DEPLOYED: gate no longer deletes apps Authorization header (broke every Nostr signer), gate no longer 401s credential-less PWA manifests, AI grants unified into ONE store (two existed for the same ten categories — that, not persistence, is why toggling Settings never helped the assistant), content-card description pairing, IndeeHub relay 502, content_list scopes + 2 RPCs, progressive content load, Mesh TDZ crash, peer-browse cap rebuilt as Cloud fan-out, cert section layout, LAN HTTPS (tailscaled owns :443 so nginx must bind LAN addrs explicitly). KEY FINDING: the 16 federated peers are NOT serving content — FIPS is healthy but 14 dials fail, so peers_reached 0 is CORRECT and no UI work fixes it; IndeeHub catalogue is genuinely empty. Phase 13: 14/15, only 13-15 left; operator verified checks 1, 3 and 4 — check 2 is the holdout because no film content exists here." -last_updated: "2026-08-07T00:00:00.000Z" -last_activity: 2026-08-06 -last_activity_desc: 13-14 complete (18-case adversarial eval harness EV-01..EV-18, ScriptedBackend-driven, offline/zero-footprint; E-02 confirmation-copy sign-off operator-approved with three verbatim dialog texts; E-09 naive-user comprehension study recorded as an open residual, not run) -progress: - total_phases: 13 - completed_phases: 2 - total_plans: 60 - completed_plans: 52 - percent: 15 ---- - -# Project State - -## Project Reference - -See: .planning/PROJECT.md (updated 2026-07-29) - -**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust. -**Current focus:** Phase 13 — aiui-functional-conversational-node-control-and-content-surf - -## Current Position - -Phase: 13 (aiui-functional-conversational-node-control-and-content-surf) — EXECUTING -Plan: 14 of 15 complete (13-01..13-14) — next: 13-15 -Status: Ready to execute -Last activity: 2026-08-06 — 13-14 complete (18-case adversarial eval harness EV-01..EV-18; E-02 confirmation-copy operator-approved; E-09 comprehension study recorded as an open residual) - -Progress: [█████████░] 87% - -## Performance Metrics - -**Velocity:** - -- Total plans completed: 11 -- Average duration: — -- Total execution time: — - -**By Phase:** - -| Phase | Plans | Total | Avg/Plan | -|-------|-------|-------|----------| -| 02 | 11 | - | - | -**Per-Plan Metrics:** - -| Plan | Duration | Tasks | Files | -|------|----------|-------|-------| -| Phase 02 P01 | 100min | 3 tasks | 5 files | -| Phase 02 P03 | 45min | 3 tasks | 5 files | -| Phase 02 P02 | 105min | 3 tasks | 11 files | -| Phase 02 P04 | 150min | 3 tasks | 12 files | -| Phase 02 P05 | 50min | 2 tasks | 8 files | -| Phase 02 P06 | 73min | 2 tasks | 7 files | -| Phase 02 P07 | 75min | 3 tasks | 5 files | -| Phase 02 P08 | ~190min | 3 tasks | 4 files | -| Phase 02 P09 | 130min | 3 tasks | 3 files | -| Phase 02 P10 | 55min | 2 tasks | 3 files | -| Phase 02 P11 | ~150min | 3 tasks | 8 files | -| Phase 01 P01 | n/a-continuation | 2 tasks | 1 files | -| Phase 13 P07 | ~3h45m | 2 tasks | 5 files | -| Phase 13 P08 | ~7h45m (elapsed, w/ session restart) | 3 tasks | 10 files | -| Phase 13 P10 | ~2h45m | 2 tasks | 10 files | -| Phase 13 P11 | 27min | 3 tasks | 12 files | -| Phase 13 P12 | ~4h35m (shared-box compute contention) | 3 tasks | 9 files | -| Phase 13 P13 | ~4h (shared-box compute contention, session crash-recovered mid-Task-3) | 3 tasks | 6 files | -| Phase 13 P14 | ~1h10m | 3 tasks | 4 files | - -## Accumulated Context - -### Roadmap Evolution - -- Phase 1 added (2026-07-29): Federation & Mesh Hardening — user-directed top priority (node removal/sync issues, mesh attachment parity incl. demo); prior phases shifted down -- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down -- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy -- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue -- Phase 9 added (2026-07-30): BotFights Platform Upgrade — native nostr signer login, unified AI bot-setup prompt replacing docs page, shared public match endpoint on VPS2 (all nodes see all fighters), registry/manifest update. Independent of Phases 1–8. -- Phase 13 added (2026-08-03): AIUI — Conversational Node Control & Content Surfaces. User-directed: AIUI is embedded and styled but non-functional — chat cannot act on the node, content surfaces are unwired. Scope is (a) Pine's human-language intent→action capability reachable from typed chat, (b) conversational settings, (c) peer files / music / IndeeHub movies / node content rendered live, (d) **a user-granted capability sandbox** keeping keys, secrets and identity material away from the browser and the model — the user called this out explicitly as non-negotiable. Spans two repos: this one and `git.tx1138.com/lfg2025/AIUI` (branch `development`, clone at `~/Projects/AIUI`). Appended, not inserted — numeric position is append order, not priority. -- Phase 10 added (2026-08-01): Key-Material Hardening — KEY-01/F-01 (Critical: unauthenticated `seed.generate`/`seed.restore` overwrite a live node's identity keys), KEY-02/F-03 (fail-open first-boot secret regeneration over a fleet-shared rootfs), KEY-03/F-13 (BIP-84 private key imported into Bitcoin Core), KEY-04 (on-node verification of the audit's UNVERIFIED checklist). Sourced from `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (quick task 260731-upz). Appended rather than inserted to avoid renumbering a roadmap with concurrent uncommitted edits — **numeric position is append order, not priority; F-01 is Critical and live on the fleet.** - -### Decisions - -- [Phase 13, D-19 (2026-08-03)]: **AIUI's source migrated into this repo at `aiui/`**, via `git subtree` with its full 230-commit history (`7ba3109b`, from AIUI `development` @ `e30ac1d`). Supersedes D-15's two-repo premise and voids D-18 entirely. Retires the recurring "AIUI commit stranded local-only" failure (windows 4 and 17) — `e30ac1d` came across in the import and is now pushed. D-17 (standalone mode) is unaffected: archy has no root `package.json`, so AIUI's pnpm/turbo workspace does not collide. Consequence: plans **13-06, 13-09, 13-11** still target `/home/archipelago/Projects/AIUI/` absolute paths and must be re-planned before wave 2; 13-09's `scripts/aiui.pin` deliverable is now meaningless for an in-repo directory. -- [Phase 13, wave 1]: Recovered from a broken-pipe interruption that left 13-01 and 13-02 executor work uncommitted in orphaned worktrees. Both were checkpointed verbatim before any agent touched them, then re-committed atomically per task by continuation executors (both chose `reset --soft` + recommit). 13-03 was already complete and was fast-forwarded in. -- [Phase 13, execution mode]: `parallelization: false` — this 4-core box thrashed (load 35-55, 15G/23G swap) running two worktree cargo builds concurrently alongside a live node (bitcoind/electrumx/lnd). Serial execution is strictly faster here. -- [Phase 13, 13-04 / D-13 one-way half (2026-08-04, operator)]: Music entity model recorded in 13-MUSIC-MODEL.md BEFORE any node indexes a library — hybrid-identity ((source, canonical path) row key + lazily-backfilled content-hash dedupe column), derived-albums (albums/artists computed at read time, never stored rows), index-format-json (data_dir/music/index.json, content_server.rs precedent), sources = both OwnLibrary and Peer. MUSIC_SCHEMA_VERSION starts at 1; an older binary treats a newer-versioned index as absent (log + empty in-memory index, never overwrite) until an explicit reindex. -- [Phase 13, 13-04 Task 2 (2026-08-04, operator)]: lofty 0.24.0 approved through the blocking-human package-legitimacy gate ([ASSUMED] in 13-RESEARCH.md's audit); dep tree reviewed, no networking crates. Media-root confinement in extract_tags is a parameter (media_roots), checked via canonicalize before any file open (T-13-20). - -Decisions are logged in PROJECT.md (10 locked ADRs in the `` block + milestone decisions table). Recent decisions affecting current work: - -- Milestone version = 1.8.0-alpha (decided 2026-07-08) -- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted) -- Workstream D (DHT distribution) deferred to v2 — design-only backlog -- Canonical manifest schema = `core/container/src/manifest.rs` (code wins over spec doc) -- [Phase ?]: Marketplace is the 02-02 tracer tab (worst-measured main tab, 2033ms revisit) matching the user's own 'often app store' complaint -- [Phase ?]: ContainerAppDetails.vue confirmed fully unreachable dead code (no importer, no route) — no serial-RPC-waterfall target exists in the measured D-09 surface set -- [Phase ?]: archi-dev-box UI password was unknown/undiscoverable from this environment — paused at a checkpoint:human-action rather than guessing or falling back to a mock baseline silently -- [Phase ?]: Purged the resource cache on logout via clearAll() + a generation guard, so no in-flight fetch from an ending session can repopulate memory or sessionStorage (T-02-02) -- [Phase ?]: AppDetails/MarketplaceAppDetails/OpenWrtGateway converted to per-item (or single-key) keyed useCachedResource; CloudFolder.vue's existing store-level cache left as-is (cloud.ts TTL gate is a follow-up, out of this plan's file scope) -- [Phase ?]: Wallet/send flow (SendBitcoinModal.vue) reported as an unplanned-item gap — named by findings as owned by 02-03 but not in files_modified; its cost is pure client-side remount, not a caching problem -- [Phase ?]: PERF-03 reverted to Pending in REQUIREMENTS.md after an initial mark-complete was premature — its own text requires real-node-hardware verification, which is 02-08's job (also declares PERF-03); 02-03 delivers the code-level portion only -- [Phase ?]: 02-02: DashboardRouterView final shape uses statically-named per-route KeepAlive wrapper components (dashboardViewWrappers.ts) with :include name-matching, restoring pre-restructure view-wrapper DOM/animations byte-for-byte after a checkpoint-caught regression -- [Phase ?]: 02-02: HARD RULE for rest of Phase 02 — perf work must be visually invisible; verify against the real dev preview before considering a checkpoint satisfied -- [Phase ?]: 02-02: app-catalog persist:true ttl 300000ms; bitcoin.prune-status persist:true ttl 30000ms — both explicit per T-02-01, no default relied on -- [Phase ?]: 02-02: PERF-02 reverted to Pending/In-Progress in REQUIREMENTS.md after an automated mark-complete run — PERF-02 also spans 02-04..02-07 (extending KeepAlive caching to every remaining main tab); this plan proves the architecture on the tracer tab only -- [Phase ?]: 02-04: KEEP_ALIVE_PATHS widened to every audited main tab (10 paths) derived from TAB_ORDER + /dashboard/discover; /dashboard/settings deliberately withheld — its child sections (SystemDangerZone reboot poll, several onMounted-only fetches) were never audited by this plan -- [Phase ?]: 02-04: onActivated is a documented no-op outside a KeepAlive boundary — every arm function now runs from both onMounted and onActivated (fresh-mount guards on Home/Web5/Mesh/Server avoid doubling first-load RPC cost); caught by CloudPeersRefresh.test.ts -- [Phase ?]: 02-04: useCachedResource.ts's onActivated no longer eagerly force-loads a never-fetched immediate:false resource, so tab-gated lazy data (Cloud.vue Paid Files/My Files) isn't force-loaded merely by its view entering the KeepAlive cache -- [Phase ?]: 02-04: AIUI blank-screen-and-loading symptom reported at Task 3 checkpoint diagnosed as pre-existing (local mock-backend dev mode sets VITE_AIUI_URL=http://localhost:5173 unconditionally with no AIUI repo checked out) — not a regression, left for 02-07 (Chat/AIUI) to address -- [Phase ?]: 02-05: mesh.refreshAll()/transport.fetchStatus() stay uncached at the store level (other callers need guaranteed-fresh reads); the useCachedResource wrapper around each lives in Mesh.vue instead, since Pinia's defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops -- [Phase ?]: 02-05: FLAGGED - RESEARCH.md's premise that Mesh.vue owns a D3 force simulation is incorrect for this codebase (verified via grep); only NetworkMap.vue/Federation.vue has one. Task 2's D3 truths are vacuously satisfied; only the real Leaflet map lifecycle (MeshMap.vue, added to scope) was implemented -- [Phase ?]: 02-05: per-group TTL/persist table - mesh.refresh-all/federation-nodes/self-onion/self-did/contacts all persist:false (identity payload); transport-status persists (aggregate, non-identity); reachability groups get 10s TTL, identity groups 300s -- [Phase ?]: 02-06: RESEARCH A3 settled — none of Server's seven load-group loaders consumes another's result; concurrent fan-out is correct as-is -- [Phase ?]: 02-06: Five of Server's seven groups were already on useCachedResource from a pre-phase legacy commit (ea254f63) with only composable defaults (30s TTL, persist:true) — this plan's work was explicit TTL/persist/dedup, not initial conversion; only loadDiskStatus was a genuinely uncached plain fetch -- [Phase ?]: 02-06: Home's wallet composite does NOT share a cache key with Web5.vue's web5.lnd-info — sharing would either corrupt Web5's typed entry.data or fail to close the sessionStorage gap since Web5.vue's own hook (out of scope) defaults persist:true -- [Phase ?]: 02-06: homeStatus.refresh() wrapped by useCachedResource at Home.vue (the view), not inside the homeStatus Pinia store — defineStore(id,setup) runs in a bare effectScope where onActivated() silently no-ops, same finding as 02-05's Mesh.vue -- [Phase ?]: 02-07: AIUI source located mid-plan at git.tx1138.com/lfg2025/AIUI (base branch development, not stale main); AIUI-side D-14 commit 900c0b9 initially local-only (anonymous push 403) then pushed/merged upstream onto development by the orchestrator using a user-supplied write token -- [Phase ?]: 02-07: D-14a fixed via new ?chatExpanded param overriding chat.ts's chatCollapsed default (never persisted to localStorage); D-14b fixed via new ?mobileChat param re-asserting ChatPage.vue's mobileTab='chat' once on mount, guarding against module-singleton content-panel state surviving an internal AIUI remount -- [Phase ?]: 02-07: PERF-02 marked Complete in REQUIREMENTS.md — 02-02 through 02-07 extended KeepAlive/useCachedResource to every main tab, each dev-preview-verified against archi-dev-box per D-11 -- [Phase ?]: 02-08: KEEP_ALIVE_MAX left at 6, now backed by an on-device memory reading (4 cycles, 11 tabs, JS heap fluctuating 10-21MB, no monotonic growth) rather than the FA-D estimate -- [Phase ?]: 02-08: archy-x250-dev offline for the entire plan (checked 3x); archi-dev-box (D-11's named target) is the only dev-pair node this phase reached -- [Phase ?]: 02-08: the harness's remount-probe field is confounded for main tabs once real KeepAlive keeps multiple instances alive simultaneously; corrected via an independent, reproduced-twice verification rather than editing the frozen 02-01 harness — revealed Server.vue genuinely does not survive a round-trip (open gap, not hidden) -- [Phase ?]: 02-08: a user-reported Cloud first-visit navigation regression was treated as release-blocking, not known-open, per explicit direction — root-caused to content.browse-peer's unbounded, untimed-enough per-peer RPC fan-out starving Chromium's connection pool; fixed via a concurrency cap + shortened timeout, verified 5/5, user-approved on-node -- [Phase ?]: 02-08: 4 other user-reported UX issues (Paid Files window.open, PiP not closing lightbox, missing loader on Paid Files item-open, PiP not surviving tab changes) classified as pre-existing (predate phase 2 via git history) and captured into UIFIX-04/05/06, not fixed -- [Phase ?]: 02-09: /dashboard/server's (and Web5's) 'genuinely remounts' reading was a proven probe-measurement artifact (generic .view-container selector can't disambiguate the foreground tab from other still-connected cached tabs) — confirmed via document.elementFromPoint() hit-test contradicting the naive verdict across device runs; no source change needed, pinned with vm.$.uid-based regression tests instead -- [Phase ?]: 02-09: committed neode-ui/e2e/perf/keepalive-remount-probe.spec.ts as a re-runnable, instrumented probe covering every KEEP_ALIVE_PATHS tab, replacing the ad-hoc 02-08 probe so this class of false positive cannot recur -- [Phase ?]: [Phase 2, gap closure 02-10]: Wallet/send-flow's timing regression cleared as environmental noise (re-measure at/below baseline); Discover/Server/Web5/AppDetails/OpenWrtGateway confirmed as real, phase-2-caused client-side render/reactivation regressions via 3-run dispersion + git bisection, recorded as accepted deviations (not fixed — deploy blocked mid-session by a shared-tree hazard with concurrent security-follow-up and BotFights sessions) -- [Phase 2, gap closure 02-11]: Real cause of the six regressions was NOT compute-bound render cost (CPU profile: 86-99% idle/program, <10% JS self-time everywhere) — it was three background pollers (useFleetData.ts 60s, FipsNetworkCard.vue 15s, Web5Monitoring.vue 30s) armed in onMounted and never disarmed once their owning views joined KEEP_ALIVE_PATHS in 02-04, invisible to that audit because it grepped the top-level view files, not the child composables they delegate to. Gated to onActivated/onDeactivated, mirroring 02-04's own established pattern. Fixed: web5 275ms (was 566ms baseline/1329ms regressed), server 574ms (was 738/1239), fleet 790ms (was 330/2631) -- [Phase 2, gap closure 02-11]: Discover (1389ms, worst remaining) has a SECOND, distinct cause: card-stagger/showStagger entrance-animation classes are baked into the DOM at first mount and never programmatically removed, so every KeepAlive detach/reattach cycle restarts the CSS animation on reactivation — replaying the full entrance cascade on every revisit. Confirmed via a diagnostic (DOM card count doubling transiently on every revisit) and an extended animation-event log. NOT fixed — blast radius spans 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards), needs its own real-device verification budget; recommended as a dedicated follow-up -- [Phase 2, gap closure 02-11]: openwrt-gateway unmeasurable in the final re-measure (Chromium "Target crashed" cascading from an unrelated surface, cloud-folder, earlier in the same harness run) — recorded as not-measurable, not written in as data. Separately confirmed the prior baseline/after/remeasure numbers were measuring a real, substantive disconnected-state UI (OpenWrtGateway.vue's h1 is unconditional; a "No router configured" RPC error deterministically renders a real Connect-to-Router form, not a blank/error page) — the six-surface regression count is not retracted, but the numbers reflect one specific code branch (no OpenWrt device has ever been connected to archi-dev-box) -- [Phase ?]: 01-01: record_peer_transport and update_node routed through FEDERATION_STORE_LOCK via *_inner; tombstone-write-failure test added; full-suite verify blocked by a concurrent agent's uncommitted install.rs edit (unrelated file, not fixed per scope boundary) -- [Phase ?]: UIFIX-02: connected-nodes card height tracks row sibling via xl:flex-1 xl:basis-0 (zero-basis flex-grow) instead of flex-auto, with an xl:min-h-[40rem] floor for a short sibling (discovery disabled), tuned from an initial 20rem guess per Dorian's live feedback -- [Phase ?]: 13-07: media_roots = filebrowser/Music + purchased-content (both D-13 sources as local roots); music.reindex incremental:true wires refresh_incremental to a production caller; one comparator everywhere with TrackId (source,path) tiebreak -- [Phase ?]: [Phase 13, 13-08]: System prompt rewritten to make the model call destructive tools directly rather than text-asking for confirmation — the original wording ('every write requires a human confirmation') read as 'collect consent in text first', which skipped the tool call and dropped the user's typed 'confirmed' into a void on the next stateless turn -- [Phase ?]: [Phase 13, 13-08]: CONFIRM_TIMEOUT raised 120s->300s and the full HTTP timeout chain (rpcClient, assistant.chat 420s, AIUI bridge 180s->430s) raised past it, so transport can no longer time out a human reading the confirm dialog before the confirm gate itself does -- [Phase ?]: [Phase 13, 13-08]: Declined actions remembered per-turn in ToolExecCtx, keyed by the same action_key the approval nonce binds; execute_tool refuses a re-ask for that exact action before the gate reopens, closing a retry loop where a declined action kept re-prompting (T-13-50 mechanized) -- [Phase ?]: [Phase 13, 13-10]: OllamaBackend leads the D-04 chain via POST /api/chat (never assist.rs's /api/generate); model_supports_tools() probes /api/show and process-caches the answer, turning AI-SPEC's qwen2.5-coder [ASSUMED] tool-capability note into a runtime fact; FallbackChain falls through to Claude on a transport error mid-turn, not just at initial selection -- [Phase ?]: [Phase 13, 13-10]: history.rs persists the full ChatMessage transcript per CallerScope-derived HistoryKey under data_dir, atomic (temp+rename) and 0600, with wallet/files-category tool-call arguments redacted before disk (verified against raw bytes, not just the struct); chat() persists but does not yet feed prior turns back into live model context (deliberately scoped out, needs Claude tool_use/tool_result id-pairing test budget as a follow-up) -- [Phase ?]: [Phase 13, 13-10]: detect_ollama() and its two containing modules bumped to pub(crate) (api/rpc/mod.rs, api/rpc/mesh/mod.rs, api/rpc/mesh/assistant.rs) so assistant::backends could reuse the existing Ollama probe rather than writing a second one; run_loop (loop_.rs) now returns (answer, full_history) instead of just the answer, both Rule-3 deviations structurally required by the plan's own stated intent -- [Phase ?]: 13-11: kind:'library' content:request routes to music.list-tracks (not content.*) via a new contextBroker.ts fetchLibraryContent branch — Rule 2 deviation, contextBroker.ts's diff-clean acceptance criterion could not hold alongside genuine music.* wiring (ContentItem has no artist/album/duration field at all) -- [Phase ?]: 13-11: closed the plan's GAP-FOUND must_have — useArchy.ts's init() now fires requestArchyContent + requestArchyLibrary automatically as a live init-time event, and useContentPanel.ts's setArchyContent opens the panel/sets tabs for non-empty content, instead of leaving the fetch merely callable with nothing in the UI ever invoking it (13-06's own documented Known Limitation) -- [Phase ?]: 13-12: wrap_untrusted's per-call token is drawn fresh from rand on every call (never a module constant) — a forged closing boundary using a guessed/fixed token can never match, defeating EV-11 by construction; no pattern-stripping filter added anywhere in assistant/ (D-10 rejects that approach by name) -- [Phase ?]: 13-12: assistant.chat's G-B3 rate limit is keyed by authenticated SESSION id (not client IP) via a new session_requests map on the existing EndpointRateLimiter — 13-AI-SPEC §6 is explicit that per-session, not per-IP, is the guardrail's own spec -- [Phase ?]: 13-12: screen_outbound (G-B1/G-B2) wired into backends/claude.rs's send() and the assistant.chat rate limit wired into api/rpc/assistant_chat.rs — both Rule 3 deviations outside their task's declared file list, since the plan's own stated behavior (run on the Claude leg / rate-limited per session) had no real call site otherwise -- [Phase 13, 13-13 Task 1 (2026-08-05, operator via AskUserQuestion)]: Routstr decision = proceed-docs-with-probe-first (0/9 protocol claims independently confirmed per 13-ROUTSTR-FINDINGS.md — no live provider was reachable during the 13-03 spike). Implemented against docs.routstr.com's cited shape (kind 38421, `Authorization: Bearer cashuA…`, OpenAI-shape chat completions); the first live HTTP call to any provider doubles as the capability probe and fails loudly (real status/body, or "no choices array") on any wrong guess rather than silently misbehaving. D-04's chain is now complete: Ollama -> Claude -> Routstr -- [Phase 13, 13-13]: D-05's budget ceiling (`AssistantBudget`) is computed ONLY from persisted allowance_sats/spent_sats — never from anything model/tool/provider-influenced; `BudgetExhausted` (typed, anyhow-downcastable) stops `run_loop` with a plain-language message, no retry/re-price/partial-spend/fallthrough. Verified load-bearing by fault injection: temporarily replacing the terminating `return` with `continue` made `zero_budget_stops_loop_without_retry` go red (8 retries to MAX_TURNS, generic error) before being restored -- [Phase 13, 13-13]: egress.rs's message_is_turn_own (13-12's G-B2 check) was Claude-shape-only and would have silently stripped Routstr's OpenAI-shape system prompt + tool results out of every outbound request — fixed with explicit "system"/"tool"-role handling (Rule 1 bug, found while wiring screen_outbound into routstr.rs) -- [Phase ?]: [Phase 13, 13-14]: Task 3's E-02 sign-off was conducted via the orchestrator driving real node RPCs, with the operator reviewing the captured dialog texts directly and approving them; E-09's naive-user timed-comprehension protocol was NOT run and is recorded as an open residual carried forward, not force-passed under a lowered bar. -- [Phase ?]: [Phase 13, 13-14]: In-crate #[cfg(test)] eval module (assistant::evals) used instead of a tests/ integration target, since core/archipelago is [[bin]]-only with no [lib] — cargo test --package archipelago assistant::evals:: is the invocation; release-binary string grep confirms zero shipped footprint. - -### Pending Todos - -- [blocker/ui] Keep FIPS/Tor pills on cloud files and show them on mobile (`.planning/todos/pending/2026-07-30-keep-fips-tor-pills-on-cloud-files-and-show-them-on-mobile.md`) -- [blocker/security] Fedimint gateway must not install with a pre-set password — tracked as FED-07 / Phase 1 gap plan (`.planning/todos/pending/2026-07-30-fedimint-gateway-must-not-install-with-preset-password.md`) -- [blocker/ui] Connected-nodes list must scroll at row-matched height, not grow to fit (`.planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md`) -- [blocker/ui] Onboarding tickbox hidden below fold on short screens — make it beautifully obvious (`.planning/todos/pending/2026-07-30-onboarding-tickbox-hidden-below-fold-on-short-screens.md`) -- [major/ui] Paid Files pictures open in browser tab, not the app lightbox — UIFIX-04 (`.planning/todos/pending/2026-07-30-peer-files-pictures-open-in-tab-not-lightbox.md`) -- [major/ui] PiP should close the lightbox with a fluid animation — UIFIX-05 (`.planning/todos/pending/2026-07-30-pip-should-close-lightbox-with-fluid-animation.md`) -- [major/ui] Missing loader states on slow opens — UIFIX-06 (`.planning/todos/pending/2026-07-30-missing-loader-states-on-slow-opens.md`) - -### Blockers/Concerns - -- [Phase 1] Federation tombstone fix touches trust code — fix carefully, re-verify with `tests/multinode/smoke.sh`, don't patch blind -- [Phase 3] Multinode gate on archy-x250-beta was launched 2026-07-01 (log on-node); verify outcome before re-running -- [Phase 5] Fleet registry flip awaits explicit user authorization + timing call -- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands -- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests) -- cloud.ts's navigate() needs a TTL gate to fully satisfy 'no new RPC within TTL' for CloudFolder.vue — currently always re-fetches on revisit (just doesn't block paint) -- [Phase 2, RESOLVED by 02-09] ~~Server.vue does not survive a tab round-trip despite KEEP_ALIVE_PATHS registration~~ — retracted: proven a probe-measurement artifact (shared generic `.view-container` selector couldn't disambiguate the foreground tab from other cached tabs), not a real defect. Server.vue's (and Web5.vue's) instance genuinely survives; pinned with `vm.$.uid`-based regression tests immune to the same ambiguity. Checkpoint approved on real hardware. -- [Phase 2, RESOLVED by 02-11] ~~Timing regressions on Discover/Web5/Fleet/AppDetails/OpenWrtGateway~~ — root cause found (three leaked background pollers, not compute-bound render cost) and fixed for web5/server/fleet, each proven with a real before/after number on archi-dev-box. AppDetails restored to at/near its own pre-phase-2 baseline (pre-existing per-mount cost, not a new defect). OpenWrtGateway not measurable this pass (browser crash); prior numbers stand with a data-integrity note (measuring a real disconnected-device UI, not an empty page). -- [Phase 2, follow-up needed] Discover (1389ms, worst remaining named surface) has a second, evidenced, phase-2-caused defect: KeepAlive'd entrance-stagger animations (`card-stagger`/`showStagger`) never get their class removed from the DOM after first play, so every reactivation replays the full CSS animation cascade. Fix requires touching 5+ files outside 02-11's scope (Apps.vue, Marketplace.vue, Home.vue, Web5Wallet.vue/Web5Identities.vue/Web5NodeVisibility.vue/Web5NostrRelays.vue) with its own real-device visual-regression verification budget (the same class of risk 02-02's original KeepAlive rollout hit on its first checkpoint attempt) — needs a dedicated follow-up plan, not squeezed into a gap-closure pass. - -### Quick Tasks Completed - -| # | Description | Date | Commit | Directory | -|---|-------------|------|--------|-----------| -| 260729-fw7 | improve mesh message hop graphic/animation: balanced desktop sizing, vertical mobile layout, archipelago branding | 2026-07-29 | ac09fc5d | [260729-fw7-improve-mesh-message-hop-graphic-animati](./quick/260729-fw7-improve-mesh-message-hop-graphic-animati/) | -| 260729-gjd | demo: indee.tx1138.com in app iframe (:2101 whole-origin proxy), auto nostr signer sign-in, IndeeHub pre-installed on fresh session | 2026-07-29 | d00ca624 | [260729-gjd-demo-make-indee-tx1138-com-work-in-the-a](./quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/) | -| 260729-hj1 | peer-files media batch: Wavlake paid tracks + purchases + dedupe + real photos (demo); lightbox/player open routing + free-image lightbox fix (both builds) | 2026-07-29 | f52c5407 | [260729-hj1-peer-files-media-batch-wavlake-paid-trac](./quick/260729-hj1-peer-files-media-batch-wavlake-paid-trac/) | -| 260729-je5 | connected-nodes list fills card height (constant footer gap); companion app skips demo intro | 2026-07-29 | d54517cf | [260729-je5-ui-fixes-connected-nodes-scrollable-list](./quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/) | - -## Deferred Items - -| Category | Item | Status | Deferred At | -|----------|------|--------|-------------| -| Distribution | DIST-01 DHT/iroh backbone (workstream D) | v2 | 2026-07-29 | -| Fleet | FLEET-01 Bitcoin multi-version fleet OTA (user-gated) | v2 | 2026-07-29 | -| Fleet | FLEET-02 per-app deep health assertions (~34 apps) | v2 | 2026-07-29 | -| Fleet | FLEET-03 LUKS2 data-partition encryption | v2 | 2026-07-29 | - -## Release SHIPPED — v1.7.120-alpha (2026-08-03) - -**LIVE.** signature PRESENT (did:key:z6Mkkid…q7ur), both assets HTTP 200 at exactly their -manifest byte counts, tag pushed. Two release-process traps hit and documented in memory: -create-release.sh commits the manifest BEFORE signing (fleet refuses unsigned), and -gitea-vps2 is the SAME server as gitea-ai (vps2 token is dead). - -### Staging record (kept for the evidence trail) - -Built from `4d67f56b` (release profile, 15m15s, exit 0), deployed to archi-dev-box, -`.bak` rollback at /opt/archipelago/rollback/archipelago.bak. - -Verified on the node: both security gates 401 unauthenticated from a non-loopback -address; CORS origin-scoped; AIUI assets 200 AFTER the frontend rsync (the deploy that -would have wiped a copied-file fix); mesh.lightning-peers/send-lightning-info answer -correctly; system.stats host_secrets = per-node; served bundle sha256-matches the build -on all three chunks; 31 containers up, none down, no restart loop. - -NOT verified, deliberately: the new torrc SocksPort/SocksPolicy block. regenerate_torrc -only fires on a Tor services change, so the live torrc still reads only `SocksPort 9050`. -Gateway detection was proven in isolation (10.89.0.1 10.89.0.0/24; missing network exits -non-zero -> stays loopback-only). The change is INERT this release since bitcoind has no --onion flag yet (Phase 12), so forcing a torrc regeneration would risk bouncing every -onion service for zero benefit. - -Frontend is a proven no-op this cycle — built chunks are byte-identical to those already -served — so a fleet node only changes binary + the two app-UI images + nginx. - -Remaining to ship: operator go/no-go, then `scripts/create-release.sh 1.7.120-alpha` -(stops at the signing prompt — reads the master mnemonic interactively, operator-only), -then publish-release-assets.sh to gitea-vps2, then push tags. CHANGELOG.md already -carries curated v1.7.120-alpha notes (create-release.sh hard-fails without them). -The 5x lifecycle gate was NOT run. - -## Session Continuity - -Last session: 2026-08-06 (resumed) -Stopped at: Session resumed 2026-08-06 via /gsd-resume-work. Tree clean, in sync with -gitea-ai @ 7bb09ffe. 13-14 COMPLETE (14/15), next 13-15 device-close — BLOCKED on four -operator browser checks (list in .planning/todos/pending/2026-08-06-open-operational-tasks.md, -which also carries the four non-phase node/infra tasks and the follow-on A/B/C proposal). -Awaiting operator choice between: (a) run the four checks and close 13-15/the phase, -(b) start follow-on Phase B (web-search setting derives the CSP, node-side), (c) take a -node/infra task (app-gate iframe login decision, Starting-vs-Unreachable, .228 frontend). -NOTE: .planning/HANDOFF.json + .planning/.continue-here.md are STALE (phase 09, 2026-08-02, -fully reconciled) and should not be read as live resume context. - -Prior (13-12) stop note, retained for history: Completed 13-12-PLAN.md (D-10 untrusted-content boundary, G-B1/G-B2 cloud-egress screen, G-B3 read-only-loop rate limit + owner notices). -`assistant::` tests incl. `approval_nonce_binds_to_exact_action` individually; dispatcher.rs -untouched; fc09d7a2's tools.rs/grants.rs/backends/mod.rs diffs confirmed rustfmt-only, no -behavior change). Task 2 (ToolConfirmModal.vue, contextBroker.ts, Chat.vue mount, -toolConfirm.test.ts) was already complete in fc09d7a2 — all 10 toolConfirm.test.ts cases pass, -pre-existing contextBroker/chatAiuiEmbed suites (28 tests) still green, vue-tsc clean, all -acceptance-criteria greps pass. fc09d7a2 stands as commit of record for both Task 1 and Task 2. -STOPPED at Task 3 (checkpoint:human-verify, gate=blocking) — on-device dialog verification on -archi-dev-box is required before 13-08-SUMMARY.md can be written. Resume: build+deploy per -Task 3's how-to-verify, then re-invoke the 13-08 executor with the operator's "approved" signal. -Resume file: None - -Open on this thread (all recorded as broken windows, none blocking): - -- Window 15 CLOSED 2026-08-02 20:02 — f6b5245b's reconcile path proven on archi-dev-box by - a controlled test: stale conf installed + container restarted (probe 200, genuinely - re-exposed), daemon started, reconcile repaired it unaided at 20:02:19 with the expected - warn line, probe 401, conf byte-identical to the known-good. Both halves now proven on - hardware. - -- Windows 11/12: host-secret rotation on three fleet nodes sharing SSH host keys — - detect-only so far; rotation is USER-GATED and deliberately not actioned. - -- Credential rotation DECIDED AGAINST 2026-08-02 (operator): no LND macaroon rotation, no - Bitcoin RPC password rotation — no evidence of exploitation and the vulnerability is - being closed rather than lived with. rotate-lnd-macaroon.sh stays as a tool, exercised in - detect mode only, never run against a node. Do not re-litigate; see - docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md. - -- Dev-pair verification is archi-dev-box ONLY, by operator instruction 2026-08-02. Do not - raise archy-x250-dev as a blocker again. diff --git a/.planning/WINDOWS.md b/.planning/WINDOWS.md deleted file mode 100644 index 28fa182c..00000000 --- a/.planning/WINDOWS.md +++ /dev/null @@ -1,269 +0,0 @@ ---- -schema_version: 1 -open_count: 14 -waived_count: 0 -fixed_count: 5 -total_count: 19 -last_updated: 2026-08-04T00:00:00.000Z ---- - -# Broken Windows Ledger - -> Cross-phase defect register. `/gsd-ship` blocks while `open_count > 0`. -> Waive with `gsd-tools windows waive ""` (reason required). -> Mark fixed with `gsd-tools windows fixed `. - -| id | phase | kind | file | line | description | status | reason | recorded_at | resolved_at | -|----|-------|------|------|------|-------------|--------|--------|-------------|-------------| -| 1 | 02 | deviation | neode-ui/src/stores/cloud.ts | | CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'. | open | | 2026-07-30T12:25:22.301Z | | -| 2 | 02 | deviation | neode-ui/src/views/Home.vue | | Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem. | open | | 2026-07-30T12:25:22.450Z | | -| 3 | 02 | deviation | neode-ui/src/views/PeerFiles.vue | | 02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan. | open | | 2026-07-30T12:25:22.605Z | | -| 4 | 02 | deviation | neode-ui/src/views/Chat.vue | | AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access. | fixed | | 2026-07-30T22:37:25.565Z | 2026-07-30T22:37:44.642Z | -| 5 | 09 | unrun-verify | botfight/e2e/signup-bot.spec.ts | | pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures. | open | | 2026-07-31T02:35:00.391Z | | -| 6 | 02 | deviation | neode-ui/src/views/Discover.vue | | Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.089Z | | -| 7 | 02 | deviation | neode-ui/src/views/Server.vue | | Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.305Z | | -| 8 | 02 | deviation | neode-ui/src/views/web5/Web5.vue | | Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.570Z | | -| 9 | 02 | deviation | neode-ui/src/views/AppDetails.vue | | AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.751Z | | -| 10 | 02 | deviation | neode-ui/src/views/server/OpenWrtGateway.vue | | OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session) | open | | 2026-07-31T10:56:26.933Z | | -| 11 | 10 | unrun-verify | docs/security/KEY-02-FLEET-ROTATION.md | | C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node. | open | | 2026-08-02T19:07:39.861Z | | -| 12 | 10 | unrun-verify | scripts/security/host-secrets-audit.sh | | Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose. | open | | 2026-08-02T19:07:40.217Z | | -| 13 | 10 | unrun-verify | core/archipelago/src/api/rpc/system/handlers.rs | | system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call. | fixed | | 2026-08-02T19:07:40.522Z | 2026-08-02T23:00:30.894Z | -| 14 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched. | fixed | | 2026-08-02T22:44:15.215Z | 2026-08-02T23:16:04.071Z | -| 15 | 10 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one. | fixed | | 2026-08-02T23:16:04.510Z | 2026-08-03T00:06:03.112Z | -| 16 | 13 | unrun-verify | core/archipelago/src/assistant/loop_.rs | | cargo test --package archipelago assistant:: (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session) never completed this session — killed twice under machine resource contention (load avg 35-46 on 4 cores, sibling worktree builds). cargo build --package archipelago DID complete clean (exit 0, only expected dead-code warnings). Test logic was read and reasoned correct but not independently executed — needs a follow-up cargo test run when the machine is free. RESOLVED 2026-08-04: all three tests observed passing (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session) once the lane was merged forward past 0de67ca6 and given a realistic timeout. The earlier failures were NOT a machine or code problem: they were the orchestrator's own `timeout 2400` firing SIGTERM on a cold debug build, misdiagnosed at the time as memory contention. | fixed | | 2026-08-03T18:49:48.842Z | | -| 17 | 13 | deviation | external:AIUI/packages/app/src/services/archyBridge.ts | | Task 3 (sendChat/streamViaArchy, external repo /home/archipelago/Projects/AIUI, branch development, commit e30ac1d) is committed locally but NOT pushed to git.tx1138.com — origin was unreachable this session (DNS resolves to 80.71.235.99 but TCP/TLS connect and even 'git ls-remote' timed out repeatedly, sandbox-disabled too). Needs a push from an environment with network access to git.tx1138.com before the fix lands upstream. | fixed | | 2026-08-03T18:50:07.659Z | | -| 18 | 13 | deploy-topology | core/archipelago/src/bootstrap.rs | | run_runtime_assets() in core/archipelago/src/bootstrap.rs reinstalls a SECOND on-node copy of the nginx template (/opt/archipelago/web-ui/archipelago-runtime/image-recipe/configs/nginx-archipelago.conf) over /etc/nginx/sites-available/archipelago on EVERY `systemctl restart archipelago`. Found 2026-08-03 on archy-x250-dev3 during 13-02 Task 3: a hand-patched nginx deploy was silently reverted within ~5 seconds of the daemon restart. Any nginx change that updates only /etc/nginx/ is therefore transient — both copies must be written. This is a live OTA hazard: an operator can deploy an nginx fix, see it applied, restart the daemon, and silently lose it with no error. | open | | 2026-08-03T19:05:00.000Z | | -| 19 | 13 | unrun-verify | core/archipelago/src/container/prod_orchestrator.rs | | 13-05's cargo test --package archipelago (assistant::) cannot be run: the whole test binary fails E0063 in prod_orchestrator.rs's #[cfg(test)] fn port() helper, missing fields auth/auth_rationale on archipelago_container::manifest::PortMapping. Introduced by 0c4826f8 (feat(security): declare which app ports may skip authentication), which added those fields without updating this unrelated test helper — not touched by 13-05 (assistant/tools.rs, grants.rs, mod.rs, assistant_chat.rs) and out of scope per the executor's deviation-rule SCOPE BOUNDARY. cargo check --package archipelago (non-test, real binary) passes clean. Needs a one-line fix to fn port() (add auth: Default::default(), auth_rationale: Default::default()) from whoever owns that file, then a full cargo test --package archipelago assistant:: run to actually verify 13-05's 13 new tests. RESOLVED 2026-08-04: root cause was lane staleness, not a defect. The lane merged main at 0c4826f8, one commit before 0de67ca6 added auth/auth_rationale to PortMapping's test constructors, so no test in the crate could compile. Merged main forward (7cc58b7a); `cargo test --package archipelago assistant::` now reports 21 passed / 0 failed, including registry_never_exposes_excluded_authority, settable_keys_never_include_claude_api_key and grant_revocation_takes_effect_next_turn. | fixed | | 2026-08-04T00:00:00.000Z | | - -````json -[ - { - "id": 19, - "kind": "unrun-verify", - "phase": "13", - "file": "core/archipelago/src/container/prod_orchestrator.rs", - "line": null, - "description": "13-05's cargo test --package archipelago (assistant::) cannot be run: the whole test binary fails E0063 in prod_orchestrator.rs's #[cfg(test)] fn port() helper, missing fields auth/auth_rationale on archipelago_container::manifest::PortMapping. Introduced by 0c4826f8 (feat(security): declare which app ports may skip authentication), which added those fields without updating this unrelated test helper — not touched by 13-05 (assistant/tools.rs, grants.rs, mod.rs, assistant_chat.rs) and out of scope per the executor's deviation-rule SCOPE BOUNDARY. cargo check --package archipelago (non-test, real binary) passes clean. Needs a one-line fix to fn port() (add auth: Default::default(), auth_rationale: Default::default()) from whoever owns that file, then a full cargo test --package archipelago assistant:: run to actually verify 13-05's 13 new tests.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-04T00:00:00.000Z", - "resolved_at": "2026-08-04T03:10:00.000Z" - }, - { - "id": 18, - "kind": "deploy-topology", - "phase": "13", - "file": "core/archipelago/src/bootstrap.rs", - "line": null, - "description": "run_runtime_assets() in core/archipelago/src/bootstrap.rs reinstalls a SECOND on-node copy of the nginx template (/opt/archipelago/web-ui/archipelago-runtime/image-recipe/configs/nginx-archipelago.conf) over /etc/nginx/sites-available/archipelago on EVERY `systemctl restart archipelago`. Found 2026-08-03 on archy-x250-dev3 during 13-02 Task 3: a hand-patched nginx deploy was silently reverted within ~5 seconds of the daemon restart. Any nginx change that updates only /etc/nginx/ is therefore transient \u2014 both copies must be written. This is a live OTA hazard: an operator can deploy an nginx fix, see it applied, restart the daemon, and silently lose it with no error.", - "status": "open", - "recorded_at": "2026-08-03T19:05:00.000Z", - "resolved_at": null - }, - { - "id": 1, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/stores/cloud.ts", - "line": null, - "description": "CloudFolder.vue's file listing cache lacks a TTL gate in cloudStore.navigate() — always re-issues the RPC on revisit (paints from cache instantly first, but still refetches unconditionally). Needs a TTL check added to navigate() to fully satisfy 'no new RPC within TTL'.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.301Z", - "resolved_at": null - }, - { - "id": 2, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Home.vue", - "line": null, - "description": "Wallet/send flow (SendBitcoinModal.vue via Home.vue) named by 02-FINDINGS.md as owned by 02-03 (worst-ranked revisit, 2607ms) but not in 02-03-PLAN.md's files_modified — reported as an unplanned-item gap, not converted. Cause is pure client-side remount cost (0 RPC), not a caching problem.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.450Z", - "resolved_at": null - }, - { - "id": 3, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/PeerFiles.vue", - "line": null, - "description": "02-03-PLAN.md assumed PeerFiles.vue already used useCachedResource; it actually uses the raw resources store directly (correctly per-item-keyed) with no TTL gate and the same loading/refreshing conflation bug fixed in OpenWrtGateway.vue this plan. Left untouched (out of files_modified scope) — candidate for the same fix in a future plan.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-30T12:25:22.605Z", - "resolved_at": null - }, - { - "id": 4, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Chat.vue", - "line": null, - "description": "AIUI-side D-14 commit (900c0b9, branch feat/d14-embed-defaults in local clone /home/archipelago/Projects/AIUI, based on development) is NOT pushed upstream to git.tx1138.com/lfg2025/AIUI — anonymous push returned 403 Forbidden. neode-ui's two new query params (chatExpanded, mobileChat) are inert no-ops against any currently-deployed AIUI build until a maintainer with push rights merges and it is rebuilt/redeployed. 02-08 (deploy) or the user must resolve push access.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-07-30T22:37:25.565Z", - "resolved_at": "2026-07-30T22:37:44.642Z" - }, - { - "id": 5, - "kind": "unrun-verify", - "phase": "09", - "file": "botfight/e2e/signup-bot.spec.ts", - "line": null, - "description": "pnpm test:e2e -- e2e/signup-bot.spec.ts not run: local backend dev port 9100 is occupied by the live archi-dev-box botfights container (podman, 42h uptime) needed for tomorrow's demo — could not free it to run a local dev server. Task-level automated verify (vue-tsc + grep sweep) passed; vitest server suite passed with only pre-existing unrelated flaky failures.", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T02:35:00.391Z", - "resolved_at": null - }, - { - "id": 6, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Discover.vue", - "line": null, - "description": "Discover revisit-ms regression (1083->1257->1453ms across 3 runs), confirmed phase-2-caused split-signal client-side render cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.089Z", - "resolved_at": null - }, - { - "id": 7, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/Server.vue", - "line": null, - "description": "Server revisit-ms regression (738->849->1239ms across 3 runs) despite confirmed instance survival and improved RPC count; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.305Z", - "resolved_at": null - }, - { - "id": 8, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/web5/Web5.vue", - "line": null, - "description": "Web5 revisit-ms regression (566->709->1329ms, zero overlap across 3 runs) despite confirmed instance survival; confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.570Z", - "resolved_at": null - }, - { - "id": 9, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/AppDetails.vue", - "line": null, - "description": "AppDetails revisit-ms regression (1204->1510->2668ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.751Z", - "resolved_at": null - }, - { - "id": 10, - "kind": "deviation", - "phase": "02", - "file": "neode-ui/src/views/server/OpenWrtGateway.vue", - "line": null, - "description": "OpenWrtGateway revisit-ms regression (663.5->1148->1460ms across 3 runs); confirmed phase-2-caused split-signal cost, not fixed (deploy blocked this session)", - "status": "open", - "reason": "", - "recorded_at": "2026-07-31T10:56:26.933Z", - "resolved_at": null - }, - { - "id": 11, - "kind": "unrun-verify", - "phase": "10", - "file": "docs/security/KEY-02-FLEET-ROTATION.md", - "line": null, - "description": "C-3 FAILED: archipelago-1, archy-x250-beta and archipelago share all three SSH host keys; the first two also share their TLS private key. Not rotated — needs an operator-driven --apply --yes per node.", - "status": "open", - "reason": "", - "recorded_at": "2026-08-02T19:07:39.861Z", - "resolved_at": null - }, - { - "id": 12, - "kind": "unrun-verify", - "phase": "10", - "file": "scripts/security/host-secrets-audit.sh", - "line": null, - "description": "Rotation never exercised on real hardware: that 'systemctl reload ssh' keeps the operator's own forked session alive is proven only by design, not by observation. Needs --apply --yes on one disposable node from a session the operator is willing to lose.", - "status": "open", - "reason": "", - "recorded_at": "2026-08-02T19:07:40.217Z", - "resolved_at": null - }, - { - "id": 13, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/api/rpc/system/handlers.rs", - "line": null, - "description": "system.stats host_secrets never observed on a real node — proven against the file contract in unit tests only. Needs a build carrying 10-04 deployed to the dev pair, then a system.stats call.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T19:07:40.522Z", - "resolved_at": "2026-08-02T23:00:30.894Z" - }, - { - "id": 14, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/container/prod_orchestrator.rs", - "line": null, - "description": "LIVE EXPOSURE on archi-dev-box: archy-bitcoin-ui (systemd/Quadlet-owned, user-uninstalled marker set) still serves unauthenticated POST /bitcoin-rpc/ on 0.0.0.0:8334 with Access-Control-Allow-Origin *, reaching Bitcoin Core RPC through a credential-injecting proxy. Verified live 2026-08-02 (returned a real block height with no cookies). Code fix committed f6b5245b but NOT deployed: closing it needs the new binary on the node plus an archy-bitcoin-ui restart. archy-electrs-ui is in the same uninstalled-but-running state (static UI only, no credential proxy). Operator-gated; no node touched.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T22:44:15.215Z", - "resolved_at": "2026-08-02T23:16:04.071Z" - }, - { - "id": 15, - "kind": "unrun-verify", - "phase": "10", - "file": "core/archipelago/src/container/prod_orchestrator.rs", - "line": null, - "description": "The f6b5245b reconcile fix is DEPLOYED on archi-dev-box (binary installed 19:06, running) but NEVER EXERCISED on hardware: the state it repairs (uninstall marker + Quadlet-running + stale config) stopped existing here at 18:36, when a separate rebuild of bitcoin-ui rendered the fixed conf and restarted the container. So :8334 returning 401 proves a05956c4's template, NOT the reconcile path that is supposed to deliver it. archy-electrs-ui still carries the marker+running shape and could exercise it, but has no rendered config to rewrite. Needs a node that still has a stale bitcoin-ui conf, or a deliberately re-staled one.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-02T23:16:04.510Z", - "resolved_at": "2026-08-03T00:06:03.112Z" - }, - { - "id": 16, - "kind": "unrun-verify", - "phase": "13", - "file": "core/archipelago/src/assistant/loop_.rs", - "line": null, - "description": "cargo test --package archipelago assistant:: (disk_status_tool_executes, unknown_tool_is_refused_not_ignored, assistant_methods_require_session) never completed this session — killed twice under machine resource contention (load avg 35-46 on 4 cores, sibling worktree builds). cargo build --package archipelago DID complete clean (exit 0, only expected dead-code warnings). Test logic was read and reasoned correct but not independently executed — needs a follow-up cargo test run when the machine is free.", - "status": "fixed", - "reason": "", - "recorded_at": "2026-08-03T18:49:48.842Z", - "resolved_at": "2026-08-04T03:10:00.000Z" - }, - { - "id": 17, - "kind": "deviation", - "phase": "13", - "file": "external:AIUI/packages/app/src/services/archyBridge.ts", - "line": null, - "description": "Task 3 (sendChat/streamViaArchy, external repo /home/archipelago/Projects/AIUI, branch development, commit e30ac1d) is committed locally but NOT pushed to git.tx1138.com — origin was unreachable this session (DNS resolves to 80.71.235.99 but TCP/TLS connect and even 'git ls-remote' timed out repeatedly, sandbox-disabled too). Needs a push from an environment with network access to git.tx1138.com before the fix lands upstream.", - "status": "fixed", - "resolution": "RESOLVED 2026-08-03 by the AIUI in-repo migration (D-19): AIUI's source was imported into this repo at aiui/ via git subtree with full history, carrying commit e30ac1d across. It is now committed and pushed as part of this repo, so the unreachable git.tx1138.com remote no longer gates it.", - "reason": "", - "recorded_at": "2026-08-03T18:50:07.659Z", - "resolved_at": "2026-08-03T19:20:00.000Z" - } -] -```` diff --git a/.planning/codebase/ARCHITECTURE.md b/.planning/codebase/ARCHITECTURE.md deleted file mode 100644 index 904c84b6..00000000 --- a/.planning/codebase/ARCHITECTURE.md +++ /dev/null @@ -1,333 +0,0 @@ - -# Architecture - -**Analysis Date:** 2026-07-29 - -## System Overview - -```text -┌────────────────────────────────────────────────────────────────┐ -│ Frontend Layer (Vue 3) │ -│ `neode-ui/src` (TypeScript + SPA) │ -│ Routes → Views → Components → Composables → RPC Client │ -└────────────────┬─────────────────────────────────────────────┘ - │ WebSocket + HTTP(S) - │ JSON-RPC 2.0 protocol - ▼ -┌────────────────────────────────────────────────────────────────┐ -│ HTTP Server Layer (Hyper) │ -│ `core/archipelago/src/server.rs` │ -│ TCP Listener → Hyper → Router → ApiHandler/RpcHandler │ -└────────────────┬─────────────────────────────────────────────┘ - │ - ┌──────────┴──────────┬──────────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌────────────┐ - │ WebSocket │ RPC │ │ Content │ - │ Handler │ Handler │ │ Proxy │ - │ (state sync) │ (methods)│ │ (app URIs) │ - └─────────┘ └──────────┘ └────────────┘ - │ │ - └──────────┬───────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ Service Layer (Async Tasks) │ - │ `core/archipelago/src/api/rpc/*` │ - │ │ - │ • auth, identity, secrets │ - │ • container orchestration │ - │ • bitcoin, lightning, wallet │ - │ • mesh, federation, FIPS │ - │ • content, backup, settings │ - └─────────────┬───────────────────────┘ - │ - ┌───────────┼───────────┬──────────────┐ - │ │ │ │ - ▼ ▼ ▼ ▼ - ┌─────────┐ ┌──────────┐ ┌────────┐ ┌──────────┐ - │Container│ │ State │ │BlobStore - │Orch. │ │Manager │ │ │ │Identity │ - │(Podman) │ │(Broadcast - │ │ │ channels)│ │ ContentClient Manager │ - └─────────┘ └──────────┘ └────────┘ └──────────┘ - │ │ │ │ - └───────────┼───────────┼─────────┘ - │ - ▼ - ┌─────────────────────────────────────┐ - │ Persistent Storage Layer │ - │ │ - │ • Data directory files (YAML/JSON) │ - │ • SQLite (session store) │ - │ • Blob store (content-addressed) │ - │ • Podman container state │ - │ • Secret vaults (encrypted) │ - └─────────────────────────────────────┘ -``` - -## Component Responsibilities - -| Component | Responsibility | File | -|-----------|----------------|------| -| **Server** | HTTP listener, connection multiplexing, TLS/encryption | `core/archipelago/src/server.rs` | -| **ApiHandler** | HTTP request routing, authentication, response formatting | `core/archipelago/src/api/handler/mod.rs` | -| **RpcHandler** | JSON-RPC 2.0 dispatch, method registration, rate limiting | `core/archipelago/src/api/rpc/mod.rs` | -| **ContainerOrchestrator** | Podman lifecycle, manifest reconciliation, adoption | `core/archipelago/src/container/prod_orchestrator.rs` | -| **StateManager** | Central state broadcast channel, revision tracking | `core/archipelago/src/state.rs` | -| **AuthManager** | User credentials, session validation, password hashing | `core/archipelago/src/auth.rs` | -| **Identity Manager** | Node Ed25519 keys, seed derivation, Tor address | `core/archipelago/src/identity_manager.rs` | -| **BootReconciler** | Periodic manifest sync loop, adoption, remediation | `core/archipelago/src/container/boot_reconciler.rs` | -| **Frontend Router** | Vue Router, page navigation, deep linking | `neode-ui/src/router/index.ts` | -| **Frontend Stores** | Pinia state (apps, settings, user, mesh) | `neode-ui/src/stores/` | -| **Frontend Components** | UI elements, modals, cards, layout primitives | `neode-ui/src/components/` | - -## Pattern Overview - -**Overall:** Multi-tier async architecture with centralized request dispatch and broadcast state synchronization. - -**Key Characteristics:** -- **Async-first (Tokio)** - All I/O operations are non-blocking; task spawning for background work -- **RPC-driven API** - Frontend communicates via JSON-RPC 2.0 (not REST); single `/api/v0` WebSocket + HTTP endpoint -- **State as broadcast** - Global state changes flow through Tokio broadcast channels to all connected WebSocket clients -- **Manifest-driven containers** - App lifecycle controlled by declarative YAML manifests (Archipelago-specific extensions) -- **Plugin architecture** - Apps are isolated Podman containers with declarative interfaces (web UI, ports, secrets) - -## Layers - -**HTTP/Transport Layer:** -- Purpose: Accept inbound connections, handle TLS termination, demultiplex HTTP/WebSocket -- Location: `core/archipelago/src/server.rs` -- Contains: Hyper listener, TCP accept loop, connection state tracking -- Depends on: Tokio, Hyper, TLS/mTLS libraries (rustls/openssl) -- Used by: All external clients (web UI, companion app, API consumers) - -**Request Routing & Auth Layer:** -- Purpose: Dispatch HTTP requests to handlers, validate sessions, enforce CSRF, rate-limit login -- Location: `core/archipelago/src/api/` (handler + rpc submodules) -- Contains: Route matching, middleware chain, cookie extraction, error formatting -- Depends on: Server, StateManager, SessionStore -- Used by: All request paths; gates API access - -**RPC Dispatch Layer:** -- Purpose: Deserialize JSON-RPC 2.0 requests, call appropriate service method, serialize responses -- Location: `core/archipelago/src/api/rpc/mod.rs` + subdirectories (auth.rs, container.rs, bitcoin.rs, etc.) -- Contains: Method table, parameter validation, response formatting, rate limit checks -- Depends on: All service modules -- Used by: Frontend (WebSocket + HTTP POST to /api/v0), internal tools - -**Service Layer:** -- Purpose: Implement business logic — container lifecycle, identity, auth, content sync, mesh discovery -- Location: `core/archipelago/src/api/rpc/*` (one RPC module per domain), plus `core/archipelago/src/` (background tasks) -- Contains: ~40 RPC method modules + 50+ core service modules (bootstrap.rs, health_monitor.rs, crash_recovery.rs, etc.) -- Depends on: StateManager, ContainerOrchestrator, config/secrets, external services (Bitcoin, Lightning, FIPS) -- Used by: RPC layer; other services for cross-cutting concerns (mesh, federation, webhooks) - -**State Management Layer:** -- Purpose: Hold canonical application state, broadcast changes to all connected clients, persist snapshots -- Location: `core/archipelago/src/state.rs` (StateManager + data_model.rs) -- Contains: RwLock, broadcast channel, revision counter -- Depends on: DataModel (serde-serializable struct tree) -- Used by: All services that mutate state (container ops, auth, settings) - -**Container Orchestration Layer:** -- Purpose: Podman lifecycle management, image verification, secret injection, crash recovery, adoption -- Location: `core/archipelago/src/container/prod_orchestrator.rs` (1M+ lines; split across boot_reconciler.rs, quadlet.rs, docker_packages.rs, etc.) -- Contains: Manifest parsing, image pull/verify, container create/start/stop, volume mounts, networking -- Depends on: Podman CLI + socket, config parser, image registries, local filesystem -- Used by: RPC container.* methods, BootReconciler loop, crash recovery - -**Frontend Layer (Vue 3):** -- Purpose: Render UI, dispatch RPC calls, maintain local UI state, handle user input -- Location: `neode-ui/src/` -- Contains: Views (pages), Components (reusable UI), Composables (logic hooks), Stores (Pinia), Router -- Depends on: Vue 3, Vue Router, Pinia, RPC client library (custom), D3/Leaflet (charts/maps) -- Used by: Browser clients (desktop, mobile, companion app via WebView) - -## Data Flow - -### Primary Request Path (User Action → Backend → State Sync) - -1. **Frontend user interaction** (click button, type input) → Vue component event handler - - Location: `neode-ui/src/views/*.vue` or `neode-ui/src/components/*.vue` - -2. **Composable dispatches RPC** (e.g., `useContainerInstall()` calls `rpc.container.install()`) - - Location: `neode-ui/src/composables/` (custom or imported from `api/rpc-client.ts`) - -3. **RPC client serializes → HTTP/WebSocket POST to /api/v0** - - Location: `neode-ui/src/api/rpc-client.ts` - - Payload: `{ jsonrpc: "2.0", method: "container.install", params: {...}, id: ... }` - -4. **HTTP Server receives, routes to ApiHandler** - - Location: `core/archipelago/src/server.rs` (listener) → `core/archipelago/src/api/handler/mod.rs` (dispatch) - -5. **ApiHandler checks auth**, extracts body, calls RpcHandler - - Location: `core/archipelago/src/api/handler/mod.rs:handle_request()` - -6. **RpcHandler dispatches by method name** to specific RPC module - - Location: `core/archipelago/src/api/rpc/mod.rs:call()` → routing to `core/archipelago/src/api/rpc/container.rs:install()` - -7. **Service method executes** (e.g., `container.rs:install()` calls orchestrator, updates state) - - Location: `core/archipelago/src/api/rpc/container.rs` (calls methods on ContainerOrchestrator) - -8. **StateManager.update_data()** broadcasts the new state to all WebSocket subscribers - - Location: `core/archipelago/src/state.rs:update_data()` → broadcast channel - - All connected WebSocket clients receive `{ rev: N, data: {...} }` update - -9. **Frontend receives state update**, updates Pinia stores, re-renders UI - - Location: `neode-ui/src/stores/` (Pinia stores mutate) → Vue reactivity chain → DOM update - -**State Management:** -- All reads from `StateManager` go through `get_snapshot()` which acquires read-lock -- All writes go through `update_data()` which acquires write-lock + increments revision -- Broadcast channel has ~100-message buffer; slow subscribers may lose old updates (by design — UI only needs latest) -- WebSocket clients re-sync on reconnect via `get_snapshot()` call (full state transfer) - -### Secondary Flow: Scheduled Reconciliation (Convergence Loop) - -1. **BootReconciler spawned at startup** in `main.rs` - - Location: `core/archipelago/src/main.rs` (line ~338-348) - -2. **Reconciler runs every `RECONCILER_DEFAULT_INTERVAL`** (~30s typical) - - Location: `core/archipelago/src/container/boot_reconciler.rs:run_forever()` - -3. **Compares desired manifests (disk + registry catalog) vs actual Podman state** - - Looks for: containers missing, containers orphaned, image updates, secret changes - -4. **Applies remediation** (create, delete, restart containers) - - Calls: orchestrator.reconcile_*() methods - -5. **Logs changes, broadcasts state update if anything changed** - - Frontend receives update, shows user the reconciled app state - -This ensures apps survive crashes, OTA updates, or manual Podman edits — the desired state always converges. - -## Key Abstractions - -**ContainerOrchestrator trait:** -- Purpose: Abstract container lifecycle behind a trait so Prod (Podman-based) and Dev (in-memory) modes can coexist -- Examples: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/container/dev_orchestrator.rs` -- Pattern: Trait-based strategy; RpcHandler holds `Arc`, switches at runtime -- Methods: create, start, stop, delete, adopt, list, reconcile, install, upgrade - -**Manifest (YAML-based declarative app):** -- Purpose: Fully describe an app's container, dependencies, secrets, ports, UI in one file -- Examples: `/opt/archipelago/apps/*/manifest.yml` (on-disk) or registry-delivered catalogs -- Pattern: Custom extensions over OCI/Docker Compose (e.g., `interfaces.main.ui`, `generated_secrets`) -- Parsed into: `container::manifest::Manifest` struct, consumed by orchestrator - -**RPC Method Modules:** -- Purpose: Group related JSON-RPC methods by domain (auth, container, bitcoin, mesh, etc.) -- Examples: `core/archipelago/src/api/rpc/auth.rs`, `core/archipelago/src/api/rpc/bitcoin.rs` -- Pattern: Each module exports `pub async fn method_name(handler, params) -> Result` -- Registration: Hardcoded dispatch in `RpcHandler::call()` (no reflection; methods are explicit) - -**BlobStore (Content-Addressed):** -- Purpose: Store attachments/files by SHA-256 hash; issue time-limited capability tokens for access -- Examples: Used by mesh.send-content, federation attachments, backup archives -- Pattern: Capability-based access control (CBAC); tokens scoped to issuer pubkey + hash -- Located: `core/archipelago/src/blobs.rs` + `core/archipelago/src/content_server.rs` - -**StateManager + DataModel:** -- Purpose: Single source of truth for UI state; broadcast updates to all clients -- Pattern: Read-write lock over a serde-serializable struct tree; broadcast channel for efficiency -- Persistence: Most state is ephemeral (app listings, UI settings); durable state persists to disk separately -- Clients: Frontend (WebSocket subscriber), internal services (read via get_snapshot), monitoring/debug - -**Session Store:** -- Purpose: Track authenticated HTTP sessions (cookie → user identity mapping) -- Examples: SQLite-backed or in-memory store -- Pattern: Session token issued at login, validated on each request, expires after TTL -- Used by: ApiHandler auth check, rate limiter (per IP + per user) - -## Entry Points - -**Backend Daemon (Binary):** -- Location: `core/archipelago/src/main.rs` -- Triggers: `systemd start archipelago.service` or manual `./archipelago` on development node -- Responsibilities: Parse config, init tracing, load/reconcile containers, start HTTP server, spawn background tasks -- Key setup: Load identity → setup auth → spawn orchestrator → load manifests → start reconciler → start server - -**Frontend SPA:** -- Location: `neode-ui/src/main.ts` -- Triggers: Browser loads `/index.html` (served by HTTP server from `/opt/archipelago/web-ui/`) -- Responsibilities: Boot Vue app, setup Router, setup Pinia stores, establish WebSocket to backend -- Key setup: Mount app → router ready → fetch initial state → subscribe to updates - -**RPC Endpoints (HTTP + WebSocket):** -- Location: `core/archipelago/src/api/` (handler routes requests here) -- Endpoint: `/api/v0` (JSON-RPC 2.0 POST or WebSocket upgrade) -- Methods: ~200+ RPCs across domains (auth, container, bitcoin, mesh, federation, etc.) -- Example: `POST /api/v0` with body `{"jsonrpc": "2.0", "method": "auth.login", "params": {...}, "id": 1}` - -**Background Tasks (Spawned at startup):** -- BootReconciler: Periodic manifest reconciliation loop -- Health Monitor: Periodic app health checks + restart -- Update Scheduler: Periodic app update checks -- Mesh Service: P2P mesh listener + sender (federation, LoRa) -- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers -- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber -- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks) - -## Architectural Constraints - -- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking) -- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design) -- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge -- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks) -- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory) -- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies) -- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler - -## Anti-Patterns - -### Circular RPC Dispatches - -**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login) -**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug -**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount). - -### Synchronous blocking in RPC handlers - -**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop -**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users -**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples. - -### Hardcoding paths in app RPC modules - -**What happens:** `bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir` -**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found" -**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern. - -### Frontend state outside Pinia stores - -**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data -**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall -**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`. - -### Not handling WebSocket reconnection - -**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state -**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens -**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop. - -## Error Handling - -**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages. - -**Patterns:** -- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes) -- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec -- Service layer: Use `anyhow::Result` + `?` operator for early exit; convert to `RpcError` at handler boundary -- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app) - -**Critical paths:** -- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames) -- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon) -- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries) -- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting") - ---- - -*Architecture analysis: 2026-07-29* diff --git a/.planning/codebase/CONCERNS.md b/.planning/codebase/CONCERNS.md deleted file mode 100644 index 33b335c3..00000000 --- a/.planning/codebase/CONCERNS.md +++ /dev/null @@ -1,195 +0,0 @@ -# Codebase Concerns - -**Analysis Date:** 2026-07-29 - -## Tech Debt - -**Federation node removal tombstone gap:** -- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it. -- Files: `core/archipelago/src/federation/storage.rs:180-197`, `core/archipelago/src/api/rpc/federation/handlers.rs:272-300` -- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections. -- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles. - -**Container reconciler observability gap:** -- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconciler loop), `core/archipelago/src/health_monitor.rs` -- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop. -- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output. - -**Failed systemd unit self-healing gap:** -- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (reconcile loop) -- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator. -- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed ` + `start`; add backoff to avoid busy-loop on persistent failures. - -**Bitcoin RPC credentials not retrieved from config/secrets:** -- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store. -- Files: `core/container/src/bitcoin_simulator.rs:155-165` -- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail. -- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation. - -**Container security policies not wired in:** -- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers. -- Files: `core/security/src/container_policies.rs:63-75` -- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints. -- Fix approach: Pass `--security-opt apparmor=` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly. - -**Dynamic resource adjustment not implemented:** -- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift. -- Files: `core/performance/src/resource_manager.rs:86-88` -- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically. -- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+). - -## Known Bugs - -**Multinode RPC robustness gap:** -- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback. -- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`) -- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever. -- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery. -- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify. - -## Security Considerations - -**Secrets environment variable exposure risk:** -- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc//environ` and potentially logged. -- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/container/src/manifest.rs`, `core/archipelago/src/api/rpc/package/config.rs` -- Current mitigation: Secrets are declared as `generated_secrets` in manifests and materialized 0600/rootless; the orchestrator avoids logging values. -- Recommendations: Audit all env-var passing to containers; consider switching high-sensitivity secrets (bitcoin RPC, LND macaroons) to file-based secrets mounted read-only; add audit logging for secret access. - -**Federation DID validation incomplete:** -- Risk: Federation peer DIDs are added via the RPC without cryptographic verification of ownership. A compromised peer could advertise arbitrary DIDs. -- Files: `core/archipelago/src/api/rpc/federation/handlers.rs` (add-node path), `core/archipelago/src/federation/storage.rs` -- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers. -- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers. - -**AppArmor profiles overly permissive:** -- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs. -- Files: `core/security/src/container_policies.rs:46-54` -- Current mitigation: None (profiles not applied). -- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod. - -## Performance Bottlenecks - -**Container thrashing during reconcile:** -- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts. -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine) -- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units. -- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup. - -**Reconciler churn on boot:** -- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability. -- Files: `core/archipelago/src/container/prod_orchestrator.rs`, `core/archipelago/src/bootstrap.rs` -- Cause: Multi-pass reconciliation + no incremental diff detection. -- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path. - -**Bitcoin IBD on .198 stalled (disk I/O):** -- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5). -- Files: `tests/multinode-testing-plan.md` (documented issue) -- Cause: Undersized/slow disk; concurrent workload. -- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue. - -## Fragile Areas - -**Uninstall + reinstall lifecycle:** -- Files: `core/archipelago/src/api/rpc/package/install.rs`, `core/archipelago/src/container/quadlet.rs:disable_remove()`, `neode-ui/src/components/AppCard.vue` -- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in. -- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll. -- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`. - -**Production orchestrator state machine:** -- Files: `core/archipelago/src/container/prod_orchestrator.rs` (6291 lines) -- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved. -- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution. -- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate. - -**Mesh radio configuration + boot race:** -- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats` -- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware. -- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues. -- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate). - -**Lightning payment state machine:** -- Files: `core/archipelago/src/api/rpc/lnd/wallet.rs:payinvoice()` -- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop. -- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release. -- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate. - -## Scaling Limits - -**Uninstall progress bar truthfulness:** -- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity). -- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration. -- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app). - -**Federation node list deduplication on disk bloat:** -- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation. -- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible. -- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load). - -**Lifecycle gate iteration count:** -- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228. -- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual. -- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention). - -## Dependencies at Risk - -**Reticulum transport daemon process group:** -- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions. -- Impact: Stale reticulum processes accumulating over time; resource leaks on node. -- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment. - -**Podman socket mount security model:** -- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied). -- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer) -- Impact: A compromised app with podman socket access can start/stop/delete any container on the node. -- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired. - -**Bitcoin version multi-version branch not fleet-wide:** -- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version. -- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation. -- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing. - -## Missing Critical Features - -**Developer tooling CLI suite:** -- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches. -- Blocks: External marketplace (workstream C); external developer onboarding. -- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5. - -**Manifest-distributed registry flip:** -- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed. -- Blocks: Cannot confidently add/bump apps without re-signing the catalog. -- Status: Code ready; flip awaits authorization + timing call from user. - -**Phase-3 Quadlet default-flip:** -- Problem: Orchestrator still uses legacy cgroup-based container management; Phase-3 `use_quadlet_backends` switch exists but is opt-in only. -- Blocks: Resolves container thrashing; unlocks independent app restarts; unblocks lifecycle perfection (workstream F). -- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes. - -## Test Coverage Gaps - -**~30 apps with zero app-specific assertions:** -- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running." -- Files: `tests/lifecycle/bats/all-apps-matrix.bats`, `tests/lifecycle/bats/all-apps-lifecycle.bats` (generic baseline coverage) -- Risk: App-specific bugs (API down, data corruption, dependency failure) go unnoticed until user encounters them. -- Priority: Medium — baseline coverage is a real safety net; app-specific assertions are a "nice to harden" backlog item, not a gate blocker. -- Approach: Add per-app health RPC endpoints or HTTP probes; wire into the gate as opt-in per-app test suites. - -**Progress UI assertions incomplete:** -- What's not tested: Install + uninstall must report monotonic, truthful progress. No stage/percentage assertions in the gate. -- Files: `neode-ui/src/components/AppCard.vue`, `core/archipelago/src/api/rpc/package/install.rs` (backend progress events) -- Risk: Silent hangs or fake progress bars are invisible to the gate. -- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F. -- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in). - -**All-apps matrix in cascade gate:** -- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps. -- Files: `tests/lifecycle/bats/cascade-uninstall.bats` (single-app variant) -- Risk: Multi-container app uninstall bugs (e.g., orphan postgres container) go undetected. -- Priority: High — part of workstream F definition of done. -- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall. - ---- - -*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).* diff --git a/.planning/codebase/CONVENTIONS.md b/.planning/codebase/CONVENTIONS.md deleted file mode 100644 index 2cd4f060..00000000 --- a/.planning/codebase/CONVENTIONS.md +++ /dev/null @@ -1,159 +0,0 @@ -# Coding Conventions - -**Analysis Date:** 2026-07-29 - -## Naming Patterns - -**Files:** -- TypeScript/Vue: PascalCase for components (e.g., `ToggleSwitch.vue`, `SendBitcoinModal.vue`), camelCase for composables and stores (e.g., `useFileType.ts`, `controller.ts`) -- Rust: snake_case for modules and files (e.g., `bitcoin_rpc.rs`, `storage_crypto.rs`) -- Test files: co-located with source in `__tests__/` subdirectories with `.test.ts` or `.spec.ts` suffix for Vitest, `.bats` for shell tests -- Constants in TypeScript use UPPER_SNAKE_CASE within modules (e.g., `IMAGE_EXTS`, `CATEGORY_COLORS` in `useFileType.ts`) - -**Functions:** -- TypeScript/Vue: camelCase for all functions (e.g., `getFileCategory`, `formatSize`, `useFileType`) -- Composables: `use` prefix for Vue composables (e.g., `useFileType`, `useToast`, `useMessageToast`) — exported as named exports or default exports -- Store functions (Pinia): defined with snake_case action names, exported from `defineStore` factory -- Rust: snake_case for all functions and methods (e.g., `doesnt_reallocate`, following Rust conventions) - -**Variables:** -- TypeScript: camelCase for local variables and reactive refs (e.g., `modelValue`, `isActive`, `gamepadCount`) -- Refs (Vue 3): prefix not required, but convention is lowercase start (e.g., `const ext = ref('jpg')`) -- Computed properties: camelCase, explicit `.value` suffix in templates when needed -- Parameters: camelCase, typed explicitly in TypeScript (e.g., `password: string`, `isDir: Ref`) - -**Types:** -- TypeScript: PascalCase for type aliases and interfaces (e.g., `RPCOptions`, `FileCategory`, `CatalogVersionInfo`) -- Union types: PascalCase (e.g., `PendingState = 'pending' | 'sent' | 'approved'`) -- Component props: typed with `defineProps<{ ... }>()` syntax in `';` - — a classic (non-module) script injected at end of head still executes - BEFORE the SPA's deferred module bundle, which is what the seeding needs. - Add `location = /__demo/indee-demo-signin.js { root /usr/share/nginx/html; }` - (or alias) inside the 2101 server so the seed script is served same-origin - to the iframe. Update the comment block that currently explains why - IndeeHub is not proxied (lines ~106-109) to describe the new :2101 design. - - Create neode-ui/docker/indee-demo-signin.js: a small plain-JS classic - script, clearly headed with a comment stating it is PUBLIC-DEMO-ONLY and - that the embedded key is a freshly generated THROWAWAY demo identity, not - a real secret. Generate ONE fresh secp256k1 keypair at implementation time - (e.g. `node -e` with a tiny script using any available schnorr/secp lib, or - a one-off `npx` of nostr-tools in the scratchpad — the generator itself is - not committed) and embed hex sk + hex pk as constants. The script: if - `localStorage.getItem('indeedhub-accounts')` is empty/absent, write the - two keys IndeeHub's boot-restore reads — `indeedhub-accounts` (JSON array - with ONE serialized private-key account: verify the exact `type` string - and common-field shape against the live bundle per verified_findings, shape - `{ id, type, pubkey, signer: { key } }` + whatever `loadCommonFields` - round-trips, give it a friendly name/metadata like "Archy Demo" if the - shape supports it) and `indeedhub-active-account` (that account's id). - Because the script runs on the :2101 origin inside the iframe, this - touches only the proxied app's isolated storage. IndeeHub then restores - the account on boot and self-signs with its own bundled signer — no - window.nostr and no parent bridge required. Do NOT define a partial - `window.nostr` in this approach (a pubkey-only shim with a broken - signEvent causes worse failures than no shim). - - FALLBACK (only if live testing in Task-3 verification shows the seeded - account shape is not accepted): seed an `"extension"`-type account - instead, define a `window.nostr` postMessage client in this same script - (request/response protocol matching useNostrBridge: post - `{type:'nostr-request', id, method, params}` to `window.parent`, resolve on - `{type:'nostr-response', id, ...}`), and implement `node.nostr-sign` / - `identity.nostr-sign` in mock-backend.js with real schnorr signatures over - the same throwaway key (add `nostr-tools` to neode-ui dependencies — it is - pure JS and Dockerfile.backend runs `npm install` over package.json). - Prefer the primary approach; only fall back with evidence. - - Wire the plumbing: `EXPOSE 2101` in Dockerfile.web (the seed script is - already inside `neode-ui/` so the existing `COPY neode-ui/ ./` + - dist copy do NOT ship it — add an explicit - `COPY neode-ui/docker/indee-demo-signin.js /usr/share/nginx/html/__demo/indee-demo-signin.js` - in the nginx stage of Dockerfile.web; it lands only in the demo web image, - never in real-node artifacts). Publish the port in docker-compose.demo.yml - (`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an - env-overridable mapping consistent with its existing `DEMO_WEB_PORT` - style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that - file's header comment). Read docker-entrypoint.sh first and make sure the - new server block survives its template substitution exactly like the - existing blocks (same escaping convention for nginx `$` variables); touch - the entrypoint only if its substitution list needs it. - - Do not put any host IP in any of these files; upstream hostname - indee.tx1138.com is fine. - - - docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js - - nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere. - - - - Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall - neode-ui/src/composables/useDemoIntro.ts, neode-ui/src/views/appSession/useAppIdentity.ts - - In useDemoIntro.ts: remove `indeedhub` from `DEMO_EXTERNAL_URLS` (delete - the map entirely if it becomes empty, simplifying `isDemoExternal` to - return false — keep the exported function so call sites in appLauncher.ts - and AppSession.vue compile unchanged). Make `demoAppUrl('indeedhub')` - return the proxied origin built at runtime: - `${window.location.protocol}//${window.location.hostname}:2101/` - (hostname, never a hardcoded host/IP — works on any deploy host). Keep - `isDemoApp('indeedhub')` true (it must stay in the demoable set so the - NEW_TAB bypass in appLauncher.openSession and AppSession.mustOpenNewTab - keeps routing it into the in-app iframe session, and so the install - button stays enabled). Update the file-header comment block that - currently documents the external-tab workaround to describe the :2101 - whole-origin proxy design instead. SSR-safety is not a concern (Vite SPA) - but guard `typeof window !== 'undefined'` if other tests import the module - in node context — check the existing unit tests under - src/views/appSession/__tests__/ and src/stores/__tests__/ for assertions - about indeedhub being demo-external and update them to the new behavior. - - In useAppIdentity.ts: gate the picker for the demo. Import IS_DEMO from - useDemoIntro and in `onIframeLoadIdentity` / `handleIdentityRequest`, - when IS_DEMO is true, never set `showIdentityPicker` — the demo visitor - must not be interrupted by an identity modal (the embedded IndeeHub is - already signed in via the seeded account from Task 1, and `sendIdentity`'s - `identity.sign` RPC is not what logs it in). Real-node behavior - (picker on first launch) is untouched because IS_DEMO is compile-time - false there. - - - cd neode-ui && npx vitest run src/views/appSession src/stores --silent 2>&1 | tail -5 (all green) && VITE_DEMO=1 npm run build && grep -rq "2101" dist/assets && npm run build && grep -rq "indee.tx1138.com" dist/assets && echo BUNDLE-OK - - Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO. - - - - Task 3: mock backend — IndeeHub pre-installed on fresh demo sessions - neode-ui/mock-backend.js - - Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the - existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub` - (match the existing title map at ~line 537 and APP_TITLES), a short/long - description consistent with the marketplace copy ("Bitcoin documentary - streaming platform" per the existing entry), `state: 'running'`, - `lanPort: 8190` (matches the existing port map), icon - `/assets/img/app-icons/indeedhub.png`. Because per-session demo state is - `structuredClone(staticDevApps)`, this alone makes it installed+running on - every fresh session. Then reconcile the rest of the mock so nothing - contradicts installed status: check the marketplace/available-apps mock - responses and any install/uninstall handlers (~lines 540-740, 1900-1960, - 4900+) for `indeedhub` entries that would render it as not-installed or - double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub - placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the - iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey` - mock as-is unless Task 1's fallback path was taken (in which case align - its pubkey with the throwaway demo key and add the sign handlers described - there). - - - cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine) - - A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1. - - - - - -## Trust Boundaries - -| Boundary | Description | -|----------|-------------| -| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host | -| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path | -| public visitors → demo host | anyone can drive the proxy | - -## STRIDE Threat Register - -| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | -|-----------|----------|-----------|----------|-------------|-----------------| -| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" | -| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop | -| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy | -| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config | -| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) | - - - -Local (executor, before commit): -1. nginx config parses (Task 1 verify). -2. Unit tests green; VITE_DEMO=1 build contains ":2101" logic; plain build - unaffected (Task 2 verify). Note: demo-gated strings are tree-shaken out of - the plain build — that is EXPECTED; the bundle-grep for demo strings must be - done on the VITE_DEMO=1 build, which is exactly what the demo Docker image - builds (Dockerfile.web defaults ARG VITE_DEMO=1). -3. Fresh-session mock package-data includes indeedhub (Task 3 verify). -4. Optional full-stack smoke: `docker compose -f docker-compose.demo.yml up - --build` locally, browse http://localhost:2100 in a private window → - login `entertoexit` → IndeeHub installed → launch → iframe renders the - proxied site from http://localhost:2101 with a signed-in account. -5. `git status` — confirm nothing under indeedhub/ is staged, ever. - -Post-deploy on vps2 (orchestrator deploys; verify on http://146.59.87.168:2100): -1. `curl -sI http://146.59.87.168:2101/` returns 200 with NO X-Frame-Options - header and the injected `indee-demo-signin.js` tag in the HTML body - (`curl -s http://146.59.87.168:2101/ | grep indee-demo-signin`). If the - port is unreachable, the vps2 firewall needs 2101 opened — flag to - orchestrator. -2. Fresh private browser window → :2100 → login → IndeeHub shows installed/ - running on the dashboard/My Apps without any install action. -3. Launch IndeeHub → renders inside the in-app iframe (panel/overlay), not a - new tab; content browsable; no identity-picker modal. -4. Signed-in check: IndeeHub header shows an active account (avatar/profile - instead of a sign-in button). If the seeded account shape was rejected - (login wall still visible), execute the documented fallback (extension - account + window.nostr shim + mock signer) and redeploy. -5. View-source/network spot-check: no occurrence of the private - release-server IP in any served response. -6. Repeat-visit check: reload the iframe once — a service worker registered by - IndeeHub may serve cached HTML without the injected tag on later loads; - that is acceptable because localStorage is already seeded on first load, - but confirm sign-in persists. - - - -- Demo visitor on a fresh browser sees IndeeHub installed, launches it into - the in-app iframe, and browses indee.tx1138.com content signed in — zero - clicks spent on install/login/identity modals. -- Real-node build behavior unchanged (all changes IS_DEMO- or demo-image-gated). -- No secrets committed beyond the labelled throwaway demo key; nothing staged - under indeedhub/; demo serves no private release-server IP. -- Work committed in focused commits (infra / frontend / mock) with the - Co-Authored-By trailer and pushed via gitea-ai per CLAUDE.md; docs left to - the orchestrator. - - - -Create `.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md` when done. - diff --git a/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md b/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md deleted file mode 100644 index 15f50ae7..00000000 --- a/.planning/quick/260729-gjd-demo-make-indee-tx1138-com-work-in-the-a/260729-gjd-SUMMARY.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -phase: quick-260729-gjd -plan: 01 -subsystem: public-demo -tags: [demo, indeedhub, nginx, reverse-proxy, nostr, mock-backend] -requires: [] -provides: - - "IndeeHub whole-origin demo proxy on :2101 (framing headers stripped, sign-in seeded)" - - "Demo iframe launch of indeedhub via demoAppUrl → :2101/" - - "IndeeHub pre-installed/running on every fresh demo session" -affects: [demo-deploy, neode-ui demo image] -tech-stack: - added: [] - patterns: - - "Whole-origin per-port reverse proxy for frame-busting external SPAs (vs broken path-prefix sub_filter)" - - "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)" -key-files: - created: - - neode-ui/docker/indee-demo-signin.js - modified: - - neode-ui/docker/nginx-demo.conf - - neode-ui/Dockerfile.web - - docker-compose.demo.yml - - demo-deploy/docker-compose.yml - - neode-ui/src/composables/useDemoIntro.ts - - neode-ui/src/views/appSession/useAppIdentity.ts - - neode-ui/mock-backend.js -decisions: - - "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle" - - "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)" -metrics: - duration: "~50 min" - completed: 2026-07-29 -status: complete ---- - -# Quick Task 260729-gjd: IndeeHub in the Demo Summary - -**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session. - -## Commits - -| Task | Commit | Scope | -|------|--------|-------| -| 1 | 69bc3d3f | nginx :2101 whole-origin proxy + indee-demo-signin.js seeder + Dockerfile.web COPY/EXPOSE + both compose files publish 2101 | -| 2 | 66d540f8 | useDemoIntro: DEMO_EXTERNAL_URLS → DEMO_PROXY_PORTS, demoAppUrl builds `//:2101/`; useAppIdentity: picker suppressed under IS_DEMO | -| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session | - -## What was verified at exec time (live-bundle facts) - -- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`. -- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON` → `{ signer: { key: }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode. -- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing. - -## Throwaway demo identity - -Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed): -- pk `7261540160244ec65ce0bf86ba03997e9b1b3b35c277e416bf1c7ba4271fee31` -- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key. - -## Local verification results - -1. **nginx parse:** `nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference). -2. **Live proxy smoke (podman, config + seeder mounted):** `curl` through :2101 → 200, **no X-Frame-Options / CSP**, injected ` - - - diff --git a/docs/archive/demo-deployment-design.md b/docs/archive/demo-deployment-design.md index d0e6a4c1..3edf5d9a 100644 --- a/docs/archive/demo-deployment-design.md +++ b/docs/archive/demo-deployment-design.md @@ -7,8 +7,7 @@ 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). +(existing demo stack). --- @@ -46,7 +45,7 @@ layer**. CI: build archy-demo-web + archy-demo-backend │ push :demo / :latest ▼ - registry (146.59.87.168:3000 / vps2) + registry (source.archipelago-foundation.org / vps2) │ Portainer webhook / re-pull ▼ archy-demo (public repo — tiny) @@ -162,7 +161,7 @@ Today filebrowser upload/delete/rename are 200-OK no-ops. 1. **Demo host** — which Portainer instance (OVH `.168`? a dedicated VPS)? Public DNS + TLS for `demo.`? -2. **Registry for `:demo` images** — `146.59.87.168:3000` vs vps2; public-pull or +2. **Registry for `:demo` images** — `source.archipelago-foundation.org` 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? diff --git a/docs/archive/lora-functionality.html b/docs/archive/lora-functionality.html deleted file mode 100644 index 3fceac08..00000000 --- a/docs/archive/lora-functionality.html +++ /dev/null @@ -1,899 +0,0 @@ - - - - - -Archipelago — LoRa & Mesh Functionality Guide - - - - - - -
- -
-

LoRa & Mesh Functionality

-

How Archipelago sends encrypted messages, Bitcoin transactions, and emergency alerts over long-range radio when the internet is gone.

-
- Meshcore Companion USB - Double Ratchet E2E - 23 Message Types - 160-byte LoRa Frame -
-
- -

Introduction

-

This document explains Archipelago's mesh subsystem — the code under core/archipelago/src/mesh/ that lets nodes talk to each other over LoRa radio 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.

-

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.

- -

What is LoRa? Layman

-
- Think of LoRa as a whisper that travels 10 kilometers. - 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 160 bytes at a time, and each whisper takes a second or two to complete. -
-

Technically, LoRa (Long Range) is a proprietary radio modulation by Semtech that uses chirp spread spectrum (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.

-

Archipelago does not talk to a LoRa chipset directly. Instead it delegates to a small USB-attached device running Meshcore firmware, which handles the radio, the mesh routing, and the store-and-forward queue. Archipelago speaks to that device over USB serial.

- -

Why Archipelago uses it

-
-
-

Off-grid safety

-

Dead-man switch and emergency alerts reach family without cell coverage.

-
-
-

Censorship resistance

-

No ISP, no DNS, no TLS termination — just radio waves between nodes.

-
-
-

Bitcoin when internet is down

-

Relay signed transactions and Lightning payments through on-grid peers.

-
-
-

Truly peer-to-peer chat

-

Text, replies, reactions, read-receipts — Telegram-quality UX, zero servers.

-
-
- -
- -

Hardware & Firmware

-

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.

- - - - - - - - - - - -
ComponentRoleExamples
MCURuns Meshcore firmware, talks USB serialESP32, nRF52840
RadioSemtech LoRa transceiverSX1262, SX1276
BoardMCU + radio + USB + antennaHeltec V3, T-Beam, RAK WisBlock, Station G2
FirmwareMesh routing + Companion USB protocolMeshcore
ConnectionUSB CDC-ACM serial/dev/mesh-radio (udev symlink), /dev/ttyUSB*, /dev/ttyACM*
Link params115200 baud, 8N1Set in mesh/serial.rs
- -
- It's a modem. 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. -
- -

USB Serial Transport

-

Every byte in and out of the radio is wrapped in a framed serial protocol. The host speaks with '<' and listens for '>'.

- -
Host → Device: 0x3C '<' │ len_lo len_hiframe_bytes... -Device → Host: 0x3E '>' │ len_lo len_hiframe_bytes... - -Baud: 115200 Framing: 8N1 Source: mesh/serial.rs
- -

The frame body is a Meshcore Companion command or response. Archipelago builds these in mesh/protocol.rs and parses replies in mesh/listener/decode.rs.

- -

Companion commands Archipelago uses

- - - - - - - - - - - - - - - -
CodeNamePurpose
0x01APP_STARTHandshake; device returns its node_id and name
0x02SEND_TXT_MSGSend payload to a contact (targeted by 6-byte pubkey prefix)
0x03SEND_CHANNEL_TXT_MSGBroadcast on a channel (no specific recipient)
0x04GET_CONTACTSPull the device's contact table
0x06SET_DEVICE_TIMESync Unix timestamp for message dating
0x07SEND_SELF_ADVERTBroadcast our identity onto the mesh
0x08SET_ADVERT_NAMESet our display name
0x0ASYNC_NEXT_MESSAGEPop the next queued inbound message
0x0BSET_RADIO_PARAMSFrequency, spreading factor, bandwidth
0x0CSET_RADIO_TX_POWERTransmit power (dBm)
0x38GET_STATSDevice statistics
- -

Responses and push notifications

-

Responses begin with a status byte. Codes < 0x80 are replies to a command we sent; codes >= 0x80 are asynchronous push events from the device.

- - - - - - - - - - - - -
CodeNameMeaning
0x00RESP_OKCommand accepted
0x01RESP_ERRCommand failed + error code
0x03RESP_CONTACTOne contact entry (32-byte pubkey + metadata)
0x05RESP_SELF_INFOOur node_id and name after APP_START
0x10RESP_CONTACT_MSG_V3Direct inbound message (SNR + sender prefix + payload)
0x11RESP_CHANNEL_MSG_V3Channel broadcast inbound
0x83PUSH_MESSAGES_WAITINGAsync: new messages in queue, call SYNC_NEXT_MESSAGE
- -

Wire Format — the payload byte 0

-

Once a frame reaches the message payload, Archipelago looks at the first byte to decide what kind of thing it's dealing with. This single-byte marker is the master switch of the entire mesh protocol.

- -
0x00 Plain text (legacy, unencrypted) -0x01 Identity broadcast (ARCHY:2 / ARCHY:3) -0x02 Typed CBOR envelope (plaintext, used for debug or intra-LAN) -0xEE Encrypted typed — ChaCha20-Poly1305 w/ static shared secret -0xDD Ratcheted typed — Double Ratchet, forward-secure
- -

Markers 0xEE and 0xDD are the interesting ones — they carry real production traffic. Everything else is either debug or identity bootstrap.

- -

0xEE — static-key encrypted envelope

-
[0xEE] [nonce: 12 bytes] [ciphertext...] [auth tag: 16 bytes]
-
    -
  • Key: X25519 ECDH between our Ed25519 identity (converted) and the peer's.
  • -
  • Cipher: ChaCha20-Poly1305 AEAD.
  • -
  • Max plaintext: 160 − 1 − 12 − 16 = 131 bytes (see crypto::MAX_ENCRYPTED_PLAINTEXT).
  • -
  • Properties: confidential + authenticated, but compromise of a key decrypts all history.
  • -
- -

0xDD — Double Ratchet envelope

-
[0xDD] [RatchetHeader: 40 bytes] [nonce: 12] [ciphertext] [tag: 16]
-
    -
  • Per-message keys derived via DH ratchet + symmetric-key ratchet (HKDF-SHA256).
  • -
  • Handles out-of-order delivery via a skipped-keys cache.
  • -
  • Properties: forward secrecy + post-compromise recovery. Used for mesh.* chat once a session is established.
  • -
  • Implementation: mesh/ratchet.rs, session load/save in mesh/listener/session.rs.
  • -
- -
- Static key vs. ratchet = a safe vs. a self-shredding envelope. - The 0xEE lane is like a locked safe: one key opens everything. The 0xDD 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. -
- -

Encryption Layers

-

Three cryptographic primitives combine to produce the 0xDD ratchet flow:

- -
-
-

X25519 ECDH

-

Each Double Ratchet step generates a fresh keypair. Peers mix the new shared secret into the chain.

-
-
-

HKDF-SHA256

-

Derives root key, chain key, and message key at each ratchet step.

-
-
-

ChaCha20-Poly1305

-

Symmetric AEAD used for the actual payload encryption + authentication tag.

-
-
- -

Session bootstrap — X3DH-like handshake

-

Before the ratchet can start, peers exchange a PrekeyBundle (type 5) and a SessionInit (type 6). Those two messages are carried by the 0xEE static-key envelope, because the ratchet session doesn't exist yet. Once SessionInit is processed, subsequent traffic switches to 0xDD. See mesh/x3dh.rs.

- -

Fragmentation — how a 500-byte message rides a 160-byte pipe

-

The LoRa frame budget is 160 bytes (protocol::MAX_MESSAGE_LEN). Subtract the marker, nonce, ratchet header, and tag and you end up with ~90 usable plaintext bytes per frame. Anything bigger gets chunked.

- -
Chunk header ┌──────────┬──────────┬────────────┐ - │ type (1) │ id (1) │ total (1) │ - └──────────┴──────────┴────────────┘ -Chunk body 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
- -

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.

- -
- Escape hatch: federation fallback. If a peer is a synthetic federation contact and the message is bigger than 160 bytes, Archipelago skips LoRa entirely and routes the message over Tor federation instead. See the ContentRef path in rpc/mesh/typed_messages.rs. -
- -

Dual Transport — LoRa + Tor federation

-

Archipelago treats LoRa and Tor federation as two lanes of the same highway. 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.

- -
┌──────────────────┐ - │ mesh.send(...) │ - └────────┬─────────┘ - │ - ┌──────────┴──────────┐ - │ Is peer synthetic? │ - └──────────┬──────────┘ - No │ Yes - ┌──────────┘ └──────────┐ - ▼ ▼ - LoRa radio Tor federation - (160-byte frame) (unlimited, slower setup) - │ │ - │ if > 160 B && synth ──────┘ (fallback) - ▼ - Chunked over LoRa - or refused if no fallback
- -

Addressing

-
    -
  • Contact ID — 32-bit handle from Meshcore's contact table. Used by SEND_TXT_MSG.
  • -
  • Pubkey prefix — first 6 bytes of the peer's Ed25519 public key. Included on the wire so receivers can deduplicate and route replies.
  • -
  • DID / onion — used for federation peers; synthetic contacts carry the DID so the mesh layer can hand the message to the federation layer.
  • -
- -

Synthetic federation contacts

-

To let the chat list show federation peers before any message arrives, Archipelago inserts synthetic contacts into the mesh peer list. Their contact IDs live in the upper half of the 32-bit space (≥ 0x8000_0000), derived deterministically from the federation node's Ed25519 pubkey. Collisions with real LoRa contact IDs are impossible by construction.

- -
- -

All 23 Message Types

-

Every typed message is a CBOR envelope identified by a single MeshMessageType byte. The Transport column shows which marker carries it on the wire and which Companion command is used.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
IDTypePurposeMarkerCmdChunked?
0TextPlain chat message0xDD0x02If >160 B
1AlertEmergency / dead-man heartbeat0xDD0x02/0x03No (short)
2InvoiceLightning / BOLT11 invoice0xDD0x02Usually
3PsbtHashUnsigned tx hash for co-signing0xDD0x02No
4CoordinateGPS location share0xDD0x02No
5PrekeyBundleX3DH bootstrap (pre-session)0xEE0x02No
6SessionInitInitial ratchet message0xEE0x02No
7BlockHeaderBitcoin block height/hash0xDD0x03No
8TxRelaySigned Bitcoin tx for on-grid peer to broadcast0xDD0x02Yes
9TxRelayResponsetxid or error from the relay peer0xDD0x02No
10LightningRelayBOLT11 to pay via on-grid peer0xDD0x02Yes
11LightningRelayResponsepayment_hash or error0xDD0x02No
12TxConfirmationDepth update (1/2/3 confs)0xDD0x02No
13ReplyQuoted reply to a previous message0xDD0x02If long
14ReactionEmoji reaction on MessageKey0xDD0x02No
15ReadReceipt"Seen up to MessageKey X"0xDD0x02No
16ForwardRe-forwarded original w/ provenance0xDD0x02Yes
17EditIn-place text replacement0xDD0x02If long
18DeleteTombstone for earlier message0xDD0x02No
19ContentRefCID of blob held by sender (file/image)0xDD0x02 or TorFederation fallback
20PresenceHeartbeat + last-activity epoch0xDD0x03No
21ChannelInviteGroup membership announcement0xDD0x03No
22ContactCardShareable federation node card0xDD0x02Maybe
- -

The remaining sections walk through each category and explain both the sender-side code path and what the bytes look like on the air.

- -

Text, Reply, Edit, Delete, Forward

- -

Text (type 0)

-

Sender path. rpc.mesh.sendtyped_messages::send_text → CBOR-encode the Text{body} variant → ratchet-encrypt → prefix 0xDD → if under 160 B, send in one SEND_TXT_MSG frame; otherwise split into Base64 chunks and send sequentially with a small inter-frame sleep so the radio doesn't overflow its TX buffer.

- -

Reply (type 13)

-

Same as Text, but the CBOR envelope carries a MessageKey pointing at the parent message (sender pubkey prefix + timestamp). The UI renders a quote banner; the wire cost is ~12 extra bytes.

- -

Edit (type 17)

-

Envelope contains the original MessageKey plus the new body. Receiver updates its local store in-place and tags the entry "edited".

- -

Delete (type 18)

-

Tombstone only: MessageKey with no body. Receivers keep the original bytes but mark the row deleted. Costs ~20 bytes on the wire.

- -

Forward (type 16)

-

Wraps original {sender_name, original_timestamp, body} so the receiver can render "Forwarded from <name>". Because the body is nested, forwards are almost always chunked.

- -

Reaction, ReadReceipt, Presence

- -

Reaction (type 14)

-

Envelope: {target: MessageKey, emoji: String}. Single-frame, single-emoji. Receiver aggregates reactions per MessageKey and shows them as inline chips (see MessageActions in neode-ui).

- -

ReadReceipt (type 15)

-

Envelope: {up_to: MessageKey}. 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).

- -

Presence (type 20)

-

Periodic heartbeat carrying {last_activity_epoch}. Broadcast on a channel (SEND_CHANNEL_TXT_MSG, cmd 0x03) rather than to a specific peer, so every listener updates their "last seen" indicator in one shot.

- -
- Like a lighthouse beacon. 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. -
- -

ContentRef — files and images without bloating the radio

-

LoRa cannot move a 500 KB image. The ContentRef type (19) solves this by sending only a pointer — a content ID (CID) plus a tiny thumbnail or description — and letting the receiver fetch the full blob out-of-band over Tor federation.

- -
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
- -
- Resolution bug fix note. An earlier revision of ContentRef 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 5f7ebf14) resolves the owning peer by DID and falls back to name-match only if DID lookup fails. -
- -

Bitcoin & Lightning over LoRa

-

Archipelago uses the mesh as a Bitcoin transport of last resort. 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.

- -

TxRelay (8) → TxRelayResponse (9) → TxConfirmation (12)

-
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} │ - └────────────────────────┘
- -

The binary framing in mesh/bitcoin_relay.rs 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.

- -

LightningRelay (10) → LightningRelayResponse (11)

-

Same shape but the payload is a BOLT11 invoice string. The relay peer pays the invoice from its own node and returns payment_hash or an error. Invoices are often long enough to chunk.

- -

Invoice (2) and PsbtHash (3)

-

These are not relays — they're peer-to-peer handoffs. Invoice delivers a BOLT11 to be paid by the recipient. PsbtHash carries just the hash of an unsigned PSBT so the recipient can retrieve the full PSBT out-of-band and co-sign.

- -

BlockHeader (7)

-

Off-grid nodes need a recent block height to avoid being fooled by stale data. A BlockHeader broadcast (sent via SEND_CHANNEL_TXT_MSG) lets anyone in range learn the latest height and hash from any peer with internet. Tiny payload: 4 bytes height + 32 bytes hash.

- -

Alerts, Coordinates, Dead-Man

- -

Alert (type 1)

-

Envelope: {kind, message, sender_contact_id}. Kinds include Emergency and Deadman. Alerts can be sent direct-to-contact (for family) or channel-broadcast (for community).

- -

Dead-man switch

-

A background task in mesh/alerts.rs sends a Deadman 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.

- -

Coordinate (type 4)

-

Envelope: {lat, lon, accuracy_m} with lat/lon as fixed-point integers to stay under 16 bytes. Used for off-grid location sharing — hiking, sailing, field ops.

- -

ChannelInvite (type 21)

-

Phase 5 group chat primitive. Announces a new channel and its membership so other nodes can subscribe. Broadcast via SEND_CHANNEL_TXT_MSG.

- -

Identity, PrekeyBundle, ContactCard

- -

Identity broadcast (marker 0x01, ARCHY:2/3)

-

The handshake. Before any ratchet session exists, a node advertises its Ed25519 public key on the mesh with an identity packet prefixed 0x01. This is how peers discover each other. The payload encodes protocol version (ARCHY:2 or ARCHY:3) and the raw pubkey. Carried by CMD_SEND_SELF_ADVERT (0x07).

- -

PrekeyBundle (type 5) and SessionInit (type 6)

-

X3DH handshake. PrekeyBundle advertises a signed prekey; SessionInit consumes it to derive the initial ratchet root key. Both ride on 0xEE (static-key encryption), because the ratchet session they're creating doesn't yet exist.

- -

ContactCard (type 22)

-

A shareable card containing {did, onion_address, pubkey, display_name}. 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.

- -
- -

RPC API — what callers actually invoke

-

Every user-facing action goes through the RPC dispatcher (api/rpc/dispatcher.rs, lines 287+) and ends in api/rpc/mesh/typed_messages.rs. The tables below show the public surface.

- -

Core commands

- - - - - - - - - - - - -
RPCEffect
mesh.statusDevice info, peer count, enabled state
mesh.peersList all discovered peers with RSSI / SNR / hop count
mesh.messagesRetrieve stored mesh messages
mesh.sendSend plain text to a specific peer
mesh.send-channelBroadcast on a channel
mesh.broadcastMesh-wide announcement
mesh.configureSet device params (name, power, channel)
mesh.debug-dumpRaw state for debugging
- -

Rich message commands

- - - - - - - - - - - - - - - - -
RPCMsg TypeNotes
mesh.send-invoiceInvoice (2)Deliver BOLT11 to peer
mesh.send-coordinateCoordinate (4)Single frame, fixed-point
mesh.send-alertAlert (1)Emergency or deadman
mesh.send-contentContentRef (19)Stores blob, sends CID
mesh.fetch-contentPulls blob via federation
mesh.send-psbtPsbtHash (3)Hash only, full PSBT via fetch
mesh.send-replyReply (13)Quoted response
mesh.send-reactionReaction (14)Emoji
mesh.send-read-receiptReadReceipt (15)Cumulative "seen up to"
mesh.forward-messageForward (16)Wraps original + provenance
mesh.edit-messageEdit (17)In-place text replacement
mesh.delete-messageDelete (18)Tombstone
- -

User Interface

-

The Vue side lives under neode-ui/src/views/mesh/ with state in stores/mesh.ts. Notable panels:

-
-
-

Mesh chat

-

Telegram-style UI with reply banners, inline reaction chips, forward/edit/delete action menu, read-receipts, outbox status.

-
-
-

MeshBitcoinPanel

-

UI for TxRelay / LightningRelay submission and confirmation tracking.

-
-
-

MeshDeadmanPanel

-

Configure dead-man interval, pick recipients, show last heartbeat time.

-
-
-

Unified inbox

-

Federation and mesh chats appear side-by-side; the transport is invisible to the user.

-
-
- -

Listener loop — how inbound traffic is decoded

-

A long-running async task in mesh/listener/mod.rs owns the serial device and feeds events into the rest of the system.

- -
loop { - event = await serial_read() - match event { - PUSH_MESSAGES_WAITING → send SYNC_NEXT_MESSAGE until empty - RESP_CONTACT_MSG_V3 → decode.rs extracts payload - → match first byte: - 0x00 plain text - 0x01 identity → frames::parse_identity - 0x02 typed CBOR plaintext - 0xEE → crypto::decrypt_static - 0xDD → session::load + ratchet::decrypt - → dispatch.rs routes typed msg - to chat store / bitcoin relay / - alerts / presence / ... - RESP_CONTACT → contact list update - RESP_SELF_INFO → record our node_id - } -}
- -

Chunk reassembly happens in listener/session.rs, keyed by (sender_pubkey_prefix, chunk_id). Incomplete chunks expire after a timeout so a lost frame doesn't leak memory.

- -

File Map

- - - - - - - - - - - - - - - - - - -
FileSizeRole
mesh/mod.rs52 KBPublic API, send paths, federation integration
mesh/protocol.rs26 KBFrame encoding/decoding, command builders
mesh/serial.rs15 KBUSB driver, device detection, handshake
mesh/crypto.rs10 KBX25519 ECDH, ChaCha20-Poly1305, HKDF
mesh/ratchet.rs16 KBDouble Ratchet implementation
mesh/message_types.rs23 KB23 typed message discriminators + CBOR schemas
mesh/bitcoin_relay.rs17 KBTxRelay / LightningRelay binary framing
mesh/listener/dispatch.rs29 KBTyped-message routing into chat/relay/alerts
mesh/listener/session.rs14 KBRatchet session persistence + chunk reassembly
mesh/x3dh.rsPrekey / SessionInit bootstrap
mesh/outbox.rsRetry queue for unacked sends
mesh/steganography.rsWeather/sensor framing for deniable traffic
api/rpc/mesh/typed_messages.rsAll mesh.* RPC handlers
neode-ui/src/stores/mesh.ts14 KBPinia store consumed by all mesh Vue views
- -
- -

Summary scoreboard

-
-
23
Message types
-
160
Bytes / frame
-
2
Transports
-
5
Wire markers
-
~6k
LoC in mesh/
-
FS
Forward-secure
-
- -
- Bottom line. 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. -
- -
- - diff --git a/docs/bitcoin-multi-version-design.md b/docs/bitcoin-multi-version-design.md index 3f74ff01..a91db710 100644 --- a/docs/bitcoin-multi-version-design.md +++ b/docs/bitcoin-multi-version-design.md @@ -1,91 +1,7 @@ # Bitcoin Multi-Version Support — Design - - -**Status:** design (2026-06-22) +**Status:** implemented — all four phases shipped (catalog schema, install-time selection, in-app switch + auto-update toggle, and the verified image build pipeline). Downgrades are guarded: the update path never offers a lower version than what is running. **Goal:** let a user choose *which* version of Bitcoin Core / Bitcoin Knots to install (latest pre-selected, older versions in a dropdown), and later switch versions or opt into auto-update — all manifest/catalog-driven, all served from @@ -94,13 +10,8 @@ changes. See also: [`docs/registry-manifest-design.md`](registry-manifest-design.md) (catalog distribution + signing this builds on), -[`docs/PRODUCTION-MASTER-PLAN.md`](PRODUCTION-MASTER-PLAN.md) (gate that must be -green first), `MEMORY → project_decoupled_app_updates`, -`MEMORY → project_manifest_driven_north_star`. +and the production test gate (which must be green first). -> **Scheduling:** this is net-new scope. It lands **after** the production test -> gate (`tests/lifecycle/run-20x.sh`) is green on `.228` + `.198`. The data- -> preservation invariant (downgrade vs. chainstate) is the highest risk here. --- @@ -111,7 +22,7 @@ green first), `MEMORY → project_decoupled_app_updates`, |-------|-------| | `apps/bitcoin-core/Dockerfile` | `FROM bitcoin/bitcoin:24.0` — a **community** image, **stale** (manifest says 28.4), no project-official Docker image exists | | `apps/bitcoin-knots/` | **no Dockerfile** — `:latest` is built/pushed by hand | -| Registry | `scripts/image-versions.sh` → `ARCHY_REGISTRY="146.59.87.168:3000/lfg2025"`; only `BITCOIN_KNOTS_IMAGE=…/bitcoin-knots:latest` pinned, no Core pin | +| Registry | `scripts/image-versions.sh` → `ARCHY_REGISTRY="source.archipelago-foundation.org/lfg2025"`; only `BITCOIN_KNOTS_IMAGE=…/bitcoin-knots:latest` pinned, no Core pin | | Tags in registry | **one tag per image**. No historical versions. | ### Version pinning @@ -260,7 +171,7 @@ free. `version`/`image` stay as the default for back-compat. - **Floating tags** (`latest`) are never advertised as a selectable "version" and never counted as an available update (already handled by `available_update_for_app`). -- **Verify on a real node** (`.228` then `.198`) and pass `run-20x` before any +- **Verify on a real node** and pass the lifecycle gate before any tag. --- diff --git a/docs/bitcoin-rpc-relay.md b/docs/bitcoin-rpc-relay.md index 8c6d7857..9c85a691 100644 --- a/docs/bitcoin-rpc-relay.md +++ b/docs/bitcoin-rpc-relay.md @@ -171,7 +171,7 @@ http://:80 For the tested node the LAN upstream was: ```text -http://192.168.1.116:80 +http://archipelago.local:80 ``` The public proxy should serve a valid TLS certificate for the chosen subdomain. @@ -276,7 +276,7 @@ Expected result: The working endpoint used in this setup was: ```text -https://shard.tx1138.com/ +https:/// ``` It was verified with: diff --git a/docs/bitcoin-version-bulletproof-rollout.md b/docs/bitcoin-version-bulletproof-rollout.md deleted file mode 100644 index f8a6773f..00000000 --- a/docs/bitcoin-version-bulletproof-rollout.md +++ /dev/null @@ -1,131 +0,0 @@ -# Bitcoin Multi-Version — Bulletproofing & Rollout (handoff) - -> **Status 2026-06-29:** code + images + catalog + frontend DONE on branch -> `bitcoin-version-bulletproof` (base commit `095a76cd`, plus the catalog-generator -> + handoff follow-ups). **.228 is the test node**: binary + frontend + catalog are -> live there; its Knots chainstate is mid-**reindex recovery** (see §5). The fleet -> rollout (OTA binary+frontend, mirror catalog publish, `:latest` repoint) is the -> **coordinated step the other agent owns** — see §4. Pairs with -> `docs/bitcoin-multi-version-design.md` (the original design). - -## 1. What was broken (root causes) - -User report: "switched Knots to `v29.3.knots20260508`, version didn't update in the UI." -Three **stacked** bugs, plus a data-corruption hazard: - -1. **Reconciler reverted the pin.** `prod_orchestrator::sync_quadlet_unit` re-rendered the - quadlet every reconcile tick using the manifest's `:latest`, ignoring the per-app - pinned version → any switch silently reverted within one tick. -2. **Entrypoint render bug.** The renderer folded the manifest `entrypoint: ["sh","-lc"]` - into `Exec=`. That only works when the image ENTRYPOINT is a passthrough shell wrapper. - The versioned images use `ENTRYPOINT ["bitcoind"]`, so `Exec=sh -lc …` became - `bitcoind sh -lc …` → `unexpected token 'sh'` → crash loop. -3. **Image USER divergence.** The versioned images were built `USER bitcoin` (uid 1000); - the legacy `:latest` ran as **root**. Chain data is owned by the `data_uid` - (host 100101 / container uid 102). Root reads it via `CAP_DAC_OVERRIDE` (granted in the - manifest); uid-1000 cannot → `Error initializing block database`. -4. **Data hazard (already hit on .228).** Repeated failed starts under mixed UIDs left - bitcoind's two LevelDBs (`blocks/index/` + `chainstate/`) truncated to KB stubs while - the raw `blocks/blk*.dat` (797 GB) stayed intact. Recovery = `bitcoind -reindex` from - local blocks (no re-download). The uniform-root image fix (below) removes the mixed-UID - cause going forward; the proper switch flow was already data-safe (600s stop grace, - clean stop→rm→recreate, conflict-stops the other impl — they share port 8332 + datadir - `/var/lib/archipelago/bitcoin`). - -## 2. What was fixed (all on the branch) - -- **Renderer** (`core/archipelago/src/container/`): - - `prod_orchestrator.rs`: factored `resolve_catalog_image()` (catalog/pinned-version → - image) and call it in BOTH `install_fresh` and `sync_quadlet_unit` — the pin now - survives reconcile. - - `quadlet.rs`: emit a real `Entrypoint=` + `Exec=` instead of folding; - `exec_changed` now also diffs `Entrypoint=` so the recreate fires. Validated against - the live podman 5.4.2 quadlet generator. -- **Images** (`scripts/build-bitcoin-image.sh`, `apps/bitcoin-{knots,core}/Dockerfile`): - removed `USER bitcoin` → run as **container-root** like legacy (still 100% rootless: - container-root maps to the unprivileged host service user; `CAP_DAC_OVERRIDE` from the - manifest lets bitcoind read the `data_uid`-owned datadir). **All** images rebuilt root + - pushed to the mirror (`146.59.87.168:3000/lfg2025`): - - Knots: `29.3.knots20260508`, `29.3.knots20260507`, `29.3.knots20260210`, `29.2.knots20251110` - - Core: `25.2 26.2 27.2 28.4 29.2 29.3 30.2 31.0` + `latest` (→31.0) -- **Catalog** (`scripts/generate-app-catalog.sh` VERSIONS map + regenerated - `releases/app-catalog.json`): Knots & Core `versions[]` populated; the generator now - forces top-level `version` == the `default` entry's version (the `169ff2e2` invariant) - regardless of the manifest version. Knots `latest` entry points at the newest **dated** - image (`29.3.knots20260508`) so "Always use latest" = newest on fixed-binary nodes. -- **Frontend** (`neode-ui/`): - - `AppSidebar.vue`: rename the latest option to **"Always use the latest version"** - (no `v` prefix), fix right padding, and `pickSelection()` guarantees the bound value is - a real option (fixes the blank dropdown). - - New `components/InstallVersionModal.vue`: full-screen version chooser shown from the - App Store / Discover **card** install button for multi-version apps — app icon + - "Install ", latest pre-selected. Wired in `Discover.vue handleInstall`. - - i18n keys: `appDetails.alwaysUseLatestVersion`, `marketplace.installModalTitle/Hint`. - -## 3. Current live state on .228 (test node) - -- Binary with both renderer fixes: **deployed** (`/usr/local/bin/archipelago`). -- New frontend bundle: **deployed** to `/opt/archipelago/web-ui` (hard-refresh to see it). -- Updated catalog: placed at `/var/lib/archipelago/app-catalog.json` (local override — - will refresh from the mirror's OLDER copy at the next hourly fetch until §4 publishes it). -- Knots: `bitcoin-knots` service held **stopped** (`package.stop`, user_stopped); - a detached `bitcoin-knots-reindex` container is rebuilding the index+UTXO (§5). - -## 4. Remaining — coordinated fleet rollout (OTHER AGENT) - -Do this together with the other workstream's release, AFTER both are ready: - -1. **Merge** branch `bitcoin-version-bulletproof` into the release line. -2. **Build + OTA** the binary + frontend (these carry the renderer fix + UI). The renderer - fix is a **hard prerequisite** for the new images everywhere — see fleet-safety below. -3. **Publish the catalog** to the mirror (push `releases/app-catalog.json` to gitea-vps2 - `main`, the raw URL nodes fetch hourly). The current catalog is **fleet-safe even before - the binary lands**: unpinned/auto-update nodes resolve via the manifest's floating - `:latest` (still the legacy image); only explicit version selection (needs the new UI) - uses the new root images. -4. **Only AFTER the binary is fleet-wide:** optionally repoint the `bitcoin-knots:latest` - tag → `29.3.knots20260508` (root) and simplify the catalog `latest` entry back to the - `:latest` tag. **Do NOT repoint `:latest` before then** — old-binary nodes fold - `Exec=sh -lc …` and would crash on an `ENTRYPOINT ["bitcoind"]` image. (Core never - worked on old binaries — it always shipped `ENTRYPOINT ["bitcoind"]` — so Core has no - such constraint.) -5. **Verify the full switch matrix** on a healthy node (§6). - -## 5. Finishing .228's reindex (OTHER AGENT owns this — not babysat by the original author) - -The detached `bitcoin-knots-reindex` container runs the new **root** `29.3.knots20260508` -image with `-reindex -server=0` against `/var/lib/archipelago/bitcoin`. It holds the datadir -lock, so the managed service (held stopped) can't collide. When it has connected blocks up -to ~the prior tip (height ≥ ~955800) it's done; then: - -```sh -# on .228 (SSH/sudo/UI pw all: ThisIsWeb54321@) -podman stop -t 600 bitcoin-knots-reindex && podman rm bitcoin-knots-reindex -# start the managed service via RPC (sets desired=running, clears user_stopped): -# package.start {id: bitcoin-knots} (POST https://127.0.0.1/rpc/v1, CSRF: echo csrf_token cookie as X-CSRF-Token) -# verify: -podman exec bitcoin-knots sh -lc '$(command -v bitcoind) --version | head -1' # → v29.3.knots20260508 -# RPC up → the Bitcoin UI populates; it syncs the gap to tip. -``` -The "Bitcoin RPC connection refused (127.0.0.1:8332)" the UI shows is EXPECTED until this -swap (reindex runs with RPC off). - -## 6. Switch-matrix test plan (what "bulletproof" must prove) - -On a healthy node, each step must end with bitcoind running + RPC answering + syncing, with -NO `Error initializing block database` and NO data loss: -- Knots: switch `latest` → `29.3.knots20260507` → `29.3.knots20260210` → back to `latest`. -- Core: install `latest`; switch `31.0` → `28.4.0`. -- **Knots ↔ Core** (shared datadir/port): Knots→Core upgrade path (Core ≥ data version) and - the reverse. **Cross-major DOWNGRADES** (e.g. 29.x data → Core 28.4) legitimately need a - reindex — the UI already surfaces a downgrade warning; confirm it does and that confirming - reindexes cleanly rather than crash-looping. -- Reboot survival after each switch. - -## 7. Notes / assumptions - -- **"29.2"** in the request doesn't exist as a Knots build (404 upstream); added as **Bitcoin - Core 29.2** (exists). Revisit if a Knots 29.2 was meant. -- Reindex is unavoidable ONLY because .228's index was already corrupted by the pre-fix - crash loop; a normal switch on the fixed binary does NOT reindex. -- Creds for .228: SSH/sudo + UI/RPC all `ThisIsWeb54321@`. diff --git a/docs/bulletproof-containers.md b/docs/bulletproof-containers.md index 6b0940ef..f75e845e 100644 --- a/docs/bulletproof-containers.md +++ b/docs/bulletproof-containers.md @@ -1,15 +1,35 @@ -# Bulletproof Containers for Beta +# Bulletproof Containers -**Status**: plan agreed 2026-04-22, implementation started. -**Target**: zero-manual-intervention container lifecycle for the beta launch. A user installs, uninstalls, reboots, updates, or loses power — every combination must leave the node in a known-good state without SSH. -**Project memory**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md` -**Failure log**: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md` +**Status**: historical design record (agreed 2026-04-22). The *architecture* — +level-triggered, desired-state reconciliation — was adopted and is live. Several +specifics below were not built as written, so read this for the incident history +and the reasoning, not as a description of the code. For how the lifecycle +actually works today, read [Container lifecycle](container-lifecycle.md). + +What became of the plan, verified against the tree: + +| Item | Outcome | +|---|---| +| Level-triggered reconciler | ✅ Shipped, but as `container/boot_reconciler.rs` + `container/prod_orchestrator.rs`. The `core/archipelago/src/reconcile/` module laid out below (`desired.rs`/`current.rs`/`diff.rs`/`apply.rs`/…) **was never created** — no file in it exists | +| FM5 post-OTA probe + auto-rollback | ✅ Shipped — `update-pending-verify.json` (`update.rs:100`) | +| FM4 `host.archipelago` alias | ✅ Shipped — `AddHost=host.archipelago:10.89.0.1` in generated units | +| FM1/FM3 Quadlet ownership | ◐ Partial. Companion UIs run as Quadlet units; **main app containers do not** — `use_quadlet_backends` still defaults false, so the "v1.7.48+ full migration" below has not happened | +| FM2 bitcoin.conf drift | ◐ Solved differently. There is no `reconcile::derived::render_bitcoin_conf`; instead bitcoind is run with an explicit `-conf` derived from secrets at each start and stale datadir configs are removed (`remove_stale_bitcoin_conf`) | +| FM6 podman corrupt-state self-heal | ❌ **Not implemented.** No `podman system renumber` recovery, no startup probe for "invalid internal status". The failure that made a node unreachable in 2026-04 would still need manual SSH | + +Note also that the unit paths below say `/etc/containers/systemd/`; units are +actually written per-user to `~/.config/containers/systemd/` +(`quadlet.rs:DEFAULT_REL_UNIT_DIR`), since the whole path is rootless. + +**Target**: zero-manual-intervention container lifecycle. A user installs, +uninstalls, reboots, updates, or loses power — every combination must leave the +node in a known-good state without SSH. --- ## Why we're doing this -The v1.7.38 and v1.7.39 rollouts on 2026-04-22 exposed a cluster of container-lifecycle failures that required manual SSH recovery on every affected node (.116, .198, .228, .253). If a user had been on those nodes, they'd have been stuck with "can't reach" or 500 errors and no path forward. We can't ship beta with this class of failure on the table. +The v1.7.38 and v1.7.39 rollouts on 2026-04-22 exposed a cluster of container-lifecycle failures that required manual SSH recovery on every affected node. If a user had been on those nodes, they'd have been stuck with "can't reach" or 500 errors and no path forward. We can't ship beta with this class of failure on the table. The pattern under every failure: **the canonical source of truth had the right answer, but derived state drifted away from it and nothing noticed or fixed it.** @@ -297,18 +317,3 @@ Ordered by likelihood × severity: ### Tor - [rend-spec-v3](https://github.com/torproject/torspec/blob/main/rend-spec-v3.txt) — descriptor lifetime + republish cadence - [stem](https://stem.torproject.org/) — Python Tor controller for `HS_DESC UPLOADED` waits - ---- - -## To resume - -1. Read project memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/project_reconcile_architecture.md` -2. Read failure-mode memory: `~/.claude/projects/-home-archipelago-Projects-archy/memory/feedback_container_lifecycle_failure_modes.md` -3. Check task list for current release (should start with v1.7.41) -4. Current state on fleet as of 2026-04-22: - - All 4 mirrors (tx1138, gitea-local, .160, .168) synced to v1.7.40-alpha - - .116, .198, .228, .253 healed manually via `systemd-run chmod 755 /opt/archipelago/web-ui` - - .228 still has stale `bitcoin.conf` rpcauth (regenerated during triage; will drift again until v1.7.43) - - .228 UI companions (archy-bitcoin-ui, archy-lnd-ui) keep vanishing (Quadlet migration in v1.7.45+ fixes) - - .160 Gitea required `podman system renumber` recovery (v1.7.44 automates this) -5. Implementation is in progress on `main` branch — next edit is `core/archipelago/src/update.rs` for v1.7.41. diff --git a/docs/combined-test-plan-2026-07-22.md b/docs/combined-test-plan-2026-07-22.md deleted file mode 100644 index cd0308ec..00000000 --- a/docs/combined-test-plan-2026-07-22.md +++ /dev/null @@ -1,173 +0,0 @@ -# Combined test session — 2026-07-22 batch (one sitting) - -Staged on **framework-pt** (`100.65.115.109`) AND **archi thinkpad** (this -machine's node) so everything can be tested in one pass. Items marked ✅ were -already verified by the agent on a node; ❑ items need a human. - -**What's in this batch:** mesh message/DM persistence across restarts · -first-message + DM announce fix · 15s announce poll · radio hot-swap modal -(probe / keep-as-is / apply-settings) · whisper beam-1 (release-gated, see §D) · -real-time wallet push (0-conf tx shows in seconds) · calm Lightning -"still starting" notice · external tx-explorer fallback with consent modal + -wallet-settings On-chain tab · apps open ABOVE modals with the launch -animation · mempool installs no longer blocked by a resyncing ElectrumX · -[pending: other agents' two push sets — section F fills in when their code -lands]. - -## H. Wallet & explorer (new — test on the thinkpad node, it's pruned) - -1. ❑ **Real-time tx display:** send a small on-chain amount to this node's - wallet → the balance and the yellow "unconfirmed" transaction appear - within a few seconds of broadcast, no refresh, no wallet action. -2. ❑ **External explorer consent:** with no local Mempool app running, tap a - transaction → amber consent modal explains it opens on another node's - mempool (default tx1138.com, placeholder mempool.guide, editable) → - Open Explorer opens `/tx/` in a new tab. Tick "don't ask - again" and confirm the next tap opens directly. -3. ❑ **Wallet Settings → On-chain tab:** explorer URL editable, warning shown, - "don't warn" toggle; tabs now read Channels / Cashu / Fedi / Ark / On-chain - and fit on one row (check mobile too). -4. ❑ **Modal → app animation:** on a node WITH Mempool running, open - Transactions and tap a tx → the Mempool app animates in ABOVE the modal - (previously loaded invisibly underneath); closing it returns to the modal. -5. ❑ **Lightning "still starting":** right after a node restart, try opening a - channel → either it just works (silent retry) or a calm amber ⏳ notice - appears — never the red "Failed to connect to peer" error. - ---- - -## A. Staged state (agent-verified before you start) - -- ✅ Dev binary (persistence + announce seeder + hot-swap) on - `/usr/local/bin/archipelago`, service healthy, no crash-loop. -- ✅ Frontend bundle with the new device modal at `/opt/archipelago/web-ui`. -- ✅ Seeder re-ran: `automations.yaml` upgraded v1→v2 (first-message announce), - `configuration.yaml` rest block at `scan_interval: 15`, HA restarted clean. -- ✅ `mesh-messages.json` persisting + restored across a service restart. -- ✅ `mesh.probe-device` returns real firmware details for the plugged stick. - -## B. Mesh history survives restarts (the "messages go missing" fix) - -1. ❑ Open Mesh chat — your existing DM/channel history from today is visible. -2. ❑ Send one channel message and one DM (either direction). -3. ❑ Reboot the whole node (not just the service). After it's back: history - still there, including the two new messages, correct timestamps/senders. -4. ❑ Send a NEW message to another node right after the reboot and confirm the - other side receives it (this exercises the send-seq fix — before it, the - first post-reboot sends were silently dropped by peers as replays). - -## C. Speaker announcements - -1. ❑ Have another node send a **public channel** message → speaker announces - sender + text within ~15s (was ~30s). -2. ❑ Have another node send you a **DM** → speaker announces it the same way. -3. ❑ Restart Home Assistant (or the node) → the last old message is NOT - re-announced (no announce storm). -4. ❑ (First-message case — the original bug — only reproducible on a node with - an empty history: optional, covered by agent verification of the guard.) - -## D. Voice (regression + speed) - -1. ❑ "Hey Jarvis, what's the block height" and one fuzzy phrasing — same - correct answers as before (no behavior change is the pass condition). -2. ⓘ The ~45% faster speech-to-text (whisper beam-1) ships via the **signed - catalog in the release** — it is NOT on the node during this test session. - Benchmarked on this exact hardware: identical transcripts, 0.94s → 0.51s. - -## E. Radio hot-swap modal (your Reticulum stick is already plugged in) - -1. ❑ Open the web UI anywhere — within ~30s a "Mesh Radio Detected" modal - appears showing the stick on `/dev/ttyACM0`, with a card of what's on it - (firmware badge: Reticulum RNode / MeshCore / Meshtastic + current - name/region/channels where the firmware exposes them). -2. ❑ Press **Keep As Is** → mesh connects using the radio exactly as flashed - (check Mesh → Device tab: connected, firmware type correct; nothing on the - radio changed). -3. ❑ Unplug the stick, plug the old MeshCore one → the modal appears AGAIN - (every plug re-triggers, same or different /dev path). -4. ❑ This time press **Set Up with Archipelago Settings** → second screen - shows channel `archipelago`, your region, and the node's RF params (the - validated Portugal preset on this fleet) BEFORE anything is written; - confirm → radio provisions and joins the mesh. -5. ❑ Swap sticks once more with no UI interaction except "Keep As Is" — chat - still works end-to-end afterwards (hot-swap without ceremony). - -## F. Companion pairing + mobile onboarding (other agent — push set #1, MERGED) - -1. ❑ Companion app: pair with the node via the new named QR (device tokens) — - pairing completes instantly, device appears in the paired-devices list. -2. ❑ Remote access now rides the embedded FIPS mesh (WireGuard replaced): - with the phone OFF the node's WiFi, the companion still reaches the node. -3. ❑ The reworked mobile onboarding/intro overlay screens flow correctly on - first launch of the new APK (in-tarball APK is the 27MB build). -4. ❑ (Push set #2 from the other agents is still pending — the release waits - for it; this staged build does NOT include it yet.) - -## G. Quick regressions - -1. ❑ Pine launcher page (:10380) still shows the live node card; "Connect - Pine to WiFi" button loads without JS errors. -2. ❑ Mobile Home: wallet card directly under My Apps (G5 from the voice epic). -3. ❑ Mesh RF settings panel (Mesh → Device) still loads and saves. - -## H. LoRa radio firmware flashing (Heltec V3/V4, new — extends Section E) - -Full v1 scope is 3 firmware families × 2 boards (6 cells); mark each cell -tested on real hardware vs. code-reviewed only as this is run. - -1. ❑ From the hot-swap modal's step 1 (device already probed), press - **Flash Firmware…** → new step shows firmware-family + board pickers and - the erase-confirmation checkbox; "Erase & Flash Now" stays disabled until - family, board, AND the checkbox are all set. -2. ❑ Confirm what's currently on the test stick via the existing probe - BEFORE flashing it — don't flash the only known-good device without a - fallback board on hand. -3. ❑ Prefer a spare Heltec V3/V4 for the first destructive erase+flash run; - only exercise a primary/in-use stick once the flow is proven safe. -4. ❑ MeshCore → Heltec V3: erase + write completes, progress bar and log - tail update live, ends at "Flash complete". -5. ❑ Meshtastic → Heltec V3: same, using the extracted `*.factory.bin` from - the esp32s3 release zip. -6. ❑ Reticulum/RNode → Heltec V3: `archy-rnodeconf --autoinstall` path - completes (no raw esptool erase/write step for this family — see - `mesh/flash.rs` doc comment). -7. ❑ Repeat 4-6 against a Heltec V4. Confirmed 2026-07-23 on real hardware: - V4 uses the ESP32-S3's native-USB JTAG/serial peripheral (vid:pid - 303a:1001, generic to every native-USB ESP32-S3 board, not V4-specific) - — so unlike V3's CP2102 bridge chip, V4 is permanently NOT auto-matchable - by vid:pid. Board auto-detect should fail closed for it every time - (manual board selection required, "couldn't confirm automatically" - warning shown) — this is expected steady-state behavior, not a gap to - close later. -8. ❑ After a successful flash, the modal automatically re-probes and shows - the NEW firmware's badge/details — same as unplugging and replugging - (Section E item 3), but without physically touching the cable. -9. ❑ Deliberately test a failure path once (disconnect the board mid-write, - or point at a bad cached asset) — confirm the error surfaces in the - progress log AND that `docs/troubleshooting.md`'s "LoRa radio firmware - flash failed" recovery steps (BOOT+RST bootloader entry, manual esptool/ - rnodeconf command) actually get the board back to a flashable state. -10. ❑ Cancel button only appears (and only works) while still in the - "Downloading firmware…" stage — once erasing/writing starts, no cancel - affordance is offered. -11. ❑ **Boot-loop regression (2026-07-23 incident)**: after a *failed* flash - (e.g. kill network access mid-download to force a failure), confirm the - mesh listener does NOT auto-resume — `journalctl -u archipelago` should - show a single `Leaving mesh listener stopped after failed flash` line - and then go quiet for that device, not a repeating `mesh::serial: - Opened serial port... Starting Meshcore handshake` cycle every few - seconds. Reconnect manually via the hot-swap modal afterward and confirm - it connects normally (the board itself should be untouched — the - download fails before esptool/rnodeconf ever runs). -12. ❑ Separately, force a device to flap connected/disconnected a few times - in under 20s each (e.g. a marginal USB connection) and confirm - `reconnect_delay` in the logs actually escalates (5s → 10s → 20s → ...) - rather than resetting to 5s on every attempt — see - `STABLE_SESSION_THRESHOLD` in `mesh/listener/mod.rs`. - ---- - -After this passes: fold the batch + other agent's work into the next release -(OTA binary + frontend tarball + catalog regen/sign/publish for pine-whisper -3.4.2), then re-run `tests/lifecycle/run-gate.sh` on .228 (back online as -Tailscale `shorty-s`). diff --git a/docs/companion-pairing-qr.md b/docs/companion-pairing-qr.md index a5290f1d..e8242aed 100644 --- a/docs/companion-pairing-qr.md +++ b/docs/companion-pairing-qr.md @@ -30,7 +30,7 @@ Query parameters: | param | required | meaning | |-------|----------|---------| | `v` | yes | Payload version, currently `1`. Reject/ignore unknown majors gracefully — show "please update the app". | -| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.168.1.228`, etc. No trailing slash guaranteed either way — normalize. | +| `url` | yes | Full origin the app should connect to, scheme included: `https://demo.archipelago-foundation.org`, `http://archipelago.local`, `http://192.0.2.10`, etc. No trailing slash guaranteed either way — normalize. | | `name`| no | Display name for the server entry. Real nodes send the configured server name, or `My Archipelago` when it's still the factory default. | | `tok` | no | **Device token** minted via `auth.createDeviceToken` when the QR is rendered. The app logs in with `{"method":"auth.login","params":{"token":"…"}}` — same endpoint, same rate limiter, skips TOTP (the token was minted from an authenticated session). Long-lived until re-minted (re-showing the pair screen replaces the `companion` token) or revoked (`auth.revokeDeviceToken`). Scan → instantly connected, no typing. | | `pw` | no | Login password. **Only present in the public demo** (shared demo password `entertoexit`). Real nodes never embed a password — the frontend doesn't have it. | @@ -43,7 +43,7 @@ Query parameters: Examples the web UI actually emits: - Demo: `archipelago://pair?v=1&url=https%3A%2F%2Fdemo.archipelago-foundation.org&pw=entertoexit` -- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.168.1.228` +- Real node, browsed via LAN IP: `archipelago://pair?v=1&url=http%3A%2F%2F192.0.2.10` - Real node kiosk (UI runs on localhost, so it advertises the mDNS name from `system.get-hostname`): `archipelago://pair?v=1&url=http%3A%2F%2Farchipelago.local` diff --git a/docs/container-architecture.html b/docs/container-architecture.html deleted file mode 100644 index ce1e6e96..00000000 --- a/docs/container-architecture.html +++ /dev/null @@ -1,4279 +0,0 @@ - - - - - -Archipelago System Architecture - - - -
-
-

Archipelago System Architecture

-

Complete interactive map of every layer, protocol, container, and data path · Click anything to expand

-
- - -
-
- -
- - - - -
- - -
- - - - -
- -
-
34
Containers
-
260+
RPC Methods
-
9
Protocols
-
LUKS2
Encryption
-
Rootless
Podman
-
8 GB+
Recommended RAM
-
- -
-
-
- Kiosk Display - Layer 8 · Physical -
-
X11 + Chromium fullscreen on VT7, showing the web UI directly on connected monitor
-
The TV/monitor screen you see when the box is plugged in. No keyboard needed — it just shows the dashboard.
-
-
-
- Web UI (Vue.js SPA) - Layer 7 · Application -
-
Vue 3 + TypeScript + Pinia frontend served by nginx, communicates via JSON-RPC and WebSocket
-
The dashboard you use in your browser to manage everything — apps, Bitcoin, settings.
-
-
-
- Rust Backend - Layer 6 · Service -
-
Archipelago binary on 127.0.0.1:5678 — RPC server, auth, session management, container orchestration, Tor control
-
The brain of the system. Handles login, manages containers, talks to Bitcoin, and coordinates everything.
-
-
-
- Container Layer (Podman Rootless) - Layer 5 · Isolation -
-
34 containers on archy-net (internal DNS) and bridge networks, managed by rootless Podman
-
Each app runs in its own sandbox. If one app crashes or gets hacked, the others are unaffected.
-
-
-
- Network Layer - Layer 4 · Network -
-
Nginx reverse proxy (80/443), Tailscale mesh VPN, Tor hidden services, UFW firewall
-
Controls what traffic goes where. One front door (nginx) routes requests to the right app. Tor makes you reachable without exposing your IP.
-
-
-
- Encryption Layer - Layer 3 · Security -
-
LUKS2 full-disk encryption on /var/lib/archipelago with auto-detected cipher (AES-XTS or ChaCha20-Adiantum)
-
All your Bitcoin data, passwords, and app data are encrypted. If someone steals the hard drive, they get nothing.
-
-
-
- Operating System - Layer 2 · OS -
-
Debian 12 (Bookworm) minimal — systemd services, x86_64/ARM64, debootstrap custom base
-
The operating system. Debian is rock-solid Linux used by servers worldwide. We strip it down to just what's needed.
-
-
-
- Hardware / Boot - Layer 1 · Physical -
-
UEFI + BIOS dual-boot, GPT partitions, USB flash installer, auto-detect disk + network + CPU features
-
The physical computer. Flash a USB stick, boot from it, and the installer sets everything up automatically.
-
-
- -
-

Container Dependency Chain

-
// Startup order: Databases → Core → Services → Apps -// Health monitor restarts in this order too - -mempool-db ───┐ -btcpay-db ───┤ - - ├──→ bitcoin-knots ──→ electrumx - - ┌────┴────┬──────────┬──────────┐ - - lnd fedimint mempool-api nbxplorer - - fedi-gw mempool-web btcpay - - └──→ lnd-ui - -// IndeedHub stack (independent) -ih-postgres ──→ ih-api ──→ indeedhub -ih-redis ──→ ih-api -ih-minio ──→ ih-api - -// Penpot stack (independent) -penpot-pg ──→ penpot-be ──→ penpot-fe -penpot-vk ──→ penpot-be ──→ penpot-exp - -// Tier 3: All independent — start in any order -filebrowser grafana homeassist jellyfin photoprism -vaultwarden nextcloud searxng uptime-kuma ollama -onlyoffice nginx-pm portainer
-
- -

System Resources

- -
- -
-
Hardware Requirements
- - - - - - - - -
Minimum RAM4 GB
Recommended RAM8 GB+ (core stack uses ~8–10 GB)
Minimum Disk32 GB SSD
Recommended Disk1 TB+ NVMe SSD
CPUx86_64 or ARM64, 4+ cores recommended
NetworkEthernet recommended (WiFi supported)
TargetsHP ProDesk, Intel NUC, any standard PC
-
- -
-
Memory Budget (all containers)
- - - - - - - - - - -
Bitcoin Knots2 GB (1 GB low-memory mode)
ElectrumX1 GB
LND512 MB
BTCPay + DB1.5 GB (1 GB + 512 MB)
Mempool stack1.3 GB (512+256+512 MB)
Fedimint + GW1 GB (512+512 MB)
Ollama (AI)4 GB (1 GB low-memory)
All other apps128–1024 MB each
Total allocated~20 GB (not all run simultaneously)
-
- -
- -
- -
-
Disk Usage by Component
- - - - - - - - - -
Bitcoin blockchain (full)~600 GB
Bitcoin (pruned)~550 MB
ElectrumX index~50 GB
LND channels + wallet~1 GB
Databases (all)~2–10 GB
Container images~15 GB
Ollama models1–50 GB (varies)
Media (Jellyfin/Photos)User-determined
-
- -
-
Network Ports (External)
- - - - - - - -
80 / 443Nginx → Web UI, app proxies
8333Bitcoin P2P (node discovery)
9735Lightning P2P (payment routing)
50001Electrum protocol (wallet queries)
22SSH (admin access)
Internal only8332 (RPC), 10009 (gRPC), 8080 (REST), 8999, 4080, 3000, 3001, 8082–8096, 9000…
-
- -
- -
-
-
Container Security Defaults
- - - - - - - -
Capabilities--cap-drop=ALL then add only needed: CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE. Some get NET_RAW (LND), NET_BIND_SERVICE (Vaultwarden, nginx-pm, LND-UI).
Privileges--security-opt=no-new-privileges on all containers
Health checksAll containers: --health-interval=120s --health-timeout=5s --health-retries=3
Low-memory modeAuto-detected: Bitcoin 2G→1G, PhotoPrism 1G→512M, OnlyOffice 2G→1G, Ollama 4G→1G
Disk modeAuto: if disk <1TB → Bitcoin prune=550, dbcache=512M. If ≥1TB → full txindex, dbcache=4G
RPC methods260+ registered across 20+ namespaces (auth, seed, package, bitcoin, lnd, identity, tor, nostr, mesh, federation, dwn, system, monitoring…)
-
-
- -
- - - - -
- -
- -
-
- 1. Hardware / Boot - Physical -
-
UEFI + BIOS dual-boot installer, GPT partition table, auto-detect hardware
-
You flash a USB drive, plug it in, and the computer installs itself. Works on both old and new machines.
-
-

Partition Layout

-
    -
  • 1 MB — BIOS boot (for older machines without UEFI)
  • -
  • 512 MB — EFI System Partition (UEFI boot files)
  • -
  • 30 GB — Root filesystem (Debian OS, binaries, Podman storage)
  • -
  • Remaining — LUKS2 encrypted → /var/lib/archipelago (all user data)
  • -
-

Installer Features

-
    -
  • Auto-detects target disk (largest available, prefers NVMe)
  • -
  • Auto-detects AES-NI CPU support for encryption cipher selection
  • -
  • Debootstrap minimal Debian 12 (no bloat)
  • -
  • Configures GRUB for both UEFI and legacy BIOS
  • -
  • Creates archipelago user (UID 1000) with Podman subuid/subgid mapping
  • -
-

Hardware Targets

-
    -
  • x86_64: HP ProDesk, Intel NUC, any standard PC
  • -
  • ARM64: Planned but not primary target yet
  • -
  • Minimum: 4 cores, 8GB RAM, 256GB disk
  • -
  • Recommended: 4+ cores, 16GB RAM, 1TB+ disk (for full Bitcoin node)
  • -
-
-
- -
-
- 2. Operating System — Debian 12 - OS -
-
Minimal Debian Bookworm with systemd, custom kernel parameters, hardened services
-
The foundation. Debian is one of the most stable and trusted Linux versions. We remove everything unnecessary.
-
-

Key Packages

-
    -
  • podman — Rootless container runtime (replaces Docker)
  • -
  • nginx — Reverse proxy (front door for all web traffic)
  • -
  • tor — Privacy network daemon
  • -
  • tailscale — Mesh VPN for remote access
  • -
  • chromium — Kiosk browser for local display
  • -
  • cryptsetup — LUKS disk encryption
  • -
  • xorg — Display server for kiosk mode
  • -
-

Kernel Tuning

-
    -
  • net.ipv4.ip_unprivileged_port_start=80 — lets rootless Podman bind ports 80+
  • -
  • vm.overcommit_memory=1 — for Redis/Valkey container requirements
  • -
  • User namespaces enabled for rootless containers
  • -
-

Users

-
    -
  • archipelago (UID 1000) — main user, owns all containers and data
  • -
  • root — only for Tor management, LUKS, and boot services
  • -
-
-
- -
-
- 3. Encryption — LUKS2 - Security -
-
Full-disk encryption on all user data with auto-detected hardware-accelerated ciphers
-
Your Bitcoin wallet, passwords, photos — everything is scrambled. Without the key, the data is just noise.
-
-

Cipher Selection (auto-detected at install)

-
    -
  • With AES-NI: aes-xts-plain64 (AES-256-XTS) — hardware-accelerated, fastest option
  • -
  • Without AES-NI: xchacha20,aes-adiantum-plain64 (ChaCha20-Adiantum) — fast on any CPU
  • -
-

Key Derivation

-
    -
  • PBKDF: Argon2id (memory-hard, GPU-resistant)
  • -
  • Key size: 512 bits
  • -
  • Key file: /root/.luks-archipelago.key (4KB random, auto-generated)
  • -
-

What's Encrypted

-
    -
  • Bitcoin blockchain data
  • -
  • LND wallet & Lightning channels
  • -
  • All database volumes (PostgreSQL, MariaDB)
  • -
  • All app data directories
  • -
  • Secrets (RPC passwords, macaroons, API keys)
  • -
  • Tor hidden service keys
  • -
-

What's NOT Encrypted

-
    -
  • Root filesystem (OS binaries, system config) — no sensitive data here
  • -
  • EFI/boot partitions (must be readable to start)
  • -
-
-
- -
-
- 4. Network Layer - Network -
-
Nginx reverse proxy, Tailscale mesh VPN, Tor hidden services, UFW firewall
-
One front door (nginx) for all traffic. Tor lets people reach you without knowing your real address. Tailscale lets YOU reach the box from anywhere.
-
-

Nginx Reverse Proxy

-
    -
  • Listens on :80 (HTTP) and :443 (HTTPS with self-signed cert)
  • -
  • Serves Vue.js SPA at /
  • -
  • Proxies backend at /rpc/v1, /ws, /health
  • -
  • Proxies each app at /app/{name}/
  • -
  • Rate limits: auth (3/s), RPC (20/s), P2P (10/s)
  • -
  • Security headers: CSP, HSTS, X-Frame-Options, Permissions-Policy
  • -
  • Injects nostr-provider.js into all app iframes
  • -
-

Tor

-
    -
  • System-level Tor daemon (not containerized)
  • -
  • SOCKS5 proxy at 127.0.0.1:9050
  • -
  • Hidden services for: web UI, LND, BTCPay, Mempool, Fedimint
  • -
  • Backend manages services via privileged helper script
  • -
  • Containers connect via host.containers.internal:9050
  • -
-

Tailscale

-
    -
  • Mesh VPN — access your node from anywhere via encrypted tunnel
  • -
  • Runs as system service or container
  • -
  • Provides stable IP (e.g., 100.x.x.x) regardless of network
  • -
-

Firewall (UFW)

-
    -
  • DEFAULT_FORWARD_POLICY=ACCEPT (required for rootless Podman)
  • -
  • Allow: 22 (SSH), 80 (HTTP), 443 (HTTPS), 8333 (Bitcoin P2P), 9735 (Lightning P2P)
  • -
-
-
- -
-
- 5. Rust Backend - Service -
-
Archipelago binary — JSON-RPC server, auth, RBAC, container management, Tor control, DID identity
-
The control center. Every button you click in the dashboard sends a message here, and it makes things happen.
-
-

Bind

-
    -
  • 127.0.0.1:5678 — localhost only, nginx handles external access
  • -
-

Endpoints

-
    -
  • POST /rpc/v1 — JSON-RPC 2.0 (all commands)
  • -
  • WS /ws — WebSocket (live updates, container status, logs)
  • -
  • GET /health — Health check (no auth)
  • -
  • /archipelago/ — P2P node messaging
  • -
  • /content — Content sharing (via Tor)
  • -
  • /dwn — Decentralized Web Node protocol
  • -
-

Key RPC Methods

-
    -
  • auth.* — login, TOTP, password change, onboarding
  • -
  • seed.* — generate, verify, restore wallet seeds
  • -
  • package.* — container CRUD (create, start, stop, remove)
  • -
  • node.* — DID identity, signing, backups
  • -
  • app.* — marketplace, app config
  • -
-

Systemd Service

-
    -
  • Type: notify (signals readiness to systemd)
  • -
  • Watchdog: 300s (must ping every 120s or gets killed)
  • -
  • MemoryMax: 4GB
  • -
  • Crash recovery on startup (detects unclean shutdown, restarts containers)
  • -
  • Periodic container state snapshots for recovery
  • -
-
-
- -
-
- 6. Container Layer — Rootless Podman - Isolation -
-
34 containers, custom bridge network (archy-net), UID mapping, security caps, memory limits
-
Apps run in sealed boxes. They can only see what we let them see, use only the memory we allow, and can't mess with each other.
-
-

Rootless Podman

-
    -
  • All containers run as user archipelago (UID 1000)
  • -
  • No root access required — even if a container is compromised, it can't escalate to root
  • -
  • Subuid/subgid: archipelago:100000:65536
  • -
  • Socket: /run/user/1000/podman/podman.sock
  • -
-

Networks

-
    -
  • archy-net (custom bridge) — Bitcoin stack + services, containers can reach each other by name (DNS)
  • -
  • bridge (default) — standalone apps, port-mapped only
  • -
  • host — Tailscale only (needs full network access)
  • -
-

Security Defaults (per container)

-
    -
  • --cap-drop=ALL then add only what's needed (least privilege)
  • -
  • --security-opt=no-new-privileges
  • -
  • Memory limits (128MB to 4GB depending on app)
  • -
  • Health checks with auto-restart on failure
  • -
  • Read-only root filesystem where possible (--read-only)
  • -
-

UID Mapping (inside container → host)

-
    -
  • root (0) → host UID 100000
  • -
  • postgres (70) → host UID 100070
  • -
  • bitcoin (101) → host UID 100101
  • -
  • grafana (472) → host UID 100472
  • -
  • mariadb (999) → host UID 100999
  • -
-

Registry

-
    -
  • Private registry at 146.59.87.168:3000/lfg2025/
  • -
  • HTTPS (self-hosted Gitea)
  • -
  • All images pre-pulled into registry; nodes pull on first boot
  • -
-
-
- -
-
- 7. Web UI — Vue.js SPA - Application -
-
Vue 3 + TypeScript + Pinia + Vite, served as static files by nginx
-
The website that runs on the box. Open it in any browser on your network to manage everything.
-
-

Tech Stack

-
    -
  • Framework: Vue 3 with <script setup lang="ts">
  • -
  • State: Pinia stores
  • -
  • Bundler: Vite 7
  • -
  • Styling: Global CSS with Tailwind utility classes in style.css
  • -
-

Communication

-
    -
  • JSON-RPC: All commands go through rpc-client.tsPOST /rpc/v1
  • -
  • WebSocket: Real-time container status, logs, events via /ws
  • -
  • CSRF: Token in cookie + X-CSRF-Token header
  • -
  • Session: HttpOnly cookie, SameSite=Lax
  • -
  • Retry: Auto-retry 3x with exponential backoff on 502/503
  • -
  • Timeout: 15s default (configurable per call)
  • -
-

Key Views

-
    -
  • / — Dashboard (system status, apps)
  • -
  • /kiosk — Kiosk mode (public, no auth)
  • -
  • /kiosk-recovery — Fallback with IP + QR code
  • -
  • /marketplace — App installer
  • -
  • /settings — System configuration
  • -
-

App Embedding

-
    -
  • Apps open as iframes via /app/{name}/ proxy paths
  • -
  • Each iframe gets nostr-provider.js injected for identity
  • -
-
-
- -
-
- 8. Kiosk Display - Physical -
-
X11 + Chromium in kiosk mode on VT7, auto-start, crash recovery
-
Plug in a monitor and the dashboard appears fullscreen. No login, no desktop, just your node. Press Ctrl+Alt+F1 for a terminal.
-
-

How It Works

-
    -
  • X11 server (Xorg) starts on Virtual Terminal 7
  • -
  • Chromium launches in --kiosk --app=http://localhost/kiosk mode
  • -
  • No address bar, no tabs, no right-click — just the dashboard
  • -
  • Cursor hidden after 3 seconds of inactivity
  • -
  • Screen blanking disabled
  • -
-

Resource Limits

-
    -
  • --disable-gpu — software rendering only
  • -
  • --renderer-process-limit=1 — single renderer process
  • -
  • --js-flags="--max-old-space-size=128" — 128MB JS heap max
  • -
  • --disable-metrics-reporting — no telemetry to Google
  • -
  • --enable-low-end-device-mode — reduce animations and compositing
  • -
-

Controls

-
    -
  • Ctrl+Alt+F7 — switch to kiosk
  • -
  • Ctrl+Alt+F1 — switch to terminal
  • -
  • sudo archipelago-kiosk enable|disable|toggle|status
  • -
-
-
- -
-
- - - - -
- -
- - - - - - - - - -
- - -
-
Tier 0 — Databases
-
- -
-
archy-mempool-dbarchy-net
-
MariaDB database storing Bitcoin mempool transaction data for the Mempool block explorer.
-
A database that remembers pending Bitcoin transactions so the block explorer can show them.
-
mariadb:11.4.10
-
No exposed ports (internal only)
-
-
UID100999:100999 (mariadb user)
-
Memory512 MB
-
Healthmariadb -uroot -e 'SELECT 1'
-
Data/var/lib/archipelago/mysql-mempool
-
Databasemempool (user: mempool)
-
DepsNone
-
Needed bymempool-api
-
-
- -
-
archy-btcpay-dbarchy-net
-
PostgreSQL database for BTCPay Server and NBXplorer, storing invoices, transactions, and merchant data.
-
Stores your payment invoices and transaction history for the Bitcoin payment processor.
-
postgres:15.17
-
No exposed ports (internal only)
-
-
UID100070:100070 (postgres user)
-
Memory512 MB
-
Healthpg_isready -U postgres
-
Data/var/lib/archipelago/postgres-btcpay
-
Databasesbtcpay, nbxplorer
-
Needed bynbxplorer btcpay
-
-
- -
-
indeedhub-postgresarchy-net
-
PostgreSQL database for IndeedHub social platform, storing posts, user profiles, and relay data.
-
The database that stores all the social media posts and user data for IndeedHub.
-
postgres:16.13-alpine
-
No exposed ports (internal only)
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-redisarchy-net
-
Redis in-memory cache for IndeedHub, handling sessions, job queues, and real-time data.
-
A fast temporary memory store so IndeedHub pages load quickly and background tasks run smoothly.
-
redis:7.4.8-alpine
-
No exposed ports
-
-
Needed byindeedhub-api
-
-
- -
-
penpot-postgresbridge
-
PostgreSQL database for Penpot design tool, storing projects, layers, and design assets.
-
Stores all the design projects and files for the Penpot design tool.
-
postgres:15
-
No exposed ports
-
-
Memory256 MB
-
Needed bypenpot-backend
-
-
- -
-
penpot-valkeybridge
-
Valkey (Redis fork) cache for Penpot, handling sessions and real-time collaboration sync.
-
Fast memory cache that makes Penpot's real-time collaboration work smoothly.
-
valkey:8.1
-
No exposed ports
-
-
Memory128 MB
-
Needed bypenpot-backend
-
-
- -
-
immich_postgresbridge
-
PostgreSQL with vector extensions for Immich photo AI/search. Optional — only if Immich is installed.
-
Database for the photo manager. Has special AI search features for finding photos by what's in them.
-
immich-postgres:14-vectorchord (optional)
-
-
Memory256 MB
-
Needed byimmich
-
-
- -
-
immich_redisbridge
-
Valkey cache for Immich job queue (photo processing, thumbnail generation).
-
Manages the queue of photos waiting to be processed and thumbnailed.
-
valkey:8.1.6 (optional)
-
-
Memory128 MB
-
Needed byimmich
-
-
- -
-
- - -
-
Tier 1 — Core Bitcoin Infrastructure
-
- -
-
bitcoin-knotsarchy-net
-
Full Bitcoin node (Knots variant). Validates every transaction and block independently. The root dependency for the entire Bitcoin stack.
-
Your own copy of the entire Bitcoin network. Nobody can lie to you about your balance because you verify everything yourself.
-
bitcoin-knots:latest
-
Ports: 8332 8333 28332 28333
-
-
Port 8332JSON-RPC API (how other apps talk to Bitcoin)
-
Port 8333P2P network (connects to other Bitcoin nodes worldwide)
-
Port 28332ZMQ block notifications (instant alert when new block arrives)
-
Port 28333ZMQ transaction notifications (instant alert for new transactions)
-
Memory2 GB (1 GB on low-memory systems)
-
Healthbitcoin-cli getblockchaininfo
-
Data/var/lib/archipelago/bitcoin (~500GB full, ~550MB pruned)
-
Disk modeAuto: prune if <1TB, full txindex if ≥1TB
-
RPC AuthHMAC-SHA256 salted hash (no plaintext password in config)
-
TorRoutes P2P through Tor SOCKS5 for privacy
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE
-
DepsNone — ROOT DEPENDENCY
-
Needed byelectrumx lnd mempool nbxplorer fedimint
-
-
- -
-
electrumxarchy-net
-
Electrum protocol server. Indexes the blockchain by address so wallets can look up balances instantly without scanning every block.
-
An index for Bitcoin. Like a book's table of contents — instead of reading every page to find your info, you jump straight to it.
-
electrumx:v1.18.0
-
Ports: 50001 8000
-
-
Port 50001Electrum protocol (wallet connections)
-
Port 8000Health check / status API
-
Memory1 GB
-
Data/var/lib/archipelago/electrumx
-
ProtocolElectrum JSON-RPC over TCP
-
Depsbitcoin-knots (reads blockchain via RPC)
-
Needed bymempool-api
-
-
- -
-
- - -
-
Tier 2 — Services (depend on Bitcoin core)
-
- -
-
lndarchy-net
-
Lightning Network Daemon. Enables instant, low-fee Bitcoin payments through payment channels.
-
Lets you send and receive Bitcoin instantly (instead of waiting 10+ minutes for a block). Like a tab at a bar — settle up later on-chain.
-
lnd:v0.18.4-beta
-
Ports: 9735 10009 8080
-
-
Port 9735Lightning P2P (connects to other Lightning nodes)
-
Port 10009gRPC API (admin operations, authenticated with macaroons)
-
Port 8080REST API (simpler HTTP interface to LND)
-
Memory512 MB
-
Data/var/lib/archipelago/lnd (wallet, channels, macaroons)
-
AuthMacaroon tokens (read-only for queries, admin for mutations)
-
TorActive with stream isolation (each connection uses different circuit)
-
CapsCHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE, NET_RAW
-
Depsbitcoin-knots
-
Needed byfedi-gateway (LND mode) lnd-ui
-
-
- -
-
mempool-apiarchy-net
-
Mempool.space backend API. Provides blockchain analytics, fee estimates, and transaction tracking.
-
The engine behind the block explorer. Shows you what's happening on the Bitcoin network in real time.
-
mempool-backend:v3.0.0
-
Ports: 8999
-
-
Memory512 MB
-
Data/var/lib/archipelago/mempool
-
Depsbitcoin-knots electrumx mempool-db
-
Needed bymempool-web
-
-
- -
-
archy-mempool-webarchy-net
-
Mempool.space frontend. The visual block explorer with real-time mempool visualization and fee graphs.
-
Your personal mempool.space — watch Bitcoin blocks being mined, see fee rates, track your transactions.
-
mempool-frontend:v3.0.0
-
Ports: 4080
-
-
Memory256 MB
-
Depsmempool-api
-
Nginx path/app/mempool/
-
-
- -
-
archy-nbxplorerarchy-net
-
NBXplorer blockchain scanner. Watches Bitcoin addresses for BTCPay and notifies when payments arrive.
-
Watches Bitcoin for incoming payments and tells BTCPay Server when money arrives for your invoices.
-
nbxplorer:2.6.0
-
Ports: 32838
-
-
Memory512 MB
-
Data/var/lib/archipelago/nbxplorer
-
Depsbitcoin-knots btcpay-db
-
Needed bybtcpay
-
-
- -
-
btcpay-serverarchy-net
-
Self-hosted Bitcoin payment processor. Accept Bitcoin payments with invoices, checkout pages, and POS.
-
Your own payment terminal for Bitcoin. Create invoices, get paid, no middleman taking a cut.
-
btcpayserver:2.3.9
-
Ports: 23000
-
-
Memory1 GB
-
Data/var/lib/archipelago/btcpay
-
Depsnbxplorer btcpay-db
-
Nginx path/app/btcpay/
-
TorHas its own .onion address for receiving payments privately
-
-
- -
-
fedimintarchy-net
-
Federated mint daemon. Enables community-run Bitcoin custody with threshold signing and e-cash tokens.
-
A way for a group of trusted people to collectively hold Bitcoin. No single person can steal the funds — you need a majority to approve.
-
fedimintd:v0.10.0
-
Ports: 8173 8174 8175
-
-
Port 8173P2P (federation member communication)
-
Port 8174API / WebSocket (client connections)
-
Port 8175Web UI (guardian dashboard)
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint
-
Depsbitcoin-knots
-
Needed byfedi-gateway
-
-
- -
-
fedimint-gatewayarchy-net
-
Lightning bridge for Fedimint. Connects the federation to the Lightning Network for instant payments.
-
Connects your community mint to Lightning so federation members can send/receive instant payments.
-
gatewayd:v0.10.0
-
Ports: 8176
-
-
Memory512 MB
-
Data/var/lib/archipelago/fedimint-gateway
-
ModeAuto-detect: uses LND if available, otherwise built-in LDK Lightning
-
Depsbitcoin-knots fedimint
-
-
- -
-
immich_serverbridge
-
Self-hosted Google Photos replacement with AI-powered search, face detection, and automatic organization.
-
Like Google Photos but on your own hardware. Your photos never leave your box. AI finds faces and objects locally.
-
immich-server:release (optional)
-
Ports: 2283
-
-
Depsimmich_postgres immich_redis
-
Nginx path/app/immich/
-
-
- -
-
- - -
-
Tier 3 — Applications (independent, no cross-dependencies)
-
- -
-
archy-bitcoin-uiarchy-net
-
Custom Bitcoin node dashboard showing sync status, peer connections, and blockchain info.
-
A pretty dashboard for your Bitcoin node. See how synced you are, how many peers you have, block height.
-
bitcoin-ui:latest
-
Ports: 8334
-
-
Memory128 MB
-
Nginx path/app/bitcoin-ui/
-
-
- -
-
archy-lnd-uiarchy-net
-
Custom Lightning dashboard showing channels, balances, routing stats, and payment history.
-
Dashboard for your Lightning node. See your channels, balance, and recent payments at a glance.
-
lnd-ui:latest
-
Ports: 8081
-
-
Memory128 MB
-
Nginx path/app/lnd/
-
-
- -
-
archy-electrs-uihost
-
ElectrumX status dashboard showing sync progress, connected clients, and index health.
-
Shows whether the Electrum index is synced and healthy. How far behind it is, how many wallets are connected.
-
electrs-ui:latest
-
Ports: 50002
-
-
Memory128 MB
-
Networkhost (needs direct access to localhost:50001)
-
Nginx path/app/electrumx/
-
-
- -
-
homeassistantbridge
-
Open-source home automation platform. Control lights, sensors, cameras, and IoT devices from one dashboard.
-
Smart home control center. Turn lights on, check sensors, automate your house — all locally, no cloud needed.
-
home-assistant:2024.1
-
Ports: 8123
-
-
Memory512 MB
-
Data/var/lib/archipelago/home-assistant
-
Nginx path/app/homeassistant/
-
-
- -
-
grafanabridge
-
Monitoring and visualization platform. Dashboards for system metrics, Bitcoin stats, and container health.
-
Beautiful graphs and charts showing how your system is doing. CPU, memory, Bitcoin sync, everything visualized.
-
grafana:10.2.0
-
Ports: 3000
-
-
UID100472:100472 (grafana user)
-
Memory256 MB
-
Data/var/lib/archipelago/grafana
-
Read-onlyYes (tmpfs for /tmp, /run)
-
Nginx path/app/grafana/
-
-
- -
-
uptime-kumabridge
-
Self-hosted uptime monitor. Pings your services and alerts you when something goes down.
-
Watches all your apps and sends alerts if anything stops working. Like a security guard for your services.
-
uptime-kuma:1
-
Ports: 3001
-
-
Memory256 MB
-
Data/var/lib/archipelago/uptime-kuma
-
-
- -
-
jellyfinbridge
-
Self-hosted media server. Stream your movies, TV shows, and music from your own hardware.
-
Your own Netflix. Put movies on the box, watch them on any device. No subscription, no limits.
-
jellyfin:10.8.13
-
Ports: 8096
-
-
Memory1 GB
-
Data/var/lib/archipelago/jellyfin/{config,cache}
-
Transcodetmpfs /tmp (256MB, rw,exec)
-
Nginx path/app/jellyfin/
-
-
- -
-
photoprismbridge
-
AI-powered photo management. Automatic face recognition, location mapping, and smart search.
-
Photo organizer that uses AI to tag and sort your pictures. Find photos by searching "sunset" or "cat."
-
photoprism:240915
-
Ports: 2342
-
-
Memory1 GB (512 MB on low-memory)
-
Data/var/lib/archipelago/photoprism
-
-
- -
-
vaultwardenbridge
-
Bitwarden-compatible password manager. Store all your passwords encrypted, sync across devices.
-
Your personal password safe. Store every password securely and auto-fill them on your phone and computer.
-
vaultwarden:1.30.0-alpine
-
Ports: 8082
-
-
Memory256 MB
-
Data/var/lib/archipelago/vaultwarden
-
CapsCHOWN, SETUID, SETGID, NET_BIND_SERVICE
-
-
- -
-
nextcloudbridge
-
Self-hosted file sync and collaboration platform. Dropbox/Google Drive replacement with calendar, contacts, and office docs.
-
Your own Dropbox. Sync files, share documents, manage calendar and contacts — all on your own hardware.
-
nextcloud:29
-
Ports: 8085
-
-
Memory1 GB
-
Data/var/lib/archipelago/nextcloud
-
-
- -
-
searxngbridge
-
Privacy-respecting metasearch engine. Searches Google, Bing, DuckDuckGo and others without tracking you.
-
Private search engine. Searches the web without anyone tracking what you look for.
-
searxng:latest
-
Ports: 8888
-
-
Memory512 MB
-
Data/var/lib/archipelago/searxng
-
Read-onlyYes (tmpfs for /tmp, /run)
-
-
- -
-
onlyofficebridge
-
Self-hosted document editor. Edit Word, Excel, and PowerPoint files collaboratively in the browser.
-
Like Google Docs but on your own box. Edit spreadsheets and documents with others in real time.
-
onlyoffice:latest
-
Ports: 9980
-
-
Memory2 GB (1 GB on low-memory)
-
-
- -
-
ollamabridge
-
Local AI model runner. Run LLMs (like Llama, Mistral) entirely on your hardware, no cloud needed.
-
ChatGPT on your own box. Talk to AI privately — nothing you say leaves your machine.
-
ollama:latest (optional)
-
Ports: 11434
-
-
Memory4 GB (1 GB on low-memory)
-
Data/var/lib/archipelago/ollama
-
Read-onlyYes (tmpfs for /tmp, /run)
-
ProtocolREST API at :11434 (OpenAI-compatible)
-
-
- -
-
filebrowserbridge
-
Web-based file manager. Browse, upload, and download files through the browser.
-
A file explorer in your browser. Upload, download, and manage files on the box without SSH.
-
filebrowser:v2.27.0
-
Ports: 8083
-
-
Memory256 MB
-
Data/var/lib/archipelago/filebrowser (served), filebrowser-data (DB)
-
Read-onlyYes
-
Max upload10 GB (nginx limit)
-
-
- -
-
nginx-proxy-managerbridge
-
GUI for managing nginx proxy rules and SSL certificates. Point-and-click reverse proxy configuration.
-
A visual tool for routing web traffic. Point domains to services and manage HTTPS certificates with clicks, not config files.
-
nginx-proxy-manager:latest
-
Ports: 81 8084 8443
-
-
Port 81Admin dashboard
-
Port 8084HTTP proxy
-
Port 8443HTTPS proxy
-
Memory256 MB
-
-
- -
-
portainerbridge
-
Container management UI. Visual dashboard for Podman containers — start, stop, inspect, view logs.
-
Visual control panel for all your containers. See what's running, restart things, read logs — no terminal needed.
-
portainer:latest
-
Ports: 9000
-
-
Memory256 MB
-
SocketPodman socket mounted as Docker socket
-
-
- -
-
- - -
-
IndeedHub Stack — Nostr-based Social Platform
-
- -
-
indeedhub-minioarchy-net
-
S3-compatible object storage for IndeedHub media files (images, videos, attachments).
-
File storage for IndeedHub. When someone posts an image, it lives here.
-
minio:RELEASE.2024-11-07
-
-
Needed byindeedhub-api
-
-
- -
-
indeedhub-apiarchy-net
-
IndeedHub backend API. Handles Nostr events, user profiles, media uploads, and relay communication.
-
The engine behind IndeedHub. Processes posts, handles user accounts, talks to Nostr relays.
-
indeedhub-api (custom build)
-
-
Depspostgres redis minio
-
Needed byindeedhub
-
-
- -
-
indeedhub-ffmpegarchy-net
-
Video transcoding worker for IndeedHub. Converts uploaded videos to web-friendly formats.
-
Converts videos so they play smoothly in the browser. Like a video format translator.
-
indeedhub-ffmpeg (custom build)
-
- -
-
indeedhub-relayarchy-net
-
Nostr relay for IndeedHub. Stores and distributes Nostr events (posts, follows, reactions).
-
A message board that stores Nostr posts. Other Nostr apps can connect here to read and post.
-
indeedhub-relay (custom build)
-
- -
-
indeedhubarchy-net
-
IndeedHub web frontend. Nostr-based social media client with feeds, profiles, messaging, and media.
-
The social media app itself. Post, follow people, send messages, share media — all on the Nostr protocol.
-
indeedhub-frontend (custom build)
-
Ports: 7777
-
-
Nginx path/app/indeedhub/
-
WebSocketYes (for real-time updates)
-
Depsindeedhub-api
-
-
- -
-
- - -
-
Penpot Stack — Design Tool
-
- -
-
penpot-backendbridge
-
Penpot application server. Handles design data, real-time collaboration, and file storage.
-
The engine behind the design tool. Saves your designs and lets multiple people edit at the same time.
-
penpot-backend:2.4
-
-
Memory512 MB
-
Depspenpot-postgres penpot-valkey
-
-
- -
-
penpot-exporterbridge
-
Renders Penpot designs to PDF, SVG, and image formats for export.
-
Turns your designs into downloadable files — PDFs, images, SVGs.
-
penpot-exporter:2.4
-
-
Memory256 MB
-
Depspenpot-backend
-
-
- -
-
penpot-frontendbridge
-
Penpot web UI. Open-source Figma alternative with vector editing, prototyping, and collaboration.
-
Your own Figma. Design interfaces, create prototypes, collaborate — completely self-hosted.
-
penpot-frontend:2.4
-
Ports: 9001
-
-
Memory256 MB
-
Nginx path/app/penpot/
-
Depspenpot-backend
-
-
- -
-
- -
-
archy-net (internal DNS, Bitcoin stack)
-
bridge (standalone, port-mapped)
-
host (direct network access)
-
Dimmed = optional / not always installed
-
-
- - - - -
- -
- -
-
JSON-RPC 2.0
-
Primary protocol between the web UI and the Rust backend. All commands are RPC calls.
-
Like texting the backend: you send a message ("please start this app"), it texts back ("done" or "error").
-
- Endpoint: POST /rpc/v1
- Format: {"jsonrpc":"2.0","method":"package.start","params":{"id":"bitcoin-knots"},"id":1}
- Auth: Session cookie + CSRF token header
- Timeout: 15s default
- Retry: 3 attempts with exponential backoff on 502/503
- Rate limit: 20 req/s (burst 40)
- Used by: Vue.js frontend → Rust backend -
-
- -
-
WebSocket
-
Real-time bidirectional channel for live updates — container status changes, logs, events.
-
An open phone line between your browser and the server. Instead of asking "any updates?" every second, the server just tells you when something changes.
-
- Endpoint: WS /ws (HTTP upgrade)
- Auth: Session cookie
- Read timeout: 86,400s (24 hours)
- Events: Container state changes, log streams, system alerts
- Used by: Vue.js frontend ↔ Rust backend -
-
- -
-
Bitcoin RPC (JSON-RPC 1.0)
-
How apps talk to the Bitcoin node. Authenticated with username + HMAC-hashed password.
-
The language apps use to ask Bitcoin questions: "what's the current block?" or "send this transaction."
-
- Endpoint: bitcoin-knots:8332 (inside archy-net)
- Auth: HTTP Basic with rpcauth hash (HMAC-SHA256, no plaintext)
- Methods: getblockchaininfo, getmempoolinfo, sendrawtransaction, etc.
- Timeout: 10s default, 30s for heavy ops
- Used by: ElectrumX, LND, Mempool, NBXplorer, Fedimint → Bitcoin Knots -
-
- -
-
gRPC
-
High-performance RPC protocol used by LND for admin operations. Binary format, strongly typed.
-
A fast, structured way for apps to control the Lightning node. More efficient than regular HTTP for complex operations.
-
- Endpoint: lnd:10009
- Auth: Macaroon tokens (read-only for queries, admin for mutations)
- TLS: Self-signed certificate (auto-generated)
- Methods: OpenChannel, SendPayment, GetInfo, ListChannels, etc.
- Used by: Fedimint Gateway, LND UI → LND -
-
- -
-
Electrum Protocol
-
Lightweight protocol for wallet address lookups. JSON-RPC over raw TCP sockets.
-
How Bitcoin wallets check their balance without downloading the entire blockchain. Ask "what transactions touched this address?" and get an instant answer.
-
- Endpoint: electrumx:50001 (TCP)
- Format: Newline-delimited JSON-RPC
- Methods: blockchain.scripthash.get_balance, blockchain.transaction.get, etc.
- Used by: Wallets (Sparrow, Electrum), Mempool API → ElectrumX -
-
- -
-
ZMQ (ZeroMQ)
-
Publish-subscribe messaging from Bitcoin node. Instant notifications for new blocks and transactions.
-
A broadcasting system. When a new Bitcoin block is found, Bitcoin instantly shouts it out and everyone listening hears immediately.
-
- Endpoints:
- • tcp://bitcoin-knots:28332 — New block hashes (hashblock)
- • tcp://bitcoin-knots:28333 — New raw transactions (rawtx)
- Pattern: PUB/SUB (publisher/subscriber)
- Subscribers: LND, Mempool, ElectrumX -
-
- -
-
Tor (SOCKS5 + Hidden Services)
-
Privacy layer. Routes Bitcoin P2P through onion routing, exposes services as .onion addresses.
-
Like sending a letter through 3 random post offices so nobody knows where it came from. Also lets people reach your node without knowing your real IP.
-
- SOCKS5 proxy: 127.0.0.1:9050
- Container access: host.containers.internal:9050
- Hidden services: Web UI, LND, BTCPay, Mempool, Fedimint
- Managed by: System Tor daemon + tor-helper.sh (privileged helper)
- Used by: Bitcoin P2P, LND P2P, BTCPay invoices -
-
- -
-
Nostr (NIP-01)
-
Decentralized social protocol. WebSocket-based relay communication for events (posts, follows, messages).
-
A social media protocol where no company controls the network. Your posts live on relays, and you own your identity with a cryptographic key.
-
- Transport: WebSocket (WSS)
- Format: JSON events signed with secp256k1 keys
- Relay: IndeedHub relay (local), configurable external relays
- Integration: nostr-provider.js injected into all app iframes
- Identity: DID-based, linked to node Ed25519 keypair -
-
- -
-
DWN (Decentralized Web Node)
-
W3C protocol for storing encrypted data and messages in a decentralized way. Identity-linked storage.
-
A personal data vault. Apps can store data here that only you control. Like a safety deposit box that follows you across the internet.
-
- Endpoint: /dwn (proxied through nginx)
- Auth: Per-record DID-based permissions
- Reachable via: Tor hidden service
- Used for: Encrypted backups, cross-node messaging, app data sync -
-
- -
-
- - - - -
- -
-
2
DID Methods
-
26+
Identity RPCs
-
W3C 2.0
VC Spec
-
Ed25519
Primary Key
-
DWN
Data Store
-
Dual Key
Ed25519 + secp256k1
-
- -
- -
-
- Applications - Layer 5 · UI -
-
Vue.js views for identity management, credential issuance, DWN dashboard, and quick actions
-
The screens where you manage your digital identity, issue credentials, and control your personal data store.
-
-

Key Views

-
    -
  • Web5Identities.vue — Create/manage identities (Personal, Business, Anonymous purposes)
  • -
  • Web5CredentialsSummary.vue — View issued/held credentials with status badges
  • -
  • Web5DWN.vue — DWN status, protocol registration, message browser
  • -
  • Web5QuickActions.vue — Copy DID, publish to DHT, trigger sync
  • -
-

Data Types (TypeScript)

-
    -
  • ManagedIdentity — id, name, purpose, did, pubkey, nostr_pubkey, profile
  • -
  • VCData — id, issuer, subject, type, claims, status (active/revoked/expired)
  • -
  • DwnStatusData — running, sync_status, message_count, registered_protocols
  • -
-
-
- -
-
- Verifiable Credentials (W3C VC 2.0) - Layer 4 · Trust -
-
Issue, verify, revoke, and present credentials with Ed25519Signature2020 proofs — W3C VC Data Model 2.0
-
Digital certificates that prove things about you — signed by one identity, held by another, verified by anyone. Like a digitally signed diploma.
-
-

Three-Party Model

-
    -
  • Issuer: Creates and signs the credential (any managed identity)
  • -
  • Holder: Stores credentials, creates Verifiable Presentations
  • -
  • Verifier: Checks signature + expiration + revocation status
  • -
-

Credential Structure

-
    -
  • @context: W3C Credentials v2 + Ed25519 signature suite
  • -
  • type: ["VerifiableCredential", "CustomType"]
  • -
  • issuer: did:key or did:dht
  • -
  • credentialSubject: { id: did, claims: {...} }
  • -
  • proof: Ed25519Signature2020 with verification method reference
  • -
  • credentialStatus: CredentialStatusList2021 for revocation
  • -
-

Verifiable Presentations

-
    -
  • Bundle one or more VCs with holder's own signature
  • -
  • Proof purpose: authentication (vs. assertionMethod for VCs)
  • -
  • Selective disclosure — present only relevant credentials
  • -
-

RPC Methods

-
    -
  • identity.issue-credential — Issue from any managed identity
  • -
  • identity.verify-credential — Verify by credential ID
  • -
  • identity.list-credentials — List with optional filtering
  • -
-

Storage

-
    -
  • /var/lib/archipelago/credentials/store.json
  • -
-
-
- -
-
- Decentralized Web Node (DWN) - Layer 3 · Storage -
-
Personal data store with protocol-governed records, peer sync over Tor, and DID-based authorization
-
Your personal database that YOU own. Apps ask permission to read/write data. Syncs with trusted peers automatically over Tor.
-
-

Records Interface

-
    -
  • Records.Write — Store a message (UUID-based record_id)
  • -
  • Records.Read — Retrieve by record_id
  • -
  • Records.Query — Filter by protocol, schema, author, date range
  • -
  • Records.Delete — Remove record
  • -
-

Protocol Definitions

-
    -
  • Declarative rule sets governing data structure and access permissions
  • -
  • types: Define allowed dataFormats and optional schema URIs
  • -
  • structure: Hierarchical — records can have child records (post → comment)
  • -
  • $actions: Who can create/read/update/delete (anyone, author, recipient)
  • -
  • Registered via dwn.register-protocol RPC, enforced automatically
  • -
-

Peer Sync

-
    -
  • Bidirectional sync with trusted peers over Tor SOCKS5 proxy (127.0.0.1:9050)
  • -
  • Deduplication by record_id, batched (200 messages per sync)
  • -
  • 30s per-peer timeout, 90s total timeout
  • -
  • State persisted to /var/lib/archipelago/dwn/sync_state.json
  • -
  • Triggered manually or via background task
  • -
-

HTTP API

-
    -
  • Endpoint: POST /dwn (proxied through nginx)
  • -
  • Reachable remotely via Tor hidden service
  • -
-

RPC Methods (8)

-
    -
  • dwn.status — Running state, sync status, message count
  • -
  • dwn.sync — Trigger background sync with trusted peers
  • -
  • dwn.register-protocol / dwn.list-protocols / dwn.remove-protocol
  • -
  • dwn.write-message / dwn.query-messages / dwn.read-message / dwn.delete-message
  • -
-

Storage

-
    -
  • Messages: /var/lib/archipelago/dwn/messages/{record_id}.json
  • -
  • Protocols: /var/lib/archipelago/dwn/protocols/{protocol_uri}.json
  • -
-
-
- -
-
- Decentralized Identifiers (DIDs) - Layer 2 · Identity -
-
W3C DID Core 1.0 — did:key (primary, offline-capable) and did:dht (discoverability via BitTorrent Mainline DHT)
-
Your self-sovereign digital identity. No company issues it, no platform controls it. You prove who you are with cryptographic keys.
-
-

did:key (Primary Method)

-
    -
  • Self-contained — no external resolution, works fully offline
  • -
  • Format: did:key:z6Mk... (multicodec Ed25519 in base58btc)
  • -
  • Instant, zero-cost, no network dependency
  • -
  • Used for: VCs, federation trust, backup encryption, DWN signing
  • -
  • Cannot be rotated — key is the identifier
  • -
-

did:dht (Discovery Method)

-
    -
  • Publishes DID Document to BitTorrent Mainline DHT via BEP-44 signed mutable items
  • -
  • Format: did:dht:z... (z-base-32 encoded Ed25519 pubkey)
  • -
  • Globally discoverable without any centralized registry
  • -
  • DID Document encoded as DNS Resource Records in DNS packet
  • -
  • Supports key rotation (increment sequence number, republish)
  • -
  • 1-hour TTL cache for performance
  • -
  • Replaced did:ion (Bitcoin-anchored) — simpler, no full node required
  • -
-

DID Document (W3C Core 1.0)

-
    -
  • verificationMethod: Ed25519VerificationKey2020 + derived X25519KeyAgreementKey2020
  • -
  • authentication, assertionMethod, capabilityInvocation, capabilityDelegation
  • -
  • keyAgreement: X25519 (derived from Ed25519 via Curve25519)
  • -
  • Optional EcdsaSecp256k1VerificationKey2019 for Nostr interop
  • -
  • Service endpoints: DWN URL, Nostr relay list
  • -
-

Multi-Identity Manager

-
    -
  • Users create multiple identities with purpose tags: Personal, Business, Anonymous
  • -
  • One default identity (marked with star in UI)
  • -
  • Each identity: Ed25519 key + optional Nostr secp256k1 key + optional NIP-01 profile
  • -
  • Stored as JSON in /var/lib/archipelago/identities/{id}.json
  • -
-

Identity RPC Methods (26+)

-
    -
  • identity.create / .list / .get / .delete / .set-default
  • -
  • identity.sign / .verify — Ed25519 message signing
  • -
  • identity.resolve-did / .verify-did-document
  • -
  • identity.create-dht-did / .resolve-dht-did / .refresh-dht-did
  • -
  • identity.create-nostr-key / .nostr-sign
  • -
  • identity.nostr-encrypt-nip04 / .nostr-decrypt-nip04
  • -
  • identity.nostr-encrypt-nip44 / .nostr-decrypt-nip44
  • -
  • identity.update-profile / .resolve-remote-did
  • -
-
-
- -
-
- Cryptographic Keys - Layer 1 · Foundation -
-
Dual key architecture — Ed25519 for Web5/DIDs + secp256k1 for Bitcoin/Nostr, both derived from BIP-39 master seed
-
Two types of cryptographic keys derived from one master seed. One for identity (Web5), one for money and social (Bitcoin/Nostr).
-
-

Ed25519 (Web5 & Identity)

-
    -
  • W3C DIDs, Verifiable Credentials (Ed25519Signature2020)
  • -
  • DWN message signing and authorization
  • -
  • Federation peer authentication and trust
  • -
  • Backup encryption (via derived X25519 key agreement)
  • -
  • Storage: /var/lib/archipelago/identity/node_key (32 bytes raw)
  • -
-

secp256k1 (Bitcoin & Nostr)

-
    -
  • Nostr event signing (NIP-01), encrypted DMs (NIP-04, NIP-44)
  • -
  • Lightning Network node identity
  • -
  • Social presence and discovery (NIP-05, kind 30078)
  • -
  • Format: hex pubkey + Nostr npub (NIP-19 bech32)
  • -
-

Key Derivation

-
    -
  • Single BIP-39 master seed (12 or 24 word mnemonic)
  • -
  • Deterministic derivation of both Ed25519 and secp256k1 keys
  • -
  • All keys recoverable from seed phrase alone
  • -
  • Seed generated at first boot, stored on LUKS-encrypted partition
  • -
-

Rust Dependencies

-
    -
  • ed25519-dalek 2.2.0 — Ed25519 signatures
  • -
  • curve25519-dalek 4.1.3 — X25519 key agreement (Ed25519 → X25519 conversion)
  • -
  • nostr-sdk 0.44 — secp256k1 signing, NIP-04/44 encryption
  • -
  • mainline 2 — BitTorrent Mainline DHT client (did:dht)
  • -
  • zbase32 0.1 — z-base-32 encoding for DID identifiers
  • -
-
-
- -
- -

Specification Status

-

- Web5 was initiated by TBD (Block/Jack Dorsey) and shut down November 2024. Open-source components were donated to the Decentralized Identity Foundation (DIF). - The W3C specs (DIDs, VCs) are independent standards with broad industry adoption. Archipelago implements these W3C standards directly with a custom DWN — not dependent on TBD's SDK. -

- - - - - - - - - -
ComponentSpecStatusArchipelago
DID Core 1.0W3C RecommendationStabledid:key + did:dht, full DID Document generation
VC Data Model 2.0W3C Recommendation (May 2025)StableIssue/verify/revoke with Ed25519Signature2020
DWNDIF DraftDraftCustom Records interface, protocol management, Tor sync
did:dhtNear v1.0ActiveMainline DHT publishing via mainline crate
did:ion (Sidetree)DIF 1.0AbandonedNot implemented — requires Bitcoin + IPFS full nodes
Presentation Exchange 2.0DIF RatifiedStableVerifiable Presentations with holder proof
- -

Architecture Decision Records

- - - - - -
ADRDecisionRationale
ADR-002did:key as primary DID methodSelf-contained, offline-capable, zero-cost, aligns with sovereignty
ADR-008Dual key architecture (Ed25519 + secp256k1)Ed25519 for W3C/Web5, secp256k1 for Bitcoin/Lightning/Nostr ecosystems
ADR-011Custom DWN, not full W3C spec complianceTBD shut down, DWN spec stalled. Federation + Nostr relays prioritized for peer sync
- -
- - - - -
- -
-
-
1
-
-
BIOS / UEFI → GRUB Bootloader
-
Firmware loads GRUB from EFI partition or BIOS boot sector. GRUB loads the Linux kernel.
-
The computer turns on and finds the operating system to start. Works on both old and new machines.
-
GRUB installed for both UEFI (EFI partition) and legacy BIOS (1MB boot sector) — dual-boot compatible
-
-
- -
-
2
-
-
LUKS Unlock → Mount Encrypted Partition
-
Cryptsetup opens the LUKS2 volume using /root/.luks-archipelago.key and mounts it at /var/lib/archipelago.
-
The encrypted safe is unlocked automatically (no password prompt). All your data becomes accessible.
-
Configured in /etc/crypttab for automatic boot-time unlock
-
-
- -
-
3
-
-
systemd Starts → Network Online
-
systemd boots Debian, starts networking, reaches network-online.target. Tor and Tailscale start.
-
The operating system finishes starting up and connects to the internet.
-
-
- -
-
4
-
-
Archipelago Backend Starts
-
archipelago.service launches the Rust binary. Runs crash recovery, starts container state snapshots, initializes JSON-RPC server on :5678.
-
The brain of the system wakes up. If there was a crash last time, it automatically recovers.
-
Type=notify, WatchdogSec=300s, MemoryMax=4G
-
-
- -
-
5
-
-
First Boot: Container Creation
-
first-boot-containers.sh runs once (guarded by marker file). Creates all containers in tier order: databases → Bitcoin → services → apps.
-
On first startup only: installs all the apps. Databases first, then Bitcoin, then everything else. Takes 5-15 minutes.
-
ConditionPathExists=!/var/lib/archipelago/.first-boot-containers-done · Timeout: 900s
-
-
- -
-
6
-
-
Nginx Ready → Web UI Accessible
-
Nginx serves the Vue.js SPA on :80/:443. Backend health check at /health passes.
-
The website is now live. Open a browser and go to the machine's IP address to see the dashboard.
-
-
- -
-
7
-
-
Kiosk Starts (if enabled)
-
archipelago-kiosk.service waits for /health endpoint (up to 30s), then starts X11 + Chromium on VT7.
-
If a monitor is plugged in, the dashboard appears fullscreen automatically. No login needed for the local screen.
-
Polls /health 15 times at 2s intervals before launching Chromium
-
-
- -
-
8
-
-
Background Services Start
-
Timers activate: container doctor (health repair), reconciler (spec enforcement), self-update. Tor helper watches for service config changes.
-
Maintenance robots start working in the background. They check on apps, fix broken ones, and keep everything updated.
-
archipelago-doctor.timer, archipelago-reconcile.timer, archipelago-update.timer, archipelago-tor-helper.path
-
-
-
-
- - - - -
- -
- -
-
LUKS2 Full-Disk Encryption
-
All user data on an encrypted partition. Auto-detects AES-NI for hardware acceleration, falls back to ChaCha20-Adiantum. Key derived with Argon2id (GPU-resistant).
-
If someone physically steals your hard drive, all they get is encrypted noise. The data is useless without the key.
-
- -
-
Rootless Containers
-
All containers run as unprivileged user archipelago (UID 1000). Even a compromised container cannot escalate to root. UID remapping isolates container users from host users.
-
Apps run in sealed boxes without admin access. A hacked app can't take over the whole machine.
-
- -
-
Capability Dropping
-
--cap-drop=ALL then add only specific capabilities needed. --security-opt=no-new-privileges prevents privilege escalation inside containers.
-
Each app only gets the exact permissions it needs, nothing more. Like giving a valet driver only the car key, not your house keys.
-
- -
-
RBAC (Role-Based Access Control)
-
Backend uses explicit method allowlists per role. No prefix matching — each RPC method must be explicitly permitted. Session cookies are HttpOnly, SameSite=Lax.
-
Different users have different permissions. A viewer can't install apps, and nobody can run commands that aren't on the approved list.
-
- -
-
Rate Limiting
-
Nginx rate limits: auth endpoints (3/s), RPC (20/s), P2P (10/s). Prevents brute-force attacks and API abuse.
-
If someone tries to guess your password by trying thousands of combinations, they get locked out after a few attempts per second.
-
- -
-
Tor Privacy
-
Bitcoin P2P routes through Tor with stream isolation. Hidden services expose node without revealing IP. LND uses Tor for Lightning P2P.
-
Your Bitcoin node connects through the Tor privacy network. Nobody can see your real IP address or location.
-
- -
-
Credential Management
-
Secrets auto-generated at first boot (CSPRNG). Stored in /var/lib/archipelago/secrets/ (mode 700). Bitcoin RPC uses HMAC-SHA256 auth hashes, never plaintext.
-
Passwords are randomly generated and stored securely. They never appear in config files as readable text.
-
- -
-
Memory Limits & Health Checks
-
Every container has a memory limit (128MB–4GB). Health checks auto-restart failed containers. Backend has systemd watchdog (300s).
-
Runaway apps can't eat all the memory. Crashed apps restart automatically. The system self-heals.
-
- -
-
Security Headers
-
CSP (Content Security Policy), HSTS, X-Frame-Options: SAMEORIGIN, X-Content-Type-Options: nosniff, strict Referrer-Policy, disabled camera/mic/geolocation.
-
The web UI tells browsers to follow strict security rules — no loading scripts from unknown sites, no accessing your camera.
-
- -
-
Systemd Hardening
-
ProtectSystem=strict, MemoryDenyWriteExecute, RestrictRealtime, RestrictAddressFamilies. Backend can only write to approved paths.
-
The operating system restricts what the backend can do. It can only touch the files it needs to, nothing else on the system.
-
- -
-
- - - - -
- -
/var/lib/archipelago/ ← LUKS2 encrypted partition - ├── bitcoin/ Bitcoin blockchain data (~500GB full, ~550MB pruned) - ├── lnd/ Lightning wallet, channels, macaroons, TLS cert - ├── electrumx/ Address index database - ├── postgres-btcpay/ BTCPay PostgreSQL data - ├── mysql-mempool/ Mempool MariaDB data - ├── mempool/ Mempool backend cache - ├── btcpay/ BTCPay server data, plugins - ├── nbxplorer/ NBXplorer blockchain scan state - ├── fedimint/ Federation data, consensus state - ├── fedimint-gateway/ Gateway keys and routing table - ├── home-assistant/ Smart home config, automations, database - ├── grafana/ Dashboards, datasources, alerting rules - ├── uptime-kuma/ Monitor definitions, status history - ├── jellyfin/ Media library metadata, transcoding cache - ├── photoprism/ Photo index, thumbnails, AI models - ├── ollama/ Downloaded LLM models (can be multi-GB) - ├── vaultwarden/ Encrypted password vault database - ├── nextcloud/ Files, calendar, contacts, config - ├── searxng/ Search engine settings - ├── filebrowser/ Served files (user uploads) - ├── filebrowser-data/ FileBrowser internal database - ├── nginx-proxy-manager/ Proxy rules, Let's Encrypt certificates - ├── portainer/ Portainer config and database - ├── tailscale/ VPN state, node identity - ├── secrets/ RPC passwords, DB passwords (mode 700) - ├── identity/ Node Ed25519 keypair (DID identity) - ├── identities/ User DIDs - ├── tor-config/ Tor service definitions (backend-managed) - ├── tor-hostnames/ .onion addresses (synced from /var/lib/tor) - └── .first-boot-containers-done Marker: first boot completed - -/opt/archipelago/ ← Unencrypted (on root partition) - ├── web-ui/ Vue.js SPA (static files served by nginx) - ├── scripts/ Deploy, container, and maintenance scripts - └── image-versions.sh Pinned container image versions - -/usr/local/bin/archipelago Rust backend binary -/etc/nginx/ Nginx config (reverse proxy rules) -/etc/tor/torrc Tor daemon configuration
- -
- - - - -
- -
- -
-
Podman
-
Daemonless container engine (OCI-compatible, Docker alternative)
-
A tool that runs apps in isolated sandboxes. Like Docker but doesn't need a background service running as root.
-
- -
-
Rootless
-
Containers run entirely in user namespace, no root privileges required
-
The sandboxes run without admin access. Even if someone breaks into one, they can't take over the system.
-
- -
-
LUKS2
-
Linux Unified Key Setup v2 — dm-crypt disk encryption with Argon2 KDF
-
Industry-standard disk encryption for Linux. Scrambles the entire partition so data is unreadable without the key.
-
- -
-
Argon2id
-
Memory-hard password hashing function resistant to GPU/ASIC brute-force
-
A way to protect passwords that requires lots of memory to crack, making it extremely expensive to brute-force even with specialized hardware.
-
- -
-
Nginx
-
High-performance HTTP reverse proxy and web server
-
The front door of the system. All web traffic goes through nginx, which directs each request to the right app.
-
- -
-
Reverse Proxy
-
Server that forwards client requests to backend services based on URL path or hostname
-
A traffic cop for web requests. You visit one address, and the proxy routes you to the right app behind the scenes.
-
- -
-
JSON-RPC
-
Remote procedure call protocol using JSON over HTTP/TCP
-
A simple way for one program to ask another to do something. Send a JSON message, get a JSON reply.
-
- -
-
WebSocket
-
Full-duplex TCP communication channel over HTTP upgrade
-
A persistent connection between browser and server. Instead of repeatedly asking "anything new?", the server pushes updates instantly.
-
- -
-
gRPC
-
Google's high-performance RPC framework using Protocol Buffers over HTTP/2
-
A fast, structured way for programs to communicate. Used by LND because it handles many Lightning operations efficiently.
-
- -
-
ZMQ (ZeroMQ)
-
Asynchronous messaging library for pub/sub and push/pull patterns
-
A broadcasting system. Bitcoin publishes "new block!" and every subscribed app hears it instantly.
-
- -
-
Tor
-
Onion routing network for anonymous communication via encrypted relay circuits
-
A privacy network that bounces your traffic through multiple servers so nobody can trace it back to you.
-
- -
-
Hidden Service (.onion)
-
Tor service accessible via .onion address without revealing server IP
-
A way to make your node reachable on the internet without revealing your IP address or location.
-
- -
-
Tailscale
-
WireGuard-based mesh VPN with NAT traversal and SSO integration
-
A private tunnel to your node from anywhere. Like a VPN but easier — install the app on your phone, and you can access the node from a coffee shop.
-
- -
-
Macaroon
-
Bearer token with embedded caveats (permissions) used by LND for API auth
-
A special key for LND that says exactly what you're allowed to do. A "read-only" macaroon can check balance but can't send money.
-
- -
-
CSRF Token
-
Cross-Site Request Forgery prevention token sent in cookie + header
-
A secret code that proves your browser request is genuine and not a trick from a malicious website.
-
- -
-
DID (Decentralized Identifier)
-
W3C standard for self-sovereign identity using cryptographic keypairs
-
Your digital identity that you own completely. Like a passport that no government issued — you prove who you are with math, not authority.
-
- -
-
Nostr
-
Notes and Other Stuff Transmitted by Relays — decentralized social protocol
-
A social media protocol where you own your identity. No company can ban you because your account is just a cryptographic key.
-
- -
-
Lightning Network
-
Bitcoin Layer 2 payment channel network for instant, low-fee transactions
-
A way to send Bitcoin instantly (milliseconds) for tiny fees. Works by opening "tabs" between nodes and settling on-chain later.
-
- -
-
Fedimint
-
Federated Bitcoin custody protocol with threshold signing and Chaumian e-cash
-
A community Bitcoin bank where a group of trusted guardians hold funds together. No single person can steal — you need a majority to sign.
-
- -
-
archy-net
-
Custom Podman bridge network with DNS resolution for Bitcoin-stack containers
-
A private network inside the box where Bitcoin apps can find each other by name. Like a local phone book for containers.
-
- -
-
Capability (CAP)
-
Fine-grained Linux privilege (e.g. CAP_NET_RAW, CAP_CHOWN) instead of full root
-
Instead of giving an app all admin powers, we give it only the specific abilities it needs. A file manager gets "change file ownership" but not "change network settings."
-
- -
-
Systemd
-
Linux init system and service manager (PID 1)
-
The thing that starts everything when Linux boots. Manages all services, restarts crashed ones, and enforces resource limits.
-
- -
-
RBAC
-
Role-Based Access Control — permissions assigned by user role, not individually
-
Different users get different permissions based on their role (admin, viewer, etc). Prevents regular users from doing dangerous things.
-
- -
-
- - - - -
- -

Architecture analysis sourced from Start9Labs/start-os on GitHub (master branch). Click any layer to expand.

- -
-
LXC
Container Runtime
-
Rust
Backend (startd)
-
Angular 21
Frontend
-
S9PK v2
Package Format
-
Optional
LUKS Encryption
-
btrfs
Filesystem
-
- -
-
Angular 21 + Taiga UI 5UI
Three Angular apps: admin UI, setup wizard, VPN management. Patch-DB reactive sync via CBOR diffs over WebSocket.
-
Rust Backend (startd / startbox)Service
Single binary with 5 personalities (symlinks). Built-in reverse proxy (Axum), DNS (hickory-server), ACME, WireGuard, SOCKS5.
-
LXC ContainersIsolation
Two-layer model: outer LXC per service (SquashFS + OverlayFS), inner subcontainers from S9PK images. JSON-RPC over Unix sockets.
-
Network (built-in)Network
VHostController reverse proxy, hickory-server DNS, ACME TLS, WireGuard tunnels, SOCKS5 at 10.0.3.1:1080. No nginx/caddy.
-
Optional LUKS on btrfsSecurity
User chooses encrypted or unencrypted during setup. LVM with btrfs for COW snapshots enabling safe app installs.
-
Debian BookwormOS
Same Debian 12 base. Targets x86_64, ARM64 (aarch64), and RISC-V (riscv64).
-
- -
- - - - -
-
- -
-
- Web UI — Angular 21 - Application -
-
Angular 21 + TypeScript + Taiga UI 5 components, served directly by the Rust backend (Axum)
-
The dashboard you use in your browser. Built with Angular (Google's web framework), not served by a separate web server.
-
-

Three Separate Angular Apps

-
    -
  • projects/ui/ — Main admin interface
  • -
  • projects/setup-wizard/ — Initial setup flow
  • -
  • projects/start-tunnel/ — VPN management UI
  • -
-

State Management

-
    -
  • Patch-DB: Backend pushes CBOR diffs over WebSocket
  • -
  • Frontend applies diffs and notifies observers via PatchDB.watch$()
  • -
  • Converted to Angular signals via toSignal()
  • -
  • Reactive — UI updates automatically when backend state changes
  • -
-

Communication

-
    -
  • JSON-RPC exclusively (not REST)
  • -
  • ApiService abstract class with 100+ methods
  • -
  • i18n: 5 languages (en, es, de, fr, pl)
  • -
-
-
- -
-
- Rust Backend — startd (startbox) - Service -
-
Single Rust binary (multi-personality via symlinks: startd, start-cli, start-container, registrybox, tunnelbox)
-
The brain of the system. One binary that does everything — serves the UI, manages containers, handles networking, runs the built-in reverse proxy.
-
-

Key Components

-
    -
  • Axum web server: Serves UI + JSON-RPC API (no separate web server)
  • -
  • VHostController: Built-in reverse proxy with TLS termination (no nginx/caddy)
  • -
  • Patch-DB: Custom CBOR-encoded reactive database with diff-based WebSocket sync
  • -
  • LxcManager: Container lifecycle (create, destroy, garbage collection)
  • -
  • NetController: DNS (hickory-server), SOCKS5, ACME, WiFi, WireGuard, port forwarding
  • -
  • Service Actors: Per-service state machines managing lifecycle
  • -
-

Binary Personalities (symlinks)

-
    -
  • startd — Main daemon
  • -
  • start-cli — CLI interface
  • -
  • start-container — Runs inside LXC containers, communicates with host
  • -
  • registrybox — Package registry daemon
  • -
  • tunnelbox — WireGuard VPN tunnel daemon
  • -
-

Key Dependencies

-
    -
  • Async: Tokio · Web: Axum 0.8 + Hyper 1.5 · TLS: tokio-rustls 0.26 + OpenSSL (vendored)
  • -
  • DNS: hickory-server · Crypto: blake3, ed25519, x25519-dalek, aes
  • -
  • TypeScript bindings: ts-rs (auto-generates TS types from Rust structs)
  • -
-

Systemd

-
    -
  • startd.service: Type=simple, Restart=always, RestartSec=3
  • -
  • LimitNOFILE=65536
  • -
-
-
- -
-
- Container Layer — LXC - Isolation -
-
Linux Containers (LXC) with two-layer model: outer LXC per service + inner subcontainers from S9PK images
-
Each app gets its own sealed Linux environment. Unlike Docker, these are full system containers with their own init process.
-
-

Two-Layer Container Model

-
    -
  • Outer LXC container: One per service. Created by Rust backend via lxc-create/destroy
  • -
  • Base rootfs: SquashFS image (/usr/lib/startos/container-runtime/rootfs.squashfs) mounted as OverlayFS
  • -
  • Inner subcontainers: Node.js container runtime inside each LXC can launch additional containers from S9PK-bundled images
  • -
  • Timeout: 30-second container creation timeout
  • -
-

LXC Configuration

-
    -
  • User namespaces: lxc.idmap = u 0 100000 65536
  • -
  • AppArmor profile: generated with nesting allowed
  • -
  • Network: veth bridge on lxcbr0 (10.0.3.x subnet, host at 10.0.3.1)
  • -
  • OverlayFS rootfs (base read-only squashfs, writes to overlay)
  • -
  • GPU passthrough support: /dev/dri, /dev/nvidia*, /dev/kfd
  • -
-

Communication

-
    -
  • JSON-RPC over Unix domain sockets
  • -
  • /media/startos/rpc/service.sock — Inbound (runtime listens)
  • -
  • /media/startos/rpc/host.sock — Host callbacks (effects)
  • -
-
-
- -
-
- Network Layer - Network -
-
Built-in reverse proxy (Axum/Hyper), DNS (hickory-server), SOCKS5, ACME (Let's Encrypt), WireGuard tunnels
-
No nginx or caddy — the Rust backend IS the web server, proxy, and DNS. Also manages VPN tunnels and encryption certificates.
-
-

Built-in Reverse Proxy

-
    -
  • VHostController in core/src/net/vhost.rs handles all HTTP routing
  • -
  • TLS termination via tokio-rustls with SNI-based routing
  • -
  • No external proxy software (no nginx, no caddy, no traefik)
  • -
  • Virtual hosting with per-service domain assignment
  • -
-

DNS

-
    -
  • Built-in DNS server using hickory-server (formerly trust-dns)
  • -
  • Service discovery and resolution for containers
  • -
  • mDNS via avahi-resolve-host-name for .local domains
  • -
-

TLS / Certificates

-
    -
  • Self-signed root CA per server (NIST P-256 via OpenSSL)
  • -
  • Built-in ACME client (async-acme) for Let's Encrypt with TLS-ALPN-01 challenge
  • -
  • Certificate store managed in Patch-DB
  • -
-

Connectivity

-
    -
  • SOCKS5 proxy: Built-in at 10.0.3.1:1080 for container outbound traffic
  • -
  • WireGuard: First-class support via wg-quick + x25519-dalek
  • -
  • Multi-gateway: Supports multiple interfaces (Ethernet, WiFi, WireGuard) with separate domain configs
  • -
  • Port forwarding: iptables-based via InterfacePortForwardController
  • -
-

Tor (Status: Removed in v0.4)

-
    -
  • Architecture doc mentions "Tor via Arti" but Arti is absent from current Cargo.toml
  • -
  • Previous versions (0.3.x) used the C Tor daemon for hidden services
  • -
  • Likely planned for re-integration but not yet implemented in the 0.4 rewrite
  • -
-
-
- -
-
- Encryption — Optional LUKS on btrfs - Security -
-
Optional LUKS encryption on LVM volumes, btrfs filesystem with COW snapshots for safe installs
-
Disk encryption is optional (you choose during setup). Uses btrfs which can make instant copies of data for safe app updates.
-
-

Encryption (Optional)

-
    -
  • User chooses encrypted or unencrypted during setup
  • -
  • LVM volume groups: STARTOS_<random> (encrypted) or STARTOS_<random>_UNENC
  • -
  • LUKS via cryptsetup luksFormat/luksOpen with password-based key
  • -
  • Default password: "password" (changed during setup)
  • -
-

Filesystem: btrfs

-
    -
  • Copy-on-Write (COW) snapshots for safe service installs
  • -
  • cp --reflink=always for instant volume snapshots before upgrades
  • -
  • If install fails, volumes restored from snapshot automatically
  • -
-

Volume Layout (LVM)

-
    -
  • main (8 GB) — System data, Patch-DB
  • -
  • package-data (100% remaining) — All service/app data
  • -
-
-
- -
-
- Operating System — Debian Bookworm - OS -
-
Debian 12 (same base as Archipelago), systemd services, x86_64 + ARM64 + RISC-V targets
-
Same stable Debian foundation as Archipelago. Supports more CPU architectures including RISC-V.
-
-

Platform Targets

-
    -
  • x86_64: Standard PCs and servers
  • -
  • aarch64: ARM64 (Raspberry Pi 4/5, etc.)
  • -
  • riscv64: RISC-V (emerging architecture)
  • -
-
-
- -
- -
- - - - -
- -

LXC Container Model

-
-
-
Two-Layer Architecture
-
Outer: One LXC container per service, created by the Rust backend. Base rootfs is a read-only SquashFS image mounted as OverlayFS. Inner: Node.js container runtime inside each LXC can launch subcontainers from S9PK-bundled images.
-
Each app gets its own sealed Linux environment with a read-only base. Any changes go to a separate overlay layer.
-
-
-
Communication
-
JSON-RPC over Unix domain sockets. /media/startos/rpc/service.sock (inbound) and host.sock (host callbacks). Services export init(), uninit(), main() via JavaScript ABI.
-
Apps talk to the system through socket files, not network ports. Each app implements 3 required JavaScript functions.
-
-
-
Isolation
-
User namespaces (container UID 0 → host UID 100000, range 65536). AppArmor profiles with nesting. veth bridge on lxcbr0 (10.0.3.x subnet). GPU passthrough support via manifest flag.
-
Container root maps to an unprivileged host user. Each container gets its own virtual network interface.
-
-
-
Lifecycle
-
LxcManager handles creation (30s timeout), garbage collection, and cleanup. Service actors manage per-service state machines. btrfs reflink snapshots before install/upgrade for atomic rollback.
-
-
- -

S9PK Package Format (v2)

-
-
-
Signed Merkle Archive
-
Ed25519 signatures with prehashed content (SHA-512 over blake3 merkle root). Magic bytes: 0x3b 0x3b 0x02. Enables partial downloads, integrity verification of subsets, and efficient delta updates.
-
App packages are cryptographically signed and structured so you can verify integrity without downloading the entire thing.
-
-
-
Archive Contents
-
manifest.json (metadata) + javascript.squashfs (service logic, Node.js) + images/<arch>/*.squashfs (container filesystems per CPU architecture) + assets.squashfs (optional static assets) + icon + LICENSE.md
-
Each package bundles its own containers, logic code, icon, and license in one downloadable file.
-
-
-
Service ABI (JavaScript/Node.js)
-
Services implement init(), uninit(), and main() in JavaScript. The container runtime provides an Effects interface for host callbacks (dependency queries, config, health reporting).
-
App developers write their service logic in JavaScript. The system provides a standard API for the app to interact with the host.
-
-
- -
- - - - -
- -
-
-
JSON-RPC (Host ↔ Service)
-
All communication between the Rust backend and services uses JSON-RPC over Unix domain sockets.
-
Apps and the system talk through a structured messaging format over local socket files — fast and secure.
-
- Transport: Unix domain sockets
- Inbound: /media/startos/rpc/service.sock
- Host callbacks: /media/startos/rpc/host.sock (Effects interface)
- Library: rpc-toolkit (custom Rust crate)
- Used by: All service containers ↔ startd -
-
- -
-
JSON-RPC (UI ↔ Backend)
-
The Angular frontend communicates with startd via JSON-RPC over HTTP. 100+ API methods. State sync via Patch-DB WebSocket.
-
The dashboard sends commands and gets responses in JSON format. Live updates stream automatically through a WebSocket.
-
- Transport: HTTP POST (commands) + WebSocket (state sync)
- State sync: Patch-DB pushes CBOR-encoded diffs over WebSocket
- Frontend applies: PatchDB.watch$() → Angular signals via toSignal()
- Methods: 100+ via ApiService abstract class -
-
- -
-
Patch-DB (CBOR Reactive Sync)
-
Custom reactive database using CBOR encoding. Backend pushes diffs over WebSocket — UI updates automatically without polling.
-
Instead of the UI constantly asking "what changed?", the backend pushes only what changed, in a compact binary format.
-
- Encoding: CBOR (Concise Binary Object Representation, RFC 8949)
- Sync model: Server-push diffs, not request-response
- Storage: /media/startos/data/main/
- Advantage: Much smaller than JSON, real-time without polling -
-
- -
-
HTTPS / TLS (Built-in)
-
TLS termination handled directly by the Rust backend (tokio-rustls). Self-signed root CA per server + ACME for public domains.
-
The backend IS the web server — no nginx or caddy needed. It handles encryption directly.
-
- TLS library: tokio-rustls 0.26 + OpenSSL (vendored, for cert generation)
- Local certs: Self-signed root CA (NIST P-256 keys)
- Public certs: ACME client (async-acme) with TLS-ALPN-01 challenge
- Routing: SNI-based virtual hosting via VHostController -
-
- -
-
WireGuard (VPN Tunnels)
-
First-class WireGuard support for remote access. Users add WireGuard configs as "gateways." Managed by tunnelbox daemon.
-
Built-in VPN for accessing your node from anywhere. Add a WireGuard config and get a secure tunnel.
-
- Implementation: wg-quick + x25519-dalek (Rust)
- Daemon: tunnelbox (symlink of startbox binary)
- Multi-gateway: Supports multiple interfaces with separate domain configs -
-
- -
-
DNS (hickory-server)
-
Built-in DNS server for service discovery and resolution. Also uses mDNS (avahi) for .local domain access on LAN.
-
The system runs its own DNS so containers can find each other by name. Your phone finds the node via .local address.
-
- Library: hickory-server (formerly trust-dns)
- mDNS: avahi-resolve-host-name for .local domains
- Container network: lxcbr0 bridge, host at 10.0.3.1 -
-
-
-
- - - - -
-
-

Not Implemented in StartOS

-

StartOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses password-based sessions and public/private key signatures.

-
-
- - - - -
-
-
-
LXC + User Namespaces
-
Each service in its own LXC container with UID/GID mapping (container 0 → host 100000, range 65536). AppArmor profiles with nesting. OverlayFS rootfs (base read-only).
-
Apps run in isolated Linux environments with their own user systems. Container root is mapped to an unprivileged host user.
-
-
-
Package Signing (Ed25519)
-
All S9PK packages signed with Ed25519 over blake3 merkle roots. Signature verified before installation. Prevents supply chain attacks.
-
Every app package is cryptographically signed. If someone tampers with it, the signature check fails and installation is blocked.
-
-
-
btrfs Snapshots
-
COW filesystem snapshots before every install/upgrade. If an install fails, data is atomically restored to the pre-install state.
-
The system takes a snapshot before every app update. If the update fails, your data is automatically rolled back.
-
-
-
Authentication
-
Password-based + session cookies. Local authcookie for CLI. Public/private key signatures for remote admin. Encrypted wire protocol during setup (public key exchange + encrypted password).
-
-
- -
- - - - -
-
-
-
1
-
-
Preinit Script
-
Optional /media/startos/config/preinit.sh runs before anything else. Enables local auth cookie.
-
-
-
-
2
-
-
Load Database + SSH Keys
-
Patch-DB loaded from disk (CBOR format). SSH developer keys written. MOK enrollment for Secure Boot if applicable.
-
-
-
-
3
-
-
Network Controller
-
DNS server (hickory-server), SOCKS5 proxy, VHost reverse proxy, port forwarding, ACME client, WiFi configuration — all start together.
-
-
-
-
4
-
-
System Initialization
-
Mount logs to data drive, load CA certificate, set CPU governor to performance, NTP clock sync, enable zram, hardware inventory via lshw.
-
-
-
-
5
-
-
Launch Service Intranet + Services
-
LXC bridge network (lxcbr0) created. Database validated. Service actors start all installed services. Postinit script runs.
-
-
-
- -
- - - - -
-
/media/startos/data/ ← Root data directory (optionally LUKS encrypted) - ├── main/ System data, Patch-DB (8 GB LVM volume) - └── package-data/ All service data (remaining disk space) - ├── volumes/{pkg-id}/data/{vol}/ Per-service volume data - ├── volumes/{pkg-id}/assets/{ver}/ Per-service read-only assets - └── logs/{pkg-id}/ Per-service log output - -/usr/lib/startos/ ← System binaries and base images - ├── container-runtime/rootfs.squashfs Base LXC container image - └── package/ Mounted JS from S9PK inside containers - -/var/lib/lxc/ LXC container storage -/media/startos/config/ System config (preinit.sh, postinit.sh, standby) -/media/startos/backups/ Backup mount points per service
-
- - - - - - - - - - - - - - -
- -

Architecture analysis sourced from getumbrel/umbrel on GitHub (master branch). Click any layer to expand.

- -
-
Docker
Container Runtime
-
Node.js
Backend (umbreld)
-
React 19
Frontend
-
Compose
App Format
-
None
Disk Encryption
-
A/B Boot
Rugix Partitions
-
- -
-
React 19 + Tailwind 4 + Radix UIUI
Static SPA served by umbreld's Express server. Zustand + TanStack React Query for state. tRPC for typed API. 8+ languages.
-
umbreld (Node.js 22 / TypeScript)Service
Single daemon on port 80: Express + tRPC API, app lifecycle via Docker Compose, file manager, backups (Kopia), terminal (node-pty).
-
Docker 28.5 (rootful)Containers
Each app is a Docker Compose project. Flat bridge network (10.21.0.0/16). Per-app auth proxy containers. All containers destroyed on boot.
-
NetworkingNetwork
No reverse proxy. Express serves on :80 directly. Optional Tor (containerized). mDNS via avahi. No TLS by default.
-
Debian Trixie (testing) + RugixOS
Date-pinned Debian testing. Rugix A/B root partitions for atomic OS updates with automatic rollback. /data partition persists.
-
- -
- -
-
- -
-
- Web UI — React 19 - Application -
-
React 19 + TypeScript + Vite 6 + Tailwind 4 + Radix UI, served as static SPA by umbreld's Express server
-
The dashboard you use in your browser. Built with React (Meta's web framework), styled with Tailwind.
-
-

Tech Stack

-
    -
  • Framework: React 19 + TypeScript (strict)
  • -
  • Build: Vite 6
  • -
  • Styling: Tailwind CSS 4 + Radix UI primitives + shadcn/ui patterns
  • -
  • State: Zustand (client) + TanStack React Query v5 (server)
  • -
  • API: tRPC React Query v11 for typed RPC
  • -
  • i18n: i18next (8+ languages)
  • -
  • Animations: Framer Motion (as motion package)
  • -
  • Terminal: xterm.js for in-browser terminal
  • -
  • Charts: Recharts for data visualization
  • -
-
-
- -
-
- Backend — umbreld (Node.js/TypeScript) - Service -
-
Single Node.js 22 daemon handling web server, tRPC API, app lifecycle, Tor, backups, file management, and OS updates
-
The brain of the system. A TypeScript process that does everything — web server, app manager, backup handler.
-
-

Key Modules

-
    -
  • Server: Express 4 + tRPC v11 over HTTP and WebSocket on port 80
  • -
  • Apps: Docker Compose lifecycle (install, start, stop, update, uninstall)
  • -
  • AppStore: Git-based — clones getumbrel/umbrel-apps, pulls every 5 minutes
  • -
  • User: Single-user JWT auth (bcrypt $2b$, 12 rounds) + optional TOTP 2FA
  • -
  • Files: File browser with Samba sharing, thumbnails, external storage
  • -
  • Hardware: RAID (ZFS) for Umbrel Home Pro, internal/external storage detection
  • -
  • Backups: Kopia v0.19.0 encrypted backups to external drives
  • -
  • Notifications: In-app notification system + widgets
  • -
  • Terminal: WebSocket-based terminal (node-pty + xterm.js)
  • -
  • Dbus: D-Bus interface to systemd for reboot/shutdown/hostname
  • -
-

State Storage

-
    -
  • YAML file: umbrel.yaml — no database, just a YAML file
  • -
  • Validation: Zod schemas
  • -
  • Docker: dockerode library + execa shell calls
  • -
  • Git: isomorphic-git for app store management
  • -
-

Legacy Compat Layer

-
    -
  • App lifecycle handled by a large bash script (app-script)
  • -
  • Shells out to: docker compose, yq, envsubst, openssl
  • -
  • Explicitly labeled "legacy" in the codebase
  • -
-

Systemd

-
    -
  • umbrel.service: After=network-online.target docker.service
  • -
  • Restart=always, 15-minute stop timeout, StartLimitInterval=0
  • -
-
-
- -
-
- Container Layer — Docker (rootful) - Isolation -
-
Docker 28.5.0 (rootful, not rootless) + Docker Compose v2. Each app is a separate Compose project.
-
Apps run in Docker containers managed by Docker Compose. Unlike Podman, Docker runs as root — simpler but less isolated.
-
-

Docker Setup

-
    -
  • Installed via official Docker install script, pinned to v28.5.0
  • -
  • Rootful (runs as root) — not rootless
  • -
  • Each app: separate Docker Compose project (--project-name <app-id>)
  • -
  • Legacy container naming: <app-id>_<service>_1 for DNS compat
  • -
-

Network: Flat Bridge

-
    -
  • Single shared network: umbrel_main_network (10.21.0.0/16)
  • -
  • All apps share one flat network — any container can talk to any other
  • -
  • Static IPs assigned per service (defined in exports.sh)
  • -
  • No per-app network isolation
  • -
-

Per-App Proxy

-
    -
  • Each app gets an app_proxy container (Node.js Express, getumbrel/app-proxy)
  • -
  • Handles JWT authentication for iframe embedding
  • -
  • Proxies to the actual app container on its internal port
  • -
  • UI renders apps in iframes pointing to proxy port
  • -
-

Boot Cleanup

-
    -
  • On every startup: stops ALL containers, prunes ALL networks
  • -
  • Prevents stale state from previous versions
  • -
  • Pre-loads images from /images/ (tor, auth-server baked into ISO)
  • -
-
-
- -
-
- Network Layer - Network -
-
No traditional reverse proxy. umbreld serves on port 80. Per-app proxy containers. Optional Tor via container.
-
No nginx, no caddy — the backend itself serves on port 80. Each app has its own mini proxy container for authentication.
-
-

HTTP

-
    -
  • umbreld's Express server listens directly on port 80
  • -
  • Serves UI static files + tRPC API
  • -
  • No port 443/TLS by default — HTTP only on LAN
  • -
  • mDNS via avahi for HOSTNAME.local access
  • -
-

Tor (Optional)

-
    -
  • Toggle per-system (not per-app)
  • -
  • tor_proxy container on 10.21.21.11 (SOCKS5)
  • -
  • Each app gets a tor_server container creating hidden services
  • -
  • Dashboard also gets its own hidden service
  • -
  • Provides end-to-end encryption for remote access
  • -
-

Inter-App Communication

-
    -
  • Via static IPs on the flat 10.21.0.0/16 bridge network
  • -
  • No DNS-based service discovery — IPs hardcoded in exports.sh
  • -
-
-
- -
-
- Operating System — Debian Trixie (testing) - OS -
-
Debian Trixie (testing branch, not stable), built from date-pinned snapshot for reproducibility, Rugix A/B partitions
-
Uses Debian's "testing" branch (less stable than Bookworm). Has a clever A/B partition system for safe OS updates.
-
-

OS Build

-
    -
  • Built inside Docker via multi-stage Dockerfile (umbrelos.Dockerfile)
  • -
  • Date-pinned Debian snapshot (e.g., 20251229) for reproducibility
  • -
  • Includes: NetworkManager, avahi, systemd-timesyncd, Bluetooth, SSH
  • -
  • Node.js 22.13.0 baked in
  • -
-

Rugix A/B Partitions

-
    -
  • Two root partitions — active and standby
  • -
  • OS updates write to inactive partition, then swap on reboot
  • -
  • If boot fails, automatic rollback to previous partition
  • -
  • Root filesystem committed after successful boot
  • -
-

Persistent Bind Mounts

-
    -
  • /var/log/data/umbrel-os/var/log
  • -
  • /var/lib/docker/data/umbrel-os/var/lib/docker
  • -
  • /home/data/umbrel-os/home
  • -
  • Separate /data partition persists across OS updates
  • -
-

User

-
    -
  • umbrel (UID 1000), default password umbrel
  • -
  • Synced to web UI password after onboarding
  • -
  • Has sudo access
  • -
-
-
- -
- -
- -
- -

Docker Container Model

-
-
-
Docker 28.5 (Rootful)
-
Docker daemon runs as root (not rootless). Each app is a separate Docker Compose v2 project (--project-name <app-id>). Legacy container naming: <app-id>_<service>_1 for DNS compatibility.
-
Standard Docker running with root permissions. Each app is managed as a Compose project with its own containers.
-
-
-
Flat Network (No Isolation)
-
Single shared bridge: umbrel_main_network (10.21.0.0/16). All apps share one network — any container can communicate with any other. Static IPs assigned per service via exports.sh.
-
All apps are on the same network. A compromised app could potentially reach other apps' services directly.
-
-
-
Per-App Auth Proxy
-
Each app gets an app_proxy container (getumbrel/app-proxy, Node.js Express) that handles JWT authentication for iframe embedding. Proxies to the actual app on its internal port.
-
Each app has a mini web server in front of it that checks your login before letting you in.
-
-
-
Boot Cleanup
-
On every startup: stops ALL containers, prunes ALL networks to prevent stale state. Pre-loads images from /images/ (tor, auth-server baked into ISO).
-
Every reboot starts fresh by destroying all containers and recreating them. Clean but adds startup time.
-
-
- -

App Packaging

-
-
-
umbrel-app.yml
-
App manifest with: id, name, version, port, category, dependencies, permissions (GPU), gallery images, release notes, widgets, torOnly flag, installSize. Validated by Zod schema.
-
A YAML file describing the app — what it does, what it needs, what ports it uses, and how to display it in the store.
-
-
-
docker-compose.yml
-
Standard Docker Compose v3.7. Services reference umbrel_main_network. Images pinned by SHA256 digest. Apps define their own security constraints (no enforced capability dropping).
-
Standard Docker Compose file that defines the app's containers, networks, and volumes.
-
-
-
exports.sh + hooks/
-
exports.sh exports environment variables (IPs, ports, credentials) for dependency resolution. hooks/ directory with lifecycle scripts: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall.
-
Shell scripts that set up environment variables so apps can find each other, plus hooks that run at key lifecycle moments.
-
-
-
App Store (Git repo)
-
Apps distributed via Git repository (getumbrel/umbrel-apps). Cloned locally, pulled every 5 minutes. Community app stores supported. implements field enables alternative implementations (e.g., Bitcoin Knots for Bitcoin Core).
-
The app store is just a Git repository. Umbrel checks for updates every 5 minutes by pulling the latest commits.
-
-
- -
- -
-
-
-
tRPC (UI ↔ Backend)
-
TypeScript-first RPC framework with end-to-end type safety. Runs over both HTTP and WebSocket on port 80.
-
A typed communication channel between the dashboard and the backend. If the API changes, TypeScript catches errors automatically.
-
- Version: tRPC v11
- Transport: HTTP + WebSocket (via Express 4)
- Port: 80 (Express serves both UI and API)
- Type safety: Server types flow directly to client (TanStack React Query v5)
- Used by: React 19 frontend ↔ umbreld -
-
- -
-
Docker Compose (App Lifecycle)
-
Each app managed via Docker Compose v2. Install/start/stop/update handled by a bash script (app-script) calling docker compose.
-
Apps are defined as Docker Compose projects. A bash script handles the lifecycle by calling docker compose commands.
-
- Compose version: v2 (docker compose plugin, not docker-compose binary)
- Lifecycle script: app-script (bash, labeled "legacy")
- Tools used: docker compose, yq, envsubst, openssl
- Hooks: pre/post-install, pre/post-start, pre/post-stop, pre/post-update, pre-uninstall -
-
- -
-
exports.sh (Dependency Resolution)
-
Shell scripts that export environment variables (IPs, ports, RPC credentials). When app B depends on app A, A's exports.sh is sourced first.
-
Apps share their connection details through environment variables set by shell scripts.
-
- Variables exported: IP addresses (static), ports, RPC passwords, hidden service hostnames
- Resolution: Transitive deps resolved in post-order (depth-first)
- Alternative implementations: settings.yml can map dependency (e.g., bitcoin → bitcoin-knots)
- Deps NOT auto-installed: UI warns users to install dependencies first -
-
- -
-
Tor (Optional, Containerized)
-
Toggle per-system. tor_proxy container provides SOCKS5 at 10.21.21.11. Per-app tor_server containers create hidden services.
-
Tor is optional and runs in its own container. When enabled, each app gets its own .onion address for remote access.
-
- SOCKS5: 10.21.21.11 (tor_proxy container)
- Per-app: tor_server container creates hidden service pointing to app_proxy
- Dashboard: Also gets its own hidden service
- Provides: End-to-end encryption for remote access (since no TLS by default) -
-
- -
-
JWT + Proxy Tokens (Auth)
-
JWT for API authentication. Separate "proxy tokens" validate iframe requests to app_proxy containers. bcrypt password hashing.
-
Login tokens that prove who you are. Separate tokens for the dashboard API and for accessing individual apps.
-
- API auth: JWT (jsonwebtoken library)
- Password: bcrypt ($2b$, 12 rounds)
- App auth: UMBREL_PROXY_TOKEN cookie validated by app_proxy containers
- 2FA: Optional TOTP -
-
- -
-
Git (App Store)
-
App store is a Git repository cloned locally via isomorphic-git. Pulled every 5 minutes for updates.
-
The app catalog is just a Git repo. Umbrel checks for new apps and updates by pulling the latest commits every 5 minutes.
-
- Default repo: getumbrel/umbrel-apps (GitHub)
- Library: isomorphic-git (pure JS Git implementation)
- Pull interval: Every 5 minutes
- Community stores: Supported (add custom Git URLs) -
-
-
-
- -
-
-

Not Implemented in umbrelOS

-

umbrelOS does not implement Web5 (DIDs, DWNs, or Verifiable Credentials).
Authentication uses a single-user JWT model. Per-app passwords are derived from a deterministic seed via HMAC-SHA256.

-
-
- -
-
-
-
No Disk Encryption
-
No LUKS, no dm-crypt. All data stored unencrypted on disk. Backups use Kopia with per-repository passwords. A deterministic seed (256-byte random token) derives per-app passwords via HMAC-SHA256.
-
If someone physically steals the drive, all data is readable. No encryption at rest.
-
-
-
Flat Network (No App Isolation)
-
All apps share one Docker bridge (10.21.0.0/16). Any container can communicate with any other container. The app_proxy adds authentication but not network isolation.
-
All apps are on the same network. A compromised app could potentially access other apps' services.
-
-
-
Authentication
-
Single user model. Password hashed with bcrypt ($2b$, 12 rounds). JWT tokens for API auth. Separate "proxy tokens" for app iframe auth. Optional TOTP 2FA. Session cookie: UMBREL_PROXY_TOKEN.
-
-
-
Rootful Docker
-
Docker daemon runs as root. Containers run as UID 1000 where possible, but no enforced capability dropping or security profiles. No --cap-drop=ALL, no no-new-privileges by default.
-
Docker has root access to the machine. Individual containers may or may not restrict their own privileges.
-
-
- -
- -
-
-
-
1
-
-
GRUB / Rugix → Select Active Partition
-
GRUB (amd64) or tryboot (RPi) loads the kernel from the active A/B partition. Rugix commits to current partition on successful boot.
-
-
-
-
2
-
-
systemd → Docker → umbreld
-
systemd starts, brings up networking (NetworkManager) and Docker daemon. umbrel.service starts umbreld --data-directory=/home/umbrel/umbrel.
-
-
-
-
3
-
-
umbreld Initialization
-
Runs startup migrations, syncs system password, restores WiFi, waits for NTP sync (10s, important for RPi with no RTC).
-
-
-
-
4
-
-
Docker Clean Slate
-
Stops and removes ALL containers, prunes ALL networks. Pre-loads images from /images/. Prevents stale state from previous versions.
-
Destructive reset on every boot — ensures clean state but adds startup time
-
-
-
-
5
-
-
Start App Environment + All Apps
-
Starts tor_proxy + auth containers first, then all installed apps in parallel. Express HTTP server starts on port 80. App store update loop begins (every 5 min).
-
-
-
- -
- -
-
/home/umbrel/umbrel/ ← Main data directory (NO encryption) - ├── umbrel.yaml Main config/state (YAML file, not a database) - ├── app-data/{app-id}/ Per-app data, compose files, manifests - ├── app-stores/ Git clones of app store repositories - ├── tor/data/app-{id}/hostname Per-app .onion addresses - ├── db/umbrel-seed/seed Deterministic seed (256-byte) for per-app passwords - ├── secrets/jwt JWT signing secret - └── home/ User files, backups - -/opt/umbreld/ umbreld daemon (npm-linked) -/opt/umbreld/ui/ React SPA static files -/images/ Pre-loaded Docker images (tor, auth-server)
-
- - - - - - - -
- -
-
3
Systems Compared
-
Rust
2/3 Backends
-
Debian
3/3 Base OS
-
3
Container Runtimes
-
3
Frontend Frameworks
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AspectArchipelagoStartOSumbrelOSNotes / Trade-offs
Core Architecture
Backend LanguageRustRustTypeScript / Node.js 22Rust: memory safety, performance, no GC pauses. Node.js: faster prototyping, larger ecosystem, but runtime overhead.
FrontendVue 3 + Vite 7 + TailwindAngular 21 + Taiga UI 5React 19 + Vite 6 + Tailwind 4All modern choices. Angular is heaviest (TypeScript-only). Vue/React are lighter. Tailwind enables rapid UI iteration.
API ProtocolJSON-RPC 2.0JSON-RPC (rpc-toolkit)tRPC v11JSON-RPC is standard and language-agnostic. tRPC gives end-to-end TypeScript type safety but couples frontend/backend.
State SyncPinia + JSON Patch over WebSocketPatch-DB (CBOR diffs over WebSocket)Zustand + TanStack React QueryPatch-DB is most efficient (binary diffs). Archipelago and StartOS push updates; Umbrel polls via React Query.
Reverse ProxyNginx (external, battle-tested)Built into Rust backend (Axum/Hyper)None (Express on :80 + per-app proxy containers)Nginx: proven, configurable, rate limiting. Built-in: fewer moving parts. Umbrel: no central proxy means no rate limiting or security headers.
Container Isolation
Container RuntimePodman (rootless, OCI)LXC (system containers, AppArmor)Docker 28.5 (rootful)Podman: no daemon, rootless by design. LXC: heavier isolation (full system containers). Docker: rootful daemon is a larger attack surface.
Rootless ContainersYes (all containers as UID 1000)User namespaces (UID 0 → host 100000)No (Docker daemon runs as root)Rootless Podman: container escape = unprivileged user. LXC: namespace mapping mitigates. Docker rootful: escape = root on host.
Capability Dropping--cap-drop=ALL + whitelistAppArmor profiles (generated)Not enforced by defaultArchipelago: explicit least-privilege. StartOS: AppArmor provides MAC. Umbrel: apps define their own security (inconsistent).
Network IsolationPer-tier networks (archy-net + bridge)Per-service veth on lxcbr0Flat bridge (10.21.0.0/16, all apps share)Archipelago/StartOS: apps can't see each other unless connected. Umbrel: any container can reach any other.
Memory LimitsPer-container (128MB–4GB)Configurable via manifestNot enforced by defaultMemory limits prevent a single app from consuming all RAM and crashing the system.
Security
Disk EncryptionMandatory LUKS2 (AES-XTS or ChaCha20-Adiantum)Optional LUKS on LVM/btrfsNonePhysical theft risk: Archipelago data is unreadable. StartOS depends on user choice. Umbrel data is fully exposed.
Security HeadersCSP, HSTS, X-Frame-Options, Permissions-PolicyPartial (built-in proxy)None (no central proxy)Headers prevent XSS, clickjacking, and protocol downgrade attacks. Critical for browser-based management.
Rate LimitingAuth 3/s, RPC 20/s, P2P 10/sRBAC via method metadataNoneRate limiting prevents brute-force password attacks and API abuse.
TLSSelf-signed cert on :443 + HSTSSelf-signed CA + ACME (Let's Encrypt)None (HTTP only on LAN)Without TLS, any device on the LAN can intercept credentials. Tor provides encryption for remote but not LAN access.
Auth ModelRBAC (Admin/Viewer/AppUser) + CSRF + session cookiesPassword + session cookies + key signaturesSingle-user JWT + optional TOTPArchipelago supports multiple roles. Others are single-user only.
App Ecosystem
App FormatContainer images from private registryS9PK v2 (signed merkle archive)docker-compose.yml + umbrel-app.ymlS9PK: most sophisticated (signed, partial downloads, delta updates). Compose: simplest for developers. Registry: fast deployment.
Package SigningRegistry-based trustEd25519 over blake3 merkle rootsDocker image digests onlyStartOS has the strongest supply chain security. Archipelago trusts its private registry. Umbrel relies on Docker content trust.
App StoreBuilt-in marketplace (curated)Registry-based (marketplace)Git repository (pulled every 5 min)Git-based: easy for devs to contribute. Registry: more control. Curated: quality gate but slower additions.
Update MechanismISO reflash / manual upgradeRegistry-based OTARugix A/B partitions (atomic, rollback)Umbrel has the smoothest update path with automatic rollback. Archipelago's ISO approach is most disruptive.
Networking & Privacy
TorSystem daemon + hidden services (always available)Removed in v0.4 (planned re-integration)Optional (containerized)Archipelago: Tor is first-class. StartOS temporarily lost Tor in 0.4 rewrite. Umbrel: toggle on/off.
VPNTailscale (WireGuard mesh)WireGuard (first-class, tunnelbox)None built-inBoth Archipelago and StartOS offer remote access without port forwarding. Umbrel relies on Tor or manual setup.
DNSSystem DNS + container NetAvark DNSBuilt-in (hickory-server)Docker DNS + static IPs in exports.shStartOS has the most integrated DNS. Archipelago uses standard tools. Umbrel hardcodes IPs.
Identity & Web5
DID Supportdid:key + did:dht + W3C DID DocumentsNoneNoneArchipelago is the only node OS with decentralized identity support. Enables credential issuance and cross-node trust.
Verifiable CredentialsW3C VC 2.0 (Ed25519Signature2020)NoneNoneArchipelago can issue and verify digital certificates without any central authority.
DWN (Data Store)Custom implementation + peer sync via TorNoneNonePersonal data store that syncs across nodes. Unique to Archipelago.
Nostr IntegrationNIP-01/04/44, nostr-provider.js in iframesNoneNoneArchipelago injects Nostr identity into every app iframe for seamless decentralized social integration.
Infrastructure
Base OSDebian 12 Bookworm (stable)Debian BookwormDebian Trixie (testing)Stable: proven, security patches. Testing: newer packages but less battle-tested, potential for regressions.
Filesystemext4btrfs (COW snapshots)ext4 (A/B partitions)btrfs snapshots enable instant rollback on failed installs. ext4 is simpler and more mature. A/B adds OS-level rollback.
Kiosk DisplayX11 + Chromium on VT7NoneNonePlug in a monitor and the dashboard appears fullscreen. Unique physical UX for dedicated hardware.
Boot RecoveryCrash recovery + container state snapshotsbtrfs snapshots + preinit/postinit hooksDestroys all containers on every bootArchipelago/StartOS: resume from last known state. Umbrel: clean slate every boot (slower but deterministic).
-
- -

Summary

-
-
-
Archipelago
-
Strengths: Security (rootless, LUKS, caps, rate limiting, CSP), identity (DIDs, VCs, DWN, Nostr), kiosk display, Tor first-class.
Trade-offs: No OTA updates (ISO reflash), ext4 lacks snapshot rollback, smaller app ecosystem.
-
-
-
StartOS
-
Strengths: Package signing (S9PK), btrfs snapshots, built-in reverse proxy (fewer moving parts), WireGuard VPN, multi-arch (x86/ARM/RISC-V).
Trade-offs: Tor removed in v0.4, no identity system, Angular is heavier, LXC is less container-ecosystem-compatible.
-
-
-
umbrelOS
-
Strengths: Easiest setup, A/B OTA updates with rollback, largest app ecosystem, Git-based app store (easy contributions), React UI polish.
Trade-offs: No disk encryption, flat network (no isolation), rootful Docker, no TLS, no rate limiting, no security headers, Node.js backend.
-
-
- -
- - - - - diff --git a/docs/container-lifecycle.md b/docs/container-lifecycle.md new file mode 100644 index 00000000..f3b6c3b9 --- /dev/null +++ b/docs/container-lifecycle.md @@ -0,0 +1,108 @@ +# Container lifecycle + +How Archipelago keeps apps in the state you asked for — install, start, stop, +restart, uninstall — and how it self-heals without ever resurrecting something +you deliberately stopped. Source of truth: +`core/archipelago/src/container/prod_orchestrator.rs` and +`core/archipelago/src/container/boot_reconciler.rs`. + +## The model: level-triggered, not fire-and-forget + +Archipelago does not start a container and hope. A long-running **reconciler** +compares *desired state* (what the manifests and your explicit choices say +should be running) against *actual state* (what podman reports) and repairs the +difference. It is **level-triggered**: it acts on the current gap every tick, not +on a one-time event, so a container that dies, a unit that vanishes, or a reboot +that clears everything are all just "the gap is non-zero, close it". + +The reconciler is spawned once at boot (`BootReconciler`) after an initial +`adopt_existing()` pass, and runs every **30 seconds**. It finishes an in-flight +pull or build before honouring a shutdown signal — it is never interrupted +mid-operation. + +Concurrency: each app has its own async mutex guarding all mutating operations +against the reconciler, so a manual `stop` and a reconcile tick can't race, but +reconciles across different apps still run without serialising against each +other. + +## Desired state has three inputs + +For each app the reconciler asks: *should this be running right now?* The answer +comes from three durable signals, checked in this order: + +1. **Explicitly user-stopped** (`user-stopped.json`). If you stopped an app, its + id is recorded and the reconciler leaves it down — it is **not** a gap to + repair. Cleared when you start it again. This is what makes a stop *stick* + across restarts and reboots. +2. **Explicitly uninstalled** (`user-uninstalled.json`). Same idea for uninstall: + a baseline app you removed stays removed, so self-heal can't reinstall it. +3. **Otherwise, the manifest set** — every catalog/disk app that isn't stopped or + uninstalled should be running. + +Dependencies are pulled in: an app that is up requires its declared +dependencies, so they are kept up too — but a dependency you explicitly stopped +still stays stopped. + +## The operations + +All go through the orchestrator, all take the per-app lock, all are idempotent: + +| Operation | What it does | +|-------------|--------------| +| **adopt** | At boot, take ownership of a pre-existing container **by name** rather than recreating it — preserves data, ports and identity across a daemon restart. | +| **install** | Materialise secrets → ensure image (build from a local Dockerfile or use a pre-pulled image) → create and start the container (via Quadlet where enabled). | +| **start / stop** | Bring the container up/down and record the desired-state change. A stop writes the app to `user-stopped.json`. | +| **restart** | Stop then start, preserving the container's data and identity. | +| **remove** | Stop and remove the container, **preserving `/var/lib/archipelago/`, secrets, credentials and ports** — a reinstall or upgrade lands on the same data. | +| **upgrade** | Recreate at a new image while preserving data (see the version rules below). | +| **health** | Report the container's health from its declared `health_check`. | + +## Self-heal vs. respecting your choice + +The one rule that ties it together: **self-heal must never override a deliberate +stop or uninstall.** + +- A container that disappeared while its siblings run — a wedged teardown, a + reboot that cleared it — is a hole to repair, and the reconciler rebuilds it + from the durable "was running" snapshot. +- A container that is down because you stopped or uninstalled it is a *choice*, + and the reconciler leaves it alone. + +A small set of **baseline apps** are expected to exist from first boot and +self-heal when their container is missing — but the `user_stopped` / +`user_uninstalled` gates are checked first, so even a baseline app you turned off +stays off. Getting this wrong in either direction is a real bug: resurrecting a +stopped app ignores the operator, and failing to rebuild a crashed one is the +fire-and-forget failure the whole design exists to remove. + +## Migrations never destroy data + +Any recreate path — upgrade, reinstall, repair — preserves the app's data +directory, its generated secrets, its credentials, its ports, and the container +name used for adoption. An update that would roll a version *backwards* is +refused (see the version guard in `container::image_versions`): the update button +never offers a lower version than what is running, so a stale record cannot turn +into a downgrade. Version pins are honoured — a pinned app is not "updated" out +from under the operator by the catalog. + +## Inspecting lifecycle state + +```bash +# what podman actually has — run as the archipelago service user (rootless) +podman ps -a --format '{{.Names}}\t{{.Status}}' + +# the durable desired-state signals +cat /var/lib/archipelago/user-stopped.json +cat /var/lib/archipelago/user-uninstalled.json + +# the reconciler's decisions. archipelago.service is a SYSTEM unit that runs +# as User=archipelago (WantedBy=multi-user.target), so this is not --user — +# unlike the companion Quadlet units, which are per-user. +sudo journalctl -u archipelago | grep -iE 'reconcile|adopt|install|user.stopped' +``` + +## Related + +- [Manifest → Quadlet unit](quadlet-compilation.md) — how the unit the reconciler manages is generated +- [App secrets](secrets.md) — the `ensure_generated_secrets` tick that runs before start +- [App Manifest Specification](app-manifest-spec.md) — `health_check`, `dependencies`, `restart` fields diff --git a/docs/demo-build-info.md b/docs/demo-build-info.md index 4992b7c9..027c95d8 100644 --- a/docs/demo-build-info.md +++ b/docs/demo-build-info.md @@ -3,7 +3,7 @@ **Status:** implemented & deployable (2026-07-14) **Branch:** `main` — the demo machinery was merged from the old `demo-build` branch and now lives on main, pushed to -`gitea-vps2` = `http://146.59.87.168:3000/lfg2025/archy.git`. +`gitea-vps2` = `https://source.archipelago-foundation.org/lfg2025/archy.git`. A public, click-to-play demo of the Archipelago UI, 100% mock-data driven, multi-visitor, deployed via Portainer. See also `docs/archive/demo-deployment-design.md` @@ -17,7 +17,7 @@ Build-from-repo (works today, no registry needed): | Field | Value | |-------|-------| -| Repository URL | `http://146.59.87.168:3000/lfg2025/archy.git` | +| Repository URL | `https://source.archipelago-foundation.org/lfg2025/archy.git` | | Reference | `refs/heads/main` | | Compose path | `docker-compose.demo.yml` | | Auth | user `lfg2025`, password = Gitea token | diff --git a/docs/developer-guide.md b/docs/developer-guide.md index 37d81e91..184d5233 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -54,11 +54,10 @@ archy/ │ ├── vite.config.ts │ └── package.json ├── scripts/ # Deployment and utility scripts -│ ├── deploy-to-target.sh # Main deploy script │ ├── first-boot-containers.sh # ISO first-boot setup │ └── run-tests.sh # CI test runner ├── image-recipe/ # ISO build configuration -│ ├── build-auto-installer-iso.sh +│ ├── build-debian-iso.sh │ └── configs/ # Nginx, systemd configs ├── docs/ # Documentation │ ├── architecture.md @@ -66,7 +65,7 @@ archy/ │ ├── marketplace-protocol.md │ └── multi-node-architecture.md ├── apps/ # App manifests (YAML) -├── CLAUDE.md # AI development instructions +├── CLAUDE.md # Contributor guide (invariants, build/verify) └── docs/ROADMAP.md # Project roadmap ``` @@ -91,19 +90,16 @@ The dev server at `http://localhost:8100` uses a mock backend. ### Deploying Changes -Release and host-integration builds should run on Linux. The deploy script rsyncs -source to a configured Linux target and builds there. +Release and host-integration builds should run on Linux. Build the backend and +frontend on the target, or cross-build and copy the artifacts across: ```bash -# Deploy to the configured primary target (builds backend + frontend, restarts services) -./scripts/deploy-to-target.sh --live - -# Deploy to both configured targets -./scripts/deploy-to-target.sh --both +cd core && cargo build --release +cd neode-ui && npm ci && npm run build ``` -The deploy script: -1. Rsyncs source to the server +A deploy then: +1. Copies the build output to the node 2. Builds Rust backend on the server (`cargo build --release`) 3. Builds Vue frontend (`npm run build`) 4. Copies artifacts to production paths @@ -203,7 +199,6 @@ async myAction(params: { name: string }): Promise<{ ok: boolean; result: string ### 5. Deploy and Test ```bash -./scripts/deploy-to-target.sh --live curl -X POST http:///rpc/v1 \ -H "Content-Type: application/json" \ -b "archipelago_session=YOUR_SESSION" \ @@ -312,6 +307,5 @@ mod tests { 1. Create a feature branch: `git checkout -b feature/my-feature` 2. Make changes following the standards above 3. Test locally: `cd neode-ui && npm test` -4. Deploy to dev server: `./scripts/deploy-to-target.sh --live` -5. Verify on your configured development target -6. Commit with conventional format: `feat: add my feature` +4. Verify on an Archipelago node +5. Commit with conventional format: `feat: add my feature` diff --git a/docs/dht-distribution-design.md b/docs/dht-distribution-design.md index a209c4c7..a8fd7c48 100644 --- a/docs/dht-distribution-design.md +++ b/docs/dht-distribution-design.md @@ -1,6 +1,13 @@ # DHT / Peer-Distributed Content Design -**Status:** Design (no code yet) · **Date:** 2026-06-16 · **Author:** archipelago + Claude +**Status:** partially implemented — **not** "no code yet" as this line previously +read. `core/archipelago/src/swarm/` exists (`mod.rs`, `iroh_provider.rs`, +`paid.rs`, `paid_alpn.rs`, `payment.rs`) along with `content_hash.rs`, behind the +**default-off** `iroh-swarm` cargo feature (`Cargo.toml:21` — the iroh/iroh-blobs +deps are optional and only pulled in by that feature). `config.swarm_enabled` +gates it at runtime and also defaults off, so a stock build ships this inert. +Treat the phases below as design; check the feature flag before assuming a phase +is live. · **Date:** 2026-06-16 · **Author:** archipelago + Claude ## 1. Purpose @@ -28,7 +35,7 @@ origin": ### OTA (`core/archipelago/src/update.rs`) - Manifest at `DEFAULT_UPDATE_MANIFEST_URL` (`update.rs:67`) = vps2 OVH - (`146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json`). + (`source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json`). - `check_for_updates()` (`:565`) walks an operator mirror list (`default_mirrors()` `:105`, `load_mirrors()` `:123`), origin-rewrites component URLs to the chosen mirror (`rewrite_manifest_origins()` `:227`). @@ -65,9 +72,9 @@ origin": ### IndeeHub (the streaming target) - Original platform (not a fork). Working source: `~/Projects/Indeedhub Prototype/` - (Vue 3 + NestJS). Submodule `146.59.87.168:3000/lfg2025/indeehub.git` (repointed off the retired host — + (Vue 3 + NestJS). Submodule `source.archipelago-foundation.org/lfg2025/indeehub.git` (repointed off the retired host — needs a live remote). In `archy`: image-only, `apps/indeedhub/manifest.yml` pulls - `146.59.87.168:3000/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, + `source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0` (+ `-api`, `-ffmpeg`, postgres, redis, minio, nostr-rs-relay). - Streaming today: FFmpeg → **HLS (.m3u8 + AES-128 .ts segments)** in **MinIO** (`indeedhub-private`/`-public`), metadata in Postgres, transcode queue in Redis, diff --git a/docs/dual-ecash-design.md b/docs/dual-ecash-design.md index 664bcbfe..7911a95f 100644 --- a/docs/dual-ecash-design.md +++ b/docs/dual-ecash-design.md @@ -94,7 +94,7 @@ in sync with the manifest env. clientd is a **client, not the guardian** — it under `image-recipe/_archived/` (likely stale); `first-boot-containers.sh`/`image-versions.sh` are current. - Image: build from source (no official image; `flake.nix` only) → push to vps2 - `146.59.87.168:3000/lfg2025/fedimint-clientd:v0.4.0`. + `source.archipelago-foundation.org/lfg2025/fedimint-clientd:v0.4.0`. ### 5. Unified balance `HomeWalletCard` ecash row = Cashu `wallet.ecash-balance` + Fedimint `wallet.fedimint-balance`. diff --git a/docs/hotfix-process.md b/docs/hotfix-process.md deleted file mode 100644 index 865ceecf..00000000 --- a/docs/hotfix-process.md +++ /dev/null @@ -1,52 +0,0 @@ -# Hotfix Process - -For critical bugs discovered after a tagged release. - -## Severity Classification - -| Level | Response Time | Examples | -|-------|--------------|---------| -| P0 — Critical | < 4 hours | Data loss, security vulnerability, node bricked | -| P1 — High | < 24 hours | App won't start, auth broken, major UI failure | -| P2 — Medium | < 72 hours | Non-critical feature broken, performance regression | -| P3 — Low | Next release | Cosmetic, minor UX, edge cases | - -## Hotfix Workflow - -### 1. Triage -- Reproduce the issue on dev server (192.168.1.228) -- Classify severity (P0-P3) -- P0/P1: proceed immediately. P2/P3: add to the next release (`docs/UNIFIED-TASK-TRACKER.md`). - -### 2. Fix -- Create branch: `hotfix/vX.Y.Z-description` -- Fix the issue with minimal code changes -- Run full test suite: `cd neode-ui && npm test && npm run type-check` -- Deploy to dev server: `./scripts/deploy-to-target.sh --live` -- Verify fix on live server - -### 3. Release -- Merge hotfix branch to `main` -- Tag: `vX.Y.Z` (increment patch version) -- Cut the release with `./scripts/create-release.sh X.Y.Z` (updates - `releases/manifest.json` and signs it) -- Push `main` + tags to the primary Gitea release server so nodes pick it up OTA - -### 4. Communicate -- Update RELEASE-NOTES with hotfix details -- Note in CHANGELOG.md - -## Monitoring Dashboards - -- **Uptime monitor**: `/var/lib/archipelago/uptime-monitor/summary.json` -- **Soak test**: `/tmp/stability-test-*.log` on dev server -- **Health endpoint**: `http://192.168.1.228/health` - -## Rollback - -If a hotfix causes regressions: -1. The updater self-verifies after applying (health check on restart) and rolls the - binary back automatically if the new one fails to come up -2. Point `releases/manifest.json` back at the last-known-good version and push -3. Backend binary backups: `/opt/archipelago/rollback/archipelago.bak` (deploy script) - and `/var/lib/archipelago/update-backup/archipelago.bak` (`self-update.sh`) diff --git a/docs/manifest-hooks-design.md b/docs/manifest-hooks-design.md index 6e0aa17e..262e2889 100644 --- a/docs/manifest-hooks-design.md +++ b/docs/manifest-hooks-design.md @@ -3,7 +3,7 @@ **Status:** implemented through Phase 4 (see §6; updated 2026-07-08) — only declarative `pre_start` remains · originally Task #20 (indeedhub, netbird) off legacy Rust installers. -See `docs/PRODUCTION-MASTER-PLAN.md`, `docs/APP-PACKAGING-MIGRATION-PLAN.md` +See `docs/APP-PACKAGING-MIGRATION-PLAN.md` ("controlled hooks"). --- diff --git a/docs/marketplace-protocol.md b/docs/marketplace-protocol.md index bf96ee9f..b73bd845 100644 --- a/docs/marketplace-protocol.md +++ b/docs/marketplace-protocol.md @@ -10,6 +10,14 @@ purchases). What remains is maturation: publishing tooling and trust UX own flatter format, **not** the runtime `apps/*/manifest.yml` schema (`app-manifest-spec.md`). +> **The DID signature layer is implemented** as of 2026-08-08. `publish` signs +> with the node's Ed25519 identity key, `discover` verifies every manifest +> before caching it, and a manifest whose signature is *present but wrong* is +> dropped rather than listed at a lower score. See +> [Signing Protocol](#signing-protocol) for the exact preimage rules — they are +> normative, and an implementation that canonicalises differently will produce +> signatures this node rejects. + ## Overview Archipelago's community marketplace enables developers to publish app manifests to Nostr relays, where nodes discover and install them without a central app store. Trust is established through DID-signed manifests and community reputation. @@ -37,7 +45,12 @@ Developer Node Nostr Relays User Node ## Manifest Schema -App manifests published to Nostr relays follow the existing `apps/{app-id}/manifest.yml` schema (see `docs/app-manifest-spec.md`), serialized as JSON within a Nostr event. +App manifests published to Nostr relays use the marketplace's own flatter JSON +schema — the `AppManifest` type in `marketplace.rs`, shown below — serialized +into the Nostr event's `content`. It is **not** the runtime +`apps/{app-id}/manifest.yml` schema in +[`app-manifest-spec.md`](app-manifest-spec.md); the two are separate types that +happen to share a name. ### Marketplace Manifest Fields @@ -98,7 +111,7 @@ App manifests published to Nostr relays follow the existing `apps/{app-id}/manif |-------|---------|-------------| | `container.readonly_root` | true | Container root filesystem is read-only | | `container.no_new_privileges` | true | Prevent privilege escalation | -| `container.run_as_user` | 1000 | UID to run as (must be > 1000) | +| `container.run_as_user` | 1000 | UID to run as (must be ≥ 1000) | | `container.capabilities` | [] | Required Linux capabilities (drop all, add only needed) | ## Nostr Event Format @@ -141,13 +154,21 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** ### Publishing a Manifest 1. Developer creates/updates their app manifest -2. Serialize manifest as JSON -3. Compute SHA-256 hash of the serialized manifest -4. Sign the hash with the developer's DID key -5. Embed manifest + signature in Nostr event content -6. Sign the Nostr event with the node's secp256k1 key +2. `author.did` is filled in with the node's own `did:key` if empty. If it is + set to a **different** DID, publishing is refused — the node can only sign as + itself, and broadcasting a manifest every verifier will reject helps nobody +3. Canonicalise the manifest without `signatures` and SHA-256 it (see + [Signing Protocol](#signing-protocol)) +4. Sign the digest with the node's Ed25519 identity key and attach `signatures` +5. Embed the signed manifest as the Nostr event content +6. Sign the Nostr event with the node's secp256k1 Nostr key 7. Publish to all configured Nostr relays +Note the two distinct keys: the **Ed25519 identity key** proves *authorship of +the manifest* and is what `author.did` names; the **secp256k1 Nostr key** proves +*who sent this event*. They are separate on purpose — relaying is not +authorship, and only the first survives being copied between relays. + ### Discovering Manifests 1. Node queries configured relays with filter: @@ -172,13 +193,29 @@ App manifests use **NIP-78 application-specific data** with event kind **30078** Each discovered app receives a trust score (0-100) based on: -| Factor | Weight | Description | -|--------|--------|-------------| -| **DID Verification** | 30 | Manifest is signed by a valid DID key | -| **Relay Consensus** | 20 | Manifest found on multiple independent relays | -| **Federation Trust** | 20 | Developer's DID is in the user's federation network | -| **Version History** | 15 | App has multiple published versions (shows maintenance) | -| **Security Compliance** | 15 | Manifest follows all security requirements | +This table is `calculate_trust_score()` in `marketplace.rs`. What each factor +actually checks: + +| Factor | Max | What is checked | +|--------|-----|-----------------| +| **Identity proven** | 30 | The manifest carries a `valid` DID signature — the author demonstrated control of the key `author.did` encodes. Requires key material; cannot be faked by choosing a string | +| **Relay consensus** | 20 | Graduated, and never zero: 1 relay → 5, 2–3 → 12, 4+ → 20 | +| **Federation trust** | 20 | `author.did` is in the user's federated DID list **and** identity is proven. Both halves are required — see below | +| **Provenance** | 15 | 10 for a 3-part semver `version`, 5 for a non-empty `repo_url`. Nothing counts published versions | +| **Security compliance** | 15 | 15 when `validate_manifest()` returns no issues, 5 when it returns 1–2, 0 otherwise | + +Both identity-derived factors hang off the signature, which is the point: + +- Before, "DID present" was `did.starts_with("did:")`, so an unsigned manifest + with a plausible-looking DID string and a pinned image scored 65 — *Community* + tier — on no cryptography whatsoever. It now scores 35, *Unverified*. +- Federation trust is gated too. An unverified `author.did` is just a string the + publisher chose, so an attacker could otherwise copy the DID of a peer the + user federates with and collect 20 points for impersonating precisely the + party the user trusts most. + +An unsigned publisher is not punished beyond losing those points: `missing` is a +normal state, and such apps still appear. ### Trust Tiers @@ -212,19 +249,44 @@ When a developer's DID appears in the user's federation network (trusted peer), ### Manifest Signing (DID Layer) +**Normative.** These rules define the signed preimage byte-for-byte. An +implementation that canonicalises differently will produce signatures this node +rejects, so they are worth following exactly. + ``` -1. Serialize manifest to canonical JSON (sorted keys, no whitespace) -2. Compute: manifest_hash = SHA-256(canonical_json) -3. Sign: did_signature = Ed25519_Sign(did_private_key, manifest_hash) -4. Attach to manifest: +1. Take the manifest with `signatures` REMOVED (a signature cannot cover the + field that holds it; omit the key entirely rather than setting it null). +2. Canonicalise to JSON: + - every object's keys sorted lexicographically, recursively; + - no insignificant whitespace; + - arrays keep their order. +3. manifest_hash = SHA-256(canonical_json_bytes) +4. did_signature = Ed25519_Sign(author_private_key, manifest_hash) + ^ the signature covers the 32 RAW DIGEST BYTES, not the "sha256:..." + string and not the JSON itself. +5. Attach: { "signatures": { - "manifest_hash": "sha256:", - "did_signature": "" + "manifest_hash": "sha256:<64 lowercase hex chars>", + "did_signature": "" } } ``` +The signing key MUST be the Ed25519 key that `author.did` encodes — `author.did` +is a `did:key` whose multibase body is `0xed01 || <32-byte public key>`. A +publisher signing with any other key produces a manifest that verifies as +`invalid` and is dropped. + +**Why canonicalisation is required and not cosmetic.** `container.env` is a map, +and map iteration order is not stable across processes or implementations. Sign +the serialiser's natural output and the same manifest hashes differently between +runs, so signatures fail at random rather than never — much harder to diagnose +than a clean rejection. Sorting keys removes the ambiguity. + +`archipelago` implements this in `marketplace::canonical_signing_bytes` / +`sign_manifest` / `verify_manifest_signature`. + ### Event Signing (Nostr Layer) Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fields). This is handled by the Nostr client library. @@ -233,16 +295,32 @@ Standard NIP-01 Schnorr signature over the event ID (hash of serialized event fi ``` Receiving Node: - 1. Verify Nostr event signature (NIP-01) → Proves event authenticity - 2. Extract manifest JSON from event content - 3. Compute SHA-256 of manifest content - 4. Compare with manifest.signatures.manifest_hash → Proves content integrity - 5. Resolve DID document for manifest.author.did - 6. Verify did_signature with DID public key → Proves developer identity - 7. Check container.image tag is pinned (not :latest) - 8. Validate security fields meet minimums + 1. Verify Nostr event signature (NIP-01) → event authenticity [IMPLEMENTED] + 2. Extract manifest JSON from event content [IMPLEMENTED] + 3. Canonicalise the manifest without `signatures`, SHA-256 it [IMPLEMENTED] + 4. Compare with manifest.signatures.manifest_hash → content integrity [IMPLEMENTED] + 5. Resolve author.did (did:key) to its Ed25519 public key [IMPLEMENTED] + 6. Verify did_signature over the digest → author identity [IMPLEMENTED] + 7. Check container.image tag is pinned (not :latest) [ADVISORY] + 8. Validate security fields meet minimums [ADVISORY] ``` +Steps 3–6 are `verify_manifest_signature()`, which returns one of three verdicts +rather than a boolean: + +| Verdict | Meaning | What discovery does | +|---|---|---| +| `valid` | Hash matches the content **and** the key named by `author.did` signed it | Listed; earns the identity-derived trust points | +| `missing` | No `signatures` block | Listed, but scores **zero** on identity and federation. An unsigned publisher is unproven, not hostile | +| `invalid` | A `signatures` block is present and wrong — tampered, corrupt, or signed by another key | **Dropped entirely**, with the reason logged. Never cached, never installable | + +That `invalid` handling is deliberate: a broken signature is not a low-quality +manifest, it is a forged or corrupted one, so it fails closed rather than +appearing with a scary badge someone can click past. + +Steps 7–8 still run, but `validate_manifest()` returns a list of *issues* that +feed the trust score — they do not block discovery or installation. + ## RPC Endpoints ### Marketplace Discovery @@ -250,30 +328,67 @@ Receiving Node: | Method | Description | Auth | |--------|-------------|------| | `marketplace.discover` | Query relays for app manifests, verify, score, return sorted | Local | -| `marketplace.publish` | Publish an app manifest to configured relays | Local | +| `marketplace.publish` | Sign the manifest with this node's identity key, then publish to configured relays | Local | | `marketplace.get-manifest` | Get full manifest for a specific app by ID | Local | -| `marketplace.verify` | Verify a manifest's signatures and security compliance | Local | +| `marketplace.verify` | Check a manifest's DID signature and security compliance without publishing it | Local | + +`marketplace.verify` returns the signature verdict separately from the advisory +policy issues, because they mean different things: + +```json +{ + "signature": { "status": "invalid", "reason": "did_signature does not verify against author.did" }, + "signature_valid": false, + "valid": true, // ← policy compliance only; NOT authenticity + "issues": [], + "trust_score": 35, + "trust_tier": "unverified" +} +``` + +`valid` has always meant "passes the advisory security checks". Read +`signature_valid` for authenticity. Discovered apps carry the same verdict in +their `signature` field. ### Manifest Management | Method | Description | Auth | |--------|-------------|------| | `marketplace.list-published` | List manifests published by this node | Local | -| `marketplace.unpublish` | Remove a published manifest from relays | Local | + +### Purchases + +| Method | Description | Auth | +|--------|-------------|------| +| `marketplace.create-invoice` | Create a Lightning BOLT11 invoice for a paid app | Local | +| `marketplace.check-payment` | Poll whether an invoice has settled | Local | + +`marketplace.unpublish` was specified here but **never implemented** — the +string appears nowhere in the codebase, and there is no dispatcher entry. NIP-33 +replaceable events mean an unpublish would have to be a tombstone/replacement +rather than a delete, which is presumably why it stalled. ## Security Requirements ### Container Security Enforcement -Before installing a community app, the node validates: +`validate_manifest()` checks the following and returns them as a list of issues. +**These are score inputs, not gates** — a manifest that fails all of them is +still discoverable and installable, it just scores 0 on the security factor: 1. **No `latest` tag**: Image must use a specific version tag -2. **Read-only root**: `readonly_root` must be true (or explicitly overridden by user) -3. **No root**: `run_as_user` must be > 1000 -4. **No new privileges**: `no_new_privileges` must be true -5. **Minimal capabilities**: Only allowed capabilities are accepted (CHOWN, NET_BIND_SERVICE, etc.) -6. **No host networking**: Apps cannot use `--network host` -7. **Volume restrictions**: Apps cannot mount system paths (/, /etc, /var, /usr) +2. **Read-only root**: `readonly_root` should be true +3. **No root**: `run_as_user` must be **≥ 1000** (the code's bound; the example + manifest above uses exactly `1000`) +4. **No new privileges**: `no_new_privileges` should be true + +Items previously listed here — a capability allow-list, a host-networking ban, +and system-path mount restrictions — are **not** part of marketplace validation. +Those rules exist, but they live in the runtime manifest parser +(`core/container/src/manifest.rs`, see [`app-manifest-spec.md`](app-manifest-spec.md)) +and apply to `apps/*/manifest.yml`, which is a different schema from the +marketplace manifest. Closing that gap is part of the pre-third-party-publishing +work. ### Image Verification @@ -316,22 +431,29 @@ Accessible from Settings or a "Developer" section: ``` /var/lib/archipelago/marketplace/ ├── cache/ - │ ├── manifests.json # Cached discovered manifests - │ └── trust-scores.json # Cached trust scores - ├── published/ - │ └── .json # Manifests published by this node - └── config.json # Marketplace preferences (auto-refresh interval, etc.) + │ └── manifests.json # Cached discovered manifests, trust scores included + └── published/ + └── .json # Manifests published by this node ``` +The earlier version of this tree also listed `cache/trust-scores.json` and +`config.json`. Neither is written: scores live on the cached entries themselves +(`MarketplaceCache`), and there is no marketplace preferences file. + ## Implementation Notes ### Relay Query Strategy -1. Query all enabled relays in parallel (from `nostr_relays.rs` config) +1. Query all enabled relays in parallel (from `nostr_relays.rs` config), with a + 10s connect timeout and a 20s fetch timeout per relay 2. Deduplicate manifests by `app_id` + `version` -3. If same manifest found on multiple relays, boost trust score -4. Cache results with 15-minute TTL -5. Background refresh every 30 minutes +3. If the same manifest is found on multiple relays, boost trust score +4. Write results to `cache/manifests.json` + +Items 4–5 of the original design — a 15-minute cache TTL and a 30-minute +background refresh — are **not implemented**. The cache has no expiry and +nothing refreshes it on a timer; it is rewritten whenever +`marketplace.discover` runs. ### Version Comparison diff --git a/docs/multinode-testing-plan.md b/docs/multinode-testing-plan.md deleted file mode 100644 index bbda64b3..00000000 --- a/docs/multinode-testing-plan.md +++ /dev/null @@ -1,69 +0,0 @@ -# Multinode / Fleet Testing Plan (separate from the single-node gate) - -> **Scope split (2026-06-22):** the production test gate (`docs/PRODUCTION-MASTER-PLAN.md` §5, -> `tests/lifecycle/TESTING.md`) is now a **single-node criterion on .228**. Verifying the same -> lifecycle matrix across the rest of the fleet (.198 and the other testers) lives HERE and is run -> **after** the .228 single-node gate is green. This is intentionally NOT a blocker on the .228 gate. - -## Why split it out - -The lifecycle gate must be **run ON the node under test** — its bitcoin/companion/orphan/endpoint -checks use local `podman`/`systemctl`/`bitcoin-cli`/`curl`, not RPC to a remote host. Running it from -one host against another silently tests the *runner*. So "multinode" isn't "point the harness at N -hosts" — it's "run the on-node gate on each host," plus the genuinely cross-node concerns (federation, -mesh, transport, sync) that a single node can't exercise. - -## How to run the gate on another node - -Bats + jq usually aren't installed on ISO nodes. Bootstrap (one-time per node): - -``` -# from a host that has them (e.g. .116): -dpkg -L bats | grep -E '^/usr/(bin|lib|libexec)' | tar czf /tmp/bats.tgz -P -T - $(which jq) -tar czf /tmp/tests.tgz -C tests/lifecycle -scp /tmp/bats.tgz /tmp/tests.tgz :/tmp/ -# on the node: -sudo tar xzf /tmp/bats.tgz -P -C / # bats (jq here is dynamically linked — may need libs) -sudo curl -fsSL -o /usr/local/bin/jq \ - https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64 && sudo chmod +x /usr/local/bin/jq -mkdir -p /tmp/lifecycle-run && tar xzf /tmp/tests.tgz -C /tmp/lifecycle-run -cd /tmp/lifecycle-run/tests/lifecycle -ARCHY_HOST=127.0.0.1 ARCHY_SCHEME=https ARCHY_PASSWORD= \ - ARCHY_ALLOW_DESTRUCTIVE=1 ARCHY_ITERATIONS=5 nohup ./run-gate.sh > /tmp/gate.log 2>&1 & -``` - -## Per-node preconditions (learned on .228) - -- **Bitcoin must be fully synced + archival** (`initialblockdownload:false`, `pruned:false`). - test 83 reads the *real* `getblockchaininfo`, not the UI's headers-height. A node mid-IBD will - cascade-fail electrumx/lnd/btcpay/mempool even though the apps run. -- **Backends should be proper installs** (in `manifest_ids`), not adopted plain-podman left over - from ad-hoc `package.start`/cascade churn — otherwise companion self-heal and quadlet checks skew. -- **No stale per-app nginx proxy targets.** e.g. `/app/lnd/` must point at the lnd-ui port (18083), - not a stale `8081`. Repo code is correct; old node configs may be stale — re-check + regenerate. -- **No orphan quadlet units** (e.g. a `home-assistant.container` whose ContainerName ≠ the real - `homeassistant` container) — these wedge `systemctl --user` "activating" and fail the quadlet checks. - -## Node roster (carry-over) - -| Node | Role | Notes | -|------|------|-------| -| .228 | **single-node gate** (primary) | 14-app resilience node; bitcoin synced archival; gate GREEN. | -| .198 | fleet verify | was weak/loaded (load ~3–5) + **bitcoin mid-IBD** at split time → must finish syncing first; sshd wedges under concurrent SSH (use ONE session; gate uses HTTPS RPC so fine). | -| .5 / .120 | x250 testers (Tailscale) | flaky cellular; SSH via `tailscale nc` ProxyCommand. | -| .116 | dev/validation | local repo; its own bitcoin may be mid-IBD — do NOT treat as a gate target unless synced. | - -## Cross-node concerns (only a multinode setup can test) - -- Federation sync (Tor/FIPS transports), DID/contact federation, peer file fetch. -- Mesh (Meshtastic/MeshCore) + mesh-AI gating. -- Dual-ecash federation validation + networking-sats routing. -- DHT / iroh swarm distribution (origin-always-wins) once that dep lands. - -## Sequence - -1. Get the **.228 single-node gate green 5×** (master plan §5/§6) — DONE/in progress. -2. THEN: bring each fleet node to the preconditions above; run the on-node gate 5× per node. -3. THEN: the cross-node suites (federation/mesh/transport), tracked here. - -This plan does not gate the v1.7.x single-node criterion; it is the next layer. diff --git a/docs/operations-runbook.md b/docs/operations-runbook.md deleted file mode 100644 index 647288a4..00000000 --- a/docs/operations-runbook.md +++ /dev/null @@ -1,366 +0,0 @@ -# Archipelago Operations Runbook - -Quick reference for common operational tasks on Archipelago nodes. - -**Primary node**: `192.168.1.228` (Arch 1) -**Secondary node**: `192.168.1.198` (Arch 2) -**SSH**: `ssh -i ~/.ssh/archipelago-deploy archipelago@{IP}` -**Sudo**: use the node's sudo password (kept out of this doc — never commit credentials) - ---- - -## 1. Check Node Health - -```bash -# Quick health check (from any machine) -curl http://192.168.1.228/health # Should return "OK" -curl http://192.168.1.198/health - -# Detailed system stats via RPC -curl -s -X POST -H "Content-Type: application/json" \ - -d '{"method":"system.stats"}' \ - http://192.168.1.228:5678/rpc/v1 - -# Check services -ssh archipelago@192.168.1.228 -sudo systemctl status archipelago # Backend service -sudo systemctl status nginx # Web server -sudo systemctl status tor # Tor hidden services -``` - -## 2. Check Container Status - -```bash -# List all containers -podman ps -a - -# Running count -podman ps --format '{{.Names}}' | wc -l - -# Find exited/crashed containers -podman ps -a --filter status=exited - -# Container logs -podman logs {container-name} --tail 50 - -# Container resource usage -podman stats --no-stream -``` - -## 3. Fix Crashed Containers - -```bash -# Restart a specific container -podman restart {container-name} - -# If container won't start, check logs first -podman logs {container-name} --tail 100 - -# Remove and recreate (last resort) -podman rm -f {container-name} -# Then redeploy with: ./scripts/deploy-to-target.sh --live - -# The health monitor auto-restarts containers every 60s -# Check its status: -sudo journalctl -u archipelago --grep="health_monitor" --no-pager -n 20 -``` - -## 4. Add/Remove Federation Peers - -```bash -# Generate invite code (on inviting node) -# Via UI: Federation page > Generate Invite -# Via RPC: -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.invite"}' \ - http://localhost:5678/rpc/v1 - -# Join federation (on joining node) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.join","params":{"invite_code":"{code}"}}' \ - http://localhost:5678/rpc/v1 - -# List peers -curl -s -X POST -H "Content-Type: application/json" \ - -d '{"method":"federation.list-nodes"}' \ - http://localhost:5678/rpc/v1 - -# Remove a peer -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"federation.remove-node","params":{"did":"{peer-did}"}}' \ - http://localhost:5678/rpc/v1 -``` - -## 5. Rotate Tor Address - -```bash -# Delete current hidden service keys -sudo rm -rf /var/lib/tor/hidden_service/ -sudo systemctl restart tor - -# Wait for new hostname -sleep 15 -sudo cat /var/lib/tor/hidden_service/hostname - -# The backend picks up the new address automatically (30s refresh) -# Federation peers need to re-discover via sync -``` - -## 6. Create/Restore Backups - -```bash -# Create encrypted backup (via RPC) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.create","params":{"passphrase":"your-passphrase","description":"manual backup"}}' \ - http://localhost:5678/rpc/v1 - -# List backups -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.list"}' \ - http://localhost:5678/rpc/v1 - -# Verify backup integrity -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.verify","params":{"id":"{backup-id}","passphrase":"your-passphrase"}}' \ - http://localhost:5678/rpc/v1 - -# Restore (warning: overwrites current identity/data) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"backup.restore","params":{"id":"{backup-id}","passphrase":"your-passphrase"}}' \ - http://localhost:5678/rpc/v1 - -# Backup files stored at: /var/lib/archipelago/backups/ -``` - -## 7. Update the Node - -```bash -# From development machine: -./scripts/deploy-to-target.sh --live # Deploy to .228 -./scripts/deploy-to-target.sh --both # Deploy to both nodes -./scripts/deploy-to-target.sh --dry-run --live # Preview changes - -# The deploy script: -# 1. Syncs code to target -# 2. Builds frontend (vue-tsc + vite) -# 3. Builds backend (cargo build --release) -# 4. Deploys binary, frontend, configs -# 5. Restarts services -# 6. Verifies health -``` - -## 8. Diagnose High CPU - -```bash -# Check system load -uptime - -# Find CPU-heavy processes -top -b -n 1 | head -15 - -# Check container CPU usage -podman stats --no-stream --format '{{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' - -# Common causes: -# - Bitcoin IBD (initial block download): normal, takes days -# - Container crash loops: check `podman ps -a --filter status=exited` -# - mempool-electrs indexing: normal after Bitcoin sync -``` - -## 9. Diagnose High Memory - -```bash -# Check memory -free -h - -# Check swap usage -swapon --show - -# Per-container memory -podman stats --no-stream --format '{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}' - -# Check for OOM kills -dmesg --level=err,crit | grep -i oom - -# Add swap if missing -sudo fallocate -l 4G /swapfile -sudo chmod 600 /swapfile -sudo mkswap /swapfile -sudo swapon /swapfile -echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab -``` - -## 10. Diagnose Disk Space - -```bash -# Disk usage overview -df -h / - -# Find large directories -sudo du -h --max-depth=2 /var/lib/archipelago/ | sort -rh | head -20 - -# Container image sizes -podman images --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' - -# Clean unused images -podman image prune -a - -# Clean old journal logs -sudo journalctl --vacuum-size=500M -``` - -## 11. Check Tor Connectivity - -```bash -# Tor service status -sudo systemctl status tor - -# Get onion address -sudo cat /var/lib/tor/hidden_service/hostname - -# Test self-connection via Tor -curl --socks5-hostname 127.0.0.1:9050 http://$(sudo cat /var/lib/tor/hidden_service/hostname)/health - -# Test cross-node Tor -curl --socks5-hostname 127.0.0.1:9050 http://{peer-onion}/health -``` - -## 12. Check DWN Sync - -```bash -# DWN status (via RPC, needs auth) -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"dwn.status"}' \ - http://localhost:5678/rpc/v1 - -# Trigger manual sync -curl -s -X POST -H "Content-Type: application/json" \ - -H "Cookie: session={session}; csrf_token={csrf}" \ - -H "X-CSRF-Token: {csrf}" \ - -d '{"method":"dwn.sync"}' \ - http://localhost:5678/rpc/v1 - -# Check message count -ls /var/lib/archipelago/dwn/messages/ | wc -l -``` - -## 13. Restart Services - -```bash -# Restart backend only -sudo systemctl restart archipelago - -# Restart nginx -sudo systemctl restart nginx - -# Restart Tor -sudo systemctl restart tor - -# Full service restart (backend + nginx) -sudo systemctl restart archipelago nginx - -# Reboot (containers auto-recover via restart policy + health monitor) -sudo reboot -``` - -## 14. View Logs - -```bash -# Backend logs -sudo journalctl -u archipelago --no-pager -n 100 - -# Follow logs in real time -sudo journalctl -u archipelago -f - -# Nginx access log -sudo tail -f /var/log/nginx/access.log - -# Nginx error log -sudo tail -f /var/log/nginx/error.log - -# Container logs -podman logs {container-name} --tail 50 -f -``` - -## 15. Network Diagnostics - -```bash -# Check listening ports -sudo ss -tlnp - -# Check firewall rules -sudo ufw status verbose - -# Required ports: -# 22 - SSH -# 80 - HTTP (nginx) -# 443 - HTTPS (nginx) -# 5678 - Backend API (localhost only, proxied by nginx) -# 8332 - Bitcoin RPC (container network only) -# 9050 - Tor SOCKS proxy (localhost only) - -# If ports are blocked after reboot, re-add UFW rules: -sudo ufw allow ssh -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw allow from 10.88.0.0/16 # Podman container subnet -sudo ufw allow from 10.89.0.0/16 # Podman container subnet -``` - -## 16. Emergency: Node Won't Boot - -If a node responds to ping but SSH/HTTP are down: - -1. **Check UFW**: After reboot, UFW may block all ports - ```bash - # If you have console access: - sudo ufw allow ssh - sudo ufw allow 80/tcp - sudo ufw allow 443/tcp - sudo ufw reload - ``` - -2. **Check services**: SSH or nginx may not have started - ```bash - sudo systemctl start ssh - sudo systemctl start nginx - sudo systemctl start archipelago - ``` - -3. **Check disk**: If root filesystem is full, services won't start - ```bash - df -h / - sudo journalctl --vacuum-size=200M - podman image prune -a - ``` - -## 17. Run Tests - -```bash -# Production lifecycle gate — run ON the node (uses local podman/systemctl): -tests/lifecycle/run-gate.sh # see tests/lifecycle/TESTING.md -ARCHY_ITERATIONS=5 tests/lifecycle/run-gate.sh - -# Cross-node suites (federation/mesh): -tests/multinode/smoke.sh # see docs/multinode-testing-plan.md - -# E2E / post-install: -./scripts/run-e2e-tests.sh -./scripts/run-post-install-tests.sh -``` diff --git a/docs/phase4-streaming-ecash-plan.md b/docs/phase4-streaming-ecash-plan.md index 16c7c8a3..cb0905c9 100644 --- a/docs/phase4-streaming-ecash-plan.md +++ b/docs/phase4-streaming-ecash-plan.md @@ -1,6 +1,15 @@ # Phase 4+ — Paid swarm streaming & the IndeeHub "Archipelago" source -**Status:** PLAN / design (2026-06-17) · **Branch:** `agent-trust-wip` · not implemented +**Status:** PLAN / design (2026-06-17) · **Branch:** `agent-trust-wip` · +**partly implemented — "not implemented" was stale.** The paid-serving half +landed on main: `core/archipelago/src/swarm/paid.rs` says in its own header that +it is "DHT distribution plan, Phase 4 step F", with `paid_alpn.rs` and +`payment.rs` alongside it, a `streaming::` module, and the +`streaming.list-services` / `configure-service` / `toggle-service` / `pay` / +`prepare-payment` RPCs. It is doubly default-off: the swarm needs the +`iroh-swarm` cargo feature plus `config.swarm_enabled`, and serving stays free +for everyone until the operator enables the `content-download` service. Check +those gates before assuming any step below is live or dead. **Builds on:** `docs/dht-distribution-design.md` (Phases 0–3, swarm + Blossom), the Phase 3 swarm work just landed (`swarm/`, `content_hash.rs`, `trust/`). diff --git a/docs/pine-voice-release-test-plan.md b/docs/pine-voice-release-test-plan.md deleted file mode 100644 index 6b7e024a..00000000 --- a/docs/pine-voice-release-test-plan.md +++ /dev/null @@ -1,60 +0,0 @@ -# Framework PT test plan — Pine voice epic (pre-release gate) - -Target node: **framework-pt** (`100.65.115.109`, LAN 192.168.1.249). Run after -BOTH agents' work is merged, with the dev binary sideloaded and the signed -catalog (pine 1.3.0 + pine-openwakeword) published. Every ❑ must pass before -the release ritual starts. Items marked **(user)** need a human in the room. - -## A. Deploy / prerequisites -- ❑ A1 Dev binary sideloaded, `archipelago` service active, no crash-loop in journal. -- ❑ A2 nginx self-heal added `location /api/pine/status` to every server block; `nginx -t` passes; nginx reloaded. -- ❑ A3 Signed catalog with pine 1.3.0 + pine-openwakeword live at the raw URL; node refreshed it (hourly sweep or "Check for updates"). - -## B. `/api/pine/status` endpoint -- ❑ B1 Public tier through nginx (`curl http://127.0.0.1/api/pine/status`): version, uptime, bitcoin height/sync_percent/peers, mesh peers. `lightning` null, `mesh_message` absent. -- ❑ B2 Wrong bearer token → still public-only (no balances). Correct token (from `/var/lib/archipelago/secrets/pine-status-token`) → lightning balances + latest mesh message present. -- ❑ B3 Reachable from inside the HA container via `host.containers.internal:80`. -- ❑ B4 Token file is 0600, owned by the service user. - -## C. Stack / openwakeword container -- ❑ C1 Reconcile installs `pine-openwakeword` (wyoming-openwakeword 2.1.0), healthy on :10400. -- ❑ C2 Existing pine-whisper / pine-piper / pine were ADOPTED, not recreated — model data dirs untouched. -- ❑ C3 `archipelago` service restart → all four pine containers come back (crash-recovery stack spec). -- ❑ C4 UI: openwakeword listed under Services (no extra store card); Pine card shows 1.3.0. - -## D. Home Assistant seeding -- ❑ D1 configuration.yaml: legacy hand-staged block (bitcoind :18332 + plaintext RPC creds) fully replaced by the bounded token-based block. -- ❑ D2 `custom_sentences/en/archy.yaml` carries all four intents. -- ❑ D3 `.storage/core.config_entries`: wyoming entry for openwakeword (:10400) + `anthropic` entry (Claude, conversation + ai_task subentries). -- ❑ D4 Pipeline: `conversation_engine = conversation.claude_conversation`, `prefer_local_intents: true`. -- ❑ D5 automations.yaml: `archy_mesh_announce` seeded. -- ❑ D6 HA restarts clean — no setup errors for anthropic / wyoming / rest / intent_script in `podman logs homeassistant`. -- ❑ D7 Sensors report real values: archy_block_height, archy_bitcoin_sync, archy_bitcoin_peers, archy_mesh_peers, archy_lightning_balance (or clean unavailable if LND absent), archy_mesh_message. - -## E. Voice / intents (API level first, then live speaker) -- ❑ E1 Exact phrase "what's the block height" → answered by the LOCAL intent (correct height, no Anthropic API call in HA logs). -- ❑ E2 Fuzzy phrase (e.g. "how tall is the chain right now") → Claude routes to the ArchyBlockHeight tool; answer contains the real height. -- ❑ E3 "how many peers", "is the node synced", "what's my lightning balance" → correct spoken-length answers. -- ❑ E4 Off-topic question → Claude answers, 1–2 sentences, no markdown. -- ❑ E5 **(user)** Live speaker: "Hey Jarvis, what's the block height" → audible correct answer. -- ❑ E6 Mesh announce: new received mesh text (or manual `assist_satellite.announce` if no radio) → speaker announces sender + text; no announce storm on HA restart. - -## F. Pine launcher page (1.3.0) -- ❑ F1 Page on :10380→:10381 shows the live node card (version, uptime, block, sync, peers) within ~5s. -- ❑ F2 `/node-status` proxy works (pine nginx resolves host.containers.internal at startup — container must not crash-loop). -- ❑ F3 "Connect Pine to WiFi" provisioner still intact (no JS errors on load). - -## G. Cleanup / regression sweep -- ❑ G1 Both stray socat 18332 forwarders killed; sensors still work via the endpoint. -- ❑ G2 No bitcoind RPC credentials anywhere in HA config. -- ❑ G3 Pre-existing HA function intact: whisper/piper entities, PineVoice satellite pairing, other integrations. -- ❑ G4 nginx regressions: `/health`, `/bitcoin-status`, `/api/app-catalog`, `/proxy/lnd/` all still proxied post-patch. -- ❑ G5 **(user)** Mobile Home: wallet card sits directly under My Apps; desktop layout unchanged. -- ❑ G6 Other agent's changes re-verified after merge (their own checklist). - -## H. Production-readiness (release ritual gate) -- ❑ H1 `cargo test` workspace green; frontend builds; drift check `--release --strict` green. -- ❑ H2 `tests/lifecycle/run-gate.sh` re-run ON .228 (stack membership changed → lifecycle gate rule applies). -- ❑ H3 Catalog regenerated → signed (ceremony) → published via gitea-ai; verified at the raw URL. -- ❑ H4 Changelog (layman-readable) + `scripts/sync-whats-new.py` + version bump; release ritual per v1.7.110 notes (push main via gitea-ai BEFORE publish; sign manifest AFTER create-release). -- ❑ H5 No secrets in any commit; frontend tarball flat + APK policy per release notes. diff --git a/docs/qr-scanner-snappiness-handover.md b/docs/qr-scanner-snappiness-handover.md deleted file mode 100644 index f53cd4a7..00000000 --- a/docs/qr-scanner-snappiness-handover.md +++ /dev/null @@ -1,82 +0,0 @@ -# QR scanner snappiness — research + companion-dev handover - -*2026-07-29. Owner: web side = node repo (this doc's "web" items); native side = -companion app dev (Mac). Backlog origin: UNIFIED-TASK-TRACKER "optimise -companion QR scan (quicker start/decode, low-light)".* - -## Where scanning happens today - -| Path | Stack | Used when | -|---|---|---| -| Web live scan | nimiq `qr-scanner` 1.4.x over `getUserMedia`, in `WalletScanModal.vue` | HTTPS browsers / secure contexts | -| Photo fallback | `` photo → `BarcodeDetector` if present, else `qr-scanner.scanImage` multi-pass (`decodePhotoRobust`) | Plain-http (LAN) where `getUserMedia` doesn't exist | -| Native scan | `ArchipelagoQr` JS bridge → companion's native scanner (0.5.22 fixed dense invoice QRs) | Inside the companion app | - -## What makes it feel slow (ranked) - -1. **Camera cold-start** — the stream starts only after the user reaches the - scan pane; on phones `getUserMedia` + first frame is routinely 600–1500ms, - and the native path pays a similar CameraX bind + ML Kit model cold-start. -2. **Decode cadence** — web live scan was capped at 4 scans/sec (WebView - preview lagged at 10/s when decoding on the JS worker). A hand-held code - therefore waits up to 250ms *after* it's already sharp and centered. -3. **Low light / focus hunting** — no torch control anywhere; no explicit - continuous-focus request. Dense LN invoices need sharpness more than - resolution. -4. **Dense-QR decode budget** — big bolt11/catalog QRs push the JS decoder - hard; the native ML Kit path is far better at these (proven by 0.5.22). - -## Web side (node repo — can be done here) - -- ✅ DONE (2026-07-29): scan at **10/s when `BarcodeDetector` exists** (Chrome/ - Android WebView decode natively — cheap), keep 4/s only for the JS-worker - fallback. -- **Pre-warm the camera**: start `getUserMedia` the moment the modal opens - (action pane), not when the scan pane is reached — hide the preview until - needed. Saves the entire cold-start from the user's perceived timeline. -- **Torch toggle**: `qr-scanner` exposes `hasFlash()/turnFlashOn()` — add a 🔦 - button on the scan pane (it silently no-ops where unsupported). -- **Continuous focus + modest resolution**: pass constraints - `{ focusMode: 'continuous', width: { ideal: 1280 } }` — 720p-class frames - start faster AND decode faster than 1080p+, with no loss for QR density - that matters to us. -- **Don't stop/start between panes**: returning from amount → scan currently - re-inits the scanner; keep the (paused) stream alive while the modal lives. - -## Native side (companion dev handover) - -The `ArchipelagoQr` bridge overlay is the right architecture — these are -tuning items inside the native scanner activity: - -1. **Pre-warm CameraX + ML Kit**: bind the camera provider and instantiate - `BarcodeScanning.getClient(...)` when the WebView *requests* the overlay — - or even when the wallet modal opens (add a `ArchipelagoQr.prewarm()` bridge - method; the web side will call it if present). ML Kit's first-inference - model load is 100–300ms — pay it before the user aims. -2. **Restrict formats**: `BarcodeScannerOptions` with `FORMAT_QR_CODE` only — - skipping the other symbologies measurably cuts per-frame latency. -3. **Analysis resolution ≈ 1280×720** with - `ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST` — never queue stale frames; - decode the newest one only. -4. **Continuous autofocus + tap-to-focus** on the preview, and a **torch - toggle** (low-light was an explicit user complaint). -5. **`zoomRatio` nudge for small codes**: if no hit after ~2s, step zoom to - 1.5× — helps distant/small printed codes without user action. -6. **Success haptic + instant dismiss**: vibrate on decode and close the - overlay immediately; perceived speed is heavily back-loaded. -7. Optional: **ML Kit `enableAllPotentialBarcodes` off** and skip inverted - scans unless first pass fails (inverted QRs are rare; halves work). - -## Acceptance criteria - -- Cold open → first successful scan of a normal invoice QR in **< 2s** on the - companion app, **< 3s** in a mobile browser. -- Dense (700+ char) bolt11 QR decodes in **< 1.5s** once framed, both paths. -- Dim-room scan succeeds with the torch toggle without leaving the scanner. - -## Verification notes for whoever implements - -- Measure with a timestamp log: overlay-requested → camera-first-frame → - decode-success. The three deltas map 1:1 onto items above. -- Web `BarcodeDetector` presence differs per WebView/Play-Services build — - keep the JS-worker fallback path intact. diff --git a/docs/quadlet-compilation.md b/docs/quadlet-compilation.md new file mode 100644 index 00000000..a98aa486 --- /dev/null +++ b/docs/quadlet-compilation.md @@ -0,0 +1,126 @@ +# Manifest → Quadlet unit + +How an app manifest becomes a Podman [Quadlet](https://docs.podman.io/en/latest/markdown/podman-systemd.unit.5.html) +`.container` unit that systemd owns, where the unit lands, and how to inspect one. +Source of truth: `core/archipelago/src/container/quadlet.rs`. + +## Why Quadlet + +Containers used to be fire-and-forget `tokio::spawn` blocks. If the daemon +crashed mid-spawn or the kernel reaped a parent cgroup, the container vanished +from `podman ps` and only a manual `podman run` brought it back. Quadlet removes +that whole class of failure: the unit lives on disk, **systemd owns +start/restart, and archipelago is just the provisioner**. This is the path that +runs the companion UI containers today (`archy-bitcoin-ui`, `archy-lnd-ui`, +`archy-electrs-ui`), and the validated path being flipped to default for apps. + +## What gets generated + +`Quadlet::from_manifest(manifest, name)` translates a manifest into a unit, and +`render()` produces the file. Every unit carries a header making clear it is not +hand-edited: + +```ini +# Generated by archipelago. DO NOT EDIT. +# Edits are overwritten on the next reconcile. + +[Unit] +Description= +After=network-online.target +Wants=network-online.target +Requires=.service # one per declared dependency +After=.service + +[Container] +ContainerName= +Image= +Pull=never # image must be present locally already +Network= +User= # when the manifest pins one +DropCapability=ALL # security default +AddCapability= # only capabilities the manifest opts into +PublishPort=::/ +Environment== # non-secret env only +Secret=,type=env,target= # secrets by REFERENCE, never value +Volume=: +ReadOnly=true # when security.readonly_root +NoNewPrivileges=true # when security.no_new_privileges +HealthCmd= # from the health_check block + +[Service] +TimeoutStartSec=0 +Restart= # from the restart policy +RestartSec=10 # 10s backoff caps a crash loop + +[Install] +WantedBy=default.target +``` + +Two things to note in that mapping: + +- **Secrets go in by reference, never by value.** A `secret_env` entry renders as + `Secret=,type=env,target=`, so podman injects the value at run time + from the node's secret store. The plaintext never appears in the unit file. See + [App secrets](secrets.md). +- **`Pull=never` is deliberate.** The provisioner does not pull images from here; + the image must already be local (pre-pulled or built). A missing image surfaces + immediately instead of retrying silently behind systemd's restart loop. +- **`PublishPort` is dropped entirely under `Network=host`.** Podman rejects the + combination and the container crash-loops on exit 125, so declared ports are + omitted rather than rendered. With host networking the container is already on + the host's ports; a manifest that declares both is not an error, the mapping is + just silently unnecessary. + +## Where units land + +Rootless, per-user, under the archipelago service user (uid 1000, with linger +enabled so the units run without an active login): + +``` +~/.config/containers/systemd/.container +``` + +Quadlet's systemd generator translates `.container` into a +`.service` unit at **daemon-reload** time. Everything is `systemctl --user` +— the system bus is never touched from this path. + +## Lifecycle: render → write → enable → disable + +The module does four things and nothing else: + +1. **render** — manifest → unit text (above). +2. **write** — `tempfile + rename` so a partially-written unit is never visible to + systemd, and `write_if_changed` compares bytes first: if the rendered unit + matches what is on disk, nothing is touched — no daemon-reload, no restart + cascade. This is what makes a reconcile tick cheap and non-disruptive. +3. **enable** — `daemon-reload` then start the `.service`. +4. **disable** — stop and remove. + +## Inspecting a unit + +Run these **as the archipelago service user** (the units are in its user bus): + +```bash +# the generated unit +cat ~/.config/containers/systemd/archy-bitcoin-ui.container + +# what systemd made of it +systemctl --user cat archy-bitcoin-ui.service +systemctl --user status archy-bitcoin-ui.service +journalctl --user -u archy-bitcoin-ui.service + +# after editing a unit by hand for debugging (it will be overwritten on reconcile) +systemctl --user daemon-reload +``` + +Because the unit is regenerated on every reconcile, the way to change a +container's shape is to change its **manifest** (and, for a catalog-covered app, +regenerate and re-sign the catalog), never to edit the `.container` file — the +`DO NOT EDIT` header is literal. + +## Related + +- [Container lifecycle](container-lifecycle.md) — the reconciler that drives this +- [App Manifest Specification](app-manifest-spec.md) — the manifest fields mapped above +- [App secrets](secrets.md) — how `Secret=` references resolve +- [ADR-001: Podman over Docker](adr/001-podman-over-docker.md) diff --git a/docs/registry-manifest-design.md b/docs/registry-manifest-design.md index 63f019ea..f501f3c3 100644 --- a/docs/registry-manifest-design.md +++ b/docs/registry-manifest-design.md @@ -9,18 +9,30 @@ signed app-catalog on the registry — **no OS-level code reliance, no OTA-shipped disk manifest required**. Rootless, signed, robust, reboot-survivable. See also: [`docs/dht-distribution-design.md`](dht-distribution-design.md) (this is -its "discovery/authenticity" layer), `MEMORY → project_manifest_driven_north_star`. +its "discovery/authenticity" layer). --- -## 1. Where we are today +## 1. Where we started (the pre-Phase-1 baseline) -Two distinct mechanisms, only one of which is registry-distributed: +This section is the problem statement the design was written against, kept for +context. **It no longer describes the running system** — Phases 1–3 shipped, so +see "Where we are now" below. -| Thing | Source | Reaches node via | Carries | +Two distinct mechanisms, only one of which was registry-distributed: + +| Thing | Source | Reaches node via | Carried | |-------|--------|------------------|---------| -| `apps/*/manifest.yml` (48) | repo working tree | **OTA**: `self-update.sh` rsyncs `apps/ → /opt/archipelago/apps/` | full manifest (the orchestrator's real source of truth) | -| `app-catalog.json` (28) | `releases/app-catalog.json` | **registry HTTP fetch**, hourly, **signed** (`app_catalog::refresh_catalog`) | version + image override only | +| `apps/*/manifest.yml` | repo working tree | **OTA**: `self-update.sh` rsyncs `apps/ → /opt/archipelago/apps/` | full manifest (the orchestrator's real source of truth) | +| `app-catalog.json` | `releases/app-catalog.json` | **registry HTTP fetch**, hourly, **signed** (`app_catalog::refresh_catalog`) | version + image override only | + +### Where we are now + +`releases/app-catalog.json` carries 66 entries, and 56 of them embed a full +`manifest` block — one for every `apps/*/manifest.yml` in the tree. So the +"catalog carries an image override only" gap below is closed for image-only +apps; what remains is build-context apps (Phase 4) and dropping `apps/` from the +OTA rsync (Phase 5). - Orchestrator registry = in-memory `state.manifests: HashMap`, populated by `ProdContainerOrchestrator::load_manifests()` walking the disk dir. @@ -44,7 +56,8 @@ binary OTA, no disk manifest. publisher: apps/*/manifest.yml ──generate──▶ releases/app-catalog.json (embeds + signs) node: refresh_catalog() ──fetch+verify──▶ /app-catalog.json load_manifests() ──merge──▶ state.manifests (catalog wins; disk = fallback) - install(app_id) ──▶ render Quadlet unit (rootless, systemd-managed) + install(app_id) ──▶ create the rootless container (Quadlet unit when + use_quadlet_backends is on; podman create+start otherwise) ``` ## 3. Schema change (`app_catalog::AppCatalogEntry`) @@ -143,6 +156,8 @@ Add a generator (extend `create-release.sh` / a small `scripts/gen-app-catalog`) separate signed blob? Inline is simplest for Phase 1; hashing aligns with the DHT image-by-digest plan and keeps the catalog small. Lean inline now, revisit at Phase 4 when build contexts (large) need addressing anyway. -- `generated_files` with inline content (vs. source-dir) — already supported in the - manifest schema? If so, registry manifests can carry small rendered files inline, - removing another disk dependency. +- ~~`generated_files` with inline content (vs. source-dir) — already supported in + the manifest schema?~~ **Answered: yes.** `app.files[]` takes inline `content` + (with `{{HOST_IP}}` / `{{NETWORK_GATEWAY}}` / `{{secret:NAME}}` rendering), so + registry manifests already carry small rendered files inline and that disk + dependency is gone. See [`app-manifest-spec.md`](app-manifest-spec.md). diff --git a/docs/secrets.md b/docs/secrets.md new file mode 100644 index 00000000..5009fce5 --- /dev/null +++ b/docs/secrets.md @@ -0,0 +1,117 @@ +# App secrets + +How an app declares a secret, how Archipelago materialises it, and how it +reaches the container — with the rules a developer must not break. + +The whole point: **an app never ships a credential.** It declares the *shape* of +the secrets it needs, and the node generates a fresh, per-install value that +never leaves the node and is never logged. Source of truth: +`core/archipelago/src/container/secrets.rs` and the manifest schema in +`core/container/src/manifest.rs`. + +## The two halves + +A secret has a producer and a consumer, and they are separate manifest fields: + +- **`generated_secrets`** — *produce* a random value into a file. +- **`secret_env`** — *inject* a file's contents into the container as an env var. + +An app can use either alone. A generated secret with no consumer is just a file +on the node; a `secret_env` with no matching `generated_secrets` reads a file +that some other component (or the daemon) is expected to have written. + +## Declaring a generated secret + +```yaml +container: + generated_secrets: + - name: btcpay-db-password + kind: hex16 + - name: fedimint-gateway-hash + kind: bcrypt +``` + +`name` is a **bare filename** under the node's secrets directory +(`/var/lib/archipelago/secrets/`). It is validated at manifest-load time — no +`/`, no `..` — so a manifest cannot write outside that directory. + +`kind` chooses how the value is produced. Each kind is deterministic in *shape* +(the orchestrator knows exactly which files it will create) but random in value: + +| `kind` | Value | Files written | Use for | +|---------|-----------------------------------------|-----------------------------------|---------| +| `hex16` | 16 random bytes, lowercase hex (32 ch) | `` | service passwords, API tokens | +| `hex32` | 32 random bytes, lowercase hex (64 ch) | `` | longer keys/cookies | +| `base64`| 32 random bytes, standard base64 (44 ch)| `` | services that base64-decode their key (e.g. netbird relay `authSecret`) | +| `bcrypt`| a random password **and** its bcrypt hash| `` (hash) + `.pw` (plaintext) | server configured with a hash, client needs the plaintext | + +`bcrypt` is the only kind that writes two files: `` holds the bcrypt hash a +server is configured with, and `.pw` holds the plaintext for any client +that must authenticate against it. A `secret_env` injects whichever of the two it +references. + +## Injecting a secret into the container + +```yaml +container: + secret_env: + - key: BTCPAY_DB_PASS + secret_file: btcpay-db-password +``` + +At apply time the orchestrator reads `/var/lib/archipelago/secrets/` +and makes it available in the container as ``. It does **not** do this by +adding `KEY=value` to the environment — that value would show up in +`podman inspect` output and, on the Quadlet path, as a plaintext `Environment=` +line in a unit file on disk. Instead the resolved pairs are registered as podman +secrets named `archy-env--` and referenced by name, so the value +never lands in the manifest, a unit file, `podman inspect`, or a log line. + +**Interpolation taints.** A plain `environment` entry that interpolates a secret +— e.g. BTCPay's `ConnectionString=...Password=${BTCPAY_DB_PASS}` — is treated as +secret-bearing itself and travels the same protected path, rather than being +left in the clear because it was declared under `environment`. So you can build +connection strings from secrets without leaking them. + +## How materialisation works + +`ensure_generated_secrets()` runs on **every install and reconcile tick**, before +`secret_env` is resolved. It is idempotent and self-healing: + +1. **Fast path.** If every target file for a secret already exists, is readable + by the service user, and is non-empty, it is left untouched. A secret is + generated **once** and then persists across restarts, updates and reinstalls — + this is what makes credentials stable (migrations never regenerate a working + secret out from under a database). +2. **Self-heal.** A target file that exists but is unreadable or empty — e.g. + left root-owned by a botched earlier write — is removed and recreated, owned + by the service user. The unlink uses the secrets directory's own write bit, so + recovery needs no privilege escalation. +3. **Write.** New values are written through an atomic `0600` writer: a temp file + in the same directory, fsynced, then renamed over the target, so a reader never + sees a half-written secret and the file is only ever readable by its owner. + +Because it runs every tick and no-ops when the secret is healthy, calling it is +always safe; there is no separate "provision secrets" step to forget. + +## Rules a developer must not break + +- **Never hardcode a credential**, in the manifest or in code, even as a + fallback. A shared fallback password means everyone holding a copy of the repo + holds that credential. Declare `generated_secrets` instead. +- **Never log a secret.** `secret_env` values and the files under the secrets + directory stay out of logs, error messages and status output. +- **One canonical name.** The orchestrator, first-boot script, reconcile path and + any deploy tooling must all reference a secret by the *same* filename. A + producer writing `-password` while the consumer reads `-hash` yields a + service that authenticates against a credential nothing generated. +- **Pick the encoding the service expects.** `hex*` and `base64` decode to + different bytes; a service that base64-decodes its configured key must be given + a `base64` secret, or it will run with the wrong key material. + +## Related + +- [App Manifest Specification](app-manifest-spec.md) — the full manifest schema +- [ADR-009: Manifest-Level Container Security](adr/009-manifest-container-security.md) +- [Entropy Enforcement (KEY-05)](security/KEY-05-ENTROPY-ENFORCEMENT.md) — why secret + generation draws from an explicitly-named CSPRNG diff --git a/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md b/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md index f3ef0ef8..55b5cc1b 100644 --- a/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md +++ b/docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md @@ -1,7 +1,7 @@ # The Bitcoin RPC proxy that stayed open after it was fixed **Status:** code fix committed (`f6b5245b`); on-node verification recorded below. -**Found:** 2026-08-02, archi-dev-box, while verifying `a05956c4` instead of assuming it. +**Found:** 2026-08-02, a test node, while verifying `a05956c4` instead of assuming it. **Severity:** critical on any affected node — unauthenticated control of Bitcoin Core RPC through a proxy that injects the node's own credentials. @@ -25,9 +25,9 @@ cookies, no credentials: | Probe | Result | |---|---| -| `GET http://192.168.63.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** | -| `POST http://192.168.63.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** | -| `OPTIONS http://192.168.63.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *` — **OPEN** | +| `GET http://192.0.2.240:18083/lnd-connect-info` | `401`, 24 bytes, `{"error":"Unauthorized"}` — **closed** | +| `POST http://192.0.2.240:8334/bitcoin-rpc/` (`getblockcount`) | `200` — `{"result":960774,"error":null}` — **OPEN** | +| `OPTIONS http://192.0.2.240:8334/bitcoin-rpc/` | `204` with `Access-Control-Allow-Origin: *` — **OPEN** | The rendered config on disk, `/var/lib/archipelago/bitcoin-ui/nginx.conf`, was dated **2026-06-30** — the pre-fix version, with no `auth_request` and with the wildcard CORS @@ -79,7 +79,7 @@ Deliberately narrow: survived. A new regression test pins the whole chain: stale conf in, gate present out, container restarted, nothing created. -## What actually closed it on archi-dev-box — and what that does NOT prove +## What actually closed it on a test node — and what that does NOT prove Sequence, from file mtimes, container start times and the daemon journal: @@ -88,7 +88,7 @@ Sequence, from file mtimes, container start times and the daemon journal: | 18:33 | Probe: `POST /bitcoin-rpc/` → `200` with a real block height. Exposure confirmed live. | | 18:36 | A **separate rebuild of bitcoin-ui**, done outside this work, rendered the fixed conf and recreated `archy-bitcoin-ui`. `:8334` closes here. | | 19:06 | The binary carrying `f6b5245b` is installed and the daemon restarted. | -| 19:12 | Probe: `POST /bitcoin-rpc/` → `401`. `OPTIONS` now returns `Access-Control-Allow-Origin: http://192.168.63.240:8334`, not `*`. | +| 19:12 | Probe: `POST /bitcoin-rpc/` → `401`. `OPTIONS` now returns `Access-Control-Allow-Origin: http://192.0.2.240:8334`, not `*`. | So the node is closed, and the fixed template is proven to work end to end on real hardware — but **the reconcile fix itself was never exercised.** By the time it was @@ -104,7 +104,7 @@ Tracked as broken window 15 — **since closed by the controlled test below.** ## Proving the delivery path on real hardware -Run on archi-dev-box, 2026-08-02 20:00–20:03 EDT, with operator approval. The point was to +Run on a test node, 2026-08-02 20:00–20:03 EDT, with operator approval. The point was to prove the thing the incidental rebuild had made unprovable: that **reconcile itself** repairs this state, unaided. @@ -118,7 +118,7 @@ which mechanism produced it. | 2 | Probe with no cookies | `POST /bitcoin-rpc/` → **`200`**, `{"result":960790}`; `Allow-Origin: *`. **Genuinely re-exposed** | | 3 | Start the daemon (20:00:36) and touch nothing further | — | | 4 | Reconcile pass at **20:02:19** | `bitcoin_ui: nginx.conf rendered auth_hash=51f2b5af`, then `WARN prod_orchestrator: rewrote config for a user-uninstalled app whose container is still RUNNING (systemd/Quadlet keeps it alive independently of reconcile) — restarting so it picks the new config up app_id=bitcoin-ui container=archy-bitcoin-ui` | -| 5 | Probe again | `POST /bitcoin-rpc/` → **`401`**; `Allow-Origin: http://192.168.63.240:8334` | +| 5 | Probe again | `POST /bitcoin-rpc/` → **`401`**; `Allow-Origin: http://192.0.2.240:8334` | | 6 | Compare state | Conf **byte-identical** to the pre-test known-good; container healthy | Step 2 is what makes steps 4–6 mean anything: without a confirmed `200`, the later `401` @@ -134,12 +134,21 @@ The operator's call, recorded here so it is not silently re-litigated: **no LND rotation, and no Bitcoin RPC password rotation.** The reasoning was that there is no evidence of exploitation and the vulnerability is being closed rather than lived with. -`scripts/security/rotate-lnd-macaroon.sh` stays in the tree as a tool. It has been -exercised in detect mode only, and has never rotated anything on any node. Its ordering +`scripts/security/rotate-lnd-macaroon.sh` stays in the tree as a tool. Its ordering guard (refuses to rotate on a binary lacking the fix) remains the right shape for whenever rotation is wanted — including for the Bitcoin RPC password, which has no equivalent tool yet. +**Amended 2026-08-08.** This section said the script "has never rotated anything on any +node"; that is no longer true. A rotation was performed on a development node while +responding to the BTCPay Server advisory (that node had been running an affected +`btcpayserver:2.3.9`), and it exposed a gap the script did not cover: BTCPay's inline copy +of the macaroon was left stranded, so its Lightning payments failed silently while both +apps reported healthy. Rotation is now a first-class, password-confirmed dashboard action +that repairs that copy as part of the run — see +[`LND-MACAROON-ROTATION.md`](LND-MACAROON-ROTATION.md). The fleet decision recorded above +is unchanged: no fleet-wide rotation for this leak. + What this decision accepts: any macaroon or RPC password read through either hole before it was closed stays valid. That is a deliberate, informed trade, not an oversight. diff --git a/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md b/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md deleted file mode 100644 index 41b6e55b..00000000 --- a/docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md +++ /dev/null @@ -1,1006 +0,0 @@ -# Entropy & Seed-Generation Security Audit — 2026-07-31 - -**Trigger:** the Coinkite COLDCARD entropy incident, disclosed 2026-07-30 (see -`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`, -"T1"). That defect silently rebound seed generation to a non-cryptographic PRNG through a -build-time macro guard, reducing effective seed entropy to ≤2^32 and enabling a ~1,082 BTC -sweep. This audit asks the same question of Archipelago. - -**Why the stakes here are higher than a hardware wallet's.** Archipelago derives its *entire* -key hierarchy from one 24-word BIP-39 mnemonic (`core/archipelago/src/seed.rs:1-18`): the node -Ed25519 `did:key`, the node Nostr key, the FIPS mesh transport key, per-identity keys, the -BIP-84 Bitcoin wallet, the LND aezeed entropy — **and the fleet release-root signing key** -(`core/archipelago/src/seed.rs:143-146`). A Coldcard-class entropy defect here would not merely -drain wallets; it would let an attacker forge signed release manifests and catalogs for every -node in the fleet. - -**Headline verdict:** **no Coldcard-class entropy defect exists in this codebase.** Every -first-party key-generation call site draws from a genuine CSPRNG, and the code does several -things better than most implementations. The findings below are (a) one structural pattern -that is the *exact shape* of T1 and should be closed cheaply, (b) one **Critical** -access-control defect found while tracing the secret classes — unrelated to entropy but far -more immediately exploitable than anything entropy-related — and (c) a set of Medium/Low -hygiene items. - ---- - -## 1. Scope and method - -### Directories covered - -| Path | Coverage | -|---|---| -| `core/*/src/**/*.rs` | full RNG-API grep sweep + call-graph trace of every secret class | -| `neode-ui/src/**/*.{ts,vue}` | full browser-RNG grep sweep | -| `scripts/**/*.{sh,py}` | RNG / secret-material grep sweep | -| `image-recipe/**` | entropy, seed-file, machine-id, host-key and first-boot ordering evidence | -| `~/.cargo/registry/src/*/bip39-2.1.0/`, `argon2-0.5.3/` | vendored-dependency default-RNG / default-parameter reads | -| `docs/adr/005-chacha20-backup-encryption.md` | Argon2 parameter cross-check | - -### Explicitly excluded, and why - -- **`core/target/`** — build output, not source. Excluded from every grep (the pipeline used - `core/*/src`, which cannot reach it). -- **`image-recipe/_archived/` — NOT excluded, contrary to the original scoping assumption.** - This is a correction the next auditor should not have to re-derive: - `image-recipe/build-debian-iso.sh:19-40` is a thin wrapper that copies - `image-recipe/_archived/build-auto-installer-iso.sh` to a temp path, rewrites its relative - paths, and `exec`s it (`image-recipe/build-debian-iso.sh:40`). **The "archived" auto-installer - IS the live ISO build path.** Treating `_archived/` as dead code would have made [ARCHY-3] - unanswerable. It is therefore in scope and is the primary [ARCHY-3] evidence surface. -- `image-recipe/_archived/build/auto-installer/installer-iso/...` — a stale *build output* tree - under `_archived/`, superseded by the generator above. Its `/dev/urandom` hits - (`image-recipe/_archived/build/auto-installer/installer-iso/archipelago/scripts/first-boot-containers.sh:182`) - are duplicates of the live `scripts/first-boot-containers.sh` and are not separately assessed. - -### Greps run - -``` -grep -rnE 'SmallRng|seed_from_u64|::from_seed\(|rand::rngs::mock|StdRng' core/*/src --include=*.rs -grep -rnE 'OsRng|thread_rng|rand::random|getrandom|SystemRandom' core/*/src --include=*.rs -grep -rn -B3 -A3 -E 'SystemTime::now|as_nanos|Instant::now' core/*/src --include=*.rs \ - | grep -iE 'key|seed|nonce|salt|token|secret|password|mnemonic' -grep -rn -B2 -A2 -E 'Math\.random|getRandomValues|crypto\.subtle|jsbn|SecureRandom\(' \ - neode-ui/src --include=*.ts --include=*.vue -grep -rnE '\$RANDOM|/dev/urandom|/dev/random|openssl rand|uuidgen|random\.random|random\.randint|shuf ' \ - scripts/ image-recipe/ --include=*.sh --include=*.py -grep -rniE 'random-seed|urandom|jitterentropy|haveged|rng-tools|rngd|crng' image-recipe/ \ - --include=*.sh --include=*.service --include=*.conf -find image-recipe -name 'random-seed' -o -name '*.seed' -grep -rniE '(info|warn|error|debug|trace)!\(.*(mnemonic|seed|privkey|private_key|passphrase|aezeed)' \ - core/*/src --include=*.rs -grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs \ - core/archipelago/src/credentials/store.rs -cargo tree -i rand@0.8.5 -p archipelago ; cargo tree -i rand@0.9.2 -p archipelago -``` - -Results of the two negative greps, stated so they count as findings rather than silence: - -- `find image-recipe -name 'random-seed' -o -name '*.seed'` returned **nothing**. No seed file - is checked into the image recipe. -- `grep -cE 'haveged|jitterentropy|rng-tools|rngd' image-recipe/_archived/build-auto-installer-iso.sh` - returned **0**. No userspace entropy daemon is installed by the image. -- `grep -rnE 'SmallRng|seed_from_u64|rand::rngs::mock|StdRng' core/*/src` returned **no RNG - hits at all** — the four matches are `NodeIdentity::from_seed(...)` calls - (`core/archipelago/src/api/rpc/seed_rpc.rs:122`, `:255`; - `core/archipelago/src/identity.rs:608`, `:634`), which is Archipelago's own - seed-to-identity function, not `rand`'s `from_seed`. **No non-cryptographic PRNG and no - deterministic seeding exists anywhere in the Rust workspace.** - -### Not performed - -- `cargo audit` — **`cargo-audit` is not installed on this host** (`command -v cargo-audit` - fails). No RustSec snapshot was taken. This is recorded as gap **F-07**; the research's - recommendation stands that `cargo audit`/`cargo deny` belongs in CI rather than in a - point-in-time audit. -- Anything requiring real hardware — see §6, the UNVERIFIED on-node checklist. - -### Concurrent-work caveat - -`core/archipelago/src/container/secrets.rs` and `neode-ui/src/views/OnboardingSeedGenerate.vue` -had **uncommitted third-party changes** on disk at audit time (another agent working in the -same tree). They were read as-is and not modified. Line numbers cited for those two files are -against the working-tree state of 2026-07-31, not against `HEAD`. - ---- - -## 2. Executive summary - -Archipelago's entropy path is structurally sound. Every first-party call site that produces key -material draws from `rand::rngs::OsRng` (a direct `getrandom(2)` wrapper) or from -`rand::random`/`rand::thread_rng` on `rand 0.8.5`, which is `ReseedingRng` -— a real CSPRNG that still carries fork protection in the 0.8 series. There is no Mersenne -Twister, no clock-seeded key, no `SmallRng`, no `seed_from_u64`, and no `Math.random()` in any -browser key path. The master-seed function is preceded by a genuinely good, non-blocking -CSPRNG-readiness probe (`core/archipelago/src/seed.rs:52-91`) that most implementations lack, -and the derivation is domain-separated, zeroized, and pinned by known-answer tests. - -Three things nonetheless warrant action, in this order: - -1. **The most urgent finding is not about entropy at all.** While tracing secret classes (3) - and (4), the audit found that `seed.generate` and `seed.restore` are in the - **unauthenticated** RPC allowlist (`core/archipelago/src/api/rpc/middleware.rs:25-27`), carry - **no onboarding-complete gate and no rate limit**, and unconditionally overwrite a live - node's Ed25519 identity, Nostr key and FIPS mesh key - (`core/archipelago/src/identity.rs:79-114`). The endpoint is proxied to the LAN over - plaintext HTTP (`image-recipe/configs/nginx-archipelago.conf:11`, `:165`, `:192`) and is - also reachable by mesh peers (`core/archipelago/src/server.rs:2080`). A guard function for - exactly this already exists and is simply never called - (`core/archipelago/src/identity.rs:117`). **Critical — F-01.** -2. **The T1-shaped structural risk is real but currently benign.** - `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92` delegates its entropy - source to a transitive dependency default. Not a vulnerability today; exactly the pattern - that produced T1. **Medium — F-02**, and the one code change this audit applies. -3. **The one-ISO-many-nodes story is better than feared but has a fail-open hole.** No - `random-seed` file is baked, and per-device TLS/SSH regeneration exists — but the rootfs is - a cached container export shared by every node, the regeneration is fail-open, and its - completion marker is set even when regeneration failed, so a single failure leaves fleet-wide - shared SSH host keys and TLS private key permanently. **High — F-03.** - -Nothing in this audit suggests any existing Archipelago node has a weak master seed. No user -action of the "your seed may be predictable, migrate now" kind is warranted — a point §7 of -`docs/security/PSBT-SIGNING-ARCHITECTURE.md` depends on and must not overstate. - ---- - -## 3. Findings - -| ID | Severity | Title | Primary evidence | -|---|---|---|---| -| F-01 | **Critical** | Unauthenticated, unrated `seed.generate`/`seed.restore` overwrite a live node's identity keys | `core/archipelago/src/api/rpc/middleware.rs:25`, `core/archipelago/src/identity.rs:79` | -| F-02 | **Medium** | Master mnemonic's entropy source is a transitive-dependency default, not a call-site argument (T1 shape) | `core/archipelago/src/seed.rs:92` | -| F-03 | **High** | First-boot per-device secret regeneration is fail-open and never retried, over a fleet-shared cached rootfs | `image-recipe/_archived/build-auto-installer-iso.sh:1647`, `:1659`, `:1663` | -| F-04 | **Medium** | Master mnemonic crosses the RPC boundary and is held in memory for 10 min, deliberately un-cleared, over plaintext-capable HTTP | `core/archipelago/src/api/rpc/seed_rpc.rs:147`, `:205-211` | -| F-05 | **Medium** | `Argon2::default()` is 19 MiB / t=2, not ADR-005's stated 64 MB / 3 iterations | `core/archipelago/src/seed.rs:249`, `docs/adr/005-chacha20-backup-encryption.md:31` | -| F-06 | **Medium** | Release master mnemonic is passed via env var / stdout in the signing ceremony | `core/archipelago/src/ceremony.rs:71-77`, `:149-160` | -| F-07 | **Medium** | No `cargo audit`/`cargo deny` in CI; two `rand` majors coexist in the graph | `core/archipelago/Cargo.toml:68` | -| F-08 | **Low** | 24-word master mnemonic persisted in browser `sessionStorage` during onboarding | `neode-ui/src/views/OnboardingSeedGenerate.vue:330` | -| F-09 | **Low** | Modulo bias in TOTP backup-code generation | `core/archipelago/src/totp.rs:305` | -| F-10 | **Low** → **see F-10a** | Container `generated_secrets` use `thread_rng()` rather than an explicit `OsRng` (same T1 shape as F-02, smaller blast radius) | `core/archipelago/src/container/secrets.rs:92`, `:101` | -| F-10a | **Medium** | **Scope correction to F-10 (2026-08-02):** the defaulted-RNG surface is crate-wide — **41 raw matches across 15 files**, not 2 — and includes ecash key material and an AEAD nonce | `core/archipelago/src/wallet/bdhke.rs:133`,`:139` (Cashu secret + blinding factor), `storage_crypto.rs:39` (nonce), `session.rs` (4 prod), +12 more files — full table in §F-10a | -| F-11 | **Informational** | `Math.random()` inside a seed-handling view (benign — UX challenge selection only) | `neode-ui/src/views/OnboardingSeedVerify.vue:159` | -| F-12 | **Informational** | Identical default OS credentials on every flashed node | `image-recipe/archipelago-scripts/install-to-disk.sh:205` | -| F-13 | **High** | BIP-84 account **private** key is imported into Bitcoin Core's wallet, duplicating the spending key outside the encrypted envelope | `core/archipelago/src/api/rpc/bitcoin.rs:203`, `:229-231` | - ---- - -### F-01 — Unauthenticated `seed.generate` / `seed.restore` overwrite a live node's identity keys — **Critical** - -**Evidence.** -- `core/archipelago/src/api/rpc/middleware.rs:24-28` places `seed.generate`, `seed.verify`, - `seed.restore` and `seed.save-encrypted` in `UNAUTHENTICATED_METHODS`, under the comment - "Onboarding flow (before user has a session)". -- `core/archipelago/src/api/rpc/mod.rs:263-265` — membership in that list skips the entire - session check; `:295` skips RBAC; `:326` skips CSRF. -- `core/archipelago/src/api/rpc/seed_rpc.rs:93-159` (`handle_seed_generate`) and `:226-305` - (`handle_seed_restore`) contain **no** check that onboarding is already complete or that a - node key already exists. -- `core/archipelago/src/identity.rs:79-114` (`NodeIdentity::from_seed`) writes `node_key`, - `node_key.pub` and, via `write_fips_key_from_seed` (`:108`), the FIPS mesh key — - **unconditionally, with no existence check.** `seed_rpc.rs:130-131` and `:261-266` likewise - overwrite `nostr_secret` / `nostr_pubkey`. -- The guard already exists and is never called on this path: - `core/archipelago/src/identity.rs:117-119` (`NodeIdentity::key_exists`). Its only callers are - `core/archipelago/src/server.rs:63` and `core/archipelago/src/api/rpc/seed_rpc.rs:343` - (read-only status). -- No rate limit: `core/archipelago/src/rate_limit.rs:60-97` enumerates per-method limits and - contains **no `seed.*` entry**, while explicitly acknowledging at `:96` that - "Inter-node federation RPCs (unauthenticated, need stricter limits)". -- Reachability: `image-recipe/configs/nginx-archipelago.conf:11` and `:15` bind port 80 as - `default_server` (plaintext, LAN); `:165-175` proxies `/rpc/v1` and `:192-195` proxies - `/rpc/` to `127.0.0.1:5678`. The FIPS mesh peer listener applies a path filter - (`core/archipelago/src/server.rs:1375`, `:1270`) but that filter **allows** `/rpc/v1` — - asserted at `core/archipelago/src/server.rs:2080`. - -**Exploitability.** No credentials, no session, no CSRF token, no rate limit. A single -unauthenticated JSON-RPC POST from anywhere on the LAN — or from any peer that can reach the -mesh listener — is sufficient. `seed.restore` is the worse of the two because the attacker -supplies the mnemonic: they then hold the node's Ed25519 signing key, its Nostr node key and -its FIPS transport key. `seed.generate` is a pure destructive primitive: it mints a mnemonic -nobody ever sees and overwrites the node's identity with it. - -**Blast radius.** Node identity takeover or permanent identity destruction. Downstream: the -node's `did:key` changes, so every federation trust relationship keyed on that DID breaks; the -FIPS mesh key changes, so mesh peering breaks; the Nostr node key changes, so discovery -announcements are signed by a key the fleet does not recognise. This does **not** by itself -expose the user's Bitcoin funds (the on-disk `master_seed.enc` envelope is not overwritten by -these handlers) — but do not read that as reassurance: an attacker who controls the node's -identity keys controls how that node presents itself to the federation. - -**This is not an entropy defect.** It surfaced because Step B of this audit required tracing -secret classes (3) and (4) end-to-end rather than only checking where their bits come from. -It is reported here because it is the most serious thing found and suppressing it until a -"more appropriate" document would be indefensible. - -**Remediation (concrete).** In `handle_seed_generate` and `handle_seed_restore`, bail early -when `NodeIdentity::key_exists(&identity_dir)` is true *and* the in-memory onboarding mnemonic -is absent — i.e. this is a booted, already-provisioned node rather than an onboarding retry. -Prefer additionally gating on `auth_manager.is_onboarding_complete()` -(`core/archipelago/src/auth.rs:182`). Add `seed.generate` / `seed.restore` to -`rate_limit.rs`'s table at the strictness of `auth.changePassword` (3 per 300s). Consider -removing `/rpc/v1` from `is_peer_allowed_path` for seed methods specifically, or filtering by -method rather than path. Needs its own plan — see Backlog R-01. - ---- - -### F-02 — Mnemonic entropy source is a transitive-dependency default — **Medium** — [ARCHY-1], **FIXED IN THIS AUDIT** - -**Evidence.** `core/archipelago/src/seed.rs:92`: - -```rust -let mnemonic = bip39::Mnemonic::generate(24) -``` - -Resolved against the vendored crate: -`~/.cargo/registry/src/index.crates.io-.../bip39-2.1.0/src/lib.rs:311-313` → -`generate_in` at `:296-298`, whose body is -`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)` → -`generate_in_with` at `:267-283`, which is generic over `R: RngCore + CryptoRng` and fills the -entropy buffer at `:281`. - -So the entropy backend for Archipelago's whole key hierarchy — including the release-root -signing key — was selected by `bip39`'s default, not stated at Archipelago's call site. - -**Exploitability.** **None today.** `rand::thread_rng()` on `rand 0.8.5` -(`core/archipelago/Cargo.toml:68`) is `ReseedingRng`: seeded from -`getrandom(2)`, reseeded every 64 KiB, `CryptoRng`, and still fork-protected in the 0.8 series. -The mnemonic is genuinely 256-bit. This finding is about *future* exploitability, not present. - -**Blast radius (if it ever rebinds).** Total. Every key in `seed.rs:1-18`, including the fleet -release-root signing key at `:143-146`. That is strictly larger than a hardware wallet's, -because it includes the ability to forge signed release manifests. - -**Why it is worth fixing anyway.** This is the precise structural shape of T1: a call whose -entropy backend is fixed by dependency/build configuration rather than by the calling code, -with no compile error if it changes. `bip39` is pinned `=2.1.0` -(`core/archipelago/Cargo.toml:74`) which contains the exposure today, and a future `rand` bump -to 0.9+ removes fork protection (upstream changelog, 2025-01-27) without touching a line of -Archipelago source. - -**Remediation — applied.** `seed.rs` now routes generation through an internal helper that -takes `&mut (impl CryptoRng + RngCore)` and calls `bip39::Mnemonic::generate_in_with` -explicitly, with the production caller passing `OsRng`, plus a known-answer test that drives -generation from a deterministic RNG and asserts the resulting words. That test is impossible -to write against the pre-change code, because there was no seam to inject through. See §7. - ---- - -### F-03 — Fail-open, never-retried first-boot secret regeneration over a fleet-shared rootfs — **High** — part of [ARCHY-3] - -**Evidence.** -- The installed root filesystem is a **container image exported to a tar** - (`image-recipe/_archived/build-auto-installer-iso.sh:717-726`), cached across builds - (`:267`), shipped on the ISO (`:1094`) and extracted verbatim onto every target disk - (`:2303`, `tar -xf "$ROOTFS_TAR" -C /mnt/target`). Every node flashed from one ISO therefore - starts from a byte-identical filesystem. -- That rootfs installs `openssh-server` (`:345`). Debian's `openssh-server` postinst generates - host keys at install time — i.e. **inside the container build** — so SSH host keys are baked - into the shared tar. -- It also bakes a self-signed RSA-2048 TLS keypair at `:463-469` - (`openssl req -x509 -nodes -days 3650 -newkey rsa:2048 ... /etc/archipelago/ssl/archipelago.key`). -- The mitigation exists and is correct in intent: `archipelago-first-boot-secrets.service` - (`:1599-1614`) runs `first-boot-secrets.sh` (`:1616-1665`), which regenerates the TLS keypair - (`:1635-1648`) and the full SSH host-key set via `ssh-keygen -A` into a staging dir and swaps - on success (`:1651-1662`). It is installed at `:2587-2593` and enabled at `:3336`. -- **The hole:** both branches are fail-open — `:1647` "WARNING: TLS regeneration failed, - keeping baked key" and `:1659` "WARNING: ssh-keygen -A failed, keeping baked host keys" — and - `touch "$MARKER"` at `:1663` runs **unconditionally, outside both `if` blocks**. The unit's - `ConditionPathExists=!/var/lib/archipelago/.secrets-regenerated` (`:1605`) and the script's - own `[ -f "$MARKER" ] && exit 0` (`:1625`) then guarantee it **never runs again**. -- Timing: the unit declares `DefaultDependencies=no` and only `After=local-fs.target` - (`:1603-1604`), so it runs very early — precisely when a freshly-flashed headless machine has - the least accumulated entropy, and it is the first consumer of the pool. - -**Exploitability.** One transient failure at first boot (a full disk, a slow-to-seed pool -causing a timeout, an `openssl`/`ssh-keygen` hiccup) permanently leaves that node running the -**image-wide shared** SSH host key and TLS private key. An attacker who obtains one copy of the -ISO — which is a published artifact — holds the SSH host key and TLS private key of every node -that hit that failure path, enabling transparent MITM of the web UI and undetectable SSH host -impersonation. The failure is logged only to `/var/log/archipelago-first-boot-secrets.log` and -surfaces nowhere in the UI. - -**Blast radius.** Per-node, but silently and permanently, and correlated fleet-wide by ISO -build. - -**Remediation.** Move `touch "$MARKER"` inside a success branch that requires *both* -regenerations to have succeeded; on failure, leave the marker absent so the oneshot retries on -the next boot, and surface the condition (a `system.stats`/doctor field, not just a log file). -Additionally add `After=systemd-random-seed.service` — harmless today (no seed file is baked, -see [ARCHY-3]) and correct if one is ever introduced. Independently, strip the baked SSH host -keys and TLS key from the rootfs tar at build time so a regeneration failure degrades to "no -key / service refuses to start" rather than "shared key, silently". - ---- - -### F-04 — Master mnemonic crosses the RPC boundary and lingers in memory — **Medium** — [ARCHY-4] - -**Evidence.** -- `core/archipelago/src/api/rpc/seed_rpc.rs:147` builds `words: Vec` from the mnemonic - and `:156-158` returns it as the JSON-RPC result. -- Held server-side in a process-global `LazyLock>>>` - (`:13-19`) under a 10-minute TTL (`:27`). -- **Deliberately not cleared at verify time** — `:205-211` documents the reasoning (the web - client aborts at 15s and retries; clearing would make a retried verify fail). The rationale is - sound; the residual risk is real and should be named rather than assumed away. -- `save_pending_seed_encrypted` (`:42-57`) deliberately ignores the TTL, documented at `:35-39`. -- Plaintext HTTP is a supported deployment: `core/archipelago/src/api/rpc/mod.rs:227-241` - sets the session cookie's `Secure` flag **only** when `X-Forwarded-Proto: https` is present, - with the comment "On LAN HTTP, Secure flag prevents browsers from sending cookies back" — - i.e. plaintext LAN is an expected mode, corroborated by - `image-recipe/configs/nginx-archipelago.conf:11` binding `:80` as `default_server`. - -**Exploitability.** Passive: anyone with LAN traffic visibility during the ~1-2 minutes of -onboarding reads the 24 words in cleartext. This unlocks the Bitcoin wallet, the node identity, -and — if the same mnemonic is ever used as a release master seed — the fleet signing key. -Requires being on-path during onboarding, which bounds it. - -**Blast radius.** Total for that node's key hierarchy. - -**Mitigating factors (real, and worth stating).** `OnboardingMnemonicState` implements `Drop` -with `zeroize` (`:21-25`); the words are never logged; and `seed.reveal` — the *post*-onboarding -path — is properly gated (see §5). The exposure is confined to the onboarding window. - -**Remediation.** Confine seed-bearing methods to loopback or require TLS for them specifically; -shrink `MNEMONIC_TTL`; clear on a *successful, acknowledged* verify with a short grace window -rather than never. Deferred to a plan — Backlog R-04. - ---- - -### F-05 — `Argon2::default()` does not match ADR-005 — **Medium** - -**Evidence.** `docs/adr/005-chacha20-backup-encryption.md:31` specifies "Argon2id with high -memory cost (64MB) and iterations (3)". The code uses `Argon2::default()` at -`core/archipelago/src/seed.rs:249` and `:285` (the master-seed and aezeed envelope), -`core/archipelago/src/backup/identity.rs:38` and `:93`, and -`core/archipelago/src/backup/full.rs:618` and `:650`. - -From the vendored crate `argon2-0.5.3`: `impl Default for Argon2` (`src/lib.rs:176-180`) uses -`Params::default()`, whose constants are `DEFAULT_M_COST = 19 * 1024` KiB = **19 MiB** -(`src/params.rs:42`), `DEFAULT_T_COST = 2` (`:52`), `DEFAULT_P_COST = 1` (`:61`). - -**Actual: Argon2id, v0x13, m=19456 KiB, t=2, p=1. ADR-005 states: 64 MB, 3 iterations.** The -algorithm choice (Argon2id) is correct; the cost parameters are roughly 3.4× weaker in memory -and 1.5× weaker in time than the ADR claims. The defaults are the current OWASP minimum, so -this is a documentation-vs-code divergence and a modest hardening gap, not a break. - -**Exploitability.** Offline brute force of `master_seed.enc` / backup blobs by an attacker who -already has file read access, at a lower cost than the ADR promises. - -**Remediation.** Either construct `Argon2::new(Algorithm::Argon2id, Version::V0x13, -Params::new(65536, 3, 1, None)?)` in one shared helper and use it everywhere, **or** amend -ADR-005 to state the real parameters. Do **not** silently change the parameters on the -master-seed envelope without a migration path: an existing `master_seed.enc` was encrypted -under the old parameters and would fail to decrypt. That constraint is what makes this a -backlog item rather than a quick fix. - ---- - -### F-06 — Release master mnemonic passed by env var / printed to stdout — **Medium** - -**Evidence.** `core/archipelago/src/ceremony.rs:70-78` (`cmd_gen`) prints -`RELEASE_MASTER_MNEMONIC="<24 words>"` to **stdout** via `println!`. `:149-153` -(`load_release_root_key`) reads the phrase via `read_mnemonic()`, which at `:157-160` prefers -the `RELEASE_MASTER_MNEMONIC` environment variable and falls back to stdin. - -**Exploitability.** An environment variable is readable from `/proc//environ` by the same -user and lands in shell history if set inline; stdout lands in terminal scrollback, tmux -buffers, CI logs and `script`/asciinema captures. This is the seed that derives the **fleet -release-root signing key** (`core/archipelago/src/seed.rs:143-146`) — compromise means forging -signed manifests for every node. - -**Mitigating factors.** The ceremony is a deliberate, human-operated, offline procedure, the -tool prints a prominent warning at `ceremony.rs:73-75`, and the stdin path exists and is the -documented practice (project memory: "sign via user TTY"). The env-var path is a convenience -affordance, not the intended default. - -**Remediation.** Make stdin/TTY the only supported input for `sign`/`pubkey` and remove or -feature-gate the env-var branch; for `gen`, write the mnemonic to a `0600` file on explicitly -named removable media rather than stdout, or require an interactive confirmation. Low effort, -but it touches the signing ceremony — schedule it deliberately, not opportunistically. - ---- - -### F-07 — No dependency-advisory gate in CI; two `rand` majors in the graph — **Medium** - -**Evidence.** `cargo-audit` is not installed on this host, so no RustSec check was run. -`cargo tree -i rand@0.8.5 -p archipelago` and `-i rand@0.9.2 -p archipelago` show **both** -majors resolved into the same binary: - -- `rand 0.8.5` — direct (`core/archipelago/Cargo.toml:68`), plus `archipelago-security`, - `bip39 2.1.0`, `mainline 2.0.1`, `secp256k1 0.29.1`, `tungstenite 0.20.1`. -- `rand 0.9.2` — transitively via `totp-rs 5.7.0` and `tungstenite 0.26.2` (through - `tokio-tungstenite` → `async-wsocket` → `nostr-relay-pool` → `nostr-sdk 0.44.1`). - -**No Archipelago-authored key-generation call site uses `rand 0.9.x`** — the direct dependency -is pinned to `0.8.5` and every first-party `OsRng`/`thread_rng`/`rand::random` call resolves -against it. But `rand 0.9.0` removed fork protection from `ThreadRng`, and the orchestrator -forks and spawns constantly, so the day a `rand` bump lands the T1 shape in F-02 and F-10 -becomes materially worse. `getrandom` is likewise split across `0.2.17` and `0.3.4`. - -**Remediation.** Add `cargo audit` (or `cargo deny check advisories bans`) to CI, with a `bans` -rule that fails on duplicate `rand` majors so the split is visible rather than silent. Before -any `rand` 0.9+ bump, convert every key-generation site to explicit `OsRng` (F-02, F-10) — after -which the fork-protection removal is irrelevant to Archipelago. - ---- - -### F-08 — 24-word master mnemonic persisted in browser `sessionStorage` — **Low** - -**Evidence.** `neode-ui/src/views/OnboardingSeedGenerate.vue:330` writes the full word list: -`sessionStorage.setItem('_seed_words', JSON.stringify(words.value))`; it is re-read at `:297` -and at `neode-ui/src/views/OnboardingSeedVerify.vue:165`. The mnemonic itself arrives from -`seed.generate` at `OnboardingSeedGenerate.vue:256-258`. - -**Mitigating factors.** It **is** removed on successful verify -(`neode-ui/src/views/OnboardingSeedVerify.vue:251`), and its exclusion from the logout -cache-purge is a deliberate, test-pinned decision -(`neode-ui/src/stores/__tests__/resourcesClear.test.ts:213`, `:231`) — onboarding must survive a -reload. So this is a considered trade-off, not an oversight. - -**Residual risk.** A user who abandons onboarding mid-flow leaves the master mnemonic in -plaintext `sessionStorage` for the lifetime of the tab. On the node's own kiosk browser, that -tab may stay open indefinitely. Any XSS in the UI during that window reads it directly. - -**Remediation.** Clear `_seed_words` on route-leave from the onboarding flow as well as on -verify, and add a wall-clock expiry to the stored blob mirroring the server's `MNEMONIC_TTL`. - ---- - -### F-09 — Modulo bias in TOTP backup-code generation — **Low** — [ARCHY-5] - -**Evidence.** `core/archipelago/src/totp.rs:305`: - -```rust -let idx = (rand::random::() as usize) % charset.len(); -``` - -with `charset` = 32 characters (`:298`). **32 divides 256 exactly**, so in the *current* code -the bias is **zero** — the research's [ARCHY-5] framing of "classic modulo bias" is correct as a -pattern but the concrete instance is presently unbiased. The defect is latent: any future edit -to the charset (adding a symbol, removing an ambiguous letter) silently introduces bias with no -test to catch it. Reported as Low on that basis, not on present harm. - -**Remediation.** Replace with `rand::seq::SliceRandom::choose(&mut OsRng)`, which is -unbiased for any charset length, and add an assertion or test that pins the property. Left to -the backlog rather than applied here: the entropy source is already correct and the present -bias is nil, so it does not meet this plan's bar for a code change. - ---- - -### F-10 — Container `generated_secrets` use `thread_rng()` — **Low** - -**Evidence.** `core/archipelago/src/container/secrets.rs:90-93` (`random_hex`) and `:98-102` -(`random_base64`) both use `rand::thread_rng().fill_bytes(&mut buf)`. These materialise -manifest-declared `generated_secrets` for every app (Bitcoin RPC password, DB passwords, -netbird store encryption key, the Fedimint gateway credential at `:135-...`). - -**Assessment.** Cryptographically fine on `rand 0.8.5` for the same reason as F-02, and the same -T1-shaped structural objection applies with a smaller blast radius (per-app credentials rather -than the master key hierarchy). File permissions were verified rather than assumed: -`core/archipelago/src/container/secrets.rs:207` sets `.mode(0o600)` on creation, and `:269` and -`:307` are tests asserting `mode == 0o600` for the written files. **CLAUDE.md's "0600/rootless" -invariant holds and is test-enforced.** - -*(This file carried uncommitted third-party changes at audit time — line numbers are against the -2026-07-31 working tree.)* - -**Remediation.** Swap both helpers to `rand::rngs::OsRng` when F-02's pattern is generalised. -One-line change each; batched into the same backlog item. - ---- - -### F-10a — Scope correction: the defaulted-RNG surface is crate-wide — **Medium** - -> **Added 2026-08-02, after the original audit.** F-10 above reported this defect as two call -> sites in one file. That was **understated**. This section records the true scope with evidence. -> F-10's own text and remediation are left unedited above so the correction is auditable rather -> than retroactive. - -**Evidence.** `grep -rn "rand::random\|thread_rng()" core/archipelago/src --include=*.rs` returns -**43 matches across 16 files**. Two of those (`seed.rs:87`, `:671`) are comments in the -already-remediated F-02 file, leaving **41 matches across 15 files**: - -| File | Matches | Generates | -|---|---|---| -| `core/archipelago/src/session.rs` | 16 → **4 prod + 12 test** | session tokens (`#[cfg(test)]` begins `:470`) | -| `core/archipelago/src/api/rpc/package/pine_ha.rs` | 6 | app credentials | -| `core/archipelago/src/wallet/bdhke.rs` | 4 → **2 prod + 2 test** | **Cashu proof secret (`generate_secret`, `:133`) and blinding factor (`random_blinding_factor`, `:139`) — genuine key material** (`#[cfg(test)]` begins `:143`) | -| `core/archipelago/src/mesh/x3dh.rs` | 2 | ~~X3DH key agreement — key material~~ **CORRECTED: `u32` prekey *identifiers*** (`spk_id` `:100`, `otk_id` `:114`). The X25519 secrets come from `crypto::generate_x25519_ephemeral()` at `:99`/`:113` and are **not** in this table | -| `core/archipelago/src/container/secrets.rs` | 2 | `generated_secrets` (the original F-10) | -| `core/archipelago/src/api/rpc/package/install.rs` | 2 | install-time secrets | -| `core/archipelago/src/storage_crypto.rs` | 1 | **ChaCha20-Poly1305 nonce — reuse breaks the AEAD** | -| `core/archipelago/src/credentials/store.rs` | 1 | credential store material | -| `core/archipelago/src/device_tokens.rs` | 1 | device tokens | -| `core/archipelago/src/federation/invites.rs` | 1 | federation invites | -| `core/archipelago/src/bitcoin_rpc.rs` | 1 | Bitcoin RPC password | -| `core/archipelago/src/totp.rs` | 1 | TOTP backup codes (also F-09) | -| `core/archipelago/src/transport/chunking.rs` | 1 | chunk identifiers | -| `core/archipelago/src/fips/dial.rs` | 1 | dial jitter/identifiers | -| `core/archipelago/src/api/rpc/auth.rs` | 1 | auth-path material | - -**Per-site production-vs-test classification is deliberately NOT asserted here** — it is the -first task of the remediation (KEY-05 Task 1), not an assumption of this correction. The counts -above are raw matches. - -> **Self-correction, 2026-08-02 (same day, before any KEY-05 work began).** The first version of -> this table asserted semantics in its right-hand column that two entries did not support, and -> the KEY-05 planner caught it against the code. Both are fixed inline above, struck rather than -> silently rewritten: -> - **`mesh/x3dh.rs` was wrong.** Called "X3DH key agreement — key material"; the two sites are -> `u32` prekey *identifiers*. The X25519 secrets are drawn elsewhere and were never in scope. -> - **`session.rs` was misleading.** 16 raw matches read as 16 production token sites; it is 4 -> production and 12 test. -> -> **The Medium rating still holds, on narrower grounds.** It now rests on `wallet/bdhke.rs`'s two -> production sites (the Cashu proof secret and the blinding factor — a predictable blinding -> factor breaks the ecash unlinkability guarantee and is genuinely key material) and on -> `storage_crypto.rs:39`'s AEAD nonce. It does **not** rest on x3dh. Recorded because the -> correction to F-10 was made on the grounds that understatement misleads the next reader — -> overstatement does exactly the same, and this table did both within one day. - -**Assessment.** Unchanged from F-10 in kind: `rand::random()` and `thread_rng()` are backed by -ChaCha12 seeded from `getrandom(2)` on `rand 0.8.5`, so **nothing in this table is broken -today**. What changes is the *blast radius* of the T1 structural objection. F-10 rated this Low -on the basis of "per-app credentials rather than the master key hierarchy". That justification -does not survive the true scope: `wallet/bdhke.rs:133`/`:139` generate the Cashu proof secret and -blinding factor — genuine key material, where a predictable blinding factor breaks ecash -unlinkability — and `storage_crypto.rs:39` draws an AEAD nonce, where a silent rebinding to a -non-cryptographic PRNG would be catastrophic rather than merely undesirable. Re-rated **Medium** -on those two grounds. (An earlier version of this sentence also cited `mesh/x3dh.rs`; that was -incorrect and is struck in the table above — those sites are prekey identifiers.) - -**Why the original audit missed it.** F-10 was reached by tracing the *manifest secrets* path -(secret class 4). No step enumerated defaulted-RNG use across the whole crate independently of -the traced paths — so files outside those traces were never in scope to be looked at. Recorded -here because the same blind spot would recur in the next audit run under the same method. - -**Remediation → tracked as KEY-05 in Phase 10** (`.planning/ROADMAP.md`), which supersedes R-13: -a sealed allowlist trait so only approved RNGs can be passed at key-generation seams; a -`clippy.toml` `disallowed-methods` ban on `rand::thread_rng` / `rand::random` crate-wide so the -default cannot be inherited by *new* code either; `cargo-deny` failing on duplicate `rand` -majors (R-05, the mechanism by which a bump could silently rebind); a degenerate-entropy runtime -check before key generation; and persisting the CSPRNG-readiness verdict (R-09) that -`seed.rs:59` already computes but discards. - ---- - -### F-11 — `Math.random()` inside a seed-handling view — **Informational (benign)** - -**Evidence.** `neode-ui/src/views/OnboardingSeedVerify.vue:157-163`, `pickRandomIndices` uses -`Math.floor(Math.random() * max)` to choose which of the 24 words the user is quizzed on. - -**Assessment: benign, and annotated here so the next auditor does not re-derive it.** The -indices select a UX challenge only. They are not key material, not a nonce, not a salt, and not -a secret: an attacker who predicts perfectly which words will be quizzed learns nothing — the -words themselves are what they would need, and those are already on the user's screen. The -verification is a *user*-facing "did you write it down" check, not an authentication boundary -(the server compares against its own held copy at -`core/archipelago/src/api/rpc/seed_rpc.rs:190-194`). - -Other `Math.random()` sites, all confirmed non-security: -`neode-ui/src/api/rpc-client.ts:183`, `:206`, `:215` (retry jitter); -`neode-ui/src/views/Login.vue:317` (progress bar); -`neode-ui/src/components/BootScreen.vue:112`, `:123` (starfield animation). - -**No remediation required.** Optionally add a one-line comment at the call site so this stays -annotated in the code rather than only in this document. - ---- - -### F-12 — Identical default OS credentials on every flashed node — **Informational** - -**Evidence.** `image-recipe/archipelago-scripts/install-to-disk.sh:205` sets -`archipelago:archipelago` via `chpasswd`, and `:367-371` prints the credentials with a -"Please change the password after first login!" warning. - -**Assessment.** Not an entropy defect and a known, documented alpha-stage default. Recorded here -only because it belongs to the same one-image-many-nodes correlation theme as [ARCHY-3]: it is -the one identity artefact that is *deliberately* identical across the fleet, and unlike the SSH -host key and TLS key (F-03) there is no first-boot regeneration for it. Out of scope to fix; -in scope to name. - ---- - -### F-13 — BIP-84 account **private** key is imported into Bitcoin Core — **High** - -**Evidence.** `core/archipelago/src/api/rpc/bitcoin.rs:161-294` -(`handle_bitcoin_init_wallet_from_seed`): - -- `:203` passes `disable_private_keys = false` to `createwallet`. -- `:188-189` derives the BIP-84 account **xprv** (`crate::seed::derive_bitcoin_xprv`, - `core/archipelago/src/seed.rs:207-224`) and stringifies it. -- `:229-231` builds `wpkh({xprv}/0/*)` and `wpkh({xprv}/1/*)`. -- `:278-281` imports those descriptors via `importdescriptors`. - -**Assessment.** The node's Bitcoin spending key is therefore persisted **twice**: once in the -daemon's Argon2 + ChaCha20-Poly1305 envelope (`core/archipelago/src/seed.rs:238-269`, `0600` via -`:318-324`), and once in Bitcoin Core's `wallet.dat`, which has neither the Argon2 passphrase -protection nor the same ownership story — it lives in the Bitcoin Core container's data volume. -The wallet is created with an **empty** encryption passphrase (`bitcoin.rs:205`), so Core's own -wallet encryption is not engaged either. - -**Not an entropy defect**, and reported here because Step B required tracing secret class (1), -the user Bitcoin/LND wallet seed, from generation to consumer — and this is where that trace -ends up. - -**Credit where due:** the in-memory handling of the xprv string is careful — it is zeroized on -both the error path (`bitcoin.rs:222`) and the success path (`:284`) — and the wallet is a -*descriptor* wallet (`:207`), which is the correct foundation. The defect is which key goes -into it. - -**Secondary defect, same lines.** The descriptors at `:230-231` carry **no key-origin -annotation** (`[fingerprint/derivation]`). Without it, no hardware signer can locate its key in -a PSBT — so the current wallet could not be converted to an external-signer setup even if the -private key were removed. - -**Exploitability.** Requires read access to the Bitcoin Core data volume. That is a lower bar -than the encrypted envelope: a container escape, a backup of the Bitcoin volume, or a -misconfigured bind mount exposes it, whereas `master_seed.enc` additionally requires the user's -password. - -**Blast radius.** The node's entire on-chain Bitcoin balance at `m/84'/0'/0'`. It does **not** -extend to the other key classes — the release-root key, node identity and FIPS keys are HKDF -siblings, not children of the BIP-84 branch, so an attacker with the account xprv cannot climb -back to the master seed. - -**Remediation.** Pass `disable_private_keys = true`; import the account **xpub** with a -key-origin annotation instead of the xprv; sign via the daemon (or an external signer) rather -than via Core. This is Phase 1 of `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8, including -the migration that verifies balance/UTXO parity before removing the private-key-bearing wallet. - ---- - -## 4. [ARCHY-1] … [ARCHY-4] adjudication - -### [ARCHY-1] — **CONFIRMED** - -The research's claim that `bip39::Mnemonic::generate(24)` at `core/archipelago/src/seed.rs:92` -resolves its entropy source through a transitive default is **exactly right**, and the citation -is accurate: `bip39-2.1.0/src/lib.rs:296-298` is `generate_in`, whose body is -`Mnemonic::generate_in_with(&mut rand::thread_rng(), language, word_count)`. The full chain is -`generate` (`:311-313`) → `generate_in` (`:296-298`) → `generate_in_with` (`:267-283`). - -**The entropy source is chosen by the dependency, not at the call site.** The injectable seam -exists and is public — `generate_in_with` — so closing this costs -almost nothing. It is **not** a vulnerability today (`rand 0.8.5`'s `thread_rng` is a -fork-protected ChaCha12 CSPRNG seeded from `getrandom(2)`), but it is the structural shape of -T1. **Fixed in this audit — see §7 and F-02.** - -### [ARCHY-2] — **CONFIRMED (as a positive finding)** - -`kernel_csprng_ready()` at `core/archipelago/src/seed.rs:58-75` calls -`libc::getrandom(..., libc::GRND_NONBLOCK)` (`:62-67`), maps a 1-byte success to `Some(true)` -(`:68-69`), `EAGAIN` to `Some(false)` (`:70-71`), and anything else to `None` (`:73`). The -single byte it draws is **discarded** — `byte` is never read again. It is used only by -`MasterSeed::generate` at `:85-91` to emit `info!` or `warn!`. - -**No key material is drawn from the non-blocking path.** The actual mnemonic entropy comes from -`bip39::Mnemonic::generate(24)` at `:92`, i.e. `getrandom(2)` **without** `GRND_NONBLOCK`, which -blocks until the pool is initialised. The doc comment at `:52-57` states this reasoning -correctly. The research's assessment — "exactly right and better than most implementations" — -holds. The two hardening notes it raised also hold and are carried to the backlog: the -invariant depends on the `getrandom` crate using the blocking syscall (worth a test, not just a -comment), and the `warn!` should be persisted as a structured, durable event so a node can -answer post-hoc "was the pool ready when this seed was born?" — the question Coldcard owners -cannot answer today. - -### [ARCHY-3] — **PARTIALLY CONFIRMED; the tree answers three of four sub-questions, the fourth is UNVERIFIED** - -First, a scoping correction the research could not have known: `image-recipe/_archived/` is -**not** dead. `image-recipe/build-debian-iso.sh:19-40` execs -`image-recipe/_archived/build-auto-installer-iso.sh`. That file is the ISO builder. - -| Sub-question | Verdict | Evidence | -|---|---|---| -| Does the build bake a populated seed file into the image? | **NO** | `find image-recipe -name 'random-seed' -o -name '*.seed'` → empty. The rootfs is a container export (`build-auto-installer-iso.sh:717-726`); `systemd-random-seed.service` never runs inside a container build, so `/var/lib/systemd/random-seed` is never created. The installer extracts that tar (`:2303`) and adds no seed file. | -| Is there a first-boot regeneration unit? | **YES, for TLS + SSH host keys — but it is fail-open and never retried** | `archipelago-first-boot-secrets.service` at `:1599-1614`, script at `:1616-1665`, installed `:2587-2593`, enabled `:3336`. Hole documented as **F-03** (`:1647`, `:1659`, `:1663`). It does **not** touch `/etc/machine-id` or any random-seed file. | -| Does the image install `jitterentropy-rngd` / `haveged` / `rng-tools`? | **NO** | `grep -cE 'haveged\|jitterentropy\|rng-tools\|rngd' image-recipe/_archived/build-auto-installer-iso.sh` → `0`. The rootfs package list at `:330-352` and following contains no entropy daemon. Kernel ≥5.6's in-kernel jitter source is therefore the only supplemental source on headless hardware. | -| Can onboarding key generation run before the kernel CSPRNG is initialised? | **NO — it can be *delayed* by it, but never weakened** | `bip39` fills entropy via `rand`'s `OsRng`/`ThreadRng` seeding, i.e. blocking `getrandom(2)`. `core/archipelago/src/seed.rs:52-57` documents exactly this and the probe at `:85-91` makes the ordering visible in the logs. The failure mode is a hang, not a weak key — the correct trade. | - -**What remains genuinely UNVERIFIED.** Whether `/etc/machine-id` is empty (regenerated per node) -or populated (shared) in the exported rootfs tar; whether SSH host keys are in fact present in -that tar as the `openssh-server` install at `:345` implies; the real `crng init done` timestamp -relative to seed generation on freshly-flashed hardware; and whether N nodes flashed from one -ISO actually produce N distinct seeds. **None of these is answerable from this environment.** -They are the on-node checklist in §6 and must not be reported as verified. - -**Net assessment.** The most-feared version of [ARCHY-3] — a baked, credited `random-seed` -giving every node a correlated pool — **does not exist**. The real exposure is narrower and -different from what the research predicted: fleet-shared SSH host keys and a fleet-shared TLS -private key in the cached rootfs, protected by a regeneration step that fails open and never -retries (F-03). - -### [ARCHY-4] — **CONFIRMED, and worse than described** - -Every specific claim checks out: - -- The mnemonic is returned to the web client as `words: Vec` — - `core/archipelago/src/api/rpc/seed_rpc.rs:147`, returned at `:156-158`. (The research cited - "~line 147"; exact.) -- 10-minute in-memory TTL — `MNEMONIC_TTL` at `:27`, state struct at `:16-19`. -- Deliberately **not** cleared at verify time, with a documented rationale — `:205-211`. - (Research cited `:205-209`; the comment block runs `:205-211`.) -- Plaintext HTTP is a live mode — `core/archipelago/src/api/rpc/mod.rs:227-241` conditions the - cookie `Secure` flag on `X-Forwarded-Proto: https` and comments explicitly on "LAN HTTP"; - `image-recipe/configs/nginx-archipelago.conf:11`, `:15` bind `:80` as `default_server` and - `:165-195` proxy `/rpc/v1` and `/rpc/` to the daemon. - -**Worse than described:** the research treated this as a confidentiality exposure. It is also an -**integrity and availability** exposure, because the same four seed methods are in -`UNAUTHENTICATED_METHODS` (`core/archipelago/src/api/rpc/middleware.rs:24-28`) with no -onboarding gate and no rate limit, and the handlers overwrite live identity keys -unconditionally. That is **F-01**, severity Critical. - -### [ARCHY-5] — **CONFIRMED as a pattern, REFUTED as a present defect** - -The line is exactly as cited (`core/archipelago/src/totp.rs:305`) but the charset at `:298` is -32 characters, and 32 divides 256 exactly, so the current distribution is **uniform — there is -no bias today**. The research's characterisation ("classic modulo bias whenever -`charset.len()` does not divide 256") is technically precise; its implied conclusion that this -instance is biased is not. Recorded as **F-09**, Low, on latent-defect grounds only. Stated -plainly rather than quietly dropped, per this audit's honesty rule. - -### Open question 9 (Argon2 parameters) — **DIVERGENCE CONFIRMED** - -`Argon2::default()` = Argon2id, v0x13, **m=19456 KiB (19 MiB), t=2, p=1** -(`argon2-0.5.3/src/params.rs:42`, `:52`, `:61`; `src/lib.rs:176-180`). -`docs/adr/005-chacha20-backup-encryption.md:31` states **64 MB and 3 iterations**. The code does -not match the ADR. Full detail and the migration constraint are in **F-05**. - -### Also noted from the research, confirmed benign - -`core/archipelago/src/storage_crypto.rs:39` and `core/archipelago/src/credentials/store.rs:69` -draw 96-bit ChaCha20-Poly1305 nonces via `rand::random()`. CSPRNG-backed; fine. The -random-nonce birthday bound (~2^32 messages per key) is not approached by either use. Same for -`core/archipelago/src/mesh/crypto.rs:70` (explicit `OsRng`, with a correct explanatory comment -at `:64`), `core/archipelago/src/fips/dial.rs:75` (a 16-bit dial ID, not a secret), and -`core/archipelago/src/wallet/bdhke.rs:133`, `:139`. - ---- - -## 5. What we do right - -Credit where the code is correct — each with evidence, so a future refactor that removes any of -these is visibly a regression. - -1. **The CSPRNG-readiness probe.** `core/archipelago/src/seed.rs:52-91`. Uses `GRND_NONBLOCK` - *as a probe only*, discards the byte, and logs the pool state immediately before generating - the master seed. The doc comment reasons correctly about why blocking `getrandom(2)` makes a - weak seed impossible. This is better than most wallet implementations and is precisely the - audit trail Coldcard owners now wish they had. -2. **Zeroization is real, not decorative.** `MasterSeed` is `#[derive(Zeroize, ZeroizeOnDrop)]` - (`core/archipelago/src/seed.rs:47-50`); the Argon2-derived key is explicitly zeroized on both - the encrypt and decrypt paths (`:262`, `:292`); the aezeed plaintext join is zeroized after - use (`:384`, `:401`); the in-memory onboarding mnemonic zeroizes on `Drop` - (`core/archipelago/src/api/rpc/seed_rpc.rs:21-25`); the reveal path zeroizes the password on - every exit (`:396`, `:430`, `:441`, `:465`). -3. **No `#[derive(Debug)]` on any secret-bearing type.** - `grep -rn 'derive(Debug' core/archipelago/src/seed.rs core/archipelago/src/identity.rs - core/archipelago/src/credentials/store.rs` returns **nothing** — the classic accidental-log - escape is closed by construction. -4. **No secret is logged.** The secret-logging grep across `core/*/src` returned only - non-secret status lines. The most sensitive one, - `core/archipelago/src/seed.rs:86` ("kernel CSPRNG initialized; generating master seed"), - contains no material. `core/archipelago/src/identity.rs:103-106` logs only the first 16 hex - chars of a **public** key. The file-level invariant at `core/archipelago/src/seed.rs:18` - ("Never log mnemonic or seed material at any level") is actually honoured. -5. **Encrypted-at-rest envelope with per-blob salt and nonce from `OsRng`.** - `core/archipelago/src/seed.rs:243-246`, AEAD at `:253-260`, and every identity blob written - `0600` via a single shared helper (`:318-324`). One implementation, not five. -6. **24-word enforcement on restore.** `core/archipelago/src/seed.rs:111-114` rejects any word - count other than 24, so a 12-word (128-bit) mnemonic cannot be smuggled into a hierarchy that - assumes 256 bits. -7. **Domain-separated derivation, pinned by known-answer tests.** Distinct HKDF info strings - per key class (`core/archipelago/src/seed.rs:37-41`), with KATs that pin the exact bytes: - `:764-779` (node key, cross-checked against `scripts/verify-seed-derivation.py`) and - `:800-816` (release-root private *and* public key). A derivation change cannot land silently. -8. **An existing non-determinism regression guard.** `core/archipelago/src/seed.rs:597-622` - generates 64 mnemonics and asserts both uniqueness and word-distribution spread, with a - comment naming exactly the failure it guards against. This is a genuinely good instinct that - predates the Coldcard incident — it would have caught a Yasmarang-class collapse. -9. **`seed.reveal` is properly gated.** `core/archipelago/src/api/rpc/seed_rpc.rs:360-369`: - authenticated session required (it is deliberately *not* in the unauthenticated allowlist), - password re-verification, replay-protected TOTP when 2FA is on, and separate backup-passphrase - decryption. The contrast with F-01's ungated `seed.generate`/`seed.restore` is what makes - F-01 look like an oversight rather than a design position. -10. **Correct browser RNG at the call sites that matter.** - `neode-ui/src/views/OnboardingVerify.vue:105-109` uses `crypto.getRandomValues` for the - 32-byte signing challenge; `neode-ui/src/views/web5/Web5.vue:183-185` does the same, and - guards on `crypto.subtle` being absent — which is exactly right, because `subtle` is - undefined in an insecure context while `getRandomValues` keeps working over plain HTTP. -11. **Container secret file modes are test-enforced, not assumed.** - `core/archipelago/src/container/secrets.rs:207` sets `0o600`; `:269` and `:307` are tests - asserting it. CLAUDE.md's invariant is mechanically defended. -12. **The release-root key is derived, not stored, and nodes hold only the public half.** - `core/archipelago/src/seed.rs:133-146` documents the publisher-only derivation; - `core/archipelago/src/trust/anchor.rs:34` pins the public key. Fleet nodes never hold the - signing key. -13. **The FIPS mesh peer listener is path-filtered.** `core/archipelago/src/server.rs:1375`, - `:1270`. The mechanism is right even though its current allowlist is too permissive for - seed methods (F-01). - ---- - -## 6. On-node verification checklist — **UNVERIFIED** - -**Every item below is UNVERIFIED.** None was executed. Real hardware — a freshly-flashed node, -`.228`, or the dev-box — is not reachable from the environment this audit ran in. Do not treat -any of these as checked until an operator has run them and recorded the output. - -**Run on a *freshly flashed* node, before completing onboarding, unless noted.** - -### C-1 — Was the kernel CSPRNG ready when keys were generated? ([ARCHY-3]) - -```bash -journalctl -b | grep -iE 'crng init|random: ' -journalctl -b -u archipelago | grep -i 'kernel CSPRNG' -cat /proc/sys/kernel/random/entropy_avail -systemd-analyze blame | grep -iE 'random|archipelago-first-boot-secrets' -``` -**Pass:** `crng init done` timestamp strictly precedes the -`kernel CSPRNG initialized; generating master seed` line from -`core/archipelago/src/seed.rs:86`. A `not yet initialized` warn line from `:87-89` is the -signal to escalate. - -### C-2 — Is a seed file present, and is `machine-id` unique? ([ARCHY-3]) - -```bash -ls -l /var/lib/systemd/random-seed /var/lib/urandom/random-seed 2>&1 -cat /etc/machine-id -``` -**Pass:** either no seed file at first boot, or one created *after* first boot with a -current mtime. `machine-id` must differ between two nodes flashed from the same ISO — run on -both and compare. - -### C-3 — Are SSH host keys and the TLS key per-node? (**F-03**, the highest-value check here) - -On two nodes flashed from the same ISO: -```bash -for f in /etc/ssh/ssh_host_*_key.pub; do echo "$f: $(ssh-keygen -lf "$f")"; done -openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256 -cat /var/lib/archipelago/.secrets-regenerated 2>&1; ls -l /var/lib/archipelago/.secrets-regenerated -grep -i warning /var/log/archipelago-first-boot-secrets.log -``` -**Fail:** any fingerprint matching between the two nodes, or any `WARNING:` line in the log -alongside an existing `.secrets-regenerated` marker (that combination is exactly the fail-open -path at `image-recipe/_archived/build-auto-installer-iso.sh:1647`/`:1659`/`:1663`). - -### C-4 — Does the shipped rootfs tar contain identity artefacts? (**F-03**, run on the *build host*) - -```bash -tar -tvf /archipelago-rootfs.tar | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago.key' -``` -**Expected:** SSH host keys and the TLS key **present** (they are baked — see -`build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id` absent -or zero-length. Anything else changes F-03's severity. - -### C-5 — Cross-node same-ISO seed collision test (the empirical proof that would have caught T1) - -Flash N ≥ 3 nodes from one ISO. On each, without user interaction: -```bash -curl -s -X POST http://127.0.0.1:5678/rpc/v1 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.generate","params":null}' \ - | sha256sum -``` -**Pass:** N distinct digests. **Handle the output as key material** — these are real mnemonics; -compare digests only, never the words, and re-provision every node used for this test. -Do **not** run this against a node in real use — per F-01 it overwrites the node's identity. - -### C-6 — Is the RPC endpoint reachable unauthenticated from the LAN? (**F-01**) - -From a *different* machine on the same LAN, against a **disposable** node: -```bash -curl -s -o /dev/null -w '%{http_code}\n' -X POST http:///rpc/v1 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.status","params":null}' -``` -**Fail:** `200`. Use `seed.status` (read-only), **never** `seed.generate`/`seed.restore`, -to probe this. Repeat over the Tor onion address and over the FIPS mesh ULA to establish the -full exposure surface. - -### C-7 — Is the daemon's memory swappable? - -```bash -systemctl cat archipelago.service | grep -E 'MemoryDenyWriteExecute|LimitMEMLOCK' -swapon --show -``` -Informational: the onboarding mnemonic lives in process memory for up to 10 minutes (F-04) and -`image-recipe/archipelago-scripts/install-to-disk.sh:226-236` creates a 2-8 GB swapfile on -every install. - ---- - -## 7. `ARCHY-1` remediation status — **APPLIED** - -The injectable-RNG-seam refactor described in F-02 was applied to -`core/archipelago/src/seed.rs`. Scope, precisely: - -- A new private helper `generate_mnemonic_with(rng: &mut R)` - calls `bip39::Mnemonic::generate_in_with(rng, Language::English, 24)` — the **injectable** - bip39 entry point — instead of the defaulting `Mnemonic::generate(24)`. -- `MasterSeed::generate()` passes `&mut rand::rngs::OsRng` explicitly. -- A doc comment at the helper pins the rationale to this audit and to T1, so a future - `rand`/`bip39` bump cannot rebind the entropy source without someone reading why it matters. -- Two tests added to the existing module. - -**Nothing else changed.** The derivation paths, the 24-word count, the empty-BIP-39-passphrase -decision, the at-rest encryption envelope, and every existing test are untouched. - -**Tests.** `cd core && CARGO_INCREMENTAL=0 cargo test -p archipelago seed::` → -**25 passed, 0 failed.** - -- `mnemonic_generation_uses_injected_rng` — drives generation from a deterministic - `CryptoRng + RngCore` test RNG and asserts (a) the result equals - `bip39::Mnemonic::from_entropy()`, which is the direct proof - that the **injected** RNG — not bip39's transitive `rand::thread_rng()` default — is the one - actually consumed; (b) a known-answer word list; (c) determinism across two identical RNG - states. **This test is impossible to write against the pre-change code**, because - `Mnemonic::generate(24)` exposes no seam through which the RNG can be observed or substituted. -- `mnemonic_generation_is_256_bit` — the production `OsRng` path yields 24 words and two - successive productions differ. - -**The residual risk this does not close.** Making the source explicit does not make the *fix* -retroactive: mnemonics generated before this change came from `rand::thread_rng()`. That was -and remains a genuine CSPRNG (F-02, "Exploitability: none today"), so no existing seed is -weakened — but the guarantee for those seeds rests on `rand 0.8.5`'s behaviour, not on this -call site. Reviewers should read this as *removing a future failure mode*, not as repairing a -past one. - -Everything else in this document is queued in §8, not implemented. - ---- - -## 8. Remediation Backlog - -Prioritised by severity × effort, most valuable per unit of work first. **Only R-00 was -implemented by this audit.** Everything else is queued here and mirrored into -`docs/UNIFIED-TASK-TRACKER.md` so it is not stranded in a document nobody re-reads. - -**Effort scale:** S = under an hour; M = half a day; L = a day or more; **PHASE** = needs its -own `/gsd-plan-phase`, not an opportunistic edit. - -| # | Closes | Change | Files | Effort | Hardware? | -|---|---|---|---|---|---| -| **R-00** | F-02 / [ARCHY-1] | **DONE in this audit.** Route mnemonic generation through an injectable-RNG helper; production passes `OsRng`; known-answer test proves the injected RNG is consumed | `core/archipelago/src/seed.rs` | S | no | -| **R-01** | **F-01 (Critical)** | Gate `seed.generate` / `seed.restore` on onboarding being incomplete; add rate limits; narrow the mesh peer path filter | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../middleware.rs`, `.../rate_limit.rs`, `server.rs` | M | yes (re-onboard + federation re-verify) | -| **R-02** | F-03 (High) | Move `touch "$MARKER"` inside a both-succeeded branch so a failed regeneration retries next boot; surface the failure beyond a log file | `image-recipe/_archived/build-auto-installer-iso.sh` | S | **yes** (ISO rebuild + fresh flash) | -| **R-03** | F-03 (High) | Strip baked SSH host keys and the TLS keypair from the rootfs tar at build time, so a regeneration failure degrades to "no key" not "shared key" | `image-recipe/_archived/build-auto-installer-iso.sh` | M | **yes** | -| **R-04** | **F-13 (High)** | `disable_private_keys=true`; import the **xpub** with a `[fingerprint/derivation]` key origin; migrate with balance/UTXO parity verification | `core/archipelago/src/api/rpc/bitcoin.rs` | **PHASE** | **yes** (node with real UTXO history) | -| **R-05** | F-07 (Medium) | Add `cargo audit` / `cargo deny check advisories bans` to CI, with a `bans` rule failing on duplicate `rand` majors | CI config | S | no | -| **R-06** | F-05 (Medium) | Reconcile `Argon2::default()` (19 MiB / t=2) with ADR-005 (64 MB / 3) — either raise the params behind a versioned envelope with a migration, or amend the ADR | `core/archipelago/src/seed.rs`, `backup/full.rs`, `backup/identity.rs`, `docs/adr/005-...` | M | no | -| **R-07** | F-04 (Medium) | Confine seed-bearing RPCs to loopback/TLS; shrink `MNEMONIC_TTL`; clear on acknowledged verify with a short grace window | `core/archipelago/src/api/rpc/seed_rpc.rs`, `.../mod.rs`, nginx config | **PHASE** | yes | -| **R-08** | F-06 (Medium) | Make stdin/TTY the only mnemonic input for `ceremony sign`/`pubkey`; stop printing the mnemonic to stdout in `ceremony gen` | `core/archipelago/src/ceremony.rs` | S | no (but schedule deliberately — it is the signing ceremony) | -| **R-09** | [ARCHY-2] hardening | Persist the CSPRNG-readiness verdict as a durable structured event, so any node can answer post-hoc "was the pool ready when this seed was born?" | `core/archipelago/src/seed.rs` | S | no | -| **R-10** | [ARCHY-2] hardening | Add a test asserting the `getrandom` crate uses the **blocking** syscall, so the invariant is mechanical rather than a comment | `core/archipelago/src/seed.rs` | S | no | -| **R-11** | F-08 (Low) | Clear `_seed_words` on route-leave from onboarding, not only on successful verify; add a wall-clock expiry mirroring `MNEMONIC_TTL` | `neode-ui/src/views/OnboardingSeedGenerate.vue`, `OnboardingSeedVerify.vue` | S | no | -| **R-12** | F-09 (Low) | Replace `% charset.len()` with `SliceRandom::choose(&mut OsRng)` and pin the uniformity property with a test | `core/archipelago/src/totp.rs` | S | no | -| **R-13** | F-10 (Low) | ~~Swap `random_hex` / `random_base64` from `thread_rng()` to explicit `OsRng`~~ — **SUPERSEDED 2026-08-02 by R-16**; this file is 2 of 41 sites | `core/archipelago/src/container/secrets.rs` | S | no | -| **R-16** | **F-10a (Medium)** | Crate-wide enforcement so a defaulted RNG cannot be inherited anywhere: sealed allowlist trait at key-generation seams; `clippy.toml` `disallowed-methods` ban on `rand::thread_rng`/`rand::random` (compile-time, CI-enforced); `cargo-deny` on duplicate `rand` majors; degenerate-entropy runtime check; persist the CSPRNG-readiness verdict (absorbs R-05, R-09, R-13) | 15 files — see §F-10a | **PHASE** — tracked as **KEY-05**, Phase 10 | no | -| **R-14** | F-11 (Informational) | One-line comment at `pickRandomIndices` recording that the `Math.random()` is a UX challenge selector, not key material | `neode-ui/src/views/OnboardingSeedVerify.vue` | S | no | -| **R-15** | §6 checklist | Run the on-node verification checklist — especially C-3 (per-node SSH/TLS keys) and C-5 (cross-node collision test) | — | M | **yes** (2+ nodes from one ISO) | - -### Explicitly NOT implemented in this task, and why - -- **R-01, R-04, R-07** need their own phase. R-01 changes an authentication boundary on a live - fleet; R-04 moves the spending key out of a wallet holding real funds; R-07 changes the - onboarding transport. Each needs a migration story and real-node verification that an - audit-and-spec task cannot provide. -- **R-02, R-03** require rebuilding the ISO and flashing at least two machines to verify. Not - reachable from this environment. -- **R-13** is blocked purely by tree hygiene: `core/archipelago/src/container/secrets.rs` had - another agent's uncommitted changes at audit time and this plan's invariant is that no commit - it authors touches their files. Trivial once that work lands. -- **The whole of `docs/security/PSBT-SIGNING-ARCHITECTURE.md`** is a rollout, not a fix. It is - queued as a spec for `/gsd-plan-phase`, not implemented anywhere. - ---- - -## 9. Related documents - -- `docs/security/PSBT-SIGNING-ARCHITECTURE.md` — the signing architecture this audit's - conclusions feed into (watch-only descriptors, PSBT, multisig, honest LND limits). -- `docs/hardware-signer-design.md` — exploratory TROPIC01 air-gapped signer. -- `docs/adr/005-chacha20-backup-encryption.md` — the ADR that F-05 diverges from. -- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md` - — the incident analysis, the T1-T7 catalogue, and the audit checklist this document executed. diff --git a/docs/security/KEY-01-ON-NODE-VERIFICATION.md b/docs/security/KEY-01-ON-NODE-VERIFICATION.md deleted file mode 100644 index ae5109c9..00000000 --- a/docs/security/KEY-01-ON-NODE-VERIFICATION.md +++ /dev/null @@ -1,224 +0,0 @@ -# KEY-01 on-node verification — audit item C-6 and the F-01 refusal proof - -**Status: INCOMPLETE — C-6 is NOT yet verified.** -**Opened:** 2026-08-02 · **Phase:** 10 (key-material hardening) · **Plan:** 10-02 -**Probe:** `scripts/security/rpc-exposure-probe.sh` - -This document records on-node evidence for -`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §6 item **C-6** ("Is the RPC endpoint -reachable unauthenticated from the LAN?") and for the KEY-01 / F-01 refusal shipped by -plan 10-01 (`core/archipelago/src/api/rpc/onboarding_gate.rs`, commit `879de59e`). - -Nothing below is recorded unless it was actually executed and its output observed. Rows -marked **NOT MEASURED** are open work, not assumptions. Per threat T-10-13 this document -records node **labels** and status codes only — never raw LAN addresses, onion addresses -or mesh ULAs, because this repository is being prepared for open-sourcing. - ---- - -## Probe-method correction - -**The audit's own C-6 command cannot detect the condition it claims to test. Do not -re-derive this; it has now been checked against the code twice.** - -`ENTROPY-SEED-AUDIT-2026-07-31.md:890-901` probes with `seed.status` and declares -`200` a failure. But `seed.status` is **not** in `UNAUTHENTICATED_METHODS` -(`core/archipelago/src/api/rpc/middleware.rs:5-38`, which lists `seed.generate`, -`seed.verify`, `seed.restore` and `seed.save-encrypted` — not `seed.status`). An -unauthenticated `seed.status` is therefore rejected at -`core/archipelago/src/api/rpc/mod.rs:293` with a **401 by design**. The audit's "Fail: -200" criterion can never fire, so the probe would report the unauthenticated surface as -closed while F-01's actual door stands open. - -`scripts/security/rpc-exposure-probe.sh` measures the two facts separately: - -| Signal | Method | Why | Reading | -|---|---|---|---| -| **Exposure** | `auth.isOnboardingComplete` | genuinely unauthenticated (`middleware.rs:9`), read-only, no side effects | `200` = the unauthenticated RPC surface is reachable from this vantage point. This is the honest C-6 result. | -| **Session enforcement** | `seed.status` | deliberately *not* allowlisted | `401` = the session check is working. Anything else is a worse finding than C-6 and halts the phase. | - -The probe reports a reachable unauthenticated surface as `EXPOSED`, not `FAIL`: on the LAN -this is the current expected posture, and the purpose of C-6 is to **measure** the surface, -not to assert it is already closed. - ---- - -## C-6 — unauthenticated RPC reachability - -### Result table - -| Transport | Label | `health` | `auth.isOnboardingComplete` (exposure) | `seed.status` (enforcement) | Status | -|---|---|---|---|---|---| -| Loopback | `loopback` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 | -| Node's own LAN address, probed *from the node itself* | `self-lan-ip` | 200 | **200 — EXPOSED** | **401 — PASS** | measured 2026-08-02 | -| LAN, from a second machine | `lan` | — | — | — | **NOT MEASURED** | -| Tor onion | `tor` | — | — | — | **NOT MEASURED** | -| FIPS mesh ULA, from a peer node | `mesh` | — | — | — | **NOT MEASURED** | - -**`seed.status` returned `401` on every vantage point actually tested.** No -stop-the-plan condition was observed. - -### Why the two measured rows are NOT a C-6 result - -Both runs originated **on the node under test**. Packets to the node's own addresses are -delivered by the local stack and never traverse the LAN, so neither run exercises the -external path an attacker would use, and neither run passes through any host or upstream -filtering that applies only to foreign packets. They are recorded because they establish -two real facts — the probe works against a live daemon, and session enforcement is intact -— but C-6 asks specifically whether a **different machine** can reach the surface, and -that question is still open. - -### Verbatim probe output (measured rows) - -``` -$ bash scripts/security/rpc-exposure-probe.sh --target 127.0.0.1 --scheme http --port 80 --label loopback -RPC exposure probe — label=loopback endpoint=http://127.0.0.1:80 - audit item C-6 · KEY-01 (F-01) · read-only mode - -[loopback] health 200 REACHABLE endpoint answers from this vantage point -[loopback] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result) -[loopback] seed.status 401 PASS session enforcement active for non-allowlisted methods -[loopback] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200 -exit=0 -``` - -``` -$ bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label self-lan-ip -RPC exposure probe — label=self-lan-ip endpoint=http://:80 - audit item C-6 · KEY-01 (F-01) · read-only mode - -[self-lan-ip] health 200 REACHABLE endpoint answers from this vantage point -[self-lan-ip] auth.isOnboardingComplete 200 EXPOSED unauthenticated RPC surface IS reachable from here (C-6 result) -[self-lan-ip] seed.status 401 PASS session enforcement active for non-allowlisted methods -[self-lan-ip] auth.isOnboardingComplete (/rpc/) 404 NOT-EXPOSED alternate proxy path did not answer 200 -exit=0 -``` - -### Corroborating host state (observed, but NOT a substitute for the LAN measurement) - -Recorded because it predicts the LAN result and tells the operator what to expect: - -- nginx listens on **`0.0.0.0:80` and `[::]:80`** (`ss -ltn`), i.e. on every interface, - not on loopback only. The daemon itself is bound loopback-only on `127.0.0.1:5678`, so - all external reachability is via nginx. -- The host packet filter does **not** block port 80: `iptables -S INPUT` is - `-P INPUT ACCEPT` with a single jump into Tailscale's chain, and the `nft` ruleset - contains only Tailscale's `ts-input`/`ts-forward` chains — no rule matching tcp/80. - -Together these make an `EXPOSED` LAN result very likely. **That is a prediction, not a -measurement, and C-6 stays open until a second machine produces the status code.** - -### Incidental finding — `/rpc/` is not a second door - -`auth.isOnboardingComplete` on nginx's `location /rpc/` block -(`image-recipe/configs/nginx-archipelago.conf:192`) returned **404** from both vantage -points. The block proxies the full URI to the backend, which only routes `/rpc/v1`, so -the unauthenticated surface is reachable through exactly one path. This narrows F-01's -exposure surface by one path and should be re-checked if the nginx config changes. - ---- - -## KEY-01 refusal check — NOT PERFORMED - -**Requirement:** on a node running 10-01's gate, an unauthenticated `seed.restore` -carrying attacker-supplied words is refused, and `identity/node_key` and -`identity/nostr_secret` are byte-identical afterwards. - -**Blocker — no node in the fleet is running 10-01's gate yet.** Verified on the dev-box -rather than assumed: - -``` -$ ls -l /usr/local/bin/archipelago --rwxr-xr-x 1 root root 53437536 Aug 2 06:37 /usr/local/bin/archipelago -$ git log -1 --format='%H %ci' 879de59e -879de59eccb489d590c8e0fca6ae79098df68200 2026-08-02 13:05:35 -0400 -$ grep -qa "Not supported: this node is already provisioned" /usr/local/bin/archipelago \ - && echo PRESENT || echo ABSENT -ABSENT -``` - -The installed binary was built at 06:37; 10-01 landed at 13:05 the same day, and the -gate's refusal string is absent from the running binary. A `--destructive` run against -this node would therefore **not** be refused — it would replace the node's identity. The -dev-box is a live dev-pair deploy target in real use, so the run was not made. - -**This check is blocked on deployment, which the phase brief explicitly excludes from -this plan.** It cannot be closed by any amount of work inside the repository. - ---- - -## Fresh-node onboarding non-regression — NOT PERFORMED - -**Requirement:** a genuinely un-onboarded instance completes the whole wizard with 10-01's -gate in place (the anti-brick proof for correctness trap 1 and the D-03a signal -correction), then refuses `seed.restore` immediately afterwards. - -**Blocker — no un-onboarded instance exists.** The intended harness is shape (A) of -`.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` -(a second daemon under its own `ARCHIPELAGO_DATA_DIR`/`ARCHIPELAGO_BIND`/ -`ARCHIPELAGO_PORT_OFFSET`), and that todo is still **pending** — the harness has not been -built. It would additionally need a binary built from `879de59e` or later, which the -running daemon is not. - -Note for whoever builds it: that todo records that several constants ignore -`ARCHIPELAGO_DATA_DIR` and point at `/var/lib/archipelago` literally -(`bitcoin_rpc.rs:10`, `container/lnd.rs:131`, `electrs_status.rs:15`, -`api/rpc/package/pine_ha.rs:34-36`, `bootstrap.rs:242`, `disk_monitor.rs:41`), so a shape-A -instance must not install Bitcoin, LND, electrumx or Pine/HA — it would read and write the -live node's files. The onboarding walkthrough this check needs does not install apps, so -the hazard is avoidable, not blocking. - ---- - -## Pre-OTA fleet check carried over from 10-01 - -10-01's summary records a state that its gate makes unrecoverable: a node with -`onboarding.json = {"complete": true}` but **no** `user.json` can no longer call -`auth.setup`, and the recovery path needs a session it cannot create. Recovery is one SSH -command (`rm /var/lib/archipelago/onboarding.json`), but the fleet must be checked -**before** the OTA ships (D-10). - -| Node label | `user.json` | `onboarding.json` | Verdict | -|---|---|---|---| -| dev-box | PRESENT | `{"complete": true}` | **safe** — provisioned normally; the gate refuses re-keying, which is the intent | -| rest of fleet | — | — | **NOT CHECKED** | - -Command to run per node: - -```bash -ls -l /var/lib/archipelago/user.json /var/lib/archipelago/onboarding.json 2>&1 -cat /var/lib/archipelago/onboarding.json 2>/dev/null -``` - -A node is at risk only if `onboarding.json` says `complete: true` **and** `user.json` is -absent. - ---- - -## What is still required to close C-6 and KEY-01 - -Every item below needs an operator with fleet access; none can be done from the repository. - -1. **LAN exposure.** From a second machine on the node's LAN: - `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label lan` -2. **Tor exposure.** `torsocks bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label tor` -3. **Mesh exposure.** From a peer node over the FIPS mesh ULA: - `bash scripts/security/rpc-exposure-probe.sh --target --scheme http --port 80 --label mesh` - (the peer listener allows `/rpc/v1` — `core/archipelago/src/server.rs:1270-1296` — so a - `200` confirms the mesh half of F-01's reachability claim). An unreachable transport is - recorded as `UNREACHABLE` with its error, never omitted. -4. **Deploy 10-01 to a disposable node**, then, from a second machine: - `bash scripts/security/rpc-exposure-probe.sh --target --destructive --label refusal` - with `sudo sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret` - captured on the node immediately before and after. The response must carry the - `Not supported:` prefix and the two digests must match character for character. -5. **Build shape (A)** and walk the wizard end to end on a 10-01 binary - (intro → options → path → seed → seed-verify → did → identity → backup → verify → done, - then set the password), reloading once on the seed screen to confirm the same 24 words - return. No `Not supported:` and no `Rate limit exceeded` may appear at any point. Then - re-run step 4 against that same instance to confirm the door closed behind onboarding. -6. **Check the remaining fleet** for the `onboarding.json`-without-`user.json` state above. - -Until items 1–3 are done, audit item **C-6 remains UNVERIFIED**. Until item 4 is done, the -KEY-01 refusal is proven only by 10-01's unit tests against temp directories, never against -a running daemon over HTTP. diff --git a/docs/security/KEY-02-FLEET-ROTATION.md b/docs/security/KEY-02-FLEET-ROTATION.md deleted file mode 100644 index 1b2c515e..00000000 --- a/docs/security/KEY-02-FLEET-ROTATION.md +++ /dev/null @@ -1,244 +0,0 @@ -# KEY-02 — fleet host-secret detection and rotation (F-03, deployed half) - -Phase 10 plan 10-04. Companion to `docs/security/KEY-02-ROOTFS-EVIDENCE.md`, which covers the -build half (10-03). - -10-03 stopped the exposure growing: the ISO no longer bakes SSH host keys or a TLS keypair into -the shared rootfs, and first-boot regeneration now fails closed instead of setting its completion -marker on a failed run. That does **nothing** for nodes already in the field, which is exactly -where the exposure sits — a node that hit the old fail-open path is running the SSH host key and -TLS private key that every downloader of that ISO also holds, and it will never try again. - -This document records the two human decisions that govern the deployed half. - ---- - -## D-06 rotation trigger - -**Chosen option: `detect-report-then-apply`** — recorded 2026-08-02. - -Verbatim option id as written in `10-04-PLAN.md`: **`detect-report-then-apply`** -("Detect and report on boot; rotate only when an operator runs the script with an explicit apply -flag"). - -### Why - -Rotating an SSH host key is one-way. Every `known_hosts` entry for that node breaks, on every -machine that has ever connected to it, and the old private key is destroyed by the swap. The -fleet is reached over Tailscale for day-to-day work and several nodes are remote — `.228` is at -a remote site and is in real use (CLAUDE.md). `auto-on-boot` would fire that rotation on many -nodes simultaneously during an OTA rollout, with no advance notice and no operator holding the -new fingerprints. A node whose only access path is SSH and whose tooling pins the host key -becomes unreachable until someone clears the entry; a rotation that fails partway on a remote -node needs physical console access to recover, which for `.228` means a site visit. - -Against that, the cost of `detect-report-then-apply` is that exposure persists on any node whose -operator does not act. That cost is bounded by making the verdict **visible**: detection runs at -boot on every node and the verdict reaches `system.stats`, so an exposed node shows up in the -dashboard without shell access. The exposure becomes measured rather than assumed, and the list -of nodes still to rotate is a fact on a screen rather than a guess. - -This also matches the project's standing policy that changes are verified on the dev pair -(archi-dev-box + x250-dev) before they reach the fleet (CLAUDE.md, `feedback_dev_pair_before_ota`). -A rotation that fires unattended on first boot after an OTA cannot be dev-paired — by the time it -has been observed on the dev pair it has already run everywhere. - -### What this decision binds - -- `scripts/security/host-secrets-audit.sh` defaults to `--detect`, which is read-only. -- `--apply` **without** `--yes` prints its plan and exits 0 having touched nothing, so a mistyped - invocation is inert. -- `image-recipe/configs/archipelago-host-secrets-audit.service` ships in **detect-only** mode. - It contains no apply path. Making the boot unit rotate would require editing the unit, which is - a deliberate act, not a default. -- `--apply --yes` refuses to do anything unless the detect pass returned `shared`. A node whose - verdict is `per-node` cannot have its keys rotated by this script even by explicit command — - the guard against "operator runs it on the wrong node" is structural, not procedural. - -### Consequence recorded honestly - -Any node whose verdict comes back `shared` and which is never revisited stays exposed -indefinitely. The mitigation is the visibility, not the automation. The list under -"Nodes with a `shared` verdict, deliberately not rotated" below exists so that no such node is -quietly forgotten, and it is part of this plan's acceptance criteria that the list is kept. - ---- - -## How a node decides - -Four on-disk signals, evaluated in this precedence order by -`scripts/security/host-secrets-audit.sh --detect`. Every verdict carries the evidence strings -that produced it, and each evidence string names the file it was read from. - -| # | Signal | Source | -|---|---|---| -| 1 | mtime of each host key / the TLS key against the first-boot anchor | `/var/lib/archipelago/.secrets-regenerated`, falling back to `/root/.luks-archipelago.key` then `/etc/machine-id` | -| 2 | The fail-open fingerprint: marker present **and** a `WARNING:` line in the first-boot log | `/var/log/archipelago-first-boot-secrets.log` | -| 3 | 10-03's durable failure record | `/var/lib/archipelago/first-boot-secrets.failed` | -| 4 | Rootfs provenance | `/opt/archipelago/rootfs-identity-stripped` | - -Verdicts: `per-node`, `shared`, `fail-closed-missing`, `unknown`. - -**`per-node` is never reported on the strength of an absent signal.** With no anchor at all the -verdict is `unknown`, and while a durable failure record stands the verdict is `unknown` rather -than `per-node` — the node's own generator most recently reported failure, so a clean-looking -mtime is not evidence of success. - -Signal 4 changes the meaning of missing material rather than adding to the shared/per-node -question: on a node flashed from a 10-03-or-later ISO the rootfs shipped identity-free, so an -absent host key is a **fail-closed** state (generation never succeeded), not a shared one. - ---- - -## C-3 — per-node host key and TLS uniqueness - -Audit checklist item C-3 (`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` §855), described -there as "the highest-value check here". - -### Status: **FAILED — with finding.** Recorded 2026-08-02. - -> **This section names live fleet nodes that are still running shared key material. -> Review it before this repository is made public** (`docs/OPEN-SOURCE-READINESS-PLAN.md`). -> Digests below are truncated; the fingerprints of public keys are public data — every SSH -> handshake offers them — but there is no reason to make a target list convenient. - -**Three distinct live fleet nodes share all three of their SSH host keys. Two of those three -also share their TLS certificate, and therefore their TLS private key.** This is not a -theoretical exposure: it is F-03 in production, today. - -#### Method - -Gathered **remotely and read-only** — no node was logged into, nothing was written to any node, -nothing was rotated. Host keys came from `ssh-keyscan`, which is what every SSH client does -before it decides whether to trust a host, and certificates from an anonymous TLS handshake: - -```bash -ssh-keyscan -T 6 | ssh-keygen -lf - -openssl s_client -connect :443 /dev/null \ - | openssl x509 -noout -fingerprint -sha256 -subject -``` - -This is a deliberately weaker instrument than the checklist's on-node commands, and it was chosen -because it needs no access and can therefore cover the whole reachable fleet rather than two -nodes. What it can prove is exactly the FAIL condition: *any fingerprint appearing on two nodes*. - -#### Result - -| Node label | SSH host keys (ECDSA/ED25519/RSA, truncated) | TLS cert sha256 (truncated) | Cert CN | -|---|---|---|---| -| `archipelago-1` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` | -| `archy-x250-beta` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `62:F6:A6:02…` | `archipelago` | -| `archipelago` | `8WJplzKW…` / `lQgRXZ1n…` / `ym+gMOio…` | `7C:6B:CD:98…` | `austin-sapien` | -| `archipelago-5` | `/bmgd6jS…` / `SpaNfLLf…` / `hhVFABi3…` | `95:FE:EB:C7…` | `archipelago.local` | -| `archi-dev-box` | `8hFU7QGM…` / `GAxNAcgX…` / `Tv7AfaVp…` | (no :443 listener) | — | -| `archy-dev-pa` | `JtD/RM0a…` / `XD2A5OVL…` / `esIBpbWk…` | not probed | — | -| `framework-pt` | `oicpsj3Y…` / `zxA1/kRU…` / `oxi+tMli…` | `88:85:CE:CC…` | `framework-pt` | -| `shorty-s` (`.228`) | `YVsgrv8M…` / `D/5n851i…` / `YMFLUerk…` | `4D:98:D4:9B…` | `shorty-s` | - -Unreachable at scan time, so **UNVERIFIED**: `archy-x250-dev`, `archy-x250-pa`, `archy-x250-r2`, -`quantumterminal`. - -#### That the three are genuinely different machines, not one host seen three times - -The obvious alternative explanation for identical host keys is a single machine registered on the -tailnet more than once. Ruled out: - -- All three answered a live TCP connection on port 22 within the same minute. One `tailscaled` - instance serves one tailnet identity, so three simultaneously-live addresses are three hosts. -- `tailscale ping` resolves them to **different physical endpoints**: `archy-x250-beta` answers - from `178.38.147.13` (and over the Frankfurt DERP), while `archipelago-1` and `archipelago` - answer from `45.20.199.86` on different source ports — a different continent for the first, - and two distinct machines behind one NAT for the other two. -- They are owned by different tailnet accounts. - -#### Why `archipelago` has a different TLS cert but the same SSH keys - -Its cert CN is `austin-sapien`, not the image default `archipelago`. That is the signature of a -node that was **renamed** through `server.set-name`, which re-mints the TLS cert via -`regenerate_tls_cert()` so the SAN matches the new hostname — and touches nothing else. - -This is worth stating plainly because it is a trap: **TLS uniqueness alone is not evidence that -a node's key material is per-node.** Any renamed node gets a unique certificate for free while -its SSH host keys stay exactly as the image shipped them. Had C-3 been checked on TLS -fingerprints only, `archipelago` would have looked clean. The SSH host key is the reliable -signal, and this is why the audit script treats the two classes separately and reports which one -is shared rather than issuing a single node-level verdict. - -#### What this does NOT establish — UNVERIFIED - -| Claim | Status | Evidence still needed | -|---|---|---| -| The three nodes were flashed from the **same ISO** | UNVERIFIED | Not required for the FAIL — shared host keys are the exposure however they got there — but the ISO build id would tell us how many other downloads carry the same keys. Needs on-node `/opt/archipelago/` provenance. | -| The audit script's verdict on those three nodes | UNVERIFIED | `sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect` on each. Requires the OTA carrying this plan's runtime payload to land, or the script to be hand-staged. Predicted `shared`; predicted is not observed. | -| A rotation preserves the operator's own session | UNVERIFIED **on hardware** | Checkpoint steps 4–6: run `--apply --yes` on one disposable node from a session you are willing to lose, confirm that session survives, confirm a second connection shows the expected mismatch. The harness proves the script's ordering and its abort path; it cannot prove that `systemctl reload ssh` keeps a real forked session alive. | -| `host_secrets` reaches `system.stats` on a real node | UNVERIFIED | Needs a build carrying this plan deployed to the dev pair, then a `system.stats` call. Proven in unit tests against the file contract only. | -| The four unreachable nodes | UNVERIFIED | Re-run the scan when they come back online. | - -#### Consequence - -`archipelago-1`, `archy-x250-beta` and `archipelago` are a **confirmed live F-03 instance**. -Anyone holding a copy of the ISO these nodes were flashed from holds their SSH host private keys, -and for the first two, their TLS private key as well — enough for undetectable SSH host -impersonation and transparent MITM of the web UI. - -None of them was rotated as part of this verification, and that is deliberate: this checkpoint -verifies, it does not remediate, and remediating a node inside a verification task is how a -verification task takes a node offline. They are recorded below. - ---- - -## Nodes with a `shared` verdict, deliberately not rotated - -Any node that reports `shared` and is not rotated in the same session MUST be added here with the -date and the reason, so that the standing consequence of `detect-report-then-apply` is a visible -list rather than an assumption. - -| Node label | Date detected | Why not rotated | Next step | -|---|---|---|---| -| `archipelago-1` | 2026-08-02 | Detected by remote fingerprint comparison during C-3, not by an operator running the script. In real use; rotating it inside a verification task is exactly what the task forbids. | Stage the script, run `--detect`, then rotate from a session the operator is willing to lose. | -| `archy-x250-beta` | 2026-08-02 | Same. Also shares its **TLS private key** with `archipelago-1`, so it is the more urgent of the two. Reached over a DERP relay from another continent — the least recoverable node in the set if a rotation goes wrong. | Rotate from physical or console access if available; otherwise rotate TLS first, confirm, then SSH. | -| `archipelago` | 2026-08-02 | Same. TLS is already unique (the node was renamed, which re-mints the cert); only its SSH host keys are shared. | `--apply --yes` will rotate SSH only — the detect pass flags the classes separately, so this node's already-unique TLS pair is left alone. | - -**Nobody has been told their `known_hosts` is about to break.** Three nodes here are in real use; -the rotation is one-way and every existing entry for them dies with it. Sequencing that is an -operator decision, which is the whole content of D-06. - ---- - -## Operator runbook — rotating one node - -Run this from a session you are willing to lose, on **one node at a time**. Never on `.228` or -any node in real use without arranging access recovery first. - -```bash -# 1. Detect. Read-only; safe on any node, including production. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --detect -cat /var/lib/archipelago/host-secrets-audit.json - -# 2. Dry run. Prints the plan, touches nothing, exits 0. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply - -# 3. Rotate. Only proceeds if the verdict is `shared`. -sudo /opt/archipelago/scripts/security/host-secrets-audit.sh --apply --yes - -# 4. WITHOUT closing that session, prove it survived: -echo still-here - -# 5. From a second terminal, expect a host-key mismatch warning. That is the -# correct outcome. Update known_hosts against the fingerprints printed by -# step 3 (also in /var/lib/archipelago/host-key-rotation.json), never by -# blindly accepting whatever is offered. -ssh-keygen -R -ssh - -# 6. The web UI will present a new self-signed cert. A fresh browser trust -# prompt is expected and is the correct outcome. -``` - -The script reloads sshd rather than restarting it. A reload re-execs the listener while -already-forked session children keep running, which is why the operator's own SSH session -survives its own rotation. `restart` would kill it, and on a remote node with no console that is -unrecoverable. - -Old fingerprints are written to `/var/lib/archipelago/host-key-rotation.json` **before** the -swap, so an operator who loses access anyway can still identify what changed. diff --git a/docs/security/KEY-02-ROOTFS-EVIDENCE.md b/docs/security/KEY-02-ROOTFS-EVIDENCE.md deleted file mode 100644 index 7e0b06f1..00000000 --- a/docs/security/KEY-02-ROOTFS-EVIDENCE.md +++ /dev/null @@ -1,208 +0,0 @@ -# KEY-02 — build-host evidence for the rootfs identity strip - -**Audit item:** C-4 of `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` (§868), which -belongs to finding **F-03** (fail-open, never-retried first-boot secret regeneration over -a fleet-shared rootfs). - -**Status: ⛔ UNVERIFIED — awaiting a run on a real ISO build host.** - -The code change is committed and unit-tested; the tar listing that proves its effect on a -real build has not been produced yet, because it requires a build host with podman/docker -and enough disk for a full rootfs rebuild. Do not read anything below the "Result" heading -as a passing check until it is filled in. - -| Field | Value | -|---|---| -| Builder commit (Task 1) | `21043096` — fail-closed first-boot regeneration | -| Builder commit (Task 2) | `408b328c` — rootfs identity strip | -| Builder commit (follow-up) | single-producer unification, build-time generator assertion, self-heal timer | -| Builder file | `image-recipe/_archived/build-auto-installer-iso.sh` (LIVE; `image-recipe/build-debian-iso.sh` execs it) | -| Build host | _to be recorded_ | -| Date run | _to be recorded_ | -| RECIPE_HASH observed | _to be recorded — read it from the stamp file, see the caveat below_ | - ---- - -## The expectation is deliberately INVERTED relative to the audit - -This is the single most important thing to understand when comparing this document with the -audit, and the reason it is stated before the commands rather than after. - -The audit's C-4 entry says: - -> **Expected:** SSH host keys and the TLS key **present** (they are baked — see -> `build-auto-installer-iso.sh:345`, `:463-469`), `random-seed` **absent**, `machine-id` -> absent or zero-length. Anything else changes F-03's severity. - -That expectation described the **broken** state the audit found, and recording it was how the -audit measured the size of F-03. Phase 10 plan 10-03 Task 2 removed that material. So: - -**After this change, the audit's stated expectation is the FAILURE condition.** If SSH host -keys or the TLS private key still appear in the tar, the strip layer did not run — most -likely because a cached `archipelago-rootfs.tar` was reused. That is not a regression in the -check; it is the check working. - -The two negative findings the audit recorded are unchanged and must still hold: -`var/lib/systemd/random-seed` absent, `etc/machine-id` absent or zero-length. - ---- - -## Commands to run - -Run all of these **on the build host**, from the repo root, on a checkout that contains -commits `21043096` and `408b328c`. - -### 1. Force a full rebuild - -The strip layer lives inside the `RECIPE_HASH` region (between the `# STEP 1: Build complete -root filesystem` and `# STEP 2: Build minimal installer` markers), so the hash changes and the -cached tar is invalidated automatically. `--rebuild` is passed anyway so that a stale tar -cannot mask the result for any reason: - -```bash -UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild -``` - -`UNBUNDLED=1` is mandatory per `CLAUDE.md` and project memory — the default env silently -builds the wrong full-bundle variant. - -### 2. List the identity artefacts in the shipped tar - -`WORK_DIR` is `image-recipe/build/auto-installer`, so: - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \ - | grep -E 'etc/ssh/ssh_host|etc/machine-id|var/lib/systemd/random-seed|archipelago/ssl/archipelago' -``` - -### 3. Expected result after this plan - -- **no** `etc/ssh/ssh_host_*` entries at all -- **no** `etc/archipelago/ssl/archipelago.key` and **no** `archipelago.crt` - (the `etc/archipelago/ssl/` **directory** must still be present — the first-boot staging - swap needs somewhere to land) -- **no** `var/lib/systemd/random-seed` -- `etc/machine-id` present with size **0**, or absent. Either satisfies "not shared"; record - which one was actually observed rather than generalising. - -Note on the TLS keypair specifically: it is now absent for two independent reasons, not one. -The Dockerfile no longer generates it at all (that layer was removed so there is a single -producer), *and* the strip layer still deletes it as belt-and-braces in case a future layer -starts baking one. Seeing it present therefore means both defences were bypassed. - -### 4. Confirm the provenance file rode along - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar | grep rootfs-identity-stripped -``` - -Expected: one entry, `opt/archipelago/rootfs-identity-stripped`. Its absence means the strip -layer did not execute and the whole check is void. - -### 5. Confirm the regeneration path and its self-heal timer are still shipped - -This is the brick check, and it is not optional. A stripped rootfs whose first-boot -generation script failed to ship would leave every flashed node with no SSH host key and -nothing to create one. The timer is part of the same check: without it, a node whose -generators fail every in-boot retry has no unattended way back. - -```bash -ls -l image-recipe/build/auto-installer/installer-iso/archipelago/scripts/first-boot-secrets.sh \ - image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.service \ - image-recipe/build/auto-installer/installer-iso/archipelago/scripts/archipelago-first-boot-secrets.timer -``` - -Expected: all three present, `first-boot-secrets.sh` executable. - -### 5b. Confirm the build-time generator assertion actually ran - -The rootfs build fails outright if `openssl` or `ssh-keygen` is missing or non-executable, -because that is the one way first-boot generation can fail deterministically — retries and -reboots would never fix it, so it must never reach a node. A successful build therefore -already proves the generators are present, and the build log says so: - -```bash -grep 'first-boot secret generators present' -``` - -If you did not capture the log, assert it against the tar instead: - -```bash -tar -tvf image-recipe/build/auto-installer/archipelago-rootfs.tar \ - | grep -E 'usr/bin/(openssl|ssh-keygen)$' -``` - -Expected: both present and mode `-rwxr-xr-x`. - -### 6. Record the RECIPE_HASH the builder actually used - -```bash -cat image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256 -``` - -**Caveat — do not compute this hash from the repo file.** `image-recipe/build-debian-iso.sh` -copies the archived builder to a temp path and rewrites its relative paths before exec'ing it, -and `RECIPE_HASH` hashes `"$0"` — the rewritten copy. The hashed region contains 35 such -rewritten path expressions, and `SCRIPT_DIR` is substituted with an absolute path, so the hash -is specific to the build host and checkout location. For reference, hashing the region of the -committed repo file directly gives `d2dc4df5427fe73d48227aab08cdf6debfe8dd554e6b18e3718f8d37ea9d675c`, -which is **expected to differ** from the stamp above. - ---- - -## Result - -_Paste the raw output of steps 2, 4, 5 and 6 here, then set the status at the top of this -document to VERIFIED with the date and build-host label._ - -```text -(pending — not yet run on a build host) -``` - -**Verdict:** _pending_ - ---- - -## What this does and does not prove - -**Proves (once run):** the rootfs tar extracted verbatim onto every disk flashed from the ISO -carries no SSH host key, no TLS private key and no populated machine-id — so a first-boot -regeneration failure degrades to "no key, the service refuses to start" rather than -"fleet-shared key, silently", which is the substance of F-03. - -**Does not prove:** that two nodes flashed from the same ISO actually end up with different -keys. That is audit item **C-3** (§779) and needs two physical machines; it remains -separately UNVERIFIED. C-4 is a build-host check only. - -### Guidance for C-3: SSH and TLS are now equally sharp signals - -An earlier revision of this document said SSH host keys were the sharper divergence signal for -C-3, because the installer had a per-install TLS fallback that would produce a differing cert -even if first-boot generation had failed. **That asymmetry no longer exists.** - -There is now exactly one producer of each secret — `gen_tls()` and `gen_ssh()` inside -`first-boot-secrets.sh` — and no other code in the ISO build creates either. The Dockerfile no -longer bakes a TLS keypair and the installer's "ensure SSL cert exists" block is gone. So for -C-3, treat both the same way: - -```bash -# on each node -ssh-keyscan -t ed25519 localhost 2>/dev/null | ssh-keygen -lf - -openssl x509 -in /etc/archipelago/ssl/archipelago.crt -noout -fingerprint -sha256 -``` - -**Pass:** both fingerprints differ between the two nodes. **Fail:** either matches — a matching -TLS fingerprint is now exactly as damning as a matching host key, whereas before it could have -been explained away by the fallback. - -Also check, on each node, that the run actually succeeded rather than merely being quiet: - -```bash -ls -l /var/lib/archipelago/.secrets-regenerated # present on a healthy node -cat /var/lib/archipelago/first-boot-secrets.failed 2>&1 # absent on a healthy node -systemctl status archipelago-first-boot-secrets.timer # enabled; the self-heal path -``` - -The audit's original C-3 fail condition — a `WARNING:` line in the log alongside an existing -marker — can no longer occur by construction: the marker is only written when both generators -succeeded. If you ever see that combination, the fix has been reverted. diff --git a/docs/security/KEY-03-SIGNING-POSTURE.md b/docs/security/KEY-03-SIGNING-POSTURE.md deleted file mode 100644 index f63bf3df..00000000 --- a/docs/security/KEY-03-SIGNING-POSTURE.md +++ /dev/null @@ -1,448 +0,0 @@ -# KEY-03 — Signing posture after the Bitcoin Core wallet deletion - -> **What this document is.** The evidence-backed record of how Archipelago's Bitcoin signing -> posture stands after Phase 10 KEY-03. It supersedes, for the Bitcoin Core wallet specifically, -> the target state described in `docs/security/PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 1 — that -> phase planned to *convert* Core's wallet to watch-only; **D-07b deleted the path instead.** -> -> **Governing decisions:** `.planning/phases/10-key-material-hardening/10-CONTEXT.md` -> **D-07b** (final KEY-03 scope — delete, do not migrate) and **D-07c** (the deferred BDK cold -> vault, recorded so it is not lost with the code). D-07b supersedes D-07 and D-07a's conditional -> migration. -> -> **Audit finding closed:** F-13 (High) — -> `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:604`, remediation register R-04. - ---- - -## Bitcoin Core wallet path — deleted (D-07b) - -### What was deleted - -| Symbol | Kind | Location before deletion | -|---|---|---| -| `handle_bitcoin_init_wallet_from_seed` | `async fn` | `core/archipelago/src/api/rpc/bitcoin.rs:161-295` | -| `"bitcoin.init-wallet-from-seed"` | JSON-RPC dispatch arm | `core/archipelago/src/api/rpc/dispatcher.rs:122-124` | - -### The defect (F-13) - -The handler loaded the encrypted seed, derived the **BIP-84 account extended private key** -(`crate::seed::derive_bitcoin_xprv`, `bitcoin.rs:188`), stringified it (`:189`), and imported -`wpkh(xprv/0/*)` and `wpkh(xprv/1/*)` (`:230-231`) into a Bitcoin Core descriptor wallet created -with `disable_private_keys = false` (`:203`) and an **empty** wallet passphrase (`:205`). - -The result was a **second copy of the node's spending key**, persisted in Core's `wallet.dat` -inside the Bitcoin container's data volume, with no Argon2 passphrase — while the first copy sits -in the daemon's Argon2 + ChaCha20-Poly1305 envelope written `0600` -(`core/archipelago/src/seed.rs:238-269`, `:318-324`). That duplication, into weaker protection, -was the entire finding. - -### Evidence that deletion was the right close (re-established for this task, not inherited) - -The four D-07a evidence points, verified again against the tree before anything was removed: - -**1. No caller anywhere.** Repo-wide search across `core/`, `neode-ui/src`, `scripts/`, `web/`, -`apps/`, `tests/` and `docs/`, excluding `core/target`, `node_modules` and `.git`: - -``` -$ grep -rn 'bitcoin\.init-wallet-from-seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ -core/archipelago/src/api/rpc/dispatcher.rs:122: "bitcoin.init-wallet-from-seed" => { - -$ grep -rn 'handle_bitcoin_init_wallet_from_seed' core/ neode-ui/src scripts/ web/ apps/ tests/ docs/ -core/archipelago/src/api/rpc/bitcoin.rs:161: pub(super) async fn handle_bitcoin_init_wallet_from_seed( -core/archipelago/src/api/rpc/dispatcher.rs:123: self.handle_bitcoin_init_wallet_from_seed(params).await -docs/UNIFIED-TASK-TRACKER.md:208: §8 Phase 1). `handle_bitcoin_init_wallet_from_seed` passes -docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md:607:(`handle_bitcoin_init_wallet_from_seed`): -docs/security/PSBT-SIGNING-ARCHITECTURE.md:147: `handle_bitcoin_init_wallet_from_seed`, `core/archipelago/src/api/rpc/bitcoin.rs:161-294`). -``` - -Exactly one occurrence of the method name (its own dispatcher registration) and two of the symbol -in code (its definition and the dispatcher call). The three remaining symbol hits are prose in -documentation — the audit, the task tracker, and the PSBT architecture spec — not callers. No -frontend, script, test or other Rust module invoked it. - -**2. LND is the wallet the product actually drives.** Across all of `neode-ui/src`, every -`bitcoin.*` RPC call is read-only status: `bitcoin.getinfo` (14 call sites), -`bitcoin.prune-status` (3), `bitcoin.onion` (1). There are **no** `bitcoin.*` wallet operations. -The wallet UI (`Web5Wallet.vue`, `SendBitcoinModal.vue`) sends via `lnd.sendcoins`, estimates via -`lnd.estimatefee`, and reads balance via `lnd.getinfo`. - -**3. The wallet it creates never existed on the reference node.** Verified live on -**archi-dev-box, 2026-08-02**, against the running `bitcoin-knots` container (read-only RPCs -only — see the census section for the exact commands and the standing ban on -`listdescriptors true`): - -``` -listwalletdir → { "wallets": [ "gatewayd-02004b91…", "gatewayd-03443c0c…", "" ] } -listwallets → [ "" ] -``` - -**There is no wallet named `archipelago`** — the handler's default `wallet_name` -(`bitcoin.rs:170-173`). It has never run on this node. `getwalletinfo` on the one loaded wallet -(the unnamed default) reports: - -``` -walletname: "" blank: true keypoolsize: 0 -txcount: 0 balance: 0.00000000 -descriptors: true private_keys_enabled: true -``` - -`blank: true` with `keypoolsize: 0` and `txcount: 0` is Bitcoin Core's own statement that **no -key was ever imported into it and no transaction ever touched it**. The two `gatewayd-*` entries -are Fedimint gateway wallets, unrelated to the BIP-84 path. The `wallet.dat` at the datadir root -is Core's own legacy default-wallet location, not this handler's output. - -**This is one node.** The same check was subsequently run across the reachable fleet — see the -census below: **4 nodes examined and clear, 6 unreachable and therefore unknown.** - -**Supporting history evidence:** `git log -S "init-wallet-from-seed"` scoped to -`core/archipelago/src/api/rpc/dispatcher.rs` and `neode-ui/src` returns exactly one commit — -`19dcfd4f feat: BIP-39 master seed for unified key derivation`, the commit that **added** it. No -frontend wrapper was ever written: it was built and never wired up. - -**4. It was never remotely reachable.** The endpoint is absent from `UNAUTHENTICATED_METHODS` -(`core/archipelago/src/api/rpc/middleware.rs:5-40`) — so it required an authenticated session — -**and** it additionally re-verified the user's password before touching the seed -(`self.auth_manager.verify_password(password)`, `bitcoin.rs:176-179`). **F-13 was therefore -key-at-rest duplication, not an exposed endpoint.** That is why it was rated High rather than -Critical, and why deleting it is a hardening measure rather than an incident response. - -### What was *not* wrong with it - -Worth stating so the record is fair, and so the next reader does not mistake the lesson. The -in-memory handling of the xprv string was **careful**: it was zeroized on the error path -(`bitcoin.rs:222`) and on the success path (`:284`), matching the standard set elsewhere in -`seed.rs`. The wallet type was also correct — `createwallet` already passed `descriptors = true` -(`:207`), which is the right foundation. - -**The defect was which key went into the wallet, not how the key was held in memory or what kind -of wallet it was.** A watch-only rewrite (xpub + `[fingerprint/derivation]` key origin) would -have been a legitimate fix. Deletion was chosen over rewrite because the endpoint had no caller, -no consumer, and no product role: rewriting it would have produced a correct implementation of -something nothing uses, and left a wallet-creating code path to be maintained and re-audited -forever. - -### How F-13 is closed - -**By removal, not by conversion to watch-only.** After this change there is no code path in the -daemon that writes the BIP-84 account private key into Bitcoin Core. The only on-node copy of -that key is the daemon's Argon2 + ChaCha20-Poly1305 envelope. - -**No migration was performed and none is planned.** D-07's parity-proof migration and its -one-way checkpoint are **withdrawn** (D-07b) — there is no wallet to migrate. If a fleet node is -ever found holding a descriptor wallet this handler created, that is a **finding to surface and -stop on**, not a trigger to auto-migrate: it would mean the endpoint was invoked by hand and that -node's spending key is duplicated in Core, which deserves a human decision rather than an -automated rewrite of a wallet that may hold funds. - -### This deletion removes code, not wallets - -Stated explicitly so nobody reading the change later has to wonder whether it was destructive: - -> **Nothing on disk is touched.** No `wallet.dat` is modified, unloaded or removed. No funds -> move. No LND state, secret, descriptor or seed is altered. The change removes a Rust function -> and a `match` arm — the *path* by which a private key could be imported into Bitcoin Core — -> and nothing else. - -This holds even on a hypothetical node where the endpoint had been invoked by hand: deleting the -handler destroys nothing there either. It closes the door; it does not clean the room. Cleaning -up such a wallet, if one is ever found, is a separate human decision (see the census below), and -CLAUDE.md's **"migrations never destroy data"** invariant is not engaged by this change because -there is no migration. - -### What deletion does to D-08 and D-09 - -Neither decision lapses; both are satisfied by a different mechanism. - -- **D-08** asked that the spending key exist in exactly one place, with an opt-in air-gapped - path. Deleting the Core import achieves the first half outright. The opt-in path is LND's - existing PSBT round trip, not a Core watch-only wallet — see the next section, including the - recorded verdict on how far that actually goes today. -- **D-09** required a `[fingerprint/derivation]` key origin on emitted descriptors so a hardware - signer can locate its key. With Core's descriptors deleted there are **no Archipelago-emitted - descriptors left to annotate**, so D-09's actual protection moves to the PSBT itself. That is - why `lnd.create-psbt` now inspects and reports the key-origin data its PSBT carries - (`psbt_key_origin_report`, `core/archipelago/src/api/rpc/lnd/wallet.rs`). - -### `derive_bitcoin_xprv` is retained deliberately (D-07c) - -`crate::seed::derive_bitcoin_xprv` (`core/archipelago/src/seed.rs:231`) lost its only non-test -caller and was **kept**, marked `#[allow(dead_code)]` with the reason in its doc comment. It is -covered by existing tests (`seed.rs:601-602`, `:856`) and it is the derivation **D-07c's deferred -BDK cold vault** — a descriptor wallet in the daemon using the node's own ElectrumX app -(`apps/electrumx`, `electrs_status.rs`) as chain source — will need. - -D-07c was considered and deliberately deferred out of Phase 10 (it needs its own phase: a new -dependency and a new UI surface). It is recorded here, and in the function's doc comment, so the -option is not quietly lost along with the code that was deleted. The alternative shape — LND -watch-only via `importaccount` plus remote signing — was considered and rejected for coupling -cold storage to LND's upgrade path. - ---- - -## LND PSBT round trip — what is covered - -With Core's wallet deleted, LND is the only wallet Archipelago has, and its PSBT round trip is -the only external-signer path that exists. This section records what that path actually consists -of, what is tested, and — the question that decides whether any of it is an air gap — whether an -externally-held signer can sign a default node's PSBT at all. - -### Per-step coverage map - -Round trip: **fund → export → sign offline → import → finalize → broadcast.** - -| # | Step | Where it lives | `file:line` | Automated test coverage | -|---|---|---|---|---| -| 1 | **Fund** — build a funded PSBT via LND WalletKit `/v2/wallet/psbt/fund` | `lnd.create-psbt` handler | `core/archipelago/src/api/rpc/lnd/wallet.rs:605`; dispatch arm `api/rpc/dispatcher.rs:136` | **Untested.** No LND mock exists; the handler's request/response handling is exercised only by hand. | -| 1a | **Inspect** — report BIP-32 key origin on the funded PSBT | `psbt_key_origin_report` + wiring | `lnd/wallet.rs:1186` (fn), `:1169` (struct), `:705` (call site), `:737` (response field) | **Tested.** 3 unit tests, below. | -| 2 | **Export** — hand the base64 PSBT to the user | UI renders `psbt_base64` for copy | `neode-ui/src/api/rpc-client.ts:407-423`; `neode-ui/src/views/web5/Web5SendReceiveModals.vue:308` | **Partial.** `neode-ui/src/api/__tests__/rpc-client.test.ts:319-323` asserts only that the client calls the method `lnd.create-psbt`; it does not test the payload or the rendering. | -| 3 | **Sign offline** — external signer produces a signed PSBT | **Not in this repo.** No first-party signer ships today. | — | N/A | -| 4 | **Import** — user pastes the signed PSBT back | textarea → `signedPsbtInput` | `Web5SendReceiveModals.vue:102`, `:419-424` | **Untested.** | -| 5 | **Finalize** — `/v2/wallet/psbt/finalize` | `lnd.finalize-psbt` handler | `lnd/wallet.rs:743`; dispatch arm `dispatcher.rs:137` | **Untested.** | -| 6 | **Broadcast** — `/v2/wallet/tx`, in the same handler | `handle_lnd_finalize_psbt` tail | `lnd/wallet.rs:795` | **Untested.** | -| — | **Rate limiting** — both endpoints at 5 calls / 300s | `RateLimiter` defaults | `core/archipelago/src/rate_limit.rs:68-69` | **Untested for these two methods specifically.** | - -**Stated plainly, because an untested path must not be described as verified:** of the six steps, -**one** (the key-origin inspection added by this plan) has automated coverage in the Rust -crate. Steps 1, 4, 5 and 6 have **none** — no test exercises the LND REST calls, the finalize -handler, or the broadcast. Step 2's only test asserts a method name. **No end-to-end test of the -round trip exists**, and none of it has been verified against a real hardware signer. - -There is also **no air-gap transport**: no animated QR encode/decode, no `.psbt` file -download/upload. Export and import are copy-paste of base64 in a textarea. The BC-UR v2 / BBQr -design in `PSBT-SIGNING-ARCHITECTURE.md` §4 is unimplemented. - -### New tests added by this plan - -In `core/archipelago/src/api/rpc/lnd/wallet.rs`'s `mod tests`, with fixtures built -programmatically from the `bitcoin` crate rather than pasted as opaque base64: - -| Test | Asserts | -|---|---| -| `psbt_without_derivations_reports_no_key_origin` | A one-input unsigned PSBT with no `bip32_derivation` reports `inputs_with_key_origin: 0` and `all_inputs_have_key_origin: false`. | -| `psbt_with_derivations_reports_key_origin` | The same PSBT with a `(Fingerprint, DerivationPath)` inserted on input 0 reports `1/1` and `true`. | -| `malformed_psbt_is_an_error_not_a_panic` | Non-base64, truncated-PSBT and empty inputs all return `Err`, never panic. | - -``` -running 3 tests -test api::rpc::lnd::wallet::tests::psbt_with_derivations_reports_key_origin ... ok -test api::rpc::lnd::wallet::tests::psbt_without_derivations_reports_no_key_origin ... ok -test api::rpc::lnd::wallet::tests::malformed_psbt_is_an_error_not_a_panic ... ok - -test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 1014 filtered out -``` - -`lnd.create-psbt` now returns an additive `key_origin` field: - -```json -"key_origin": { "input_count": 1, "inputs_with_key_origin": 0, "all_inputs_have_key_origin": false } -``` - -It is computed **best-effort**: a decode failure degrades to `null` and logs a warning, never to -an error — a user's send must not fail because an inspection helper could not parse something. -When `all_inputs_have_key_origin` is false the handler emits a `tracing::warn!` with the counts, -because that is the exact condition under which a hardware signer refuses the PSBT. Existing -response fields are unchanged; `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (the -sibling that deliberately auto-signs with LND's hot keys) were not touched. - -### Can an external signer actually sign a default node's PSBT? — **No, not today** - -This is the question that separates "we have PSBT plumbing" from "we have air-gapped custody", -and the two must not be allowed to blur. - -**Verdict: on a default Archipelago node, an externally-held signer cannot meaningfully sign a -PSBT produced by `lnd.create-psbt`.** The evidence: - -1. **The PSBT is funded from LND's own wallet.** `lnd.create-psbt` POSTs to LND's WalletKit - `/v2/wallet/psbt/fund` (`lnd/wallet.rs:672`), which selects UTXOs belonging to **LND's** - wallet. The keys for those inputs are the keys LND holds. -2. **LND's wallet on every node is a full key-holding wallet, created locally.** - `container::lnd::ensure_wallet_initialized` (`core/archipelago/src/container/lnd.rs:86`) calls - `init_wallet_via_rest`, which POSTs `/v1/initwallet` with a `cipher_seed_mnemonic` - (`container/lnd.rs:504-516`) and persists the aezeed backup (`:523-525`). That is a normal - wallet with private keys, not a watch-only one. -3. **No node's `lnd.conf` carries a remote-signing block.** The config Archipelago generates - (`container/lnd.rs:64-79`) contains `bitcoin.node=bitcoind` and the bitcoind RPC settings, and - **no `remotesigner.*` keys at all**. -4. **Nothing in the repo provisions watch-only LND.** A search of `apps/`, `scripts/`, - `core/archipelago/src` and `image-recipe/` for `remotesigner`, `createwatchonly` and - `nochainbackend` returns **zero matches**. There is no code path, script or manifest that sets - any node up this way. - -An external signer could only sign these inputs if LND were first provisioned **watch-only -against that signer** — `remotesigner.*` on the node plus `lncli createwatchonly` from the -signer's exported accounts, with the level-3 accounts and the p2tr import step described in -`PSBT-SIGNING-ARCHITECTURE.md` §5.1-5.2. **No fleet node is so provisioned.** - -**What therefore ships today is the PSBT *transport*, not air-gapped custody.** The round trip is -real and rate-limited, and it is genuinely useful for signing a PSBT whose inputs belong to some -*other* wallet — but on a default node the signer that holds the input keys is LND itself, so -routing the PSBT out to an external device and back adds a step without moving custody anywhere. -The gap between here and D-08's opt-in air-gapped path is **provisioning, not plumbing**, and -that provisioning is out of scope for Phase 10 (it is `PSBT-SIGNING-ARCHITECTURE.md` §8 Phase 6). - -Nothing in the UI currently claims otherwise, and nothing added by this plan does either. If -copy is ever written for this flow, it must not describe it as cold storage on the strength of -the PSBT round trip alone. - -### Lightning channel, revocation and HTLC keys are not air-gappable — at all - -This is a standing constraint, not a caveat, and it survives every change in this document. - -> **A Lightning node's channel, revocation and HTLC keys must sign in real time to answer -> counterparty commitments. They cannot be air-gapped.** A routing node cannot tolerate a -> human-in-the-loop signing step: a delayed response to a commitment update risks a force-close, -> and a missing revocation risks loss. LND remote signing **relocates** these keys to a hardened -> host — it does **not** cool them. There is no configuration, present or future, in which a -> live Lightning node's channel keys are cold. - -This is the same limit stated in `PSBT-SIGNING-ARCHITECTURE.md` §5.1 ("Air-gap channel / -revocation / HTLC keys — **No**") and §5.4, whose honesty table remains correct and unmodified. - -The consequence for user-facing copy, quoted from §5.4 and repeated here so it cannot be lost: - -> *A Lightning routing node's channel keys are necessarily hot. Remote signing moves them to a -> hardened machine; it does not make them cold. Only your on-chain balance can be genuinely -> protected by an offline signer.* - -**No wording in this document, or in any document this phase touches, may imply that Lightning -funds can be held cold.** A user who believes their Lightning balance is cold will keep more in -it than they otherwise would, which is exactly the miscalibration that turns an incident into a -loss. - ---- - -## Fleet census — Core descriptor wallets - -**Status: run 2026-08-02 — 4 nodes examined and CLEAR, 6 nodes UNCHECKED. No escalation.** - -This section answers one question per node: *does this node hold a Bitcoin Core descriptor wallet -that the deleted wallet-init handler created, and does it hold private keys?* It is recorded per -node rather than assumed, because deletion closes the door but does not tell us whether anyone -walked through it before. - -The nodes that could **not** be examined are listed with their reasons, not omitted. A census -that quietly drops its failures is worthless — an auditor must be able to see exactly which -machines were looked at and which were not. - -### Hard constraint on every command in this census - -> **Never run `listdescriptors true`.** The `true` argument makes Bitcoin Core return the -> descriptors **including private keys**, which would print an xprv to a terminal and into a -> transcript — creating the exact exposure this census exists to measure. -> `listwalletdir`, `listwallets`, `getwalletinfo` and `listdescriptors` **with no second -> argument** answer the question completely. -> -> If any output unexpectedly contains a string beginning `xprv`, **stop immediately, do not -> paste it**, and report only that it occurred. - -### Commands (re-runnable by an auditor) - -Per node, against the Bitcoin Core / Knots container: - -```bash -# 0. Does the handler's wallets directory exist at all? An absent directory is -# itself a complete answer for that node — paste the output as-is. -ls -la /var/lib/archipelago/bitcoin/wallets/ 2>&1 - -# bitcoin-cli is NOT on $PATH inside the container. On archi-dev-box (Knots -# 29.3) it lives at: -# /opt/bitcoin-29.3.knots20260210/bin/bitcoin-cli -# The RPC user is `archipelago`; the password is read from -# /var/lib/archipelago/secrets/bitcoin-rpc-password -# — reference that path, never the value, and prefer -stdinrpcpass so the -# password never appears in a process list or shell history. - -# 1. Every wallet on disk, loaded or not. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwalletdir - -# 2. Currently loaded wallets. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass listwallets - -# 3. Per wallet returned: record walletname, private_keys_enabled, descriptors, -# blank, keypoolsize, txcount, balance. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet= getwalletinfo - -# 4. ONLY for a wallet with private_keys_enabled: true — NOTE: no second argument. -# Record descriptor prefixes (`wpkh(...`) only, never a full key string. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass -rpcwallet= listdescriptors - -# 5. Which Bitcoin app and version. -bitcoin-cli -rpcuser=archipelago -stdinrpcpass getnetworkinfo | head -``` - -### Results — examined, 2026-08-02 (4 nodes, all CLEAR) - -Run by the operator over Tailscale, read-only RPCs only. - -| Node | Tailscale IP | Container | `listwalletdir` | `listwallets` | `archipelago` wallet? | Default wallet state | Verdict | -|---|---|---|---|---|---|---|---| -| **archi-dev-box** | `100.69.68.39` | `bitcoin-knots` | 2× `gatewayd-*`, `""` | `[ "" ]` | **No** | `blank: true`, `keypoolsize: 0`, `txcount: 0`, `balance: 0.00000000`, `descriptors: true` | **CLEAR** | -| **shorty-s** (`.228`) | `100.64.204.114` | `bitcoin-knots` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | -| **archy-x250-beta** | `100.72.136.5` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | -| **archy-x250-pa** | `100.89.209.89` | `bitcoin-core` | 1× `gatewayd-*`, `""` | `[ "" ]` | **No** | same | **CLEAR** | - -On every examined node there is **no wallet named `archipelago`** — the deleted handler's default -`wallet_name`. The only named wallets are Fedimint `gatewayd-*`, unrelated to the BIP-84 path. - -The one loaded wallet on each node is Core's unnamed default. It does report -`private_keys_enabled: true`, but also `blank: true` with `keypoolsize: 0`, `txcount: 0` and -`balance: 0.00000000` — **Bitcoin Core's own statement that no key was ever imported into it and -no transaction ever touched it.** It is not the deleted handler's output, and it holds nothing. - -**The result holds across two container vintages** — `bitcoin-knots` on two nodes and -`bitcoin-core` on two others. That matters: it is not four copies of one image behaving -identically, so the finding is a property of the fleet rather than an artefact of a single build. - -**No key material appeared in any output, and `listdescriptors true` was never run.** - -### Not examined, 2026-08-02 (6 nodes, with reasons) - -| Node | Tailscale IP | Why not checked | -|---|---|---| -| framework-pt | `100.65.115.109` | `Permission denied (publickey,password)` — SSH password rotated, not held | -| archipelago-1 | `100.82.34.38` | `Permission denied (publickey,password)` | -| archipelago | `100.70.96.88` | `Permission denied (publickey,password)` | -| archy-dev-pa | `100.64.83.15` | `Permission denied (publickey,password)` | -| archipelago-5 | `100.114.134.21` | Timed out during SSH banner exchange | -| archy-x250-dev | `100.113.100.55` | Offline — Tailscale reports last seen 2 days prior | - -**Password authentication was deliberately not attempted on any of these.** Several fleet nodes -lock PAM quickly on a wrong password, and locking an in-use production node out is a worse -outcome than an incomplete census. These are recorded as UNCHECKED, **not** as clear. - -### Conclusion, at the strength the evidence supports - -> **No examined node holds a wallet created by the deleted handler, and no examined node holds -> any wallet with keys or funds.** Four nodes, across two container vintages, on 2026-08-02. - -**This is deliberately not a claim that "the fleet is clear."** Six nodes were not examined, and -an unexamined node is unknown, not safe. F-13 is closed **by deletion** — the code that could -create such a wallet is gone from every future build, which is true regardless of the census — -and the census adds that no such wallet was found where anyone could look. - -### Standing item — finish the census - -The six unchecked nodes remain open. **Homed in `docs/UNIFIED-TASK-TRACKER.md`** (the project's -canonical "what's open" list) as *"Finish the Core-wallet fleet census — 6 nodes unchecked"*, -rather than only here, so it is visible to someone who is not already reading a security -document. It is flagged there as a natural fold-in for **KEY-04's on-node work**, which needs -node access anyway — but it is tracked independently so it does not vanish if KEY-04 is -re-scoped. - -Re-run the read-only procedure above when credentials or connectivity allow. - -### Standing rule if a wallet is found - -If any node reports a wallet named `archipelago` (or any descriptor wallet with -`private_keys_enabled: true` that this handler plausibly created), that is a **finding**: - -1. **Stop.** Record it here with the node label and wallet name. -2. **Raise it as a blocker.** KEY-03 does not close until a human decides what to do about it. -3. **Do not migrate, unload, rescan or modify it.** D-07b withdrew the migration deliberately. - Rewriting a wallet that might hold funds is exactly the kind of decision that belongs to a - human, and CLAUDE.md's "migrations never destroy data" invariant applies the moment anyone - touches it. - -Such a wallet would mean the endpoint was invoked manually before this plan deleted it, and that -node's spending key is duplicated in Core outside the Argon2 envelope. diff --git a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md index 71be9513..92df28cb 100644 --- a/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md +++ b/docs/security/KEY-05-ENTROPY-ENFORCEMENT.md @@ -1,13 +1,18 @@ # KEY-05 — Entropy enforcement: per-site classification and mechanism record -**Requirement:** ROADMAP `KEY-05`. **Plan:** `.planning/phases/10-key-material-hardening/10-06-PLAN.md`. +**Requirement:** ROADMAP `KEY-05`. **Supersedes:** backlog `R-13`. **Absorbs:** `R-05` (duplicate-`rand` visibility) and `R-09` -(CSPRNG-readiness record). **Resolves:** `F-10a` in -`docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md`, which recorded raw match counts and -**deliberately declined to classify them**. +(CSPRNG-readiness record). **Resolves:** `F-10a` from the internal entropy and +seed-generation audit, which recorded raw match counts and **deliberately declined +to classify them**. **Tree state this document was derived against:** `HEAD = c5a82cba` (2026-08-02). +**Update:** every `migrate` disposition in the table below has since been applied. +No `rand::random()` / `rand::thread_rng()` call remains in production `archipelago` +code — each draws through `entropy::draw_key_bytes` from a named `OsRng`, and +`core/clippy.toml` now bans both APIs, so a regression fails the build. + --- ## Nothing here is broken today @@ -44,7 +49,7 @@ they are enabled wrongly. Both are gated behind Task 5, a `gate="blocking-human" ## Source precedence -`.planning/phases/10-key-material-hardening/10-CONTEXT.md` (2026-08-01) lists **F-07 / R-05** +The Phase 10 hardening work lists **F-07 / R-05** and **F-10 / R-13** under `## Deferred Ideas`. KEY-05 was added to the ROADMAP on **2026-08-02**, after that context was gathered, and explicitly absorbs R-05 and supersedes R-13. The ROADMAP requirement is the later and governing artifact. @@ -206,8 +211,8 @@ lines are `core/archipelago/src/mesh/x3dh.rs:99` and `:113` — produced there; `:100` and `:114` draw only the `u32` `id` fields of `SignedPrekey` and `OneTimePrekey`. They remain in scope — they are values that go on the wire — but the characterisation "X3DH key agreement — key material" overstates these two specific lines. -(The audit has since been corrected in place at `ENTROPY-SEED-AUDIT-2026-07-31.md:508`; this -section records the derivation independently.) +(The internal audit has since been corrected; this section records the derivation +independently.) --- diff --git a/docs/security/LND-MACAROON-ROTATION.md b/docs/security/LND-MACAROON-ROTATION.md new file mode 100644 index 00000000..44b8b2ba --- /dev/null +++ b/docs/security/LND-MACAROON-ROTATION.md @@ -0,0 +1,166 @@ +# Rotating this node's Lightning credentials + +A Lightning macaroon is a **bearer token**: whoever holds one can spend from the +node's wallet. There is no revocation list and no expiry. If a macaroon is ever +read by something you do not control — a leaked endpoint, a screenshot, a phone +that has since been lost, an app that ran a version with a published +vulnerability — that ability persists until the macaroons are rotated. + +Rotation is therefore a **routine operator action**, not an emergency procedure. +Two paths do the same work: + +| Path | Use when | +|---|---| +| **Dashboard** — Settings → *Lightning credentials* | Normal case. Password-confirmed, shows progress, repairs BTCPay for you. | +| **`scripts/security/rotate-lnd-macaroon.sh`** | No dashboard reachable, or you want a detect-only report. | + +## What rotation actually does + +LND derives every macaroon it issues from a root key in `macaroons.db`. Remove +that root key plus the issued `*.macaroon` files, restart, and LND mints a fresh +root key and a fresh set of macaroons when the wallet unlocks. Every macaroon +issued before that moment — including any an attacker holds — stops verifying. + +## Why your funds and channels survive + +Macaroons are bearer tokens, not keys. Coins live in `wallet.db` and channel +state in `channel.db`; channels are secured by the node's identity and channel +keys, none of which are derived from the macaroon root key. Neither database is +opened, moved or deleted. + +Both paths **prove** this rather than asserting it: they record the node's +identity pubkey and its channel census before rotating, and refuse to report +success if either differs afterwards. + +Two details in that check are deliberate and should not be "tightened": + +- **Channels are compared as a total, not as `num_active_channels`.** The active + count only counts channels whose peer is currently online, so it legitimately + dips for minutes after *any* restart while peers reconnect. Asserting on it + alone would abort a perfectly healthy rotation. +- **`wallet.db` is not compared byte-for-byte.** btcwallet records chain-sync + progress inside it, so the file changes on every start. Asserting byte-identity + would fire a frightening false alarm on a completely healthy rotation. + +## What it never does + +- No macaroon **content** reaches a response, an error, a log line, or the + progress feed the dashboard polls. Everything reported is a SHA-256 digest or a + byte count — enough to prove the material changed without disclosing it to + whoever is reading the screen. +- No path from "rotate my credentials" to "delete my wallet". LND's boot path + self-heals a wallet no candidate password can open by wiping and recreating it; + correct for an unattended boot, catastrophic here. Rotation unlocks through + `container::lnd::unlock_existing_wallet_no_wipe`, so a wallet whose password + this node does not hold surfaces as a **failed rotation** with the wallet + intact. + +## The BTCPay coupling — the part that bites + +**BTCPay Server keeps its own inline copy of the admin macaroon**, and it cannot +self-heal. LND's data directory is owned by its container's mapped uid, so BTCPay +cannot bind-mount the macaroon file (EACCES across the userns boundary). The +connection string therefore carries the macaroon as hex: + +``` +type=lnd-rest;server=https://lnd:8080/;macaroon=;certthumbprint= +``` + +delivered as the `btcpay-lnd-connection` secret file. Rotate the macaroons and +that copy becomes a dead credential. Nothing notices on its own, because the +daemon only regenerates this secret when LND's **TLS cert thumbprint** changes — +and macaroon rotation does not touch the cert. + +The resulting state is the dangerous one: **BTCPay is up, LND is up, both report +healthy, and every Lightning invoice BTCPay tries to create fails.** + +Repair needs two things, and one without the other is cosmetic: + +1. **Rewrite the secret** (`container::lnd::rewrite_btcpay_lnd_connection_secret`). + This is what makes the change visible: `secret_env_hash` is derived from the + resolved secret contents, so a changed file reads as label drift on the + running container. +2. **Recreate the container.** `btcpay-server` is on the restart-sensitive list, + and the reconcile loop runs in `ExistingOnly` mode *always* — boot and + periodic alike — where env drift on a restart-sensitive app is detected and + then deliberately skipped. Rewriting the secret alone therefore changes + nothing that is running. Observed directly on a development node, once per + tick, for half an hour: + + ``` + container drift detected during boot reconcile; leaving running + restart-sensitive app untouched app_id=btcpay-server + ``` + + The dashboard path calls + `ContainerOrchestrator::mark_credential_rotated("btcpay-server")`, which is + the flag the drift check consults to override restart-sensitivity. It is the + same carve-out FED-07 added for the Fedimint gateway, and the reasoning is + identical: restart sensitivity protects apps that are *working*, and this one + is working only in appearance. + +**The shell script cannot set that in-process flag**, so it does the equivalent +from outside: it deletes the secret (the daemon regenerates it within a tick), +then removes the `btcpay-server` container so the orchestrator's own +desired-state recovery rebuilds it around unchanged data. That recovery is what +makes this safe rather than a hand-rolled remove-and-run — it fires because the +app is still installed and was in the last running-containers snapshot. The +script then prints the commands to confirm it actually happened, because a +failure here is invisible. + +## Slow nodes: the unlock budget + +LND opens `channel.db`, `graph.db` and `wallet.db` before it serves the unlocker +at all, and on a busy node that is genuinely slow — **2m38s measured on a box +running 30 containers**. The unlock helper used to give up after ~60s, which on +such a node could never succeed. + +That timeout was not a harmless retry. Reconcile records the post-start hook as +failed, restarts LND, and the slow database open starts over: a restart loop that +leaves the wallet permanently locked and every LND-dependent app (BTCPay's +internal node included) broken, on exactly the nodes least able to afford it. + +The not-ready budget is now ~10 minutes (`UNLOCK_NOT_READY_ATTEMPTS`). Waiting +longer costs nothing, because a genuinely wrong password still exits on the first +pass through the candidate list — the `all_rejected` fast path is untouched. + +## Verifying a rotation + +The dashboard shows all of this. From a shell: + +```bash +# 1. Fingerprint changed (digest only — never print the macaroon) +sudo sha256sum /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon + +# 2. Same node, same channels +podman exec lnd lncli --network=mainnet getinfo \ + | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d["identity_pubkey"], \ + d["num_active_channels"] + d["num_inactive_channels"], d["num_pending_channels"])' + +# 3. BTCPay is carrying the CURRENT macaroon, not the rotated-out one +CUR=$(sudo od -An -v -tx1 /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon | tr -d ' \n') +SEC=$(sudo sed -n 's/.*macaroon=\([0-9a-f]*\).*/\1/p' /var/lib/archipelago/secrets/btcpay-lnd-connection) +[ "$CUR" = "$SEC" ] && echo "current" || echo "STALE — BTCPay's Lightning is broken" + +# 4. BTCPay was actually recreated (a silent failure looks like success) +podman inspect btcpay-server --format '{{.Created}}' +``` + +Check 3 is the one people skip, and it is the one that fails. + +## Afterwards + +- **Re-pair every wallet app**, Zeus most importantly. Open the Lightning app in + the dashboard and scan the pairing QR again; it serves the new macaroon. +- **Delete the backup once re-pairing is done.** Both paths back the old material + up to `/var/lib/archipelago/lnd/macaroon-rotation-` (0700) so a mistake + is recoverable. That directory holds the **old root key** and is still + sensitive: `sudo rm -rf `. + +## Related + +- `docs/security/BITCOIN-RPC-PROXY-EXPOSURE.md` — the leak that first made + rotation necessary, and the operator decision not to rotate the fleet for it. +- `scripts/security/rotate-lnd-macaroon.sh` — the shell path, including its + ordering guard (it refuses to rotate on a binary that still leaks + `/lnd-connect-info`, since the new macaroon would leak within seconds). diff --git a/docs/security/PHASE-10-VERIFICATION-GUIDE.md b/docs/security/PHASE-10-VERIFICATION-GUIDE.md deleted file mode 100644 index a0a8dd09..00000000 --- a/docs/security/PHASE-10-VERIFICATION-GUIDE.md +++ /dev/null @@ -1,267 +0,0 @@ -# Phase 10 — Independent Verification Guide - -**Audience:** third-party security auditors, and the Archipelago team. -**Purpose:** verify the Phase 10 security claims *independently*, without trusting the -project's own test harness. -**Status:** LIVING — sections are marked ✅ verifiable now, ⏳ pending a plan still in -execution, or 🔒 hardware-gated. Do not read an unmarked absence as a passing result. - ---- - -## 0. How to use this document - -Every claim below follows the same four-part structure, and **all four parts matter**: - -| Part | Why it exists | -|---|---| -| **Claim** | Stated so it can be falsified. A claim you cannot disprove is not a security claim. | -| **Reproduce the defect** | Check out the parent commit and demonstrate the bug. *A test that passes on both the fixed and unfixed code proves nothing.* | -| **Verify the fix** | Command + expected output, runnable without our harness wherever possible. | -| **Negative control** | Break the fix deliberately; confirm the check goes red on **exactly** that and nothing else. This is what separates verification from demonstration. | - -**Do not skip "Reproduce the defect".** It is the only step that proves the fix addresses -something real, and it is the step most often omitted in security theatre. - -### Trust posture - -Where a claim can be checked from *outside* the codebase — an HTTP request from another host, -a `tar` listing, a file comparison across two machines — **prefer that over running our tests.** -Our tests are offered as convenience and as evidence of intent, not as proof. Every claim below -that can be externally checked says so explicitly. - ---- - -## 1. Scope - -### In scope — what Phase 10 claims - -| ID | Claim | Severity | Status | -|---|---|---|---| -| KEY-01 | An already-provisioned node refuses every unauthenticated RPC that can mutate identity or credentials | **Critical** | ⏳ `10-01` in execution | -| KEY-02 | First-boot per-device secret generation is fail-closed, retried, self-healing, and has exactly one producer; the shipped rootfs contains no fleet-shared identity material | **High** | ✅ partially landed (`21043096`, `408b328c`), ⏳ single-producer + self-heal in progress | -| KEY-03 | The BIP-84 account private key is never imported into Bitcoin Core; the dead import path is deleted | **High** | ⏳ `10-05` in execution | -| KEY-04 | On-node evidence for C-3 / C-4 / C-6 | — | 🔒 hardware-gated | -| KEY-05 | A defaulted RNG cannot be inherited anywhere in the crate | Medium | ⏳ `10-06` not started | - -### Explicitly NOT claimed - -State these plainly so an auditor is not left inferring them: - -- **Lightning custody is not air-gappable.** Channel, revocation and HTLC keys must sign in real - time to answer counterparty commitments. LND remote signing *relocates* those keys; it does not - make them cold. Any document implying otherwise is wrong. -- **No claim against a compromised kernel CSPRNG**, a malicious dependency in the supply chain, - memory disclosure on a running node, or physical access. -- **KEY-05 fixes a structural risk, not a live vulnerability.** `rand::random()`/`thread_rng()` - are ChaCha12 seeded from `getrandom(2)`; nothing in that finding is exploitable today. The - mitigation targets *future silent rebinding* of the entropy source. -- **Findings F-04 through F-12 are out of scope** for this phase and remain open. See - `ENTROPY-SEED-AUDIT-2026-07-31.md` remediation register (R-05..R-15) and - `docs/UNIFIED-TASK-TRACKER.md`. - ---- - -## 2. Provenance - -```bash -# The audit that motivated this phase -docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md # 103 file:line references - -# The entropy fix that preceded the phase -git show 8b51b7e2 # seed.rs — explicit OsRng at the call site - -# Phase 10 plans and locked decisions -.planning/phases/10-key-material-hardening/ -``` - -`.planning/` is committed deliberately: an auditor can read *why* each decision was made, -including the ones that were reversed. `10-CONTEXT.md` records D-01..D-11 plus three -in-flight corrections (D-03a, D-07a/b/c) where our own earlier reasoning was wrong. - ---- - -## 3. Tier 0 — verifiable on any checkout, no node required ✅ - -No hardware, no deploy. Start here. - -### 3.1 First-boot secrets are fail-closed (KEY-02) - -**Claim.** If per-device secret generation fails, the completion marker is **not** written and -the boot does not proceed as if it had succeeded. - -**Reproduce the defect:** -```bash -git log --oneline -1 21043096 # the fix commit -git show 21043096^:image-recipe/_archived/build-auto-installer-iso.sh > /tmp/pre-fix.sh -grep -n 'touch .*MARKER' /tmp/pre-fix.sh -# Observe: the marker write is NOT inside the success branch — it runs regardless of outcome. -``` - -**Verify the fix:** -```bash -bash tests/first-boot-secrets/run-tests.sh -# Expect: passed: 3 failed: 0 (more cases once the self-heal work lands) -``` -The harness extracts the heredoc body **from the builder itself**, so it exercises the bytes -that ship rather than a copy. Confirm that for yourself: -```bash -grep -n 'extracted .* lines from the builder' tests/first-boot-secrets/run-tests.sh -``` - -**Negative control:** -```bash -# Move `touch "$MARKER"` outside the success branch in the builder, then: -bash tests/first-boot-secrets/run-tests.sh -# Expect: FAIL: openssl fails every attempt -> MARKER-SET-ON-FAILURE -# passed: 2 failed: 1 EXIT=1 -git checkout image-recipe/_archived/build-auto-installer-iso.sh -``` -It must fail on **that case only**. A negative control that reddens everything is measuring -nothing. - -### 3.2 Master-seed entropy is explicit (F-02, shipped) - -**Claim.** Mnemonic generation draws from an explicitly-passed `OsRng`, not a -transitive-dependency default, and a test proves the injected RNG is the one consumed. - -```bash -git show 8b51b7e2 -- core/archipelago/src/seed.rs # ~6 lines of production change -cd core && cargo test -p archipelago seed:: # expect 25 passed; 0 failed -``` - -**Reproduce the defect:** on `8b51b7e2^`, `MasterSeed::generate` calls -`bip39::Mnemonic::generate(24)`, which resolves to `&mut rand::thread_rng()` *inside* the bip39 -crate — there is no seam to inject through, so the proving test cannot be written at all. - -**Note for auditors:** the test module implements `rand::CryptoRng` for a counter RNG. That is a -deliberately false marker-trait promise, confined to `#[cfg(test)]` (`seed.rs:502`). KEY-05 -retires it. Confirm containment: -```bash -grep -n 'CountingRng' core/archipelago/src/seed.rs # all hits must be after the cfg(test) at :502 -``` - -### 3.3 Unauthenticated method inventory (KEY-01 context) - -Read the authoritative list rather than trusting prose: -```bash -sed -n '/UNAUTHENTICATED_METHODS/,/];/p' core/archipelago/src/api/rpc/middleware.rs -``` -Every entry is reachable without a session, RBAC check, or CSRF token. KEY-01's claim is that -those which can mutate identity or credentials refuse once the node is provisioned. - ---- - -## 4. Tier 1 — requires a running node ⏳ - -Pending `10-01` and `10-02`. `10-02` produces `scripts/security/rpc-exposure-probe.sh` and -`docs/security/KEY-01-ON-NODE-VERIFICATION.md`. - -**The external check that matters most (C-6).** From a *different host* on the same network, -against a node that has completed onboarding: - -```bash -curl -sS -X POST http:///rpc \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","id":1,"method":"seed.restore","params":{"words":["<24 words>"]}}' -``` - -- **Before the fix:** the node accepts attacker-supplied words and overwrites `node_key`, - `nostr_secret` and the FIPS mesh key. This is the Critical finding. -- **After the fix:** refused, and the node's identity is byte-identical afterwards. - -Verify byte-identity yourself rather than trusting a log line: -```bash -sha256sum /var/lib/archipelago/identity/node_key /var/lib/archipelago/identity/nostr_secret -# run before and after the request; the hashes must be unchanged -``` - -> ⚠️ **Do not run the "before" case against a node you care about.** It really does overwrite the -> identity. Use a disposable node — see `.planning/todos/pending/2026-08-01-archi-dev-box-as-fresh-test-node-without-iso.md` -> for standing up an isolated instance without flashing an ISO. - -**Do not probe with `seed.status`.** The original audit's C-6 command used it; `seed.status` is -**not** in `UNAUTHENTICATED_METHODS`, so it returns 401 by design and would report the surface -closed while the real door stands open. Probe with a method that is genuinely on the -unauthenticated list. - -**Non-regression, equally important:** a *fresh, un-onboarded* node must still complete -onboarding. The gate distinguishes provisioned from fresh; a fix that refuses on a fresh node -bricks first boot fleet-wide. - ---- - -## 5. Tier 2 — ISO build host 🔒 - -Full procedure: `docs/security/KEY-02-ROOTFS-EVIDENCE.md` (C-4). - -```bash -UNBUNDLED=1 bash image-recipe/build-debian-iso.sh --rebuild -# then follow steps 2/4/5/6 in KEY-02-ROOTFS-EVIDENCE.md -``` - -**Claim.** The shipped rootfs tar contains no SSH host keys, no TLS private key, and no -machine-id — so no two nodes flashed from one image can share them. - -**Gotcha, recorded because it will waste your afternoon:** read `RECIPE_HASH` from -`image-recipe/build/auto-installer/archipelago-rootfs.recipe.sha256`, **not** by hashing the -repo file. The wrapper rewrites 35 path expressions and absolutises `SCRIPT_DIR` before exec, -so the hash is host- and checkout-specific. - -**Note the inverted expectation.** The original audit expected these artefacts to be *present*. -This check passes when they are *absent*. - ---- - -## 6. Tier 3 — two physical nodes 🔒 - -**C-3 — host-key uniqueness.** Flash two machines from the *same* ISO, then compare: -```bash -# on each node -sha256sum /etc/ssh/ssh_host_*_key.pub -sha256sum /etc/ssl/private/ # path per the nginx config -cat /etc/machine-id -``` -Every value must differ between the two nodes. Any match is a finding. - -SSH host keys and the TLS key are equally sharp signals once the single-producer work lands -(before it, TLS had an installer fallback and SSH did not — see `KEY-02-ROOTFS-EVIDENCE.md`). - ---- - -## 7. Tier 4 — pre-release gate - -```bash -# ON the node, not over RPC — it uses local podman/systemctl/bitcoin probes -ARCHY_ITERATIONS=5 bash tests/lifecycle/run-gate.sh -``` -Install / UI / stop / start / restart / reinstall / reboot-survive / -archipelago-restart-survive / uninstall, 5× green. See `tests/lifecycle/TESTING.md`. - -Frontend: `cd neode-ui && npm run test` (vitest) and `npm run build`. -Rust: `cd core && cargo test -p archipelago`. - ---- - -## 8. Known-accepted risks - -Recorded so an auditor does not have to discover them by reading commit messages. - -| Risk | Decision | Where | -|---|---|---| -| A node whose first-boot secret generation can never succeed will not serve TLS | Accepted. Mitigated by a build-time assertion on generator binaries, retry-with-backoff, and self-heal on subsequent boots — leaving genuinely-broken hardware as the residual | `10-03` | -| Rotating host keys on already-deployed nodes invalidates `known_hosts` fleet-wide | Accepted, rated one-way, gated behind a decision checkpoint | D-06, `10-04` | -| KEY-01's fix ships on the next scheduled OTA, not an emergency release | Deliberate. The Critical finding stays live on the fleet until that OTA | D-10 | -| `#[cfg(test)]` code implements `rand::CryptoRng` falsely | Accepted until KEY-05 retires it; contained to test builds | `seed.rs:656` | - ---- - -## 9. Reporting a finding - -If any check above fails, or you find something not covered: the audit format that produced this -work is `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — evidence as `file:line`, an explicit -severity, and a stated confidence. Findings that cannot be verified without hardware belong in an -UNVERIFIED section rather than being asserted. - -Two corrections in that document are worth reading as calibration, because both were ours: F-10 -**understated** its scope by a factor of 20, and the correction to it then **overstated** the -severity of two files within a day. Both are struck in place rather than rewritten. diff --git a/docs/security/PSBT-SIGNING-ARCHITECTURE.md b/docs/security/PSBT-SIGNING-ARCHITECTURE.md index d1b64b84..71e0a2bf 100644 --- a/docs/security/PSBT-SIGNING-ARCHITECTURE.md +++ b/docs/security/PSBT-SIGNING-ARCHITECTURE.md @@ -23,7 +23,7 @@ > - **§5 (LND) is unaffected and remains accurate**, including **§5.4's honesty table**, which is > correct as written and unchanged. > -> **For the current state, read `docs/security/KEY-03-SIGNING-POSTURE.md`** — it records the +> **Note:** the current signing-posture record is maintained internally. It records the > deletion with its evidence, an honest per-step coverage map of the LND PSBT round trip, and the > verdict on whether an external signer can sign a default node's PSBT today (it cannot: no fleet > node is provisioned watch-only). Phases 2-7 below are unaffected as design targets. @@ -32,15 +32,14 @@ > a phased rollout that a future `/gsd-plan-phase` can consume directly. It deliberately > contains no code, adds no dependencies, and changes no wallet or signing behaviour. > -> **Companion document:** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the entropy and -> seed-generation audit that motivated this spec. **Cross-linked design:** +> **Companion document:** the internal entropy and seed-generation audit that +> motivated this spec. **Cross-linked design:** > `docs/hardware-signer-design.md` — the exploratory TROPIC01 air-gapped signer, which this > architecture treats as the future *first-party* signer, not as a competing design. **Provenance rules used throughout.** Every architectural claim is grounded in either (a) a `file:line` from this tree, or (b) RESEARCH.md Part C -(`.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md`, -which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the +(which cites Bitcoin Core `doc/psbt.md`, `doc/descriptors.md`, `doc/multisig-tutorial.md`, the Core 30.0 release notes, LND `docs/remote-signing.md` and `docs/psbt.md`). Anything from neither is marked `[UNVERIFIED]`. @@ -426,7 +425,7 @@ nagged-at that users stop reading warnings. Concretely: - **A software fix does not repair an already-generated seed.** If a seed was produced by a defective RNG, updating the software leaves it exactly as guessable. This is why Coinkite told users to migrate rather than merely update. -- **The audit found no such defect in Archipelago.** `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` +- **The audit found no such defect in Archipelago.** The internal entropy audit's §2 and §4 record that every first-party key-generation call site draws from a genuine CSPRNG, that the mnemonic is a real 256-bit value, and that `[ARCHY-1]` is a *structural* risk with no present exploitability. @@ -610,11 +609,10 @@ current security posture and should not wait for the rest. ## 9. Related documents -- `docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md` — the audit motivating this spec; see F-05 +- The internal entropy and seed-generation audit — motivating this spec; see F-05 (Argon2 parameters) and the F-13 addendum on the xprv-in-Core issue. - `docs/hardware-signer-design.md` — the first-party TROPIC01 air-gapped signer; §4.3 above answers two of its open items. - `docs/adr/005-chacha20-backup-encryption.md` — the at-rest envelope §6 reuses. -- `.planning/quick/260731-upz-research-coinkite-conkite-low-entropy-ha/260731-upz-RESEARCH.md` — Part C is the source for the Core RPC table, the LND capability matrix, and the air-gap format comparison. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 70079225..c024c189 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,6 +1,6 @@ # Archipelago Troubleshooting Guide -This guide covers the 20 most common issues you may encounter with Archipelago, along with diagnostic commands and solutions. +This guide covers the most common issues you may encounter with Archipelago, along with diagnostic commands and solutions. ## Connection & Access @@ -46,7 +46,11 @@ curl -s -X POST http://localhost:5678/rpc/v1 \ ``` **Solutions**: -- Default password is `password123` — change it after first login +- There is no default password — the password is the one you created on this + node's first-boot "Set Up Your Node" screen. Password recovery requires SSH + access to the node; note that simply deleting `/var/lib/archipelago/user.json` + does **not** work, because the onboarding gate refuses `auth.setup` once the + node is provisioned - Clear browser cookies and try again (stale session cookie) - Restart the backend: `sudo systemctl restart archipelago` - Check if the database is accessible: `ls -la /var/lib/archipelago/` @@ -107,9 +111,12 @@ df -h /var/lib/archipelago **Solutions**: - If the image is missing: reinstall the app from the Marketplace -- If disk is full: run disk cleanup from Settings, or manually `podman system prune` +- If disk is full: run disk cleanup from the **Server** page (`/server`), or manually `podman system prune` - If the container exits immediately: check logs for the root cause (usually missing config or permissions) -- Restart podman: `sudo systemctl restart podman` +- Restart the Podman socket. Archipelago runs **rootless** Podman as the + `archipelago` user, so this is a `--user` unit — `sudo systemctl restart podman` + would restart the unrelated root socket: + `systemctl --user restart podman.socket` ### 6. App shows "unhealthy" status @@ -142,20 +149,34 @@ podman logs --tail 100 | grep -i error # Check Bitcoin logs podman logs bitcoin-knots --tail 50 -# Check if Bitcoin is connected to peers -podman exec bitcoin-knots bitcoin-cli -datadir=/data getpeerinfo | grep -c '"addr"' +# Check if Bitcoin is connected to peers. +# The datadir inside the container is /home/bitcoin/.bitcoin, and the RPC +# credentials live in the generated /tmp/rpc.conf (the manifest's entrypoint +# writes it from the BITCOIN_RPC_USER/BITCOIN_RPC_PASS secrets) — bitcoin-cli +# needs both flags or it can't authenticate. +podman exec bitcoin-knots bitcoin-cli \ + -datadir=/home/bitcoin/.bitcoin -conf=/tmp/rpc.conf \ + getpeerinfo | grep -c '"addr"' # Check sync progress -podman exec bitcoin-knots bitcoin-cli -datadir=/data getblockchaininfo | grep -E "blocks|headers|verificationprogress" +podman exec bitcoin-knots bitcoin-cli \ + -datadir=/home/bitcoin/.bitcoin -conf=/tmp/rpc.conf \ + getblockchaininfo | grep -E "blocks|headers|verificationprogress" ``` **Solutions**: - Initial sync takes 1-7 days depending on hardware — be patient - Ensure the server has a stable internet connection -- Check disk space: Bitcoin requires 600GB+ for full chain +- Check disk space. The manifest picks the mode from the disk it's given: under + 1000 GB it runs **pruned** (`-prune=550`, a few GB); at 1000 GB or more it runs + a full `-txindex=1` archival node, which needs 600 GB+ and growing - If stuck: restart the container `podman restart bitcoin-knots` - If peers = 0: check firewall allows port 8333 outbound -- Add manual peers: edit bitcoin.conf to add `addnode=` entries +- Editing `bitcoin.conf` in the data directory has **no effect** — the + entrypoint runs bitcoind with an explicit `-conf=/tmp/rpc.conf` and logs + "ignoring legacy datadir bitcoin.conf". Flags come from the app manifest, so + persistent changes belong there (and, for catalog-covered apps, in the signed + catalog entry that overrides the on-disk manifest) ### 8. LND won't connect to Bitcoin @@ -250,7 +271,9 @@ df -h / - Ensure at least 2GB free disk space - If update hangs: wait 10 minutes, then restart the backend - Do NOT power off during an update — this can corrupt the system -- If system is in a bad state after failed update: boot from the USB installer and select "Repair" +- If the system is in a bad state after a failed update, recover over SSH — the + USB installer has no repair mode (its boot menu offers only "Install + Archipelago", "Install Archipelago (verbose)" and "Boot from local disk") ### 12. Server won't boot after update @@ -259,8 +282,11 @@ df -h / **Solutions**: - Wait 5 minutes — the first boot after update may take longer - If still unresponsive: connect a monitor/keyboard to check boot messages -- Try the recovery mode: boot from USB installer and select "Repair" -- As a last resort: reflash the USB and restore from backup +- If it's a bootloader problem rather than a disk problem, boot the USB and pick + "Boot from local disk" to chainload the installed system +- As a last resort: reinstall from USB and restore from backup. The installer is + interactive — it asks for the target disk and requires typing `yes` — so + booting it does not by itself destroy the existing install --- @@ -308,21 +334,35 @@ xrandr --query 2>/dev/null || echo "No display server" **Symptoms**: Settings shows "Tor: Not configured" or the .onion address is missing +Tor is **not** a container — it's the host's Debian `tor` package, running as +`debian-tor`. Archipelago never touches it directly: it stages a torrc and asks +`archipelago-tor-helper` (a `.path` unit watching +`/var/lib/archipelago/tor-config/tor-action`) to install it and restart Tor. + **Diagnosis**: ```bash -# Check Tor container -podman ps --filter "name=tor" -podman logs tor --tail 20 +# Check the host Tor service and the helper that drives it +sudo systemctl status tor +sudo journalctl -u archipelago-tor-helper --since "10 minutes ago" -# Check if Tor hostname file exists -cat /var/lib/archipelago/tor/hidden_service/hostname 2>/dev/null +# Is the SOCKS port up? (this is the liveness check the backend itself uses) +nc -z 127.0.0.1 9050 && echo "Tor SOCKS OK" + +# The readable hostname copy the backend actually reads +cat /var/lib/archipelago/tor-hostnames/archipelago + +# The hidden-service dir itself (root-owned 0700 — needs sudo) +sudo cat /var/lib/tor/hidden_service_archipelago/hostname 2>/dev/null \ + || sudo cat /var/lib/archipelago/tor/hidden_service_archipelago/hostname ``` **Solutions**: - Tor takes 30-60 seconds to bootstrap — wait and refresh -- If Tor container is stopped: start it from the Apps page -- Check that the Tor data directory exists and has correct permissions -- Restart Tor: `podman restart tor` +- If `/var/lib/archipelago/tor-hostnames/archipelago` is missing but the + hidden-service dir has a `hostname`, the readable copy didn't sync — the + helper's `sync-hostnames` action rewrites it +- Check that the Tor data directory exists and is owned by `debian-tor` +- Restart Tor: `sudo systemctl restart tor` ### 16. Peers can't reach my node @@ -330,11 +370,11 @@ cat /var/lib/archipelago/tor/hidden_service/hostname 2>/dev/null **Diagnosis**: ```bash -# Check if Tor is running (needed for peer connectivity) -podman ps --filter "name=tor" +# Check if Tor is running (the fallback transport for peer connectivity) +sudo systemctl status tor # Check your Tor address -cat /var/lib/archipelago/tor/hidden_service/hostname +cat /var/lib/archipelago/tor-hostnames/archipelago # Test connectivity from the server side curl -s http://localhost:5678/rpc/v1 \ @@ -343,10 +383,13 @@ curl -s http://localhost:5678/rpc/v1 \ ``` **Solutions**: -- Ensure Tor is running (required for peer-to-peer communication) +- Tor is the last-resort transport, not the only one: peering prefers mesh + radio, then LAN, then FIPS, and only falls back to Tor. A peer stuck on + "unreachable" with Tor healthy usually means the higher transports are all + down too — check the FIPS anchor first - Tor circuits can be slow — connections may take 30+ seconds - Share your correct .onion address with peers -- Both nodes must have Tor running and be on the same federation +- Both nodes must be on the same federation ### 17. DNS resolution issues @@ -366,7 +409,7 @@ podman exec bitcoin-knots nslookup seed.bitcoin.sipa.be ``` **Solutions**: -- Configure DNS from Settings > Network: try Cloudflare (1.1.1.1) or Google (8.8.8.8) +- Configure DNS from the **Server** page (`/server`): try Cloudflare (1.1.1.1) or Google (8.8.8.8) - If using custom DNS, verify the server addresses are correct - Restart networking: `sudo systemctl restart systemd-resolved` @@ -414,7 +457,7 @@ podman system df ``` **Solutions**: -- Run disk cleanup from Settings +- Run disk cleanup from the **Server** page (`/server`) - Remove unused app data: `podman system prune -a` (WARNING: removes all stopped containers and unused images) - Move Bitcoin data to external drive if chain data is too large - Check for large log files: `du -sh /var/log/*/ | sort -rh` @@ -556,7 +599,8 @@ If the system is completely unresponsive: 1. **Power cycle**: Hold power button for 10 seconds, then turn back on 2. **Wait 5 minutes**: Services take time to start, especially if containers need to recover 3. **SSH in**: If web UI is down but SSH works, restart services manually -4. **USB recovery**: Boot from the Archipelago USB installer and select "Repair" +4. **Chainload the installed system**: Boot the Archipelago USB and pick "Boot + from local disk" — this rules out a broken bootloader 5. **Clean install + restore**: As last resort, do a fresh install and restore from backup ### Collecting Diagnostic Information diff --git a/docs/user-walkthrough.md b/docs/user-walkthrough.md index 502cce4f..b50b1860 100644 --- a/docs/user-walkthrough.md +++ b/docs/user-walkthrough.md @@ -91,13 +91,21 @@ The auto-installer handles everything: 2. Tap or click anywhere to proceed 3. A typing animation welcomes you: "Welcome, Noderunner" -### Step 8: Login Screen +### Step 8: Create Your Password -> **Screenshot**: The login screen with a password field and glass-morphism design. +> **Screenshot**: The "Set Up Your Node" screen with password and confirm-password fields, glass-morphism design. -1. Enter the default password: `password123` -2. Click "Login" -3. You'll be prompted to change this password immediately +**There is no default web password.** A freshly installed node has no user +account at all, so this screen shows a password-creation form rather than a +login form: + +1. Enter a password (minimum 8 characters) +2. Confirm it in the second field +3. Click "Set Up Node" + +Every boot after this one shows the normal login form and asks for the password +you chose here. Store it somewhere you can get back to — recovering it requires +SSH access to the node. ### Step 9: Choose Your Path (Onboarding) diff --git a/docs/workstream-b-signing-runbook.md b/docs/workstream-b-signing-runbook.md index 29c4ee00..c32b9747 100644 --- a/docs/workstream-b-signing-runbook.md +++ b/docs/workstream-b-signing-runbook.md @@ -1,12 +1,28 @@ # Workstream B — Signed app-catalog: completion runbook -**Status (2026-06-28):** The registry-distributed manifest pipeline is live — nodes fetch +**Status: ✅ COMPLETE** (runbook retained for re-running the ceremony — key +rotation, a new publisher, or a fresh release root). + +The ceremony described below has been performed. Verified 2026-08-08: + +- The anchor is **pinned** — `trust::anchor::RELEASE_ROOT_PUBKEY_HEX` is a + `Some(...)`, not `None`. +- `releases/app-catalog.json` carries a `signature` and a `signed_by` did:key. + +Everything below therefore describes how to *do* the ceremony, not work that is +outstanding. The one-way-door warning in "Why this is gated on you" still +applies in full to any re-run: once a binary pins an anchor, a catalog signed by +a different key is hard-rejected fleet-wide. + +--- + +**Original status (2026-06-28):** The registry-distributed manifest pipeline is live — nodes fetch `releases/app-catalog.json` from the OTA mirror and embed manifests (origin-wins, disk fallback). What remains for Workstream B is **authenticity**: pin the release-root anchor and ship a *signed* catalog so nodes can cryptographically verify the publisher. Today the catalog is **accepted unsigned** ("migration window") and the anchor is **unpinned** -(`core/archipelago/src/trust/anchor.rs:21` → `RELEASE_ROOT_PUBKEY_HEX = None`). Completing B is +(`core/archipelago/src/trust/anchor.rs` → `RELEASE_ROOT_PUBKEY_HEX = None`). Completing B is a coordinated ceremony that **only the publisher can run** — it needs the offline `RELEASE_MASTER_MNEMONIC`, which is not (and must not be) stored on any node or build host. diff --git a/image-recipe/INTEGRATION-GUIDE.md b/image-recipe/INTEGRATION-GUIDE.md deleted file mode 100644 index 7ebb0ced..00000000 --- a/image-recipe/INTEGRATION-GUIDE.md +++ /dev/null @@ -1,195 +0,0 @@ -# Live Server to ISO Build Integration Guide - -This document explains how to keep the ISO build synchronized with the live development server. - -## Development Workflow - -### 1. Develop and Test on Live Server - -```bash -# Make changes locally -vim core/archipelago/src/... - -# Deploy to live server for testing -./scripts/deploy-to-target.sh --live - -# Test at http://192.168.1.228 -# Check logs: ssh archipelago@192.168.1.228 'sudo journalctl -u archipelago -f' -``` - -### 2. Capture System Changes - -When you make system-level changes on the live server (nginx config, systemd service, etc.): - -```bash -cd image-recipe -./sync-from-live.sh -``` - -This automatically captures: -- `/etc/systemd/system/archipelago.service` → `configs/archipelago.service` -- `/etc/nginx/sites-available/archipelago` → `configs/nginx-archipelago.conf` -- `/etc/logrotate.d/archipelago` → `configs/logrotate.conf` - -### 3. Build New ISO - -```bash -# Build backend and frontend -./scripts/build-backend.sh -./scripts/build-frontend.sh - -# Build ISO with latest changes -./build-debian-iso.sh - -# Test in QEMU -./test-iso-qemu.sh -``` - -### 4. Verify Integration - -The ISO build script should: -1. Copy `configs/archipelago.service` to `/etc/systemd/system/` -2. Copy `configs/nginx-archipelago.conf` to `/etc/nginx/sites-available/archipelago` -3. Create symlink: `/etc/nginx/sites-enabled/archipelago` -4. Enable the service: `systemctl enable archipelago` -5. Install backend to `/usr/local/bin/archipelago` -6. Install frontend to `/opt/archipelago/web-ui/` - -## Critical Configuration Settings - -### Backend Service (archipelago.service) - -**Must-have settings**: -```ini -[Service] -User=root # Required for root Podman access -Environment="ARCHIPELAGO_BIND=127.0.0.1:5678" # Backend API port -Environment="ARCHIPELAGO_DEV_MODE=true" # Enable container auto-detection -``` - -**Why root?**: The backend must run as root to access containers started with `sudo podman`. Containers in root Podman context are invisible to rootless Podman. - -### Nginx Configuration (nginx-archipelago.conf) - -**Must-have proxies**: -```nginx -location /rpc/ { - proxy_pass http://127.0.0.1:5678; # Backend RPC endpoint -} - -location /ws { - proxy_pass http://127.0.0.1:5678; # WebSocket for real-time updates - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; -} -``` - -## File Paths Reference - -### Build Artifacts -- `build/backend/archipelago` - Compiled Rust backend -- `build/frontend/` - Built Vue.js frontend -- `configs/` - System configuration files -- `results/` - Built ISO images - -### Live Server Paths -- `/usr/local/bin/archipelago` - Backend binary -- `/opt/archipelago/web-ui/` - Frontend files -- `/etc/systemd/system/archipelago.service` - Service definition -- `/etc/nginx/sites-available/archipelago` - Nginx config -- `/var/lib/archipelago/` - Application data - -### ISO Installation Paths -Same as live server (above) - the ISO must replicate the exact file structure. - -## Container Management - -### Root vs Rootless Podman - -**Current approach**: Root Podman -- Containers started with: `sudo podman run ...` -- Backend runs as: `root` user (in systemd) -- Container detection: Works automatically in dev mode - -**Why not rootless?** -- Would require `User=archipelago` in systemd service -- All containers must be started as `archipelago` user -- More complex permission management - -### Container Detection - -The backend automatically detects running containers when: -1. `ARCHIPELAGO_DEV_MODE=true` is set -2. Backend runs with same privileges as container runtime -3. Containers exist in accessible Podman context - -## Troubleshooting - -### Issue: Containers not detected in ISO - -**Cause**: Backend not running as root, or dev mode disabled - -**Fix**: -1. Check `configs/archipelago.service` has `User=root` -2. Check `Environment="ARCHIPELAGO_DEV_MODE=true"` is set -3. Rebuild ISO and test - -### Issue: UI not loading - -**Cause**: Nginx config not copied or frontend files missing - -**Fix**: -1. Verify `configs/nginx-archipelago.conf` exists -2. Check frontend built to `build/frontend/` -3. Verify ISO build script copies these files - -### Issue: Backend won't start - -**Cause**: Binary permissions or missing dependencies - -**Fix**: -1. Check backend binary is executable: `chmod +x /usr/local/bin/archipelago` -2. Check dependencies installed (Podman, nginx) -3. Review systemd logs: `journalctl -u archipelago` - -## Testing Checklist - -Before releasing an ISO, verify: - -- [ ] Boot ISO in QEMU -- [ ] Systemd service starts: `systemctl status archipelago` -- [ ] Backend responds: `curl http://localhost:5678/health` -- [ ] UI accessible: Open browser to `http://localhost` -- [ ] Container detection: `sudo podman run -d --name test nginx` → Shows in UI -- [ ] RPC works: Test login and API calls -- [ ] WebSocket connects: Check browser console - -## Automated Build Pipeline (Future) - -To automate this workflow: - -1. **CI/CD Integration** - - Trigger on main branch commits - - Run `sync-from-live.sh` with credentials - - Build backend and frontend - - Build ISO - - Upload to releases - -2. **Version Management** - - Tag releases with semantic versions - - Include git commit hash in ISO metadata - - Track which configs were included - -3. **Testing Automation** - - Boot ISO in headless QEMU - - Run API tests - - Verify container detection - - Generate test report - -## Resources - -- Development Workflow Rules: `.cursor/rules/Development-Workflow.mdc` -- Build Checklist: `ISO-BUILD-CHECKLIST.md` -- Architecture Docs: `.cursor/rules/Architecture.mdc` -- Deployment Scripts: `scripts/deploy-to-target.sh` diff --git a/image-recipe/README.md b/image-recipe/README.md index e45d193f..a62632cb 100644 --- a/image-recipe/README.md +++ b/image-recipe/README.md @@ -8,7 +8,6 @@ Build scripts for creating bootable Debian Linux OS images for Archipelago Bitco ```bash # 1. Sync latest configs from live dev server -./sync-from-live.sh # 2. Build components ./scripts/build-backend.sh diff --git a/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml b/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml index a05c7cd8..e674ead3 100644 --- a/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml +++ b/image-recipe/_archived/.gitea-workflows/build-iso-dev.yml @@ -112,7 +112,7 @@ jobs: run: | sudo mkdir -p /etc/containers/registries.conf.d echo '[[registry]] - location = "146.59.87.168:3000" + location = "source.archipelago-foundation.org" insecure = true' | sudo tee /etc/containers/registries.conf.d/archipelago.conf - name: Build unbundled ISO @@ -243,7 +243,7 @@ jobs: # Build download base URL (FileBrowser serves from /Builds/) HOST=$(hostname -I 2>/dev/null | awk '{print $1}') - BASE_URL="http://${HOST:-192.168.1.228}:8083/Builds/releases/v${VERSION}" + BASE_URL="http://${HOST:-192.0.2.10}:8083/Builds/releases/v${VERSION}" # Generate manifest JSON python3 -c " diff --git a/image-recipe/_archived/BUILD-ISO-STATUS.md b/image-recipe/_archived/BUILD-ISO-STATUS.md index d90b3007..cd4fda97 100644 --- a/image-recipe/_archived/BUILD-ISO-STATUS.md +++ b/image-recipe/_archived/BUILD-ISO-STATUS.md @@ -18,7 +18,7 @@ The script will automatically: ```bash # From your Mac (captures from remote dev server): cd image-recipe -DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh +DEV_SERVER=archipelago@192.0.2.10 sudo bash build-auto-installer-iso.sh # From the dev server itself: cd ~/archy/image-recipe @@ -40,7 +40,7 @@ BUILD_FROM_SOURCE=1 sudo bash build-auto-installer-iso.sh ```bash # Instead of building on the server, build from your Mac: cd ~/Projects/archy/image-recipe -DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh +DEV_SERVER=archipelago@192.0.2.10 sudo bash build-auto-installer-iso.sh ``` ### Issue: Podman registry not configured @@ -49,7 +49,7 @@ DEV_SERVER=archipelago@192.168.1.228 sudo bash build-auto-installer-iso.sh **Fix**: ```bash -ssh archipelago@192.168.1.228 +ssh archipelago@192.0.2.10 sudo tee -a /etc/containers/registries.conf </dev/null && cat > "$REGCONF_DIR/archipelago.conf" 2>/dev/null <<'REGCONF' [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true REGCONF then @@ -241,7 +241,7 @@ mkdir -p "$OUTPUT_DIR" container_pull() { local image="$1" - if [[ "$CONTAINER_CMD" == podman* && "$image" == 146.59.87.168:3000/* ]]; then + if [[ "$CONTAINER_CMD" == podman* && "$image" == source.archipelago-foundation.org/* ]]; then $CONTAINER_CMD pull --tls-verify=false --platform "$CONTAINER_PLATFORM" "$image" else $CONTAINER_CMD pull --platform "$CONTAINER_PLATFORM" "$image" @@ -1270,7 +1270,7 @@ fi # copies everything in archipelago/bin/ to /usr/local/bin, and the mesh # listener spawns /usr/local/bin/archy-reticulum-daemon for RNode radios — # a node imaged without it can never connect a Reticulum stick -# (framework-pt, 2026-07-22: silent connect failures until hand-copied). +# (a test node, 2026-07-22: silent connect failures until hand-copied). RETICULUM_DAEMON="${ARCHY_RETICULUM_DAEMON:-/usr/local/bin/archy-reticulum-daemon}" if [ -f "$RETICULUM_DAEMON" ]; then cp "$RETICULUM_DAEMON" "$ARCH_DIR/bin/archy-reticulum-daemon" @@ -1283,7 +1283,7 @@ fi # archy-rnodeconf drives the in-app "Flash LoRa" flow for RNode firmware # (mesh/flash.rs spawns /usr/local/bin/archy-rnodeconf --autoinstall). A node # imaged without it fails every RNode flash with "No such file or directory" -# (framework-pt, 2026-07-29, v1.7.117). +# (a test node, 2026-07-29, v1.7.117). RNODECONF="${ARCHY_RNODECONF:-/usr/local/bin/archy-rnodeconf}" if [ -f "$RNODECONF" ]; then cp "$RNODECONF" "$ARCH_DIR/bin/archy-rnodeconf" @@ -1330,11 +1330,11 @@ fi # Extract nostr-rs-relay binary from container image (native system service for VPN signaling) echo " Extracting nostr-rs-relay binary..." -RELAY_IMAGE="$($CONTAINER_CMD images -q 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" +RELAY_IMAGE="$($CONTAINER_CMD images -q source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null)" if [ -z "$RELAY_IMAGE" ]; then - $CONTAINER_CMD pull 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true + $CONTAINER_CMD pull source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null || true fi -RELAY_CONTAINER=$($CONTAINER_CMD create 146.59.87.168:3000/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true +RELAY_CONTAINER=$($CONTAINER_CMD create source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0 2>/dev/null) || true if [ -n "$RELAY_CONTAINER" ]; then # The relay image builds to its WORKDIR /usr/src/app and execs # ./nostr-rs-relay from there (not /usr/local/bin — that path was from an @@ -1359,7 +1359,7 @@ if [ -n "$MISSING_VPN_BINARIES" ]; then echo " ⚠ Building WITHOUT:$MISSING_VPN_BINARIES (ALLOW_MISSING_VPN_BINARIES=1)" else echo " ❌ Required binaries not extracted:$MISSING_VPN_BINARIES" - echo " The registry (146.59.87.168:3000) must be reachable and hold the images," + echo " The registry (source.archipelago-foundation.org) must be reachable and hold the images," echo " or set ALLOW_MISSING_VPN_BINARIES=1 to ship without VPN signaling." exit 1 fi @@ -2512,7 +2512,7 @@ if [ -f /var/lib/archipelago/tor-hostnames/bitcoin ]; then BOOTSTRAP_ONION=$(cat /var/lib/archipelago/tor-hostnames/bitcoin 2>/dev/null) fi if [ -n "$BOOTSTRAP_RPC_PASS" ]; then - DEV_IP="${DEV_SERVER:-192.168.1.228}" + DEV_IP="${DEV_SERVER:-192.0.2.10}" cat > "$ARCH_DIR/bootstrap.conf" < /mnt/target/home/archipelago/.config/containers/registries.conf <<'REGCONF unqualified-search-registries = ["docker.io"] [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true [[registry]] -location = "146.59.87.168:3000" +location = "source.archipelago-foundation.org" insecure = true REGCONF chown -R 1000:1000 /mnt/target/home/archipelago/.config @@ -3042,8 +3042,8 @@ mkdir -p /mnt/target/var/lib/archipelago/config cat > /mnt/target/var/lib/archipelago/config/registries.json <<'DYNREG' { "registries": [ - {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, - {"url": "146.59.87.168:3000/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10} + {"url": "source.archipelago-foundation.org/lfg2025", "name": "Archipelago Primary", "tls_verify": false, "enabled": true, "priority": 0}, + {"url": "source.archipelago-foundation.org/lfg2025", "name": "Archipelago Fallback", "tls_verify": true, "enabled": true, "priority": 10} ] } DYNREG @@ -3217,7 +3217,7 @@ if [ -d "$REPO_DIR/.git" ]; then exit 0 # Already cloned fi echo "[update] Cloning Archipelago repo for self-updates..." -su - archipelago -c "git clone https://146.59.87.168:3000/lfg2025/archy $REPO_DIR" 2>/dev/null || { +su - archipelago -c "git clone https://source.archipelago-foundation.org/lfg2025/archy $REPO_DIR" 2>/dev/null || { echo "[update] Git clone failed (network?). Updates will retry on next boot." exit 0 } @@ -3266,7 +3266,8 @@ if [ -t 0 ] && [ -z "$ARCHIPELAGO_WELCOMED" ]; then if [ -n "$IP" ]; then echo -e " ${W}web ui http://$IP${N}" echo -e " ${W}ssh archipelago@$IP${N}" - echo -e " ${W}password archipelago (SSH) / password123 (Web)${N}" + echo -e " ${W}password archipelago (SSH)${N}" + echo -e " ${OD}web ui asks you to create a password on first visit${N}" else echo -e " ${OD}Waiting for network...${N}" fi @@ -4102,7 +4103,7 @@ p "${ORANGE} http://${NC}" echo "" p "${WHITE} SSH ssh archipelago@${NC}" p "${WHITE} Password archipelago${NC}" -p "${WHITE} Web Login password123${NC}" +p "${WHITE} Web Login create your password on first visit${NC}" echo "" hrule echo "" diff --git a/image-recipe/_archived/build-unbundled-iso.sh b/image-recipe/_archived/build-unbundled-iso.sh index 5019f6c0..5f2c8ef2 100755 --- a/image-recipe/_archived/build-unbundled-iso.sh +++ b/image-recipe/_archived/build-unbundled-iso.sh @@ -16,14 +16,14 @@ # # Usage: # sudo ./build-unbundled-iso.sh -# DEV_SERVER=archipelago@192.168.1.228 sudo ./build-unbundled-iso.sh +# DEV_SERVER=archipelago@192.0.2.10 sudo ./build-unbundled-iso.sh # BUILD_FROM_SOURCE=1 sudo ./build-unbundled-iso.sh # set -e # Configuration -DEV_SERVER="${DEV_SERVER:-archipelago@192.168.1.228}" +DEV_SERVER="${DEV_SERVER:-archipelago@192.0.2.10}" BUILD_FROM_SOURCE="${BUILD_FROM_SOURCE:-0}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" diff --git a/image-recipe/configs/archipelago-host-secrets-audit.service b/image-recipe/configs/archipelago-host-secrets-audit.service index a4a87ab6..bcf07e2d 100644 --- a/image-recipe/configs/archipelago-host-secrets-audit.service +++ b/image-recipe/configs/archipelago-host-secrets-audit.service @@ -13,7 +13,7 @@ Type=oneshot # /var/lib/archipelago/host-secrets-audit.json, all of which are root-owned. User=root # DETECT ONLY. D-06 chose detect-report-then-apply -# (docs/security/KEY-02-FLEET-ROTATION.md): rotation is one-way and must never +# rotation is one-way and must never # fire unattended across the fleet during an OTA. There is deliberately NO # --apply here. Adding one is a decision, not a configuration change. ExecStart=-/opt/archipelago/scripts/security/host-secrets-audit.sh --detect diff --git a/image-recipe/configs/archipelago-kiosk.service b/image-recipe/configs/archipelago-kiosk.service index 2b53a8e0..421ed5f8 100644 --- a/image-recipe/configs/archipelago-kiosk.service +++ b/image-recipe/configs/archipelago-kiosk.service @@ -34,7 +34,7 @@ RestartSec=5 # also binds the chromium/Xorg children in this unit's cgroup. # CPUQuota=75% (0.75 cores) was too tight even for normal playback — the kiosk # was throttled ~40% of the time, which is what caused choppy HDMI audio on -# archy-x250-exp (2026-06-28 incident). 200% (2 cores) gives enough headroom. +# a test node (2026-06-28 incident). 200% (2 cores) gives enough headroom. Delegate=yes CPUQuota=200% # Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used / diff --git a/image-recipe/configs/archipelago.service b/image-recipe/configs/archipelago.service index e3dd33b7..e99af66f 100644 --- a/image-recipe/configs/archipelago.service +++ b/image-recipe/configs/archipelago.service @@ -32,7 +32,7 @@ ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:a ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh ExecStart=/usr/local/bin/archipelago # always (not on-failure): the OTA restart path once stopped the daemon -# cleanly and the queued start never fired (framework-pt, v1.7.114->115, +# cleanly and the queued start never fired (a test node, v1.7.114->115, # 2026-07-26) — the node sat dead all night behind "server starting up". # Restart=always self-heals any lost start job; an explicit # `systemctl stop` is still honored (systemd never auto-restarts after diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index e699bb0a..375ad0ef 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -11,7 +11,7 @@ server { listen 80 default_server; # IPv6 listener is REQUIRED: companion phones reach this node over the # FIPS mesh at its fips0 ULA (http://[fdxx:…]) — without [::]:80 that - # address can never connect (found live 2026-07-23, framework-pt). + # address can never connect (found live 2026-07-23, a test node). listen [::]:80 default_server; server_name _; diff --git a/image-recipe/dev-branding.sh b/image-recipe/dev-branding.sh index 57bef6a4..4a8f7522 100755 --- a/image-recipe/dev-branding.sh +++ b/image-recipe/dev-branding.sh @@ -16,7 +16,7 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" WORK="/tmp/archipelago-dev-branding" PATCHED="$SCRIPT_DIR/results/archipelago-dev-patched.iso" CACHED_ISO="$SCRIPT_DIR/results/archipelago-dev-base.iso" -DEV_SERVER="archipelago@192.168.1.228" +DEV_SERVER="${DEV_SERVER:?DEV_SERVER must be set, e.g. archipelago@}" SSH_KEY="$HOME/.ssh/archipelago-deploy" echo "" diff --git a/image-recipe/scripts/install-podman.sh b/image-recipe/scripts/install-podman.sh index e772a4e0..c252ce20 100755 --- a/image-recipe/scripts/install-podman.sh +++ b/image-recipe/scripts/install-podman.sh @@ -40,10 +40,10 @@ EOF # Configure registries (use Docker Hub and quay.io) mkdir -p /home/archipelago/.config/containers/registries.conf.d cat > /home/archipelago/.config/containers/registries.conf < "$CONFIG_DIR/archipelago.service" -echo " ✅ Saved to configs/archipelago.service" - -# Sync nginx configuration -echo "📋 Capturing nginx configuration..." -ssh "$TARGET_HOST" 'sudo cat /etc/nginx/sites-available/archipelago' > "$CONFIG_DIR/nginx-archipelago.conf" -echo " ✅ Saved to configs/nginx-archipelago.conf" - -# Sync logrotate if it exists -if ssh "$TARGET_HOST" 'sudo test -f /etc/logrotate.d/archipelago'; then - echo "📋 Capturing logrotate configuration..." - ssh "$TARGET_HOST" 'sudo cat /etc/logrotate.d/archipelago' > "$CONFIG_DIR/logrotate.conf" - echo " ✅ Saved to configs/logrotate.conf" -fi - -# Check for custom scripts -echo "" -echo "📋 Checking for custom scripts..." -if ssh "$TARGET_HOST" 'sudo test -d /opt/archipelago/scripts'; then - SCRIPT_COUNT=$(ssh "$TARGET_HOST" 'sudo ls /opt/archipelago/scripts/ 2>/dev/null | wc -l' | tr -d ' ') - if [ "$SCRIPT_COUNT" -gt 0 ]; then - echo " ⚠️ Found $SCRIPT_COUNT script(s) in /opt/archipelago/scripts/" - echo " Review and manually sync if needed" - ssh "$TARGET_HOST" 'sudo ls -lh /opt/archipelago/scripts/' - else - echo " ✅ No custom scripts found" - fi -else - echo " ✅ No custom scripts directory" -fi - -# Summary -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Sync Complete! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "Configuration files captured:" -ls -lh "$CONFIG_DIR" -echo "" -echo "Next steps:" -echo " 1. Review the captured configurations" -echo " 2. Build backend: ./scripts/build-backend.sh" -echo " 3. Build frontend: ./scripts/build-frontend.sh" -echo " 4. Update integration script to use these configs" -echo " 5. Build ISO: ./build-debian-iso.sh" -echo "" diff --git a/indeedhub b/indeedhub deleted file mode 160000 index 1e72c254..00000000 --- a/indeedhub +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 1e72c254bc1688a2106b295aa4d71d64e730a22a diff --git a/loop/loop.sh b/loop/loop.sh deleted file mode 100755 index 6667bb7d..00000000 --- a/loop/loop.sh +++ /dev/null @@ -1,187 +0,0 @@ -#!/usr/bin/env sh -# Headless loop script for overnight Claude Code automation. -# Rate-limit aware: detects limits, sleeps until reset, and retries automatically. -set -u - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -PROMPT_FILE="${PROMPT_FILE:-$PROJECT_DIR/loop/prompt.md}" -LOG_FILE="${LOG_FILE:-$PROJECT_DIR/loop/loop.log}" -ITERATION_COUNT="${ITERATION_COUNT:-10}" -ITERATION_DELAY="${ITERATION_DELAY:-30}" -CLAUDE_BIN="${CLAUDE_BIN:-claude}" -RATE_LIMIT_WAIT="${RATE_LIMIT_WAIT:-3600}" -MAX_RATE_LIMIT_RETRIES="${MAX_RATE_LIMIT_RETRIES:-5}" -CLAUDE_EXIT=0 - -cd "$PROJECT_DIR" - -log() { - echo "$1" | tee -a "$LOG_FILE" -} - -banner() { - log "" - log "================================================================" - log " $1" - log " $(date '+%Y-%m-%d %H:%M:%S')" - log "================================================================" - log "" -} - -section() { - log "" - log "----------------------------------------" - log " $1" - log "----------------------------------------" - log "" -} - -plan_has_tasks() { - grep -q '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null -} - -remaining_tasks() { - grep -c '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null || echo "0" -} - -next_task() { - grep -m1 '^\- \[ \]' "$PROJECT_DIR/loop/plan.md" 2>/dev/null | sed 's/^- \[ \] //' || echo "(none)" -} - -check_rate_limit() { - [ "${CLAUDE_EXIT:-0}" -eq 0 ] && return 1 - tail -50 "$LOG_FILE" 2>/dev/null | grep -v "^Rate limit detected" | grep -v "^Sleeping" | grep -v "^=" | grep -v "^-" | grep -qi \ - -e "rate.limit" \ - -e "too.many.requests" \ - -e "429" \ - -e "quota.exceeded" \ - -e "usage.limit" \ - -e "limit.reached" 2>/dev/null -} - -banner "WEB OVERNIGHT AUTOMATION STARTED" -log " Project: $PROJECT_DIR" -log " Prompt: $PROMPT_FILE" -log " Autonomous: ${CLAUDE_AUTONOMOUS:-0}" -log " Iterations: $ITERATION_COUNT (${ITERATION_DELAY}s between each)" -log " Rate limit: wait ${RATE_LIMIT_WAIT}s, retry up to ${MAX_RATE_LIMIT_RETRIES}x" -log " Tasks left: $(remaining_tasks)" -log " Next task: $(next_task)" -log "" - -i=1 -rate_limit_retries=0 -while [ "$i" -le "$ITERATION_COUNT" ]; do - - if ! plan_has_tasks; then - banner "ALL TASKS COMPLETE" - log " No remaining tasks in plan.md. Stopping." - break - fi - - section "ITERATION $i/$ITERATION_COUNT" - log " Tasks remaining: $(remaining_tasks)" - log " Next task: $(next_task)" - log "" - - export CLAUDE_PROJECT_DIR="$PROJECT_DIR" - export CLAUDE_AUTONOMOUS="${CLAUDE_AUTONOMOUS:-1}" - - if [ -f "$PROMPT_FILE" ]; then - log " Starting Claude session..." - log "" - "$CLAUDE_BIN" -p --dangerously-skip-permissions \ - < "$PROMPT_FILE" 2>&1 | tee -a "$LOG_FILE" - CLAUDE_EXIT=$? - log "" - log " Claude exited with code: $CLAUDE_EXIT" - else - log " ERROR: $PROMPT_FILE not found" - exit 1 - fi - - if check_rate_limit; then - rate_limit_retries=$((rate_limit_retries + 1)) - if [ "$rate_limit_retries" -ge "$MAX_RATE_LIMIT_RETRIES" ]; then - section "RATE LIMITED — SCHEDULING LAUNCHD RETRY" - log " Hit rate limit $rate_limit_retries times. Creating launchd job to retry later." - - PLIST_LABEL="com.web.overnight-retry" - PLIST_PATH="$HOME/Library/LaunchAgents/${PLIST_LABEL}.plist" - RETRY_TIME=$(date -v+${RATE_LIMIT_WAIT}S '+%H:%M' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M') - RETRY_HOUR=$(echo "$RETRY_TIME" | cut -d: -f1) - RETRY_MIN=$(echo "$RETRY_TIME" | cut -d: -f2) - - cat > "$PLIST_PATH" < - - - - Label - ${PLIST_LABEL} - ProgramArguments - - /bin/sh - -c - cd ${PROJECT_DIR} && caffeinate -i ./loop/loop.sh >> ${LOG_FILE} 2>&1; launchctl unload ${PLIST_PATH}; rm -f ${PLIST_PATH} - - StartCalendarInterval - - Hour - ${RETRY_HOUR} - Minute - ${RETRY_MIN} - - EnvironmentVariables - - CLAUDE_AUTONOMOUS - 1 - CLAUDE_PROJECT_DIR - ${PROJECT_DIR} - PATH - /usr/local/bin:/usr/bin:/bin:$HOME/.local/bin - - StandardOutPath - ${LOG_FILE} - StandardErrorPath - ${LOG_FILE} - - -PLIST - - launchctl load "$PLIST_PATH" 2>/dev/null || true - log " Scheduled retry at ~${RETRY_TIME}" - log " Plist: $PLIST_PATH (auto-removes after running)" - exit 0 - fi - - section "RATE LIMITED — WAITING" - log " Attempt $rate_limit_retries/$MAX_RATE_LIMIT_RETRIES" - log " Sleeping ${RATE_LIMIT_WAIT}s until $(date -v+${RATE_LIMIT_WAIT}S '+%H:%M:%S' 2>/dev/null || date -d "+${RATE_LIMIT_WAIT} seconds" '+%H:%M:%S')..." - sleep "$RATE_LIMIT_WAIT" - - if ! plan_has_tasks; then - banner "ALL TASKS COMPLETE (during rate limit wait)" - break - fi - log " Retrying..." - continue - fi - - rate_limit_retries=0 - - section "ITERATION $i COMPLETE" - log " Tasks remaining: $(remaining_tasks)" - log " Next task: $(next_task)" - - i=$((i + 1)) - if [ "$i" -le "$ITERATION_COUNT" ] && [ "$ITERATION_DELAY" -gt 0 ]; then - log " Pausing ${ITERATION_DELAY}s before next iteration..." - sleep "$ITERATION_DELAY" - fi -done - -banner "LOOP FINISHED" -log " Completed $((i - 1)) iterations" -log " Tasks remaining: $(remaining_tasks)" -log "" diff --git a/loop/plan.md b/loop/plan.md deleted file mode 100644 index 1d45fb27..00000000 --- a/loop/plan.md +++ /dev/null @@ -1,232 +0,0 @@ -# Overnight Plan — Container Resilience: Zero Failures - -> Deploy → pull apps → read logs → find failures → fix code → redeploy → retest → repeat until ZERO failures. -> Target: .228 (`ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228`). -> DO NOT PUSH — CI build in progress. Commit locally only. -> Follow CLAUDE.md strictly. Production-quality code. No unwrap(), no TODO, no hacks, no garbage. -> Every code change must be clean, well-structured, properly typed, and follow existing patterns. - ---- - -## Cycle 1: Baseline — Deploy and Discover Every Failure - -- [x] **C1-DEPLOY — Deploy current codebase to .228**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228` from macOS. If deploy script fails, read the error, fix the script, retry. After deploy succeeds, SSH to .228 and verify backend is alive: `sudo systemctl status archipelago` and `curl -s http://127.0.0.1:5678/health`. If backend is not running, check `journalctl -u archipelago --no-pager -n 100` and fix whatever is wrong. Do not mark done until: deploy succeeds AND backend returns health JSON. - -- [x] **C1-CONTAINERS — Check every single container**: SSH to .228. Run `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}\t{{.Ports}}'` to see ALL containers. For EVERY container that is not `running`: run `podman logs --tail 100` and record the error. For every container showing `(unhealthy)`: run `podman logs --tail 100` and record why. For containers that don't exist yet but should (bitcoin-knots, lnd, electrumx, archy-bitcoin-ui, archy-lnd-ui, archy-electrs-ui): note them as missing. Write a summary of ALL issues found as a comment at the bottom of this plan file under `## Issue Log`. Do not fix anything yet — just diagnose. Mark done when you have a complete picture of every container's state. - -- [x] **C1-APPS — Pull and start every Bitcoin stack app**: SSH to .228. For each app in the Bitcoin stack, ensure it exists and is running. Check: (1) `podman ps -a --filter name=bitcoin-knots` — if missing or stopped, check if the image exists (`podman images | grep bitcoin-knots`), if not pull it. Start or create the container using the spec from `scripts/container-specs.sh`. (2) Same for `lnd`. (3) Same for `electrumx`. (4) Same for `archy-bitcoin-ui`, `archy-lnd-ui`, `archy-electrs-ui`. After starting each container, immediately read its logs: `podman logs --tail 50`. Record every error. If a container won't start, record the exact error. If it starts but crashes within 30 seconds, record the crash log. Do not mark done until you have attempted to start ALL 6 containers and recorded the outcome of each. - -- [x] **C1-HEALTH — Deep health check of every running container**: SSH to .228. For each running Bitcoin stack container: (1) **bitcoin-knots**: `podman exec bitcoin-knots bitcoin-cli getblockchaininfo 2>&1` — record if RPC works or fails. Check `podman logs bitcoin-knots --tail 50` for any warnings/errors. (2) **lnd**: Check if it connects to Bitcoin backend — `podman logs lnd --tail 50 | grep -i 'error\|fail\|disconnect\|unable'`. (3) **electrumx**: Check if it connects to Bitcoin — `podman logs electrumx --tail 50 | grep -i 'error\|fail\|disconnect\|unable'`. (4) **archy-bitcoin-ui**: `curl -sf http://localhost:8334/ > /dev/null && echo OK || echo FAIL`. (5) **archy-lnd-ui**: `curl -sf http://localhost:8081/ > /dev/null && echo OK || echo FAIL`. (6) **archy-electrs-ui**: Find its port (`podman port archy-electrs-ui 2>/dev/null || echo 'not running'`) and curl it. Record EVERY failure. Do not mark done until every container has been health-checked and all results recorded in the Issue Log below. - ---- - -## Cycle 2: Fix Every Issue Found — Redeploy — Retest - -- [x] **C2-FIX — Fix every issue from Cycle 1**: Read the Issue Log at the bottom of this file. For EACH issue listed: (1) Read the relevant source code. (2) Understand the root cause. (3) Write a proper, production-quality fix — clean code, proper error handling, no hacks. (4) Commit with `fix: description`. Address ALL issues — do not cherry-pick. If a fix requires changing Rust code, make the change locally (it will be compiled on .228 during deploy). If a fix requires changing container specs, update `scripts/container-specs.sh`. If a fix requires changing a Dockerfile, update the relevant `docker/*/Dockerfile`. If a fix requires changing image versions, update `scripts/image-versions.sh`. If a fix requires changing nginx configs, update the relevant config file. Do not mark done until every issue from the log has a fix committed. - -- [x] **C2-DEPLOY — Redeploy with all fixes**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228`. If deploy fails, fix the deploy error and retry. After deploy, SSH to .228 and rebuild any UI containers that changed: `cd ~/archy/docker/bitcoin-ui && podman build -t bitcoin-ui:local . && podman stop archy-bitcoin-ui 2>/dev/null; podman rm archy-bitcoin-ui 2>/dev/null` — then recreate from spec. Same for lnd-ui and electrs-ui if their Dockerfiles changed. Do not mark done until deploy succeeds and backend health check passes. - -- [x] **C2-RETEST — Test everything again**: SSH to .228. Run the EXACT same checks as C1-CONTAINERS, C1-APPS, and C1-HEALTH. For EVERY container: `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'`. For every running container, read logs: `podman logs --tail 50 | grep -i 'error\|fail\|panic\|crash\|unable\|refused\|timeout'`. Curl every UI. Check every RPC endpoint. **If ANY new issues are found**: fix them right here — edit code, commit, redeploy to .228, and retest. Keep looping (fix → deploy → test) within this single task until ALL containers are running, ALL health checks pass, ALL UIs respond, ALL logs are clean. Do not mark done until: `podman ps -a --format '{{.Names}} {{.State}}' | grep -v running` returns ZERO non-running containers in the Bitcoin stack, and every curl returns 200, and every log tail has no errors. - ---- - -## Cycle 3: Resilience — Kill Every Container and Verify Recovery - -- [x] **C3-RESTART-BITCOIN — Kill Bitcoin Knots, verify auto-restart**: SSH to .228. Run `podman stop bitcoin-knots`. Wait 15 seconds. Check `podman ps --filter name=bitcoin-knots --format '{{.Names}} {{.State}}'`. It MUST be `running` (restarted by restart policy). If not running: (1) Check `podman inspect bitcoin-knots --format '{{.HostConfig.RestartPolicy.Name}}'` — must be `unless-stopped` or `always`. (2) If restart policy is wrong, fix `scripts/container-specs.sh`, recreate the container with correct policy. (3) Retest until bitcoin-knots auto-restarts after stop. After it restarts, verify RPC works: `podman exec bitcoin-knots bitcoin-cli getblockchaininfo`. Check logs for crash messages. **Loop fix → recreate → kill → verify until it works.** Do not mark done until bitcoin-knots survives a stop and auto-restarts within 30 seconds. - -- [x] **C3-RESTART-LND — Kill LND, verify auto-restart**: Same process. `podman stop lnd`. Wait 15 seconds. Verify it auto-restarts. Verify it reconnects to bitcoin-knots (check logs: `podman logs lnd --tail 20`). If it doesn't restart or can't reconnect: fix, recreate, retest. Loop until it works. Do not mark done until lnd auto-restarts and reconnects to Bitcoin. - -- [x] **C3-RESTART-ELECTRUMX — Kill ElectrumX, verify auto-restart**: Same. `podman stop electrumx`. Wait 15 seconds. Verify auto-restart. Verify it reconnects to bitcoin-knots. Fix → recreate → retest loop. Do not mark done until electrumx auto-restarts and reconnects. - -- [x] **C3-RESTART-UIS — Kill all UI containers, verify auto-restart**: `podman stop archy-bitcoin-ui archy-lnd-ui archy-electrs-ui`. Wait 15 seconds. Run `podman ps --format '{{.Names}} {{.State}}' | grep -E 'bitcoin-ui|lnd-ui|electrs-ui'` — all three must be `running`. Curl each UI endpoint — all must return 200. If any doesn't restart: fix restart policy, recreate, retest. Loop until all three survive kill and auto-restart. - -- [x] **C3-CASCADE — Kill Bitcoin, watch everything, restart, verify full recovery**: This is the critical test. `podman stop bitcoin-knots`. Wait 60 seconds. Check LND and ElectrumX: they should either stay running (waiting for Bitcoin) or enter unhealthy/restarting state — NOT crash permanently. Run `podman ps -a --format '{{.Names}} {{.State}} {{.Status}}' | grep -E 'bitcoin|lnd|electrumx'`. Now start Bitcoin: `podman start bitcoin-knots`. Wait 120 seconds for Bitcoin RPC to come up. Check ALL containers: `podman ps --format '{{.Names}} {{.State}} {{.Status}}' | grep -E 'bitcoin|lnd|electrumx'`. ALL must be `running`. Read logs of each: `podman logs lnd --tail 30` and `podman logs electrumx --tail 30` — should show reconnection, not permanent failure. If ANY container is stuck in a crash loop or permanently dead: read logs, diagnose root cause, fix the code/config, redeploy, retest the entire cascade. **Loop until the full cascade works**: stop Bitcoin → dependents survive → restart Bitcoin → everything recovers. Do not mark done until this passes cleanly. - -- [x] **C3-BACKEND-CRASH — Kill Archipelago backend, verify containers survive**: `sudo systemctl kill -s SIGKILL archipelago`. Wait 10 seconds. (1) Check backend restarted: `sudo systemctl status archipelago` — must be `active`. (2) Check containers: `podman ps --format '{{.Names}} {{.State}}' | grep -E 'bitcoin|lnd|electrumx'` — ALL must still be `running` (containers are independent of backend). (3) Check crash recovery: `journalctl -u archipelago --no-pager -n 50 | grep -i crash` — should show crash detected. (4) Check health endpoint: `curl -s http://127.0.0.1:5678/health` — should return JSON. If any of these fail: read full journal logs, find the error, fix the backend code, redeploy, retest. Loop until backend crash recovery works cleanly. - ---- - -## Cycle 4: Full Retest — Deploy Clean, Test Everything, Zero Failures - -- [x] **C4-CLEAN-DEPLOY — Fresh deploy with all accumulated fixes**: Run `./scripts/deploy-to-target.sh --target 192.168.1.228`. Rebuild UI containers on .228 if any Dockerfiles changed. Restart backend: `sudo systemctl restart archipelago`. Wait 30 seconds. This is the "clean slate" deploy with everything fixed from previous cycles. - -- [x] **C4-FULL-TEST — Complete test suite, fix anything that fails, loop until perfect**: SSH to .228. Run EVERY check below. If ANY fails, fix → redeploy → rerun ALL checks. Repeat until every single line passes: - - **Container state** (all must show `running`): - ``` - podman ps -a --format '{{.Names}} {{.State}}' | grep -E 'bitcoin-knots|lnd|electrumx|bitcoin-ui|lnd-ui|electrs-ui' - ``` - - **Container health** (none should show `unhealthy`): - ``` - podman ps --format '{{.Names}} {{.Status}}' | grep -E 'bitcoin-knots|lnd|electrumx' - ``` - - **Bitcoin RPC** (must return JSON with blockheight): - ``` - podman exec bitcoin-knots bitcoin-cli getblockchaininfo 2>&1 | head -5 - ``` - - **LND connection** (must show no errors): - ``` - podman logs lnd --tail 30 2>&1 | grep -i 'error\|fail\|unable\|refused' | head -10 - ``` - - **ElectrumX connection** (must show no errors): - ``` - podman logs electrumx --tail 30 2>&1 | grep -i 'error\|fail\|unable\|refused' | head -10 - ``` - - **UI endpoints** (all must return HTTP 200): - ``` - curl -sf http://localhost:8334/ > /dev/null && echo "bitcoin-ui OK" || echo "bitcoin-ui FAIL" - curl -sf http://localhost:8081/ > /dev/null && echo "lnd-ui OK" || echo "lnd-ui FAIL" - ``` - For electrs-ui, find port: `podman port archy-electrs-ui 2>/dev/null` - - **Backend health** (must return JSON): - ``` - curl -s http://127.0.0.1:5678/health - ``` - - **Restart policies** (all must be `unless-stopped` or `always`): - ``` - for c in bitcoin-knots lnd electrumx archy-bitcoin-ui archy-lnd-ui archy-electrs-ui; do - echo "$c: $(podman inspect $c --format '{{.HostConfig.RestartPolicy.Name}}' 2>/dev/null || echo 'NOT FOUND')" - done - ``` - - **Memory limits** (all must show non-zero): - ``` - for c in bitcoin-knots lnd electrumx archy-bitcoin-ui archy-lnd-ui archy-electrs-ui; do - echo "$c: $(podman inspect $c --format '{{.HostConfig.Memory}}' 2>/dev/null || echo 'NOT FOUND')" - done - ``` - - **Clean logs** (zero errors in last 30 lines of each): - ``` - for c in bitcoin-knots lnd electrumx; do - echo "=== $c ===" - podman logs $c --tail 30 2>&1 | grep -i 'error\|panic\|fatal\|crash' | head -5 - done - ``` - - **Kill-restart test** (all must auto-restart): - ``` - podman stop bitcoin-knots && sleep 20 && podman ps --filter name=bitcoin-knots --format '{{.State}}' - podman stop lnd && sleep 20 && podman ps --filter name=lnd --format '{{.State}}' - podman stop electrumx && sleep 20 && podman ps --filter name=electrumx --format '{{.State}}' - ``` - - **IF ANY CHECK FAILS**: Read the logs, find the root cause, fix the code properly (clean, well-structured, typed, following CLAUDE.md), commit with `fix:` prefix, redeploy to .228, and run ALL checks again from the top. Keep looping. Do not mark done until EVERY SINGLE CHECK above passes in a single clean run with zero failures. - ---- - -## Cycle 5: Soak — Let It Run, Watch for Drift - -- [x] **C5-SOAK — Wait 5 minutes, recheck everything**: SSH to .228. Wait 5 minutes (`sleep 300`). Then rerun every check from C4-FULL-TEST. Containers that pass immediately but fail after 5 minutes have stability issues (memory leaks, connection timeouts, health check flaps). If ANYTHING changed state or went unhealthy during the 5-minute window: read logs (`podman logs --since 5m`), find the issue, fix it, redeploy, wait 5 minutes again, recheck. Loop until everything stays healthy for a full 5-minute soak. Do not mark done until a clean 5-minute soak passes with zero state changes. - -- [x] **C5-FINAL — Record final state**: SSH to .228. Run and paste output of: (1) `podman ps -a --format 'table {{.Names}}\t{{.State}}\t{{.Status}}'` (2) `curl -s http://127.0.0.1:5678/health` (3) `for c in bitcoin-knots lnd electrumx; do echo "=== $c ==="; podman logs $c --tail 5 2>&1; done`. Record this as the final passing state in the Issue Log at the bottom of this file. Mark the overall result: **PASS** or note any accepted limitations. Do not mark done until the final state is recorded. - ---- - -## Cycle 6: Code Quality Gate - -- [x] **C6-QUALITY — Verify all code changes meet production standards**: Review every commit made during this overnight run. For each changed file: (1) Rust files: `grep -n 'unwrap()\|expect(' | grep -v test | grep -v 'unwrap_or\|unwrap_err'` — zero results. `grep -n 'TODO\|FIXME\|HACK' ` — zero results. (2) TypeScript/Vue files: `cd neode-ui && npx vue-tsc -b --noEmit` — zero errors. (3) Shell scripts: `bash -n ` — syntax OK for every changed script. (4) No hardcoded credentials, no `:latest` tags, no `sudo podman`. If ANY quality issue is found: fix it properly, commit, redeploy, and rerun the relevant tests from C4-FULL-TEST to confirm the quality fix didn't break anything. Do not mark done until all code is production-quality AND all tests still pass. - ---- - -## Issue Log - -### Cycle 1 Findings (2026-03-30 21:03 UTC) - -**Bitcoin Stack Issues:** - -1. **electrumx — EXITED (0), unhealthy** - - Error: `plyvel._plyvel.IOError: b'IO error: utxo/LOCK: Permission denied'` - - Volume `/var/lib/archipelago/electrumx` → `/data` owned by 100000:100000 (correct for container root) - - Container runs as root, `--read-only=false`, restart policy `unless-stopped` - - Root cause: Stale LOCK file from prior crash OR container user mismatch. Need to investigate further. - -2. **lnd — RUNNING but UNHEALTHY** - - Health check: `curl -sf --insecure https://localhost:8080/v1/getinfo` — fails with "expected 1 macaroon, got 0" - - LND itself is functioning: gossip syncing, peer connections active, no critical errors - - Root cause: Health check needs macaroon auth. The health check command is wrong. - - Also: Some Tor SOCKS connection refused errors (transient, non-critical) - -3. **bitcoin-knots — RUNNING, HEALTHY** ✅ - - Uses rpcauth (not rpcuser/rpcpassword). `bitcoin-cli` exec needs cookie or rpcuser auth. - - Port 8332-8333 mapped correctly. - -4. **archy-bitcoin-ui — RUNNING** ✅ - - Host network mode, nginx proxies on port 8334. Curl OK. - -5. **archy-lnd-ui — RUNNING** ✅ - - Port 8081->80. Curl OK. - -6. **archy-electrs-ui — RUNNING** ✅ - - Host network mode, no direct port mapping visible. Served via nginx. - -**Non-Bitcoin Stack Issues (lower priority):** - -7. **grafana — EXITED (1), unhealthy** - - Error: `unable to open database file: permission denied` / `GF_PATHS_DATA is not writable` - - Container has `--read-only` rootfs. Volume perms correct (100472:100472). - - Likely needs tmpfs mounts for `/tmp` and `/var/log/grafana`. - -8. **nextcloud — EXITED (1)** - - Data version 29.0.16.1 > image version 28.0.14.1. Cannot downgrade. Image needs upgrade. - -9. **homeassistant — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -10. **searxng — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -11. **onlyoffice — RUNNING, UNHEALTHY** (not in Bitcoin stack scope) -12. **fedimint — CREATED** (never started, not in scope) - -**All restart policies**: `unless-stopped` ✅ -**All memory limits**: Set for all 6 Bitcoin stack containers ✅ - -### Health Check Results (C1-HEALTH) - -| Container | Status | Health | Details | -|-----------|--------|--------|---------| -| bitcoin-knots | running | healthy | RPC OK, blocks=942975, fully synced | -| lnd | running | **unhealthy** | Health check needs macaroon. LND itself works (gossip syncing, peers connected). Only gossip noise errors. | -| electrumx | **crash-loop** | unhealthy | 130+ restarts, `utxo/LOCK: Permission denied` — `--cap-drop=ALL` with empty `SPEC_CAPS` removes `DAC_OVERRIDE` needed for rootless volume writes | -| archy-bitcoin-ui | running | n/a | Curl OK via nginx :8334 | -| archy-lnd-ui | running | n/a | Curl OK on :8081 | -| archy-electrs-ui | running | n/a | Host network, no direct port (served via nginx) | - -**Root causes fixed in Cycle 2:** -1. ✅ electrumx `SPEC_CAPS=""` → added `DAC_OVERRIDE` -2. ✅ lnd health check → replaced curl with `lncli` using readonly macaroon -3. ✅ grafana `SPEC_CAPS` → added `DAC_OVERRIDE` -4. ✅ electrumx health check → replaced missing curl with python3 socket check -5. ✅ container-doctor conmon cleanup → fixed root/rootless podman mismatch (was killing active conmon) -6. ✅ container-doctor restart → added stopped core container recovery for rootless restart policy workaround - -### Final State (2026-03-30 22:33 UTC) — **PASS** - -| Container | State | Health | Notes | -|-----------|-------|--------|-------| -| bitcoin-knots | running | healthy | Block 942982, 13 peers | -| lnd | running | healthy | Gossip syncing, peer connections active | -| electrumx | running | healthy | Caught up to daemon, accepting connections | -| archy-bitcoin-ui | running | n/a | Curl OK on :8334 | -| archy-lnd-ui | running | n/a | Curl OK on :8081 | -| archy-electrs-ui | running | n/a | Curl OK on :50002 | -| grafana | running | healthy | | - -Backend: `{"status":"ok","crash_recovery_complete":true,"version":"1.2.0-alpha","uptime_seconds":1063}` - -**Resilience tests passed:** -- Kill bitcoin-knots → LND/ElectrumX survive, Bitcoin auto-restarts, dependents reconnect -- Kill LND → auto-restarts, reconnects to Bitcoin -- Kill ElectrumX → auto-restarts, reconnects to Bitcoin -- Kill all UI containers → all auto-restart within 30s -- Kill backend (SIGKILL) → systemd restarts, crash recovery runs, all containers unaffected -- 5-minute soak → zero state changes, zero critical errors - -**Fixed this session:** -- UI container specs: added CHOWN/SETUID/SETGID caps (nginx chown failure), NET_BIND_SERVICE for lnd-ui (port 80 bind) - -**Known limitation:** Rootless Podman `unless-stopped` restart policy does not auto-restart containers after `podman stop`. Recovery relies on the backend health monitor + reconcile-containers.sh (runs on boot and periodically). diff --git a/loop/prepare.sh b/loop/prepare.sh deleted file mode 100755 index 43ef6cb2..00000000 --- a/loop/prepare.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env sh -# Pre-run script: verify repo state and create overnight branch. -set -eu - -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" -cd "$PROJECT_DIR" - -DATE=$(date '+%Y-%m-%d') -BRANCH="overnight/${DATE}" - -echo "=== archy overnight pre-run check @ $(date '+%Y-%m-%dT%H:%M:%S') ===" - -# 1. Check git status is clean -if ! git diff --quiet || ! git diff --cached --quiet; then - echo "Error: Working tree not clean. Commit or stash changes first." >&2 - git status --short >&2 - exit 1 -fi - -# 2. Check we're not already on an overnight branch -current=$(git branch --show-current 2>/dev/null || true) -if [ -n "$current" ] && [ "$current" = "$BRANCH" ]; then - echo "Already on $BRANCH. Ready to run." >&2 - exit 0 -fi - -# 3. Create date-stamped branch -if git rev-parse --verify "$BRANCH" >/dev/null 2>&1; then - echo "Branch $BRANCH already exists. Checkout or use a different date." >&2 - exit 1 -fi -git checkout -b "$BRANCH" -echo "Created branch $BRANCH" - -echo "" -echo "Reminder: Push before starting overnight run: git push -u origin $BRANCH" -echo "Then run: caffeinate -i ./loop/loop.sh" -echo "=== Ready ===" diff --git a/loop/prompt.md b/loop/prompt.md deleted file mode 100644 index add10f4d..00000000 --- a/loop/prompt.md +++ /dev/null @@ -1,49 +0,0 @@ -You are working through an overnight automation plan for the Archipelago (archy) project. Read these files first: - -1. `loop/plan.md` -- Your task checklist (mark items `- [x]` as you complete them) -2. `CLAUDE.md` -- Project conventions, architecture, and coding standards - -## Working Process - -For each task in `loop/plan.md`: - -1. Find the first unchecked `- [ ]` item -2. Read the task description carefully -3. Read the relevant source files before making changes -4. Implement following CLAUDE.md conventions -5. Run any test/build commands specified in the task -6. Fix all errors before continuing -7. Commit with conventional format: `type: description` -8. Mark it done `- [x]` in `loop/plan.md` -9. Move to the next unchecked task immediately - -## Critical Rules - -- **Deploy-test-fix LOOPS**: Many tasks require you to deploy, test, find failures, fix them, redeploy, and retest. Do NOT mark a task complete until ALL tests in that task pass. If a fix introduces a new failure, fix that too. Keep looping. -- **Read logs obsessively**: After every deploy, read `journalctl`, `podman logs`, and curl output. The logs tell you what's broken. -- **Fix the root cause**: Don't patch symptoms. If a container won't restart, find out WHY (wrong restart policy? health check failing? missing dependency?) and fix the actual cause. -- Never skip a testing gate -- if tests fail, fix before moving on -- If a task is proving difficult, make at least 10 genuine attempts before moving on -- Always read source files before editing them -- Do not stop until all tasks are checked or you are rate limited -- Commit after each completed fix (multiple commits per task is fine) -- DO NOT PUSH -- a CI build is in progress, we will push manually later -- Deploy to .228 -- `ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228` -- Run Rust builds/checks on .228, NOT macOS -- Production-quality code only -- no shortcuts, no TODO comments, no unwrap() - -## SSH Quick Reference - -```bash -SSH="ssh -i ~/.ssh/archipelago-deploy archipelago@192.168.1.228" -# Deploy from macOS: -./scripts/deploy-to-target.sh --target 192.168.1.228 -# Build Rust on .228: -$SSH "cd ~/archy/core && cargo clippy --all-targets --all-features && cargo test --all-features" -# Check containers: -$SSH "podman ps -a --format '{{.Names}} {{.State}} {{.Status}}' | sort" -# Read container logs: -$SSH "podman logs bitcoin-knots --tail 30" -# Check backend: -$SSH "journalctl -u archipelago --no-pager -n 50" -``` diff --git a/neode-ui/README.md b/neode-ui/README.md index 41f89556..3c9c4449 100644 --- a/neode-ui/README.md +++ b/neode-ui/README.md @@ -148,7 +148,6 @@ State management via Pinia stores. WebSocket patches applied automatically. - **Dev build**: `../web/dist/neode-ui/` - **Docker build**: `dist/` (deployed to nginx) -- **Production deploy**: via `scripts/deploy-to-target.sh --live` ## License diff --git a/neode-ui/dev-dist/registerSW.js b/neode-ui/dev-dist/registerSW.js deleted file mode 100644 index 1d5625f4..00000000 --- a/neode-ui/dev-dist/registerSW.js +++ /dev/null @@ -1 +0,0 @@ -if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' }) \ No newline at end of file diff --git a/neode-ui/dev-dist/sw.js b/neode-ui/dev-dist/sw.js deleted file mode 100644 index 9b6d2eaa..00000000 --- a/neode-ui/dev-dist/sw.js +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2018 Google Inc. All Rights Reserved. - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * http://www.apache.org/licenses/LICENSE-2.0 - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// If the loader is already loaded, just stop. -if (!self.define) { - let registry = {}; - - // Used for `eval` and `importScripts` where we can't get script URL by other means. - // In both cases, it's safe to use a global var because those functions are synchronous. - let nextDefineUri; - - const singleRequire = (uri, parentUri) => { - uri = new URL(uri + ".js", parentUri).href; - return registry[uri] || ( - - new Promise(resolve => { - if ("document" in self) { - const script = document.createElement("script"); - script.src = uri; - script.onload = resolve; - document.head.appendChild(script); - } else { - nextDefineUri = uri; - importScripts(uri); - resolve(); - } - }) - - .then(() => { - let promise = registry[uri]; - if (!promise) { - throw new Error(`Module ${uri} didn’t register its module`); - } - return promise; - }) - ); - }; - - self.define = (depsNames, factory) => { - const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href; - if (registry[uri]) { - // Module is already loading or loaded. - return; - } - let exports = {}; - const require = depUri => singleRequire(depUri, uri); - const specialDeps = { - module: { uri }, - exports, - require - }; - registry[uri] = Promise.all(depsNames.map( - depName => specialDeps[depName] || require(depName) - )).then(deps => { - factory(...deps); - return exports; - }); - }; -} -define(['./workbox-21a80088'], (function (workbox) { 'use strict'; - - self.skipWaiting(); - workbox.clientsClaim(); - - /** - * The precacheAndRoute() method efficiently caches and responds to - * requests for URLs in the manifest. - * See https://goo.gl/S9QRab - */ - workbox.precacheAndRoute([{ - "url": "registerSW.js", - "revision": "3ca0b8505b4bec776b69afdba2768812" - }, { - "url": "index.html", - "revision": "0.nnkdothias" - }], {}); - workbox.cleanupOutdatedCaches(); - workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), { - allowlist: [/^\/$/], - denylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//] - })); - workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({ - "cacheName": "google-fonts-cache", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 10, - maxAgeSeconds: 31536000 - }), new workbox.CacheableResponsePlugin({ - statuses: [0, 200] - })] - }), 'GET'); - workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({ - "cacheName": "gstatic-fonts-cache", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 10, - maxAgeSeconds: 31536000 - }), new workbox.CacheableResponsePlugin({ - statuses: [0, 200] - })] - }), 'GET'); - workbox.registerRoute(/\/rpc\/v1\/.*/i, new workbox.NetworkFirst({ - "cacheName": "api-cache", - "networkTimeoutSeconds": 10, - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 50, - maxAgeSeconds: 300 - })] - }), 'GET'); - workbox.registerRoute(/\/assets\/.*/i, new workbox.CacheFirst({ - "cacheName": "assets-cache-v2", - plugins: [new workbox.ExpirationPlugin({ - maxEntries: 100, - maxAgeSeconds: 2592000 - })] - }), 'GET'); - -})); diff --git a/neode-ui/dev-dist/workbox-21a80088.js b/neode-ui/dev-dist/workbox-21a80088.js deleted file mode 100644 index f3645263..00000000 --- a/neode-ui/dev-dist/workbox-21a80088.js +++ /dev/null @@ -1,4788 +0,0 @@ -define(['exports'], (function (exports) { 'use strict'; - - // @ts-ignore - try { - self['workbox:core:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const logger = (() => { - // Don't overwrite this value if it's already set. - // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 - if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { - self.__WB_DISABLE_DEV_LOGS = false; - } - let inGroup = false; - const methodToColorMap = { - debug: `#7f8c8d`, - log: `#2ecc71`, - warn: `#f39c12`, - error: `#c0392b`, - groupCollapsed: `#3498db`, - groupEnd: null // No colored prefix on groupEnd - }; - const print = function (method, args) { - if (self.__WB_DISABLE_DEV_LOGS) { - return; - } - if (method === 'groupCollapsed') { - // Safari doesn't print all console.groupCollapsed() arguments: - // https://bugs.webkit.org/show_bug.cgi?id=182754 - if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { - console[method](...args); - return; - } - } - const styles = [`background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`]; - // When in a group, the workbox prefix is not displayed. - const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; - console[method](...logPrefix, ...args); - if (method === 'groupCollapsed') { - inGroup = true; - } - if (method === 'groupEnd') { - inGroup = false; - } - }; - // eslint-disable-next-line @typescript-eslint/ban-types - const api = {}; - const loggerMethods = Object.keys(methodToColorMap); - for (const key of loggerMethods) { - const method = key; - api[method] = (...args) => { - print(method, args); - }; - } - return api; - })(); - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const messages$1 = { - 'invalid-value': ({ - paramName, - validValueDescription, - value - }) => { - if (!paramName || !validValueDescription) { - throw new Error(`Unexpected input to 'invalid-value' error.`); - } - return `The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`; - }, - 'not-an-array': ({ - moduleName, - className, - funcName, - paramName - }) => { - if (!moduleName || !className || !funcName || !paramName) { - throw new Error(`Unexpected input to 'not-an-array' error.`); - } - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`; - }, - 'incorrect-type': ({ - expectedType, - paramName, - moduleName, - className, - funcName - }) => { - if (!expectedType || !paramName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'incorrect-type' error.`); - } - const classNameStr = className ? `${className}.` : ''; - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`; - }, - 'incorrect-class': ({ - expectedClassName, - paramName, - moduleName, - className, - funcName, - isReturnValueProblem - }) => { - if (!expectedClassName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'incorrect-class' error.`); - } - const classNameStr = className ? `${className}.` : ''; - if (isReturnValueProblem) { - return `The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; - } - return `The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`; - }, - 'missing-a-method': ({ - expectedMethod, - paramName, - moduleName, - className, - funcName - }) => { - if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { - throw new Error(`Unexpected input to 'missing-a-method' error.`); - } - return `${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`; - }, - 'add-to-cache-list-unexpected-type': ({ - entry - }) => { - return `An unexpected entry was passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' The entry ` + `'${JSON.stringify(entry)}' isn't supported. You must supply an array of ` + `strings with one or more characters, objects with a url property or ` + `Request objects.`; - }, - 'add-to-cache-list-conflicting-entries': ({ - firstEntry, - secondEntry - }) => { - if (!firstEntry || !secondEntry) { - throw new Error(`Unexpected input to ` + `'add-to-cache-list-duplicate-entries' error.`); - } - return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${firstEntry} but different revision details. Workbox is ` + `unable to cache and version the asset correctly. Please remove one ` + `of the entries.`; - }, - 'plugin-error-request-will-fetch': ({ - thrownErrorMessage - }) => { - if (!thrownErrorMessage) { - throw new Error(`Unexpected input to ` + `'plugin-error-request-will-fetch', error.`); - } - return `An error was thrown by a plugins 'requestWillFetch()' method. ` + `The thrown error message was: '${thrownErrorMessage}'.`; - }, - 'invalid-cache-name': ({ - cacheNameId, - value - }) => { - if (!cacheNameId) { - throw new Error(`Expected a 'cacheNameId' for error 'invalid-cache-name'`); - } - return `You must provide a name containing at least one character for ` + `setCacheDetails({${cacheNameId}: '...'}). Received a value of ` + `'${JSON.stringify(value)}'`; - }, - 'unregister-route-but-not-found-with-method': ({ - method - }) => { - if (!method) { - throw new Error(`Unexpected input to ` + `'unregister-route-but-not-found-with-method' error.`); - } - return `The route you're trying to unregister was not previously ` + `registered for the method type '${method}'.`; - }, - 'unregister-route-route-not-registered': () => { - return `The route you're trying to unregister was not previously ` + `registered.`; - }, - 'queue-replay-failed': ({ - name - }) => { - return `Replaying the background sync queue '${name}' failed.`; - }, - 'duplicate-queue-name': ({ - name - }) => { - return `The Queue name '${name}' is already being used. ` + `All instances of backgroundSync.Queue must be given unique names.`; - }, - 'expired-test-without-max-age': ({ - methodName, - paramName - }) => { - return `The '${methodName}()' method can only be used when the ` + `'${paramName}' is used in the constructor.`; - }, - 'unsupported-route-type': ({ - moduleName, - className, - funcName, - paramName - }) => { - return `The supplied '${paramName}' parameter was an unsupported type. ` + `Please check the docs for ${moduleName}.${className}.${funcName} for ` + `valid input types.`; - }, - 'not-array-of-class': ({ - value, - expectedClass, - moduleName, - className, - funcName, - paramName - }) => { - return `The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`; - }, - 'max-entries-or-age-required': ({ - moduleName, - className, - funcName - }) => { - return `You must define either config.maxEntries or config.maxAgeSeconds` + `in ${moduleName}.${className}.${funcName}`; - }, - 'statuses-or-headers-required': ({ - moduleName, - className, - funcName - }) => { - return `You must define either config.statuses or config.headers` + `in ${moduleName}.${className}.${funcName}`; - }, - 'invalid-string': ({ - moduleName, - funcName, - paramName - }) => { - if (!paramName || !moduleName || !funcName) { - throw new Error(`Unexpected input to 'invalid-string' error.`); - } - return `When using strings, the '${paramName}' parameter must start with ` + `'http' (for cross-origin matches) or '/' (for same-origin matches). ` + `Please see the docs for ${moduleName}.${funcName}() for ` + `more info.`; - }, - 'channel-name-required': () => { - return `You must provide a channelName to construct a ` + `BroadcastCacheUpdate instance.`; - }, - 'invalid-responses-are-same-args': () => { - return `The arguments passed into responsesAreSame() appear to be ` + `invalid. Please ensure valid Responses are used.`; - }, - 'expire-custom-caches-only': () => { - return `You must provide a 'cacheName' property when using the ` + `expiration plugin with a runtime caching strategy.`; - }, - 'unit-must-be-bytes': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); - } - return `The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`; - }, - 'single-range-only': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'single-range-only' error.`); - } - return `Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`; - }, - 'invalid-range-values': ({ - normalizedRangeHeader - }) => { - if (!normalizedRangeHeader) { - throw new Error(`Unexpected input to 'invalid-range-values' error.`); - } - return `The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`; - }, - 'no-range-header': () => { - return `No Range header was found in the Request provided.`; - }, - 'range-not-satisfiable': ({ - size, - start, - end - }) => { - return `The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`; - }, - 'attempt-to-cache-non-get-request': ({ - url, - method - }) => { - return `Unable to cache '${url}' because it is a '${method}' request and ` + `only 'GET' requests can be cached.`; - }, - 'cache-put-with-no-response': ({ - url - }) => { - return `There was an attempt to cache '${url}' but the response was not ` + `defined.`; - }, - 'no-response': ({ - url, - error - }) => { - let message = `The strategy could not generate a response for '${url}'.`; - if (error) { - message += ` The underlying error is ${error}.`; - } - return message; - }, - 'bad-precaching-response': ({ - url, - status - }) => { - return `The precaching request for '${url}' failed` + (status ? ` with an HTTP status of ${status}.` : `.`); - }, - 'non-precached-url': ({ - url - }) => { - return `createHandlerBoundToURL('${url}') was called, but that URL is ` + `not precached. Please pass in a URL that is precached instead.`; - }, - 'add-to-cache-list-conflicting-integrities': ({ - url - }) => { - return `Two of the entries passed to ` + `'workbox-precaching.PrecacheController.addToCacheList()' had the URL ` + `${url} with different integrity values. Please remove one of them.`; - }, - 'missing-precache-entry': ({ - cacheName, - url - }) => { - return `Unable to find a precached response in ${cacheName} for ${url}.`; - }, - 'cross-origin-copy-response': ({ - origin - }) => { - return `workbox-core.copyResponse() can only be used with same-origin ` + `responses. It was passed a response with origin ${origin}.`; - }, - 'opaque-streams-source': ({ - type - }) => { - const message = `One of the workbox-streams sources resulted in an ` + `'${type}' response.`; - if (type === 'opaqueredirect') { - return `${message} Please do not use a navigation request that results ` + `in a redirect as a source.`; - } - return `${message} Please ensure your sources are CORS-enabled.`; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const generatorFunction = (code, details = {}) => { - const message = messages$1[code]; - if (!message) { - throw new Error(`Unable to find message for code '${code}'.`); - } - return message(details); - }; - const messageGenerator = generatorFunction; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Workbox errors should be thrown with this class. - * This allows use to ensure the type easily in tests, - * helps developers identify errors from workbox - * easily and allows use to optimise error - * messages correctly. - * - * @private - */ - class WorkboxError extends Error { - /** - * - * @param {string} errorCode The error code that - * identifies this particular error. - * @param {Object=} details Any relevant arguments - * that will help developers identify issues should - * be added as a key on the context object. - */ - constructor(errorCode, details) { - const message = messageGenerator(errorCode, details); - super(message); - this.name = errorCode; - this.details = details; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /* - * This method throws if the supplied value is not an array. - * The destructed values are required to produce a meaningful error for users. - * The destructed and restructured object is so it's clear what is - * needed. - */ - const isArray = (value, details) => { - if (!Array.isArray(value)) { - throw new WorkboxError('not-an-array', details); - } - }; - const hasMethod = (object, expectedMethod, details) => { - const type = typeof object[expectedMethod]; - if (type !== 'function') { - details['expectedMethod'] = expectedMethod; - throw new WorkboxError('missing-a-method', details); - } - }; - const isType = (object, expectedType, details) => { - if (typeof object !== expectedType) { - details['expectedType'] = expectedType; - throw new WorkboxError('incorrect-type', details); - } - }; - const isInstance = (object, - // Need the general type to do the check later. - // eslint-disable-next-line @typescript-eslint/ban-types - expectedClass, details) => { - if (!(object instanceof expectedClass)) { - details['expectedClassName'] = expectedClass.name; - throw new WorkboxError('incorrect-class', details); - } - }; - const isOneOf = (value, validValues, details) => { - if (!validValues.includes(value)) { - details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; - throw new WorkboxError('invalid-value', details); - } - }; - const isArrayOfClass = (value, - // Need general type to do check later. - expectedClass, - // eslint-disable-line - details) => { - const error = new WorkboxError('not-array-of-class', details); - if (!Array.isArray(value)) { - throw error; - } - for (const item of value) { - if (!(item instanceof expectedClass)) { - throw error; - } - } - }; - const finalAssertExports = { - hasMethod, - isArray, - isInstance, - isOneOf, - isType, - isArrayOfClass - }; - - // @ts-ignore - try { - self['workbox:routing:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The default HTTP method, 'GET', used when there's no specific method - * configured for a route. - * - * @type {string} - * - * @private - */ - const defaultMethod = 'GET'; - /** - * The list of valid HTTP methods associated with requests that could be routed. - * - * @type {Array} - * - * @private - */ - const validMethods = ['DELETE', 'GET', 'HEAD', 'PATCH', 'POST', 'PUT']; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {function()|Object} handler Either a function, or an object with a - * 'handle' method. - * @return {Object} An object with a handle method. - * - * @private - */ - const normalizeHandler = handler => { - if (handler && typeof handler === 'object') { - { - finalAssertExports.hasMethod(handler, 'handle', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'handler' - }); - } - return handler; - } else { - { - finalAssertExports.isType(handler, 'function', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'handler' - }); - } - return { - handle: handler - }; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A `Route` consists of a pair of callback functions, "match" and "handler". - * The "match" callback determine if a route should be used to "handle" a - * request by returning a non-falsy value if it can. The "handler" callback - * is called when there is a match and should return a Promise that resolves - * to a `Response`. - * - * @memberof workbox-routing - */ - class Route { - /** - * Constructor for Route class. - * - * @param {workbox-routing~matchCallback} match - * A callback function that determines whether the route matches a given - * `fetch` event by returning a non-falsy value. - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resolving to a Response. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - */ - constructor(match, handler, method = defaultMethod) { - { - finalAssertExports.isType(match, 'function', { - moduleName: 'workbox-routing', - className: 'Route', - funcName: 'constructor', - paramName: 'match' - }); - if (method) { - finalAssertExports.isOneOf(method, validMethods, { - paramName: 'method' - }); - } - } - // These values are referenced directly by Router so cannot be - // altered by minificaton. - this.handler = normalizeHandler(handler); - this.match = match; - this.method = method; - } - /** - * - * @param {workbox-routing-handlerCallback} handler A callback - * function that returns a Promise resolving to a Response - */ - setCatchHandler(handler) { - this.catchHandler = normalizeHandler(handler); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * RegExpRoute makes it easy to create a regular expression based - * {@link workbox-routing.Route}. - * - * For same-origin requests the RegExp only needs to match part of the URL. For - * requests against third-party servers, you must define a RegExp that matches - * the start of the URL. - * - * @memberof workbox-routing - * @extends workbox-routing.Route - */ - class RegExpRoute extends Route { - /** - * If the regular expression contains - * [capture groups]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#grouping-back-references}, - * the captured values will be passed to the - * {@link workbox-routing~handlerCallback} `params` - * argument. - * - * @param {RegExp} regExp The regular expression to match against URLs. - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - */ - constructor(regExp, handler, method) { - { - finalAssertExports.isInstance(regExp, RegExp, { - moduleName: 'workbox-routing', - className: 'RegExpRoute', - funcName: 'constructor', - paramName: 'pattern' - }); - } - const match = ({ - url - }) => { - const result = regExp.exec(url.href); - // Return immediately if there's no match. - if (!result) { - return; - } - // Require that the match start at the first character in the URL string - // if it's a cross-origin request. - // See https://github.com/GoogleChrome/workbox/issues/281 for the context - // behind this behavior. - if (url.origin !== location.origin && result.index !== 0) { - { - logger.debug(`The regular expression '${regExp.toString()}' only partially matched ` + `against the cross-origin URL '${url.toString()}'. RegExpRoute's will only ` + `handle cross-origin requests if they match the entire URL.`); - } - return; - } - // If the route matches, but there aren't any capture groups defined, then - // this will return [], which is truthy and therefore sufficient to - // indicate a match. - // If there are capture groups, then it will return their values. - return result.slice(1); - }; - super(match, handler, method); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const getFriendlyURL = url => { - const urlObj = new URL(String(url), location.href); - // See https://github.com/GoogleChrome/workbox/issues/2323 - // We want to include everything, except for the origin if it's same-origin. - return urlObj.href.replace(new RegExp(`^${location.origin}`), ''); - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The Router can be used to process a `FetchEvent` using one or more - * {@link workbox-routing.Route}, responding with a `Response` if - * a matching route exists. - * - * If no route matches a given a request, the Router will use a "default" - * handler if one is defined. - * - * Should the matching Route throw an error, the Router will use a "catch" - * handler if one is defined to gracefully deal with issues and respond with a - * Request. - * - * If a request matches multiple routes, the **earliest** registered route will - * be used to respond to the request. - * - * @memberof workbox-routing - */ - class Router { - /** - * Initializes a new Router. - */ - constructor() { - this._routes = new Map(); - this._defaultHandlerMap = new Map(); - } - /** - * @return {Map>} routes A `Map` of HTTP - * method name ('GET', etc.) to an array of all the corresponding `Route` - * instances that are registered. - */ - get routes() { - return this._routes; - } - /** - * Adds a fetch event listener to respond to events when a route matches - * the event's request. - */ - addFetchListener() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('fetch', event => { - const { - request - } = event; - const responsePromise = this.handleRequest({ - request, - event - }); - if (responsePromise) { - event.respondWith(responsePromise); - } - }); - } - /** - * Adds a message event listener for URLs to cache from the window. - * This is useful to cache resources loaded on the page prior to when the - * service worker started controlling it. - * - * The format of the message data sent from the window should be as follows. - * Where the `urlsToCache` array may consist of URL strings or an array of - * URL string + `requestInit` object (the same as you'd pass to `fetch()`). - * - * ``` - * { - * type: 'CACHE_URLS', - * payload: { - * urlsToCache: [ - * './script1.js', - * './script2.js', - * ['./script3.js', {mode: 'no-cors'}], - * ], - * }, - * } - * ``` - */ - addCacheListener() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('message', event => { - // event.data is type 'any' - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (event.data && event.data.type === 'CACHE_URLS') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const { - payload - } = event.data; - { - logger.debug(`Caching URLs from the window`, payload.urlsToCache); - } - const requestPromises = Promise.all(payload.urlsToCache.map(entry => { - if (typeof entry === 'string') { - entry = [entry]; - } - const request = new Request(...entry); - return this.handleRequest({ - request, - event - }); - // TODO(philipwalton): TypeScript errors without this typecast for - // some reason (probably a bug). The real type here should work but - // doesn't: `Array | undefined>`. - })); // TypeScript - event.waitUntil(requestPromises); - // If a MessageChannel was used, reply to the message on success. - if (event.ports && event.ports[0]) { - void requestPromises.then(() => event.ports[0].postMessage(true)); - } - } - }); - } - /** - * Apply the routing rules to a FetchEvent object to get a Response from an - * appropriate Route's handler. - * - * @param {Object} options - * @param {Request} options.request The request to handle. - * @param {ExtendableEvent} options.event The event that triggered the - * request. - * @return {Promise|undefined} A promise is returned if a - * registered route can handle the request. If there is no matching - * route and there's no `defaultHandler`, `undefined` is returned. - */ - handleRequest({ - request, - event - }) { - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'handleRequest', - paramName: 'options.request' - }); - } - const url = new URL(request.url, location.href); - if (!url.protocol.startsWith('http')) { - { - logger.debug(`Workbox Router only supports URLs that start with 'http'.`); - } - return; - } - const sameOrigin = url.origin === location.origin; - const { - params, - route - } = this.findMatchingRoute({ - event, - request, - sameOrigin, - url - }); - let handler = route && route.handler; - const debugMessages = []; - { - if (handler) { - debugMessages.push([`Found a route to handle this request:`, route]); - if (params) { - debugMessages.push([`Passing the following params to the route's handler:`, params]); - } - } - } - // If we don't have a handler because there was no matching route, then - // fall back to defaultHandler if that's defined. - const method = request.method; - if (!handler && this._defaultHandlerMap.has(method)) { - { - debugMessages.push(`Failed to find a matching route. Falling ` + `back to the default handler for ${method}.`); - } - handler = this._defaultHandlerMap.get(method); - } - if (!handler) { - { - // No handler so Workbox will do nothing. If logs is set of debug - // i.e. verbose, we should print out this information. - logger.debug(`No route found for: ${getFriendlyURL(url)}`); - } - return; - } - { - // We have a handler, meaning Workbox is going to handle the route. - // print the routing details to the console. - logger.groupCollapsed(`Router is responding to: ${getFriendlyURL(url)}`); - debugMessages.forEach(msg => { - if (Array.isArray(msg)) { - logger.log(...msg); - } else { - logger.log(msg); - } - }); - logger.groupEnd(); - } - // Wrap in try and catch in case the handle method throws a synchronous - // error. It should still callback to the catch handler. - let responsePromise; - try { - responsePromise = handler.handle({ - url, - request, - event, - params - }); - } catch (err) { - responsePromise = Promise.reject(err); - } - // Get route's catch handler, if it exists - const catchHandler = route && route.catchHandler; - if (responsePromise instanceof Promise && (this._catchHandler || catchHandler)) { - responsePromise = responsePromise.catch(async err => { - // If there's a route catch handler, process that first - if (catchHandler) { - { - // Still include URL here as it will be async from the console group - // and may not make sense without the URL - logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to route's Catch Handler.`); - logger.error(`Error thrown by:`, route); - logger.error(err); - logger.groupEnd(); - } - try { - return await catchHandler.handle({ - url, - request, - event, - params - }); - } catch (catchErr) { - if (catchErr instanceof Error) { - err = catchErr; - } - } - } - if (this._catchHandler) { - { - // Still include URL here as it will be async from the console group - // and may not make sense without the URL - logger.groupCollapsed(`Error thrown when responding to: ` + ` ${getFriendlyURL(url)}. Falling back to global Catch Handler.`); - logger.error(`Error thrown by:`, route); - logger.error(err); - logger.groupEnd(); - } - return this._catchHandler.handle({ - url, - request, - event - }); - } - throw err; - }); - } - return responsePromise; - } - /** - * Checks a request and URL (and optionally an event) against the list of - * registered routes, and if there's a match, returns the corresponding - * route along with any params generated by the match. - * - * @param {Object} options - * @param {URL} options.url - * @param {boolean} options.sameOrigin The result of comparing `url.origin` - * against the current origin. - * @param {Request} options.request The request to match. - * @param {Event} options.event The corresponding event. - * @return {Object} An object with `route` and `params` properties. - * They are populated if a matching route was found or `undefined` - * otherwise. - */ - findMatchingRoute({ - url, - sameOrigin, - request, - event - }) { - const routes = this._routes.get(request.method) || []; - for (const route of routes) { - let params; - // route.match returns type any, not possible to change right now. - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const matchResult = route.match({ - url, - sameOrigin, - request, - event - }); - if (matchResult) { - { - // Warn developers that using an async matchCallback is almost always - // not the right thing to do. - if (matchResult instanceof Promise) { - logger.warn(`While routing ${getFriendlyURL(url)}, an async ` + `matchCallback function was used. Please convert the ` + `following route to use a synchronous matchCallback function:`, route); - } - } - // See https://github.com/GoogleChrome/workbox/issues/2079 - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - params = matchResult; - if (Array.isArray(params) && params.length === 0) { - // Instead of passing an empty array in as params, use undefined. - params = undefined; - } else if (matchResult.constructor === Object && - // eslint-disable-line - Object.keys(matchResult).length === 0) { - // Instead of passing an empty object in as params, use undefined. - params = undefined; - } else if (typeof matchResult === 'boolean') { - // For the boolean value true (rather than just something truth-y), - // don't set params. - // See https://github.com/GoogleChrome/workbox/pull/2134#issuecomment-513924353 - params = undefined; - } - // Return early if have a match. - return { - route, - params - }; - } - } - // If no match was found above, return and empty object. - return {}; - } - /** - * Define a default `handler` that's called when no routes explicitly - * match the incoming request. - * - * Each HTTP method ('GET', 'POST', etc.) gets its own default handler. - * - * Without a default handler, unmatched requests will go against the - * network as if there were no service worker present. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {string} [method='GET'] The HTTP method to associate with this - * default handler. Each method has its own default. - */ - setDefaultHandler(handler, method = defaultMethod) { - this._defaultHandlerMap.set(method, normalizeHandler(handler)); - } - /** - * If a Route throws an error while handling a request, this `handler` - * will be called and given a chance to provide a response. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - */ - setCatchHandler(handler) { - this._catchHandler = normalizeHandler(handler); - } - /** - * Registers a route with the router. - * - * @param {workbox-routing.Route} route The route to register. - */ - registerRoute(route) { - { - finalAssertExports.isType(route, 'object', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.hasMethod(route, 'match', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.isType(route.handler, 'object', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route' - }); - finalAssertExports.hasMethod(route.handler, 'handle', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route.handler' - }); - finalAssertExports.isType(route.method, 'string', { - moduleName: 'workbox-routing', - className: 'Router', - funcName: 'registerRoute', - paramName: 'route.method' - }); - } - if (!this._routes.has(route.method)) { - this._routes.set(route.method, []); - } - // Give precedence to all of the earlier routes by adding this additional - // route to the end of the array. - this._routes.get(route.method).push(route); - } - /** - * Unregisters a route with the router. - * - * @param {workbox-routing.Route} route The route to unregister. - */ - unregisterRoute(route) { - if (!this._routes.has(route.method)) { - throw new WorkboxError('unregister-route-but-not-found-with-method', { - method: route.method - }); - } - const routeIndex = this._routes.get(route.method).indexOf(route); - if (routeIndex > -1) { - this._routes.get(route.method).splice(routeIndex, 1); - } else { - throw new WorkboxError('unregister-route-route-not-registered'); - } - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let defaultRouter; - /** - * Creates a new, singleton Router instance if one does not exist. If one - * does already exist, that instance is returned. - * - * @private - * @return {Router} - */ - const getOrCreateDefaultRouter = () => { - if (!defaultRouter) { - defaultRouter = new Router(); - // The helpers that use the default Router assume these listeners exist. - defaultRouter.addFetchListener(); - defaultRouter.addCacheListener(); - } - return defaultRouter; - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Easily register a RegExp, string, or function with a caching - * strategy to a singleton Router instance. - * - * This method will generate a Route for you if needed and - * call {@link workbox-routing.Router#registerRoute}. - * - * @param {RegExp|string|workbox-routing.Route~matchCallback|workbox-routing.Route} capture - * If the capture param is a `Route`, all other arguments will be ignored. - * @param {workbox-routing~handlerCallback} [handler] A callback - * function that returns a Promise resulting in a Response. This parameter - * is required if `capture` is not a `Route` object. - * @param {string} [method='GET'] The HTTP method to match the Route - * against. - * @return {workbox-routing.Route} The generated `Route`. - * - * @memberof workbox-routing - */ - function registerRoute(capture, handler, method) { - let route; - if (typeof capture === 'string') { - const captureUrl = new URL(capture, location.href); - { - if (!(capture.startsWith('/') || capture.startsWith('http'))) { - throw new WorkboxError('invalid-string', { - moduleName: 'workbox-routing', - funcName: 'registerRoute', - paramName: 'capture' - }); - } - // We want to check if Express-style wildcards are in the pathname only. - // TODO: Remove this log message in v4. - const valueToCheck = capture.startsWith('http') ? captureUrl.pathname : capture; - // See https://github.com/pillarjs/path-to-regexp#parameters - const wildcards = '[*:?+]'; - if (new RegExp(`${wildcards}`).exec(valueToCheck)) { - logger.debug(`The '$capture' parameter contains an Express-style wildcard ` + `character (${wildcards}). Strings are now always interpreted as ` + `exact matches; use a RegExp for partial or wildcard matches.`); - } - } - const matchCallback = ({ - url - }) => { - { - if (url.pathname === captureUrl.pathname && url.origin !== captureUrl.origin) { - logger.debug(`${capture} only partially matches the cross-origin URL ` + `${url.toString()}. This route will only handle cross-origin requests ` + `if they match the entire URL.`); - } - } - return url.href === captureUrl.href; - }; - // If `capture` is a string then `handler` and `method` must be present. - route = new Route(matchCallback, handler, method); - } else if (capture instanceof RegExp) { - // If `capture` is a `RegExp` then `handler` and `method` must be present. - route = new RegExpRoute(capture, handler, method); - } else if (typeof capture === 'function') { - // If `capture` is a function then `handler` and `method` must be present. - route = new Route(capture, handler, method); - } else if (capture instanceof Route) { - route = capture; - } else { - throw new WorkboxError('unsupported-route-type', { - moduleName: 'workbox-routing', - funcName: 'registerRoute', - paramName: 'capture' - }); - } - const defaultRouter = getOrCreateDefaultRouter(); - defaultRouter.registerRoute(route); - return route; - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const _cacheNameDetails = { - googleAnalytics: 'googleAnalytics', - precache: 'precache-v2', - prefix: 'workbox', - runtime: 'runtime', - suffix: typeof registration !== 'undefined' ? registration.scope : '' - }; - const _createCacheName = cacheName => { - return [_cacheNameDetails.prefix, cacheName, _cacheNameDetails.suffix].filter(value => value && value.length > 0).join('-'); - }; - const eachCacheNameDetail = fn => { - for (const key of Object.keys(_cacheNameDetails)) { - fn(key); - } - }; - const cacheNames = { - updateDetails: details => { - eachCacheNameDetail(key => { - if (typeof details[key] === 'string') { - _cacheNameDetails[key] = details[key]; - } - }); - }, - getGoogleAnalyticsName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.googleAnalytics); - }, - getPrecacheName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.precache); - }, - getPrefix: () => { - return _cacheNameDetails.prefix; - }, - getRuntimeName: userCacheName => { - return userCacheName || _createCacheName(_cacheNameDetails.runtime); - }, - getSuffix: () => { - return _cacheNameDetails.suffix; - } - }; - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A helper function that prevents a promise from being flagged as unused. - * - * @private - **/ - function dontWaitFor(promise) { - // Effective no-op. - void promise.then(() => {}); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - // Callbacks to be executed whenever there's a quota error. - // Can't change Function type right now. - // eslint-disable-next-line @typescript-eslint/ban-types - const quotaErrorCallbacks = new Set(); - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds a function to the set of quotaErrorCallbacks that will be executed if - * there's a quota error. - * - * @param {Function} callback - * @memberof workbox-core - */ - // Can't change Function type - // eslint-disable-next-line @typescript-eslint/ban-types - function registerQuotaErrorCallback(callback) { - { - finalAssertExports.isType(callback, 'function', { - moduleName: 'workbox-core', - funcName: 'register', - paramName: 'callback' - }); - } - quotaErrorCallbacks.add(callback); - { - logger.log('Registered a callback to respond to quota errors.', callback); - } - } - - function _extends() { - return _extends = Object.assign ? Object.assign.bind() : function (n) { - for (var e = 1; e < arguments.length; e++) { - var t = arguments[e]; - for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); - } - return n; - }, _extends.apply(null, arguments); - } - - const instanceOfAny = (object, constructors) => constructors.some(c => object instanceof c); - let idbProxyableTypes; - let cursorAdvanceMethods; - // This is a function to prevent it throwing up in node environments. - function getIdbProxyableTypes() { - return idbProxyableTypes || (idbProxyableTypes = [IDBDatabase, IDBObjectStore, IDBIndex, IDBCursor, IDBTransaction]); - } - // This is a function to prevent it throwing up in node environments. - function getCursorAdvanceMethods() { - return cursorAdvanceMethods || (cursorAdvanceMethods = [IDBCursor.prototype.advance, IDBCursor.prototype.continue, IDBCursor.prototype.continuePrimaryKey]); - } - const cursorRequestMap = new WeakMap(); - const transactionDoneMap = new WeakMap(); - const transactionStoreNamesMap = new WeakMap(); - const transformCache = new WeakMap(); - const reverseTransformCache = new WeakMap(); - function promisifyRequest(request) { - const promise = new Promise((resolve, reject) => { - const unlisten = () => { - request.removeEventListener('success', success); - request.removeEventListener('error', error); - }; - const success = () => { - resolve(wrap(request.result)); - unlisten(); - }; - const error = () => { - reject(request.error); - unlisten(); - }; - request.addEventListener('success', success); - request.addEventListener('error', error); - }); - promise.then(value => { - // Since cursoring reuses the IDBRequest (*sigh*), we cache it for later retrieval - // (see wrapFunction). - if (value instanceof IDBCursor) { - cursorRequestMap.set(value, request); - } - // Catching to avoid "Uncaught Promise exceptions" - }).catch(() => {}); - // This mapping exists in reverseTransformCache but doesn't doesn't exist in transformCache. This - // is because we create many promises from a single IDBRequest. - reverseTransformCache.set(promise, request); - return promise; - } - function cacheDonePromiseForTransaction(tx) { - // Early bail if we've already created a done promise for this transaction. - if (transactionDoneMap.has(tx)) return; - const done = new Promise((resolve, reject) => { - const unlisten = () => { - tx.removeEventListener('complete', complete); - tx.removeEventListener('error', error); - tx.removeEventListener('abort', error); - }; - const complete = () => { - resolve(); - unlisten(); - }; - const error = () => { - reject(tx.error || new DOMException('AbortError', 'AbortError')); - unlisten(); - }; - tx.addEventListener('complete', complete); - tx.addEventListener('error', error); - tx.addEventListener('abort', error); - }); - // Cache it for later retrieval. - transactionDoneMap.set(tx, done); - } - let idbProxyTraps = { - get(target, prop, receiver) { - if (target instanceof IDBTransaction) { - // Special handling for transaction.done. - if (prop === 'done') return transactionDoneMap.get(target); - // Polyfill for objectStoreNames because of Edge. - if (prop === 'objectStoreNames') { - return target.objectStoreNames || transactionStoreNamesMap.get(target); - } - // Make tx.store return the only store in the transaction, or undefined if there are many. - if (prop === 'store') { - return receiver.objectStoreNames[1] ? undefined : receiver.objectStore(receiver.objectStoreNames[0]); - } - } - // Else transform whatever we get back. - return wrap(target[prop]); - }, - set(target, prop, value) { - target[prop] = value; - return true; - }, - has(target, prop) { - if (target instanceof IDBTransaction && (prop === 'done' || prop === 'store')) { - return true; - } - return prop in target; - } - }; - function replaceTraps(callback) { - idbProxyTraps = callback(idbProxyTraps); - } - function wrapFunction(func) { - // Due to expected object equality (which is enforced by the caching in `wrap`), we - // only create one new func per func. - // Edge doesn't support objectStoreNames (booo), so we polyfill it here. - if (func === IDBDatabase.prototype.transaction && !('objectStoreNames' in IDBTransaction.prototype)) { - return function (storeNames, ...args) { - const tx = func.call(unwrap(this), storeNames, ...args); - transactionStoreNamesMap.set(tx, storeNames.sort ? storeNames.sort() : [storeNames]); - return wrap(tx); - }; - } - // Cursor methods are special, as the behaviour is a little more different to standard IDB. In - // IDB, you advance the cursor and wait for a new 'success' on the IDBRequest that gave you the - // cursor. It's kinda like a promise that can resolve with many values. That doesn't make sense - // with real promises, so each advance methods returns a new promise for the cursor object, or - // undefined if the end of the cursor has been reached. - if (getCursorAdvanceMethods().includes(func)) { - return function (...args) { - // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use - // the original object. - func.apply(unwrap(this), args); - return wrap(cursorRequestMap.get(this)); - }; - } - return function (...args) { - // Calling the original function with the proxy as 'this' causes ILLEGAL INVOCATION, so we use - // the original object. - return wrap(func.apply(unwrap(this), args)); - }; - } - function transformCachableValue(value) { - if (typeof value === 'function') return wrapFunction(value); - // This doesn't return, it just creates a 'done' promise for the transaction, - // which is later returned for transaction.done (see idbObjectHandler). - if (value instanceof IDBTransaction) cacheDonePromiseForTransaction(value); - if (instanceOfAny(value, getIdbProxyableTypes())) return new Proxy(value, idbProxyTraps); - // Return the same value back if we're not going to transform it. - return value; - } - function wrap(value) { - // We sometimes generate multiple promises from a single IDBRequest (eg when cursoring), because - // IDB is weird and a single IDBRequest can yield many responses, so these can't be cached. - if (value instanceof IDBRequest) return promisifyRequest(value); - // If we've already transformed this value before, reuse the transformed value. - // This is faster, but it also provides object equality. - if (transformCache.has(value)) return transformCache.get(value); - const newValue = transformCachableValue(value); - // Not all types are transformed. - // These may be primitive types, so they can't be WeakMap keys. - if (newValue !== value) { - transformCache.set(value, newValue); - reverseTransformCache.set(newValue, value); - } - return newValue; - } - const unwrap = value => reverseTransformCache.get(value); - - /** - * Open a database. - * - * @param name Name of the database. - * @param version Schema version. - * @param callbacks Additional callbacks. - */ - function openDB(name, version, { - blocked, - upgrade, - blocking, - terminated - } = {}) { - const request = indexedDB.open(name, version); - const openPromise = wrap(request); - if (upgrade) { - request.addEventListener('upgradeneeded', event => { - upgrade(wrap(request.result), event.oldVersion, event.newVersion, wrap(request.transaction), event); - }); - } - if (blocked) { - request.addEventListener('blocked', event => blocked( - // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 - event.oldVersion, event.newVersion, event)); - } - openPromise.then(db => { - if (terminated) db.addEventListener('close', () => terminated()); - if (blocking) { - db.addEventListener('versionchange', event => blocking(event.oldVersion, event.newVersion, event)); - } - }).catch(() => {}); - return openPromise; - } - /** - * Delete a database. - * - * @param name Name of the database. - */ - function deleteDB(name, { - blocked - } = {}) { - const request = indexedDB.deleteDatabase(name); - if (blocked) { - request.addEventListener('blocked', event => blocked( - // Casting due to https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1405 - event.oldVersion, event)); - } - return wrap(request).then(() => undefined); - } - const readMethods = ['get', 'getKey', 'getAll', 'getAllKeys', 'count']; - const writeMethods = ['put', 'add', 'delete', 'clear']; - const cachedMethods = new Map(); - function getMethod(target, prop) { - if (!(target instanceof IDBDatabase && !(prop in target) && typeof prop === 'string')) { - return; - } - if (cachedMethods.get(prop)) return cachedMethods.get(prop); - const targetFuncName = prop.replace(/FromIndex$/, ''); - const useIndex = prop !== targetFuncName; - const isWrite = writeMethods.includes(targetFuncName); - if ( - // Bail if the target doesn't exist on the target. Eg, getAll isn't in Edge. - !(targetFuncName in (useIndex ? IDBIndex : IDBObjectStore).prototype) || !(isWrite || readMethods.includes(targetFuncName))) { - return; - } - const method = async function (storeName, ...args) { - // isWrite ? 'readwrite' : undefined gzipps better, but fails in Edge :( - const tx = this.transaction(storeName, isWrite ? 'readwrite' : 'readonly'); - let target = tx.store; - if (useIndex) target = target.index(args.shift()); - // Must reject if op rejects. - // If it's a write operation, must reject if tx.done rejects. - // Must reject with op rejection first. - // Must resolve with op value. - // Must handle both promises (no unhandled rejections) - return (await Promise.all([target[targetFuncName](...args), isWrite && tx.done]))[0]; - }; - cachedMethods.set(prop, method); - return method; - } - replaceTraps(oldTraps => _extends({}, oldTraps, { - get: (target, prop, receiver) => getMethod(target, prop) || oldTraps.get(target, prop, receiver), - has: (target, prop) => !!getMethod(target, prop) || oldTraps.has(target, prop) - })); - - // @ts-ignore - try { - self['workbox:expiration:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const DB_NAME = 'workbox-expiration'; - const CACHE_OBJECT_STORE = 'cache-entries'; - const normalizeURL = unNormalizedUrl => { - const url = new URL(unNormalizedUrl, location.href); - url.hash = ''; - return url.href; - }; - /** - * Returns the timestamp model. - * - * @private - */ - class CacheTimestampsModel { - /** - * - * @param {string} cacheName - * - * @private - */ - constructor(cacheName) { - this._db = null; - this._cacheName = cacheName; - } - /** - * Performs an upgrade of indexedDB. - * - * @param {IDBPDatabase} db - * - * @private - */ - _upgradeDb(db) { - // TODO(philipwalton): EdgeHTML doesn't support arrays as a keyPath, so we - // have to use the `id` keyPath here and create our own values (a - // concatenation of `url + cacheName`) instead of simply using - // `keyPath: ['url', 'cacheName']`, which is supported in other browsers. - const objStore = db.createObjectStore(CACHE_OBJECT_STORE, { - keyPath: 'id' - }); - // TODO(philipwalton): once we don't have to support EdgeHTML, we can - // create a single index with the keyPath `['cacheName', 'timestamp']` - // instead of doing both these indexes. - objStore.createIndex('cacheName', 'cacheName', { - unique: false - }); - objStore.createIndex('timestamp', 'timestamp', { - unique: false - }); - } - /** - * Performs an upgrade of indexedDB and deletes deprecated DBs. - * - * @param {IDBPDatabase} db - * - * @private - */ - _upgradeDbAndDeleteOldDbs(db) { - this._upgradeDb(db); - if (this._cacheName) { - void deleteDB(this._cacheName); - } - } - /** - * @param {string} url - * @param {number} timestamp - * - * @private - */ - async setTimestamp(url, timestamp) { - url = normalizeURL(url); - const entry = { - url, - timestamp, - cacheName: this._cacheName, - // Creating an ID from the URL and cache name won't be necessary once - // Edge switches to Chromium and all browsers we support work with - // array keyPaths. - id: this._getId(url) - }; - const db = await this.getDb(); - const tx = db.transaction(CACHE_OBJECT_STORE, 'readwrite', { - durability: 'relaxed' - }); - await tx.store.put(entry); - await tx.done; - } - /** - * Returns the timestamp stored for a given URL. - * - * @param {string} url - * @return {number | undefined} - * - * @private - */ - async getTimestamp(url) { - const db = await this.getDb(); - const entry = await db.get(CACHE_OBJECT_STORE, this._getId(url)); - return entry === null || entry === void 0 ? void 0 : entry.timestamp; - } - /** - * Iterates through all the entries in the object store (from newest to - * oldest) and removes entries once either `maxCount` is reached or the - * entry's timestamp is less than `minTimestamp`. - * - * @param {number} minTimestamp - * @param {number} maxCount - * @return {Array} - * - * @private - */ - async expireEntries(minTimestamp, maxCount) { - const db = await this.getDb(); - let cursor = await db.transaction(CACHE_OBJECT_STORE).store.index('timestamp').openCursor(null, 'prev'); - const entriesToDelete = []; - let entriesNotDeletedCount = 0; - while (cursor) { - const result = cursor.value; - // TODO(philipwalton): once we can use a multi-key index, we - // won't have to check `cacheName` here. - if (result.cacheName === this._cacheName) { - // Delete an entry if it's older than the max age or - // if we already have the max number allowed. - if (minTimestamp && result.timestamp < minTimestamp || maxCount && entriesNotDeletedCount >= maxCount) { - // TODO(philipwalton): we should be able to delete the - // entry right here, but doing so causes an iteration - // bug in Safari stable (fixed in TP). Instead we can - // store the keys of the entries to delete, and then - // delete the separate transactions. - // https://github.com/GoogleChrome/workbox/issues/1978 - // cursor.delete(); - // We only need to return the URL, not the whole entry. - entriesToDelete.push(cursor.value); - } else { - entriesNotDeletedCount++; - } - } - cursor = await cursor.continue(); - } - // TODO(philipwalton): once the Safari bug in the following issue is fixed, - // we should be able to remove this loop and do the entry deletion in the - // cursor loop above: - // https://github.com/GoogleChrome/workbox/issues/1978 - const urlsDeleted = []; - for (const entry of entriesToDelete) { - await db.delete(CACHE_OBJECT_STORE, entry.id); - urlsDeleted.push(entry.url); - } - return urlsDeleted; - } - /** - * Takes a URL and returns an ID that will be unique in the object store. - * - * @param {string} url - * @return {string} - * - * @private - */ - _getId(url) { - // Creating an ID from the URL and cache name won't be necessary once - // Edge switches to Chromium and all browsers we support work with - // array keyPaths. - return this._cacheName + '|' + normalizeURL(url); - } - /** - * Returns an open connection to the database. - * - * @private - */ - async getDb() { - if (!this._db) { - this._db = await openDB(DB_NAME, 1, { - upgrade: this._upgradeDbAndDeleteOldDbs.bind(this) - }); - } - return this._db; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The `CacheExpiration` class allows you define an expiration and / or - * limit on the number of responses stored in a - * [`Cache`](https://developer.mozilla.org/en-US/docs/Web/API/Cache). - * - * @memberof workbox-expiration - */ - class CacheExpiration { - /** - * To construct a new CacheExpiration instance you must provide at least - * one of the `config` properties. - * - * @param {string} cacheName Name of the cache to apply restrictions to. - * @param {Object} config - * @param {number} [config.maxEntries] The maximum number of entries to cache. - * Entries used the least will be removed as the maximum is reached. - * @param {number} [config.maxAgeSeconds] The maximum age of an entry before - * it's treated as stale and removed. - * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) - * that will be used when calling `delete()` on the cache. - */ - constructor(cacheName, config = {}) { - this._isRunning = false; - this._rerunRequested = false; - { - finalAssertExports.isType(cacheName, 'string', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'cacheName' - }); - if (!(config.maxEntries || config.maxAgeSeconds)) { - throw new WorkboxError('max-entries-or-age-required', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor' - }); - } - if (config.maxEntries) { - finalAssertExports.isType(config.maxEntries, 'number', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'config.maxEntries' - }); - } - if (config.maxAgeSeconds) { - finalAssertExports.isType(config.maxAgeSeconds, 'number', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'constructor', - paramName: 'config.maxAgeSeconds' - }); - } - } - this._maxEntries = config.maxEntries; - this._maxAgeSeconds = config.maxAgeSeconds; - this._matchOptions = config.matchOptions; - this._cacheName = cacheName; - this._timestampModel = new CacheTimestampsModel(cacheName); - } - /** - * Expires entries for the given cache and given criteria. - */ - async expireEntries() { - if (this._isRunning) { - this._rerunRequested = true; - return; - } - this._isRunning = true; - const minTimestamp = this._maxAgeSeconds ? Date.now() - this._maxAgeSeconds * 1000 : 0; - const urlsExpired = await this._timestampModel.expireEntries(minTimestamp, this._maxEntries); - // Delete URLs from the cache - const cache = await self.caches.open(this._cacheName); - for (const url of urlsExpired) { - await cache.delete(url, this._matchOptions); - } - { - if (urlsExpired.length > 0) { - logger.groupCollapsed(`Expired ${urlsExpired.length} ` + `${urlsExpired.length === 1 ? 'entry' : 'entries'} and removed ` + `${urlsExpired.length === 1 ? 'it' : 'them'} from the ` + `'${this._cacheName}' cache.`); - logger.log(`Expired the following ${urlsExpired.length === 1 ? 'URL' : 'URLs'}:`); - urlsExpired.forEach(url => logger.log(` ${url}`)); - logger.groupEnd(); - } else { - logger.debug(`Cache expiration ran and found no entries to remove.`); - } - } - this._isRunning = false; - if (this._rerunRequested) { - this._rerunRequested = false; - dontWaitFor(this.expireEntries()); - } - } - /** - * Update the timestamp for the given URL. This ensures the when - * removing entries based on maximum entries, most recently used - * is accurate or when expiring, the timestamp is up-to-date. - * - * @param {string} url - */ - async updateTimestamp(url) { - { - finalAssertExports.isType(url, 'string', { - moduleName: 'workbox-expiration', - className: 'CacheExpiration', - funcName: 'updateTimestamp', - paramName: 'url' - }); - } - await this._timestampModel.setTimestamp(url, Date.now()); - } - /** - * Can be used to check if a URL has expired or not before it's used. - * - * This requires a look up from IndexedDB, so can be slow. - * - * Note: This method will not remove the cached entry, call - * `expireEntries()` to remove indexedDB and Cache entries. - * - * @param {string} url - * @return {boolean} - */ - async isURLExpired(url) { - if (!this._maxAgeSeconds) { - { - throw new WorkboxError(`expired-test-without-max-age`, { - methodName: 'isURLExpired', - paramName: 'maxAgeSeconds' - }); - } - } else { - const timestamp = await this._timestampModel.getTimestamp(url); - const expireOlderThan = Date.now() - this._maxAgeSeconds * 1000; - return timestamp !== undefined ? timestamp < expireOlderThan : true; - } - } - /** - * Removes the IndexedDB object store used to keep track of cache expiration - * metadata. - */ - async delete() { - // Make sure we don't attempt another rerun if we're called in the middle of - // a cache expiration. - this._rerunRequested = false; - await this._timestampModel.expireEntries(Infinity); // Expires all. - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This plugin can be used in a `workbox-strategy` to regularly enforce a - * limit on the age and / or the number of cached requests. - * - * It can only be used with `workbox-strategy` instances that have a - * [custom `cacheName` property set](/web/tools/workbox/guides/configure-workbox#custom_cache_names_in_strategies). - * In other words, it can't be used to expire entries in strategy that uses the - * default runtime cache name. - * - * Whenever a cached response is used or updated, this plugin will look - * at the associated cache and remove any old or extra responses. - * - * When using `maxAgeSeconds`, responses may be used *once* after expiring - * because the expiration clean up will not have occurred until *after* the - * cached response has been used. If the response has a "Date" header, then - * a light weight expiration check is performed and the response will not be - * used immediately. - * - * When using `maxEntries`, the entry least-recently requested will be removed - * from the cache first. - * - * @memberof workbox-expiration - */ - class ExpirationPlugin { - /** - * @param {ExpirationPluginOptions} config - * @param {number} [config.maxEntries] The maximum number of entries to cache. - * Entries used the least will be removed as the maximum is reached. - * @param {number} [config.maxAgeSeconds] The maximum age of an entry before - * it's treated as stale and removed. - * @param {Object} [config.matchOptions] The [`CacheQueryOptions`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters) - * that will be used when calling `delete()` on the cache. - * @param {boolean} [config.purgeOnQuotaError] Whether to opt this cache in to - * automatic deletion if the available storage quota has been exceeded. - */ - constructor(config = {}) { - /** - * A "lifecycle" callback that will be triggered automatically by the - * `workbox-strategies` handlers when a `Response` is about to be returned - * from a [Cache](https://developer.mozilla.org/en-US/docs/Web/API/Cache) to - * the handler. It allows the `Response` to be inspected for freshness and - * prevents it from being used if the `Response`'s `Date` header value is - * older than the configured `maxAgeSeconds`. - * - * @param {Object} options - * @param {string} options.cacheName Name of the cache the response is in. - * @param {Response} options.cachedResponse The `Response` object that's been - * read from a cache and whose freshness should be checked. - * @return {Response} Either the `cachedResponse`, if it's - * fresh, or `null` if the `Response` is older than `maxAgeSeconds`. - * - * @private - */ - this.cachedResponseWillBeUsed = async ({ - event, - request, - cacheName, - cachedResponse - }) => { - if (!cachedResponse) { - return null; - } - const isFresh = this._isResponseDateFresh(cachedResponse); - // Expire entries to ensure that even if the expiration date has - // expired, it'll only be used once. - const cacheExpiration = this._getCacheExpiration(cacheName); - dontWaitFor(cacheExpiration.expireEntries()); - // Update the metadata for the request URL to the current timestamp, - // but don't `await` it as we don't want to block the response. - const updateTimestampDone = cacheExpiration.updateTimestamp(request.url); - if (event) { - try { - event.waitUntil(updateTimestampDone); - } catch (error) { - { - // The event may not be a fetch event; only log the URL if it is. - if ('request' in event) { - logger.warn(`Unable to ensure service worker stays alive when ` + `updating cache entry for ` + `'${getFriendlyURL(event.request.url)}'.`); - } - } - } - } - return isFresh ? cachedResponse : null; - }; - /** - * A "lifecycle" callback that will be triggered automatically by the - * `workbox-strategies` handlers when an entry is added to a cache. - * - * @param {Object} options - * @param {string} options.cacheName Name of the cache that was updated. - * @param {string} options.request The Request for the cached entry. - * - * @private - */ - this.cacheDidUpdate = async ({ - cacheName, - request - }) => { - { - finalAssertExports.isType(cacheName, 'string', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'cacheDidUpdate', - paramName: 'cacheName' - }); - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'cacheDidUpdate', - paramName: 'request' - }); - } - const cacheExpiration = this._getCacheExpiration(cacheName); - await cacheExpiration.updateTimestamp(request.url); - await cacheExpiration.expireEntries(); - }; - { - if (!(config.maxEntries || config.maxAgeSeconds)) { - throw new WorkboxError('max-entries-or-age-required', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor' - }); - } - if (config.maxEntries) { - finalAssertExports.isType(config.maxEntries, 'number', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor', - paramName: 'config.maxEntries' - }); - } - if (config.maxAgeSeconds) { - finalAssertExports.isType(config.maxAgeSeconds, 'number', { - moduleName: 'workbox-expiration', - className: 'Plugin', - funcName: 'constructor', - paramName: 'config.maxAgeSeconds' - }); - } - } - this._config = config; - this._maxAgeSeconds = config.maxAgeSeconds; - this._cacheExpirations = new Map(); - if (config.purgeOnQuotaError) { - registerQuotaErrorCallback(() => this.deleteCacheAndMetadata()); - } - } - /** - * A simple helper method to return a CacheExpiration instance for a given - * cache name. - * - * @param {string} cacheName - * @return {CacheExpiration} - * - * @private - */ - _getCacheExpiration(cacheName) { - if (cacheName === cacheNames.getRuntimeName()) { - throw new WorkboxError('expire-custom-caches-only'); - } - let cacheExpiration = this._cacheExpirations.get(cacheName); - if (!cacheExpiration) { - cacheExpiration = new CacheExpiration(cacheName, this._config); - this._cacheExpirations.set(cacheName, cacheExpiration); - } - return cacheExpiration; - } - /** - * @param {Response} cachedResponse - * @return {boolean} - * - * @private - */ - _isResponseDateFresh(cachedResponse) { - if (!this._maxAgeSeconds) { - // We aren't expiring by age, so return true, it's fresh - return true; - } - // Check if the 'date' header will suffice a quick expiration check. - // See https://github.com/GoogleChromeLabs/sw-toolbox/issues/164 for - // discussion. - const dateHeaderTimestamp = this._getDateHeaderTimestamp(cachedResponse); - if (dateHeaderTimestamp === null) { - // Unable to parse date, so assume it's fresh. - return true; - } - // If we have a valid headerTime, then our response is fresh iff the - // headerTime plus maxAgeSeconds is greater than the current time. - const now = Date.now(); - return dateHeaderTimestamp >= now - this._maxAgeSeconds * 1000; - } - /** - * This method will extract the data header and parse it into a useful - * value. - * - * @param {Response} cachedResponse - * @return {number|null} - * - * @private - */ - _getDateHeaderTimestamp(cachedResponse) { - if (!cachedResponse.headers.has('date')) { - return null; - } - const dateHeader = cachedResponse.headers.get('date'); - const parsedDate = new Date(dateHeader); - const headerTime = parsedDate.getTime(); - // If the Date header was invalid for some reason, parsedDate.getTime() - // will return NaN. - if (isNaN(headerTime)) { - return null; - } - return headerTime; - } - /** - * This is a helper method that performs two operations: - * - * - Deletes *all* the underlying Cache instances associated with this plugin - * instance, by calling caches.delete() on your behalf. - * - Deletes the metadata from IndexedDB used to keep track of expiration - * details for each Cache instance. - * - * When using cache expiration, calling this method is preferable to calling - * `caches.delete()` directly, since this will ensure that the IndexedDB - * metadata is also cleanly removed and open IndexedDB instances are deleted. - * - * Note that if you're *not* using cache expiration for a given cache, calling - * `caches.delete()` and passing in the cache's name should be sufficient. - * There is no Workbox-specific method needed for cleanup in that case. - */ - async deleteCacheAndMetadata() { - // Do this one at a time instead of all at once via `Promise.all()` to - // reduce the chance of inconsistency if a promise rejects. - for (const [cacheName, cacheExpiration] of this._cacheExpirations) { - await self.caches.delete(cacheName); - await cacheExpiration.delete(); - } - // Reset this._cacheExpirations to its initial state. - this._cacheExpirations = new Map(); - } - } - - // @ts-ignore - try { - self['workbox:cacheable-response:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This class allows you to set up rules determining what - * status codes and/or headers need to be present in order for a - * [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) - * to be considered cacheable. - * - * @memberof workbox-cacheable-response - */ - class CacheableResponse { - /** - * To construct a new CacheableResponse instance you must provide at least - * one of the `config` properties. - * - * If both `statuses` and `headers` are specified, then both conditions must - * be met for the `Response` to be considered cacheable. - * - * @param {Object} config - * @param {Array} [config.statuses] One or more status codes that a - * `Response` can have and be considered cacheable. - * @param {Object} [config.headers] A mapping of header names - * and expected values that a `Response` can have and be considered cacheable. - * If multiple headers are provided, only one needs to be present. - */ - constructor(config = {}) { - { - if (!(config.statuses || config.headers)) { - throw new WorkboxError('statuses-or-headers-required', { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor' - }); - } - if (config.statuses) { - finalAssertExports.isArray(config.statuses, { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor', - paramName: 'config.statuses' - }); - } - if (config.headers) { - finalAssertExports.isType(config.headers, 'object', { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'constructor', - paramName: 'config.headers' - }); - } - } - this._statuses = config.statuses; - this._headers = config.headers; - } - /** - * Checks a response to see whether it's cacheable or not, based on this - * object's configuration. - * - * @param {Response} response The response whose cacheability is being - * checked. - * @return {boolean} `true` if the `Response` is cacheable, and `false` - * otherwise. - */ - isResponseCacheable(response) { - { - finalAssertExports.isInstance(response, Response, { - moduleName: 'workbox-cacheable-response', - className: 'CacheableResponse', - funcName: 'isResponseCacheable', - paramName: 'response' - }); - } - let cacheable = true; - if (this._statuses) { - cacheable = this._statuses.includes(response.status); - } - if (this._headers && cacheable) { - cacheable = Object.keys(this._headers).some(headerName => { - return response.headers.get(headerName) === this._headers[headerName]; - }); - } - { - if (!cacheable) { - logger.groupCollapsed(`The request for ` + `'${getFriendlyURL(response.url)}' returned a response that does ` + `not meet the criteria for being cached.`); - logger.groupCollapsed(`View cacheability criteria here.`); - logger.log(`Cacheable statuses: ` + JSON.stringify(this._statuses)); - logger.log(`Cacheable headers: ` + JSON.stringify(this._headers, null, 2)); - logger.groupEnd(); - const logFriendlyHeaders = {}; - response.headers.forEach((value, key) => { - logFriendlyHeaders[key] = value; - }); - logger.groupCollapsed(`View response status and headers here.`); - logger.log(`Response status: ${response.status}`); - logger.log(`Response headers: ` + JSON.stringify(logFriendlyHeaders, null, 2)); - logger.groupEnd(); - logger.groupCollapsed(`View full response details here.`); - logger.log(response.headers); - logger.log(response); - logger.groupEnd(); - logger.groupEnd(); - } - } - return cacheable; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A class implementing the `cacheWillUpdate` lifecycle callback. This makes it - * easier to add in cacheability checks to requests made via Workbox's built-in - * strategies. - * - * @memberof workbox-cacheable-response - */ - class CacheableResponsePlugin { - /** - * To construct a new CacheableResponsePlugin instance you must provide at - * least one of the `config` properties. - * - * If both `statuses` and `headers` are specified, then both conditions must - * be met for the `Response` to be considered cacheable. - * - * @param {Object} config - * @param {Array} [config.statuses] One or more status codes that a - * `Response` can have and be considered cacheable. - * @param {Object} [config.headers] A mapping of header names - * and expected values that a `Response` can have and be considered cacheable. - * If multiple headers are provided, only one needs to be present. - */ - constructor(config) { - /** - * @param {Object} options - * @param {Response} options.response - * @return {Response|null} - * @private - */ - this.cacheWillUpdate = async ({ - response - }) => { - if (this._cacheableResponse.isResponseCacheable(response)) { - return response; - } - return null; - }; - this._cacheableResponse = new CacheableResponse(config); - } - } - - /* - Copyright 2020 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - function stripParams(fullURL, ignoreParams) { - const strippedURL = new URL(fullURL); - for (const param of ignoreParams) { - strippedURL.searchParams.delete(param); - } - return strippedURL.href; - } - /** - * Matches an item in the cache, ignoring specific URL params. This is similar - * to the `ignoreSearch` option, but it allows you to ignore just specific - * params (while continuing to match on the others). - * - * @private - * @param {Cache} cache - * @param {Request} request - * @param {Object} matchOptions - * @param {Array} ignoreParams - * @return {Promise} - */ - async function cacheMatchIgnoreParams(cache, request, ignoreParams, matchOptions) { - const strippedRequestURL = stripParams(request.url, ignoreParams); - // If the request doesn't include any ignored params, match as normal. - if (request.url === strippedRequestURL) { - return cache.match(request, matchOptions); - } - // Otherwise, match by comparing keys - const keysOptions = Object.assign(Object.assign({}, matchOptions), { - ignoreSearch: true - }); - const cacheKeys = await cache.keys(request, keysOptions); - for (const cacheKey of cacheKeys) { - const strippedCacheKeyURL = stripParams(cacheKey.url, ignoreParams); - if (strippedRequestURL === strippedCacheKeyURL) { - return cache.match(cacheKey, matchOptions); - } - } - return; - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * The Deferred class composes Promises in a way that allows for them to be - * resolved or rejected from outside the constructor. In most cases promises - * should be used directly, but Deferreds can be necessary when the logic to - * resolve a promise must be separate. - * - * @private - */ - class Deferred { - /** - * Creates a promise and exposes its resolve and reject functions as methods. - */ - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Runs all of the callback functions, one at a time sequentially, in the order - * in which they were registered. - * - * @memberof workbox-core - * @private - */ - async function executeQuotaErrorCallbacks() { - { - logger.log(`About to run ${quotaErrorCallbacks.size} ` + `callbacks to clean up caches.`); - } - for (const callback of quotaErrorCallbacks) { - await callback(); - { - logger.log(callback, 'is complete.'); - } - } - { - logger.log('Finished running callbacks.'); - } - } - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Returns a promise that resolves and the passed number of milliseconds. - * This utility is an async/await-friendly version of `setTimeout`. - * - * @param {number} ms - * @return {Promise} - * @private - */ - function timeout(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - // @ts-ignore - try { - self['workbox:strategies:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - function toRequest(input) { - return typeof input === 'string' ? new Request(input) : input; - } - /** - * A class created every time a Strategy instance calls - * {@link workbox-strategies.Strategy~handle} or - * {@link workbox-strategies.Strategy~handleAll} that wraps all fetch and - * cache actions around plugin callbacks and keeps track of when the strategy - * is "done" (i.e. all added `event.waitUntil()` promises have resolved). - * - * @memberof workbox-strategies - */ - class StrategyHandler { - /** - * Creates a new instance associated with the passed strategy and event - * that's handling the request. - * - * The constructor also initializes the state that will be passed to each of - * the plugins handling this request. - * - * @param {workbox-strategies.Strategy} strategy - * @param {Object} options - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] The return value from the - * {@link workbox-routing~matchCallback} (if applicable). - */ - constructor(strategy, options) { - this._cacheKeys = {}; - /** - * The request the strategy is performing (passed to the strategy's - * `handle()` or `handleAll()` method). - * @name request - * @instance - * @type {Request} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * The event associated with this request. - * @name event - * @instance - * @type {ExtendableEvent} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * A `URL` instance of `request.url` (if passed to the strategy's - * `handle()` or `handleAll()` method). - * Note: the `url` param will be present if the strategy was invoked - * from a workbox `Route` object. - * @name url - * @instance - * @type {URL|undefined} - * @memberof workbox-strategies.StrategyHandler - */ - /** - * A `param` value (if passed to the strategy's - * `handle()` or `handleAll()` method). - * Note: the `param` param will be present if the strategy was invoked - * from a workbox `Route` object and the - * {@link workbox-routing~matchCallback} returned - * a truthy value (it will be that value). - * @name params - * @instance - * @type {*|undefined} - * @memberof workbox-strategies.StrategyHandler - */ - { - finalAssertExports.isInstance(options.event, ExtendableEvent, { - moduleName: 'workbox-strategies', - className: 'StrategyHandler', - funcName: 'constructor', - paramName: 'options.event' - }); - } - Object.assign(this, options); - this.event = options.event; - this._strategy = strategy; - this._handlerDeferred = new Deferred(); - this._extendLifetimePromises = []; - // Copy the plugins list (since it's mutable on the strategy), - // so any mutations don't affect this handler instance. - this._plugins = [...strategy.plugins]; - this._pluginStateMap = new Map(); - for (const plugin of this._plugins) { - this._pluginStateMap.set(plugin, {}); - } - this.event.waitUntil(this._handlerDeferred.promise); - } - /** - * Fetches a given request (and invokes any applicable plugin callback - * methods) using the `fetchOptions` (for non-navigation requests) and - * `plugins` defined on the `Strategy` object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - `requestWillFetch()` - * - `fetchDidSucceed()` - * - `fetchDidFail()` - * - * @param {Request|string} input The URL or request to fetch. - * @return {Promise} - */ - async fetch(input) { - const { - event - } = this; - let request = toRequest(input); - if (request.mode === 'navigate' && event instanceof FetchEvent && event.preloadResponse) { - const possiblePreloadResponse = await event.preloadResponse; - if (possiblePreloadResponse) { - { - logger.log(`Using a preloaded navigation response for ` + `'${getFriendlyURL(request.url)}'`); - } - return possiblePreloadResponse; - } - } - // If there is a fetchDidFail plugin, we need to save a clone of the - // original request before it's either modified by a requestWillFetch - // plugin or before the original request's body is consumed via fetch(). - const originalRequest = this.hasCallback('fetchDidFail') ? request.clone() : null; - try { - for (const cb of this.iterateCallbacks('requestWillFetch')) { - request = await cb({ - request: request.clone(), - event - }); - } - } catch (err) { - if (err instanceof Error) { - throw new WorkboxError('plugin-error-request-will-fetch', { - thrownErrorMessage: err.message - }); - } - } - // The request can be altered by plugins with `requestWillFetch` making - // the original request (most likely from a `fetch` event) different - // from the Request we make. Pass both to `fetchDidFail` to aid debugging. - const pluginFilteredRequest = request.clone(); - try { - let fetchResponse; - // See https://github.com/GoogleChrome/workbox/issues/1796 - fetchResponse = await fetch(request, request.mode === 'navigate' ? undefined : this._strategy.fetchOptions); - if ("development" !== 'production') { - logger.debug(`Network request for ` + `'${getFriendlyURL(request.url)}' returned a response with ` + `status '${fetchResponse.status}'.`); - } - for (const callback of this.iterateCallbacks('fetchDidSucceed')) { - fetchResponse = await callback({ - event, - request: pluginFilteredRequest, - response: fetchResponse - }); - } - return fetchResponse; - } catch (error) { - { - logger.log(`Network request for ` + `'${getFriendlyURL(request.url)}' threw an error.`, error); - } - // `originalRequest` will only exist if a `fetchDidFail` callback - // is being used (see above). - if (originalRequest) { - await this.runCallbacks('fetchDidFail', { - error: error, - event, - originalRequest: originalRequest.clone(), - request: pluginFilteredRequest.clone() - }); - } - throw error; - } - } - /** - * Calls `this.fetch()` and (in the background) runs `this.cachePut()` on - * the response generated by `this.fetch()`. - * - * The call to `this.cachePut()` automatically invokes `this.waitUntil()`, - * so you do not have to manually call `waitUntil()` on the event. - * - * @param {Request|string} input The request or URL to fetch and cache. - * @return {Promise} - */ - async fetchAndCachePut(input) { - const response = await this.fetch(input); - const responseClone = response.clone(); - void this.waitUntil(this.cachePut(input, responseClone)); - return response; - } - /** - * Matches a request from the cache (and invokes any applicable plugin - * callback methods) using the `cacheName`, `matchOptions`, and `plugins` - * defined on the strategy object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - cacheKeyWillBeUsed() - * - cachedResponseWillBeUsed() - * - * @param {Request|string} key The Request or URL to use as the cache key. - * @return {Promise} A matching response, if found. - */ - async cacheMatch(key) { - const request = toRequest(key); - let cachedResponse; - const { - cacheName, - matchOptions - } = this._strategy; - const effectiveRequest = await this.getCacheKey(request, 'read'); - const multiMatchOptions = Object.assign(Object.assign({}, matchOptions), { - cacheName - }); - cachedResponse = await caches.match(effectiveRequest, multiMatchOptions); - { - if (cachedResponse) { - logger.debug(`Found a cached response in '${cacheName}'.`); - } else { - logger.debug(`No cached response found in '${cacheName}'.`); - } - } - for (const callback of this.iterateCallbacks('cachedResponseWillBeUsed')) { - cachedResponse = (await callback({ - cacheName, - matchOptions, - cachedResponse, - request: effectiveRequest, - event: this.event - })) || undefined; - } - return cachedResponse; - } - /** - * Puts a request/response pair in the cache (and invokes any applicable - * plugin callback methods) using the `cacheName` and `plugins` defined on - * the strategy object. - * - * The following plugin lifecycle methods are invoked when using this method: - * - cacheKeyWillBeUsed() - * - cacheWillUpdate() - * - cacheDidUpdate() - * - * @param {Request|string} key The request or URL to use as the cache key. - * @param {Response} response The response to cache. - * @return {Promise} `false` if a cacheWillUpdate caused the response - * not be cached, and `true` otherwise. - */ - async cachePut(key, response) { - const request = toRequest(key); - // Run in the next task to avoid blocking other cache reads. - // https://github.com/w3c/ServiceWorker/issues/1397 - await timeout(0); - const effectiveRequest = await this.getCacheKey(request, 'write'); - { - if (effectiveRequest.method && effectiveRequest.method !== 'GET') { - throw new WorkboxError('attempt-to-cache-non-get-request', { - url: getFriendlyURL(effectiveRequest.url), - method: effectiveRequest.method - }); - } - // See https://github.com/GoogleChrome/workbox/issues/2818 - const vary = response.headers.get('Vary'); - if (vary) { - logger.debug(`The response for ${getFriendlyURL(effectiveRequest.url)} ` + `has a 'Vary: ${vary}' header. ` + `Consider setting the {ignoreVary: true} option on your strategy ` + `to ensure cache matching and deletion works as expected.`); - } - } - if (!response) { - { - logger.error(`Cannot cache non-existent response for ` + `'${getFriendlyURL(effectiveRequest.url)}'.`); - } - throw new WorkboxError('cache-put-with-no-response', { - url: getFriendlyURL(effectiveRequest.url) - }); - } - const responseToCache = await this._ensureResponseSafeToCache(response); - if (!responseToCache) { - { - logger.debug(`Response '${getFriendlyURL(effectiveRequest.url)}' ` + `will not be cached.`, responseToCache); - } - return false; - } - const { - cacheName, - matchOptions - } = this._strategy; - const cache = await self.caches.open(cacheName); - const hasCacheUpdateCallback = this.hasCallback('cacheDidUpdate'); - const oldResponse = hasCacheUpdateCallback ? await cacheMatchIgnoreParams( - // TODO(philipwalton): the `__WB_REVISION__` param is a precaching - // feature. Consider into ways to only add this behavior if using - // precaching. - cache, effectiveRequest.clone(), ['__WB_REVISION__'], matchOptions) : null; - { - logger.debug(`Updating the '${cacheName}' cache with a new Response ` + `for ${getFriendlyURL(effectiveRequest.url)}.`); - } - try { - await cache.put(effectiveRequest, hasCacheUpdateCallback ? responseToCache.clone() : responseToCache); - } catch (error) { - if (error instanceof Error) { - // See https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-QuotaExceededError - if (error.name === 'QuotaExceededError') { - await executeQuotaErrorCallbacks(); - } - throw error; - } - } - for (const callback of this.iterateCallbacks('cacheDidUpdate')) { - await callback({ - cacheName, - oldResponse, - newResponse: responseToCache.clone(), - request: effectiveRequest, - event: this.event - }); - } - return true; - } - /** - * Checks the list of plugins for the `cacheKeyWillBeUsed` callback, and - * executes any of those callbacks found in sequence. The final `Request` - * object returned by the last plugin is treated as the cache key for cache - * reads and/or writes. If no `cacheKeyWillBeUsed` plugin callbacks have - * been registered, the passed request is returned unmodified - * - * @param {Request} request - * @param {string} mode - * @return {Promise} - */ - async getCacheKey(request, mode) { - const key = `${request.url} | ${mode}`; - if (!this._cacheKeys[key]) { - let effectiveRequest = request; - for (const callback of this.iterateCallbacks('cacheKeyWillBeUsed')) { - effectiveRequest = toRequest(await callback({ - mode, - request: effectiveRequest, - event: this.event, - // params has a type any can't change right now. - params: this.params // eslint-disable-line - })); - } - this._cacheKeys[key] = effectiveRequest; - } - return this._cacheKeys[key]; - } - /** - * Returns true if the strategy has at least one plugin with the given - * callback. - * - * @param {string} name The name of the callback to check for. - * @return {boolean} - */ - hasCallback(name) { - for (const plugin of this._strategy.plugins) { - if (name in plugin) { - return true; - } - } - return false; - } - /** - * Runs all plugin callbacks matching the given name, in order, passing the - * given param object (merged ith the current plugin state) as the only - * argument. - * - * Note: since this method runs all plugins, it's not suitable for cases - * where the return value of a callback needs to be applied prior to calling - * the next callback. See - * {@link workbox-strategies.StrategyHandler#iterateCallbacks} - * below for how to handle that case. - * - * @param {string} name The name of the callback to run within each plugin. - * @param {Object} param The object to pass as the first (and only) param - * when executing each callback. This object will be merged with the - * current plugin state prior to callback execution. - */ - async runCallbacks(name, param) { - for (const callback of this.iterateCallbacks(name)) { - // TODO(philipwalton): not sure why `any` is needed. It seems like - // this should work with `as WorkboxPluginCallbackParam[C]`. - await callback(param); - } - } - /** - * Accepts a callback and returns an iterable of matching plugin callbacks, - * where each callback is wrapped with the current handler state (i.e. when - * you call each callback, whatever object parameter you pass it will - * be merged with the plugin's current state). - * - * @param {string} name The name fo the callback to run - * @return {Array} - */ - *iterateCallbacks(name) { - for (const plugin of this._strategy.plugins) { - if (typeof plugin[name] === 'function') { - const state = this._pluginStateMap.get(plugin); - const statefulCallback = param => { - const statefulParam = Object.assign(Object.assign({}, param), { - state - }); - // TODO(philipwalton): not sure why `any` is needed. It seems like - // this should work with `as WorkboxPluginCallbackParam[C]`. - return plugin[name](statefulParam); - }; - yield statefulCallback; - } - } - } - /** - * Adds a promise to the - * [extend lifetime promises]{@link https://w3c.github.io/ServiceWorker/#extendableevent-extend-lifetime-promises} - * of the event associated with the request being handled (usually a - * `FetchEvent`). - * - * Note: you can await - * {@link workbox-strategies.StrategyHandler~doneWaiting} - * to know when all added promises have settled. - * - * @param {Promise} promise A promise to add to the extend lifetime promises - * of the event that triggered the request. - */ - waitUntil(promise) { - this._extendLifetimePromises.push(promise); - return promise; - } - /** - * Returns a promise that resolves once all promises passed to - * {@link workbox-strategies.StrategyHandler~waitUntil} - * have settled. - * - * Note: any work done after `doneWaiting()` settles should be manually - * passed to an event's `waitUntil()` method (not this handler's - * `waitUntil()` method), otherwise the service worker thread may be killed - * prior to your work completing. - */ - async doneWaiting() { - while (this._extendLifetimePromises.length) { - const promises = this._extendLifetimePromises.splice(0); - const result = await Promise.allSettled(promises); - const firstRejection = result.find(i => i.status === 'rejected'); - if (firstRejection) { - throw firstRejection.reason; - } - } - } - /** - * Stops running the strategy and immediately resolves any pending - * `waitUntil()` promises. - */ - destroy() { - this._handlerDeferred.resolve(null); - } - /** - * This method will call cacheWillUpdate on the available plugins (or use - * status === 200) to determine if the Response is safe and valid to cache. - * - * @param {Request} options.request - * @param {Response} options.response - * @return {Promise} - * - * @private - */ - async _ensureResponseSafeToCache(response) { - let responseToCache = response; - let pluginsUsed = false; - for (const callback of this.iterateCallbacks('cacheWillUpdate')) { - responseToCache = (await callback({ - request: this.request, - response: responseToCache, - event: this.event - })) || undefined; - pluginsUsed = true; - if (!responseToCache) { - break; - } - } - if (!pluginsUsed) { - if (responseToCache && responseToCache.status !== 200) { - responseToCache = undefined; - } - { - if (responseToCache) { - if (responseToCache.status !== 200) { - if (responseToCache.status === 0) { - logger.warn(`The response for '${this.request.url}' ` + `is an opaque response. The caching strategy that you're ` + `using will not cache opaque responses by default.`); - } else { - logger.debug(`The response for '${this.request.url}' ` + `returned a status code of '${response.status}' and won't ` + `be cached as a result.`); - } - } - } - } - } - return responseToCache; - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An abstract base class that all other strategy classes must extend from: - * - * @memberof workbox-strategies - */ - class Strategy { - /** - * Creates a new instance of the strategy and sets all documented option - * properties as public instance properties. - * - * Note: if a custom strategy class extends the base Strategy class and does - * not need more than these properties, it does not need to define its own - * constructor. - * - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) - * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) - * `fetch()` requests made by this strategy. - * @param {Object} [options.matchOptions] The - * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - */ - constructor(options = {}) { - /** - * Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * - * @type {string} - */ - this.cacheName = cacheNames.getRuntimeName(options.cacheName); - /** - * The list - * [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * used by this strategy. - * - * @type {Array} - */ - this.plugins = options.plugins || []; - /** - * Values passed along to the - * [`init`]{@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters} - * of all fetch() requests made by this strategy. - * - * @type {Object} - */ - this.fetchOptions = options.fetchOptions; - /** - * The - * [`CacheQueryOptions`]{@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - * - * @type {Object} - */ - this.matchOptions = options.matchOptions; - } - /** - * Perform a request strategy and returns a `Promise` that will resolve with - * a `Response`, invoking all relevant plugin callbacks. - * - * When a strategy instance is registered with a Workbox - * {@link workbox-routing.Route}, this method is automatically - * called when the route matches. - * - * Alternatively, this method can be used in a standalone `FetchEvent` - * listener by passing it to `event.respondWith()`. - * - * @param {FetchEvent|Object} options A `FetchEvent` or an object with the - * properties listed below. - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] - */ - handle(options) { - const [responseDone] = this.handleAll(options); - return responseDone; - } - /** - * Similar to {@link workbox-strategies.Strategy~handle}, but - * instead of just returning a `Promise` that resolves to a `Response` it - * it will return an tuple of `[response, done]` promises, where the former - * (`response`) is equivalent to what `handle()` returns, and the latter is a - * Promise that will resolve once any promises that were added to - * `event.waitUntil()` as part of performing the strategy have completed. - * - * You can await the `done` promise to ensure any extra work performed by - * the strategy (usually caching responses) completes successfully. - * - * @param {FetchEvent|Object} options A `FetchEvent` or an object with the - * properties listed below. - * @param {Request|string} options.request A request to run this strategy for. - * @param {ExtendableEvent} options.event The event associated with the - * request. - * @param {URL} [options.url] - * @param {*} [options.params] - * @return {Array} A tuple of [response, done] - * promises that can be used to determine when the response resolves as - * well as when the handler has completed all its work. - */ - handleAll(options) { - // Allow for flexible options to be passed. - if (options instanceof FetchEvent) { - options = { - event: options, - request: options.request - }; - } - const event = options.event; - const request = typeof options.request === 'string' ? new Request(options.request) : options.request; - const params = 'params' in options ? options.params : undefined; - const handler = new StrategyHandler(this, { - event, - request, - params - }); - const responseDone = this._getResponse(handler, request, event); - const handlerDone = this._awaitComplete(responseDone, handler, request, event); - // Return an array of promises, suitable for use with Promise.all(). - return [responseDone, handlerDone]; - } - async _getResponse(handler, request, event) { - await handler.runCallbacks('handlerWillStart', { - event, - request - }); - let response = undefined; - try { - response = await this._handle(request, handler); - // The "official" Strategy subclasses all throw this error automatically, - // but in case a third-party Strategy doesn't, ensure that we have a - // consistent failure when there's no response or an error response. - if (!response || response.type === 'error') { - throw new WorkboxError('no-response', { - url: request.url - }); - } - } catch (error) { - if (error instanceof Error) { - for (const callback of handler.iterateCallbacks('handlerDidError')) { - response = await callback({ - error, - event, - request - }); - if (response) { - break; - } - } - } - if (!response) { - throw error; - } else { - logger.log(`While responding to '${getFriendlyURL(request.url)}', ` + `an ${error instanceof Error ? error.toString() : ''} error occurred. Using a fallback response provided by ` + `a handlerDidError plugin.`); - } - } - for (const callback of handler.iterateCallbacks('handlerWillRespond')) { - response = await callback({ - event, - request, - response - }); - } - return response; - } - async _awaitComplete(responseDone, handler, request, event) { - let response; - let error; - try { - response = await responseDone; - } catch (error) { - // Ignore errors, as response errors should be caught via the `response` - // promise above. The `done` promise will only throw for errors in - // promises passed to `handler.waitUntil()`. - } - try { - await handler.runCallbacks('handlerDidRespond', { - event, - request, - response - }); - await handler.doneWaiting(); - } catch (waitUntilError) { - if (waitUntilError instanceof Error) { - error = waitUntilError; - } - } - await handler.runCallbacks('handlerDidComplete', { - event, - request, - response, - error: error - }); - handler.destroy(); - if (error) { - throw error; - } - } - } - /** - * Classes extending the `Strategy` based class should implement this method, - * and leverage the {@link workbox-strategies.StrategyHandler} - * arg to perform all fetching and cache logic, which will ensure all relevant - * cache, cache options, fetch options and plugins are used (per the current - * strategy instance). - * - * @name _handle - * @instance - * @abstract - * @function - * @param {Request} request - * @param {workbox-strategies.StrategyHandler} handler - * @return {Promise} - * - * @memberof workbox-strategies.Strategy - */ - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const messages = { - strategyStart: (strategyName, request) => `Using ${strategyName} to respond to '${getFriendlyURL(request.url)}'`, - printFinalResponse: response => { - if (response) { - logger.groupCollapsed(`View the final response here.`); - logger.log(response || '[No response returned]'); - logger.groupEnd(); - } - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An implementation of a [cache-first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#cache-first-falling-back-to-network) - * request strategy. - * - * A cache first strategy is useful for assets that have been revisioned, - * such as URLs like `/styles/example.a8f5f1.css`, since they - * can be cached for long periods of time. - * - * If the network request fails, and there is no cache match, this will throw - * a `WorkboxError` exception. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-strategies - */ - class CacheFirst extends Strategy { - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const logs = []; - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'makeRequest', - paramName: 'request' - }); - } - let response = await handler.cacheMatch(request); - let error = undefined; - if (!response) { - { - logs.push(`No response found in the '${this.cacheName}' cache. ` + `Will respond with a network request.`); - } - try { - response = await handler.fetchAndCachePut(request); - } catch (err) { - if (err instanceof Error) { - error = err; - } - } - { - if (response) { - logs.push(`Got response from network.`); - } else { - logs.push(`Unable to get a response from the network.`); - } - } - } else { - { - logs.push(`Found a cached response in the '${this.cacheName}' cache.`); - } - } - { - logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); - for (const log of logs) { - logger.log(log); - } - messages.printFinalResponse(response); - logger.groupEnd(); - } - if (!response) { - throw new WorkboxError('no-response', { - url: request.url, - error - }); - } - return response; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const cacheOkAndOpaquePlugin = { - /** - * Returns a valid response (to allow caching) if the status is 200 (OK) or - * 0 (opaque). - * - * @param {Object} options - * @param {Response} options.response - * @return {Response|null} - * - * @private - */ - cacheWillUpdate: async ({ - response - }) => { - if (response.status === 200 || response.status === 0) { - return response; - } - return null; - } - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * An implementation of a - * [network first](https://developer.chrome.com/docs/workbox/caching-strategies-overview/#network-first-falling-back-to-cache) - * request strategy. - * - * By default, this strategy will cache responses with a 200 status code as - * well as [opaque responses](https://developer.chrome.com/docs/workbox/caching-resources-during-runtime/#opaque-responses). - * Opaque responses are are cross-origin requests where the response doesn't - * support [CORS](https://enable-cors.org/). - * - * If the network request fails, and there is no cache match, this will throw - * a `WorkboxError` exception. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-strategies - */ - class NetworkFirst extends Strategy { - /** - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] [Plugins]{@link https://developers.google.com/web/tools/workbox/guides/using-plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * [`init`](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters) - * of [non-navigation](https://github.com/GoogleChrome/workbox/issues/1796) - * `fetch()` requests made by this strategy. - * @param {Object} [options.matchOptions] [`CacheQueryOptions`](https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions) - * @param {number} [options.networkTimeoutSeconds] If set, any network requests - * that fail to respond within the timeout will fallback to the cache. - * - * This option can be used to combat - * "[lie-fi]{@link https://developers.google.com/web/fundamentals/performance/poor-connectivity/#lie-fi}" - * scenarios. - */ - constructor(options = {}) { - super(options); - // If this instance contains no plugins with a 'cacheWillUpdate' callback, - // prepend the `cacheOkAndOpaquePlugin` plugin to the plugins list. - if (!this.plugins.some(p => 'cacheWillUpdate' in p)) { - this.plugins.unshift(cacheOkAndOpaquePlugin); - } - this._networkTimeoutSeconds = options.networkTimeoutSeconds || 0; - { - if (this._networkTimeoutSeconds) { - finalAssertExports.isType(this._networkTimeoutSeconds, 'number', { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'constructor', - paramName: 'networkTimeoutSeconds' - }); - } - } - } - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const logs = []; - { - finalAssertExports.isInstance(request, Request, { - moduleName: 'workbox-strategies', - className: this.constructor.name, - funcName: 'handle', - paramName: 'makeRequest' - }); - } - const promises = []; - let timeoutId; - if (this._networkTimeoutSeconds) { - const { - id, - promise - } = this._getTimeoutPromise({ - request, - logs, - handler - }); - timeoutId = id; - promises.push(promise); - } - const networkPromise = this._getNetworkPromise({ - timeoutId, - request, - logs, - handler - }); - promises.push(networkPromise); - const response = await handler.waitUntil((async () => { - // Promise.race() will resolve as soon as the first promise resolves. - return (await handler.waitUntil(Promise.race(promises))) || ( - // If Promise.race() resolved with null, it might be due to a network - // timeout + a cache miss. If that were to happen, we'd rather wait until - // the networkPromise resolves instead of returning null. - // Note that it's fine to await an already-resolved promise, so we don't - // have to check to see if it's still "in flight". - await networkPromise); - })()); - { - logger.groupCollapsed(messages.strategyStart(this.constructor.name, request)); - for (const log of logs) { - logger.log(log); - } - messages.printFinalResponse(response); - logger.groupEnd(); - } - if (!response) { - throw new WorkboxError('no-response', { - url: request.url - }); - } - return response; - } - /** - * @param {Object} options - * @param {Request} options.request - * @param {Array} options.logs A reference to the logs array - * @param {Event} options.event - * @return {Promise} - * - * @private - */ - _getTimeoutPromise({ - request, - logs, - handler - }) { - let timeoutId; - const timeoutPromise = new Promise(resolve => { - const onNetworkTimeout = async () => { - { - logs.push(`Timing out the network response at ` + `${this._networkTimeoutSeconds} seconds.`); - } - resolve(await handler.cacheMatch(request)); - }; - timeoutId = setTimeout(onNetworkTimeout, this._networkTimeoutSeconds * 1000); - }); - return { - promise: timeoutPromise, - id: timeoutId - }; - } - /** - * @param {Object} options - * @param {number|undefined} options.timeoutId - * @param {Request} options.request - * @param {Array} options.logs A reference to the logs Array. - * @param {Event} options.event - * @return {Promise} - * - * @private - */ - async _getNetworkPromise({ - timeoutId, - request, - logs, - handler - }) { - let error; - let response; - try { - response = await handler.fetchAndCachePut(request); - } catch (fetchError) { - if (fetchError instanceof Error) { - error = fetchError; - } - } - if (timeoutId) { - clearTimeout(timeoutId); - } - { - if (response) { - logs.push(`Got response from network.`); - } else { - logs.push(`Unable to get a response from the network. Will respond ` + `with a cached response.`); - } - } - if (error || !response) { - response = await handler.cacheMatch(request); - { - if (response) { - logs.push(`Found a cached response in the '${this.cacheName}'` + ` cache.`); - } else { - logs.push(`No response found in the '${this.cacheName}' cache.`); - } - } - } - return response; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Claim any currently available clients once the service worker - * becomes active. This is normally used in conjunction with `skipWaiting()`. - * - * @memberof workbox-core - */ - function clientsClaim() { - self.addEventListener('activate', () => self.clients.claim()); - } - - /* - Copyright 2020 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A utility method that makes it easier to use `event.waitUntil` with - * async functions and return the result. - * - * @param {ExtendableEvent} event - * @param {Function} asyncFn - * @return {Function} - * @private - */ - function waitUntil(event, asyncFn) { - const returnPromise = asyncFn(); - event.waitUntil(returnPromise); - return returnPromise; - } - - // @ts-ignore - try { - self['workbox:precaching:7.3.0'] && _(); - } catch (e) {} - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - // Name of the search parameter used to store revision info. - const REVISION_SEARCH_PARAM = '__WB_REVISION__'; - /** - * Converts a manifest entry into a versioned URL suitable for precaching. - * - * @param {Object|string} entry - * @return {string} A URL with versioning info. - * - * @private - * @memberof workbox-precaching - */ - function createCacheKey(entry) { - if (!entry) { - throw new WorkboxError('add-to-cache-list-unexpected-type', { - entry - }); - } - // If a precache manifest entry is a string, it's assumed to be a versioned - // URL, like '/app.abcd1234.js'. Return as-is. - if (typeof entry === 'string') { - const urlObject = new URL(entry, location.href); - return { - cacheKey: urlObject.href, - url: urlObject.href - }; - } - const { - revision, - url - } = entry; - if (!url) { - throw new WorkboxError('add-to-cache-list-unexpected-type', { - entry - }); - } - // If there's just a URL and no revision, then it's also assumed to be a - // versioned URL. - if (!revision) { - const urlObject = new URL(url, location.href); - return { - cacheKey: urlObject.href, - url: urlObject.href - }; - } - // Otherwise, construct a properly versioned URL using the custom Workbox - // search parameter along with the revision info. - const cacheKeyURL = new URL(url, location.href); - const originalURL = new URL(url, location.href); - cacheKeyURL.searchParams.set(REVISION_SEARCH_PARAM, revision); - return { - cacheKey: cacheKeyURL.href, - url: originalURL.href - }; - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A plugin, designed to be used with PrecacheController, to determine the - * of assets that were updated (or not updated) during the install event. - * - * @private - */ - class PrecacheInstallReportPlugin { - constructor() { - this.updatedURLs = []; - this.notUpdatedURLs = []; - this.handlerWillStart = async ({ - request, - state - }) => { - // TODO: `state` should never be undefined... - if (state) { - state.originalRequest = request; - } - }; - this.cachedResponseWillBeUsed = async ({ - event, - state, - cachedResponse - }) => { - if (event.type === 'install') { - if (state && state.originalRequest && state.originalRequest instanceof Request) { - // TODO: `state` should never be undefined... - const url = state.originalRequest.url; - if (cachedResponse) { - this.notUpdatedURLs.push(url); - } else { - this.updatedURLs.push(url); - } - } - } - return cachedResponse; - }; - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A plugin, designed to be used with PrecacheController, to translate URLs into - * the corresponding cache key, based on the current revision info. - * - * @private - */ - class PrecacheCacheKeyPlugin { - constructor({ - precacheController - }) { - this.cacheKeyWillBeUsed = async ({ - request, - params - }) => { - // Params is type any, can't change right now. - /* eslint-disable */ - const cacheKey = (params === null || params === void 0 ? void 0 : params.cacheKey) || this._precacheController.getCacheKeyForURL(request.url); - /* eslint-enable */ - return cacheKey ? new Request(cacheKey, { - headers: request.headers - }) : request; - }; - this._precacheController = precacheController; - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {string} groupTitle - * @param {Array} deletedURLs - * - * @private - */ - const logGroup = (groupTitle, deletedURLs) => { - logger.groupCollapsed(groupTitle); - for (const url of deletedURLs) { - logger.log(url); - } - logger.groupEnd(); - }; - /** - * @param {Array} deletedURLs - * - * @private - * @memberof workbox-precaching - */ - function printCleanupDetails(deletedURLs) { - const deletionCount = deletedURLs.length; - if (deletionCount > 0) { - logger.groupCollapsed(`During precaching cleanup, ` + `${deletionCount} cached ` + `request${deletionCount === 1 ? ' was' : 's were'} deleted.`); - logGroup('Deleted Cache Requests', deletedURLs); - logger.groupEnd(); - } - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * @param {string} groupTitle - * @param {Array} urls - * - * @private - */ - function _nestedGroup(groupTitle, urls) { - if (urls.length === 0) { - return; - } - logger.groupCollapsed(groupTitle); - for (const url of urls) { - logger.log(url); - } - logger.groupEnd(); - } - /** - * @param {Array} urlsToPrecache - * @param {Array} urlsAlreadyPrecached - * - * @private - * @memberof workbox-precaching - */ - function printInstallDetails(urlsToPrecache, urlsAlreadyPrecached) { - const precachedCount = urlsToPrecache.length; - const alreadyPrecachedCount = urlsAlreadyPrecached.length; - if (precachedCount || alreadyPrecachedCount) { - let message = `Precaching ${precachedCount} file${precachedCount === 1 ? '' : 's'}.`; - if (alreadyPrecachedCount > 0) { - message += ` ${alreadyPrecachedCount} ` + `file${alreadyPrecachedCount === 1 ? ' is' : 's are'} already cached.`; - } - logger.groupCollapsed(message); - _nestedGroup(`View newly precached URLs.`, urlsToPrecache); - _nestedGroup(`View previously precached URLs.`, urlsAlreadyPrecached); - logger.groupEnd(); - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let supportStatus; - /** - * A utility function that determines whether the current browser supports - * constructing a new `Response` from a `response.body` stream. - * - * @return {boolean} `true`, if the current browser can successfully - * construct a `Response` from a `response.body` stream, `false` otherwise. - * - * @private - */ - function canConstructResponseFromBodyStream() { - if (supportStatus === undefined) { - const testResponse = new Response(''); - if ('body' in testResponse) { - try { - new Response(testResponse.body); - supportStatus = true; - } catch (error) { - supportStatus = false; - } - } - supportStatus = false; - } - return supportStatus; - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Allows developers to copy a response and modify its `headers`, `status`, - * or `statusText` values (the values settable via a - * [`ResponseInit`]{@link https://developer.mozilla.org/en-US/docs/Web/API/Response/Response#Syntax} - * object in the constructor). - * To modify these values, pass a function as the second argument. That - * function will be invoked with a single object with the response properties - * `{headers, status, statusText}`. The return value of this function will - * be used as the `ResponseInit` for the new `Response`. To change the values - * either modify the passed parameter(s) and return it, or return a totally - * new object. - * - * This method is intentionally limited to same-origin responses, regardless of - * whether CORS was used or not. - * - * @param {Response} response - * @param {Function} modifier - * @memberof workbox-core - */ - async function copyResponse(response, modifier) { - let origin = null; - // If response.url isn't set, assume it's cross-origin and keep origin null. - if (response.url) { - const responseURL = new URL(response.url); - origin = responseURL.origin; - } - if (origin !== self.location.origin) { - throw new WorkboxError('cross-origin-copy-response', { - origin - }); - } - const clonedResponse = response.clone(); - // Create a fresh `ResponseInit` object by cloning the headers. - const responseInit = { - headers: new Headers(clonedResponse.headers), - status: clonedResponse.status, - statusText: clonedResponse.statusText - }; - // Apply any user modifications. - const modifiedResponseInit = modifier ? modifier(responseInit) : responseInit; - // Create the new response from the body stream and `ResponseInit` - // modifications. Note: not all browsers support the Response.body stream, - // so fall back to reading the entire body into memory as a blob. - const body = canConstructResponseFromBodyStream() ? clonedResponse.body : await clonedResponse.blob(); - return new Response(body, modifiedResponseInit); - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A {@link workbox-strategies.Strategy} implementation - * specifically designed to work with - * {@link workbox-precaching.PrecacheController} - * to both cache and fetch precached assets. - * - * Note: an instance of this class is created automatically when creating a - * `PrecacheController`; it's generally not necessary to create this yourself. - * - * @extends workbox-strategies.Strategy - * @memberof workbox-precaching - */ - class PrecacheStrategy extends Strategy { - /** - * - * @param {Object} [options] - * @param {string} [options.cacheName] Cache name to store and retrieve - * requests. Defaults to the cache names provided by - * {@link workbox-core.cacheNames}. - * @param {Array} [options.plugins] {@link https://developers.google.com/web/tools/workbox/guides/using-plugins|Plugins} - * to use in conjunction with this caching strategy. - * @param {Object} [options.fetchOptions] Values passed along to the - * {@link https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Parameters|init} - * of all fetch() requests made by this strategy. - * @param {Object} [options.matchOptions] The - * {@link https://w3c.github.io/ServiceWorker/#dictdef-cachequeryoptions|CacheQueryOptions} - * for any `cache.match()` or `cache.put()` calls made by this strategy. - * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to - * get the response from the network if there's a precache miss. - */ - constructor(options = {}) { - options.cacheName = cacheNames.getPrecacheName(options.cacheName); - super(options); - this._fallbackToNetwork = options.fallbackToNetwork === false ? false : true; - // Redirected responses cannot be used to satisfy a navigation request, so - // any redirected response must be "copied" rather than cloned, so the new - // response doesn't contain the `redirected` flag. See: - // https://bugs.chromium.org/p/chromium/issues/detail?id=669363&desc=2#c1 - this.plugins.push(PrecacheStrategy.copyRedirectedCacheableResponsesPlugin); - } - /** - * @private - * @param {Request|string} request A request to run this strategy for. - * @param {workbox-strategies.StrategyHandler} handler The event that - * triggered the request. - * @return {Promise} - */ - async _handle(request, handler) { - const response = await handler.cacheMatch(request); - if (response) { - return response; - } - // If this is an `install` event for an entry that isn't already cached, - // then populate the cache. - if (handler.event && handler.event.type === 'install') { - return await this._handleInstall(request, handler); - } - // Getting here means something went wrong. An entry that should have been - // precached wasn't found in the cache. - return await this._handleFetch(request, handler); - } - async _handleFetch(request, handler) { - let response; - const params = handler.params || {}; - // Fall back to the network if we're configured to do so. - if (this._fallbackToNetwork) { - { - logger.warn(`The precached response for ` + `${getFriendlyURL(request.url)} in ${this.cacheName} was not ` + `found. Falling back to the network.`); - } - const integrityInManifest = params.integrity; - const integrityInRequest = request.integrity; - const noIntegrityConflict = !integrityInRequest || integrityInRequest === integrityInManifest; - // Do not add integrity if the original request is no-cors - // See https://github.com/GoogleChrome/workbox/issues/3096 - response = await handler.fetch(new Request(request, { - integrity: request.mode !== 'no-cors' ? integrityInRequest || integrityInManifest : undefined - })); - // It's only "safe" to repair the cache if we're using SRI to guarantee - // that the response matches the precache manifest's expectations, - // and there's either a) no integrity property in the incoming request - // or b) there is an integrity, and it matches the precache manifest. - // See https://github.com/GoogleChrome/workbox/issues/2858 - // Also if the original request users no-cors we don't use integrity. - // See https://github.com/GoogleChrome/workbox/issues/3096 - if (integrityInManifest && noIntegrityConflict && request.mode !== 'no-cors') { - this._useDefaultCacheabilityPluginIfNeeded(); - const wasCached = await handler.cachePut(request, response.clone()); - { - if (wasCached) { - logger.log(`A response for ${getFriendlyURL(request.url)} ` + `was used to "repair" the precache.`); - } - } - } - } else { - // This shouldn't normally happen, but there are edge cases: - // https://github.com/GoogleChrome/workbox/issues/1441 - throw new WorkboxError('missing-precache-entry', { - cacheName: this.cacheName, - url: request.url - }); - } - { - const cacheKey = params.cacheKey || (await handler.getCacheKey(request, 'read')); - // Workbox is going to handle the route. - // print the routing details to the console. - logger.groupCollapsed(`Precaching is responding to: ` + getFriendlyURL(request.url)); - logger.log(`Serving the precached url: ${getFriendlyURL(cacheKey instanceof Request ? cacheKey.url : cacheKey)}`); - logger.groupCollapsed(`View request details here.`); - logger.log(request); - logger.groupEnd(); - logger.groupCollapsed(`View response details here.`); - logger.log(response); - logger.groupEnd(); - logger.groupEnd(); - } - return response; - } - async _handleInstall(request, handler) { - this._useDefaultCacheabilityPluginIfNeeded(); - const response = await handler.fetch(request); - // Make sure we defer cachePut() until after we know the response - // should be cached; see https://github.com/GoogleChrome/workbox/issues/2737 - const wasCached = await handler.cachePut(request, response.clone()); - if (!wasCached) { - // Throwing here will lead to the `install` handler failing, which - // we want to do if *any* of the responses aren't safe to cache. - throw new WorkboxError('bad-precaching-response', { - url: request.url, - status: response.status - }); - } - return response; - } - /** - * This method is complex, as there a number of things to account for: - * - * The `plugins` array can be set at construction, and/or it might be added to - * to at any time before the strategy is used. - * - * At the time the strategy is used (i.e. during an `install` event), there - * needs to be at least one plugin that implements `cacheWillUpdate` in the - * array, other than `copyRedirectedCacheableResponsesPlugin`. - * - * - If this method is called and there are no suitable `cacheWillUpdate` - * plugins, we need to add `defaultPrecacheCacheabilityPlugin`. - * - * - If this method is called and there is exactly one `cacheWillUpdate`, then - * we don't have to do anything (this might be a previously added - * `defaultPrecacheCacheabilityPlugin`, or it might be a custom plugin). - * - * - If this method is called and there is more than one `cacheWillUpdate`, - * then we need to check if one is `defaultPrecacheCacheabilityPlugin`. If so, - * we need to remove it. (This situation is unlikely, but it could happen if - * the strategy is used multiple times, the first without a `cacheWillUpdate`, - * and then later on after manually adding a custom `cacheWillUpdate`.) - * - * See https://github.com/GoogleChrome/workbox/issues/2737 for more context. - * - * @private - */ - _useDefaultCacheabilityPluginIfNeeded() { - let defaultPluginIndex = null; - let cacheWillUpdatePluginCount = 0; - for (const [index, plugin] of this.plugins.entries()) { - // Ignore the copy redirected plugin when determining what to do. - if (plugin === PrecacheStrategy.copyRedirectedCacheableResponsesPlugin) { - continue; - } - // Save the default plugin's index, in case it needs to be removed. - if (plugin === PrecacheStrategy.defaultPrecacheCacheabilityPlugin) { - defaultPluginIndex = index; - } - if (plugin.cacheWillUpdate) { - cacheWillUpdatePluginCount++; - } - } - if (cacheWillUpdatePluginCount === 0) { - this.plugins.push(PrecacheStrategy.defaultPrecacheCacheabilityPlugin); - } else if (cacheWillUpdatePluginCount > 1 && defaultPluginIndex !== null) { - // Only remove the default plugin; multiple custom plugins are allowed. - this.plugins.splice(defaultPluginIndex, 1); - } - // Nothing needs to be done if cacheWillUpdatePluginCount is 1 - } - } - PrecacheStrategy.defaultPrecacheCacheabilityPlugin = { - async cacheWillUpdate({ - response - }) { - if (!response || response.status >= 400) { - return null; - } - return response; - } - }; - PrecacheStrategy.copyRedirectedCacheableResponsesPlugin = { - async cacheWillUpdate({ - response - }) { - return response.redirected ? await copyResponse(response) : response; - } - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Performs efficient precaching of assets. - * - * @memberof workbox-precaching - */ - class PrecacheController { - /** - * Create a new PrecacheController. - * - * @param {Object} [options] - * @param {string} [options.cacheName] The cache to use for precaching. - * @param {string} [options.plugins] Plugins to use when precaching as well - * as responding to fetch events for precached assets. - * @param {boolean} [options.fallbackToNetwork=true] Whether to attempt to - * get the response from the network if there's a precache miss. - */ - constructor({ - cacheName, - plugins = [], - fallbackToNetwork = true - } = {}) { - this._urlsToCacheKeys = new Map(); - this._urlsToCacheModes = new Map(); - this._cacheKeysToIntegrities = new Map(); - this._strategy = new PrecacheStrategy({ - cacheName: cacheNames.getPrecacheName(cacheName), - plugins: [...plugins, new PrecacheCacheKeyPlugin({ - precacheController: this - })], - fallbackToNetwork - }); - // Bind the install and activate methods to the instance. - this.install = this.install.bind(this); - this.activate = this.activate.bind(this); - } - /** - * @type {workbox-precaching.PrecacheStrategy} The strategy created by this controller and - * used to cache assets and respond to fetch events. - */ - get strategy() { - return this._strategy; - } - /** - * Adds items to the precache list, removing any duplicates and - * stores the files in the - * {@link workbox-core.cacheNames|"precache cache"} when the service - * worker installs. - * - * This method can be called multiple times. - * - * @param {Array} [entries=[]] Array of entries to precache. - */ - precache(entries) { - this.addToCacheList(entries); - if (!this._installAndActiveListenersAdded) { - self.addEventListener('install', this.install); - self.addEventListener('activate', this.activate); - this._installAndActiveListenersAdded = true; - } - } - /** - * This method will add items to the precache list, removing duplicates - * and ensuring the information is valid. - * - * @param {Array} entries - * Array of entries to precache. - */ - addToCacheList(entries) { - { - finalAssertExports.isArray(entries, { - moduleName: 'workbox-precaching', - className: 'PrecacheController', - funcName: 'addToCacheList', - paramName: 'entries' - }); - } - const urlsToWarnAbout = []; - for (const entry of entries) { - // See https://github.com/GoogleChrome/workbox/issues/2259 - if (typeof entry === 'string') { - urlsToWarnAbout.push(entry); - } else if (entry && entry.revision === undefined) { - urlsToWarnAbout.push(entry.url); - } - const { - cacheKey, - url - } = createCacheKey(entry); - const cacheMode = typeof entry !== 'string' && entry.revision ? 'reload' : 'default'; - if (this._urlsToCacheKeys.has(url) && this._urlsToCacheKeys.get(url) !== cacheKey) { - throw new WorkboxError('add-to-cache-list-conflicting-entries', { - firstEntry: this._urlsToCacheKeys.get(url), - secondEntry: cacheKey - }); - } - if (typeof entry !== 'string' && entry.integrity) { - if (this._cacheKeysToIntegrities.has(cacheKey) && this._cacheKeysToIntegrities.get(cacheKey) !== entry.integrity) { - throw new WorkboxError('add-to-cache-list-conflicting-integrities', { - url - }); - } - this._cacheKeysToIntegrities.set(cacheKey, entry.integrity); - } - this._urlsToCacheKeys.set(url, cacheKey); - this._urlsToCacheModes.set(url, cacheMode); - if (urlsToWarnAbout.length > 0) { - const warningMessage = `Workbox is precaching URLs without revision ` + `info: ${urlsToWarnAbout.join(', ')}\nThis is generally NOT safe. ` + `Learn more at https://bit.ly/wb-precache`; - { - logger.warn(warningMessage); - } - } - } - } - /** - * Precaches new and updated assets. Call this method from the service worker - * install event. - * - * Note: this method calls `event.waitUntil()` for you, so you do not need - * to call it yourself in your event handlers. - * - * @param {ExtendableEvent} event - * @return {Promise} - */ - install(event) { - // waitUntil returns Promise - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return waitUntil(event, async () => { - const installReportPlugin = new PrecacheInstallReportPlugin(); - this.strategy.plugins.push(installReportPlugin); - // Cache entries one at a time. - // See https://github.com/GoogleChrome/workbox/issues/2528 - for (const [url, cacheKey] of this._urlsToCacheKeys) { - const integrity = this._cacheKeysToIntegrities.get(cacheKey); - const cacheMode = this._urlsToCacheModes.get(url); - const request = new Request(url, { - integrity, - cache: cacheMode, - credentials: 'same-origin' - }); - await Promise.all(this.strategy.handleAll({ - params: { - cacheKey - }, - request, - event - })); - } - const { - updatedURLs, - notUpdatedURLs - } = installReportPlugin; - { - printInstallDetails(updatedURLs, notUpdatedURLs); - } - return { - updatedURLs, - notUpdatedURLs - }; - }); - } - /** - * Deletes assets that are no longer present in the current precache manifest. - * Call this method from the service worker activate event. - * - * Note: this method calls `event.waitUntil()` for you, so you do not need - * to call it yourself in your event handlers. - * - * @param {ExtendableEvent} event - * @return {Promise} - */ - activate(event) { - // waitUntil returns Promise - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return waitUntil(event, async () => { - const cache = await self.caches.open(this.strategy.cacheName); - const currentlyCachedRequests = await cache.keys(); - const expectedCacheKeys = new Set(this._urlsToCacheKeys.values()); - const deletedURLs = []; - for (const request of currentlyCachedRequests) { - if (!expectedCacheKeys.has(request.url)) { - await cache.delete(request); - deletedURLs.push(request.url); - } - } - { - printCleanupDetails(deletedURLs); - } - return { - deletedURLs - }; - }); - } - /** - * Returns a mapping of a precached URL to the corresponding cache key, taking - * into account the revision information for the URL. - * - * @return {Map} A URL to cache key mapping. - */ - getURLsToCacheKeys() { - return this._urlsToCacheKeys; - } - /** - * Returns a list of all the URLs that have been precached by the current - * service worker. - * - * @return {Array} The precached URLs. - */ - getCachedURLs() { - return [...this._urlsToCacheKeys.keys()]; - } - /** - * Returns the cache key used for storing a given URL. If that URL is - * unversioned, like `/index.html', then the cache key will be the original - * URL with a search parameter appended to it. - * - * @param {string} url A URL whose cache key you want to look up. - * @return {string} The versioned URL that corresponds to a cache key - * for the original URL, or undefined if that URL isn't precached. - */ - getCacheKeyForURL(url) { - const urlObject = new URL(url, location.href); - return this._urlsToCacheKeys.get(urlObject.href); - } - /** - * @param {string} url A cache key whose SRI you want to look up. - * @return {string} The subresource integrity associated with the cache key, - * or undefined if it's not set. - */ - getIntegrityForCacheKey(cacheKey) { - return this._cacheKeysToIntegrities.get(cacheKey); - } - /** - * This acts as a drop-in replacement for - * [`cache.match()`](https://developer.mozilla.org/en-US/docs/Web/API/Cache/match) - * with the following differences: - * - * - It knows what the name of the precache is, and only checks in that cache. - * - It allows you to pass in an "original" URL without versioning parameters, - * and it will automatically look up the correct cache key for the currently - * active revision of that URL. - * - * E.g., `matchPrecache('index.html')` will find the correct precached - * response for the currently active service worker, even if the actual cache - * key is `'/index.html?__WB_REVISION__=1234abcd'`. - * - * @param {string|Request} request The key (without revisioning parameters) - * to look up in the precache. - * @return {Promise} - */ - async matchPrecache(request) { - const url = request instanceof Request ? request.url : request; - const cacheKey = this.getCacheKeyForURL(url); - if (cacheKey) { - const cache = await self.caches.open(this.strategy.cacheName); - return cache.match(cacheKey); - } - return undefined; - } - /** - * Returns a function that looks up `url` in the precache (taking into - * account revision information), and returns the corresponding `Response`. - * - * @param {string} url The precached URL which will be used to lookup the - * `Response`. - * @return {workbox-routing~handlerCallback} - */ - createHandlerBoundToURL(url) { - const cacheKey = this.getCacheKeyForURL(url); - if (!cacheKey) { - throw new WorkboxError('non-precached-url', { - url - }); - } - return options => { - options.request = new Request(url); - options.params = Object.assign({ - cacheKey - }, options.params); - return this.strategy.handle(options); - }; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - let precacheController; - /** - * @return {PrecacheController} - * @private - */ - const getOrCreatePrecacheController = () => { - if (!precacheController) { - precacheController = new PrecacheController(); - } - return precacheController; - }; - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Removes any URL search parameters that should be ignored. - * - * @param {URL} urlObject The original URL. - * @param {Array} ignoreURLParametersMatching RegExps to test against - * each search parameter name. Matches mean that the search parameter should be - * ignored. - * @return {URL} The URL with any ignored search parameters removed. - * - * @private - * @memberof workbox-precaching - */ - function removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching = []) { - // Convert the iterable into an array at the start of the loop to make sure - // deletion doesn't mess up iteration. - for (const paramName of [...urlObject.searchParams.keys()]) { - if (ignoreURLParametersMatching.some(regExp => regExp.test(paramName))) { - urlObject.searchParams.delete(paramName); - } - } - return urlObject; - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Generator function that yields possible variations on the original URL to - * check, one at a time. - * - * @param {string} url - * @param {Object} options - * - * @private - * @memberof workbox-precaching - */ - function* generateURLVariations(url, { - ignoreURLParametersMatching = [/^utm_/, /^fbclid$/], - directoryIndex = 'index.html', - cleanURLs = true, - urlManipulation - } = {}) { - const urlObject = new URL(url, location.href); - urlObject.hash = ''; - yield urlObject.href; - const urlWithoutIgnoredParams = removeIgnoredSearchParams(urlObject, ignoreURLParametersMatching); - yield urlWithoutIgnoredParams.href; - if (directoryIndex && urlWithoutIgnoredParams.pathname.endsWith('/')) { - const directoryURL = new URL(urlWithoutIgnoredParams.href); - directoryURL.pathname += directoryIndex; - yield directoryURL.href; - } - if (cleanURLs) { - const cleanURL = new URL(urlWithoutIgnoredParams.href); - cleanURL.pathname += '.html'; - yield cleanURL.href; - } - if (urlManipulation) { - const additionalURLs = urlManipulation({ - url: urlObject - }); - for (const urlToAttempt of additionalURLs) { - yield urlToAttempt.href; - } - } - } - - /* - Copyright 2020 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * A subclass of {@link workbox-routing.Route} that takes a - * {@link workbox-precaching.PrecacheController} - * instance and uses it to match incoming requests and handle fetching - * responses from the precache. - * - * @memberof workbox-precaching - * @extends workbox-routing.Route - */ - class PrecacheRoute extends Route { - /** - * @param {PrecacheController} precacheController A `PrecacheController` - * instance used to both match requests and respond to fetch events. - * @param {Object} [options] Options to control how requests are matched - * against the list of precached URLs. - * @param {string} [options.directoryIndex=index.html] The `directoryIndex` will - * check cache entries for a URLs ending with '/' to see if there is a hit when - * appending the `directoryIndex` value. - * @param {Array} [options.ignoreURLParametersMatching=[/^utm_/, /^fbclid$/]] An - * array of regex's to remove search params when looking for a cache match. - * @param {boolean} [options.cleanURLs=true] The `cleanURLs` option will - * check the cache for the URL with a `.html` added to the end of the end. - * @param {workbox-precaching~urlManipulation} [options.urlManipulation] - * This is a function that should take a URL and return an array of - * alternative URLs that should be checked for precache matches. - */ - constructor(precacheController, options) { - const match = ({ - request - }) => { - const urlsToCacheKeys = precacheController.getURLsToCacheKeys(); - for (const possibleURL of generateURLVariations(request.url, options)) { - const cacheKey = urlsToCacheKeys.get(possibleURL); - if (cacheKey) { - const integrity = precacheController.getIntegrityForCacheKey(cacheKey); - return { - cacheKey, - integrity - }; - } - } - { - logger.debug(`Precaching did not find a match for ` + getFriendlyURL(request.url)); - } - return; - }; - super(match, precacheController.strategy); - } - } - - /* - Copyright 2019 Google LLC - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Add a `fetch` listener to the service worker that will - * respond to - * [network requests]{@link https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Custom_responses_to_requests} - * with precached assets. - * - * Requests for assets that aren't precached, the `FetchEvent` will not be - * responded to, allowing the event to fall through to other `fetch` event - * listeners. - * - * @param {Object} [options] See the {@link workbox-precaching.PrecacheRoute} - * options. - * - * @memberof workbox-precaching - */ - function addRoute(options) { - const precacheController = getOrCreatePrecacheController(); - const precacheRoute = new PrecacheRoute(precacheController, options); - registerRoute(precacheRoute); - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds items to the precache list, removing any duplicates and - * stores the files in the - * {@link workbox-core.cacheNames|"precache cache"} when the service - * worker installs. - * - * This method can be called multiple times. - * - * Please note: This method **will not** serve any of the cached files for you. - * It only precaches files. To respond to a network request you call - * {@link workbox-precaching.addRoute}. - * - * If you have a single array of files to precache, you can just call - * {@link workbox-precaching.precacheAndRoute}. - * - * @param {Array} [entries=[]] Array of entries to precache. - * - * @memberof workbox-precaching - */ - function precache(entries) { - const precacheController = getOrCreatePrecacheController(); - precacheController.precache(entries); - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * This method will add entries to the precache list and add a route to - * respond to fetch events. - * - * This is a convenience method that will call - * {@link workbox-precaching.precache} and - * {@link workbox-precaching.addRoute} in a single call. - * - * @param {Array} entries Array of entries to precache. - * @param {Object} [options] See the - * {@link workbox-precaching.PrecacheRoute} options. - * - * @memberof workbox-precaching - */ - function precacheAndRoute(entries, options) { - precache(entries); - addRoute(options); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - const SUBSTRING_TO_FIND = '-precache-'; - /** - * Cleans up incompatible precaches that were created by older versions of - * Workbox, by a service worker registered under the current scope. - * - * This is meant to be called as part of the `activate` event. - * - * This should be safe to use as long as you don't include `substringToFind` - * (defaulting to `-precache-`) in your non-precache cache names. - * - * @param {string} currentPrecacheName The cache name currently in use for - * precaching. This cache won't be deleted. - * @param {string} [substringToFind='-precache-'] Cache names which include this - * substring will be deleted (excluding `currentPrecacheName`). - * @return {Array} A list of all the cache names that were deleted. - * - * @private - * @memberof workbox-precaching - */ - const deleteOutdatedCaches = async (currentPrecacheName, substringToFind = SUBSTRING_TO_FIND) => { - const cacheNames = await self.caches.keys(); - const cacheNamesToDelete = cacheNames.filter(cacheName => { - return cacheName.includes(substringToFind) && cacheName.includes(self.registration.scope) && cacheName !== currentPrecacheName; - }); - await Promise.all(cacheNamesToDelete.map(cacheName => self.caches.delete(cacheName))); - return cacheNamesToDelete; - }; - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Adds an `activate` event listener which will clean up incompatible - * precaches that were created by older versions of Workbox. - * - * @memberof workbox-precaching - */ - function cleanupOutdatedCaches() { - // See https://github.com/Microsoft/TypeScript/issues/28357#issuecomment-436484705 - self.addEventListener('activate', event => { - const cacheName = cacheNames.getPrecacheName(); - event.waitUntil(deleteOutdatedCaches(cacheName).then(cachesDeleted => { - { - if (cachesDeleted.length > 0) { - logger.log(`The following out-of-date precaches were cleaned up ` + `automatically:`, cachesDeleted); - } - } - })); - }); - } - - /* - Copyright 2018 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * NavigationRoute makes it easy to create a - * {@link workbox-routing.Route} that matches for browser - * [navigation requests]{@link https://developers.google.com/web/fundamentals/primers/service-workers/high-performance-loading#first_what_are_navigation_requests}. - * - * It will only match incoming Requests whose - * {@link https://fetch.spec.whatwg.org/#concept-request-mode|mode} - * is set to `navigate`. - * - * You can optionally only apply this route to a subset of navigation requests - * by using one or both of the `denylist` and `allowlist` parameters. - * - * @memberof workbox-routing - * @extends workbox-routing.Route - */ - class NavigationRoute extends Route { - /** - * If both `denylist` and `allowlist` are provided, the `denylist` will - * take precedence and the request will not match this route. - * - * The regular expressions in `allowlist` and `denylist` - * are matched against the concatenated - * [`pathname`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/pathname} - * and [`search`]{@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLHyperlinkElementUtils/search} - * portions of the requested URL. - * - * *Note*: These RegExps may be evaluated against every destination URL during - * a navigation. Avoid using - * [complex RegExps](https://github.com/GoogleChrome/workbox/issues/3077), - * or else your users may see delays when navigating your site. - * - * @param {workbox-routing~handlerCallback} handler A callback - * function that returns a Promise resulting in a Response. - * @param {Object} options - * @param {Array} [options.denylist] If any of these patterns match, - * the route will not handle the request (even if a allowlist RegExp matches). - * @param {Array} [options.allowlist=[/./]] If any of these patterns - * match the URL's pathname and search parameter, the route will handle the - * request (assuming the denylist doesn't match). - */ - constructor(handler, { - allowlist = [/./], - denylist = [] - } = {}) { - { - finalAssertExports.isArrayOfClass(allowlist, RegExp, { - moduleName: 'workbox-routing', - className: 'NavigationRoute', - funcName: 'constructor', - paramName: 'options.allowlist' - }); - finalAssertExports.isArrayOfClass(denylist, RegExp, { - moduleName: 'workbox-routing', - className: 'NavigationRoute', - funcName: 'constructor', - paramName: 'options.denylist' - }); - } - super(options => this._match(options), handler); - this._allowlist = allowlist; - this._denylist = denylist; - } - /** - * Routes match handler. - * - * @param {Object} options - * @param {URL} options.url - * @param {Request} options.request - * @return {boolean} - * - * @private - */ - _match({ - url, - request - }) { - if (request && request.mode !== 'navigate') { - return false; - } - const pathnameAndSearch = url.pathname + url.search; - for (const regExp of this._denylist) { - if (regExp.test(pathnameAndSearch)) { - { - logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL matches this denylist pattern: ` + `${regExp.toString()}`); - } - return false; - } - } - if (this._allowlist.some(regExp => regExp.test(pathnameAndSearch))) { - { - logger.debug(`The navigation route ${pathnameAndSearch} ` + `is being used.`); - } - return true; - } - { - logger.log(`The navigation route ${pathnameAndSearch} is not ` + `being used, since the URL being navigated to doesn't ` + `match the allowlist.`); - } - return false; - } - } - - /* - Copyright 2019 Google LLC - - Use of this source code is governed by an MIT-style - license that can be found in the LICENSE file or at - https://opensource.org/licenses/MIT. - */ - /** - * Helper function that calls - * {@link PrecacheController#createHandlerBoundToURL} on the default - * {@link PrecacheController} instance. - * - * If you are creating your own {@link PrecacheController}, then call the - * {@link PrecacheController#createHandlerBoundToURL} on that instance, - * instead of using this function. - * - * @param {string} url The precached URL which will be used to lookup the - * `Response`. - * @param {boolean} [fallbackToNetwork=true] Whether to attempt to get the - * response from the network if there's a precache miss. - * @return {workbox-routing~handlerCallback} - * - * @memberof workbox-precaching - */ - function createHandlerBoundToURL(url) { - const precacheController = getOrCreatePrecacheController(); - return precacheController.createHandlerBoundToURL(url); - } - - exports.CacheFirst = CacheFirst; - exports.CacheableResponsePlugin = CacheableResponsePlugin; - exports.ExpirationPlugin = ExpirationPlugin; - exports.NavigationRoute = NavigationRoute; - exports.NetworkFirst = NetworkFirst; - exports.cleanupOutdatedCaches = cleanupOutdatedCaches; - exports.clientsClaim = clientsClaim; - exports.createHandlerBoundToURL = createHandlerBoundToURL; - exports.precacheAndRoute = precacheAndRoute; - exports.registerRoute = registerRoute; - -})); diff --git a/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts index 1a0b3624..b25913aa 100644 --- a/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts +++ b/neode-ui/e2e/perf/keepalive-remount-probe.spec.ts @@ -1,7 +1,7 @@ // keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B. // // Standalone, re-runnable Playwright spec that logs into the deployed -// archi-dev-box build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS, +// a test node build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS, // performs a visit -> away (to the neutral /dashboard/settings tab) -> // return round trip, reporting whether the component instance survived. // @@ -284,7 +284,7 @@ interface RoundTripResult { * next click — mirrors measure.ts's own `dismissOverlays()` (this spec * deliberately does not import from measure.ts, so the logic is duplicated * here rather than shared, per the "don't edit the frozen 02-01 harness" - * constraint). archi-dev-box currently runs at 85% disk (02-FINDINGS.md + * constraint). a test node currently runs at 85% disk (02-FINDINGS.md * Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast * live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper * has no `pointer-events: none`, so it silently intercepts clicks on diff --git a/neode-ui/e2e/perf/profile-revisit.spec.ts b/neode-ui/e2e/perf/profile-revisit.spec.ts index d6476054..f02f600d 100644 --- a/neode-ui/e2e/perf/profile-revisit.spec.ts +++ b/neode-ui/e2e/perf/profile-revisit.spec.ts @@ -12,7 +12,7 @@ // profiling evidence instead of guessed from source reading alone. // // Usage: -// ARCHY_BASE_URL=http://archi-dev-box ARCHY_PASSWORD=*** \ +// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \ // npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line import { expect, test, type Page } from '@playwright/test' import { SURFACES, type Surface } from './surfaces' @@ -112,7 +112,7 @@ interface CpuProfile { } // Production build has no sourcemaps deployed (confirmed: assets/*.js.map -> -// 404 on archi-dev-box), so minified function names inside vendor-*.js/ +// 404 on a test node), so minified function names inside vendor-*.js/ // index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME // instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled // together; index = app entry/shared code; per-route chunk name = that diff --git a/neode-ui/e2e/perf/surface-perf.spec.ts b/neode-ui/e2e/perf/surface-perf.spec.ts index f47ab22f..18d1db13 100644 --- a/neode-ui/e2e/perf/surface-perf.spec.ts +++ b/neode-ui/e2e/perf/surface-perf.spec.ts @@ -89,7 +89,7 @@ test('surface-perf: measure every D-09 surface and write the baseline artifact', } const header = { - baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228', + baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100', takenAt: new Date().toISOString(), commit: currentCommit(), runs: RUNS, diff --git a/neode-ui/mock-backend.js b/neode-ui/mock-backend.js index 75b67e4f..9e92f789 100755 --- a/neode-ui/mock-backend.js +++ b/neode-ui/mock-backend.js @@ -2877,11 +2877,11 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { interfaces: [ - { name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.168.1.228/24'] }, - { name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.168.1.230/24'] }, + { name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.0.2.10/24'] }, + { name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.0.2.14/24'] }, { name: 'lo', type: 'loopback', state: 'up', mac: '00:00:00:00:00:00', ipv4: ['127.0.0.1/8'] }, { name: 'podman0', type: 'bridge', state: 'up', mac: '2e:f4:8a:11:22:33', ipv4: ['10.89.0.1/16'] }, - { name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.82.97.63/32'] }, + { name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.64.0.63/32'] }, ], }, }) @@ -3075,11 +3075,11 @@ app.post('/rpc/v1', (req, res) => { return res.json({ result: { nodes: [ - { did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.168.1.50' }, - { did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.168.1.51' }, - { did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.168.1.72' }, - { did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.72.19.44' }, - { did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.101.7.23' }, + { did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.0.2.50' }, + { did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.0.2.51' }, + { did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.0.2.72' }, + { did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.64.0.6' }, + { did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.64.0.23' }, ], }, }) @@ -3604,7 +3604,7 @@ app.post('/rpc/v1', (req, res) => { name: 'archy-198', trust_level: 'trusted', mesh_contact_id: 1, - lan_address: '192.168.1.198:5678', + lan_address: '192.0.2.11:5678', onion_address: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', preferred_transport: 'lan', available_transports: ['mesh', 'lan', 'tor'], @@ -3640,7 +3640,7 @@ app.post('/rpc/v1', (req, res) => { name: 'office-node', trust_level: 'trusted', mesh_contact_id: null, - lan_address: '192.168.1.42:5678', + lan_address: '192.0.2.42:5678', onion_address: 'peer4mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion', preferred_transport: 'lan', available_transports: ['lan', 'tor'], @@ -4438,7 +4438,7 @@ app.post('/rpc/v1', (req, res) => { ssid: 'CasaDelSol-5G', assoc_ssid: 'CasaDelSol-5G', encryption: 'psk2', - ip: '192.168.1.187', + ip: '192.0.2.15', internet: true, radio0_disabled: false, sta_iface: 'wifinet1', diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 6436b050..c30e1272 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "neode-ui", - "version": "1.7.125-alpha", + "version": "1.7.126-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "neode-ui", - "version": "1.7.125-alpha", + "version": "1.7.126-alpha", "dependencies": { "@scure/bip39": "^2.2.0", "@types/dompurify": "^3.0.5", diff --git a/neode-ui/package.json b/neode-ui/package.json index dbcb2192..3b68c6a8 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -1,7 +1,7 @@ { "name": "neode-ui", "private": true, - "version": "1.7.125-alpha", + "version": "1.7.126-alpha", "type": "module", "scripts": { "start": "./start-dev.sh", diff --git a/neode-ui/playwright.config.ts b/neode-ui/playwright.config.ts index 10bd34bf..dc416849 100644 --- a/neode-ui/playwright.config.ts +++ b/neode-ui/playwright.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ timeout: 10_000, }, use: { - baseURL: process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228', + baseURL: process.env.ARCHY_BASE_URL ?? 'http://localhost:8100', viewport: { width: 1440, height: 900 }, screenshot: 'only-on-failure', trace: 'off', diff --git a/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf b/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf deleted file mode 100644 index 68c33948..00000000 Binary files a/neode-ui/public/assets/fonts/Benton_Sans/BentonSans-Regular.otf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf b/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf deleted file mode 100644 index 19f74291..00000000 Binary files a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Bold.ttf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf b/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf deleted file mode 100644 index ebb3361a..00000000 Binary files a/neode-ui/public/assets/fonts/Courier_New/CourierNew-Regular.ttf and /dev/null differ diff --git a/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf b/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf deleted file mode 100644 index 3bc1fe32..00000000 Binary files a/neode-ui/public/assets/fonts/Redacted/redacted.regular.ttf and /dev/null differ diff --git a/neode-ui/public/catalog.json b/neode-ui/public/catalog.json index b6020cc1..59b0f61b 100644 --- a/neode-ui/public/catalog.json +++ b/neode-ui/public/catalog.json @@ -1,7 +1,7 @@ { "version": 2, "updated": "2026-04-22T00:00:00Z", - "registry": "146.59.87.168:3000/lfg2025", + "registry": "source.archipelago-foundation.org/lfg2025", "featured": { "id": "indeedhub", "banner": "/assets/img/featured/indeedhub-banner.jpg", @@ -19,7 +19,7 @@ "author": "Bitcoin Knots", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest", "repoUrl": "https://github.com/bitcoinknots/bitcoin" }, { @@ -31,7 +31,7 @@ "author": "Bitcoin Core contributors", "category": "money", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4", + "dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4", "repoUrl": "https://github.com/bitcoin/bitcoin" }, { @@ -43,7 +43,7 @@ "author": "Lightning Labs", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta", + "dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta", "repoUrl": "https://github.com/lightningnetwork/lnd", "requires": [ "bitcoin-knots" @@ -52,13 +52,13 @@ { "id": "btcpay-server", "title": "BTCPay Server", - "version": "2.3.9", + "version": "2.4.2", "description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.", "icon": "/assets/img/app-icons/btcpay-server.png", "author": "BTCPay Server Foundation", "category": "commerce", "tier": "core", - "dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9", + "dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2", "repoUrl": "https://github.com/btcpayserver/btcpayserver", "requires": [ "bitcoin-knots" @@ -73,7 +73,7 @@ "author": "Mempool", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1", "repoUrl": "https://github.com/mempool/mempool", "requires": [ "bitcoin-knots", @@ -89,7 +89,7 @@ "author": "Luke Childs", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0", "repoUrl": "https://github.com/spesmilo/electrumx", "requires": [ "bitcoin-knots" @@ -103,7 +103,7 @@ "icon": "/assets/img/app-icons/indeedhub.png", "author": "IndeeHub", "category": "community", - "dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0", "repoUrl": "https://github.com/indeedhub/indeedhub" }, { @@ -114,7 +114,7 @@ "icon": "/assets/img/app-icons/botfights.svg", "author": "BotFights", "category": "community", - "dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.2.11", + "dockerImage": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11", "repoUrl": "https://botfights.net", "containerConfig": { "ports": [ @@ -172,7 +172,7 @@ "author": "File Browser", "category": "data", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0", "repoUrl": "https://github.com/filebrowser/filebrowser", "containerConfig": { "ports": [ @@ -223,7 +223,7 @@ "author": "Vaultwarden", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine", + "dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine", "repoUrl": "https://github.com/dani-garcia/vaultwarden", "containerConfig": { "ports": [ @@ -243,7 +243,7 @@ "author": "SearXNG", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest", + "dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest", "repoUrl": "https://github.com/searxng/searxng", "containerConfig": { "ports": [ @@ -262,7 +262,7 @@ "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint" }, { @@ -274,7 +274,7 @@ "author": "Fedimint", "category": "money", "tier": "core", - "dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1", "repoUrl": "https://github.com/minmoto/fmcd" }, { @@ -285,7 +285,7 @@ "icon": "/assets/img/app-icons/fedimint.png", "author": "Fedimint", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0", "repoUrl": "https://github.com/fedimint/fedimint", "containerConfig": { "ports": [ @@ -306,7 +306,7 @@ "icon": "/assets/img/app-icons/bark.png", "author": "Second", "category": "money", - "dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0", + "dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0", "repoUrl": "https://gitlab.com/ark-bitcoin/bark", "containerConfig": { "ports": [ @@ -325,7 +325,7 @@ "icon": "/assets/img/app-icons/jellyfin.webp", "author": "Jellyfin", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13", + "dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13", "repoUrl": "https://github.com/jellyfin/jellyfin", "containerConfig": { "ports": [ @@ -345,7 +345,7 @@ "icon": "/assets/img/app-icons/immich.png", "author": "Immich", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release", + "dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release", "repoUrl": "https://github.com/immich-app/immich" }, { @@ -356,7 +356,7 @@ "icon": "/assets/img/app-icons/homeassistant.png", "author": "Home Assistant", "category": "home", - "dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3", + "dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3", "repoUrl": "https://github.com/home-assistant/core", "containerConfig": { "ports": [ @@ -414,7 +414,7 @@ "author": "Tailscale", "category": "networking", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable", + "dockerImage": "source.archipelago-foundation.org/lfg2025/tailscale:stable", "repoUrl": "https://github.com/tailscale/tailscale", "containerConfig": { "ports": [ @@ -442,7 +442,7 @@ "author": "Portainer", "category": "development", "tier": "optional", - "dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1", "repoUrl": "https://github.com/portainer/portainer", "containerConfig": { "ports": [ @@ -487,7 +487,7 @@ "author": "Uptime Kuma", "category": "data", "tier": "recommended", - "dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1", + "dockerImage": "source.archipelago-foundation.org/lfg2025/uptime-kuma:1", "repoUrl": "https://github.com/louislam/uptime-kuma", "containerConfig": { "ports": [ @@ -514,7 +514,7 @@ "icon": "/assets/img/app-icons/photoprism.svg", "author": "PhotoPrism", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915", + "dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915", "repoUrl": "https://github.com/photoprism/photoprism", "containerConfig": { "ports": [ @@ -537,7 +537,7 @@ "icon": "/assets/img/app-icons/nextcloud.webp", "author": "Nextcloud", "category": "data", - "dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29", + "dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29", "repoUrl": "https://github.com/nextcloud/server", "containerConfig": { "ports": [ diff --git a/neode-ui/public/packages/atob.s9pk b/neode-ui/public/packages/atob.s9pk deleted file mode 100644 index 1c2c829e..00000000 Binary files a/neode-ui/public/packages/atob.s9pk and /dev/null differ diff --git a/neode-ui/public/packages/wireguard.apk b/neode-ui/public/packages/wireguard.apk deleted file mode 100644 index 962f7b75..00000000 Binary files a/neode-ui/public/packages/wireguard.apk and /dev/null differ diff --git a/neode-ui/src/api/rpc-client.ts b/neode-ui/src/api/rpc-client.ts index 6bb70fff..d411fad7 100644 --- a/neode-ui/src/api/rpc-client.ts +++ b/neode-ui/src/api/rpc-client.ts @@ -1084,6 +1084,16 @@ class RPCClient { relay_count: number first_seen: string nostr_pubkey: string + /** + * Whether the author proved control of the key their `author.did` names. + * `invalid` manifests are dropped during discovery and should never + * appear here; typed anyway so the UI fails safe rather than falling + * through to "signed" if that ever changes. + */ + signature?: + | { status: 'valid' } + | { status: 'missing' } + | { status: 'invalid'; reason: string } }> relay_count: number }> { @@ -1148,6 +1158,69 @@ class RPCClient { }) } + /** This node's Lightning credential state. Digests and counts only — the + * backend never returns macaroon content, so nothing here is sensitive. */ + async lndMacaroonStatus(): Promise { + return this.call({ method: 'lnd.macaroon-status', timeout: 30000 }) + } + + /** Begin a macaroon rotation. Returns as soon as the job is accepted; the + * work takes minutes (LND has to close and reopen its databases), so poll + * `lndMacaroonRotationProgress` for the outcome. */ + async lndRotateMacaroons(password: string): Promise<{ status: string }> { + return this.call({ + method: 'lnd.rotate-macaroons', + params: { password }, + timeout: 30000, + }) + } + + async lndMacaroonRotationProgress(): Promise { + return this.call({ method: 'lnd.macaroon-rotation-progress' }) + } +} + +export type RotationStepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped' + +export interface LndRotationStep { + key: string + label: string + state: RotationStepState + detail: string | null +} + +export interface LndRotationProgress { + running: boolean + /** null while running, then the verdict. Lets the UI tell "in progress" + * apart from "finished and failed". */ + ok: boolean | null + started_at: string | null + finished_at: string | null + error: string | null + steps: LndRotationStep[] + /** Holds the OLD root key, so it is still secret. The UI tells the operator + * to delete it once every wallet app has been re-paired. */ + backup_path: string | null + identity_pubkey: string | null + channels_before: number | null + channels_after: number | null + new_admin_macaroon_sha256: string | null +} + +export interface LndMacaroonStatus { + installed: boolean + admin_macaroon_sha256: string | null + /** When LND last minted these credentials, host local time. */ + issued_at: string | null + identity_pubkey: string | null + channels_open: number | null + channels_pending: number | null + /** Why LND could not be asked, when it could not. */ + lnd_error: string | null + btcpay_uses_internal_lnd: boolean + /** null when BTCPay has no internal Lightning node — an absence, not a fault. */ + btcpay_credential_current: boolean | null + rotation: LndRotationProgress } export const rpcClient = new RPCClient() diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 1e26c8b3..dcc76dac 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -25,7 +25,7 @@ describe('useAppLauncherStore', () => { vi.clearAllMocks() // Default to HTTP to avoid proxy rewriting Object.defineProperty(window, 'location', { - value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' }, + value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' }, writable: true, configurable: true, }) @@ -71,8 +71,8 @@ describe('useAppLauncherStore', () => { it('open() never falls through to the iframe overlay', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown app' }) - expect(openInApp).toHaveBeenCalledWith('http://192.168.1.228:9999') + store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown app' }) + expect(openInApp).toHaveBeenCalledWith('http://192.0.2.10:9999') expect(store.isOpen).toBe(false) }) }) @@ -81,7 +81,7 @@ describe('useAppLauncherStore', () => { const store = useAppLauncherStore() // Port 8083 maps to /app/filebrowser/ — should route to session - store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' }) + store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' }) // Default panel mode: sets panelAppId, doesn't open overlay expect(store.isOpen).toBe(false) @@ -128,12 +128,12 @@ describe('useAppLauncherStore', () => { it('routes BTCPay (port 23000) to full-page session', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' }) + store.open({ url: 'http://192.0.2.10:23000', title: 'BTCPay' }) expect(store.isOpen).toBe(false) expect(store.panelAppId).toBe(null) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:23000', + 'http://192.0.2.10:23000', '_blank', 'noopener,noreferrer', ) @@ -142,12 +142,12 @@ describe('useAppLauncherStore', () => { it('normalizes old Nginx Proxy Manager port 81 to 8081', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' }) + store.open({ url: 'http://192.0.2.10:81', title: 'Nginx Proxy Manager' }) expect(store.isOpen).toBe(false) expect(store.panelAppId).toBe(null) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:8081', + 'http://192.0.2.10:8081', '_blank', 'noopener,noreferrer', ) @@ -161,7 +161,7 @@ describe('useAppLauncherStore', () => { }) const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' }) + store.open({ url: 'http://192.0.2.10:8081', title: 'Nginx Proxy Manager' }) // Tab-only app on mobile-web: open directly in a new browser tab (the // companion would use the in-app WebView). No session, no route push, no @@ -170,7 +170,7 @@ describe('useAppLauncherStore', () => { expect(store.panelAppId).toBe(null) expect(mockPush).not.toHaveBeenCalled() expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:8081', + 'http://192.0.2.10:8081', '_blank', 'noopener,noreferrer', ) @@ -179,10 +179,10 @@ describe('useAppLauncherStore', () => { it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => { const store = useAppLauncherStore() - store.open({ url: 'https://192.168.1.228/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' }) + store.open({ url: 'https://192.0.2.10/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' }) expect(mockWindowOpen).toHaveBeenCalledWith( - 'https://192.168.1.228/app/nginx-proxy-manager/', + 'https://192.0.2.10/app/nginx-proxy-manager/', '_blank', 'noopener,noreferrer', ) @@ -192,10 +192,10 @@ describe('useAppLauncherStore', () => { it('normalizes legacy Nginx Proxy Manager ports to 8081', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' }) + store.open({ url: 'http://192.0.2.10:8181', title: 'Nginx Proxy Manager' }) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:8081', + 'http://192.0.2.10:8081', '_blank', 'noopener,noreferrer', ) @@ -204,10 +204,10 @@ describe('useAppLauncherStore', () => { it('normalizes legacy Uptime Kuma port 3001 to 3002', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:3001', title: 'Uptime Kuma' }) + store.open({ url: 'http://192.0.2.10:3001', title: 'Uptime Kuma' }) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:3002', + 'http://192.0.2.10:3002', '_blank', 'noopener,noreferrer', ) @@ -218,10 +218,10 @@ describe('useAppLauncherStore', () => { it('opens Uptime Kuma in new tab using title hint when URL is path-only', () => { const store = useAppLauncherStore() - store.open({ url: 'https://192.168.1.228/app/uptime-kuma/', title: 'Uptime Kuma' }) + store.open({ url: 'https://192.0.2.10/app/uptime-kuma/', title: 'Uptime Kuma' }) expect(mockWindowOpen).toHaveBeenCalledWith( - 'https://192.168.1.228/app/uptime-kuma/', + 'https://192.0.2.10/app/uptime-kuma/', '_blank', 'noopener,noreferrer', ) @@ -231,12 +231,12 @@ describe('useAppLauncherStore', () => { it('routes Home Assistant (port 8123) to full-page session', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' }) + store.open({ url: 'http://192.0.2.10:8123', title: 'Home Assistant' }) expect(store.isOpen).toBe(false) expect(store.panelAppId).toBe(null) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:8123', + 'http://192.0.2.10:8123', '_blank', 'noopener,noreferrer', ) @@ -245,12 +245,12 @@ describe('useAppLauncherStore', () => { it('routes Grafana (port 3000) to full-page session', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' }) + store.open({ url: 'http://192.0.2.10:3000', title: 'Grafana' }) expect(store.isOpen).toBe(false) expect(store.panelAppId).toBe(null) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:3000', + 'http://192.0.2.10:3000', '_blank', 'noopener,noreferrer', ) @@ -259,12 +259,12 @@ describe('useAppLauncherStore', () => { it('opens Gitea path URL in new tab', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228/app/gitea/', title: 'Gitea' }) + store.open({ url: 'http://192.0.2.10/app/gitea/', title: 'Gitea' }) expect(store.isOpen).toBe(false) expect(store.panelAppId).toBe(null) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228/app/gitea/', + 'http://192.0.2.10/app/gitea/', '_blank', 'noopener,noreferrer', ) @@ -273,7 +273,7 @@ describe('useAppLauncherStore', () => { it('does not map raw port 3001 to gitea session', () => { const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:3001', title: 'Unknown 3001' }) + store.open({ url: 'http://192.0.2.10:3001', title: 'Unknown 3001' }) expect(store.panelAppId).toBe(null) expect(store.isOpen).toBe(true) @@ -283,11 +283,11 @@ describe('useAppLauncherStore', () => { const store = useAppLauncherStore() // Use an unresolvable URL so it doesn't route to session - store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown', openInNewTab: true }) + store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown', openInNewTab: true }) expect(store.isOpen).toBe(false) expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.228:9999', + 'http://192.0.2.10:9999', '_blank', 'noopener,noreferrer', ) @@ -327,13 +327,13 @@ describe('useAppLauncherStore', () => { it('routes HTTPS same-host apps via session view', () => { Object.defineProperty(window, 'location', { - value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' }, + value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' }, writable: true, configurable: true, }) const store = useAppLauncherStore() - store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' }) + store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' }) // Known port — routes to session (panel mode by default) expect(store.isOpen).toBe(false) @@ -344,17 +344,17 @@ describe('useAppLauncherStore', () => { const store = useAppLauncherStore() // Unresolvable URL — falls through to iframe overlay - store.open({ url: 'http://192.168.1.228:9999', title: 'Custom App' }) + store.open({ url: 'http://192.0.2.10:9999', title: 'Custom App' }) expect(store.isOpen).toBe(true) - expect(store.url).toBe('http://192.168.1.228:9999') + expect(store.url).toBe('http://192.0.2.10:9999') expect(store.title).toBe('Custom App') expect(mockWindowOpen).not.toHaveBeenCalled() }) it('opens unknown different-host URL in iframe overlay', () => { Object.defineProperty(window, 'location', { - value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' }, + value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' }, writable: true, configurable: true, }) @@ -370,7 +370,7 @@ describe('useAppLauncherStore', () => { it('close resets state', () => { const store = useAppLauncherStore() // Use unknown URL to trigger iframe overlay - store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' }) + store.open({ url: 'http://192.0.2.10:9999', title: 'Custom' }) store.close() @@ -385,7 +385,7 @@ describe('useAppLauncherStore', () => { const mockButton = { focus: vi.fn() } as unknown as HTMLElement Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true }) - store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' }) + store.open({ url: 'http://192.0.2.10:9999', title: 'Custom' }) store.close() expect(store.isOpen).toBe(false) diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts index 7211250f..869707b4 100644 --- a/neode-ui/src/stores/container.ts +++ b/neode-ui/src/stores/container.ts @@ -31,7 +31,7 @@ export const BUNDLED_APPS: BundledApp[] = [ { id: 'bitcoin-knots', name: 'Bitcoin Knots', - image: '146.59.87.168:3000/lfg2025/bitcoin-knots:latest', + image: 'source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest', description: 'Full Bitcoin node with additional features', icon: '₿', ports: [{ host: 8334, container: 80 }], diff --git a/neode-ui/src/utils/dummyApps.ts b/neode-ui/src/utils/dummyApps.ts index a2e9b013..34f9e464 100644 --- a/neode-ui/src/utils/dummyApps.ts +++ b/neode-ui/src/utils/dummyApps.ts @@ -144,7 +144,7 @@ export const dummyApps: Record = { 'interface-addresses': { main: { 'tor-address': 'lorabell.onion', - 'lan-address': 'http://192.168.1.166' + 'lan-address': 'http://192.0.2.13' } }, status: ServiceStatus.Running diff --git a/neode-ui/src/views/AppRegistries.vue b/neode-ui/src/views/AppRegistries.vue index 1236a392..976e5d98 100644 --- a/neode-ui/src/views/AppRegistries.vue +++ b/neode-ui/src/views/AppRegistries.vue @@ -129,7 +129,7 @@

The URL should be of the form host[:port]/namespace — for example ghcr.io/myorg or - 192.168.1.50:3000/apps. Registries are + 192.0.2.10:3000/apps. Registries are added to the end of the list; use "Make primary" to reorder.

diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index 27270cde..e137257b 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -1044,7 +1044,7 @@ function loadCounts() { // happened to be mounting/activating at the same moment (Home/Server's own // onMounted bursts), this measurably doubles the concurrent same-origin // request volume at the single riskiest instant in the session — first -// activation. On archi-dev-box that volume was large enough to leave one +// activation. On a test node that volume was large enough to leave one // in-flight File Browser request permanently stuck (never resolving, // confirmed via direct reproduction), which then starved the browser's // per-origin connection pool and silently broke every subsequent diff --git a/neode-ui/src/views/ContainerApps.vue b/neode-ui/src/views/ContainerApps.vue index dd675190..9d5ee67d 100644 --- a/neode-ui/src/views/ContainerApps.vue +++ b/neode-ui/src/views/ContainerApps.vue @@ -368,7 +368,7 @@ const backendPort = 5678 function getLaunchUrl(app: BundledApp): string { // Prefer lan_address from backend (for apps with custom UIs) if (app.lan_address) { - // Replace localhost so Launch works when browsing from another machine (e.g. 192.168.1.228) + // Replace localhost so Launch works when browsing from another machine (e.g. a LAN address) let url = app.lan_address.replace(/localhost/i, currentHost.value) // LND UI (and other app UIs) need backend URL for live data (logs, getinfo proxy) if (app.id === 'lnd') { diff --git a/neode-ui/src/views/Home.vue b/neode-ui/src/views/Home.vue index 01435faf..bd2f9668 100644 --- a/neode-ui/src/views/Home.vue +++ b/neode-ui/src/views/Home.vue @@ -764,7 +764,7 @@ async function loadWeb5Status() { .catch(() => { // A single slow poll must NOT flip the card to "disconnected" and // hide balances the user already knows — busy nodes routinely blow - // the 5s budget mid-payment or during IO storms (framework-pt user + // the 5s budget mid-payment or during IO storms (a test node user // report: balances vanished while a payment settled). Only call it // disconnected after three consecutive failures (~30s of silence). walletInfoFailures += 1 diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index b88158af..3235ab1a 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -306,6 +306,11 @@ async function loadNostrMarketplace() { trustScore: app.trust_score, trustTier: app.trust_tier, relayCount: app.relay_count, + // Default to `missing` rather than leaving it undefined: a node running + // an older backend returns no field at all, and "we couldn't check" must + // never render as "signed". + signature: app.signature ?? { status: 'missing' as const }, + authorDid: app.manifest.author.did, })) } catch (e) { nostrError.value = e instanceof Error ? e.message : 'Discovery failed' diff --git a/neode-ui/src/views/MarketplaceAppDetails.vue b/neode-ui/src/views/MarketplaceAppDetails.vue index eaf9497f..cfbe6bdd 100644 --- a/neode-ui/src/views/MarketplaceAppDetails.vue +++ b/neode-ui/src/views/MarketplaceAppDetails.vue @@ -507,7 +507,7 @@ const features = computed(() => { }) /** App dependency definitions */ -const R = '146.59.87.168:3000/lfg2025' +const R = 'source.archipelago-foundation.org/lfg2025' const APP_DEPENDENCIES: Record = { 'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }], 'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }], diff --git a/neode-ui/src/views/SystemUpdate.vue b/neode-ui/src/views/SystemUpdate.vue index f9f9436c..09c608d8 100644 --- a/neode-ui/src/views/SystemUpdate.vue +++ b/neode-ui/src/views/SystemUpdate.vue @@ -254,7 +254,10 @@ >+ Add mirror

- Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest — switching primary switches where files come from. + Sources this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one is tried automatically. Downloads always come from the source that served the manifest — switching primary switches where files come from. +

+

+ The two built-in entries are the same server, not two servers: the second reaches it by IP without DNS or TLS, which recovers a node whose DNS is broken or whose clock is wrong. It does not help if the server itself is down. Every update is signature-checked whichever source serves it, so an unencrypted fetch can't substitute a tampered build. For real redundancy, add a mirror on a different host.

  • diff --git a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts index dd64361b..531a2e57 100644 --- a/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts +++ b/neode-ui/src/views/__tests__/AppSessionMobileNewTab.test.ts @@ -56,7 +56,7 @@ describe('AppSession mobile new-tab apps', () => { configurable: true, }) Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) diff --git a/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts b/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts index 6f00e44a..7fa543eb 100644 --- a/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts +++ b/neode-ui/src/views/__tests__/ServerNetworkRefresh.test.ts @@ -103,7 +103,7 @@ describe('Server network refresh states', () => { vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => { if (request.method === 'network.list-interfaces') { return Promise.resolve({ - interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }], + interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.0.2.10'] }], }) } if (request.method === 'network.diagnostics') { @@ -131,7 +131,7 @@ describe('Server network refresh states', () => { await flushPromises() expect(wrapper.text()).toContain('eth0') - expect(wrapper.text()).toContain('192.168.1.10') + expect(wrapper.text()).toContain('192.0.2.10') const pendingInterfaces = deferred<{ interfaces: [] }>() vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => { @@ -143,7 +143,7 @@ describe('Server network refresh states', () => { await wrapper.vm.$nextTick() expect(wrapper.text()).toContain('eth0') - expect(wrapper.text()).toContain('192.168.1.10') + expect(wrapper.text()).toContain('192.0.2.10') expect(wrapper.text()).toContain('Refreshing interfaces...') pendingInterfaces.reject(new Error('offline')) @@ -151,7 +151,7 @@ describe('Server network refresh states', () => { await flushPromises() expect(wrapper.text()).toContain('eth0') - expect(wrapper.text()).toContain('192.168.1.10') + expect(wrapper.text()).toContain('192.0.2.10') }) it('keeps Tor services visible while refresh is pending or fails', async () => { diff --git a/neode-ui/src/views/__tests__/TransportPills.test.ts b/neode-ui/src/views/__tests__/TransportPills.test.ts index 755e8cde..2c27881e 100644 --- a/neode-ui/src/views/__tests__/TransportPills.test.ts +++ b/neode-ui/src/views/__tests__/TransportPills.test.ts @@ -27,7 +27,6 @@ * The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a * bug: transport is measured PER PEER PER BROWSE, never per file, so a * per-file pill would claim a reading the app never took. The reasoning is - * written up in .planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md. * If you deliberately add a per-file pill, update that decision record and * this test together. */ diff --git a/neode-ui/src/views/appDetails/appDetailsData.ts b/neode-ui/src/views/appDetails/appDetailsData.ts index e3af6854..bbd16f56 100644 --- a/neode-ui/src/views/appDetails/appDetailsData.ts +++ b/neode-ui/src/views/appDetails/appDetailsData.ts @@ -52,39 +52,6 @@ export function resolvePackageKey(routeId: string): string { /** Apps that depend on Bitcoin being synced */ export const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver'] -/** App launch URLs for dev and prod environments */ -export const APP_URLS: Record = { - 'lorabell': { dev: 'http://192.168.1.166', prod: 'http://192.168.1.166' }, - 'atob': { dev: 'http://localhost:8102', prod: 'https://app.atobitcoin.io' }, - 'k484': { dev: 'http://localhost:8103', prod: 'http://localhost:8103' }, - 'bitcoin': { dev: 'http://localhost:8332', prod: 'http://localhost:8332' }, - 'btcpay-server': { dev: 'http://localhost:23000', prod: 'http://localhost:23000' }, - 'homeassistant': { dev: 'http://localhost:8123', prod: 'http://localhost:8123' }, - 'grafana': { dev: 'http://localhost:3000', prod: 'http://localhost:3000' }, - 'endurain': { dev: 'http://localhost:8080', prod: 'http://localhost:8080' }, - 'fedimint': { dev: 'http://localhost:8175', prod: 'http://192.168.1.228:8175' }, - 'fedimint-gateway': { dev: 'http://localhost:8176', prod: 'http://192.168.1.228:8176' }, - 'morphos-server': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' }, - 'lightning-stack': { dev: 'http://localhost:9735', prod: 'http://localhost:9735' }, - 'mempool': { dev: 'http://localhost:4080', prod: 'http://localhost:4080' }, - 'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' }, - 'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' }, - 'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' }, - 'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' }, - 'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' }, - 'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' }, - 'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' }, - 'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' }, - 'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' }, - 'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' }, - 'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' }, - 'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' }, - 'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' }, - 'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' }, - 'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' }, - 'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' }, -} - /** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */ export function isRealOnionAddress(addr: string | undefined): boolean { return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70) diff --git a/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts index 79e7661f..7086aaec 100644 --- a/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appOrigin.test.ts @@ -17,18 +17,18 @@ afterEach(() => vi.unstubAllGlobals()) describe('appOrigin', () => { it('stays on http for an http dashboard', () => { - setLocation('http:', 'archi-dev-box') - expect(appOrigin(8334)).toBe('http://archi-dev-box:8334') + setLocation('http:', 'test-node.local') + expect(appOrigin(8334)).toBe('http://test-node.local:8334') }) it('follows an https dashboard onto the app port', () => { - setLocation('https:', 'archi-dev-box') - expect(appOrigin(8334)).toBe('https://archi-dev-box:8334') + setLocation('https:', 'test-node.local') + expect(appOrigin(8334)).toBe('https://test-node.local:8334') }) it('keeps the hostname the user actually typed, not a fixed name', () => { - setLocation('https:', '100.69.68.39') - expect(appOrigin(3000)).toBe('https://100.69.68.39:3000') + setLocation('https:', '100.64.0.5') + expect(appOrigin(3000)).toBe('https://100.64.0.5:3000') }) }) diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index d9c51de1..54ceb5e1 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -16,19 +16,19 @@ describe('appSessionConfig', () => { it('resolves direct app ports against the current browser host', () => { Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) - expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080') - expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778') - expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100') + expect(resolveAppUrl('mempool')).toBe('http://192.0.2.10:4080') + expect(resolveAppUrl('indeedhub')).toBe('http://192.0.2.10:7778') + expect(resolveAppUrl('botfights')).toBe('http://192.0.2.10:9100') }) it('uses manifest-generated launch ports for apps outside the manual override list', () => { Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) @@ -36,12 +36,12 @@ describe('appSessionConfig', () => { // did-wallet's manifest publishes host port 8088 (apps/did-wallet/ // manifest.yml) — assert against the manifest-generated value, which is // exactly what this test exists to protect. - expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088') + expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088') }) it('does not treat service-only tcp ports as web launch surfaces', () => { Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) @@ -51,21 +51,21 @@ describe('appSessionConfig', () => { it('keeps NetBird on the unified dashboard proxy port', () => { Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) - expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087') + expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087') }) it('uses backend runtime URLs for apps with dynamic launch surfaces', () => { Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.228' }, + value: { hostname: '192.0.2.10' }, writable: true, configurable: true, }) - expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083') + expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') }) }) diff --git a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts index 5322944b..11ffcea9 100644 --- a/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts +++ b/neode-ui/src/views/apps/__tests__/AppIconGrid.test.ts @@ -52,7 +52,7 @@ describe('AppIconGrid', () => { configurable: true, }) Object.defineProperty(window, 'location', { - value: { hostname: '192.168.1.198' }, + value: { hostname: '192.0.2.11' }, writable: true, configurable: true, }) @@ -112,7 +112,7 @@ describe('AppIconGrid', () => { await flushPromises() expect(mockWindowOpen).toHaveBeenCalledWith( - 'http://192.168.1.198:3001', + 'http://192.0.2.11:3001', '_blank', 'noopener,noreferrer', ) diff --git a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts index e796355f..4879c89d 100644 --- a/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts +++ b/neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts @@ -693,7 +693,7 @@ describe('keepAliveRoutes: widened registration set (02-04)', () => { // 02-FINDINGS.md's "## Server KeepAlive Root Cause (gap closure)" section) // rather than a real KeepAlive/lifecycle defect — an authoritative, // independent `document.elementFromPoint()` hit-test signal on the deployed -// archi-dev-box build directly contradicted the naive selector-match probe's +// a test node build directly contradicted the naive selector-match probe's // "remounted" verdict for both Server.vue and Web5.vue, and a companion // diagnostic found the ORIGINAL stamped root still connected and visible // under a different (unpicked) DOM match. No source change to diff --git a/neode-ui/src/views/dashboard/keepAliveRoutes.ts b/neode-ui/src/views/dashboard/keepAliveRoutes.ts index e883fc8e..1b201656 100644 --- a/neode-ui/src/views/dashboard/keepAliveRoutes.ts +++ b/neode-ui/src/views/dashboard/keepAliveRoutes.ts @@ -15,7 +15,7 @@ import { TAB_ORDER } from './useRouteTransitions' * once this cap is exceeded (Vue's own LRU eviction). * * 02-08 (FA-D) tuned this against an on-device measurement rather than - * leaving the carried-forward estimate unexamined: on archi-dev-box (real + * leaving the carried-forward estimate unexamined: on a test node (real * node hardware, deployed build), a headless Chromium session logged in via * the real UI, cycled every main tab (all of TAB_ORDER incl. the withheld * `/dashboard/settings`, plus `/dashboard/discover` — 11 tabs) through 4 full diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index 3e0c6a5e..cee22849 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -1,6 +1,6 @@ import type { MarketplaceApp } from './types' -const R = '146.59.87.168:3000/lfg2025' +const R = 'source.archipelago-foundation.org/lfg2025' // ---------- Dynamic catalog from registry ---------- export interface CatalogFeatured { @@ -84,7 +84,7 @@ export function getCuratedAppList(): MarketplaceApp[] { return [ { id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' }, { id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' }, - { id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, + { id: 'btcpay-server', title: 'BTCPay Server', version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, { id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.png', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' }, { id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' }, { id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' }, diff --git a/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts b/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts index 75d8a9d1..52a6c4b0 100644 --- a/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts +++ b/neode-ui/src/views/fleet/__tests__/useFleetData.test.ts @@ -91,19 +91,19 @@ describe('fleet data helpers', () => { node_id: 'abcdef123456', node_name: 'Kitchen Node', hostname: 'kitchen-node', - server_url: 'https://192.168.1.20', + server_url: 'https://192.0.2.20', }) const hostOnly = normalizeFleetNode({ node_id: '123456abcdef', hostname: 'workshop-node', - server_url: 'https://192.168.1.21', + server_url: 'https://192.0.2.21', }) const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' }) expect(fleetNodeDisplayName(named)).toBe('Kitchen Node') expect(fleetNodeSubtitle(named)).toBe('kitchen-node') expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node') - expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.168.1.21') + expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.0.2.21') expect(fleetNodeDisplayName(idOnly)).toBe('feedface') expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef') }) diff --git a/neode-ui/src/views/marketplace/MarketplaceAppCard.vue b/neode-ui/src/views/marketplace/MarketplaceAppCard.vue index ebd06de9..94515a05 100644 --- a/neode-ui/src/views/marketplace/MarketplaceAppCard.vue +++ b/neode-ui/src/views/marketplace/MarketplaceAppCard.vue @@ -32,7 +32,7 @@ -
    +
    {{ app.trustTier }} + + + + + + {{ signatureLabel }} + + Score: {{ app.trustScore }}/100 · {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}
    @@ -175,6 +200,35 @@ defineEmits<{ launch: [app: MarketplaceApp] }>() +const signatureLabel = computed(() => { + switch (props.app.signature?.status) { + case 'valid': return 'signed' + case 'invalid': return 'bad signature' + default: return 'unsigned' + } +}) + +/** + * The badge is two words; the tooltip carries the meaning. "signed" is easy to + * read as "safe", so say what was actually proven — that the author holds the + * key their DID names — and nothing more. + */ +const signatureTooltip = computed(() => { + const sig = props.app.signature + const did = props.app.authorDid + const shortDid = did && did.length > 24 ? `${did.slice(0, 16)}…${did.slice(-6)}` : did + switch (sig?.status) { + case 'valid': + return `Authorship verified: signed by the key ${shortDid ?? 'in author.did'}. ` + + 'This proves who published it, not that the app is safe.' + case 'invalid': + return `Signature did not verify: ${sig.reason}` + default: + return 'No author signature — the publisher\'s identity is unproven. ' + + 'The app may still be fine; nothing has been demonstrated about who wrote it.' + } +}) + const installProgressMessage = computed(() => { const p = props.installProgress if (!p) return 'Installing' diff --git a/neode-ui/src/views/marketplace/marketplaceData.ts b/neode-ui/src/views/marketplace/marketplaceData.ts index 19b55307..5cecd729 100644 --- a/neode-ui/src/views/marketplace/marketplaceData.ts +++ b/neode-ui/src/views/marketplace/marketplaceData.ts @@ -29,8 +29,20 @@ export interface MarketplaceApp { trustScore?: number trustTier?: string relayCount?: number + /** + * DID-signature verdict for relay-discovered apps. `undefined` for curated + * and local apps, which don't travel through the marketplace protocol at all. + */ + signature?: AppSignature + /** The `author.did` the signature was checked against, for the tooltip. */ + authorDid?: string } +export type AppSignature = + | { status: 'valid' } + | { status: 'missing' } + | { status: 'invalid'; reason: string } + export type AppScreenshot = string | { src: string alt?: string @@ -46,7 +58,7 @@ export interface InstallProgress { } /** Archipelago app registry — all app images are mirrored here */ -const REGISTRY = '146.59.87.168:3000/lfg2025' +const REGISTRY = 'source.archipelago-foundation.org/lfg2025' /** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */ export const INSTALLED_ALIASES: Record = { @@ -155,11 +167,11 @@ export function getCuratedAppList(): MarketplaceApp[] { { id: 'btcpay-server', title: 'BTCPay Server', - version: '2.3.9', + version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', - dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', + dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', manifestUrl: undefined, repoUrl: 'https://github.com/btcpayserver/btcpayserver' }, @@ -390,7 +402,7 @@ export function getCuratedAppList(): MarketplaceApp[] { description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.', icon: '/assets/img/app-icons/indeedhub.png', author: 'Indeehub Team', - dockerImage: '146.59.87.168:3000/lfg2025/indeedhub:latest', + dockerImage: 'source.archipelago-foundation.org/lfg2025/indeedhub:latest', manifestUrl: undefined, repoUrl: 'https://github.com/indeedhub/indeedhub' }, diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 3695cac2..0c63215c 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -362,6 +362,24 @@ init()
    + +
    +
    + v1.7.126-alpha + August 7, 2026 +
    +
    +

    The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole. BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an "Update" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected.

    +

    An app now reports its own version, not a helper's. Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as "15.17", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.

    +

    Your node issues its own certificate, so apps stop being flagged as insecure. Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.

    +

    An app that is still starting says "starting". It previously reported "App not reachable", which reads as a failure when the app is simply warming up.

    +

    Updates and app downloads now come from a proper domain name. They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe.

    +

    Also in this release: the tool app developers run to check their app description no longer rejects every valid file, and the node's own security audit — which had been reporting all-clear — now actually inspects the files where credentials had been sitting.

    +

    Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source ahead of the code being opened to the public. No behaviour changes for your node.

    +

    Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.

    +
    +
    +
    diff --git a/neode-ui/src/views/settings/LightningCredentialsSection.vue b/neode-ui/src/views/settings/LightningCredentialsSection.vue new file mode 100644 index 00000000..e2dbc320 --- /dev/null +++ b/neode-ui/src/views/settings/LightningCredentialsSection.vue @@ -0,0 +1,371 @@ + + + diff --git a/neode-ui/src/views/settings/SystemSection.vue b/neode-ui/src/views/settings/SystemSection.vue index a4b8ab3f..023b652d 100644 --- a/neode-ui/src/views/settings/SystemSection.vue +++ b/neode-ui/src/views/settings/SystemSection.vue @@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue' import WebhookSection from '@/views/settings/WebhookSection.vue' import TelemetrySection from '@/views/settings/TelemetrySection.vue' import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue' +import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue' import BackupSection from '@/views/settings/BackupSection.vue' import SystemDangerZone from '@/views/settings/SystemDangerZone.vue' @@ -18,6 +19,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue' + diff --git a/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts b/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts new file mode 100644 index 00000000..05f4be33 --- /dev/null +++ b/neode-ui/src/views/settings/__tests__/LightningCredentialsSection.test.ts @@ -0,0 +1,292 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import LightningCredentialsSection from '../LightningCredentialsSection.vue' +import { rpcClient } from '@/api/rpc-client' + +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + lndMacaroonStatus: vi.fn(), + lndRotateMacaroons: vi.fn(), + lndMacaroonRotationProgress: vi.fn(), + }, +})) + +const STEP_KEYS = ['preflight', 'backup', 'stop', 'remove', 'start', 'verify', 'btcpay'] as const + +type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped' + +function steps(overrides: Partial> = {}) { + return STEP_KEYS.map((key) => ({ + key, + label: `label-${key}`, + state: overrides[key]?.state ?? ('pending' as StepState), + detail: overrides[key]?.detail ?? null, + })) +} + +function idleRotation() { + return { + running: false, + ok: null, + started_at: null, + finished_at: null, + error: null, + steps: steps(), + backup_path: null, + identity_pubkey: null, + channels_before: null, + channels_after: null, + new_admin_macaroon_sha256: null, + } +} + +function status(overrides: Record = {}) { + return { + installed: true, + admin_macaroon_sha256: 'a'.repeat(64), + issued_at: '2026-08-08 06:03:11', + identity_pubkey: '024a5fd7de13623aeec81095cf8776fedbc0c4109363022c3ec948196202130b92', + channels_open: 3, + channels_pending: 1, + lnd_error: null, + btcpay_uses_internal_lnd: true, + btcpay_credential_current: true, + rotation: idleRotation(), + ...overrides, + } +} + +function mountSection() { + return mount(LightningCredentialsSection, { + global: { stubs: { Teleport: true } }, + }) +} + +describe('LightningCredentialsSection', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.clearAllMocks() + }) + + it('shows what must survive before offering to rotate', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status()) + + const wrapper = mountSection() + await flushPromises() + + // The channel census is the reassurance an operator needs before clicking a + // button that invalidates every credential their wallet holds. + expect(wrapper.text()).toContain('3 open') + expect(wrapper.text()).toContain('1 pending') + expect(wrapper.text()).toContain('2026-08-08 06:03:11') + // A digest is fine to display; the token itself must never be fetched. + expect(wrapper.text()).toContain('aaaaaaaaaaaaaaaa…') + expect(wrapper.find('button').attributes('disabled')).toBeUndefined() + }) + + it('warns when BTCPay is stranded on a rotated-out credential', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ btcpay_credential_current: false }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).toContain('BTCPay Server is holding an old Lightning credential') + }) + + it('stays quiet about BTCPay when there is no internal node to warn about', async () => { + // null means "not configured" — an absence, not a fault. Reporting it as a + // problem would train operators to ignore the warning that matters. + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ btcpay_uses_internal_lnd: false, btcpay_credential_current: null }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).not.toContain('holding an old Lightning credential') + }) + + it('blocks rotation while LND is not answering', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ + lnd_error: 'LND is not answering on its REST port', + channels_open: null, + channels_pending: null, + identity_pubkey: null, + }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).toContain('Lightning is not answering right now') + // Without a before-reading there is no way to prove the channels came back, + // so the button must be unavailable rather than merely discouraged. + expect(wrapper.find('button').attributes('disabled')).toBeDefined() + }) + + it('says Lightning is not installed instead of offering a no-op rotation', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ installed: false, admin_macaroon_sha256: null }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).toContain('Lightning is not set up on this node yet') + expect(wrapper.findAll('button')).toHaveLength(0) + }) + + async function startRotation(wrapper: ReturnType, pw = 'node-password') { + await wrapper.find('button').trigger('click') + await wrapper.find('input[type="password"]').setValue(pw) + await wrapper.find('form').trigger('submit') + await flushPromises() + } + + it('sends the password and starts polling for progress', async () => { + vi.mocked(rpcClient.lndMacaroonStatus) + .mockResolvedValueOnce(status()) + .mockResolvedValue(status({ rotation: { ...idleRotation(), running: true } })) + vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' }) + + const wrapper = mountSection() + await flushPromises() + await startRotation(wrapper) + + expect(rpcClient.lndRotateMacaroons).toHaveBeenCalledWith('node-password') + + const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length + vi.advanceTimersByTime(4000) + await flushPromises() + expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore) + }) + + it('keeps polling when the first status after starting has not caught up yet', async () => { + // The node accepts the rotation and then answers a status request that was + // computed a moment earlier, still saying `running: false`. Cancelling the + // poll here would freeze the screen on the one action that most needs to show + // progress — the operator has just invalidated every credential their wallet + // holds and would be told nothing is happening. + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status()) + vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' }) + + const wrapper = mountSection() + await flushPromises() + await startRotation(wrapper) + + const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length + vi.advanceTimersByTime(4000) + await flushPromises() + expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore) + }) + + it('gives up polling if the node never reports the rotation as running', async () => { + // Bounded, so a request that was accepted but never acted on stops polling + // instead of hammering the node forever. + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status()) + vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' }) + + const wrapper = mountSection() + await flushPromises() + await startRotation(wrapper) + + vi.advanceTimersByTime(180_000) + await flushPromises() + const callsAfterGiveUp = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length + vi.advanceTimersByTime(30_000) + await flushPromises() + expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterGiveUp) + }) + + it('surfaces a rejected password without starting anything', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status()) + vi.mocked(rpcClient.lndRotateMacaroons).mockRejectedValue( + new Error('Password verification failed'), + ) + + const wrapper = mountSection() + await flushPromises() + + await wrapper.find('button').trigger('click') + await wrapper.find('input[type="password"]').setValue('wrong') + await wrapper.find('form').trigger('submit') + await flushPromises() + + expect(wrapper.text()).toContain('Password verification failed') + // The dialog stays open so the operator can correct the password. + expect(wrapper.find('input[type="password"]').exists()).toBe(true) + }) + + it('reports a finished rotation with the re-pair and backup-cleanup steps', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ + rotation: { + ...idleRotation(), + ok: true, + finished_at: '2026-08-08T12:00:00Z', + steps: steps({ + preflight: { state: 'done' }, + backup: { state: 'done' }, + stop: { state: 'done' }, + remove: { state: 'done' }, + start: { state: 'done' }, + verify: { state: 'done', detail: 'same node, same 3 channel(s)' }, + btcpay: { state: 'done' }, + }), + backup_path: '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z', + channels_before: 3, + channels_after: 3, + }, + }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).toContain('Rotation complete') + expect(wrapper.text()).toContain('same node, same 3 channel(s)') + expect(wrapper.text()).toContain('Re-pair anything that connects to this node') + // The backup holds the OLD root key, so telling the operator to delete it is + // part of the job, not a nicety. + expect(wrapper.text()).toContain('macaroon-rotation-20260808T120000Z') + }) + + it('reports a failed rotation as failed rather than silently idle', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue( + status({ + rotation: { + ...idleRotation(), + ok: false, + finished_at: '2026-08-08T12:00:00Z', + error: 'backup incomplete — refusing to delete anything', + steps: steps({ preflight: { state: 'done' }, backup: { state: 'failed' } }), + }, + }), + ) + + const wrapper = mountSection() + await flushPromises() + + expect(wrapper.text()).toContain('Rotation failed') + expect(wrapper.text()).toContain('backup incomplete') + }) + + it('does not poll the node when nothing is running', async () => { + vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status()) + + mountSection() + await flushPromises() + + const callsAfterLoad = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length + vi.advanceTimersByTime(30_000) + await flushPromises() + expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad) + }) +}) diff --git a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts index 1fcce2fa..cf6ed9ca 100644 --- a/neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts +++ b/neode-ui/src/views/web5/__tests__/Web5ConnectedNodesScroll.test.ts @@ -6,7 +6,6 @@ import Web5ConnectedNodes from '../Web5ConnectedNodes.vue' // its row sibling (via grid `align-items: stretch` + a zero-basis flex // child), and the tab panes must scroll inside that height rather than // growing to fit every row. See: -// .planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }), diff --git a/neode-ui/test-openwrt.mjs b/neode-ui/test-openwrt.mjs index 3124c39a..aad65b88 100644 --- a/neode-ui/test-openwrt.mjs +++ b/neode-ui/test-openwrt.mjs @@ -1,14 +1,22 @@ import { chromium } from './node_modules/playwright/index.mjs'; -const BASE = 'https://100.66.157.121'; -const PASS = 'ThisIsWeb54321@'; -const DIR = '/tmp/claude-1000/-home-debian/97c10035-69a8-40a0-9b55-219eb8ad683a/scratchpad'; +// Node under test + credentials come from the environment; never commit literals. +// ARCHY_NODE_URL=https:// ARCHY_NODE_PW=… node test-openwrt.mjs +const BASE = process.env.ARCHY_NODE_URL || 'https://127.0.0.1'; +const PASS = process.env.ARCHY_NODE_PW; +const DIR = process.env.ARCHY_OUT_DIR || '/tmp/openwrt-test'; + +if (!PASS) { + console.error('ERROR: ARCHY_NODE_PW must be set in the environment.'); + process.exit(2); +} // Find the OpenWrt router IP from the Tailscale/LAN const { execSync } = await import('child_process'); let routerIp = '192.168.1.1'; try { - const route = execSync("ssh archipelago@100.66.157.121 'ip route | grep default'", { encoding: 'utf8' }).trim(); + const sshTarget = process.env.ARCHY_NODE_SSH || `archipelago@${new URL(BASE).hostname}`; + const route = execSync(`ssh ${sshTarget} 'ip route | grep default'`, { encoding: 'utf8' }).trim(); const match = route.match(/default via ([\d.]+)/); if (match) routerIp = match[1]; } catch {} diff --git a/neode-ui/vite.config.ts b/neode-ui/vite.config.ts index e8a8f0dc..afea9723 100644 --- a/neode-ui/vite.config.ts +++ b/neode-ui/vite.config.ts @@ -117,7 +117,7 @@ export default defineConfig({ } }, server: { - host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.168.1.116:8100) + host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.0.2.12:8100) port: 8100, proxy: { '/rpc/v1': { diff --git a/release-manifest.json b/release-manifest.json index a3bb531b..30081598 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,35 +1,34 @@ { "changelog": [ - "**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.", - "**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.", - "**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.", - "**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.", - "**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.", - "**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.", - "**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.", - "Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.", - "Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." + "**The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.** BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an \"Update\" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected.", + "**An app now reports its own version, not a helper's.** Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as \"15.17\", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.", + "**Your node issues its own certificate, so apps stop being flagged as insecure.** Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.", + "**An app that is still starting says \"starting\".** It previously reported \"App not reachable\", which reads as a failure when the app is simply warming up.", + "**Updates and app downloads now come from a proper domain name.** They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe.", + "Also in this release: the tool app developers run to check their app description no longer rejects every valid file (it needed a program most machines do not have, and reported the missing program as a broken file); and the node's own security audit, which had been reporting all-clear, now actually inspects the files where credentials had been sitting.", + "Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source — machine names, addresses and internal working notes — ahead of the code being opened to the public. No behaviour changes for your node.", + "Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.125-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago", + "current_version": "1.7.126-alpha", + "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.126-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.125-alpha", - "sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d", - "size_bytes": 55060216 + "new_version": "1.7.126-alpha", + "sha256": "5c5dd08cfe0db87d33626621ac3b1c4fbc7f8f152db4a61f7abcf798d0ddaa9f", + "size_bytes": 55424208 }, { - "current_version": "1.7.125-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz", - "name": "archipelago-frontend-1.7.125-alpha.tar.gz", - "new_version": "1.7.125-alpha", - "sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1", - "size_bytes": 210532813 + "current_version": "1.7.126-alpha", + "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.126-alpha/archipelago-frontend-1.7.126-alpha.tar.gz", + "name": "archipelago-frontend-1.7.126-alpha.tar.gz", + "new_version": "1.7.126-alpha", + "sha256": "ccc017dd9557db546a272255492e95f2162f4a002c8ae6cf744986046cb0bc6b", + "size_bytes": 210566347 } ], - "release_date": "2026-08-06", - "signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403", + "release_date": "2026-08-07", + "signature": "21a8256c4366c2423b1ce9f0874bbdff0f0938bc68f0eb571b8729113703fbd5129228712aadd0dbd0f80a133315d1d58b0140b31d0b3bd99aa355bf75d35d0f", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "version": "1.7.125-alpha" + "version": "1.7.126-alpha" } diff --git a/releases/app-catalog.json b/releases/app-catalog.json index 364dfa3d..065bf05b 100644 --- a/releases/app-catalog.json +++ b/releases/app-catalog.json @@ -858,13 +858,13 @@ "version": "1.2.11" }, "btcpay": { - "image": "docker.io/btcpayserver/btcpayserver:2.3.9", + "image": "docker.io/btcpayserver/btcpayserver:2.4.2", "images": { "archy-btcpay-db": "146.59.87.168:3000/lfg2025/postgres:15.17", "archy-nbxplorer": "146.59.87.168:3000/lfg2025/nbxplorer:2.6.0", - "btcpay-server": "docker.io/btcpayserver/btcpayserver:2.3.9" + "btcpay-server": "docker.io/btcpayserver/btcpayserver:2.4.2" }, - "version": "2.3.9" + "version": "2.4.2" }, "btcpay-server": { "manifest": { @@ -880,7 +880,7 @@ "template": "{{HOST_IP}}:23000" } ], - "image": "docker.io/btcpayserver/btcpayserver:2.3.9", + "image": "docker.io/btcpayserver/btcpayserver:2.4.2", "network": "archy-net", "pull_policy": "if-not-present", "secret_env": [ @@ -972,7 +972,7 @@ "network_policy": "isolated", "readonly_root": false }, - "version": "2.3.9", + "version": "2.4.2", "volumes": [ { "options": [ @@ -985,7 +985,7 @@ ] } }, - "version": "2.3.9" + "version": "2.4.2" }, "core-lightning": { "manifest": { @@ -4897,7 +4897,7 @@ } }, "schema": 1, - "signature": "a9a0bf60aa6c47ae970a6c7c3e19e9390ee7af157f425c2e38b1bbb194f0315b73ddf16bc46244b36390454787a1ddecc559be806098fdf569d3315f388b9006", + "signature": "0ccba6b8bb26fc718ad126049f96b5800109949770d894fa4ff6c947871cf6c8779c1c474d39ccf886708842cfbbf2e57cb904092bde8f8a65163acd7f3ff401", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "updated": "2026-08-06" + "updated": "2026-08-07" } diff --git a/releases/manifest.json b/releases/manifest.json index a3bb531b..30081598 100644 --- a/releases/manifest.json +++ b/releases/manifest.json @@ -1,35 +1,34 @@ { "changelog": [ - "**The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.", - "**IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.", - "**A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.", - "**Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed \"send all funds\" toggle — no longer quietly carries over into the next payment. Choosing \"send all funds\" also shows the amount being swept instead of a confusing 0.", - "**A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with \"check server logs\"; it now explains that no transaction can be built from those coins.", - "**The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as \"the scanner is gone\". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.", - "**App data folders can no longer be \"repaired\" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.", - "Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.", - "Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." + "**The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.** BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an \"Update\" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected.", + "**An app now reports its own version, not a helper's.** Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as \"15.17\", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.", + "**Your node issues its own certificate, so apps stop being flagged as insecure.** Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.", + "**An app that is still starting says \"starting\".** It previously reported \"App not reachable\", which reads as a failure when the app is simply warming up.", + "**Updates and app downloads now come from a proper domain name.** They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe.", + "Also in this release: the tool app developers run to check their app description no longer rejects every valid file (it needed a program most machines do not have, and reported the missing program as a broken file); and the node's own security audit, which had been reporting all-clear, now actually inspects the files where credentials had been sitting.", + "Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source — machine names, addresses and internal working notes — ahead of the code being opened to the public. No behaviour changes for your node.", + "Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release." ], "components": [ { - "current_version": "1.7.125-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago", + "current_version": "1.7.126-alpha", + "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.126-alpha/archipelago", "name": "archipelago", - "new_version": "1.7.125-alpha", - "sha256": "080bc83cb10b3ebe532497917d96b7af3766c36c30119cb0f348de36162d9e9d", - "size_bytes": 55060216 + "new_version": "1.7.126-alpha", + "sha256": "5c5dd08cfe0db87d33626621ac3b1c4fbc7f8f152db4a61f7abcf798d0ddaa9f", + "size_bytes": 55424208 }, { - "current_version": "1.7.125-alpha", - "download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.125-alpha/archipelago-frontend-1.7.125-alpha.tar.gz", - "name": "archipelago-frontend-1.7.125-alpha.tar.gz", - "new_version": "1.7.125-alpha", - "sha256": "0bff6f169767043928d9189ab53045c9e4f7c523697b42bf770b2b485c2902f1", - "size_bytes": 210532813 + "current_version": "1.7.126-alpha", + "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.126-alpha/archipelago-frontend-1.7.126-alpha.tar.gz", + "name": "archipelago-frontend-1.7.126-alpha.tar.gz", + "new_version": "1.7.126-alpha", + "sha256": "ccc017dd9557db546a272255492e95f2162f4a002c8ae6cf744986046cb0bc6b", + "size_bytes": 210566347 } ], - "release_date": "2026-08-06", - "signature": "975157cc59527679f3de846a4a93d11e27f6ad48eed215fd769574dfc6db36a687867a35aeb6e705d41667ea800b948f53ce7f80bfcdb5a6e1820e50613d4403", + "release_date": "2026-08-07", + "signature": "21a8256c4366c2423b1ce9f0874bbdff0f0938bc68f0eb571b8729113703fbd5129228712aadd0dbd0f80a133315d1d58b0140b31d0b3bd99aa355bf75d35d0f", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", - "version": "1.7.125-alpha" + "version": "1.7.126-alpha" } diff --git a/releases/registry-trust-floor.json b/releases/registry-trust-floor.json new file mode 100644 index 00000000..4f71cbe3 --- /dev/null +++ b/releases/registry-trust-floor.json @@ -0,0 +1,33 @@ +{ + "_comment": [ + "Registry hosts that binaries ALREADY DEPLOYED to nodes are known to trust.", + "", + "This is the floor the signed app catalog must stay within. It is NOT the", + "same as TRUSTED_REGISTRIES in the working tree: that list describes what a", + "binary being built today accepts, while nodes in the field run whatever was", + "shipped to them. Publishing a catalog that names a host the fleet's binaries", + "do not trust makes every install fail with 'not from a trusted registry'.", + "", + "To migrate to a new registry host, in this order:", + " 1. Add the host to TRUSTED_REGISTRIES and ship a binary OTA.", + " 2. Confirm the fleet is running that binary or newer.", + " 3. Add the host here, in the same commit as the confirmation.", + " 4. Only then regenerate and re-sign the catalog against the new host.", + "", + "Removing a host is the mirror image: take it out of the catalog first, let", + "that catalog reach every node, and only then drop it from here." + ], + "hosts": [ + "docker.io", + "ghcr.io", + "localhost", + "146.59.87.168:3000" + ], + "pending": { + "source.archipelago-foundation.org": { + "trusted_from_binary": "unreleased", + "note": "Added to TRUSTED_REGISTRIES 2026-08-07. Not yet shipped in any OTA, so no deployed node accepts it. Promote to `hosts` only after the fleet is confirmed on a binary that includes it." + } + }, + "updated": "2026-08-07" +} diff --git a/reticulum-daemon/README.md b/reticulum-daemon/README.md index 18369877..81918977 100644 --- a/reticulum-daemon/README.md +++ b/reticulum-daemon/README.md @@ -1,7 +1,7 @@ # reticulum-daemon Host-supervised **Reticulum (RNS) + LXMF** bridge for Archipelago's Mesh tab. This is -the Python side of the [Reticulum transport plan](../../.claude/plans/enchanted-strolling-rocket.md): +the Python side of the Reticulum transport work: archipelago spawns one of these per active Reticulum (RNode) radio, it owns the serial port, and the Rust mesh subsystem drives it over a Unix-socket JSON-RPC. diff --git a/scripts/app-catalog-image-smoke-test.py b/scripts/app-catalog-image-smoke-test.py index b6ab7e80..90bc2ed3 100755 --- a/scripts/app-catalog-image-smoke-test.py +++ b/scripts/app-catalog-image-smoke-test.py @@ -13,7 +13,7 @@ Checks: Usage: scripts/app-catalog-image-smoke-test.py \ - --target archipelago@192.168.1.198 \ + --target archipelago@192.0.2.11 \ --ssh-key /home/archipelago/.ssh/id_ed25519 """ @@ -31,7 +31,7 @@ from pathlib import Path import yaml -INSECURE_REGISTRIES = ("146.59.87.168:3000", "23.182.128.160:3000") +INSECURE_REGISTRIES = ("source.archipelago-foundation.org", "23.182.128.160:3000") def run(cmd: list[str], timeout: int = 120) -> subprocess.CompletedProcess[str]: diff --git a/scripts/app-surface-smoke-test.sh b/scripts/app-surface-smoke-test.sh index a014a021..096b1cd6 100755 --- a/scripts/app-surface-smoke-test.sh +++ b/scripts/app-surface-smoke-test.sh @@ -7,7 +7,7 @@ # the common "container is running but UI disappeared" failure mode. # # Usage: -# scripts/app-surface-smoke-test.sh --target archipelago@192.168.1.228 --ssh-key /path/key +# scripts/app-surface-smoke-test.sh --target archipelago@192.0.2.10 --ssh-key /path/key set -euo pipefail diff --git a/scripts/audit-secrets.sh b/scripts/audit-secrets.sh index cb1c4823..f9b5456c 100755 --- a/scripts/audit-secrets.sh +++ b/scripts/audit-secrets.sh @@ -18,14 +18,26 @@ PATTERNS=( "api_key\s*=\s*['\"][^'\"]*['\"]" "secret\s*=\s*['\"][^'\"]*['\"]" "private_key\s*=\s*['\"][^'\"]*['\"]" - "sk-ant-" + "sk-ant-[A-Za-z0-9_-]{20,}" "AKIA[A-Z0-9]{16}" "ghp_[a-zA-Z0-9]{36}" "glpat-[a-zA-Z0-9_-]{20}" + # Credentialed URLs: scheme://user:pass@host + "://[A-Za-z0-9_.-]+:[A-Za-z0-9_.@!%-]{8,}@" + # sshpass with an inline literal + "sshpass\s+-p\s*['\"][^'\"]+['\"]" ) -# Allowed files (config templates, docs, test fixtures) -ALLOW_PATTERNS="test|e2e|mock|demo|example|Example|template|CLAUDE.md|deploy-config|\.md$|node_modules|dist|target|default\)|grep.*rpc|audit-secrets|startsWith|should start with" +# Path allowlist — anchored to the PATH only, never to line content. +# The old version allow-matched the whole "file:line:content" string against +# bare words like "test" and "\.md$", so any hit whose path or text contained +# "test"/"demo"/"example" was silently dropped, and *.md was never scanned at +# all. That is why live API keys and node passwords survived this audit. +ALLOW_PATHS="(^|/)node_modules/|(^|/)(dist|target|\.git)/|\.example($|\.)|(^|/)package-lock\.json$|(^|/)Cargo\.lock$|(^|/)scripts/audit-secrets\.sh$" + +# File types to scan. Markdown and YAML are in scope: docs and CI workflows are +# where the real leaks have historically lived. +SCAN_EXTS='\.(rs|ts|vue|js|mjs|cjs|json|sh|py|md|ya?ml|toml|kt|java|gradle|env)$' main() { log "=== Secrets Audit ===" @@ -60,16 +72,26 @@ main() { # 3. Scan source for hardcoded credentials log "3. Scanning source for hardcoded secrets..." local found_secrets=0 + # Scan TRACKED files only — that is exactly the set that would be published. + local scan_files + scan_files=$(cd "$REPO_ROOT" && git ls-files | grep -E "$SCAN_EXTS" | grep -vE "$ALLOW_PATHS" || echo "") + if [ -z "$scan_files" ]; then + fail "No tracked files matched the scan set (is this a git repo?)" + return 1 + fi for pattern in "${PATTERNS[@]}"; do local matches - matches=$(cd "$REPO_ROOT" && grep -rniE "$pattern" \ - --include='*.rs' --include='*.ts' --include='*.vue' --include='*.js' \ - --include='*.json' --include='*.sh' --include='*.py' \ - 2>/dev/null | grep -vE "$ALLOW_PATTERNS" || echo "") + matches=$(cd "$REPO_ROOT" && echo "$scan_files" | tr '\n' '\0' \ + | xargs -0 grep -niE "$pattern" 2>/dev/null || echo "") if [ -n "$matches" ]; then - # Filter out false positives (empty strings, variable declarations, etc.) + # Filter out false positives: empty strings, variable indirection, and + # scrubbed tokens. NOTE: the previous version wrote the + # single-quote class as \x27\x27, which GNU grep does not expand in an + # ERE — so the empty-string rule silently never matched. Use a literal + # quote via a shell variable instead. + local q="'" local real_matches - real_matches=$(echo "$matches" | grep -vE '""|\x27\x27|None|null|undefined|TODO|placeholder|example|Option<|\$\{[A-Z0-9_]+:-\}|\$[A-Z0-9_]+|TestPassword|password123|entertoexit' || echo "") + real_matches=$(echo "$matches" | grep -vE "\"\"|${q}${q}|<[A-Z_]+>|None|null|undefined|TODO|placeholder|Option<|\\\$\{[A-Za-z0-9_]+(:-[^}]*)?\}|\\\$[A-Za-z0-9_]+|TestPassword|password123|entertoexit|…|\\.\\.\\." || echo "") if [ -n "$real_matches" ]; then echo " WARNING: Pattern '$pattern' found:" echo "$real_matches" | head -5 | sed 's/^/ /' @@ -96,7 +118,9 @@ main() { # 5. Check for credential files in repo log "5. Checking for credential files..." local cred_files - cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts)$' || echo "") + # `testdata/` holds throwaway keypairs generated for unit tests (appgate TLS); + # they are not credentials for anything real. Narrow, path-anchored exemption. + cred_files=$(cd "$REPO_ROOT" && git ls-files | grep -Ei '(\.pem$|\.key$|\.p12$|\.pfx$|\.jks$|\.keystore$|id_rsa|id_ed25519|macaroon)' | grep -vE '\.(rs|ts|sh)$|(^|/)testdata/' || echo "") if [ -z "$cred_files" ]; then pass "No credential files tracked in git" else diff --git a/scripts/bitcoin-stack-lifecycle-test.sh b/scripts/bitcoin-stack-lifecycle-test.sh index 34cae1f4..ac361c49 100755 --- a/scripts/bitcoin-stack-lifecycle-test.sh +++ b/scripts/bitcoin-stack-lifecycle-test.sh @@ -10,8 +10,8 @@ # installed nodes, but it will briefly interrupt Bitcoin/ElectrumX service. # # Usage: -# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.228 -# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.168.1.116 --cycles 5 +# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.10 +# scripts/bitcoin-stack-lifecycle-test.sh --target archipelago@192.0.2.12 --cycles 5 set -euo pipefail @@ -50,7 +50,7 @@ while [ "$#" -gt 0 ]; do done if [ -z "$TARGET" ]; then - echo "--target is required, for example archipelago@192.168.1.228" >&2 + echo "--target is required, for example archipelago@192.0.2.10" >&2 exit 2 fi diff --git a/scripts/bootstrap-switchover.sh b/scripts/bootstrap-switchover.sh index 4f4543e4..1b24ae5e 100755 --- a/scripts/bootstrap-switchover.sh +++ b/scripts/bootstrap-switchover.sh @@ -78,7 +78,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^electrumx$'; then -e "DAEMON_URL=http://${RPC_USER}:${RPC_PASS}@bitcoin-knots:8332/" \ -e COIN=Bitcoin -e DB_DIRECTORY=/data \ -e "SERVICES=tcp://:50001,rpc://0.0.0.0:8000" \ - "${ELECTRUMX_IMAGE:-146.59.87.168:3000/lfg2025/electrumx:v1.18.0}" + "${ELECTRUMX_IMAGE:-source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0}" fi # Mempool API @@ -98,7 +98,7 @@ if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q '^mempool-api$'; th -e "DATABASE_ENABLED=true" -e "DATABASE_HOST=archy-mempool-db" \ -e "DATABASE_DATABASE=mempool" -e "DATABASE_USERNAME=mempool" \ -e "DATABASE_PASSWORD=$(cat "$SECRETS_DIR/mempool-db-password" 2>/dev/null || echo mempoolpass)" \ - "${MEMPOOL_API_IMAGE:-146.59.87.168:3000/lfg2025/mempool-api:v3.2.0}" + "${MEMPOOL_API_IMAGE:-source.archipelago-foundation.org/lfg2025/mempool-api:v3.2.0}" fi # Stop Tor tunnel if it was active diff --git a/scripts/check-app-catalog-drift.py b/scripts/check-app-catalog-drift.py index afe59b96..0d943ece 100644 --- a/scripts/check-app-catalog-drift.py +++ b/scripts/check-app-catalog-drift.py @@ -52,12 +52,49 @@ LEGACY_STACK_CATALOG_IDS = { def load_catalog(path: Path) -> dict[str, dict[str, Any]]: + """Load either catalog shape into {app_id: app-fields}. + + Two formats exist and only one used to be understood here: + + * app-catalog/catalog.json — `apps` is a LIST of entries carrying `id`. + * releases/app-catalog.json — `apps` is a DICT keyed by app id, and each + entry wraps the app's full manifest under `manifest.app` (the signed + release catalog; EMBED_MANIFESTS has been on since 2026-06-23). + + The signed release catalog is the one nodes actually resolve apps through, + so a drift checker that only parsed the list form was checking the file + that governs nothing and crashing on the file that governs everything. + """ with path.open("r", encoding="utf-8") as fh: data = json.load(fh) apps = data.get("apps", []) - if not isinstance(apps, list): - raise ValueError(f"{path}: expected .apps to be a list") - return {str(app.get("id", "")): app for app in apps if isinstance(app, dict) and app.get("id")} + + if isinstance(apps, list): + return { + str(app.get("id", "")): app + for app in apps + if isinstance(app, dict) and app.get("id") + } + + if isinstance(apps, dict): + out: dict[str, dict[str, Any]] = {} + for app_id, entry in apps.items(): + if not isinstance(entry, dict): + continue + manifest = entry.get("manifest") + if isinstance(manifest, dict) and isinstance(manifest.get("app"), dict): + # Embedded manifest: compare against the same fields the disk + # manifests expose, plus the entry's own version. + app = dict(manifest["app"]) + else: + app = {} + app.setdefault("id", app_id) + if entry.get("version") is not None: + app["version"] = entry["version"] + out[str(app_id)] = app + return out + + raise ValueError(f"{path}: expected .apps to be a list or an object") def load_manifests(apps_dir: Path) -> dict[str, dict[str, Any]]: diff --git a/scripts/check-catalog-registry-trust.py b/scripts/check-catalog-registry-trust.py new file mode 100755 index 00000000..e1f50adf --- /dev/null +++ b/scripts/check-catalog-registry-trust.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Refuse to publish a catalog naming registry hosts the fleet cannot pull from. + +The signed app catalog is authoritative for deployed nodes: `catalog_image_override` +makes its image reference win over the on-disk manifest. So a catalog that names a +registry host the *deployed* binaries do not trust turns every install into +"not from a trusted registry" — fleet-wide, at publish time, with no local signal. + +The subtlety this guard exists for: TRUSTED_REGISTRIES in the working tree +describes a binary being built today. Nodes run what was shipped to them. Those +two lists diverge for exactly as long as it takes an OTA to reach the fleet, and +that window is when a catalog regeneration silently breaks everything. + +So the floor is tracked explicitly in releases/registry-trust-floor.json and the +catalog is checked against that, never against the source tree. + +Usage: + scripts/check-catalog-registry-trust.py # check the release catalog + scripts/check-catalog-registry-trust.py --catalog path.json + scripts/check-catalog-registry-trust.py --show # print current state +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Iterator + +DEFAULT_CATALOG = "releases/app-catalog.json" +DEFAULT_FLOOR = "releases/registry-trust-floor.json" +IMAGE_POLICY = "core/archipelago/src/container/image_policy.rs" + + +def iter_images(node: Any) -> Iterator[str]: + """Yield every value stored under an `image` key, at any depth.""" + if isinstance(node, dict): + for key, value in node.items(): + if key == "image" and isinstance(value, str) and value: + yield value + else: + yield from iter_images(value) + elif isinstance(node, list): + for item in node: + yield from iter_images(item) + + +def registry_host(image: str) -> str | None: + """Registry host of an image ref, or None for Docker Hub shorthand. + + A ref's first segment is a registry only if it contains a '.' or ':' + (docker.io, host:3000). Otherwise it is a Docker Hub namespace — `nginx`, + `btcpayserver/btcpayserver` — which resolves via registries.conf, not an + attacker-controlled host. This mirrors is_valid_docker_image() in + image_policy.rs; keep the two in step. + """ + head = image.split("/", 1)[0] + if "/" not in image: + return None + if "." in head or ":" in head: + return head + return None + + +def source_trusted_registries(repo: Path) -> list[str]: + """TRUSTED_REGISTRIES as the working tree currently defines it (advisory).""" + path = repo / IMAGE_POLICY + try: + text = path.read_text(encoding="utf-8") + except OSError: + return [] + match = re.search(r"TRUSTED_REGISTRIES:\s*&\[&str\]\s*=\s*&\[(.*?)\];", text, re.S) + if not match: + return [] + body = match.group(1) + hosts = re.findall(r'"([^"]+)"', body) + # Entries may be consts (LEGACY_REGISTRY_HOST); resolve those too. + for const in re.findall(r"\b([A-Z][A-Z0-9_]+)\b", body): + const_match = re.search(rf'{const}:\s*&str\s*=\s*"([^"]+)"', text) + if const_match: + hosts.append(const_match.group(1)) + return sorted(set(hosts)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--catalog", default=DEFAULT_CATALOG) + parser.add_argument("--floor", default=DEFAULT_FLOOR) + parser.add_argument("--repo", default=".") + parser.add_argument("--show", action="store_true", + help="print the floor, the source list and the catalog's hosts") + args = parser.parse_args() + + repo = Path(args.repo) + catalog_path = repo / args.catalog + floor_path = repo / args.floor + + try: + catalog = json.loads(catalog_path.read_text(encoding="utf-8")) + except OSError as exc: + print(f"ERROR: cannot read catalog: {exc}", file=sys.stderr) + return 2 + try: + floor_doc = json.loads(floor_path.read_text(encoding="utf-8")) + except OSError as exc: + print(f"ERROR: cannot read trust floor: {exc}", file=sys.stderr) + return 2 + + floor = set(floor_doc.get("hosts") or []) + if not floor: + print(f"ERROR: {args.floor} lists no hosts; refusing to pass vacuously.", + file=sys.stderr) + return 2 + + hosts: dict[str, list[str]] = {} + for image in iter_images(catalog): + host = registry_host(image) + if host: + hosts.setdefault(host, []).append(image) + + if args.show: + print("trust floor (deployed binaries):") + for h in sorted(floor): + print(f" {h}") + pending = floor_doc.get("pending") or {} + if pending: + print("pending (not yet in the fleet):") + for h, meta in pending.items(): + print(f" {h} — trusted_from_binary={meta.get('trusted_from_binary')}") + print("working-tree TRUSTED_REGISTRIES (advisory):") + for h in source_trusted_registries(repo) or ["(could not parse)"]: + print(f" {h}") + print(f"catalog hosts ({catalog_path}):") + for h in sorted(hosts): + print(f" {h} ({len(hosts[h])} image refs)") + + violations = sorted(set(hosts) - floor) + if violations: + print("") + print("REFUSING: the catalog names registry hosts the deployed fleet does not trust.") + for host in violations: + examples = hosts[host][:3] + print(f"\n {host} — {len(hosts[host])} image refs, e.g.") + for ref in examples: + print(f" {ref}") + print("") + print("Publishing this would make every install fail with") + print('"not from a trusted registry" on every node in the field.') + print("") + print(f"Fix by ordering the migration — see the _comment in {args.floor}:") + print(" ship a binary that trusts the host, confirm the fleet is on it,") + print(" promote the host in the floor file, and only then regenerate.") + return 1 + + print(f"OK: all {len(hosts)} registry host(s) in {args.catalog} are trusted by the deployed fleet.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-installer-image-pins.py b/scripts/check-installer-image-pins.py new file mode 100755 index 00000000..9c9dca37 --- /dev/null +++ b/scripts/check-installer-image-pins.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Fail when a hardcoded installer image tag disagrees with the app manifest. + +The legacy stack installers in core/archipelago/src/api/rpc/package/stacks.rs +carry image references as string literals. Those literals are a second source of +truth for a version, sitting behind the manifest and the signed catalog, and +nothing keeps them in step. + +That is not cosmetic. BTCPay shipped 2.4.2 for an actively exploited 2FA bypass +on 2026-08-07 while the legacy installer still named 2.3.9, so the fallback +install path would have deployed the withdrawn release. The same shape applies +to any app whose installer literal is left behind. + +The rule enforced here: if an installer literal names the same image repository +as an app manifest, the tags must match. Repositories with no manifest are +ignored, and so are floating tags, which carry no version claim. + +Usage: + scripts/check-installer-image-pins.py + scripts/check-installer-image-pins.py --show +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import yaml + +# Files that pin images as literals on an install path. Test modules inside them +# are stripped before scanning: fixtures deliberately name old versions. +INSTALLER_SOURCES = [ + "core/archipelago/src/api/rpc/package/stacks.rs", +] + +FLOATING_TAGS = {"latest", "stable", "release", "main", "edge"} + +IMAGE_RE = re.compile(r'"([a-z0-9][a-z0-9._-]*(?:\.[a-z]+|:[0-9]+)?/[a-z0-9._/-]+:[A-Za-z0-9._-]+)"') + + +def strip_test_modules(text: str) -> str: + """Remove #[cfg(test)] modules so fixture literals are not treated as pins.""" + marker = "#[cfg(test)]" + idx = text.find(marker) + return text if idx == -1 else text[:idx] + + +def repo_of(image: str) -> str: + """Image repository without registry host or tag.""" + without_tag = image.rsplit(":", 1)[0] if ":" in image.rsplit("/", 1)[-1] else image + head, _, rest = without_tag.partition("/") + if "." in head or ":" in head or head == "localhost": + return rest + return without_tag + + +def tag_of(image: str) -> str: + last = image.rsplit("/", 1)[-1] + return last.rsplit(":", 1)[1] if ":" in last else "latest" + + +def manifest_images(repo_root: Path) -> dict[str, tuple[str, str]]: + """{image repo: (tag, manifest path)} across apps/*/manifest.yml.""" + out: dict[str, tuple[str, str]] = {} + for path in sorted((repo_root / "apps").glob("*/manifest.yml")): + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + continue + app = (data or {}).get("app") + if not isinstance(app, dict): + continue + image = (app.get("container") or {}).get("image") + if isinstance(image, str) and image: + out[repo_of(image)] = (tag_of(image), str(path.relative_to(repo_root))) + return out + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--repo", default=".") + parser.add_argument("--show", action="store_true") + args = parser.parse_args() + + repo_root = Path(args.repo) + manifests = manifest_images(repo_root) + problems: list[str] = [] + checked = 0 + + for rel in INSTALLER_SOURCES: + path = repo_root / rel + if not path.exists(): + continue + text = strip_test_modules(path.read_text(encoding="utf-8")) + for line_no, line in enumerate(text.splitlines(), start=1): + for image in IMAGE_RE.findall(line): + repo = repo_of(image) + if repo not in manifests: + continue + tag = tag_of(image) + manifest_tag, manifest_path = manifests[repo] + checked += 1 + if args.show: + print(f" {rel}:{line_no} {repo}:{tag} (manifest {manifest_tag})") + if tag in FLOATING_TAGS or manifest_tag in FLOATING_TAGS: + continue + if tag != manifest_tag: + problems.append( + f"{rel}:{line_no}\n" + f" installer pins {repo}:{tag}\n" + f" manifest wants {repo}:{manifest_tag} ({manifest_path})" + ) + + if problems: + print("") + print("Installer image pins disagree with their app manifests:") + for problem in problems: + print(f"\n {problem}") + print("") + print("An installer literal left behind deploys the older image on the") + print("fallback install path — which is how a withdrawn, vulnerable") + print("release gets installed after it has supposedly been replaced.") + print("Update the literal to match the manifest.") + return 1 + + print(f"OK: {checked} installer image pin(s) agree with their app manifests.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check-release-assets.sh b/scripts/check-release-assets.sh new file mode 100755 index 00000000..764ff369 --- /dev/null +++ b/scripts/check-release-assets.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# check-release-assets.sh — prove a release's artifacts are actually fetchable +# BEFORE its manifest goes live on main. +# +# The manifest is the trigger: nodes read releases/manifest.json from branch +# main, and the moment it names a new version they try to download it. So the +# assets must resolve before the manifest lands, not after. On 2026-08-07 the +# order was reversed — the v1.7.126-alpha manifest went live while its binary +# 500'd and its frontend tarball had never uploaded — and every polling node +# would have advertised an update it could not fetch. +# +# For each component in the manifest this checks: +# 1. the download URL returns HTTP 200 +# 2. the downloaded bytes match the manifest's sha256 and size +# +# It downloads each asset in full, because a HEAD 200 is not proof the body is +# intact — the corrupt binary that day passed HEAD-shaped checks and still +# served a broken stream. Slower, but this is the last gate before publish. +# +# Usage: +# scripts/check-release-assets.sh # check releases/manifest.json +# scripts/check-release-assets.sh path/to/manifest.json +# +# Exit 0 = every asset is downloadable and matches. Non-zero = do NOT publish. +set -euo pipefail + +MANIFEST="${1:-releases/manifest.json}" +if [[ ! -f "$MANIFEST" ]]; then + echo "ERROR: manifest not found: $MANIFEST" >&2 + exit 2 +fi + +command -v python3 >/dev/null 2>&1 || { echo "ERROR: python3 required" >&2; exit 2; } +command -v sha256sum >/dev/null 2>&1 || { echo "ERROR: sha256sum required" >&2; exit 2; } + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +# Emit "urlsha256sizename" per component, tolerating the field +# name variations the manifest has used (download_url/url, size_bytes/size). +rows="$(python3 - "$MANIFEST" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +comps = d.get("components") or [] +if not comps: + sys.exit("manifest has no components") +for c in comps: + url = c.get("download_url") or c.get("url") or "" + sha = c.get("sha256") or "" + size = c.get("size_bytes") or c.get("size") or "" + name = c.get("name") or "(unnamed)" + if not url or not sha: + sys.exit(f"component {name!r} missing url or sha256") + print(f"{url}\t{sha}\t{size}\t{name}") +PY +)" + +version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("version","?"))' "$MANIFEST")" +echo "Checking release assets for v${version} ($MANIFEST)" +echo "" + +fail=0 +n=0 +while IFS=$'\t' read -r url sha size name; do + [ -z "$url" ] && continue + n=$((n + 1)) + out="$TMP/asset.$n" + echo " [$name]" + echo " $url" + + code="$(curl -sL -o "$out" -w '%{http_code}' "$url" || echo "000")" + if [ "$code" != "200" ]; then + echo " FAIL: HTTP $code (asset not served)" + fail=1 + continue + fi + + got_size="$(stat -c%s "$out")" + if [ -n "$size" ] && [ "$size" != "$got_size" ]; then + echo " FAIL: size $got_size, manifest says $size" + fail=1 + continue + fi + + got_sha="$(sha256sum "$out" | awk '{print $1}')" + if [ "$got_sha" != "$sha" ]; then + echo " FAIL: sha256 mismatch" + echo " served: $got_sha" + echo " manifest: $sha" + fail=1 + continue + fi + + echo " OK: HTTP 200, ${got_size} bytes, sha256 matches" +done <<< "$rows" + +echo "" +if [ "$fail" -ne 0 ]; then + echo "REFUSING: one or more assets are not fetchable or do not match the manifest." + echo "Do NOT publish the manifest — nodes would advertise an update they cannot" + echo "apply. Upload/repair the assets, re-run this, and only then flip the" + echo "manifest live on main." + exit 1 +fi +echo "OK: all $n asset(s) for v${version} download and match the manifest." diff --git a/scripts/container-doctor.sh b/scripts/container-doctor.sh index fa9d774a..5a1a6ce6 100755 --- a/scripts/container-doctor.sh +++ b/scripts/container-doctor.sh @@ -428,7 +428,7 @@ print(' '.join(['\"' + a + '\"' if ' ' in a else a for a in args[2:]])) # but silently loses outbound. Bitcoin IBD stalls at 0 peers; package pulls # fail. The repair must rebuild the netns from scratch: merely cycling the # containers reuses the existing (broken) netns because its holders -# (aardvark-dns, podman's pause process) survive — observed on shorty-s +# (aardvark-dns, podman's pause process) survive — observed on a test node # 2026-07-10, where the old stop/start-only cycle bounced all 35 containers # every timer run for ~an hour without ever restoring egress. So: stop the # containers, kill the netns holders, `podman system migrate`, clear the @@ -610,7 +610,7 @@ fix_npm_public_hosts() { # A BTCPay store whose LND node has only private (unannounced) channels # produces BOLT11 invoices that external wallets cannot route to unless the # store's lightningPrivateRouteHints flag is on — payers see "no way to pay -# this invoice" (observed on shorty-s 2026-07-10 with a Blink payer). Route +# this invoice" (observed on a test node 2026-07-10 with a Blink payer). Route # hints are a no-op with public channels and essential with private ones, so # the doctor enforces the flag on every store. BTCPay reads store blobs from # Postgres per request; no restart needed. @@ -636,7 +636,7 @@ fix_btcpay_route_hints() { # Podman resolves `--init` (and any Portainer/compose deploy with # "init: true") through catatonit; Debian's podman package only # Recommends it, so a node installed or upgraded without it fails those -# deploys with a missing-init error (observed on shorty-s 2026-07-10 +# deploys with a missing-init error (observed on a test node 2026-07-10 # deploying sites via Portainer). install-podman.sh covers fresh ISO # installs; this heals nodes that predate it. fix_missing_catatonit() { diff --git a/scripts/container-specs.sh b/scripts/container-specs.sh index c9c26e2e..543cbece 100755 --- a/scripts/container-specs.sh +++ b/scripts/container-specs.sh @@ -586,7 +586,7 @@ load_spec_archy-lnd-ui() { # created by first-boot-containers.sh, which is host-networked and never # consults this file; the spec is only read when self-update.sh rebuilds a # UI image, and that only fires when a file under docker/lnd-ui/ changes. - # Verified on archi-dev-box: recreating from the old spec left :18083 + # Verified on a test node: recreating from the old spec left :18083 # refusing connections. SPEC_NETWORK="host" SPEC_MEMORY="$(mem_limit archy-lnd-ui)" diff --git a/scripts/create-release-manifest.sh b/scripts/create-release-manifest.sh index b2c147b2..fa4eff77 100755 --- a/scripts/create-release-manifest.sh +++ b/scripts/create-release-manifest.sh @@ -18,7 +18,7 @@ RELEASE_DATE="" OUTPUT_FILE="manifest.json" BACKEND_BINARY="" FRONTEND_ARCHIVE="" -BASE_URL="http://146.59.87.168:3000/lfg2025/archy/releases/download" +BASE_URL="https://source.archipelago-foundation.org/lfg2025/archy/releases/download" usage() { echo "Usage: $0 --version VERSION [--date DATE] [--output FILE]" diff --git a/scripts/create-release.sh b/scripts/create-release.sh index 2f2308ad..ec4fbfa2 100755 --- a/scripts/create-release.sh +++ b/scripts/create-release.sh @@ -128,7 +128,7 @@ if $DRY_RUN; then echo "" echo "After this script, you would:" echo " - Push: git push && git push --tags" - echo " - Build ISOs on server: ssh archipelago@192.168.1.228" + echo " - Build ISOs on server: ssh archipelago@192.0.2.10" exit 0 fi @@ -293,4 +293,4 @@ echo " 2. Publish commits, tag, artifacts, and verify download URLs:" echo " scripts/publish-release-assets.sh ${VERSION} gitea-vps2" echo " 3. Verify manifest is live on both mirrors:" echo " curl -fsS http://localhost:3000/lfg2025/archy/raw/branch/main/releases/manifest.json" -echo " curl -fsS http://146.59.87.168:3000/lfg2025/archy/raw/branch/main/releases/manifest.json" +echo " curl -fsS https://source.archipelago-foundation.org/lfg2025/archy/raw/branch/main/releases/manifest.json" diff --git a/scripts/debug-frontend.sh b/scripts/debug-frontend.sh deleted file mode 100755 index 877c00b0..00000000 --- a/scripts/debug-frontend.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/bin/bash -set -euo pipefail -# Check what's actually in the deployed frontend - -TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}" - -echo "Checking deployed frontend content..." -echo "" - -echo "1. Search for 'bundledApps' variable in JS:" -ssh "$TARGET_HOST" "grep -o 'bundledApps' /opt/archipelago/web-ui/assets/*.js | wc -l" - -echo "" -echo "2. Search for 'Bitcoin Knots' string:" -ssh "$TARGET_HOST" "grep -o 'Bitcoin Knots' /opt/archipelago/web-ui/assets/*.js | head -1" - -echo "" -echo "3. Search for the v-for loop pattern:" -ssh "$TARGET_HOST" "grep -o 'v-for.*bundled' /opt/archipelago/web-ui/assets/*.js | head -1" - -echo "" -echo "4. List all JS assets (to see if they updated):" -ssh "$TARGET_HOST" "ls -lh /opt/archipelago/web-ui/assets/*.js | head -10" - -echo "" -echo "5. Check index.html timestamp:" -ssh "$TARGET_HOST" "stat /opt/archipelago/web-ui/index.html | grep Modify" - -echo "" -echo "6. Try accessing the API from target:" -ssh "$TARGET_HOST" 'curl -s http://localhost:80/ | head -20' diff --git a/scripts/deploy-bitcoin-knots.sh b/scripts/deploy-bitcoin-knots.sh index 96c402d5..52046cce 100644 --- a/scripts/deploy-bitcoin-knots.sh +++ b/scripts/deploy-bitcoin-knots.sh @@ -74,7 +74,7 @@ mkdir -p "$BUILD_DIR" # Create Dockerfile cat > "$BUILD_DIR/Dockerfile" << 'EOF' -FROM ${NGINX_ALPINE_IMAGE:-146.59.87.168:3000/lfg2025/nginx:1.29.6-alpine} +FROM ${NGINX_ALPINE_IMAGE:-source.archipelago-foundation.org/lfg2025/nginx:1.29.6-alpine} # Copy the static UI COPY index.html /usr/share/nginx/html/ diff --git a/scripts/deploy-config-defaults.sh b/scripts/deploy-config-defaults.sh deleted file mode 100755 index d129b7ce..00000000 --- a/scripts/deploy-config-defaults.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash -# Default deployment targets — override in deploy-config.sh (gitignored) -DEFAULT_PRIMARY="192.168.1.228" -DEFAULT_SECONDARY="192.168.1.198" -TAILSCALE_ARCH1="100.82.97.63" -TAILSCALE_ARCH2="100.122.84.60" -TAILSCALE_ARCH3="100.124.105.113" diff --git a/scripts/deploy-tailscale.sh b/scripts/deploy-tailscale.sh deleted file mode 100755 index 14d3f27b..00000000 --- a/scripts/deploy-tailscale.sh +++ /dev/null @@ -1,1261 +0,0 @@ -#!/bin/bash -# -# Full deploy for Tailscale (or any remote) nodes — split-mode SSH for stability -# -# Each step is a separate short SSH session to handle unstable Tailscale connections. -# Auto-detects build capability: builds locally if cargo/npm present, otherwise copies -# pre-built artifacts from the primary build server (.228). -# -# Usage: -# ./scripts/deploy-tailscale.sh archipelago@100.82.97.63 # Single node -# ./scripts/deploy-tailscale.sh archipelago@100.122.84.60 # Arch 2 (can build) -# ./scripts/deploy-tailscale.sh archipelago@100.124.105.113 # Arch 3 (copy-only) -# ./scripts/deploy-tailscale.sh --all # All 3 Tailscale nodes -# -set -eo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -TARGET_DIR="/home/archipelago/archy" -PODMAN_IMAGE_CHECK_TIMEOUT="${PODMAN_IMAGE_CHECK_TIMEOUT:-10}" - -# Load deploy config defaults (IP addresses etc.) -[ -f "$SCRIPT_DIR/deploy-config-defaults.sh" ] && . "$SCRIPT_DIR/deploy-config-defaults.sh" - -# Load deploy config (gitignored — overrides defaults) -[ -f "$SCRIPT_DIR/deploy-config.sh" ] && . "$SCRIPT_DIR/deploy-config.sh" - -# Source pinned image versions (single source of truth) -[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh" - -# Source shared utility library -[ -f "$SCRIPT_DIR/lib/common.sh" ] && . "$SCRIPT_DIR/lib/common.sh" - -SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}" -SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o ConnectTimeout=10 -i $SSH_KEY" -BUILD_SOURCE_LAN="archipelago@${DEFAULT_PRIMARY:-192.168.1.228}" -BUILD_SOURCE_TS="archipelago@$(tailscale status 2>/dev/null | grep 'archipelago-0' | awk '{print $1}')" -# Try LAN first, fall back to Tailscale -if ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" "$BUILD_SOURCE_LAN" "echo ok" >/dev/null 2>&1; then - BUILD_SOURCE="$BUILD_SOURCE_LAN" -elif [ "$BUILD_SOURCE_TS" != "archipelago@" ] && ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" "$BUILD_SOURCE_TS" "echo ok" >/dev/null 2>&1; then - BUILD_SOURCE="$BUILD_SOURCE_TS" - echo "Build source: using Tailscale IP (LAN unreachable)" -else - BUILD_SOURCE="$BUILD_SOURCE_LAN" - echo "WARNING: Build source may be unreachable" -fi -BUILD_DIR="/home/archipelago/archy" - -# Node registry -TAILSCALE_NODES=( - "archipelago@${TAILSCALE_ARCH1:-100.82.97.63}" - "archipelago@${TAILSCALE_ARCH2:-100.122.84.60}" - "archipelago@${TAILSCALE_ARCH3:-100.124.105.113}" -) -TAILSCALE_NAMES=("Arch 1" "Arch 2" "Arch 3") - -# Git state -DEPLOY_COMMIT=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") -DEPLOY_COMMIT_FULL=$(git -C "$PROJECT_DIR" rev-parse HEAD 2>/dev/null || echo "unknown") -DEPLOY_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") -DEPLOY_DIRTY=false -[ -n "$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | grep -v '^??' | grep -v '\.claude/memory/')" ] && DEPLOY_DIRTY=true - -DEPLOY_START=$(date +%s) -ts() { echo "[$(date +%H:%M:%S)]"; } -step_num=0 -step() { step_num=$((step_num + 1)); echo ""; echo "$(ts) ━━━ Step $step_num: $1"; } - -# Temp directory for intermediate files (cleaned up on exit) -TMPDIR="/tmp/archipelago-deploy-$$" -mkdir -p "$TMPDIR" -trap 'rm -rf "$TMPDIR"' EXIT - -# ── Deploy a single node ───────────────────────────────────────────────── -deploy_node() { - local TARGET="$1" - local NODE_NAME="${2:-$TARGET}" - local TARGET_IP="$(echo "$TARGET" | cut -d@ -f2)" - step_num=0 - - echo "" - echo "╔════════════════════════════════════════════════════════════════╗" - echo "║ Deploying to $NODE_NAME ($TARGET_IP)" - echo "╚════════════════════════════════════════════════════════════════╝" - echo "$(ts) Branch: $DEPLOY_BRANCH @ $DEPLOY_COMMIT (dirty=$DEPLOY_DIRTY)" - - # ── Step 1: SSH connectivity ───────────────────────────────────── - step "Checking SSH connectivity" - if ! ssh $SSH_OPTS "$TARGET" "echo ok" >/dev/null 2>&1; then - echo " ERROR: Cannot connect to $TARGET" - return 1 - fi - echo " Connected." - - # ── Step 2: Prerequisites ──────────────────────────────────────── - step "Checking prerequisites" - ssh $SSH_OPTS "$TARGET" ' - NEED="" - command -v rsync >/dev/null 2>&1 || NEED="$NEED rsync" - command -v python3 >/dev/null 2>&1 || NEED="$NEED python3" - if [ -n "$NEED" ]; then - echo " Installing:$NEED" - sudo apt-get update -qq && sudo apt-get install -y -qq $NEED 2>&1 | tail -3 - else - echo " All prerequisites present" - fi - ' 2>&1 - - # ── Step 3: Detect build capability ────────────────────────────── - step "Detecting build capability" - CAN_BUILD=false - HAS_CARGO=$(ssh $SSH_OPTS "$TARGET" "source ~/.cargo/env 2>/dev/null; command -v cargo >/dev/null 2>&1 && echo yes || echo no" 2>/dev/null) - HAS_NPM=$(ssh $SSH_OPTS "$TARGET" "command -v npm >/dev/null 2>&1 && echo yes || echo no" 2>/dev/null) - if [ "$HAS_CARGO" = "yes" ] && [ "$HAS_NPM" = "yes" ]; then - CAN_BUILD=true - echo " Build capable (cargo + npm present)" - else - echo " Copy-only (cargo=$HAS_CARGO, npm=$HAS_NPM) — will copy from $BUILD_SOURCE" - fi - - # ── Step 4: Rootful→rootless migration (one-time) ──────────────── - step "Checking for rootful containers (migration)" - ssh $SSH_OPTS "$TARGET" ' - MIGRATION_FLAG="/var/lib/archipelago/.rootless-migrated" - if [ -f "$MIGRATION_FLAG" ]; then - ROOTLESS=$(podman ps -a --format "{{.Names}}" 2>/dev/null | grep -v "^$" | wc -l) - echo " Already migrated ($ROOTLESS rootless containers)" - else - # Check if rootful podman has any containers (sudo = rootful context) - ROOTFUL=$(sudo podman ps -a --format "{{.Names}}" 2>/dev/null | grep -v "^$" | wc -l) - ROOTLESS=$(podman ps -a --format "{{.Names}}" 2>/dev/null | grep -v "^$" | wc -l) - echo " Rootful: $ROOTFUL, Rootless: $ROOTLESS" - if [ "$ROOTFUL" -gt 0 ] && [ "$ROOTFUL" != "$ROOTLESS" ]; then - echo " MIGRATING: Stopping $ROOTFUL rootful containers..." - sudo podman stop --all --timeout 30 2>/dev/null || true - sudo podman rm --all --force 2>/dev/null || true - echo " Rootful containers removed (data preserved in /var/lib/archipelago/)" - else - echo " No rootful containers to migrate" - fi - sudo touch "$MIGRATION_FLAG" - fi - ' 2>&1 - - # ── Step 5: Sync code ──────────────────────────────────────────── - step "Syncing code" - rsync -az --delete \ - --exclude='.git' --exclude='node_modules' --exclude='target/debug' \ - --exclude='.codex-target-*' --exclude='.codex-tmp' \ - --exclude='image-recipe/_archived/build' --exclude='image-recipe/_archived/results' \ - --exclude='target/release/deps' --exclude='target/release/build' \ - --exclude='target/release/.fingerprint' --exclude='target/release/incremental' \ - --exclude='web/dist' --exclude='.DS_Store' --exclude='image-recipe/build' \ - --exclude='image-recipe/results' \ - -e "ssh $SSH_OPTS" \ - "$PROJECT_DIR/" "$TARGET:$TARGET_DIR/" || { echo " rsync failed"; return 1; } - echo " Synced." - - # ── Step 6: Build or copy artifacts ────────────────────────────── - if [ "$CAN_BUILD" = true ]; then - step "Building frontend on target" - ssh $SSH_OPTS "$TARGET" "cd $TARGET_DIR/neode-ui && npm install --silent 2>&1 && npm run build 2>&1" | tail -10 - - step "Building backend on target" - ssh $SSH_OPTS "$TARGET" "source ~/.cargo/env 2>/dev/null && cd $TARGET_DIR/core && cargo build --release 2>&1" | tail -15 - BINARY_OK=$(ssh $SSH_OPTS "$TARGET" "[ -f $TARGET_DIR/core/target/release/archipelago ] && echo ok || echo fail" 2>/dev/null) - if [ "$BINARY_OK" != "ok" ]; then echo " Backend build failed!"; return 1; fi - echo " Build complete." - else - step "Copying pre-built artifacts from $BUILD_SOURCE" - # Verify build source has artifacts - BUILD_OK=$(ssh $SSH_OPTS "$BUILD_SOURCE" "[ -f $BUILD_DIR/core/target/release/archipelago ] && echo ok || echo fail" 2>/dev/null) - if [ "$BUILD_OK" != "ok" ]; then - echo " ERROR: No binary on $BUILD_SOURCE — deploy to .228 first" - return 1 - fi - # Copy binary via local /tmp (SSH pipes unreliable with complex options) - echo " Copying binary..." - scp $SSH_OPTS "$BUILD_SOURCE:$BUILD_DIR/core/target/release/archipelago" /tmp/archipelago-deploy 2>/dev/null - scp $SSH_OPTS /tmp/archipelago-deploy "$TARGET:/tmp/archipelago-new" 2>/dev/null - rm -f /tmp/archipelago-deploy - # Copy frontend via tar through local - echo " Copying frontend..." - ssh $SSH_OPTS "$BUILD_SOURCE" "cd $BUILD_DIR && tar cf - web/dist/neode-ui 2>/dev/null" > /tmp/frontend-deploy.tar - cat /tmp/frontend-deploy.tar | ssh $SSH_OPTS "$TARGET" "mkdir -p /tmp/web-deploy && cd /tmp/web-deploy && tar xf -" - rm -f /tmp/frontend-deploy.tar - - # Transfer custom UI images (individual tarballs — never combined) - echo " Transferring custom UI images..." - for ui_img in bitcoin-ui lnd-ui electrs-ui; do - HAS_IMG=$(ssh $SSH_OPTS "$BUILD_SOURCE" "timeout --kill-after=2s ${PODMAN_IMAGE_CHECK_TIMEOUT}s podman image exists 'localhost/${ui_img}:local' 2>/dev/null && echo yes || echo no" 2>/dev/null) - if [ "$HAS_IMG" = "yes" ]; then - echo " $ui_img..." - if ssh $SSH_OPTS "$BUILD_SOURCE" "podman save 'localhost/${ui_img}:local' 2>/dev/null" > "/tmp/${ui_img}.tar" 2>/dev/null && [ -s "/tmp/${ui_img}.tar" ]; then - ssh $SSH_OPTS "$TARGET" "podman load" < "/tmp/${ui_img}.tar" 2>&1 | tail -1 - else - echo " $ui_img: not available on build server, skipping" - fi - rm -f "/tmp/${ui_img}.tar" - else - echo " $ui_img: not found on build server, skipping" - fi - done - - # Install Node.js if missing (needed for some container builds) - if [ "$HAS_NPM" != "yes" ]; then - echo " Installing Node.js on target..." - ssh $SSH_OPTS "$TARGET" ' - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - 2>&1 | tail -3 - sudo apt-get install -y -qq nodejs 2>&1 | tail -3 - ' 2>&1 || true - fi - echo " Artifacts copied." - fi - - # ── Step 7: Rollback backup ────────────────────────────────────── - step "Creating rollback backup" - ssh $SSH_OPTS "$TARGET" ' - sudo mkdir -p /opt/archipelago/rollback - [ -f /usr/local/bin/archipelago ] && sudo cp /usr/local/bin/archipelago /opt/archipelago/rollback/archipelago.bak 2>/dev/null || true - [ -d /opt/archipelago/web-ui ] && sudo tar cf /opt/archipelago/rollback/web-ui.tar -C /opt/archipelago/web-ui . 2>/dev/null || true - echo " Rollback backup created" - ' 2>&1 - - # ── Step 8: Deploy binary ──────────────────────────────────────── - step "Deploying binary" - ssh $SSH_OPTS "$TARGET" 'sudo systemctl stop archipelago --no-block 2>/dev/null; sleep 2; sudo kill -9 $(pgrep -x archipelago) 2>/dev/null; sleep 1; true' 2>/dev/null - if [ "$CAN_BUILD" = true ]; then - ssh $SSH_OPTS "$TARGET" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/" - else - ssh $SSH_OPTS "$TARGET" "sudo cp /tmp/archipelago-new /usr/local/bin/archipelago && sudo chmod +x /usr/local/bin/archipelago && rm -f /tmp/archipelago-new" - fi - echo " Binary deployed." - - # ── Step 9: Deploy frontend ────────────────────────────────────── - step "Deploying frontend" - ssh $SSH_OPTS "$TARGET" 'sudo mkdir -p /opt/archipelago/web-ui && sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name "aiui" ! -name "claude-login.html" -exec rm -rf {} +' 2>/dev/null - if [ "$CAN_BUILD" = true ]; then - ssh $SSH_OPTS "$TARGET" "sudo cp -rf $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/" - else - ssh $SSH_OPTS "$TARGET" "sudo cp -rf /tmp/web-deploy/web/dist/neode-ui/* /opt/archipelago/web-ui/ 2>/dev/null && rm -rf /tmp/web-deploy" - fi - ssh $SSH_OPTS "$TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui" - echo " Frontend deployed." - - # ── Step 10: Deploy AIUI ───────────────────────────────────────── - step "Deploying AIUI" - AIUI_DIST="$PROJECT_DIR/../AIUI/packages/app/dist" - if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then - ssh $SSH_OPTS "$TARGET" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*" - (cd "$AIUI_DIST" && tar --no-xattrs -cf - .) | ssh $SSH_OPTS "$TARGET" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/ 2>/dev/null" - ssh $SSH_OPTS "$TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui" - echo " AIUI deployed." - else - echo " AIUI not found, skipping." - fi - - # ── Step 11: Sync nginx config ─────────────────────────────────── - step "Syncing nginx config" - NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" - SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets" - if [ -f "$NGINX_CFG" ]; then - scp $SSH_OPTS "$NGINX_CFG" "$TARGET:/tmp/nginx-archipelago.conf" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" ' - sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago - sudo mkdir -p /etc/nginx/snippets - sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf - rm -f /tmp/nginx-archipelago.conf - ' 2>/dev/null - fi - if [ -d "$SNIPPETS_DIR" ]; then - for f in "$SNIPPETS_DIR"/*.conf; do - [ -f "$f" ] && scp $SSH_OPTS "$f" "$TARGET:/tmp/nginx-snippet-$(basename "$f")" 2>/dev/null || true - done - ssh $SSH_OPTS "$TARGET" ' - for f in /tmp/nginx-snippet-*.conf; do - [ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")" - done - ' 2>/dev/null || true - fi - ssh $SSH_OPTS "$TARGET" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " nginx config FAILED"' 2>/dev/null || true - - # ── Step 12: Sync systemd service ──────────────────────────────── - step "Syncing systemd service" - SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service" - if [ -f "$SERVICE_FILE" ]; then - scp $SSH_OPTS "$SERVICE_FILE" "$TARGET:/tmp/archipelago.service" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" ' - if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then - sudo cp /tmp/archipelago.service /etc/systemd/system/archipelago.service - sudo systemctl daemon-reload - echo " Service file updated" - else - echo " Service file unchanged" - fi - rm -f /tmp/archipelago.service - ' 2>/dev/null || true - fi - - step "Syncing kiosk display helpers" - KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" - if [ -f "$KIOSK_LAUNCHER" ]; then - scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" ' - sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher - rm -f /tmp/archipelago-kiosk-launcher - echo " Kiosk launcher updated" - ' 2>/dev/null || true - fi - for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do - KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit" - [ -f "$KIOSK_UNIT" ] || continue - scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET:/tmp/$unit" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" " - if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then - sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit' - sudo systemctl daemon-reload - echo ' $unit updated' - else - echo ' $unit unchanged' - fi - rm -f '/tmp/$unit' - " 2>/dev/null || true - done - - # ── Step 13: Rootless podman prereqs ───────────────────────────── - step "Setting up rootless podman prerequisites" - ssh $SSH_OPTS "$TARGET" ' - # Allow binding to ports >= 80 - if ! grep -q "unprivileged_port_start=80" /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null; then - echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-rootless-podman.conf > /dev/null - sudo sysctl -p /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null - echo " Rootless port binding enabled (>=80)" - fi - # Linger for container persistence - if [ "$(loginctl show-user archipelago 2>/dev/null | grep Linger)" != "Linger=yes" ]; then - sudo loginctl enable-linger archipelago - echo " Linger enabled" - fi - # Podman socket - systemctl --user enable podman.socket 2>/dev/null || true - systemctl --user start podman.socket 2>/dev/null || true - # Ensure subuid/subgid - grep -q "^archipelago:" /etc/subuid 2>/dev/null || { - echo "archipelago:100000:65536" | sudo tee -a /etc/subuid > /dev/null - echo "archipelago:100000:65536" | sudo tee -a /etc/subgid > /dev/null - echo " subuid/subgid configured" - } - # Ensure /etc/hosts is readable (rootless podman needs it) - sudo chmod 644 /etc/hosts 2>/dev/null - echo " Rootless prerequisites OK" - ' 2>&1 - - # ── Step 14: Data dirs + UID mapping ───────────────────────────── - step "Creating data directories + UID mapping" - ssh $SSH_OPTS "$TARGET" ' - sudo mkdir -p /var/lib/archipelago/dwn/messages /var/lib/archipelago/dwn/protocols - sudo mkdir -p /var/lib/archipelago/content/files /var/lib/archipelago/federation - sudo mkdir -p /var/lib/archipelago/identities /var/lib/archipelago/tor-config - sudo mkdir -p /var/lib/archipelago/searxng /var/lib/archipelago/vaultwarden - sudo mkdir -p /var/lib/archipelago/photoprism /var/lib/archipelago/filebrowser - sudo mkdir -p /var/lib/archipelago/nextcloud - sudo chown -R archipelago:archipelago /var/lib/archipelago/dwn /var/lib/archipelago/content \ - /var/lib/archipelago/federation /var/lib/archipelago/identities /var/lib/archipelago/tor-config 2>/dev/null || true - - echo " Fixing rootless podman UID mapping..." - # Containers running as root (UID 0 → host UID 100000) - for dir in lnd electrumx btcpay nbxplorer jellyfin vaultwarden \ - home-assistant fedimint fedimint-gateway photoprism ollama filebrowser \ - nextcloud uptime-kuma nginx-proxy-manager portainer nostr-rs-relay searxng; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null - done - # Bitcoin Knots: container UID 101 → host UID 100101 - [ -d /var/lib/archipelago/bitcoin ] && sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null - # Postgres: container UID 70 → host UID 100070 - for dir in postgres-btcpay; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100070:100070 "/var/lib/archipelago/$dir" 2>/dev/null - done - # MariaDB: container UID 999 → host UID 100999 - for dir in mempool mysql-mempool; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100999:100999 "/var/lib/archipelago/$dir" 2>/dev/null - done - # Grafana: container UID 472 → host UID 100472 - [ -d /var/lib/archipelago/grafana ] && sudo chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null - echo " UID mapping done" - ' 2>&1 - - # ── Step 15: Dev mode ──────────────────────────────────────────── - step "Configuring dev mode (HTTP cookie support)" - ssh $SSH_OPTS "$TARGET" ' - if [ -f /etc/systemd/system/archipelago.service.d/override.conf ] && grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then - echo " Dev mode already enabled" - else - sudo mkdir -p /etc/systemd/system/archipelago.service.d - printf "[Service]\nEnvironment=ARCHIPELAGO_DEV_MODE=true\n" | sudo tee /etc/systemd/system/archipelago.service.d/override.conf > /dev/null - sudo systemctl daemon-reload - echo " Dev mode enabled" - fi - ' 2>&1 - - # ── Step 16: Deploy nostr-provider.js ──────────────────────────── - step "Deploying nostr-provider.js" - if [ -f "$PROJECT_DIR/neode-ui/public/nostr-provider.js" ]; then - scp $SSH_OPTS "$PROJECT_DIR/neode-ui/public/nostr-provider.js" "$TARGET:/tmp/nostr-provider.js" 2>/dev/null && \ - ssh $SSH_OPTS "$TARGET" 'sudo cp /tmp/nostr-provider.js /opt/archipelago/web-ui/nostr-provider.js && rm -f /tmp/nostr-provider.js && echo " deployed"' 2>/dev/null - else - echo " nostr-provider.js not found, skipping" - fi - - # ── Step 17: Deploy udev rule ──────────────────────────────────── - UDEV_RULE="$PROJECT_DIR/image-recipe/configs/99-mesh-radio.rules" - if [ -f "$UDEV_RULE" ]; then - step "Deploying mesh radio udev rule" - scp $SSH_OPTS "$UDEV_RULE" "$TARGET:/tmp/99-mesh-radio.rules" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" ' - if ! diff -q /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules >/dev/null 2>&1; then - sudo cp /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules - sudo udevadm control --reload-rules 2>/dev/null - echo " Installed" - else - echo " Unchanged" - fi - rm -f /tmp/99-mesh-radio.rules - ' 2>/dev/null || true - fi - - # ── Deploy ALSA default-device config ──────────────────────────── - ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" - if [ -f "$ASOUND_CONF" ]; then - step "Deploying ALSA default-device config" - scp $SSH_OPTS "$ASOUND_CONF" "$TARGET:/tmp/asound.conf" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET" ' - if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then - sudo cp /tmp/asound.conf /etc/asound.conf - echo " Installed" - else - echo " Unchanged" - fi - rm -f /tmp/asound.conf - ' 2>/dev/null || true - fi - - # ── Step 18: NTP + swap ────────────────────────────────────────── - step "Ensuring NTP + swap" - ssh $SSH_OPTS "$TARGET" ' - if ! dpkg -l chrony >/dev/null 2>&1; then - sudo rm -f /usr/sbin/policy-rc.d - sudo apt-get update -qq && sudo apt-get install -y chrony 2>/dev/null - fi - sudo systemctl enable chrony 2>/dev/null - sudo systemctl start chrony 2>/dev/null - sudo timedatectl set-ntp true 2>/dev/null - if [ ! -f /swapfile ]; then - TOTAL_KB=$(grep MemTotal /proc/meminfo | awk "{print \$2}") - SZ=$((TOTAL_KB / 1024 / 1024)) - [ "$SZ" -gt 8 ] && SZ=8; [ "$SZ" -lt 2 ] && SZ=2 - sudo fallocate -l ${SZ}G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile - grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" | sudo tee -a /etc/fstab - echo " Created ${SZ}G swap" - fi - sudo swapon /swapfile 2>/dev/null || true - echo " NTP + swap OK" - ' 2>&1 | tail -5 - - # ── Step 19: Restart services ──────────────────────────────────── - step "Restarting services" - ssh $SSH_OPTS "$TARGET" "sudo systemctl start archipelago && sudo systemctl restart nginx && echo ' Services restarted'" 2>&1 - - # ── Step 20: Setup HTTPS ───────────────────────────────────────── - step "Setting up HTTPS" - ssh $SSH_OPTS "$TARGET" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | tail -5 | sed 's/^/ /' || true - - # ── Step 21: Read secrets ──────────────────────────────────────── - step "Reading secrets from server" - BITCOIN_RPC_PASS=$(ssh $SSH_OPTS "$TARGET" ' - SECRETS_DIR="/var/lib/archipelago/secrets" - sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR" - if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then - openssl rand -base64 24 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null - sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password" - fi - sudo cat "$SECRETS_DIR/bitcoin-rpc-password" - ' 2>/dev/null) - BITCOIN_RPC_USER="archipelago" - - # Read DB passwords from secrets (safe parsing — no eval) - ssh $SSH_OPTS "$TARGET" ' - SECRETS_DIR="/var/lib/archipelago/secrets" - for svc in mempool btcpay mysql-root; do - if [ ! -f "$SECRETS_DIR/${svc}-db-password" ]; then - openssl rand -base64 24 | sudo tee "$SECRETS_DIR/${svc}-db-password" > /dev/null - sudo chmod 600 "$SECRETS_DIR/${svc}-db-password" - fi - done - # FED-07: no shipped fallback, ever. The canonical per-install gateway - # credential (fedimint-gateway-hash / .pw) is generated by the daemon - # via container::secrets::ensure_gateway_credential — this deploy script - # no longer generates it (removes the htpasswd host dependency too). - # Legacy migration only: carry an existing fedimint-gateway-password - # value forward to the canonical fedimint-gateway-hash.pw name if that - # name does not exist yet; never regenerate a working credential, and - # never delete the legacy file (plan 01-16 owns retirement). - if [ -f "$SECRETS_DIR/fedimint-gateway-password" ] && [ ! -f "$SECRETS_DIR/fedimint-gateway-hash.pw" ]; then - sudo cp "$SECRETS_DIR/fedimint-gateway-password" "$SECRETS_DIR/fedimint-gateway-hash.pw" - sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash.pw" - fi - ' 2>/dev/null - # Read each password individually (avoids eval on SSH output) - MEMPOOL_DB_PASS=$(ssh $SSH_OPTS "$TARGET" 'sudo cat /var/lib/archipelago/secrets/mempool-db-password 2>/dev/null' 2>/dev/null) - BTCPAY_DB_PASS=$(ssh $SSH_OPTS "$TARGET" 'sudo cat /var/lib/archipelago/secrets/btcpay-db-password 2>/dev/null' 2>/dev/null) - MYSQL_ROOT_PASS=$(ssh $SSH_OPTS "$TARGET" 'sudo cat /var/lib/archipelago/secrets/mysql-root-db-password 2>/dev/null' 2>/dev/null) - FEDI_HASH=$(ssh $SSH_OPTS "$TARGET" 'sudo cat /var/lib/archipelago/secrets/fedimint-gateway-hash 2>/dev/null' 2>/dev/null) - # FED-07: no fallback literal. If the target has not generated its - # per-install gateway credential yet, FEDI_HASH stays empty and the - # gateway container creation below is skipped, never substituted. - if [ -z "${FEDI_HASH:-}" ]; then - echo " NOTE: no fedimint-gateway credential on target yet — gateway container creation will be skipped (no shipped default; the daemon generates one on next install/reconcile)" - fi - - if [ -z "$BITCOIN_RPC_PASS" ]; then - echo " WARNING: Could not read Bitcoin RPC password — skipping container setup" - else - echo " Secrets loaded." - - # ── Step 22: Create containers ─────────────────────────────── - step "Creating containers (this may take a while on first run)" - # All container creation in a single SSH session to reduce connection overhead. - # Uses the same container logic as deploy-to-target.sh --live. - ssh $SSH_OPTS "$TARGET" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - TARGET_IP='$TARGET_IP' - - # Create archy-net bridge - \$DOCKER network create archy-net 2>/dev/null || true - NET_OPT='--network archy-net' - - echo ' === Bitcoin Knots ===' - # Clean old bitcoin.conf that conflicts with container CLI args (double rpcbind) - if [ -f /var/lib/archipelago/bitcoin/bitcoin.conf ]; then - if grep -q 'rpcbind' /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null; then - echo ' Cleaning old bitcoin.conf (conflicting rpcbind)...' - printf 'printtoconsole=0\n' | sudo tee /var/lib/archipelago/bitcoin/bitcoin.conf > /dev/null - sudo chown 100101:100101 /var/lib/archipelago/bitcoin/bitcoin.conf 2>/dev/null - fi - fi - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then - echo ' Creating Bitcoin Knots...' - sudo mkdir -p /var/lib/archipelago/bitcoin - DISK_GB=\$(df --output=size -BG / 2>/dev/null | tail -1 | tr -dc '0-9') - if [ \"\${DISK_GB:-0}\" -lt 1000 ]; then - BTC_EXTRA_ARGS='-prune=550' - BTC_DBCACHE=512 - echo ' Small disk — pruning enabled' - else - BTC_EXTRA_ARGS='-txindex=1' - BTC_DBCACHE=4096 - fi - \$DOCKER run -d --name bitcoin-knots --restart unless-stopped \$NET_OPT \ - --health-cmd 'bitcoin-cli getnetworkinfo' --health-interval=60s --health-timeout=10s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8332:8332 -p 8333:8333 \ - -v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \ - $BITCOIN_KNOTS_IMAGE \ - -server=1 \$BTC_EXTRA_ARGS \ - -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \ - -rpcuser=$BITCOIN_RPC_USER -rpcpassword=$BITCOIN_RPC_PASS \ - -dbcache=\$BTC_DBCACHE - else - \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true - echo ' Bitcoin Knots already running' - fi - - echo ' === Mempool Stack ===' - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'mysql-mempool|archy-mempool-db'; then - echo ' Creating mysql-mempool...' - sudo mkdir -p /var/lib/archipelago/mysql-mempool - \$DOCKER run -d --name archy-mempool-db --restart unless-stopped \$NET_OPT \ - --health-cmd 'mariadbd-safe --help > /dev/null 2>&1 || mariadb -uroot -e SELECT\ 1' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \ - -e MYSQL_DATABASE=mempool -e MYSQL_USER=mempool \ - -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS -e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \ - $MARIADB_IMAGE - sleep 3 - fi - MYSQL_CNT=\$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mempool|archy-mempool-db' | head -1) - MYSQL_CNT=\${MYSQL_CNT:-archy-mempool-db} - \$DOCKER start \$MYSQL_CNT 2>/dev/null || true - \$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true - # Sync MariaDB user password with secrets (data dir may have stale password) - sleep 3 - \$DOCKER exec \$MYSQL_CNT mariadb -uroot -p"$MYSQL_ROOT_PASS" -e "ALTER USER 'mempool'@'%' IDENTIFIED BY '$MEMPOOL_DB_PASS';" 2>/dev/null \ - && echo " MariaDB mempool password synced" \ - || echo " MariaDB password sync skipped - may need data reinit" - - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then - \$DOCKER start electrumx 2>/dev/null || true - else - echo ' Creating electrumx...' - sudo mkdir -p /var/lib/archipelago/electrumx - \$DOCKER run -d --name electrumx --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8000/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -p 50001:50001 -v /var/lib/archipelago/electrumx:/data \ - -e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \ - -e COIN=Bitcoin -e DB_DIRECTORY=/data \ - -e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \ - $ELECTRUMX_IMAGE - fi - fi - - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then - echo ' Creating mempool-api...' - sudo mkdir -p /var/lib/archipelago/mempool - \$DOCKER run -d --name mempool-api --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8999/api/v1/backend-info' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -p 8999:8999 -v /var/lib/archipelago/mempool:/data \ - -e MEMPOOL_BACKEND=electrum -e ELECTRUM_HOST=electrumx -e ELECTRUM_PORT=50001 \ - -e ELECTRUM_TLS_ENABLED=false -e CORE_RPC_HOST=\$TARGET_IP -e CORE_RPC_PORT=8332 \ - -e CORE_RPC_USERNAME=archipelago -e CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS \ - -e DATABASE_ENABLED=true -e DATABASE_HOST=\$MYSQL_CNT -e DATABASE_DATABASE=mempool \ - -e DATABASE_USERNAME=mempool -e DATABASE_PASSWORD=$MEMPOOL_DB_PASS \ - $MEMPOOL_BACKEND_IMAGE - fi - - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-mempool-web; then - echo ' Creating mempool frontend...' - \$DOCKER run -d --name archy-mempool-web --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8080/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -p 4080:8080 -e FRONTEND_HTTP_PORT=8080 -e BACKEND_MAINNET_HTTP_HOST=mempool-api \ - $MEMPOOL_WEB_IMAGE - fi - - echo ' === BTCPay Stack ===' - # Recreate btcpay-db if postgres version mismatch (15→16 incompatible) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then - echo ' Recreating archy-btcpay-db (was stopped/broken)...' - \$DOCKER rm -f archy-btcpay-db 2>/dev/null - \$DOCKER rm -f postgres-btcpay 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then - echo ' Creating archy-btcpay-db...' - sudo mkdir -p /var/lib/archipelago/postgres-btcpay - \$DOCKER run -d --name archy-btcpay-db --restart unless-stopped \$NET_OPT \ - --health-cmd 'pg_isready -U postgres' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \ - -e POSTGRES_DB=btcpay -e POSTGRES_USER=btcpay -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \ - $BTCPAY_POSTGRES_IMAGE - sleep 3 - fi - \$DOCKER exec archy-btcpay-db psql -U postgres -tc \"SELECT 1 FROM pg_database WHERE datname='nbxplorer'\" 2>/dev/null | grep -q 1 || \ - \$DOCKER exec -e PGPASSWORD=$BTCPAY_DB_PASS archy-btcpay-db psql -U postgres -c \"CREATE DATABASE nbxplorer;\" 2>/dev/null || true - - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then - \$DOCKER start archy-nbxplorer 2>/dev/null || true - else - echo ' Creating archy-nbxplorer...' - sudo mkdir -p /var/lib/archipelago/nbxplorer - \$DOCKER run -d --name archy-nbxplorer --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:32838/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - -p 32838:32838 -v /var/lib/archipelago/nbxplorer:/data \ - -e NBXPLORER_DATADIR=/data -e NBXPLORER_NETWORK=mainnet -e NBXPLORER_CHAINS=btc \ - -e NBXPLORER_BIND=0.0.0.0:32838 -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \ - -e NBXPLORER_BTCRPCUSER=archipelago -e NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS \ - -e NBXPLORER_POSTGRES='User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true' \ - $NBXPLORER_IMAGE - sleep 5 - fi - fi - - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then - echo ' Creating btcpay-server...' - sudo mkdir -p /var/lib/archipelago/btcpay - \$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \ - --health-cmd "bash -ec '/dev/null) - if [ -f /var/lib/archipelago/lnd/lnd.conf ]; then - CURRENT_LND_PASS=\$(sudo grep "bitcoind.rpcpass=" /var/lib/archipelago/lnd/lnd.conf 2>/dev/null | cut -d= -f2) - if [ "\$CURRENT_LND_PASS" != "\$RPC_PASS" ] && [ -n "\$RPC_PASS" ]; then - echo " Syncing LND rpcpass with current secrets..." - sudo sed -i "s|bitcoind.rpcpass=.*|bitcoind.rpcpass=\$RPC_PASS|" /var/lib/archipelago/lnd/lnd.conf - sudo chown 100000:100000 /var/lib/archipelago/lnd/lnd.conf 2>/dev/null - fi - fi - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx lnd; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx lnd; then - \$DOCKER start lnd 2>/dev/null || true - else - echo ' Creating LND...' - cat > /tmp/lnd.conf </dev/null - rm -f /tmp/lnd.conf - \$DOCKER run -d --name lnd --restart unless-stopped --network archy-net \ - --health-cmd 'curl -sf --insecure https://localhost:8080/v1/getinfo' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 9735:9735 -p 10009:10009 -p 18080:8080 \ - -v /var/lib/archipelago/lnd:/root/.lnd \ - $LND_IMAGE - fi - fi - - echo ' === Fedimint ===' - # Recreate fedimint if it exists but is broken (wrong env vars) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx fedimint; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx fedimint; then - echo ' Recreating fedimint (was stopped/broken)...' - \$DOCKER rm -f fedimint 2>/dev/null - else - echo ' Fedimint already running' - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx fedimint; then - echo ' Creating Fedimint...' - sudo mkdir -p /var/lib/archipelago/fedimint - \$DOCKER run -d --name fedimint --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8175/' --health-interval=60s --health-timeout=10s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8173:8173 -p 8174:8174 -p 8175:8175 \ - -v /var/lib/archipelago/fedimint:/data \ - -e FM_DATA_DIR=/data -e FM_BITCOIND_USERNAME=archipelago -e FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS \ - -e FM_BITCOIN_NETWORK=bitcoin -e FM_BIND_P2P=0.0.0.0:8173 \ - -e FM_BIND_API=0.0.0.0:8174 -e FM_BIND_UI=0.0.0.0:8175 \ - -e FM_P2P_URL=fedimint://\$TARGET_IP:8173 -e FM_API_URL=ws://\$TARGET_IP:8174 \ - -e FM_BITCOIND_URL=http://\$TARGET_IP:8332 \ - -e FM_REL_NOTES_ACK=0_4_xyz \ - $FEDIMINT_IMAGE - fi - - # Recreate fedimint-gateway if broken - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; then - echo ' Recreating fedimint-gateway (was stopped/broken)...' - \$DOCKER rm -f fedimint-gateway 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q fedimint-gateway; then - # FED-07: the gateway is configured from the per-install bcrypt - # hash only — no shipped default, and no plaintext --password - # flag. If the target has no credential yet, skip creation and - # say why rather than starting a gateway with a known password. - GW_HASH=\$(sudo cat /var/lib/archipelago/secrets/fedimint-gateway-hash 2>/dev/null) - if [ -z \"\$GW_HASH\" ]; then - echo ' Skipping fedimint-gateway — no per-install credential on target yet (no shipped default; the daemon generates one on next install/reconcile)' - else - echo ' Creating fedimint-gateway...' - sudo mkdir -p /var/lib/archipelago/fedimint-gateway - LND_CERT=/var/lib/archipelago/lnd/tls.cert - LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon - if \$DOCKER ps --format '{{.Names}}' | grep -q '^lnd\$' && sudo test -f \$LND_CERT && sudo test -f \$LND_MACAROON; then - \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8176/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8176:8176 -v /var/lib/archipelago/fedimint-gateway:/data \ - -v /var/lib/archipelago/lnd/tls.cert:/lnd/tls.cert:ro \ - -v /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon:/lnd/admin.macaroon:ro \ - $FEDIMINT_GATEWAY_IMAGE \ - gatewayd --data-dir /data --listen 0.0.0.0:8176 \ - --bcrypt-password-hash \"\$GW_HASH\" \ - --network bitcoin --bitcoind-url http://\$TARGET_IP:8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ - lnd --lnd-rpc-host \$TARGET_IP:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon - else - \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \$NET_OPT \ - --health-cmd 'curl -sf http://localhost:8176/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8176:8176 -p 9737:9737 -v /var/lib/archipelago/fedimint-gateway:/data \ - $FEDIMINT_GATEWAY_IMAGE \ - gatewayd --data-dir /data --listen 0.0.0.0:8176 \ - --bcrypt-password-hash \"\$GW_HASH\" \ - --network bitcoin --bitcoind-url http://\$TARGET_IP:8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ - ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway - fi - fi - fi - - echo ' === Simple apps ===' - # Home Assistant - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx homeassistant; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx homeassistant; then - \$DOCKER start homeassistant 2>/dev/null || true - else - sudo mkdir -p /var/lib/archipelago/home-assistant - \$DOCKER run -d --name homeassistant --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:8123/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8123:8123 -v /var/lib/archipelago/home-assistant:/config -e TZ=UTC \ - $HOMEASSISTANT_IMAGE - fi - fi - # Grafana - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx grafana; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx grafana; then - \$DOCKER start grafana 2>/dev/null || true - else - sudo mkdir -p /var/lib/archipelago/grafana - sudo chown -R 1000:1000 /var/lib/archipelago/grafana - # If old rootful grafana data exists (wrong perms), move aside for fresh start - if [ -f /var/lib/archipelago/grafana/grafana.db ]; then - sudo mv /var/lib/archipelago/grafana /var/lib/archipelago/grafana-old 2>/dev/null - sudo mkdir -p /var/lib/archipelago/grafana - sudo chown -R 1000:1000 /var/lib/archipelago/grafana - fi - \$DOCKER run -d --name grafana --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:3000/api/health' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --user 0:0 \ - -p 3000:3000 -v /var/lib/archipelago/grafana:/var/lib/grafana \ - -e GF_PATHS_DATA=/var/lib/grafana -e GF_USERS_ALLOW_SIGN_UP=false \ - $GRAFANA_IMAGE - fi - fi - # Jellyfin - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx jellyfin; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx jellyfin; then - \$DOCKER start jellyfin 2>/dev/null || true - else - sudo mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache - \$DOCKER run -d --name jellyfin --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:8096/health' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --security-opt no-new-privileges:true \ - -p 8096:8096 \ - -v /var/lib/archipelago/jellyfin/config:/config \ - -v /var/lib/archipelago/jellyfin/cache:/cache \ - $JELLYFIN_IMAGE - fi - fi - # Vaultwarden — recreate if broken (permissions/DB) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx vaultwarden; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx vaultwarden; then - \$DOCKER rm -f vaultwarden 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx vaultwarden; then - sudo mkdir -p /var/lib/archipelago/vaultwarden - sudo chown -R 100000:100000 /var/lib/archipelago/vaultwarden 2>/dev/null - \$DOCKER run -d --name vaultwarden --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:80/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ - --security-opt no-new-privileges:true \ - -p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \ - $VAULTWARDEN_IMAGE - fi - # SearXNG — recreate if broken (permission denied on settings.yml) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx searxng; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx searxng; then - \$DOCKER rm -f searxng 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx searxng; then - sudo mkdir -p /var/lib/archipelago/searxng - \$DOCKER run -d --name searxng --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:8080/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -v /var/lib/archipelago/searxng:/etc/searxng \ - -p 8888:8080 $SEARXNG_IMAGE - fi - # FileBrowser — recreate if broken (permission denied on :80) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx filebrowser; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx filebrowser; then - \$DOCKER rm -f filebrowser 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx filebrowser; then - sudo mkdir -p /var/lib/archipelago/filebrowser - \$DOCKER run -d --name filebrowser --restart=unless-stopped \ - --health-cmd 'curl -sf http://localhost:80/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --user 0:0 \ - -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv \ - $FILEBROWSER_IMAGE - fi - - echo ' === Additional apps ===' - # Nextcloud — recreate if wrong image version (28→30 not supported, need 29) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nextcloud; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx nextcloud; then - echo ' Recreating nextcloud (was stopped/broken)...' - \$DOCKER rm -f nextcloud 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nextcloud; then - sudo mkdir -p /var/lib/archipelago/nextcloud - \$DOCKER run -d --name nextcloud --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:80/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8085:80 -v /var/lib/archipelago/nextcloud:/var/www/html \ - $NEXTCLOUD_IMAGE - fi - # PhotoPrism — recreate if broken (permissions) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx photoprism; then - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx photoprism; then - \$DOCKER rm -f photoprism 2>/dev/null - fi - fi - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx photoprism; then - sudo mkdir -p /var/lib/archipelago/photoprism - sudo chown -R 100000:100000 /var/lib/archipelago/photoprism 2>/dev/null - \$DOCKER run -d --name photoprism --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:2342/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 2342:2342 -v /var/lib/archipelago/photoprism:/photoprism/storage \ - -e PHOTOPRISM_ADMIN_PASSWORD=archipelago -e PHOTOPRISM_DEFAULT_LOCALE=en \ - $PHOTOPRISM_IMAGE - fi - # Nginx Proxy Manager - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx nginx-proxy-manager; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx nginx-proxy-manager; then - \$DOCKER start nginx-proxy-manager 2>/dev/null || true - else - sudo mkdir -p /var/lib/archipelago/nginx-proxy-manager/data/letsencrypt-acme-challenge/.well-known/acme-challenge /var/lib/archipelago/nginx-proxy-manager/letsencrypt - sudo chown -R 1000:1000 /var/lib/archipelago/nginx-proxy-manager 2>/dev/null || true - \$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ - --security-opt no-new-privileges:true \ - -p 8081:81 -p 8084:80 -p 8444:443 \ - -v /var/lib/archipelago/nginx-proxy-manager/data:/data \ - -v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \ - $NPM_IMAGE - fi - fi - # Portainer - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qx portainer; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx portainer; then - \$DOCKER start portainer 2>/dev/null || true - else - sudo mkdir -p /var/lib/archipelago/portainer/compose - sudo chown -R archipelago:archipelago /var/lib/archipelago/portainer 2>/dev/null || true - if [ ! -e /data ]; then - sudo ln -s /var/lib/archipelago/portainer /data 2>/dev/null || true - elif [ -d /data ] && [ ! -L /data ] && [ ! -e /data/compose ]; then - sudo ln -s /var/lib/archipelago/portainer/compose /data/compose 2>/dev/null || true - fi - \$DOCKER run -d --name portainer --restart unless-stopped \ - --health-cmd 'curl -sf http://localhost:9000/' --health-interval=30s --health-timeout=5s --health-retries=3 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 9000:9000 -v /var/lib/archipelago/portainer:/data \ - -v /var/lib/archipelago/portainer/compose:/data/compose \ - -v /run/user/1000/podman/podman.sock:/var/run/docker.sock \ - $PORTAINER_IMAGE - fi - fi - echo ' === Custom UI containers ===' - # Build custom UI containers if source exists - for ui in bitcoin-ui lnd-ui electrs-ui; do - CONTAINER_NAME=\"archy-\$ui\" - if \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q \"\$CONTAINER_NAME\"; then - continue - fi - case \$ui in - bitcoin-ui) PORT_ARG=''; NET_ARG='--network host' ;; - lnd-ui) PORT_ARG='-p 18083:80'; NET_ARG='' ;; - electrs-ui) PORT_ARG=''; NET_ARG='--network host' ;; - esac - if [ -d \"$TARGET_DIR/docker/\$ui\" ]; then - echo \" Building \$ui...\" - if \$DOCKER build --no-cache -t \"\$ui:local\" \"$TARGET_DIR/docker/\$ui\" 2>/dev/null; then - \$DOCKER stop \"\$CONTAINER_NAME\" 2>/dev/null; \$DOCKER rm -f \"\$CONTAINER_NAME\" 2>/dev/null - \$DOCKER run -d --name \"\$CONTAINER_NAME\" \$PORT_ARG --restart unless-stopped --health-cmd 'curl -sf http://localhost:80/' --health-interval=30s --health-timeout=5s --health-retries=3 \$NET_ARG \"\$ui:local\" - echo \" \$ui created\" - fi - elif \$DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -q \"\$ui\"; then - IMG=\$(\$DOCKER images --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep \"\$ui\" | head -1) - \$DOCKER run -d --name \"\$CONTAINER_NAME\" \$PORT_ARG --restart unless-stopped --health-cmd 'curl -sf http://localhost:80/' --health-interval=30s --health-timeout=5s --health-retries=3 \$NET_ARG \"\$IMG\" - fi - done - - # Patch bitcoin-ui with this node's RPC credentials - if \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-bitcoin-ui; then - RPC_PASS=\$(sudo cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null) - if [ -n \"\$RPC_PASS\" ]; then - AUTH_B64=\$(echo -n \"archipelago:\${RPC_PASS}\" | base64) - \$DOCKER exec archy-bitcoin-ui cat /etc/nginx/conf.d/default.conf > /tmp/btc-ui-nginx.conf 2>/dev/null - if grep -q '__BITCOIN_RPC_AUTH__' /tmp/btc-ui-nginx.conf; then - sed -i \"s|__BITCOIN_RPC_AUTH__|\${AUTH_B64}|g\" /tmp/btc-ui-nginx.conf - else - sed -i \"s|proxy_set_header Authorization \\\"Basic .*\\\";|proxy_set_header Authorization \\\"Basic \${AUTH_B64}\\\";|g\" /tmp/btc-ui-nginx.conf - fi - \$DOCKER cp /tmp/btc-ui-nginx.conf archy-bitcoin-ui:/etc/nginx/conf.d/default.conf 2>/dev/null - \$DOCKER exec archy-bitcoin-ui nginx -s reload 2>/dev/null - rm -f /tmp/btc-ui-nginx.conf - echo ' Bitcoin UI: RPC credentials patched' - fi - fi - - # Container summary - echo '' - TOTAL=\$(\$DOCKER ps --format '{{.Names}}' 2>/dev/null | wc -l) - echo \" Total containers running: \$TOTAL\" - " 2>&1 | sed 's/^/ /' - - # ── Step 23: Tor (robust setup) ────────────────────────────── - step "Setting up Tor" - ssh $SSH_OPTS "$TARGET" ' - sudo mkdir -p /var/lib/archipelago/tor - - # Install Tor if missing - if ! command -v tor >/dev/null 2>&1; then - echo " Installing Tor..." - sudo apt-get update -qq && sudo apt-get install -y -qq tor 2>/dev/null - fi - - if ! command -v tor >/dev/null 2>&1; then - echo " ERROR: Tor installation failed" - exit 0 - fi - - # Write services.json - SERVICES_JSON=/var/lib/archipelago/tor/services.json - if [ ! -f "$SERVICES_JSON" ]; then - sudo python3 -c " -import json -services = [ - {\"name\": \"archipelago\", \"local_port\": 80, \"enabled\": True}, - {\"name\": \"bitcoin\", \"local_port\": 8333, \"enabled\": True}, - {\"name\": \"electrumx\", \"local_port\": 50001, \"enabled\": True}, - {\"name\": \"lnd\", \"local_port\": 9735, \"enabled\": True}, - {\"name\": \"btcpay\", \"local_port\": 23000, \"enabled\": True}, - {\"name\": \"mempool\", \"local_port\": 4080, \"enabled\": True}, - {\"name\": \"fedimint\", \"local_port\": 8175, \"enabled\": True} -] -with open(\"/var/lib/archipelago/tor/services.json\", \"w\") as f: - json.dump({\"services\": services}, f, indent=2) -" - fi - - # Enable + start Tor service (try both unit names) - sudo systemctl enable tor 2>/dev/null || true - sudo systemctl enable tor@default 2>/dev/null || true - - # Restart Tor — try tor@default first (Debian pattern), fallback to tor - if sudo systemctl restart tor@default 2>/dev/null; then - echo " Tor running (tor@default)" - elif sudo systemctl restart tor 2>/dev/null; then - echo " Tor running (tor)" - else - echo " WARNING: Tor failed to start — check journalctl -u tor" - fi - - # Verify Tor is actually running - if systemctl is-active tor@default >/dev/null 2>&1 || systemctl is-active tor >/dev/null 2>&1; then - echo " Tor verified active" - else - echo " WARNING: Tor not active after restart attempt" - fi - ' 2>&1 | sed 's/^/ /' - fi - - # ── Step 24: UFW forward policy ────────────────────────────────── - step "Fixing UFW forward policy" - ssh $SSH_OPTS "$TARGET" ' - if grep -q "DEFAULT_FORWARD_POLICY=\"DROP\"" /etc/default/ufw 2>/dev/null; then - sudo sed -i "s/DEFAULT_FORWARD_POLICY=\"DROP\"/DEFAULT_FORWARD_POLICY=\"ACCEPT\"/" /etc/default/ufw - sudo ufw reload 2>/dev/null - echo " Fixed (was DROP, now ACCEPT)" - else - echo " Already ACCEPT" - fi - ' 2>&1 - - # ── Step 25: Fix IndeedHub NIP-07 ──────────────────────────────── - step "Fixing IndeedHub for NIP-07" - ssh $SSH_OPTS "$TARGET" ' - if podman ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then - CHANGED=false - if podman exec indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then - podman exec indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf - CHANGED=true - echo " Removed X-Frame-Options" - fi - if ! podman exec indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then - podman cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null - echo " Copied nostr-provider.js" - fi - API_IP=$(podman inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null) - MINIO_IP=$(podman inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null) - RELAY_IP=$(podman inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null) - if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then - podman exec indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null - sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf - sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf - sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf - sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf - podman cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null - rm -f /tmp/ih-nginx.conf - CHANGED=true - echo " Patched container IPs" - fi - [ "$CHANGED" = true ] && podman exec indeedhub nginx -s reload 2>/dev/null - else - echo " IndeedHub not running, skipping" - fi - ' 2>&1 - - # ── Step 26: Container doctor ──────────────────────────────────── - step "Running container doctor" - "$SCRIPT_DIR/container-doctor.sh" "$TARGET" 2>&1 | tail -10 | sed 's/^/ /' || true - - # ── Step 26b: Restart stopped containers + verify health ────── - step "Verifying all containers running" - ssh $SSH_OPTS "$TARGET" ' - DOCKER=podman; command -v podman >/dev/null 2>&1 || DOCKER=docker - - # Fix permissions before restart attempts (rootless UID mapping) - for dir in vaultwarden photoprism nextcloud filebrowser searxng; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null - done - - # Restart any exited containers (unless user-stopped) - USER_STOPPED="/var/lib/archipelago/user-stopped.json" - for ctr in $($DOCKER ps -a --filter "status=exited" --format "{{.Names}}" 2>/dev/null); do - if [ -f "$USER_STOPPED" ] && grep -q "\"$ctr\"" "$USER_STOPPED" 2>/dev/null; then - continue - fi - echo " Restarting exited container: $ctr" - $DOCKER start "$ctr" 2>/dev/null || echo " WARNING: Failed to start $ctr" - done - - # Summary - RUNNING=$($DOCKER ps --format "{{.Names}}" 2>/dev/null | wc -l) - EXITED=$($DOCKER ps -a --filter "status=exited" --format "{{.Names}}" 2>/dev/null | wc -l) - echo " Containers: $RUNNING running, $EXITED exited" - - # Verify Tor is still active - if systemctl is-active tor@default >/dev/null 2>&1 || systemctl is-active tor >/dev/null 2>&1; then - echo " Tor: active" - else - echo " Tor: NOT RUNNING — attempting restart..." - sudo systemctl restart tor@default 2>/dev/null || sudo systemctl restart tor 2>/dev/null || echo " Tor restart failed" - fi - ' 2>&1 | sed 's/^/ /' - - # ── Step 27: Deploy manifest ───────────────────────────────────── - step "Writing deploy manifest" - DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) - ssh $SSH_OPTS "$TARGET" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" << MANIFEST_EOF -{ - "commit": "$DEPLOY_COMMIT_FULL", - "commit_short": "$DEPLOY_COMMIT", - "branch": "$DEPLOY_BRANCH", - "dirty": $DEPLOY_DIRTY, - "deployed_at": "$DEPLOY_TS", - "deployed_from": "$(hostname)", - "target": "$TARGET" -} -MANIFEST_EOF - echo " Manifest written." - - # ── Step 28: Health check ──────────────────────────────────────── - step "Post-deploy health check" - HEALTH_OK=false - for i in $(seq 1 12); do - HEALTH=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$TARGET_IP/health" 2>/dev/null || { echo "WARNING: Post-deploy health check failed for $TARGET_IP" >&2; echo "000"; }) - if [ "$HEALTH" = "200" ]; then - echo " Health: OK (200) after $((i * 5))s" - HEALTH_OK=true - break - fi - echo " Health: $HEALTH (waiting... ${i}/12)" - sleep 5 - done - if [ "$HEALTH_OK" = false ]; then - echo " WARNING: Server did not become healthy within 60s" - echo " Check: ssh $TARGET 'sudo journalctl -u archipelago -n 50'" - fi - - local ELAPSED=$(($(date +%s) - DEPLOY_START)) - echo "" - echo "$(ts) Deploy complete for $NODE_NAME ($TARGET_IP) in ${ELAPSED}s" - echo " Commit: $DEPLOY_BRANCH @ $DEPLOY_COMMIT" - echo " Web UI: http://$TARGET_IP" - - # Append to deploy history - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $DEPLOY_BRANCH@$DEPLOY_COMMIT | dirty=$DEPLOY_DIRTY | target=$TARGET | ${ELAPSED}s | tailscale" >> "$PROJECT_DIR/scripts/deploy-history.log" -} - -# ── Main ───────────────────────────────────────────────────────────────── - -if [ "$1" = "--all" ]; then - echo "Deploying to all ${#TAILSCALE_NODES[@]} Tailscale nodes..." - FAILED=() - for i in "${!TAILSCALE_NODES[@]}"; do - deploy_node "${TAILSCALE_NODES[$i]}" "${TAILSCALE_NAMES[$i]}" || FAILED+=("${TAILSCALE_NAMES[$i]}") - done - echo "" - echo "════════════════════════════════════════════════════════════════" - if [ ${#FAILED[@]} -eq 0 ]; then - echo "All ${#TAILSCALE_NODES[@]} nodes deployed successfully." - else - echo "FAILED: ${FAILED[*]}" - echo "Succeeded: $((${#TAILSCALE_NODES[@]} - ${#FAILED[@]}))/${#TAILSCALE_NODES[@]}" - exit 1 - fi -elif [ -n "$1" ]; then - # Map friendly names to targets - case "$1" in - arch1|Arch1) deploy_node "${TAILSCALE_NODES[0]}" "Arch 1" ;; - arch2|Arch2) deploy_node "${TAILSCALE_NODES[1]}" "Arch 2" ;; - arch3|Arch3) deploy_node "${TAILSCALE_NODES[2]}" "Arch 3" ;; - *) deploy_node "$1" "$1" ;; - esac -else - echo "Usage: $0 " - echo "" - echo "Examples:" - echo " $0 arch2 # Deploy to Arch 2" - echo " $0 archipelago@100.82.97.63 # Deploy to specific host" - echo " $0 --all # Deploy to all 3 Tailscale nodes" - exit 1 -fi diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh deleted file mode 100755 index 7fa2a65c..00000000 --- a/scripts/deploy-to-target.sh +++ /dev/null @@ -1,2049 +0,0 @@ -#!/bin/bash -# -# Deploy Archipelago code to the HP ProDesk target -# -# Usage: -# ./scripts/deploy-to-target.sh # Sync and rebuild -# ./scripts/deploy-to-target.sh --quick # Sync only, no rebuild -# ./scripts/deploy-to-target.sh --live # Deploy to live system (default: 192.168.1.228) -# ./scripts/deploy-to-target.sh --both # Deploy to 228, then copy to 198 + 253 -# ./scripts/deploy-to-target.sh --frontend-only # Frontend-only deploy (skip Rust build + container rebuilds) -# ./scripts/deploy-to-target.sh --demo # Demo mode: Bitcoin pruning enabled (smaller disk) -# ./scripts/deploy-to-target.sh --dry-run --live # Show what would be deployed without executing -# ./scripts/deploy-to-target.sh --tailscale # Deploy to all 3 Tailscale alpha tester nodes -# ./scripts/deploy-to-target.sh --tailscale-node=arch2 # Deploy to a specific Tailscale node -# - -set -eo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" - -# Load deploy config (password etc.) - deploy-config.sh is gitignored -[ -f "$SCRIPT_DIR/deploy-config.sh" ] && . "$SCRIPT_DIR/deploy-config.sh" - -# Source pinned image versions (single source of truth) -[ -f "$SCRIPT_DIR/image-versions.sh" ] && . "$SCRIPT_DIR/image-versions.sh" - -# Source shared utility library -[ -f "$SCRIPT_DIR/lib/common.sh" ] && . "$SCRIPT_DIR/lib/common.sh" - -# Configuration -TARGET_HOST="${ARCHIPELAGO_TARGET:-archipelago@192.168.1.228}" -TARGET_DIR="/home/archipelago/archy" -SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}" -SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -i $SSH_KEY" - -DEPLOY_START=$(date +%s) -timestamp() { echo "[$(date +%H:%M:%S)]"; } - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Deploying to Archipelago Target ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "$(timestamp) Target: $TARGET_HOST" -echo "" - -# Parse arguments -QUICK=false -LIVE=false -BOTH=false -FRONTEND_ONLY=false -DEMO=false -DRY_RUN=false -CANARY=false -TAILSCALE=false -TAILSCALE_NODE="" -FLEET=false -RESET_MESH=false -for arg in "$@"; do - case $arg in - --quick) QUICK=true ;; - --live) LIVE=true ;; - --both) BOTH=true ;; - --frontend-only) FRONTEND_ONLY=true; LIVE=true ;; - --demo) DEMO=true ;; - --dry-run) DRY_RUN=true ;; - --canary) CANARY=true ;; - --tailscale) TAILSCALE=true ;; - --tailscale-node=*) TAILSCALE_NODE="${arg#*=}" ;; - --fleet) FLEET=true ;; - --all) FLEET=true ;; - --reset-mesh) RESET_MESH=true ;; - esac -done - -# Fleet deploy: .228 → .198 → all 3 Tailscale nodes (all 5 servers) -if [ "$FLEET" = true ]; then - echo "╔════════════════════════════════════════════════════════════════╗" - echo "║ FLEET DEPLOY — All nodes ║" - echo "╚════════════════════════════════════════════════════════════════╝" - echo "" - echo "Phase 1: Build + deploy to .228 (primary build server)" - # Try LAN first, fall back to Tailscale IP - if ssh $SSH_OPTS -o ConnectTimeout=5 "$TARGET_HOST" "echo ok" >/dev/null 2>&1; then - "$0" --live || { echo "FAILED: .228 deploy"; exit 1; } - elif [ -n "${TAILSCALE_PRIMARY:-}" ] || tailscale status >/dev/null 2>&1; then - TS_PRIMARY="${TAILSCALE_PRIMARY:-$(tailscale status 2>/dev/null | grep 'archipelago-0' | awk '{print $1}')}" - if [ -n "$TS_PRIMARY" ]; then - echo " LAN unreachable — using Tailscale IP $TS_PRIMARY" - ARCHIPELAGO_TARGET="archipelago@${TS_PRIMARY}" "$0" --live || { echo "FAILED: .228 deploy via Tailscale"; exit 1; } - else - echo "FAILED: .228 unreachable on LAN or Tailscale"; exit 1 - fi - else - echo "FAILED: .228 unreachable"; exit 1 - fi - echo "" - echo "Phase 2: Copy to .198 + .253 (LAN secondaries — skip if unreachable)" - "$0" --both 2>/dev/null || echo " LAN secondaries unreachable, skipping" - echo "" - echo "Phase 3: Deploy to all Tailscale nodes (Arch 1/2/3)" - "$SCRIPT_DIR/deploy-tailscale.sh" --all || { echo "WARNING: Some Tailscale nodes failed"; } - echo "" - echo "════════════════════════════════════════════════════════════════" - echo "Fleet deploy complete." - exit 0 -fi - -# Tailscale deploy: delegate to deploy-tailscale.sh -if [ "$TAILSCALE" = true ]; then - echo "Deploying to all Tailscale nodes..." - exec "$SCRIPT_DIR/deploy-tailscale.sh" --all -fi -if [ -n "$TAILSCALE_NODE" ]; then - echo "Deploying to Tailscale node: $TAILSCALE_NODE" - exec "$SCRIPT_DIR/deploy-tailscale.sh" "$TAILSCALE_NODE" -fi - -# Deploy locking — prevent concurrent deploys to the same target -TARGET_IP_FOR_LOCK="$(echo "$TARGET_HOST" | cut -d@ -f2)" -LOCK_DIR="/tmp/archipelago-deploy-${TARGET_IP_FOR_LOCK}.lock" -# Check for stale lock (older than 30 minutes) -if [ -d "$LOCK_DIR" ]; then - LOCK_STAMP="$LOCK_DIR/pid" - if [ -f "$LOCK_STAMP" ]; then - # macOS uses stat -f %m, Linux uses stat -c %Y - if stat -c %Y "$LOCK_STAMP" >/dev/null 2>&1; then - LOCK_MTIME=$(stat -c %Y "$LOCK_STAMP") - else - LOCK_MTIME=$(stat -f %m "$LOCK_STAMP") - fi - LOCK_AGE=$(( $(date +%s) - ${LOCK_MTIME:-0} )) - if [ "$LOCK_AGE" -gt 1800 ]; then - echo "$(timestamp) WARNING: Removing stale lock (${LOCK_AGE}s old)" - rm -rf "$LOCK_DIR" - fi - fi -fi -# mkdir is atomic — fails if directory already exists -if ! mkdir "$LOCK_DIR" 2>/dev/null; then - echo "ERROR: Deploy already in progress for $TARGET_HOST (lock: $LOCK_DIR)" - exit 1 -fi -echo $$ > "$LOCK_DIR"/pid -# Temp directory for intermediate files (cleaned up on exit) -TMPDIR="/tmp/archipelago-deploy-$$" -mkdir -p "$TMPDIR" -# Clean up lock and temp files on exit (normal, error, or signal) -cleanup_deploy() { rm -rf "$LOCK_DIR" "$TMPDIR"; } -trap cleanup_deploy EXIT - -# Dry run mode: show what would be deployed without executing -if [[ "$DRY_RUN" == "true" ]]; then - echo "═══ DRY RUN MODE — no changes will be made ═══" - echo "" - echo "Target: $TARGET_HOST" - echo "Project: $PROJECT_DIR" - echo "Mode: $( - [[ "$BOTH" == "true" ]] && echo "both (.228 + .198)" || \ - [[ "$LIVE" == "true" ]] && echo "live (.228)" || \ - echo "dev (sync + build)" - )" - echo "" - echo "Files that would be synced:" - rsync -avn --exclude '.git' --exclude 'target' --exclude 'node_modules' \ - --exclude 'dist' --exclude 'web/dist' --exclude '*.iso' \ - --exclude 'image-recipe/_archived/build' --exclude 'image-recipe/_archived/results' \ - --exclude '.codex-target-*' --exclude '.codex-tmp' --exclude 'uploads' \ - "$PROJECT_DIR/" "$TARGET_HOST:$TARGET_DIR/" 2>/dev/null | \ - grep -E '^[<>]|^deleting' | head -50 || echo " (rsync check failed — SSH may be unavailable)" - echo "" - echo "Frontend build: $( - [[ "$QUICK" == "true" ]] && echo "SKIP (--quick)" || echo "vue-tsc + vite build" - )" - echo "Backend build: $( - [[ "$FRONTEND_ONLY" == "true" ]] && echo "SKIP (--frontend-only)" || \ - [[ "$QUICK" == "true" ]] && echo "SKIP (--quick)" || echo "cargo build --release" - )" - echo "Live deploy: $( - [[ "$LIVE" == "true" || "$BOTH" == "true" ]] && echo "YES — binary + frontend + nginx + systemd" || echo "NO" - )" - echo "" - echo "═══ DRY RUN COMPLETE — nothing was changed ═══" - exit 0 -fi - -# Section timing helper -section_start() { SECTION_START=$(date +%s); } -section_end() { - local elapsed=$(($(date +%s) - SECTION_START)) - echo " (${elapsed}s)" -} - -# ── Progress bar ────────────────────────────────────────────── -CURRENT_STEP=0 -BAR_WIDTH=30 - -calculate_total_steps() { - local total=4 # SSH, prereqs, health, git state - - if [[ "$QUICK" == "true" ]]; then - total=$((total + 1)) # sync only - echo $total; return - fi - - total=$((total + 1)) # sync code - total=$((total + 1)) # frontend build - - if [[ "$FRONTEND_ONLY" != "true" ]]; then - total=$((total + 1)) # backend build - fi - - if [[ "$LIVE" == "true" ]]; then - total=$((total + 14)) # rollback, frontend, AIUI, nginx, systemd, claude proxy, dev mode, data dirs, nostr-provider, filebrowser, manifest, restart, HTTPS, health check - if [[ "$FRONTEND_ONLY" != "true" ]]; then - total=$((total + 1)) # deploy backend binary - total=$((total + 16)) # container rebuilds - fi - total=$((total + 3)) # UFW, IndeedHub fix, container doctor - fi - - echo $total -} - -TOTAL_STEPS=$(calculate_total_steps) - -progress() { - CURRENT_STEP=$((CURRENT_STEP + 1)) - local pct=$((CURRENT_STEP * 100 / TOTAL_STEPS)) - local filled=$((pct * BAR_WIDTH / 100)) - local empty=$((BAR_WIDTH - filled)) - local bar - bar=$(printf '%*s' "$filled" '' | tr ' ' '█')$(printf '%*s' "$empty" '' | tr ' ' '░') - printf "\033[1;36m━━━ [%s] %3d%% (%d/%d)\033[0m %s\n" "$bar" "$pct" "$CURRENT_STEP" "$TOTAL_STEPS" "$1" -} -# ───────────────────────────────────────────────────────────── - -# SSH connectivity pre-flight check -progress "Checking SSH connectivity" -if ! ssh $SSH_OPTS -o ConnectTimeout=5 "$TARGET_HOST" "echo ok" >/dev/null 2>&1; then - echo " ERROR: Cannot connect to $TARGET_HOST" - echo " Check that the server is on and reachable." - exit 1 -fi -echo " Connected." - -# Disk space pre-flight — abort if target is dangerously full -DISK_PCT=$(ssh $SSH_OPTS "$TARGET_HOST" "df / | tail -1 | awk '{print \$(NF-1)}' | tr -d '%'" 2>/dev/null) -if [ -n "$DISK_PCT" ] && [ "$DISK_PCT" -gt 85 ] 2>/dev/null; then - echo "ERROR: Target disk at ${DISK_PCT}% — need <85% for safe deploy. Free space and retry." - exit 1 -fi - -# Install prerequisites if missing (rsync for code sync, python3 for Claude API proxy) -progress "Checking prerequisites" -ssh $SSH_OPTS "$TARGET_HOST" ' - NEED_INSTALL="" - command -v rsync >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL rsync" - command -v python3 >/dev/null 2>&1 || NEED_INSTALL="$NEED_INSTALL python3" - # python3 -m venv exists but cannot bootstrap pip without the matching - # .-venv package on Debian — needed for reticulum-daemon/ - # build.sh (archy-reticulum-daemon / archy-rnodeconf packaging). - if command -v python3 >/dev/null 2>&1 && ! python3 -c "import ensurepip" >/dev/null 2>&1; then - PYVER=$(python3 -c "import sys; print(f\"{sys.version_info.major}.{sys.version_info.minor}\")") - NEED_INSTALL="$NEED_INSTALL python3.${PYVER#*.}-venv" - fi - if ! command -v node >/dev/null 2>&1 || ! command -v npm >/dev/null 2>&1; then - echo " Node.js/npm not found — installing..." - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - 2>&1 | tail -3 - NEED_INSTALL="$NEED_INSTALL nodejs" - fi - if [ -n "$NEED_INSTALL" ]; then - echo " Installing:$NEED_INSTALL" - sudo apt-get update -qq && sudo apt-get install -y -qq $NEED_INSTALL 2>&1 | tail -3 - else - echo " All prerequisites present" - fi -' 2>&1 - -# Pre-deploy health check (informational — warns but does not block) -progress "Pre-deploy health check" -TARGET_IP_ONLY="$(echo "$TARGET_HOST" | cut -d@ -f2)" -PRE_HEALTH=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$TARGET_IP_ONLY/health" 2>/dev/null || { echo "WARNING: Pre-deploy health check failed for $TARGET_IP_ONLY" >&2; echo "000"; }) -if [ "$PRE_HEALTH" = "200" ]; then - echo " Server health: OK (200)" -else - echo " ⚠️ Server health: $PRE_HEALTH (may be down or unhealthy — deploying anyway)" -fi -echo "" - -# Git state check — detect uncommitted changes and record deploy version -progress "Checking git state" -DEPLOY_COMMIT=$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo "unknown") -DEPLOY_COMMIT_FULL=$(git -C "$PROJECT_DIR" rev-parse HEAD 2>/dev/null || echo "unknown") -DEPLOY_BRANCH=$(git -C "$PROJECT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") -DIRTY_FILES=$(git -C "$PROJECT_DIR" status --porcelain 2>/dev/null | grep -v '^??' | grep -v '\.claude/memory/' || true) -DEPLOY_DIRTY=false - -echo "$(timestamp) Git state: $DEPLOY_BRANCH @ $DEPLOY_COMMIT" -if [ -n "$DIRTY_FILES" ]; then - DEPLOY_DIRTY=true - DIRTY_COUNT=$(echo "$DIRTY_FILES" | wc -l | tr -d ' ') - echo " ⚠️ WARNING: $DIRTY_COUNT uncommitted change(s) — deploying working directory, NOT last commit" - echo "$DIRTY_FILES" | head -10 | sed 's/^/ /' - [ "$DIRTY_COUNT" -gt 10 ] && echo " ... and $((DIRTY_COUNT - 10)) more" - echo "" - echo " To deploy clean: commit or stash changes first" - echo " Continuing in 3 seconds... (Ctrl+C to abort)" - sleep 3 -else - echo " Working tree clean — deploying commit $DEPLOY_COMMIT" -fi -echo "" - -# When --canary: deploy to 198 first, verify health, then deploy to 228 -if [ "$CANARY" = true ]; then - echo "🐤 Canary deploy: .198 first, then .228 if healthy..." - echo "" - - # Deploy to .228 (builds code), then copy to .198 - "$0" --both - - # Verify .198 is healthy before declaring success - echo "" - echo "🐤 Canary check: verifying .198 health..." - CANARY_OK=false - for i in $(seq 1 12); do - sleep 5 - CANARY_HEALTH=$(curl -s --max-time 5 "http://192.168.1.198/health" 2>/dev/null || { echo "WARNING: Canary health check failed for 192.168.1.198" >&2; echo ""; }) - if [ "$CANARY_HEALTH" = "OK" ]; then - echo " ✅ Canary .198 healthy after $((i * 5))s" - CANARY_OK=true - break - fi - done - - if [ "$CANARY_OK" != "true" ]; then - echo " ❌ Canary .198 FAILED health check after 60s" - echo " ⚠️ .228 was also deployed. Check both servers." - exit 1 - fi - - echo "🐤 Canary deploy complete — both nodes healthy" - exit 0 -fi - -# ── deploy_secondary: copy built binary+frontend from .228 to a secondary node ── -# Usage: deploy_secondary (e.g. deploy_secondary archipelago@192.168.1.198 198) -deploy_secondary() { - local SEC_TARGET="$1" - local SEC_LABEL="$2" - local SEC_IP="${SEC_TARGET#*@}" - - echo "" - echo "📤 Copying to $SEC_IP (no rsync/cargo on that node)..." - - scp $SSH_OPTS /tmp/archipelago-both "$SEC_TARGET:/tmp/archipelago-new" - ssh $SSH_OPTS "archipelago@192.168.1.228" "cd '$TARGET_DIR' && tar cf - web/dist/neode-ui 2>/dev/null" | ssh $SSH_OPTS "$SEC_TARGET" "mkdir -p /tmp/web-deploy && cd /tmp/web-deploy && tar xf -" - ssh $SSH_OPTS "$SEC_TARGET" ' - sudo systemctl stop archipelago - sudo cp /tmp/archipelago-new /usr/local/bin/archipelago - sudo chmod +x /usr/local/bin/archipelago - rm -f /tmp/archipelago-new - sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name "aiui" ! -name "claude-login.html" -exec rm -rf {} + - sudo cp -r /tmp/web-deploy/web/dist/neode-ui/* /opt/archipelago/web-ui/ 2>/dev/null || true - sudo chown -R 1000:1000 /opt/archipelago/web-ui - ' - - # Deploy AIUI — prefer the in-repo build (aiui/, D-19) on the build host, - # but fall back to streaming from .228's /opt/archipelago/web-ui/aiui - # (where the ISO build deposited it). Without the fallback, secondaries - # lose AIUI whenever the deploy runs from a machine that hasn't run - # scripts/build-aiui.sh yet. - AIUI_DIST="$PROJECT_DIR/aiui/packages/app/dist" - if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then - echo " Deploying AIUI to .$SEC_LABEL (from local sibling dist)..." - ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*" - cd "$AIUI_DIST" && tar --no-xattrs -cf - . | ssh $SSH_OPTS "$SEC_TARGET" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/" - cd "$PROJECT_DIR" - ssh $SSH_OPTS "$SEC_TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui" - elif ssh $SSH_OPTS archipelago@192.168.1.228 "[ -f /opt/archipelago/web-ui/aiui/index.html ]" 2>/dev/null; then - echo " Deploying AIUI to .$SEC_LABEL (streaming from .228)..." - ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /opt/archipelago/web-ui/aiui && sudo rm -rf /opt/archipelago/web-ui/aiui/*" - ssh $SSH_OPTS archipelago@192.168.1.228 "sudo tar --no-xattrs -cf - -C /opt/archipelago/web-ui/aiui ." \ - | ssh $SSH_OPTS "$SEC_TARGET" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/" - ssh $SSH_OPTS "$SEC_TARGET" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui" - else - echo " ⚠️ AIUI not available locally or on .228 — skipping AIUI deploy to .$SEC_LABEL" - fi - - # Sync nginx config + snippets - NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" - SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets" - if [ -f "$NGINX_CFG" ]; then - echo " Syncing nginx config to .$SEC_LABEL..." - scp $SSH_OPTS "$NGINX_CFG" "$SEC_TARGET:/tmp/nginx-archipelago.conf" 2>/dev/null || true - ssh $SSH_OPTS "$SEC_TARGET" ' - sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago - sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf - rm -f /tmp/nginx-archipelago.conf - ' 2>/dev/null || true - fi - if [ -d "$SNIPPETS_DIR" ]; then - echo " Syncing nginx snippets to .$SEC_LABEL..." - ssh $SSH_OPTS "$SEC_TARGET" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true - for f in "$SNIPPETS_DIR"/*.conf; do - [ -f "$f" ] && scp $SSH_OPTS "$f" "$SEC_TARGET:/tmp/nginx-snippet-$(basename "$f")" 2>/dev/null || true - done - ssh $SSH_OPTS "$SEC_TARGET" ' - for f in /tmp/nginx-snippet-*.conf; do - [ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")" - done - ' 2>/dev/null || true - fi - ssh $SSH_OPTS "$SEC_TARGET" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " nginx config test failed"' 2>/dev/null || true - - # Sync systemd service file - SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service" - if [ -f "$SERVICE_FILE" ]; then - echo " Syncing systemd service to .$SEC_LABEL..." - scp $SSH_OPTS "$SERVICE_FILE" "$SEC_TARGET:/tmp/archipelago.service" 2>/dev/null || true - ssh $SSH_OPTS "$SEC_TARGET" ' - if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then - sudo cp /tmp/archipelago.service /etc/systemd/system/archipelago.service - sudo systemctl daemon-reload - echo " Service file updated" - else - echo " Service file unchanged" - fi - rm -f /tmp/archipelago.service - ' 2>/dev/null || true - fi - if [ -n "${TELEMETRY_COLLECTOR_URL:-}" ]; then - echo " Syncing telemetry collector config to .$SEC_LABEL..." - TMP_TELEMETRY_ENV="$(mktemp)" - printf 'TELEMETRY_COLLECTOR_URL=%s\n' "$TELEMETRY_COLLECTOR_URL" > "$TMP_TELEMETRY_ENV" - scp $SSH_OPTS "$TMP_TELEMETRY_ENV" "$SEC_TARGET:/tmp/telemetry.env" 2>/dev/null || true - rm -f "$TMP_TELEMETRY_ENV" - ssh $SSH_OPTS "$SEC_TARGET" ' - sudo mkdir -p /var/lib/archipelago - sudo cp /tmp/telemetry.env /var/lib/archipelago/telemetry.env - sudo chown archipelago:archipelago /var/lib/archipelago/telemetry.env - sudo chmod 600 /var/lib/archipelago/telemetry.env - rm -f /tmp/telemetry.env - ' 2>/dev/null || true - fi - - # Deploy udev rule for mesh radio - UDEV_RULE="$PROJECT_DIR/image-recipe/configs/99-mesh-radio.rules" - if [ -f "$UDEV_RULE" ]; then - echo " Syncing udev rule to .$SEC_LABEL..." - scp $SSH_OPTS "$UDEV_RULE" "$SEC_TARGET:/tmp/99-mesh-radio.rules" 2>/dev/null || true - ssh $SSH_OPTS "$SEC_TARGET" ' - if ! diff -q /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules >/dev/null 2>&1; then - sudo cp /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --subsystem-match=tty - echo " Mesh radio udev rule installed" - else - echo " Mesh radio udev rule unchanged" - fi - rm -f /tmp/99-mesh-radio.rules - ' 2>/dev/null || true - fi - - # Deploy ALSA default-device config (routes ALSA "default" through PulseAudio/PipeWire) - ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" - if [ -f "$ASOUND_CONF" ]; then - echo " Syncing ALSA default-device config to .$SEC_LABEL..." - scp $SSH_OPTS "$ASOUND_CONF" "$SEC_TARGET:/tmp/asound.conf" 2>/dev/null || true - ssh $SSH_OPTS "$SEC_TARGET" ' - if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then - sudo cp /tmp/asound.conf /etc/asound.conf - echo " ALSA default-device config installed" - else - echo " ALSA default-device config unchanged" - fi - rm -f /tmp/asound.conf - ' 2>/dev/null || true - fi - - # Dev mode + FileBrowser - ssh $SSH_OPTS "$SEC_TARGET" ' - # Dev mode - if ! grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then - sudo mkdir -p /etc/systemd/system/archipelago.service.d - printf "[Service]\nEnvironment=ARCHIPELAGO_DEV_MODE=true\n" | sudo tee /etc/systemd/system/archipelago.service.d/override.conf > /dev/null - sudo systemctl daemon-reload - fi - # FileBrowser fix - DOCKER=podman; command -v podman >/dev/null 2>&1 || DOCKER=docker - FB=$($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true) - if [ -n "$FB" ]; then - RO=$($DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false") - if [ "$RO" = "true" ]; then - $DOCKER stop filebrowser 2>/dev/null; $DOCKER rm filebrowser 2>/dev/null - sudo mkdir -p /var/lib/archipelago/filebrowser - $DOCKER run -d --name filebrowser --restart=unless-stopped --user 0:0 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv "$FILEBROWSER_IMAGE" 2>/dev/null - fi - fi - ' 2>/dev/null || true - - # Write deploy manifest - local DEPLOY_TS - DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) - ssh $SSH_OPTS "$SEC_TARGET" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" <<-MANIFEST_SEC_EOF -{ - "commit": "$DEPLOY_COMMIT_FULL", - "commit_short": "$DEPLOY_COMMIT", - "branch": "$DEPLOY_BRANCH", - "dirty": $DEPLOY_DIRTY, - "deployed_at": "$DEPLOY_TS", - "deployed_from": "$(hostname)", - "target": "$SEC_TARGET" -} -MANIFEST_SEC_EOF - - ssh $SSH_OPTS "$SEC_TARGET" "sudo systemctl start archipelago && sudo systemctl restart nginx" - - # Run container doctor - echo " Running container doctor on .$SEC_LABEL..." - "$SCRIPT_DIR/container-doctor.sh" "$SEC_TARGET" 2>&1 | sed 's/^/ /' || true - - # Post-deploy health check - echo " Checking .$SEC_LABEL health..." - local HEALTH="fail" - for i in $(seq 1 12); do - sleep 5 - HEALTH=$(curl -s --max-time 5 "http://$SEC_IP/health" 2>/dev/null || { echo "WARNING: Health check failed for $SEC_IP" >&2; echo ""; }) - if [ "$HEALTH" = "OK" ]; then - echo " ✅ $SEC_IP deployed (health OK after $((i * 5))s)" - break - fi - done - if [ "$HEALTH" != "OK" ]; then - echo " ⚠️ $SEC_IP deployed but health check failed after 60s" - fi -} - -# When --both: deploy to 228 first, then copy to 198 + 253 -if [ "$BOTH" = true ]; then - echo "Deploying to all LAN servers (228, then 198 + 253)..." - # Release lock so the recursive --live call can acquire it - rm -rf "$LOCK_DIR" 2>/dev/null; trap - EXIT - "$0" --live - echo "" - - # Fetch built binary from .228 (shared by all secondary nodes) - if ! scp $SSH_OPTS "archipelago@192.168.1.228:$TARGET_DIR/core/target/release/archipelago" /tmp/archipelago-both 2>/dev/null; then - echo " ERROR: Failed to copy binary from .228 — is the build available?" - exit 1 - fi - - # Deploy to each secondary node - deploy_secondary "archipelago@192.168.1.198" "198" - deploy_secondary "archipelago@192.168.1.253" "253" - - rm -f /tmp/archipelago-both - exit 0 -fi - -# Sync code -section_start - -# GUARD (2026-07-31 incident): the rsync below is `--delete`, and TARGET_DIR -# (/home/archipelago/archy) is a SYMLINK to /home/archipelago/Projects/archy on -# archi-dev-box — which is this same machine over loopback SSH. Deploying from -# the main checkout is a harmless no-op (source and destination resolve to the -# same directory), but deploying from anywhere else on this host — a git -# worktree under .claude/worktrees/, a copy, a subdirectory — makes rsync mirror -# that source ONTO the main checkout and delete everything not present in it. -# That is exactly what happened: ~1810 tracked files deleted, the deploying -# worktree destroyed mid-run, a running dev server killed, and two concurrent -# sessions' uncommitted work lost permanently. -# -# Refuse the deploy when source and destination are on the same host and -# their resolved paths differ AT ALL — not just when one contains the -# other. See assert_safe_same_host_deploy in lib/common.sh for the full -# incident history and the sibling-directory gap this widening closes. -_LOCAL_SRC="$(readlink -f "$PROJECT_DIR")" -_REMOTE_ID="$(ssh $SSH_OPTS "$TARGET_HOST" 'cat /etc/machine-id 2>/dev/null' 2>/dev/null || true)" -_LOCAL_ID="$(cat /etc/machine-id 2>/dev/null || true)" -if [ -n "$_REMOTE_ID" ] && [ "$_REMOTE_ID" = "$_LOCAL_ID" ]; then - _REMOTE_DST="$(ssh $SSH_OPTS "$TARGET_HOST" "readlink -f '$TARGET_DIR'" 2>/dev/null || true)" - if [ -n "$_REMOTE_DST" ]; then - assert_safe_same_host_deploy "$_LOCAL_SRC" "$_REMOTE_DST" || exit 1 - fi -fi - -progress "Syncing code" -rsync -avz --delete \ - -e "ssh $SSH_OPTS" \ - --exclude 'node_modules' \ - --exclude 'target' \ - --exclude 'dist' \ - --exclude '.git' \ - --exclude '.codex-target-*' \ - --exclude '.codex-tmp' \ - --exclude 'uploads' \ - --exclude 'image-recipe/build' \ - --exclude 'image-recipe/results' \ - --exclude 'image-recipe/_archived/build' \ - --exclude 'image-recipe/_archived/results' \ - --exclude 'releases' \ - --exclude '.venv' \ - "$PROJECT_DIR/" "$TARGET_HOST:$TARGET_DIR/" -section_end - -if [ "$QUICK" = true ]; then - echo "" - echo "✅ Quick sync complete!" - exit 0 -fi - -# Build on target -echo "" -progress "Building frontend" -section_start -ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/neode-ui && npm install --silent && npm run build" 2>&1 | sed 's/^/ /' -section_end - -# Backend (if Rust is installed) — skip with --frontend-only -if [ "$FRONTEND_ONLY" = true ]; then - echo " Skipping backend build (--frontend-only)" -elif ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env 2>/dev/null && command -v cargo" >/dev/null 2>&1; then - progress "Building backend (Rust release)" - section_start - ssh $SSH_OPTS "$TARGET_HOST" "source ~/.cargo/env && cd $TARGET_DIR/core && cargo build --release 2>&1" | sed 's/^/ /' - section_end -else - echo " ⚠️ Rust not installed on target, skipping backend build" -fi - -# reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) — skip with -# --frontend-only. Non-fatal on failure: these are supplementary mesh/radio -# tools, not required for the rest of the deploy to succeed, and build.sh -# handles its own venv/pip setup so a first run here is slower than later ones. -if [ "$FRONTEND_ONLY" = true ]; then - echo " Skipping reticulum-daemon tools build (--frontend-only)" -else - progress "Building reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf)" - section_start - ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/reticulum-daemon && ./build.sh 2>&1" | sed 's/^/ /' \ - || echo " ⚠️ reticulum-daemon tools build failed — continuing without updating them" - section_end -fi - -if [ "$LIVE" = true ]; then - - # Create rollback backup before deploying - progress "Creating rollback backup" - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo mkdir -p /opt/archipelago/rollback - [ -f /usr/local/bin/archipelago ] && sudo cp /usr/local/bin/archipelago /opt/archipelago/rollback/archipelago.bak 2>/dev/null || true - [ -d /opt/archipelago/web-ui ] && sudo tar cf /opt/archipelago/rollback/web-ui.tar -C /opt/archipelago/web-ui . 2>/dev/null || true - ' 2>/dev/null || true - - # Deploy backend (check if binary exists) — skip with --frontend-only - if [ "$FRONTEND_ONLY" = true ]; then - echo " Skipping backend deploy (--frontend-only)" - elif ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/core/target/release/archipelago ]" 2>/dev/null; then - progress "Deploying backend binary" - ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl stop archipelago --no-block 2>/dev/null; sleep 2; sudo kill -9 $(pgrep -x archipelago) 2>/dev/null; sleep 1; true' - if [ "$RESET_MESH" = true ]; then - echo " Wiping mesh cache (peers/messages/sessions) per --reset-mesh" - ssh $SSH_OPTS "$TARGET_HOST" 'sudo rm -f /var/lib/archipelago/messages.json /var/lib/archipelago/sessions.json /var/lib/archipelago/mesh-outbox.json 2>/dev/null; true' - fi - ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/core/target/release/archipelago /usr/local/bin/" - fi - - # Deploy reticulum-daemon tools (archy-reticulum-daemon, archy-rnodeconf) — - # skip with --frontend-only. Non-fatal: archipelago falls back to its dev - # venv path (ARCHY_RETICULUM_DAEMON_PY/_SCRIPT) if the packaged binary - # isn't present, so a missing/failed build here degrades rather than - # breaks mesh. archipelago is already stopped from the backend-binary - # step above, so this is a clean window to swap both binaries. - if [ "$FRONTEND_ONLY" = true ]; then - echo " Skipping reticulum-daemon tools deploy (--frontend-only)" - else - progress "Deploying reticulum-daemon tools" - for tool in archy-reticulum-daemon archy-rnodeconf; do - if ssh $SSH_OPTS "$TARGET_HOST" "[ -f $TARGET_DIR/reticulum-daemon/dist/$tool ]" 2>/dev/null; then - ssh $SSH_OPTS "$TARGET_HOST" "sudo cp $TARGET_DIR/reticulum-daemon/dist/$tool /usr/local/bin/ && sudo chmod +x /usr/local/bin/$tool" - echo " $tool deployed" - else - echo " ⚠️ $tool not built — leaving existing /usr/local/bin/$tool (if any) in place" - fi - done - fi - - # Deploy frontend (preserve aiui/ and claude-login.html — they are NOT part of the neode-ui build) - progress "Deploying frontend" - ssh $SSH_OPTS "$TARGET_HOST" "sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name 'aiui' ! -name 'claude-login.html' -exec rm -rf {} +" - ssh $SSH_OPTS "$TARGET_HOST" "sudo cp -rf $TARGET_DIR/web/dist/neode-ui/* /opt/archipelago/web-ui/" - ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui" - - # Build and deploy AIUI (non-fatal — never delete existing AIUI on failure) - # D-19 (2026-08-03): AIUI lives in-repo at aiui/ now — no second checkout - # to build from. scripts/build-aiui.sh is the one supported way to build - # it (enforces VITE_BASE_PATH, installs from the committed lockfile, - # attributes the build to this repo's own commit — see its own header). - progress "Building & deploying AIUI" - AIUI_DIR="$PROJECT_DIR/aiui" - AIUI_DIST="$AIUI_DIR/packages/app/dist" - # Auto-build AIUI if dist is missing or older than source - if [ -d "$AIUI_DIR/packages/app/src" ] && ( [ ! -f "$AIUI_DIST/index.html" ] || [ "$(find "$AIUI_DIR/packages/app/src" -newer "$AIUI_DIST/index.html" -print -quit 2>/dev/null)" != "" ] ); then - echo "$(timestamp) Building AIUI (source newer than dist or dist missing)..." - bash "$PROJECT_DIR/scripts/build-aiui.sh" || echo "$(timestamp) ⚠️ AIUI build failed" - fi - # Fallback: if the in-repo aiui/ build didn't produce a dist, use the - # pre-built dist shipped in this repo at demo/aiui/. That path is what we - # ship in the release tarball too, so local-and-fleet-update stay - # consistent. LOUD WARNING: this ships a checked-in dist, not a fresh - # build — its attribution (BUILD-INFO, if any) reflects whatever commit - # last regenerated demo/aiui/, not this deploy's own HEAD. - if [ ! -f "$AIUI_DIST/index.html" ] && [ -f "$PROJECT_DIR/demo/aiui/index.html" ]; then - echo "$(timestamp) ⚠️⚠️ AIUI build missing/failed — falling back to the CHECKED-IN" - echo "$(timestamp) demo/aiui/ dist. This is NOT a fresh build of this repo's" - echo "$(timestamp) current commit — verify it is what you intend to ship." - AIUI_DIST="$PROJECT_DIR/demo/aiui" - fi - if [ -d "$AIUI_DIST" ] && [ -f "$AIUI_DIST/index.html" ]; then - echo "$(timestamp) Deploying AIUI..." - ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui" - ssh $SSH_OPTS "$TARGET_HOST" "sudo rm -rf /opt/archipelago/web-ui/aiui/*" - cd "$AIUI_DIST" && tar --no-xattrs -cf - . | ssh $SSH_OPTS "$TARGET_HOST" "sudo tar xf - -C /opt/archipelago/web-ui/aiui/" - cd "$PROJECT_DIR" - ssh $SSH_OPTS "$TARGET_HOST" "sudo chown -R 1000:1000 /opt/archipelago/web-ui/aiui && sudo chmod 755 /opt/archipelago/web-ui/aiui && sudo find /opt/archipelago/web-ui/aiui -type d -exec chmod 755 {} \;" - if [ -f "$PROJECT_DIR/scripts/verify-aiui-deploy.sh" ]; then - echo "$(timestamp) Verifying AIUI deploy (live-fetch check)..." - bash "$PROJECT_DIR/scripts/verify-aiui-deploy.sh" "$TARGET_HOST" "$(git -C "$PROJECT_DIR" rev-parse HEAD)" \ - || echo "$(timestamp) ⚠️ AIUI post-deploy verification failed — see output above" - fi - else - echo "$(timestamp) ⚠️ AIUI not found at $AIUI_DIR, skipping" - fi - - # Sync nginx config from image-recipe (single source of truth) - progress "Syncing nginx configuration" - NGINX_CFG="$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" - SNIPPETS_DIR="$PROJECT_DIR/image-recipe/configs/snippets" - if [ -f "$NGINX_CFG" ]; then - scp $SSH_OPTS "$NGINX_CFG" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago - # Make sites-enabled a symlink to sites-available so future - # config updates actually take effect. Older deploys left - # sites-enabled as a regular file that fell out of sync. - if [ -f /etc/nginx/sites-enabled/archipelago ] && [ ! -L /etc/nginx/sites-enabled/archipelago ]; then - sudo rm -f /etc/nginx/sites-enabled/archipelago - sudo ln -s /etc/nginx/sites-available/archipelago /etc/nginx/sites-enabled/archipelago - elif [ ! -e /etc/nginx/sites-enabled/archipelago ]; then - sudo ln -s /etc/nginx/sites-available/archipelago /etc/nginx/sites-enabled/archipelago - fi - rm -f /tmp/nginx-archipelago.conf - ' 2>/dev/null || true - fi - - # Sync nginx snippet files (HTTPS app proxies, PWA headers — included by main config) - if [ -d "$SNIPPETS_DIR" ]; then - ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /etc/nginx/snippets" 2>/dev/null || true - for f in "$SNIPPETS_DIR"/*.conf; do - [ -f "$f" ] && scp $SSH_OPTS "$f" "$TARGET_HOST:/tmp/nginx-snippet-$(basename "$f")" 2>/dev/null || true - done - ssh $SSH_OPTS "$TARGET_HOST" ' - for f in /tmp/nginx-snippet-*.conf; do - [ -f "$f" ] && sudo mv "$f" "/etc/nginx/snippets/$(basename "$f" | sed "s/^nginx-snippet-//")" - done - ' 2>/dev/null || true - fi - - # Remove old port-based external app proxies config - ssh $SSH_OPTS "$TARGET_HOST" 'sudo rm -f /etc/nginx/conf.d/external-app-proxies.conf' 2>/dev/null || true - - # Validate nginx config after all changes - ssh $SSH_OPTS "$TARGET_HOST" 'sudo nginx -t 2>&1 && echo " nginx config OK" || echo " ⚠️ nginx config test failed"' 2>/dev/null || true - - # Sync systemd service file (single source of truth: image-recipe/configs/) - progress "Syncing systemd service" - SERVICE_FILE="$PROJECT_DIR/image-recipe/configs/archipelago.service" - if [ -f "$SERVICE_FILE" ]; then - scp $SSH_OPTS "$SERVICE_FILE" "$TARGET_HOST:/tmp/archipelago.service" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" ' - if ! diff -q /tmp/archipelago.service /etc/systemd/system/archipelago.service >/dev/null 2>&1; then - sudo cp /tmp/archipelago.service /etc/systemd/system/archipelago.service - sudo systemctl daemon-reload - echo " Service file updated" - else - echo " Service file unchanged" - fi - rm -f /tmp/archipelago.service - ' 2>/dev/null || true - fi - - # Sync kiosk display helpers and units for HDMI/TV nodes. Existing nodes may - # not have a git checkout, so live deploy must carry these outside OTA too. - KIOSK_LAUNCHER="$PROJECT_DIR/image-recipe/configs/archipelago-kiosk-launcher.sh" - if [ -f "$KIOSK_LAUNCHER" ]; then - scp $SSH_OPTS "$KIOSK_LAUNCHER" "$TARGET_HOST:/tmp/archipelago-kiosk-launcher" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo install -m 755 /tmp/archipelago-kiosk-launcher /usr/local/bin/archipelago-kiosk-launcher - rm -f /tmp/archipelago-kiosk-launcher - echo " Kiosk launcher updated" - ' 2>/dev/null || true - fi - for unit in archipelago-kiosk.service archipelago-kiosk-watchdog.service; do - KIOSK_UNIT="$PROJECT_DIR/image-recipe/configs/$unit" - [ -f "$KIOSK_UNIT" ] || continue - scp $SSH_OPTS "$KIOSK_UNIT" "$TARGET_HOST:/tmp/$unit" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" " - if ! diff -q '/tmp/$unit' '/etc/systemd/system/$unit' >/dev/null 2>&1; then - sudo install -m 644 '/tmp/$unit' '/etc/systemd/system/$unit' - sudo systemctl daemon-reload - echo ' $unit updated' - else - echo ' $unit unchanged' - fi - rm -f '/tmp/$unit' - " 2>/dev/null || true - done - if [ -n "${TELEMETRY_COLLECTOR_URL:-}" ]; then - progress "Syncing telemetry collector config" - TMP_TELEMETRY_ENV="$(mktemp)" - printf 'TELEMETRY_COLLECTOR_URL=%s\n' "$TELEMETRY_COLLECTOR_URL" > "$TMP_TELEMETRY_ENV" - scp $SSH_OPTS "$TMP_TELEMETRY_ENV" "$TARGET_HOST:/tmp/telemetry.env" 2>/dev/null || true - rm -f "$TMP_TELEMETRY_ENV" - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo mkdir -p /var/lib/archipelago - sudo cp /tmp/telemetry.env /var/lib/archipelago/telemetry.env - sudo chown archipelago:archipelago /var/lib/archipelago/telemetry.env - sudo chmod 600 /var/lib/archipelago/telemetry.env - rm -f /tmp/telemetry.env - ' 2>/dev/null || true - fi - - # Deploy udev rule for mesh radio stable naming (/dev/mesh-radio) - UDEV_RULE="$PROJECT_DIR/image-recipe/configs/99-mesh-radio.rules" - if [ -f "$UDEV_RULE" ]; then - scp $SSH_OPTS "$UDEV_RULE" "$TARGET_HOST:/tmp/99-mesh-radio.rules" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" ' - if ! diff -q /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules >/dev/null 2>&1; then - sudo cp /tmp/99-mesh-radio.rules /etc/udev/rules.d/99-mesh-radio.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --subsystem-match=tty - echo " Mesh radio udev rule installed" - else - echo " Mesh radio udev rule unchanged" - fi - rm -f /tmp/99-mesh-radio.rules - ' 2>/dev/null || true - fi - - # Deploy ALSA default-device config (routes ALSA "default" through - # PulseAudio/PipeWire — without it Chromium's raw ALSA fallback can't - # reach the HDMI sink and kiosk HDMI audio is silent). - ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" - if [ -f "$ASOUND_CONF" ]; then - scp $SSH_OPTS "$ASOUND_CONF" "$TARGET_HOST:/tmp/asound.conf" 2>/dev/null || true - ssh $SSH_OPTS "$TARGET_HOST" ' - if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then - sudo cp /tmp/asound.conf /etc/asound.conf - echo " ALSA default-device config installed" - else - echo " ALSA default-device config unchanged" - fi - rm -f /tmp/asound.conf - ' 2>/dev/null || true - fi - - # Retire the Claude API proxy sidecar (13-02-PLAN.md — closing a live - # production exposure). This used to install/restart a standalone Python - # process on port 3142 holding its OWN copy of ANTHROPIC_API_KEY, reachable - # with no session gate — anyone who could reach the node's web port could - # spend the owner's API budget (T-13-08/T-13-09). AIUI's Claude/Ollama - # calls now route through the Rust daemon (127.0.0.1:5678, see the nginx - # sync above), which enforces the session cookie and reads the node's - # single key ledger (data_dir/secrets/claude-api-key). - # - # This step must run unconditionally on every deploy, not just fresh - # installs: deploying the daemon fix without tearing down an - # already-provisioned node's sidecar leaves the old unauthenticated - # listener running right alongside the new authenticated one. - progress "Removing legacy Claude API proxy sidecar" - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo systemctl stop claude-api-proxy 2>/dev/null || true - sudo systemctl disable claude-api-proxy 2>/dev/null || true - sudo rm -f /etc/systemd/system/claude-api-proxy.service - sudo rm -f /opt/archipelago/claude-api-proxy.py - sudo rm -f /var/lib/archipelago/secrets/claude-api-proxy.env - sudo systemctl daemon-reload 2>/dev/null || true - echo " claude-api-proxy: $(systemctl is-active claude-api-proxy 2>&1)" - ' 2>/dev/null || true - - # Dev mode for Tailscale HTTP access (cookies need Secure flag disabled over plain HTTP) - progress "Configuring dev mode" - ssh $SSH_OPTS "$TARGET_HOST" ' - if [ -f /etc/systemd/system/archipelago.service.d/override.conf ] && grep -q "ARCHIPELAGO_DEV_MODE=true" /etc/systemd/system/archipelago.service.d/override.conf 2>/dev/null; then - echo " Dev mode already enabled" - else - echo " Enabling dev mode (for Tailscale HTTP cookie support)..." - sudo mkdir -p /etc/systemd/system/archipelago.service.d - printf "[Service]\nEnvironment=ARCHIPELAGO_DEV_MODE=true\n" | sudo tee /etc/systemd/system/archipelago.service.d/override.conf > /dev/null - sudo systemctl daemon-reload - echo " Dev mode enabled" - fi - ' 2>/dev/null || true - - # Create data directories for DWN, content sharing, federation, identities - progress "Creating data directories" - ssh $SSH_OPTS "$TARGET_HOST" ' - # Rootless podman: allow binding to ports >= 80 (default is 1024) - if ! grep -q "unprivileged_port_start=80" /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null; then - echo "net.ipv4.ip_unprivileged_port_start=80" | sudo tee /etc/sysctl.d/99-rootless-podman.conf > /dev/null - sudo sysctl -p /etc/sysctl.d/99-rootless-podman.conf 2>/dev/null - echo " Rootless port binding enabled (>=80)" - fi - # Rootless podman: enable lingering for container persistence - if [ "$(loginctl show-user archipelago 2>/dev/null | grep Linger)" != "Linger=yes" ]; then - sudo loginctl enable-linger archipelago - echo " Linger enabled for archipelago user" - fi - # Rootless podman: enable podman socket - systemctl --user enable podman.socket 2>/dev/null || true - systemctl --user start podman.socket 2>/dev/null || true - - sudo mkdir -p /var/lib/archipelago/dwn/messages - sudo mkdir -p /var/lib/archipelago/dwn/protocols - sudo mkdir -p /var/lib/archipelago/content/files - sudo mkdir -p /var/lib/archipelago/federation - sudo mkdir -p /var/lib/archipelago/identity - sudo mkdir -p /var/lib/archipelago/identities - sudo mkdir -p /var/lib/archipelago/tor-config - sudo chown -R archipelago:archipelago /var/lib/archipelago/dwn /var/lib/archipelago/content /var/lib/archipelago/federation /var/lib/archipelago/identity /var/lib/archipelago/identities /var/lib/archipelago/tor-config 2>/dev/null || true - # Fix secrets directory ownership (must be readable by archipelago user, not root) - sudo chown -R archipelago:archipelago /var/lib/archipelago/secrets 2>/dev/null || true - sudo chmod 700 /var/lib/archipelago/secrets 2>/dev/null || true - # Fix any root-owned files in data dir - dead mans switch, sessions, server-name - sudo find /var/lib/archipelago -maxdepth 1 -name "*.json" -user root -exec chown archipelago:archipelago {} \; 2>/dev/null || true - sudo chown archipelago:archipelago /var/lib/archipelago/server-name 2>/dev/null || true - echo " Data directories OK" - - # Rootless podman UID mapping: fix data dir ownership so container processes - # can write. Rootless podman maps container UIDs via subuid (container UID 0 → - # host UID 1000, container UID N → host UID 100000+N). - echo " Fixing rootless podman UID mapping..." - # Containers running as root (UID 0 inside → host UID 100000 via subuid) - for dir in lnd electrumx btcpay nbxplorer immich jellyfin vaultwarden \ - home-assistant fedimint fedimint-gateway photoprism ollama filebrowser; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100000:100000 "/var/lib/archipelago/$dir" 2>/dev/null - done - # Bitcoin Knots: container UID 101 → host UID 100101 - [ -d /var/lib/archipelago/bitcoin ] && sudo chown -R 100101:100101 /var/lib/archipelago/bitcoin 2>/dev/null - # Postgres containers: container UID 70 → host UID 100070 - for dir in postgres-btcpay immich-db; do - [ -d "/var/lib/archipelago/$dir" ] && sudo chown -R 100070:100070 "/var/lib/archipelago/$dir" 2>/dev/null - done - # MariaDB: container UID 999 → host UID 100999 - [ -d /var/lib/archipelago/mempool ] && sudo chown -R 100999:100999 /var/lib/archipelago/mempool 2>/dev/null - # Grafana: container UID 472 → host UID 100472 - [ -d /var/lib/archipelago/grafana ] && sudo chown -R 100472:100472 /var/lib/archipelago/grafana 2>/dev/null - echo " UID mapping done" - ' 2>/dev/null || true - - # Deploy nostr-provider.js for NIP-07 iframe signing (window.nostr support) - progress "Deploying nostr-provider.js" - scp $SSH_OPTS "$PROJECT_DIR/neode-ui/public/nostr-provider.js" "$TARGET_HOST:/tmp/nostr-provider.js" 2>/dev/null && \ - ssh $SSH_OPTS "$TARGET_HOST" 'sudo cp /tmp/nostr-provider.js /opt/archipelago/web-ui/nostr-provider.js && echo " nostr-provider.js deployed"' 2>/dev/null || echo " (nostr-provider.js not found, skipping)" - - # Deploy tor-helper: script + systemd path unit for privileged Tor management - progress "Deploying tor-helper" - scp $SSH_OPTS \ - "$PROJECT_DIR/scripts/tor-helper.sh" \ - "$PROJECT_DIR/image-recipe/configs/archipelago-tor-helper.path" \ - "$PROJECT_DIR/image-recipe/configs/archipelago-tor-helper.service" \ - "$TARGET_HOST:/tmp/" 2>/dev/null && \ - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo mkdir -p /opt/archipelago/scripts - sudo cp /tmp/tor-helper.sh /opt/archipelago/scripts/tor-helper.sh - sudo chmod 755 /opt/archipelago/scripts/tor-helper.sh - sudo chown root:root /opt/archipelago/scripts/tor-helper.sh - sudo cp /tmp/archipelago-tor-helper.path /etc/systemd/system/ - sudo cp /tmp/archipelago-tor-helper.service /etc/systemd/system/ - sudo systemctl daemon-reload - sudo systemctl enable archipelago-tor-helper.path - sudo systemctl start archipelago-tor-helper.path - echo " tor-helper deployed with systemd path unit" - ' 2>/dev/null || echo " (tor-helper deploy skipped)" - - # Sync nginx config (second pass — includes HTTPS snippets) - scp $SSH_OPTS "$PROJECT_DIR/image-recipe/configs/nginx-archipelago.conf" "$TARGET_HOST:/tmp/nginx-archipelago.conf" 2>/dev/null && \ - ssh $SSH_OPTS "$TARGET_HOST" ' - sudo cp /tmp/nginx-archipelago.conf /etc/nginx/sites-available/archipelago - # Also sync HTTPS snippets if they exist - sudo mkdir -p /etc/nginx/snippets - echo " Nginx config synced" - ' 2>/dev/null || echo " (nginx config sync skipped)" - # Sync HTTPS app proxies snippet if it exists - if [ -f "$PROJECT_DIR/image-recipe/configs/snippets/archipelago-https-app-proxies.conf" ]; then - scp $SSH_OPTS "$PROJECT_DIR/image-recipe/configs/snippets/archipelago-https-app-proxies.conf" "$TARGET_HOST:/tmp/https-app-proxies.conf" 2>/dev/null && \ - ssh $SSH_OPTS "$TARGET_HOST" 'sudo cp /tmp/https-app-proxies.conf /etc/nginx/snippets/archipelago-https-app-proxies.conf' 2>/dev/null || true - fi - - # Fix FileBrowser — recreate if read-only root, create if missing - progress "Checking FileBrowser" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - FB_EXISTS=$($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -x filebrowser || true) - if [ -n "$FB_EXISTS" ]; then - RO=$($DOCKER inspect filebrowser 2>/dev/null | grep -oP "\"ReadonlyRootfs\":\s*\K\w+" || echo "false") - if [ "$RO" = "true" ]; then - echo " FileBrowser has read-only root — recreating..." - $DOCKER stop filebrowser 2>/dev/null - $DOCKER rm filebrowser 2>/dev/null - sudo mkdir -p /var/lib/archipelago/filebrowser - $DOCKER run -d --name filebrowser --restart=unless-stopped --user 0:0 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv "$FILEBROWSER_IMAGE" 2>&1 | tail -1 - echo " FileBrowser recreated" - else - echo " FileBrowser OK" - fi - else - echo " Creating FileBrowser..." - sudo mkdir -p /var/lib/archipelago/filebrowser - $DOCKER run -d --name filebrowser --restart=unless-stopped --user 0:0 \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 8083:80 -v /var/lib/archipelago/filebrowser:/srv "$FILEBROWSER_IMAGE" 2>&1 | tail -1 - echo " FileBrowser created" - fi - ' 2>/dev/null || true - - # Write deploy manifest — stamps the server with exactly what was deployed - progress "Writing deploy manifest" - DEPLOY_TS=$(date -u +%Y-%m-%dT%H:%M:%SZ) - ssh $SSH_OPTS "$TARGET_HOST" "sudo tee /opt/archipelago/deploy-manifest.json > /dev/null" << MANIFEST_EOF -{ - "commit": "$DEPLOY_COMMIT_FULL", - "commit_short": "$DEPLOY_COMMIT", - "branch": "$DEPLOY_BRANCH", - "dirty": $DEPLOY_DIRTY, - "deployed_at": "$DEPLOY_TS", - "deployed_from": "$(hostname)", - "target": "$TARGET_HOST" -} -MANIFEST_EOF - - # Write build-info.txt — this is what the UI sidebar reads for the - # displayed version (overrides the binary's CARGO_PKG_VERSION). Keeping - # it synced with Cargo.toml on every deploy prevents the 1.3.x drift - # we saw on .198/.253 where stale build-info survived across upgrades. - DEPLOY_PKG_VERSION=$(grep '^version' "$PROJECT_DIR/core/archipelago/Cargo.toml" | head -1 | sed -E 's/.*"([^"]+)".*/\1/') - ssh $SSH_OPTS "$TARGET_HOST" "sudo tee /opt/archipelago/build-info.txt > /dev/null" << BUILDINFO_EOF -version=$DEPLOY_PKG_VERSION -build=$DEPLOY_TS -commit=$DEPLOY_COMMIT -date=$DEPLOY_TS -type=deployed -BUILDINFO_EOF - - # Ensure NTP and swap are configured (prevents OOM kills and clock drift) - progress "Ensuring NTP + swap" - ssh $SSH_OPTS "$TARGET_HOST" ' - # NTP via chrony - if ! dpkg -l chrony >/dev/null 2>&1; then - sudo rm -f /usr/sbin/policy-rc.d - sudo apt-get update -qq && sudo apt-get install -y chrony 2>/dev/null - fi - sudo systemctl enable chrony 2>/dev/null - sudo systemctl start chrony 2>/dev/null - sudo timedatectl set-ntp true 2>/dev/null - # Swap - if [ ! -f /swapfile ]; then - TOTAL_KB=$(grep MemTotal /proc/meminfo | awk "{print \$2}") - SZ=$((TOTAL_KB / 1024 / 1024)) - [ "$SZ" -gt 8 ] && SZ=8 - [ "$SZ" -lt 2 ] && SZ=2 - sudo fallocate -l ${SZ}G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile - grep -q "/swapfile" /etc/fstab || echo "/swapfile none swap sw 0 0" | sudo tee -a /etc/fstab - echo " Created ${SZ}G swap" - fi - sudo swapon /swapfile 2>/dev/null || true - ' 2>&1 | tail -5 | sed 's/^/ /' || true - - # Ensure backend binds to localhost only (security: no direct LAN access to port 5678) - progress "Securing backend bind address" - ssh $SSH_OPTS "$TARGET_HOST" ' - if grep -q "ARCHIPELAGO_BIND=0.0.0.0" /etc/systemd/system/archipelago.service 2>/dev/null; then - sudo sed -i "s/ARCHIPELAGO_BIND=0.0.0.0:5678/ARCHIPELAGO_BIND=127.0.0.1:5678/" /etc/systemd/system/archipelago.service - sudo systemctl daemon-reload - echo " Fixed: backend now binds to 127.0.0.1 only" - fi - ' 2>/dev/null || true - - # Restart services - progress "Restarting services" - ssh $SSH_OPTS "$TARGET_HOST" "sudo systemctl start archipelago && sudo systemctl restart nginx" - - # Set up HTTPS for PWA installability (browsers require secure context) - progress "Setting up HTTPS" - ssh $SSH_OPTS "$TARGET_HOST" "sudo bash $TARGET_DIR/scripts/setup-https-dev.sh" 2>&1 | sed 's/^/ /' || true - - if [ "$FRONTEND_ONLY" = true ]; then - echo " Skipping container rebuilds (--frontend-only)" - fi - - # App containers are now installed exclusively via the Marketplace UI. - # The deploy script only handles code sync, backend build, and frontend build. - if false; then # Legacy app installation removed — kept for reference in git history - progress "Rebuilding LND UI" - if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/lnd-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t lnd-ui:local . || docker build --no-cache -t lnd-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then - echo " Recreating LND UI container (port 18083)..." - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i lnd-ui); do - [ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null - done - $DOCKER run -d --name archy-lnd-ui -p 18083:80 --memory=256m --restart unless-stopped lnd-ui:local - ' 2>&1 | sed 's/^/ /' || true - fi - - # Rebuild and recreate ElectrumX UI container (port 50002) - progress "Rebuilding ElectrumX UI" - if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/electrs-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t electrs-ui:local . || docker build --no-cache -t electrs-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then - echo " Recreating ElectrumX UI container (port 50002, host network)..." - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i electrs-ui); do - [ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null - done - $DOCKER run -d --name archy-electrs-ui --network host --memory=256m --restart unless-stopped electrs-ui:local - ' 2>&1 | sed 's/^/ /' || true - fi - - # Rebuild and recreate Bitcoin UI container (host network, port 8334 in nginx.conf) - # Host network required: bitcoin-ui proxies Bitcoin RPC at 127.0.0.1:8332 - progress "Rebuilding Bitcoin UI" - # Inject real RPC credentials into bitcoin-ui nginx config before building - ssh $SSH_OPTS "$TARGET_HOST" ' - SECRETS_DIR="/var/lib/archipelago/secrets" - RPC_PASS=$(sudo cat "$SECRETS_DIR/bitcoin-rpc-password" 2>/dev/null) - if [ -n "$RPC_PASS" ]; then - AUTH_B64=$(echo -n "archipelago:${RPC_PASS}" | base64) - sed -i "s|__BITCOIN_RPC_AUTH__|${AUTH_B64}|g" '"$TARGET_DIR"'/docker/bitcoin-ui/nginx.conf - fi - ' 2>/dev/null || true - if ssh $SSH_OPTS "$TARGET_HOST" "cd $TARGET_DIR/docker/bitcoin-ui && (command -v podman >/dev/null 2>&1 && podman build --no-cache -t bitcoin-ui:local . || docker build --no-cache -t bitcoin-ui:local .)" 2>&1 | tail -12 | sed 's/^/ /'; then - echo " Recreating Bitcoin UI container (port 8334, host network)..." - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - for c in $($DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -i bitcoin-ui); do - [ -n "$c" ] && $DOCKER stop "$c" 2>/dev/null; $DOCKER rm -f "$c" 2>/dev/null - done - $DOCKER run -d --name archy-bitcoin-ui --network host --memory=256m --restart unless-stopped bitcoin-ui:local - ' 2>&1 | sed 's/^/ /' || true - fi - - # Bitcoin Knots: required for Mempool, ElectrumX, BTCPay, Fedimint - TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)" - - # Read Bitcoin RPC credentials from secrets file (rpcauth — stable across restarts) - progress "Reading Bitcoin RPC credentials" - BITCOIN_RPC_PASS=$(ssh $SSH_OPTS "$TARGET_HOST" ' - SECRETS_DIR="/var/lib/archipelago/secrets" - sudo mkdir -p "$SECRETS_DIR" && sudo chmod 700 "$SECRETS_DIR" - if [ ! -f "$SECRETS_DIR/bitcoin-rpc-password" ]; then - openssl rand -hex 16 | sudo tee "$SECRETS_DIR/bitcoin-rpc-password" > /dev/null - sudo chmod 600 "$SECRETS_DIR/bitcoin-rpc-password" - fi - sudo cat "$SECRETS_DIR/bitcoin-rpc-password" - ' 2>/dev/null) - BITCOIN_RPC_USER="archipelago" - if [ -z "$BITCOIN_RPC_PASS" ]; then - echo " WARNING: Could not read Bitcoin RPC password from server" - return 1 - fi - - # Read per-installation database passwords from server secrets - DB_PASSWORDS=$(ssh $SSH_OPTS "$TARGET_HOST" ' - SECRETS_DIR="/var/lib/archipelago/secrets" - for svc in mempool btcpay immich penpot mysql-root; do - if [ ! -f "$SECRETS_DIR/${svc}-db-password" ]; then - openssl rand -base64 24 | sudo tee "$SECRETS_DIR/${svc}-db-password" > /dev/null - sudo chmod 600 "$SECRETS_DIR/${svc}-db-password" - fi - done - echo "MEMPOOL_DB_PASS=$(sudo cat "$SECRETS_DIR/mempool-db-password")" - echo "BTCPAY_DB_PASS=$(sudo cat "$SECRETS_DIR/btcpay-db-password")" - echo "IMMICH_DB_PASS=$(sudo cat "$SECRETS_DIR/immich-db-password")" - echo "PENPOT_DB_PASS=$(sudo cat "$SECRETS_DIR/penpot-db-password")" - echo "MYSQL_ROOT_PASS=$(sudo cat "$SECRETS_DIR/mysql-root-db-password")" - # FED-07: no shipped fallback, ever. The canonical per-install gateway - # credential (fedimint-gateway-hash / .pw) is generated by the daemon - # via container::secrets::ensure_gateway_credential — this deploy script no - # longer generates it (removes the htpasswd host dependency too). - # Legacy migration only: carry an existing fedimint-gateway-password - # value forward to the canonical fedimint-gateway-hash.pw name if that - # name does not exist yet; never regenerate a working credential, and - # never delete the legacy file (plan 01-16 owns retirement). - if [ -f "$SECRETS_DIR/fedimint-gateway-password" ] && [ ! -f "$SECRETS_DIR/fedimint-gateway-hash.pw" ]; then - sudo cp "$SECRETS_DIR/fedimint-gateway-password" "$SECRETS_DIR/fedimint-gateway-hash.pw" - sudo chmod 600 "$SECRETS_DIR/fedimint-gateway-hash.pw" - fi - if [ -f "$SECRETS_DIR/fedimint-gateway-hash" ]; then - echo "FEDI_HASH=$(sudo cat "$SECRETS_DIR/fedimint-gateway-hash")" - fi - ' 2>/dev/null) - # Safe variable parsing — never eval untrusted SSH output - while IFS='=' read -r key value; do - # Skip empty lines - [ -z "$key" ] && continue - # Only allow expected variable names - case "$key" in - MEMPOOL_DB_PASS) MEMPOOL_DB_PASS="$value" ;; - BTCPAY_DB_PASS) BTCPAY_DB_PASS="$value" ;; - IMMICH_DB_PASS) IMMICH_DB_PASS="$value" ;; - PENPOT_DB_PASS) PENPOT_DB_PASS="$value" ;; - MYSQL_ROOT_PASS) MYSQL_ROOT_PASS="$value" ;; - FEDI_HASH) FEDI_HASH="$value" ;; - *) echo " WARNING: Ignoring unexpected variable from server: $key" ;; - esac - done <<< "$DB_PASSWORDS" - # FED-07: no fallback literal. If the target hasn't generated its - # per-install gateway credential yet, FEDI_HASH stays empty and the - # Fedimint Gateway creation step below is skipped (not substituted). - if [ -z "${FEDI_HASH:-}" ]; then - echo " NOTE: no fedimint-gateway credential on target yet — gateway container creation will be skipped (no shipped default; the daemon generates one on next install/reconcile)" - fi - - progress "Ensuring Bitcoin Knots" - ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - \$DOCKER network create archy-net 2>/dev/null || true - NET_OPT='--network archy-net' - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -qE 'bitcoin-knots|archy-bitcoin-knots'; then - echo ' Creating Bitcoin Knots (mainnet, archipelago RPC)...' - sudo mkdir -p /var/lib/archipelago/bitcoin - # Demo mode: prune=550 saves ~194GB disk, but disables txindex (incompatible with electrumx) - if [ "$DEMO" = "true" ]; then - BTC_EXTRA_ARGS="-prune=550" - BTC_DBCACHE=512 - else - BTC_EXTRA_ARGS="-txindex=1" - BTC_DBCACHE=4096 - fi - \$DOCKER run -d --name bitcoin-knots --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8332:8332 -p 8333:8333 \ - -v /var/lib/archipelago/bitcoin:/home/bitcoin/.bitcoin \ - ${BITCOIN_KNOTS_IMAGE} \ - -server=1 \$BTC_EXTRA_ARGS \ - -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 \ - -dbcache=\$BTC_DBCACHE - echo ' Bitcoin Knots started (sync may take hours)' - else - \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true - fi - " 2>&1 | sed 's/^/ /' || true - - # Fix Mempool: clean duplicates, ensure full stack - mysql, backend (8999), frontend (4080) - progress "Fixing Mempool stack" - ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - TARGET_IP='$TARGET_IP' - NET_OPT='--network archy-net' - # Clean any duplicate/old mempool containers (user may have two versions) - # EXCLUDE electrumx/mempool-electrs - indexing takes days, do not recreate on every deploy - for c in mempool mempool-api mempool-web archy-mempool-api archy-mempool-web; do - \$DOCKER stop \$c 2>/dev/null - \$DOCKER rm -f \$c 2>/dev/null - done - # Create mysql-mempool if missing - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'mysql-mempool|archy-mempool-db'; then - echo ' Creating mysql-mempool...' - sudo mkdir -p /var/lib/archipelago/mysql-mempool - \$DOCKER run -d --name archy-mempool-db --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -v /var/lib/archipelago/mysql-mempool:/var/lib/mysql \ - -e MYSQL_DATABASE=mempool \ - -e MYSQL_USER=mempool \ - -e MYSQL_PASSWORD=$MEMPOOL_DB_PASS \ - -e MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASS \ - "$MARIADB_IMAGE" - sleep 3 - fi - MYSQL_CNT=\$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mysql-mempool|archy-mempool-db' | head -1) - MYSQL_CNT=\${MYSQL_CNT:-archy-mempool-db} - # Ensure DB is on archy-net so mempool-api can resolve it - \$DOCKER network connect archy-net \$MYSQL_CNT 2>/dev/null || true - # Stop and remove old mempool-electrs if present (replaced by electrumx) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q mempool-electrs; then - echo ' Removing old mempool-electrs (replaced by ElectrumX)...' - \$DOCKER stop mempool-electrs 2>/dev/null - \$DOCKER rm -f mempool-electrs 2>/dev/null - fi - # Create electrumx ONLY if missing - do NOT recreate (indexing takes days) - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q electrumx; then - echo ' Starting existing electrumx (preserving index)...' - \$DOCKER start electrumx 2>/dev/null || true - else - echo ' Creating electrumx (indexer - may take days to sync, do not recreate)...' - sudo mkdir -p /var/lib/archipelago/electrumx - \$DOCKER run -d --name electrumx --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 50001:50001 \ - -v /var/lib/archipelago/electrumx:/data \ - -e DAEMON_URL=http://$BITCOIN_RPC_USER:$BITCOIN_RPC_PASS@bitcoin-knots:8332/ \ - -e COIN=Bitcoin \ - -e DB_DIRECTORY=/data \ - -e SERVICES=tcp://:50001,rpc://0.0.0.0:8000 \ - "$ELECTRUMX_IMAGE" - fi - fi - # Create/recreate mempool-api (backend on 8999) - required for mempool to work - for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'mempool-api|archy-mempool-api'); do - echo ' Recreating mempool-api (backend)...' - \$DOCKER stop \"\$c\" 2>/dev/null - \$DOCKER rm -f \"\$c\" 2>/dev/null - done - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q mempool-api; then - echo ' Creating mempool-api (backend)...' - sudo mkdir -p /var/lib/archipelago/mempool - \$DOCKER run -d --name mempool-api --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 8999:8999 \ - -v /var/lib/archipelago/mempool:/data \ - -e MEMPOOL_BACKEND=electrum \ - -e ELECTRUM_HOST=electrumx \ - -e ELECTRUM_PORT=50001 \ - -e ELECTRUM_TLS_ENABLED=false \ - -e CORE_RPC_HOST=\$TARGET_IP \ - -e CORE_RPC_PORT=8332 \ - -e CORE_RPC_USERNAME=archipelago \ - -e CORE_RPC_PASSWORD=$BITCOIN_RPC_PASS \ - -e DATABASE_ENABLED=true \ - -e DATABASE_HOST=\$MYSQL_CNT \ - -e DATABASE_DATABASE=mempool \ - -e DATABASE_USERNAME=mempool \ - -e DATABASE_PASSWORD=$MEMPOOL_DB_PASS \ - "$MEMPOOL_BACKEND_IMAGE" - fi - # Recreate mempool frontend - handle both 'mempool' and 'mempool-web' (frontend was on wrong port 8999) - for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^mempool\$|mempool-web|archy-mempool-web'); do - echo ' Recreating mempool frontend on 4080...' - \$DOCKER stop \"\$c\" 2>/dev/null - \$DOCKER rm -f \"\$c\" 2>/dev/null - break - done - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-mempool-web; then - echo ' Creating mempool frontend on 4080...' - \$DOCKER run -d --name archy-mempool-web --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 4080:8080 \ - -e FRONTEND_HTTP_PORT=8080 \ - -e BACKEND_MAINNET_HTTP_HOST=mempool-api \ - "$MEMPOOL_WEB_IMAGE" - fi - " 2>&1 | sed 's/^/ /' || true - - # Fix BTCPay Server: requires PostgreSQL + NBXplorer (BTCPay needs NBXplorer for block indexing) - progress "Fixing BTCPay stack" - TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)" - ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - TARGET_IP='$TARGET_IP' - \$DOCKER network create archy-net 2>/dev/null || true - NET_OPT='--network archy-net' - # Ensure bitcoin-knots is on archy-net for NBXplorer/BTCPay to reach it - \$DOCKER network connect archy-net bitcoin-knots 2>/dev/null || true - # Create PostgreSQL for BTCPay if missing - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qE 'archy-btcpay-db|postgres-btcpay'; then - echo ' Creating archy-btcpay-db (PostgreSQL)...' - sudo mkdir -p /var/lib/archipelago/postgres-btcpay - \$DOCKER run -d --name archy-btcpay-db --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -v /var/lib/archipelago/postgres-btcpay:/var/lib/postgresql/data \ - -e POSTGRES_DB=btcpay \ - -e POSTGRES_USER=btcpay \ - -e POSTGRES_PASSWORD=$BTCPAY_DB_PASS \ - "$BTCPAY_POSTGRES_IMAGE" - sleep 3 - fi - # Create NBXplorer database in PostgreSQL (NBXplorer needs its own DB) - \$DOCKER exec archy-btcpay-db psql -U postgres -tc \"SELECT 1 FROM pg_database WHERE datname='nbxplorer'\" 2>/dev/null | grep -q 1 || \ - \$DOCKER exec -e PGPASSWORD=$BTCPAY_DB_PASS archy-btcpay-db psql -U postgres -c \"CREATE DATABASE nbxplorer;\" 2>/dev/null || true - # Create NBXplorer (required by BTCPay - indexes blocks for payment tracking) - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q archy-nbxplorer; then - \$DOCKER start archy-nbxplorer 2>/dev/null || true - else - echo ' Creating archy-nbxplorer...' - sudo mkdir -p /var/lib/archipelago/nbxplorer - \$DOCKER run -d --name archy-nbxplorer --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 32838:32838 \ - -v /var/lib/archipelago/nbxplorer:/data \ - -e NBXPLORER_DATADIR=/data \ - -e NBXPLORER_NETWORK=mainnet \ - -e NBXPLORER_CHAINS=btc \ - -e NBXPLORER_BIND=0.0.0.0:32838 \ - -e NBXPLORER_BTCRPCURL=http://bitcoin-knots:8332 \ - -e NBXPLORER_BTCRPCUSER=$BITCOIN_RPC_USER \ - -e NBXPLORER_BTCRPCPASSWORD=$BITCOIN_RPC_PASS \ - -e NBXPLORER_POSTGRES='User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=nbxplorer;Include Error Detail=true' \ - "$NBXPLORER_IMAGE" - sleep 5 - fi - fi - # Recreate btcpay-server with PostgreSQL, NBXplorer URL, and Bitcoin RPC - for c in btcpay-server archy-btcpay; do - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx \"\$c\"; then - echo ' Recreating btcpay-server with NBXplorer...' - \$DOCKER stop \"\$c\" 2>/dev/null - \$DOCKER rm -f \"\$c\" 2>/dev/null - fi - done - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q btcpay-server; then - echo ' Creating btcpay-server on 23000...' - sudo mkdir -p /var/lib/archipelago/btcpay - \$DOCKER run -d --name btcpay-server --restart unless-stopped \$NET_OPT \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 23000:49392 \ - -v /var/lib/archipelago/btcpay:/datadir \ - -e ASPNETCORE_URLS=http://0.0.0.0:49392 \ - -e BTCPAY_PROTOCOL=http \ - -e BTCPAY_HOST=\$TARGET_IP:23000 \ - -e BTCPAY_CHAINS=btc \ - -e BTCPAY_BTCEXPLORERURL=http://archy-nbxplorer:32838 \ - -e BTCPAY_BTCRPCURL=http://bitcoin-knots:8332 \ - -e BTCPAY_BTCRPCUSER=archipelago \ - -e BTCPAY_BTCRPCPASSWORD=$BITCOIN_RPC_PASS \ - -e BTCPAY_POSTGRES='User ID=btcpay;Password=$BTCPAY_DB_PASS;Host=archy-btcpay-db;Port=5432;Database=btcpay;Include Error Detail=true' \ - "$BTCPAY_IMAGE" - fi - " 2>&1 | sed 's/^/ /' || true - - # Ensure Immich stack (postgres + redis + server) - creates if missing - progress "Ensuring Immich stack" - ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - # Remove old single-container 'immich' if present (wrong port mapping, conflicts with immich_server) - if \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx immich; then - echo ' Removing old immich container (use immich_server)...' - \$DOCKER stop immich 2>/dev/null - \$DOCKER rm -f immich 2>/dev/null - \$DOCKER start immich_server 2>/dev/null || true - fi - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then - echo ' Creating Immich stack...' - sudo mkdir -p /var/lib/archipelago/immich /var/lib/archipelago/immich-db - \$DOCKER network create immich-net 2>/dev/null || true - if ! \$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -q immich_postgres; then - \$DOCKER run -d --name immich_postgres --restart unless-stopped --network immich-net \ - -v /var/lib/archipelago/immich-db:/var/lib/postgresql/data \ - -e POSTGRES_PASSWORD=$IMMICH_DB_PASS -e POSTGRES_USER=postgres -e POSTGRES_DB=immich \ - "$IMMICH_POSTGRES_IMAGE" 2>/dev/null || true - sleep 5 - fi - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_redis; then - \$DOCKER run -d --name immich_redis --restart unless-stopped --network immich-net \ - "$VALKEY_IMAGE" 2>/dev/null || true - sleep 2 - fi - if ! \$DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q immich_server; then - \$DOCKER run -d --name immich_server --restart unless-stopped --network immich-net \ - -p 2283:2283 -v /var/lib/archipelago/immich:/usr/src/app/upload \ - -e DB_HOSTNAME=immich_postgres -e DB_USERNAME=postgres -e DB_PASSWORD=$IMMICH_DB_PASS \ - -e DB_DATABASE_NAME=immich -e REDIS_HOSTNAME=immich_redis \ - -e UPLOAD_LOCATION=/usr/src/app/upload \ - "$IMMICH_SERVER_IMAGE" 2>/dev/null || true - fi - echo ' Immich stack created (may take 1-2 min to become ready)' - else - echo ' Immich already running' - fi - " 2>&1 | sed 's/^/ /' || true - - # Tor: global hidden services - each service gets its own .onion address - progress "Setting up Tor" - TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)" - ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - TARGET_IP='$TARGET_IP' - sudo mkdir -p /var/lib/archipelago/tor - - # Ensure services.json exists with default services - SERVICES_JSON=/var/lib/archipelago/tor/services.json - if [ ! -f "\$SERVICES_JSON" ]; then - sudo python3 -c ' -import json -services = [ - {"name": "archipelago", "local_port": 80, "enabled": True}, - {"name": "bitcoin", "local_port": 8333, "enabled": True}, - {"name": "electrumx", "local_port": 50001, "enabled": True}, - {"name": "lnd", "local_port": 9735, "enabled": True}, - {"name": "btcpay", "local_port": 23000, "enabled": True}, - {"name": "mempool", "local_port": 4080, "enabled": True}, - {"name": "fedimint", "local_port": 8175, "enabled": True} -] -with open("/var/lib/archipelago/tor/services.json", "w") as f: - json.dump({"services": services}, f, indent=2) -print("services.json created") -' - fi - - # Generate torrc from services.json — use /var/lib/tor/ for hidden services - sudo python3 -c ' -import json, os - -# Protocol services get direct port mapping; web apps map port 80 to their local port -PROTOCOL_SERVICES = {"bitcoin", "bitcoin-knots", "electrs", "electrumx", "lnd"} - -lines = ["# Auto-generated by Archipelago deploy", "SocksPort 0.0.0.0:9050", "# ControlPort disabled", ""] - -# Try reading services config (check both paths for compatibility) -cfg = None -for path in ["/var/lib/archipelago/tor-config/services.json", "/var/lib/archipelago/tor/services.json"]: - try: - with open(path) as f: - cfg = json.load(f) - break - except Exception: - pass - -if cfg: - for svc in cfg.get("services", []): - if not svc.get("enabled", True): - continue - n = svc["name"] - p = svc["local_port"] - lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n) - if n in PROTOCOL_SERVICES: - # Protocol: direct port mapping - lines.append("HiddenServicePort %d 127.0.0.1:%d" % (p, p)) - if n == "lnd": - lines.append("HiddenServicePort 9735 127.0.0.1:9735") - lines.append("HiddenServicePort 10009 127.0.0.1:10009") - else: - # Web app: map port 80 on .onion to local app port (access via app.onion without port) - lines.append("HiddenServicePort 80 127.0.0.1:%d" % p) - lines.append("") -else: - # Fallback: default services - for n, mappings in [("archipelago",[(80,80)]),("bitcoin",[(8333,8333)]),("electrs",[(50001,50001)]),("lnd",[(8080,8080),(9735,9735),(10009,10009)]),("btcpay",[(80,23000)]),("mempool",[(80,4080)]),("fedimint",[(80,8175)])]: - lines.append("HiddenServiceDir /var/lib/tor/hidden_service_%s" % n) - for remote_p, local_p in mappings: - lines.append("HiddenServicePort %d 127.0.0.1:%d" % (remote_p, local_p)) - lines.append("") - -with open("/etc/tor/torrc", "w") as f: - f.write("\n".join(lines) + "\n") -enabled = sum(1 for s in (cfg or {}).get("services", []) if s.get("enabled", True)) -print("torrc generated with %d services" % (enabled or 7)) -' - - # Remove any old Tor container (system Tor is preferred) - for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E 'archy-tor|^tor\$'); do - \$DOCKER stop \"\$c\" 2>/dev/null - \$DOCKER rm -f \"\$c\" 2>/dev/null - done - - # Use system Tor (preferred — no AppArmor issues with default paths) - if command -v tor >/dev/null 2>&1; then - sudo systemctl enable tor 2>/dev/null - sudo systemctl enable tor@default 2>/dev/null - sudo systemctl restart tor 2>/dev/null - sudo systemctl restart tor@default 2>/dev/null - echo ' Using system Tor daemon' - else - echo ' Installing system Tor...' - sudo apt-get update -qq && sudo apt-get install -y -qq tor 2>/dev/null || true - if command -v tor >/dev/null 2>&1; then - sudo systemctl enable tor 2>/dev/null - sudo systemctl enable tor@default 2>/dev/null - sudo systemctl restart tor 2>/dev/null - sudo systemctl restart tor@default 2>/dev/null - echo ' System Tor installed and started' - else - echo ' WARNING: Could not install Tor' - fi - fi - " 2>&1 | sed 's/^/ /' || true - - # Tor diagnostic: check if hostname files exist (may take 30-60s after Tor starts) - echo " Checking Tor hostname files..." - ssh $SSH_OPTS "$TARGET_HOST" " - # Check all hidden_service_* dirs for hostname files (check both paths) - for dir in /var/lib/tor/hidden_service_*/ /var/lib/archipelago/tor/hidden_service_*/; do - [ -d \"\$dir\" ] || continue - svc=\$(basename \"\$dir\" | sed 's/hidden_service_//') - f=\"\${dir}hostname\" - if [ -f \"\$f\" ]; then - echo \" ✓ \$svc: \$(cat \$f)\" - else - echo \" ✗ \$svc: hostname not yet generated (Tor may need 30-60s)\" - fi - done - " 2>&1 | sed 's/^/ /' || true - - # Recreate Fedimint with FM_API_URL for Guardian UI (fixes "Api URL must be configured") - section_start - progress "Fixing Fedimint" - TARGET_IP="$(echo "$TARGET_HOST" | cut -d@ -f2)" - TIMEOUT_CMD="" - command -v timeout >/dev/null 2>&1 && TIMEOUT_CMD="timeout 90" - command -v gtimeout >/dev/null 2>&1 && TIMEOUT_CMD="gtimeout 90" - ($TIMEOUT_CMD ssh $SSH_OPTS "$TARGET_HOST" " - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - for c in \$(\$DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -E '^fedimint\$'); do - echo ' Recreating fedimint with FM_API_URL...' - \$DOCKER stop \"\$c\" 2>/dev/null - \$DOCKER rm -f \"\$c\" 2>/dev/null - \$DOCKER run -d --name fedimint --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8173:8173 -p 8174:8174 -p 8175:8175 \ - -v /var/lib/archipelago/fedimint:/data \ - -e FM_DATA_DIR=/data \ - -e FM_BITCOIND_USERNAME=archipelago \ - -e FM_BITCOIND_PASSWORD=$BITCOIN_RPC_PASS \ - -e FM_BITCOIN_NETWORK=bitcoin \ - -e FM_BIND_P2P=0.0.0.0:8173 \ - -e FM_BIND_API=0.0.0.0:8174 \ - -e FM_BIND_UI=0.0.0.0:8175 \ - -e FM_P2P_URL=fedimint://$TARGET_IP:8173 \ - -e FM_API_URL=ws://$TARGET_IP:8174 \ - -e FM_BITCOIND_URL=http://$TARGET_IP:8332 \ - "$FEDIMINT_IMAGE" - break - done - - # Ensure Fedimint Gateway companion container - # Auto-detect LND: if running with credentials, use lnd mode; otherwise use ldk (built-in) - # FED-07: no shipped fallback — if no per-install credential was read - # back from this target above, do not create/recreate the gateway - # container at all (an empty --bcrypt-password-hash is never passed). - if [ -n '$FEDI_HASH' ]; then - \$DOCKER rm -f fedimint-gateway 2>/dev/null || true - echo ' Creating fedimint-gateway...' - sudo mkdir -p /var/lib/archipelago/fedimint-gateway - LND_CERT=/var/lib/archipelago/lnd/tls.cert - LND_MACAROON=/var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon - if \$DOCKER ps --format '{{.Names}}' | grep -q '^lnd\$' && sudo test -f \$LND_CERT && sudo test -f \$LND_MACAROON; then - echo ' LND detected — using lnd mode' - \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8176:8176 \ - -v /var/lib/archipelago/fedimint-gateway:/data \ - -v /var/lib/archipelago/lnd/tls.cert:/lnd/tls.cert:ro \ - -v /var/lib/archipelago/lnd/data/chain/bitcoin/mainnet/admin.macaroon:/lnd/admin.macaroon:ro \ - "$FEDIMINT_GATEWAY_IMAGE" \ - gatewayd --data-dir /data --listen 0.0.0.0:8176 \ - --bcrypt-password-hash '$FEDI_HASH' \ - --network bitcoin --bitcoind-url http://$TARGET_IP:8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ - lnd --lnd-rpc-host $TARGET_IP:10009 --lnd-tls-cert /lnd/tls.cert --lnd-macaroon /lnd/admin.macaroon - else - echo ' No LND found — using ldk (built-in Lightning)' - \$DOCKER run -d --name fedimint-gateway --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8176:8176 -p 9737:9737 \ - -v /var/lib/archipelago/fedimint-gateway:/data \ - "$FEDIMINT_GATEWAY_IMAGE" \ - gatewayd --data-dir /data --listen 0.0.0.0:8176 \ - --bcrypt-password-hash '$FEDI_HASH' \ - --network bitcoin --bitcoind-url http://$TARGET_IP:8332 \ - --bitcoind-username $BITCOIN_RPC_USER --bitcoind-password $BITCOIN_RPC_PASS \ - ldk --ldk-lightning-port 9737 --ldk-alias archipelago-gateway - fi - else - echo ' Skipping fedimint-gateway — no per-install credential on target yet (no shipped default; will create it on a future deploy/reconcile once one is generated)' - fi - " 2>&1 | sed 's/^/ /') || echo " (Fedimint fix timed out or skipped - run manually if needed)" - section_end - - # LND: Lightning Network Daemon (requires bitcoin-knots on archy-net) - progress "Ensuring LND" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx lnd; then - $DOCKER start lnd 2>/dev/null || true - echo " LND started (existing)" - else - echo " Creating LND..." - sudo mkdir -p /var/lib/archipelago/lnd - if [ ! -f /var/lib/archipelago/lnd/lnd.conf ]; then - cat > /tmp/lnd.conf </dev/null | cut -d= -f2) - NEEDS_FIX=0 - grep -q "rpccookie" "$LND_CONF" 2>/dev/null && NEEDS_FIX=1 - grep -q "rpchost=127.0.0.1" "$LND_CONF" 2>/dev/null && NEEDS_FIX=1 - RPC_PASS_EXPECTED=$(sudo cat /var/lib/archipelago/secrets/bitcoin-rpc-password 2>/dev/null) - [ "$CURRENT_PASS" != "$RPC_PASS_EXPECTED" ] && NEEDS_FIX=1 - if [ "$NEEDS_FIX" = "1" ]; then - echo " Syncing LND config with current RPC credentials..." - sudo sed -i "/bitcoind.rpccookie/d" "$LND_CONF" - sudo sed -i "s|bitcoind.rpchost=127.0.0.1:8332|bitcoind.rpchost=bitcoin-knots:8332|" "$LND_CONF" - sudo sed -i "s|bitcoind.rpcpass=.*|bitcoind.rpcpass=$RPC_PASS_EXPECTED|" "$LND_CONF" - if ! sudo grep -q "bitcoind.rpcuser=" "$LND_CONF" 2>/dev/null; then - sudo sed -i "/bitcoind.rpchost=/a bitcoind.rpcuser=archipelago" "$LND_CONF" - fi - if ! sudo grep -q "bitcoind.rpcpass=" "$LND_CONF" 2>/dev/null; then - sudo sed -i "/bitcoind.rpcuser=/a bitcoind.rpcpass=$RPC_PASS_EXPECTED" "$LND_CONF" - fi - sudo chown 100000:100000 "$LND_CONF" - RESTART_LND=1 - echo " LND config updated" - fi - fi - $DOCKER run -d --name lnd --restart unless-stopped --network archy-net \ - --cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 9735:9735 -p 10009:10009 -p 18080:8080 \ - -v /var/lib/archipelago/lnd:/root/.lnd \ - "$LND_IMAGE" - echo " LND created" - fi - else - echo " LND already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Home Assistant - progress "Ensuring Home Assistant" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx homeassistant; then - $DOCKER start homeassistant 2>/dev/null || true - else - echo " Creating Home Assistant..." - sudo mkdir -p /var/lib/archipelago/home-assistant - $DOCKER run -d --name homeassistant --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE \ - --security-opt no-new-privileges:true \ - -p 8123:8123 -v /var/lib/archipelago/home-assistant:/config \ - -e TZ=UTC \ - "$HOMEASSISTANT_IMAGE" - fi - else - echo " Home Assistant already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Grafana - progress "Ensuring Grafana" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx grafana; then - $DOCKER start grafana 2>/dev/null || true - else - echo " Creating Grafana..." - sudo mkdir -p /var/lib/archipelago/grafana - sudo chown 472:472 /var/lib/archipelago/grafana 2>/dev/null || true - $DOCKER run -d --name grafana --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID \ - --security-opt no-new-privileges:true \ - -p 3000:3000 -v /var/lib/archipelago/grafana:/var/lib/grafana \ - -e GF_PATHS_DATA=/var/lib/grafana -e GF_USERS_ALLOW_SIGN_UP=false \ - "$GRAFANA_IMAGE" - fi - else - echo " Grafana already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Jellyfin - progress "Ensuring Jellyfin" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx jellyfin; then - $DOCKER start jellyfin 2>/dev/null || true - else - echo " Creating Jellyfin..." - sudo mkdir -p /var/lib/archipelago/jellyfin/config /var/lib/archipelago/jellyfin/cache - $DOCKER run -d --name jellyfin --restart unless-stopped \ - --cap-drop ALL --security-opt no-new-privileges:true \ - -p 8096:8096 \ - -v /var/lib/archipelago/jellyfin/config:/config \ - -v /var/lib/archipelago/jellyfin/cache:/cache \ - "$JELLYFIN_IMAGE" - fi - else - echo " Jellyfin already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Vaultwarden - progress "Ensuring Vaultwarden" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx vaultwarden; then - $DOCKER start vaultwarden 2>/dev/null || true - else - echo " Creating Vaultwarden..." - sudo mkdir -p /var/lib/archipelago/vaultwarden - $DOCKER run -d --name vaultwarden --restart unless-stopped \ - --cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \ - --security-opt no-new-privileges:true \ - -p 8082:80 -v /var/lib/archipelago/vaultwarden:/data \ - "$VAULTWARDEN_IMAGE" - fi - else - echo " Vaultwarden already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # SearXNG (privacy search engine — used by AIUI web search) - progress "Ensuring SearXNG" - ssh $SSH_OPTS "$TARGET_HOST" ' - DOCKER=podman - command -v podman >/dev/null 2>&1 || DOCKER=docker - if ! $DOCKER ps --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then - if $DOCKER ps -a --format "{{.Names}}" 2>/dev/null | grep -qx searxng; then - $DOCKER start searxng 2>/dev/null || true - else - echo " Creating SearXNG..." - $DOCKER run -d --name searxng --restart unless-stopped \ - --cap-drop ALL --security-opt no-new-privileges:true \ - -p 8888:8080 \ - ${SEARXNG_IMAGE} - fi - else - echo " SearXNG already running" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Ollama — optional, install from marketplace if needed - # (removed from auto-deploy: large image, not needed for core functionality) - - fi # end legacy app installation (dead code, kept for git history) - - # Ensure UFW allows forwarded traffic (required for podman container port access from LAN) - progress "Fixing UFW forward policy" - ssh $SSH_OPTS "$TARGET_HOST" ' - if grep -q "DEFAULT_FORWARD_POLICY=\"DROP\"" /etc/default/ufw 2>/dev/null; then - sudo sed -i "s/DEFAULT_FORWARD_POLICY=\"DROP\"/DEFAULT_FORWARD_POLICY=\"ACCEPT\"/" /etc/default/ufw - sudo ufw reload 2>/dev/null - echo " Fixed UFW forward policy (was DROP, now ACCEPT)" - fi - ' 2>&1 | sed 's/^/ /' || true - - # Fix IndeedHub for iframe + NIP-07: remove X-Frame-Options, inject nostr-provider.js, - # resolve container IPs for nginx proxy (DNS resolver 127.0.0.11 is unreliable in podman) - progress "Fixing IndeedHub for NIP-07" - ssh $SSH_OPTS "$TARGET_HOST" ' - podman_quick() { timeout 20 podman "$@"; } - podman_exec_quick() { timeout 20 podman exec "$@"; } - - if podman_quick ps --format "{{.Names}}" 2>/dev/null | grep -q "^indeedhub$"; then - CHANGED=false - NETWORK=$(podman_quick inspect indeedhub --format "{{range \$k, \$v := .NetworkSettings.Networks}}{{\$k}}{{end}}" 2>/dev/null || true) - - # Remove X-Frame-Options so iframe works - if podman_exec_quick indeedhub grep -q "X-Frame-Options" /etc/nginx/conf.d/default.conf 2>/dev/null; then - podman_exec_quick indeedhub sed -i "/X-Frame-Options/d" /etc/nginx/conf.d/default.conf || true - CHANGED=true - echo " Removed X-Frame-Options from IndeedHub" - fi - - # Fix Host header for NIP-98 auth — $host strips port, $http_host preserves it - podman_exec_quick indeedhub sh -c "sed -i '"'"'s/proxy_set_header Host \$host;/proxy_set_header Host \$http_host;/g'"'"' /etc/nginx/conf.d/default.conf" 2>/dev/null && CHANGED=true && echo " Fixed Host header for NIP-98 auth" || true - - # Inject nostr-provider.js for NIP-07 signing - if ! podman_exec_quick indeedhub test -f /usr/share/nginx/html/nostr-provider.js 2>/dev/null; then - podman_quick cp /opt/archipelago/web-ui/nostr-provider.js indeedhub:/usr/share/nginx/html/nostr-provider.js 2>/dev/null || true - echo " Copied nostr-provider.js into IndeedHub" - fi - - # Add nostr-provider.js + sub_filter to nginx config - if ! podman_exec_quick indeedhub grep -q "nostr-provider" /etc/nginx/conf.d/default.conf 2>/dev/null; then - podman_exec_quick indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null || true - # Add nostr-provider location block before sw.js block - sed -i "/location = \/sw.js {/i\\ location = /nostr-provider.js {\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\n expires off;\n }\n" /tmp/ih-nginx.conf - # Add sub_filter for nostr-provider injection - sed -i "/try_files.*index.html/a\\ sub_filter_once on;\n sub_filter '"'"''"'"' '"'"''"'"';" /tmp/ih-nginx.conf - podman_quick cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true - rm -f /tmp/ih-nginx.conf - CHANGED=true - echo " Injected nostr-provider.js into IndeedHub nginx" - fi - - # Replace DNS-based upstream resolution with hardcoded container IPs - # (podman DNS resolver 127.0.0.11 is unreliable, causing 502 errors) - API_IP=$(podman_quick inspect indeedhub-build_api_1 --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null || true) - MINIO_IP=$(podman_quick inspect indeedhub-minio --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null || true) - RELAY_IP=$(podman_quick inspect indeedhub-relay --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" 2>/dev/null || true) - - if [ -n "$API_IP" ] && [ -n "$MINIO_IP" ] && [ -n "$RELAY_IP" ]; then - podman_exec_quick indeedhub cat /etc/nginx/conf.d/default.conf > /tmp/ih-nginx.conf 2>/dev/null || true - # Remove DNS resolver lines and replace upstream variables with hardcoded IPs - sed -i "s|resolver 127.0.0.11 valid=30s ipv6=off;||g" /tmp/ih-nginx.conf - sed -i "s|set \$api_upstream http://api:4000;|set \$api_upstream http://$API_IP:4000;|g" /tmp/ih-nginx.conf - sed -i "s|set \$minio_upstream http://minio:9000;|set \$minio_upstream http://$MINIO_IP:9000;|g" /tmp/ih-nginx.conf - sed -i "s|set \$relay_upstream http://relay:8080;|set \$relay_upstream http://$RELAY_IP:8080;|g" /tmp/ih-nginx.conf - sed -i "s|proxy_set_header Host \$host;|proxy_set_header Host \$http_host;|g" /tmp/ih-nginx.conf - podman_quick cp /tmp/ih-nginx.conf indeedhub:/etc/nginx/conf.d/default.conf 2>/dev/null || true - rm -f /tmp/ih-nginx.conf - CHANGED=true - echo " Patched IndeedHub nginx with container IPs (API=$API_IP MINIO=$MINIO_IP RELAY=$RELAY_IP)" - fi - - if [ "$CHANGED" = true ]; then - podman_exec_quick indeedhub nginx -s reload 2>/dev/null || true - fi - fi - ' 2>&1 | sed 's/^/ /' || true - - # Run container doctor — auto-fix common container health issues - progress "Running container doctor" - "$SCRIPT_DIR/container-doctor.sh" "$TARGET_HOST" 2>&1 | sed 's/^/ /' || true - - # Post-deploy health check — wait up to 60s for server to come healthy - echo "" - progress "Post-deploy health check" - HEALTH_OK=false - for i in $(seq 1 12); do - POST_HEALTH=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 "http://$TARGET_IP_ONLY/health" 2>/dev/null || { echo "WARNING: Post-deploy health check failed for $TARGET_IP_ONLY" >&2; echo "000"; }) - if [ "$POST_HEALTH" = "200" ]; then - echo " Health: OK (200) after $((i * 5))s" - HEALTH_OK=true - break - fi - echo " Health: $POST_HEALTH (waiting... ${i}/12)" - sleep 5 - done - if [ "$HEALTH_OK" = false ]; then - echo " ⚠️ Server did not become healthy within 60s (last: $POST_HEALTH)" - echo " Attempting automatic rollback..." - ssh $SSH_OPTS "$TARGET_HOST" ' - if [ -f /opt/archipelago/rollback/archipelago.bak ]; then - sudo systemctl stop archipelago 2>/dev/null - sudo cp /opt/archipelago/rollback/archipelago.bak /usr/local/bin/archipelago - if [ -f /opt/archipelago/rollback/web-ui.tar ]; then - sudo find /opt/archipelago/web-ui -mindepth 1 -maxdepth 1 ! -name "aiui" ! -name "claude-login.html" -exec rm -rf {} + - sudo tar xf /opt/archipelago/rollback/web-ui.tar -C /opt/archipelago/web-ui - fi - sudo systemctl start archipelago - echo "ROLLBACK_DONE" - else - echo "NO_ROLLBACK_AVAILABLE" - fi - ' 2>/dev/null | while IFS= read -r line; do - if [ "$line" = "ROLLBACK_DONE" ]; then - echo " 🔄 Rollback complete — previous version restored" - elif [ "$line" = "NO_ROLLBACK_AVAILABLE" ]; then - echo " ⚠️ No rollback backup available" - fi - done - echo " Check: sudo journalctl -u archipelago -n 50" - fi - - DEPLOY_END=$(date +%s) - DEPLOY_ELAPSED=$((DEPLOY_END - DEPLOY_START)) - - # Append to local deploy history log (gitignored) - DEPLOY_LOG="$PROJECT_DIR/scripts/deploy-history.log" - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $DEPLOY_BRANCH@$DEPLOY_COMMIT | dirty=$DEPLOY_DIRTY | target=$TARGET_HOST | ${DEPLOY_ELAPSED}s" >> "$DEPLOY_LOG" - - # Auto-tag successful deploys (only on clean commits, skip if already tagged) - if [ "$DEPLOY_DIRTY" = "0" ]; then - EXISTING_TAG=$(git tag --points-at "$DEPLOY_COMMIT" 2>/dev/null | grep "^v" | head -1) - if [ -z "$EXISTING_TAG" ]; then - LAST_ALPHA=$(git tag -l 'v1.2.0-alpha.*' | sort -V | tail -1 | sed 's/.*alpha\.//') - NEXT_ALPHA=$(( ${LAST_ALPHA:-0} + 1 )) - DEPLOY_TAG="v1.2.0-alpha.${NEXT_ALPHA}" - git tag -a "$DEPLOY_TAG" "$DEPLOY_COMMIT" -m "Auto-tagged by deploy to $TARGET_IP_ONLY" 2>/dev/null && \ - echo " Tagged: $DEPLOY_TAG" || true - fi - fi - - echo "" - echo "$(timestamp) ✅ Deployed to live system! (${DEPLOY_ELAPSED}s total)" - echo " Commit: $DEPLOY_BRANCH @ $DEPLOY_COMMIT (dirty=$DEPLOY_DIRTY)" - echo " Backend: $(ssh $SSH_OPTS "$TARGET_HOST" 'sudo systemctl is-active archipelago')" - echo " Web UI: http://$TARGET_IP_ONLY" - echo " PWA install: https://$TARGET_IP_ONLY (use HTTPS, accept cert once, then Install app)" -else - echo "" - echo "✅ Build complete!" - echo "" - echo "To test frontend dev server:" - echo " ssh $TARGET_HOST" - echo " cd ~/archy/neode-ui && npm run dev -- --host 0.0.0.0" - echo " Then open: http://$(echo "$TARGET_HOST" | cut -d@ -f2):5173" - echo "" - echo "To deploy to live system:" - echo " ./scripts/deploy-to-target.sh --live" -fi diff --git a/scripts/dev-container-test.sh b/scripts/dev-container-test.sh index 7973e0a1..1ea834b8 100755 --- a/scripts/dev-container-test.sh +++ b/scripts/dev-container-test.sh @@ -16,10 +16,15 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}" -SSH_HOST="${ARCHIPELAGO_SSH_HOST:-archipelago@192.168.1.228}" +SSH_HOST="${ARCHIPELAGO_SSH_HOST:-}" +if [ -z "$SSH_HOST" ]; then + echo "ARCHIPELAGO_SSH_HOST must be set, e.g. archipelago@" >&2 + exit 2 +fi +HOST_ONLY="${SSH_HOST#*@}" SSH_OPTS="-o StrictHostKeyChecking=no -o ServerAliveInterval=15 -i $SSH_KEY" REMOTE_DIR="/home/archipelago/archy" -RPC_URL="http://192.168.1.228/rpc/v1" +RPC_URL="http://${HOST_ONLY}/rpc/v1" COOKIE="" ONCE=false [ "$1" = "--once" ] && ONCE=true @@ -66,7 +71,7 @@ login() { wait_for_health() { local timeout=${1:-30} for i in $(seq 1 "$timeout"); do - if curl -sf "http://192.168.1.228/health" >/dev/null 2>&1; then + if curl -sf "http://${HOST_ONLY}/health" >/dev/null 2>&1; then return 0 fi sleep 1 @@ -146,7 +151,7 @@ run_smoke_tests() { # Test 3: Install a lightweight container (filebrowser — small, fast, no deps) TESTS=$((TESTS + 1)) - local install_img="146.59.87.168:3000/lfg2025/filebrowser:v2.27.0" + local install_img="source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0" # Check if already installed local fb_state fb_state=$(ssh $SSH_OPTS "$SSH_HOST" "podman inspect filebrowser --format '{{.State.Status}}' 2>/dev/null || echo 'none'") diff --git a/scripts/dev-start.sh b/scripts/dev-start.sh index e87b4c48..911e3279 100755 --- a/scripts/dev-start.sh +++ b/scripts/dev-start.sh @@ -82,7 +82,7 @@ case $choice in echo " a) Preview GRUB background only (instant):" echo " python3 image-recipe/branding/generate-grub-background.py /tmp/grub-bg.png && open /tmp/grub-bg.png" echo "" - echo " b) Download an ISO from FileBrowser (http://192.168.1.228:8083)" + echo " b) Download an ISO from FileBrowser (http://192.0.2.10:8083)" echo " then drop it on your Desktop and re-run this option." echo "" echo " Files you can edit:" diff --git a/scripts/first-boot-containers.sh b/scripts/first-boot-containers.sh index 561998e1..23d8aad5 100755 --- a/scripts/first-boot-containers.sh +++ b/scripts/first-boot-containers.sh @@ -4,12 +4,12 @@ # Creates core containers so My Apps works out of the box after ISO install # Runs after archipelago-load-images.service and archipelago-setup-tor.service # -# Based on scripts/deploy-to-target.sh (--live) container logic - do not diverge. +# Container logic mirrors the deploy path - do not diverge. # No set -e: each section continues even if one fails (idempotent, best-effort). # # Image versions: sourced from /opt/archipelago/image-versions.sh (single source of truth). # All container image references use the $*_IMAGE variables defined there. -# Images pull from the Archipelago app registry (146.59.87.168:3000/lfg2025/). +# Images pull from the Archipelago app registry (source.archipelago-foundation.org/lfg2025/). # # --- PLANNED REFACTOR (post-beta) --- # This script is ~995 lines and should be split into a modular library. diff --git a/scripts/fleet-fips-pair.sh b/scripts/fleet-fips-pair.sh deleted file mode 100755 index 78bce572..00000000 --- a/scripts/fleet-fips-pair.sh +++ /dev/null @@ -1,176 +0,0 @@ -#!/bin/bash -# LAN fast-path pairing for our 4 dev fleet nodes. -# -# ── Is this needed for every archipelago install? No. ──────────────── -# For nodes deployed anywhere in the world, FIPS-to-FIPS routing by -# npub works via the anchor peer network (fips.v0l.io ships by default -# in /etc/fips/fips.yaml on every install — that anchor bootstraps DHT -# routing for any npub the node has ever heard about). The peer's -# fips_npub is advertised in our federation invite codes (since v1.4), -# so accepting an invite is enough for `dial::peer_base_url(npub)` to -# reach the peer through the anchor mesh. -# -# ── Why this script exists ─────────────────────────────────────────── -# Our 4 fleet nodes are all on 192.168.1.0/24. Hopping through the -# fips.v0l.io anchor for intra-LAN traffic is wasteful when the peers -# are on the same wire. This script writes per-node fips.yaml with: -# 1. The public anchor (fips.v0l.io) so internet peers still route. -# 2. The other 3 fleet nodes as static LAN peers (UDP 2121 / TCP -# 8443) so LAN traffic stays on LAN. -# 3. `persistent: true` so the npub is stable across restarts — -# without this the daemon rolls a new keypair on every restart -# and any federation invite we advertised goes stale. -# -# Idempotent: re-running picks up any newly-added or removed nodes. -# -# For a production install on an unknown LAN, this script isn't the -# mechanism — the ISO install writes the anchor-only fips.yaml and -# identity comes from the archipelago seed; peer discovery is purely -# through the DHT + federation invites. -# -# Usage: -# scripts/fleet-fips-pair.sh # apply to all nodes -# scripts/fleet-fips-pair.sh --verify # just print the peer state - -set -eo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/lib/common.sh" - -# Fleet roster: " " -NODES=( - "116 enp0s25 npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g" - "198 enp2s0 npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se" - "228 enp2s0 npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h" - "253 enx9cbf0d0129f9 npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g" -) - -LAN_PREFIX="192.168.1" -UDP_PORT=2121 -TCP_PORT=8443 - -if [ "${1:-}" = "--verify" ]; then - for row in "${NODES[@]}"; do - read -r node _nic _npub <<< "$row" - echo "=== .$node ===" - ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | python3 -c 'import sys,json; d=json.load(sys.stdin); print(f\"{len(d[\"peers\"])} authenticated peers\"); [print(\" npub=\", p.get(\"npub\",\"?\"), \"alias=\", p.get(\"alias\",\"?\")) for p in d[\"peers\"]]' || echo ' fipsctl show peers failed'" - done - exit 0 -fi - -TMP_ROOT=$(mktemp -d) -trap 'rm -rf "$TMP_ROOT"' EXIT - -generate_yaml() { - # $1 = self node octet, $2 = self nic - local self_node="$1" - local self_nic="$2" - local out="$TMP_ROOT/fips.yaml.$self_node" - - cat > "$out" <> "$out" </dev/null 2>&1; then break; fi - sleep 0.5 - done - sudo systemctl is-active fips.service - ' -} - -for row in "${NODES[@]}"; do - read -r node nic _npub <<< "$row" - deploy_to "$node" "$nic" -done - -echo -log_info "Waiting 10s for peer handshakes to settle…" -sleep 10 - -echo -log_info "Post-pair peer state:" -for row in "${NODES[@]}"; do - read -r node _nic _npub <<< "$row" - count=$(ssh_cmd "$LAN_PREFIX.$node" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0") - log_info " .$node: $count authenticated peers" -done diff --git a/scripts/fleet-fips-unpair.sh b/scripts/fleet-fips-unpair.sh deleted file mode 100755 index e56c0274..00000000 --- a/scripts/fleet-fips-unpair.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/bin/bash -# Strip the LAN fast-path peers from all 4 fleet nodes' fips.yaml, -# leaving only the public anchor (fips.v0l.io). Restart fips.service -# on each node. -# -# Purpose: verify that the general-case deployment (nodes anywhere in -# the world, no LAN between them) actually works — i.e. that two -# paired archipelago peers can reach each other purely through the -# FIPS DHT bootstrapped from the anchor. -# -# After running this, test with: -# scripts/fleet-fips-pair.sh --verify (peer state per node) -# for ip in 116 198 228 253; do -# ssh archipelago@192.168.1.$ip "dig @127.0.0.1 -p 5354 +short \ -# .fips AAAA" -# done -# -# To restore the LAN fast-path: re-run scripts/fleet-fips-pair.sh. -# -# Usage: scripts/fleet-fips-unpair.sh - -set -eo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -. "$SCRIPT_DIR/lib/common.sh" - -# Roster — only need NIC names to preserve them in the yaml. -NODES=( - "116 enp0s25" - "198 enp2s0" - "228 enp2s0" - "253 enx9cbf0d0129f9" -) - -TMP_ROOT=$(mktemp -d) -trap 'rm -rf "$TMP_ROOT"' EXIT - -for row in "${NODES[@]}"; do - read -r node nic <<< "$row" - out="$TMP_ROOT/fips.yaml.$node" - cat > "$out" </dev/null 2>&1; then break; fi - sleep 0.5 - done - sudo systemctl is-active fips.service - ' -done - -echo -log_info "Waiting 20s for anchor handshake + DHT propagation…" -sleep 20 - -echo -log_info "Post-unpair state (should show only fips.v0l.io as an authenticated peer):" -for row in "${NODES[@]}"; do - read -r node _nic <<< "$row" - ip="192.168.1.$node" - count=$(ssh_cmd "$ip" "sudo fipsctl show peers 2>/dev/null | grep -c '\"npub\"' || echo 0") - log_info " .$node: $count authenticated peers" -done - -echo -log_info "DHT resolution test — each node resolves the other 3 by npub:" -declare -A NPUBS=( - [116]="npub1mxavs6scfgl056k6lm4mk73ddnrhjewg78zlyzfn2lmr0rfyrs5qhcr03g" - [198]="npub13cy4lml94cj4rdu8runrr945z2muszuvr5tql8mr9m063d7xzpqqu3k8se" - [228]="npub1a0xxcqce2tsv8ulwastep23jtf3h4wvvry8r8nklnl36jtrdnefqh5qn6h" - [253]="npub1dl0m0yfzfw6467c3z6q63s7ggzd77yg97j90ptfrheprxeypt3msj0mq4g" -) -for row in "${NODES[@]}"; do - read -r self_node _ <<< "$row" - ip="192.168.1.$self_node" - echo ".${self_node}:" - for other in 116 198 228 253; do - [ "$other" = "$self_node" ] && continue - r=$(ssh_cmd "$ip" "dig @127.0.0.1 -p 5354 +short +time=3 +tries=1 ${NPUBS[$other]}.fips AAAA" 2>&1) - if [ -z "$r" ]; then - echo " .${other} → unresolved (DHT route not found)" - else - echo " .${other} → $r" - fi - done -done diff --git a/scripts/generate-app-catalog.sh b/scripts/generate-app-catalog.sh index cfd47f0e..c0f8f72a 100755 --- a/scripts/generate-app-catalog.sh +++ b/scripts/generate-app-catalog.sh @@ -172,7 +172,7 @@ if os.environ.get("EMBED_MANIFESTS") and apps_dir: # image.sh (Phase 0) publishes more tagged images, e.g.: # {"version": "30.0", "image": f"{REGISTRY}/bitcoin:30.0"}, # {"version": "27.2", "image": f"{REGISTRY}/bitcoin:27.2", "deprecated": True, "eol": "2026-12-31"}, -REGISTRY = os.environ.get("ARCHY_REGISTRY", "146.59.87.168:3000/lfg2025") +REGISTRY = os.environ.get("ARCHY_REGISTRY", "source.archipelago-foundation.org/lfg2025") VERSIONS = { # Curated Core set (latest patch per major, current → 25). Images built + # verified (SHA-256 + OpenPGP, fail-closed) and pushed by @@ -203,7 +203,7 @@ VERSIONS = { # newest build on fixed-binary nodes, while UNPINNED nodes still resolve via # the manifest's floating :latest tag (kept on the legacy image until the # entrypoint-render fix is fleet-deployed — see - # docs/bitcoin-version-bulletproof-rollout.md). + # the bitcoin multi-version design). "bitcoin-knots": [ {"version": "latest", "image": f"{REGISTRY}/bitcoin-knots:29.3.knots20260508", "default": True}, diff --git a/scripts/image-versions.sh b/scripts/image-versions.sh index 5988c471..f1e3c24b 100644 --- a/scripts/image-versions.sh +++ b/scripts/image-versions.sh @@ -5,12 +5,12 @@ # Usage: source /opt/archipelago/image-versions.sh 2>/dev/null || true # source "$(dirname "$0")/image-versions.sh" 2>/dev/null || true # -# Tags MUST match what's actually in the registry at 146.59.87.168:3000/lfg2025/ -# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep '146.59.87.168:3000' | sort +# Tags MUST match what's actually in the registry at source.archipelago-foundation.org/lfg2025/ +# Run: podman images --format '{{.Repository}}:{{.Tag}}' | grep 'source.archipelago-foundation.org' | sort # to verify against the registry. # Archipelago app registries (primary + fallback) -ARCHY_REGISTRY="146.59.87.168:3000/lfg2025" +ARCHY_REGISTRY="source.archipelago-foundation.org/lfg2025" # No fallback registry: the old tx1138 registry host was retired (2026-06-13); empty disables the fallback path. ARCHY_REGISTRY_FALLBACK="" @@ -25,7 +25,7 @@ MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1" MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10" # BTCPay -BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.3.9" +BTCPAY_IMAGE="docker.io/btcpayserver/btcpayserver:2.4.2" NBXPLORER_IMAGE="$ARCHY_REGISTRY/nbxplorer:2.6.0" POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17" diff --git a/scripts/install-tui-demo.sh b/scripts/install-tui-demo.sh index d3e2cf19..c5c16da2 100755 --- a/scripts/install-tui-demo.sh +++ b/scripts/install-tui-demo.sh @@ -576,18 +576,18 @@ screen_complete() { # URL in orange goto $row 1 - p " ${ORANGE}http://192.168.1.198${NC}" + p " ${ORANGE}http://192.0.2.11${NC}" row=$((row + 2)) # Credentials — white, NOT orange (user request) goto $row 1 - p " ${WHITE}SSH ssh archipelago@192.168.1.198${NC}" + p " ${WHITE}SSH ssh archipelago@192.0.2.11${NC}" row=$((row + 1)) goto $row 1 p " ${WHITE}Password archipelago${NC}" row=$((row + 1)) goto $row 1 - p " ${WHITE}Web Login password123${NC}" + p " ${WHITE}Web Login create your password on first visit${NC}" row=$((row + 2)) goto $row 1; hrule; row=$((row + 2)) diff --git a/scripts/node-profile.sh b/scripts/node-profile.sh deleted file mode 100755 index c22de203..00000000 --- a/scripts/node-profile.sh +++ /dev/null @@ -1,252 +0,0 @@ -#!/bin/bash -# node-profile.sh — CPU/memory/container profiling across all Archipelago nodes -# -# Usage: -# ./scripts/node-profile.sh # All reachable nodes -# ./scripts/node-profile.sh 192.168.1.228 # Single node -# ./scripts/node-profile.sh --watch # Repeat every 30s -# -# Requires: SSH key at ~/.ssh/archipelago-deploy (or ARCHIPELAGO_SSH_KEY) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -source "$SCRIPT_DIR/lib/common.sh" -source "$SCRIPT_DIR/deploy-config-defaults.sh" -[ -f "$SCRIPT_DIR/deploy-config.sh" ] && source "$SCRIPT_DIR/deploy-config.sh" - -ALL_NODES=( - "$DEFAULT_PRIMARY" - "$DEFAULT_SECONDARY" - "$TAILSCALE_ARCH1" - "$TAILSCALE_ARCH2" - "$TAILSCALE_ARCH3" -) - -NODE_LABELS=( - "primary (.228)" - "secondary (.198)" - "tailscale-1" - "tailscale-2" - "tailscale-3" -) - -WATCH_MODE=false -WATCH_INTERVAL=30 -TARGET_NODES=() - -# ── Parse args ───────────────────────────────────────────────────────── - -while [[ $# -gt 0 ]]; do - case "$1" in - --watch) - WATCH_MODE=true - shift - ;; - --interval) - WATCH_INTERVAL="$2" - shift 2 - ;; - *) - TARGET_NODES+=("$1") - shift - ;; - esac -done - -# If specific nodes given, use those; otherwise use all -if [ ${#TARGET_NODES[@]} -eq 0 ]; then - TARGET_NODES=("${ALL_NODES[@]}") -fi - -# ── Remote profiling command ─────────────────────────────────────────── - -PROFILE_CMD=' -hostname_val=$(hostname 2>/dev/null || echo "unknown") -uptime_val=$(uptime -p 2>/dev/null || uptime | sed "s/.*up/up/;s/,.*//") - -# CPU info -cpu_cores=$(nproc 2>/dev/null || echo "?") -load_avg=$(cat /proc/loadavg 2>/dev/null | awk "{print \$1, \$2, \$3}") - -# Memory -mem_info=$(free -h 2>/dev/null | awk "/^Mem:/{printf \"%s / %s (%s free)\", \$3, \$2, \$4}") -swap_info=$(free -h 2>/dev/null | awk "/^Swap:/{if(\$2 != \"0B\" && \$2 != \"0\") printf \"%s / %s\", \$3, \$2; else print \"none\"}") - -# Disk -disk_info=$(df -h / 2>/dev/null | awk "NR==2{printf \"%s / %s (%s)\", \$3, \$2, \$5}") - -# CPU temperature (if available) -temp="n/a" -if [ -f /sys/class/thermal/thermal_zone0/temp ]; then - raw=$(cat /sys/class/thermal/thermal_zone0/temp) - temp="$((raw / 1000))°C" -fi - -echo "HEADER|${hostname_val}|${uptime_val}|${cpu_cores} cores|load ${load_avg}|${temp}" -echo "MEM|${mem_info}" -echo "SWAP|${swap_info}" -echo "DISK|${disk_info}" - -# Top 10 processes by CPU -echo "PROCS_START" -ps aux --sort=-%cpu 2>/dev/null | head -11 | awk "NR>1{printf \"%-6s %-5s %-5s %s\n\", \$2, \$3, \$4, \$11}" 2>/dev/null -echo "PROCS_END" - -# Container status -echo "CONTAINERS_START" -if command -v podman >/dev/null 2>&1; then - podman ps -a --format "{{.Names}}|{{.Status}}|{{.Size}}" 2>/dev/null || \ - podman ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || \ - echo "podman error" -elif command -v docker >/dev/null 2>&1; then - docker ps -a --format "{{.Names}}|{{.Status}}" 2>/dev/null || echo "docker error" -else - echo "no container runtime" -fi -echo "CONTAINERS_END" -' - -# ── Formatting ───────────────────────────────────────────────────────── - -BOLD="\033[1m" -DIM="\033[2m" -GREEN="\033[0;32m" -YELLOW="\033[0;33m" -RED="\033[0;31m" -CYAN="\033[0;36m" -RESET="\033[0m" - -SEP="━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -print_node_report() { - local ip="$1" - local label="$2" - local output="$3" - - echo -e "\n${BOLD}${CYAN}${SEP}${RESET}" - echo -e "${BOLD}${CYAN} ${label} ${DIM}(${ip})${RESET}" - echo -e "${BOLD}${CYAN}${SEP}${RESET}" - - # Parse HEADER line - local header - header=$(echo "$output" | grep "^HEADER|" | head -1) - if [ -n "$header" ]; then - IFS='|' read -r _ hostname uptime cores load temp <<< "$header" - echo -e " ${BOLD}Host:${RESET} ${hostname} ${DIM}${uptime}${RESET}" - echo -e " ${BOLD}CPU:${RESET} ${cores} ${load} ${temp}" - fi - - # Memory - local mem - mem=$(echo "$output" | grep "^MEM|" | cut -d'|' -f2) - [ -n "$mem" ] && echo -e " ${BOLD}Mem:${RESET} ${mem}" - - local swap - swap=$(echo "$output" | grep "^SWAP|" | cut -d'|' -f2) - [ -n "$swap" ] && echo -e " ${BOLD}Swap:${RESET} ${swap}" - - local disk - disk=$(echo "$output" | grep "^DISK|" | cut -d'|' -f2) - [ -n "$disk" ] && echo -e " ${BOLD}Disk:${RESET} ${disk}" - - # Top processes - echo "" - echo -e " ${BOLD}Top processes by CPU:${RESET}" - echo -e " ${DIM}PID CPU% MEM% Command${RESET}" - local procs - procs=$(echo "$output" | sed -n '/^PROCS_START$/,/^PROCS_END$/p' | grep -v "^PROCS_") - if [ -n "$procs" ]; then - while IFS= read -r line; do - local cpu_pct - cpu_pct=$(echo "$line" | awk '{print $2}' | tr -d '.') - if [ "${cpu_pct:-0}" -gt 500 ] 2>/dev/null; then - echo -e " ${RED}${line}${RESET}" - elif [ "${cpu_pct:-0}" -gt 100 ] 2>/dev/null; then - echo -e " ${YELLOW}${line}${RESET}" - else - echo -e " ${line}" - fi - done <<< "$procs" - else - echo -e " ${DIM}(no process data)${RESET}" - fi - - # Containers - echo "" - echo -e " ${BOLD}Containers:${RESET}" - local containers - containers=$(echo "$output" | sed -n '/^CONTAINERS_START$/,/^CONTAINERS_END$/p' | grep -v "^CONTAINERS_") - if [ -n "$containers" ] && [ "$containers" != "no container runtime" ] && [ "$containers" != "podman error" ]; then - while IFS='|' read -r name status size; do - local icon - if echo "$status" | grep -qi "up"; then - icon="${GREEN}●${RESET}" - else - icon="${RED}○${RESET}" - fi - echo -e " ${icon} ${BOLD}${name}${RESET} ${DIM}${status}${RESET}" - done <<< "$containers" - else - echo -e " ${DIM}${containers:-none}${RESET}" - fi -} - -# ── Main profiling loop ─────────────────────────────────────────────── - -profile_all() { - echo -e "\n${BOLD}Archipelago Node Profile${RESET} ${DIM}$(date '+%Y-%m-%d %H:%M:%S')${RESET}" - - local tmpdir - tmpdir=$(mktemp -d) - - # Probe all nodes in parallel - local pids=() - for i in "${!TARGET_NODES[@]}"; do - local ip="${TARGET_NODES[$i]}" - local label="${NODE_LABELS[$i]:-$ip}" - ( - result=$(ssh_cmd "$ip" "$PROFILE_CMD" 2>/dev/null) && \ - echo "$result" > "$tmpdir/$i.out" || \ - echo "UNREACHABLE" > "$tmpdir/$i.out" - ) & - pids+=($!) - done - - # Wait for all probes - for pid in "${pids[@]}"; do - wait "$pid" 2>/dev/null || true - done - - # Print reports - local reachable=0 unreachable=0 - for i in "${!TARGET_NODES[@]}"; do - local ip="${TARGET_NODES[$i]}" - local label="${NODE_LABELS[$i]:-$ip}" - local outfile="$tmpdir/$i.out" - - if [ -f "$outfile" ] && [ "$(cat "$outfile")" != "UNREACHABLE" ]; then - print_node_report "$ip" "$label" "$(cat "$outfile")" - reachable=$((reachable + 1)) - else - echo -e "\n${DIM}${SEP}${RESET}" - echo -e "${RED} ${label} (${ip}) — unreachable${RESET}" - echo -e "${DIM}${SEP}${RESET}" - unreachable=$((unreachable + 1)) - fi - done - - echo -e "\n${DIM}${reachable} reachable, ${unreachable} unreachable${RESET}\n" - rm -rf "$tmpdir" -} - -if $WATCH_MODE; then - while true; do - clear - profile_all - echo -e "${DIM}Refreshing every ${WATCH_INTERVAL}s — Ctrl+C to stop${RESET}" - sleep "$WATCH_INTERVAL" - done -else - profile_all -fi diff --git a/scripts/publish-release-assets.sh b/scripts/publish-release-assets.sh index 14de7993..da645cc1 100755 --- a/scripts/publish-release-assets.sh +++ b/scripts/publish-release-assets.sh @@ -62,8 +62,19 @@ repo_path=${repo_path%.git} api="$scheme://$host/api/v1/repos/$repo_path" release_url="$api/releases/tags/v${VERSION}" -echo "Pushing main and v${VERSION} to $REMOTE..." -git -C "$PROJECT_ROOT" push "$REMOTE" main "refs/tags/v${VERSION}" +# ORDER MATTERS. The manifest is the trigger — nodes read releases/manifest.json +# from branch main and try to download the named version the moment it appears. +# So main (which carries the live manifest) must be pushed LAST, only after the +# assets are uploaded and their bytes verified against the manifest. The tag is +# pushed first because the Gitea release and its asset download URLs hang off it, +# but the tag alone changes nothing for nodes. +# +# This used to push main and the tag together, up front, then upload assets. That +# left the manifest live for the entire upload+verify window — and on 2026-08-07 +# an upload failed inside that window, so every polling node briefly advertised a +# v1.7.126-alpha update whose binary 500'd and whose tarball did not exist. +echo "Pushing tag v${VERSION} to $REMOTE (not main yet)..." +git -C "$PROJECT_ROOT" push "$REMOTE" "refs/tags/v${VERSION}" release_json=$(curl -fsS -u "$auth" "$release_url" || true) if [ -z "$release_json" ]; then @@ -107,24 +118,16 @@ upload_asset() { upload_asset "$BACKEND" "archipelago" upload_asset "$FRONTEND" "archipelago-frontend-${VERSION}.tar.gz" -echo "Verifying public download URLs from manifest (size + sha256)..." -python3 - "$PROJECT_ROOT/releases/manifest.json" <<'PY' | while read -r url size sha; do -import json -import sys +echo "Verifying public download URLs (full GET + size + sha256)..." +# Delegated to check-release-assets.sh so the same verifier is used here and by +# hand during recovery. It fails hard on the first bad asset — the previous +# inline `while read` ran in a pipe subshell, where a `fail` (exit) killed only +# the subshell and let this script march on to "published and verified". +"$PROJECT_ROOT/scripts/check-release-assets.sh" "$PROJECT_ROOT/releases/manifest.json" \ + || fail "asset verification failed — NOT pushing main. The manifest stays off the branch nodes read, so no node sees a version it cannot fetch. Repair the assets and re-run." -manifest = json.load(open(sys.argv[1])) -for component in manifest["components"]: - print(component["download_url"], component["size_bytes"], component["sha256"]) -PY - # Full GET, not HEAD: a size-correct/content-wrong mirror asset must fail - # the publish gate, so compare the actual bytes against the manifest sha256. - tmp=$(mktemp) - curl -fsSL --max-time 900 -o "$tmp" "$url" || { rm -f "$tmp"; fail "download URL failed: $url"; } - actual_size=$(stat -c %s "$tmp") - actual_sha=$(sha256sum "$tmp" | awk '{print $1}') - rm -f "$tmp" - [ "$actual_size" = "$size" ] || fail "download size mismatch for $url (expected $size, got $actual_size)" - [ "$actual_sha" = "$sha" ] || fail "download sha256 mismatch for $url (expected $sha, got $actual_sha)" -done +# Assets are proven fetchable — only now does the manifest become live. +echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..." +git -C "$PROJECT_ROOT" push "$REMOTE" main echo "Release v${VERSION} published and verified on $REMOTE." diff --git a/scripts/resilience/README.md b/scripts/resilience/README.md index 102efedb..9c6e20ce 100644 --- a/scripts/resilience/README.md +++ b/scripts/resilience/README.md @@ -28,23 +28,23 @@ will never catch. This harness is the gate. Against the .228 test node: - scripts/resilience/resilience.sh archipelago@192.168.1.228 + scripts/resilience/resilience.sh archipelago@192.0.2.10 Or non-interactive (CI): RESILIENCE_SSH_PASS=… RESILIENCE_UI_PASS=… \ - scripts/resilience/resilience.sh archipelago@192.168.1.228 + scripts/resilience/resilience.sh archipelago@192.0.2.10 Filters: # Smoke test (3 apps, no reboot, ~15min) - scripts/resilience/resilience.sh archipelago@192.168.1.228 smoke + scripts/resilience/resilience.sh archipelago@192.0.2.10 smoke # Single app - scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots + scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots # Subset - scripts/resilience/resilience.sh archipelago@192.168.1.228 bitcoin-knots,lnd + scripts/resilience/resilience.sh archipelago@192.0.2.10 bitcoin-knots,lnd Without a filter, the harness sweeps **every** app in the catalog (~24 apps × 7 per-app transitions + 2 batch transitions) and runs the diff --git a/scripts/resilience/lib.sh b/scripts/resilience/lib.sh index 89d2ec26..7fe22d5a 100755 --- a/scripts/resilience/lib.sh +++ b/scripts/resilience/lib.sh @@ -3,7 +3,7 @@ # Sourced by resilience.sh — do not invoke directly. # Required env (set by resilience.sh before sourcing): -# TARGET — ssh target, e.g. archipelago@192.168.1.228 +# TARGET — ssh target, e.g. archipelago@192.0.2.10 # RPC_URL — http://:5678/rpc/v1 # COOKIE_JAR — path for curl cookie store # SSH_PASS — sshpass password diff --git a/scripts/resilience/resilience.sh b/scripts/resilience/resilience.sh index 0bdcc024..4643456d 100755 --- a/scripts/resilience/resilience.sh +++ b/scripts/resilience/resilience.sh @@ -8,7 +8,7 @@ # remains in the expected state at every step. # # Usage: -# scripts/resilience/resilience.sh archipelago@192.168.1.228 [filter] +# scripts/resilience/resilience.sh archipelago@192.0.2.10 [filter] # # `filter` is a comma-separated list of app IDs (or "smoke" for the curated # fast subset). Default: every app in app-catalog/catalog.json. diff --git a/scripts/run-post-install-tests.sh b/scripts/run-post-install-tests.sh index f6a1069f..59079f8d 100755 --- a/scripts/run-post-install-tests.sh +++ b/scripts/run-post-install-tests.sh @@ -2,8 +2,9 @@ # Post-install + onboarding + container lifecycle E2E tests. # Run on an installed Archipelago node (SSH or local). # -# Usage: bash run-post-install-tests.sh [password] -# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth) +# Usage: bash run-post-install-tests.sh --password-stdin # read password from stdin (preferred) +# bash run-post-install-tests.sh [password] # argv form; visible in `ps`, local use only +# bash run-post-install-tests.sh --phase1-only # Install checks only (no auth) # # Tests: # Phase 1: Install verification (services, files, logs) — safe, no side effects @@ -12,15 +13,22 @@ set -u PHASE1_ONLY=false -PASSWORD="testpass123!" +PASSWORD="" for arg in "$@"; do case "$arg" in --phase1-only) PHASE1_ONLY=true ;; + --password-stdin) IFS= read -r PASSWORD || true ;; *) PASSWORD="$arg" ;; esac done +if [ "$PHASE1_ONLY" = false ] && [ -z "$PASSWORD" ]; then + echo "ERROR: no password supplied. Use --password-stdin, pass one as an argument," >&2 + echo " or run --phase1-only for the no-auth install checks." >&2 + exit 2 +fi + BASE="http://127.0.0.1:5678" JAR="/tmp/e2e-cookies.txt" rm -f "$JAR" diff --git a/scripts/security/rotate-lnd-macaroon.sh b/scripts/security/rotate-lnd-macaroon.sh index fde104be..2ce151a5 100755 --- a/scripts/security/rotate-lnd-macaroon.sh +++ b/scripts/security/rotate-lnd-macaroon.sh @@ -268,6 +268,57 @@ if [ -n "$FAIL" ]; then die "rotation verification FAILED:$FAIL — old material is in $BACKUP" fi +# ── BTCPay's inline copy ────────────────────────────────────────────── +# BTCPay reaches the internal LND node with a connection string that carries +# the macaroon INLINE as hex, not as a file path: LND's datadir is owned by its +# container's mapped uid, so btcpay cannot bind-mount the file. That copy is +# therefore now a dead credential, and nothing else will notice — the daemon +# only regenerates this secret when LND's TLS *cert* thumbprint changes, which +# macaroon rotation does not touch. The node keeps looking healthy (btcpay up, +# LND up) while every Lightning invoice BTCPay tries to create fails. +# +# Deleting the secret file gets the daemon to regenerate it from the new +# macaroon on its next reconcile tick. That is necessary but NOT sufficient, and +# the difference matters: the RUNNING container still holds the dead value, and +# the periodic reconciler only ever runs in `ExistingOnly` mode, where env drift +# on a restart-sensitive app (btcpay-server is one) is detected and then +# deliberately skipped — "leaving running restart-sensitive app untouched". So +# the container has to be recreated on purpose. The dashboard path +# (Settings → Lightning credentials) does this itself by flagging the app as +# credential-rotated; a shell script cannot reach that in-process flag, so it +# removes the container instead and lets the orchestrator's own desired-state +# recovery rebuild it around unchanged data, ports and volumes. +# +# Nothing is printed but a path — never the value. +BTCPAY_SECRET="/var/lib/archipelago/secrets/btcpay-lnd-connection" +BTCPAY_NOTE=no +if sudo test -f "$BTCPAY_SECRET"; then + if sudo rm -f "$BTCPAY_SECRET"; then + say + say "btcpay : removed its stale connection string ($BTCPAY_SECRET)." + say " The daemon regenerates it from the new macaroon within a minute." + BTCPAY_NOTE=yes + if podman container exists btcpay-server 2>/dev/null; then + say " Recreating btcpay-server so it stops using the dead one." + podman stop btcpay-server >/dev/null 2>&1 || true + if podman rm -f btcpay-server >/dev/null 2>&1; then + say " Removed; the orchestrator rebuilds it around its existing" + say " data (it was running, so desired-state recovery restores it)." + else + say " ⚠ could not remove btcpay-server. Its Lightning payments will" + say " fail until it is recreated." + BTCPAY_NOTE=warn + fi + fi + else + say + say "btcpay : ⚠ could not remove $BTCPAY_SECRET. BTCPay is still holding" + say " the OLD macaroon, so its Lightning payments will fail until" + say " that file is deleted and btcpay-server is recreated." + BTCPAY_NOTE=warn + fi +fi + say say "✅ Rotated. Every macaroon issued before now no longer verifies." say @@ -278,6 +329,13 @@ say " and scan the new pairing QR; it serves the new macaroon." say say " Your funds and channels are untouched: the node kept its identity and" say " no channel was closed." +if [ "${BTCPAY_NOTE:-no}" != no ]; then + say + say " CONFIRM BTCPAY CAME BACK. A silent failure here looks identical to success:" + say " btcpay stays up and healthy while every Lightning payment it tries fails." + say " podman inspect btcpay-server --format '{{.Created}}' # should be just now" + say " sudo test -f $BTCPAY_SECRET && echo regenerated" +fi say say " Once every client is re-paired, delete the backup — it holds the OLD" say " root key, which is still sensitive:" diff --git a/scripts/self-update.sh b/scripts/self-update.sh index fb2ed45f..927a362a 100755 --- a/scripts/self-update.sh +++ b/scripts/self-update.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Self-update: pull latest code from the OVH Gitea (146.59.87.168:3000) and apply +# Self-update: pull latest code from the OVH Gitea (source.archipelago-foundation.org) and apply # Designed to run on installed Archipelago nodes (as archipelago user) # # Usage: @@ -8,7 +8,7 @@ # ./self-update.sh --force # Apply even if already up to date # # The script: -# 1. Pulls latest code from origin (146.59.87.168:3000) +# 1. Pulls latest code from origin (source.archipelago-foundation.org) # 2. Builds the Rust backend (release mode) # 3. Builds the Vue frontend (production mode) # 4. Installs the new binary and web UI @@ -69,7 +69,7 @@ done # Ensure repo exists if [ ! -d "$REPO_DIR/.git" ]; then err "Repo not found at $REPO_DIR" - err "Clone it first: git clone http://146.59.87.168:3000/lfg2025/archy ~/archy" + err "Clone it first: git clone https://source.archipelago-foundation.org/lfg2025/archy ~/archy" exit 1 fi @@ -217,7 +217,7 @@ ok "Backend installed" # Non-fatal: archipelago falls back to its dev venv path if the packaged # binaries aren't present, so a missing/failed build here degrades mesh # Reticulum support rather than breaking the update. This mirrors -# deploy-to-target.sh's existing manual-deploy step, which until now was the +# the existing manual-deploy step, which until now was the # only path that ever installed these — a node that only ever received OTA # self-updates had neither binary. if [ -f "$REPO_DIR/reticulum-daemon/build.sh" ]; then @@ -329,7 +329,7 @@ UI_REBUILD_LIST="" # /opt/archipelago/docker/, and nothing was ever updating that directory. # So source edits to those two trees reached nodes through no path at all: # their nginx kept listening on 0.0.0.0 and served the Guardian and FIPS -# screens unauthenticated on every interface (found by scanning archi-dev-box +# screens unauthenticated on every interface (found by scanning a test node # from outside, 2026-08-05 — the in-node audit could not see them). for ui in bitcoin-ui lnd-ui electrs-ui fips-ui fedimint-ui; do src="$REPO_DIR/docker/$ui" diff --git a/scripts/setup-aiui-server.sh b/scripts/setup-aiui-server.sh deleted file mode 100755 index 6d39cf71..00000000 --- a/scripts/setup-aiui-server.sh +++ /dev/null @@ -1,102 +0,0 @@ -#!/bin/bash -# -# Deploy the AIUI (Chat mode iframe) build to an Archipelago server. -# -# Usage: -# ./scripts/setup-aiui-server.sh -# ./scripts/setup-aiui-server.sh archipelago@192.168.1.198 -# ./scripts/setup-aiui-server.sh archipelago@192.168.1.228 -# -# What it does: -# Rsyncs (or tar+scp, if rsync is unavailable on the target) a locally -# built AIUI dist/ into /opt/archipelago/web-ui/aiui/ on the target node. -# -# What it no longer does (13-02-PLAN.md — closing a live production -# exposure): it used to also patch nginx to route /aiui/api/claude/ to a -# standalone Python proxy holding its own ANTHROPIC_API_KEY, with no session -# gate — anyone who could reach the node's web port could spend the owner's -# API budget. That proxy, its systemd unit, and this script's nginx-patch -# step are all deleted (see scripts/deploy-to-target.sh's "Removing legacy -# Claude API proxy sidecar" step). AIUI's Claude/Ollama calls now route -# through the Rust daemon (127.0.0.1:5678), which enforces the session -# cookie itself and reads the node's single key ledger. Set the key via -# `system.settings.set claude_api_key` (Settings > AIUI in neode-ui) — this -# script has nothing to do with the key anymore. -# -# Prerequisites: -# - SSH key access to target server -# - AIUI is built automatically (via scripts/build-aiui.sh) when its dist -# is missing or stale — D-19 (2026-08-03): AIUI lives in-repo at aiui/ -# now, so there is no second checkout to build separately first. - -set -e - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -SSH_KEY="${ARCHIPELAGO_SSH_KEY:-$HOME/.ssh/archipelago-deploy}" -SSH_OPTS="-o StrictHostKeyChecking=no -i $SSH_KEY" - -TARGET_HOST="$1" -if [ -z "$TARGET_HOST" ]; then - echo "Usage: $0 " - echo " e.g. $0 archipelago@192.168.1.198" - exit 1 -fi - -AIUI_DIST="$PROJECT_DIR/aiui/packages/app/dist" -AIUI_SRC="$PROJECT_DIR/aiui/packages/app/src" - -timestamp() { echo "[$(date +%H:%M:%S)]"; } - -# D-19 (2026-08-03): AIUI lives in-repo at aiui/ — no second checkout to -# build separately first. Build it automatically when the dist is missing -# or stale, via the one supported build path (scripts/build-aiui.sh -# enforces VITE_BASE_PATH, installs from the committed lockfile, and -# attributes the build to this repo's own commit). D-15's "enforced, not -# remembered" applies here too — a script that only prints instructions is -# the remembered form. -if [ ! -f "$AIUI_DIST/index.html" ] || [ "$(find "$AIUI_SRC" -newer "$AIUI_DIST/index.html" -print -quit 2>/dev/null)" != "" ]; then - echo "$(timestamp) AIUI dist missing or stale — building via scripts/build-aiui.sh..." - bash "$PROJECT_DIR/scripts/build-aiui.sh" -fi - -if [ ! -f "$AIUI_DIST/index.html" ]; then - echo "ERROR: AIUI build not found at $AIUI_DIST after running scripts/build-aiui.sh" - exit 1 -fi - -echo "╔════════════════════════════════════════════════════════════╗" -echo "║ Archipelago AIUI deploy ║" -echo "║ Target: $TARGET_HOST" -echo "╚════════════════════════════════════════════════════════════╝" - -# --- Deploy AIUI files --- -echo "" -echo "$(timestamp) 📦 Deploying AIUI files..." - -if ssh $SSH_OPTS "$TARGET_HOST" "which rsync" &>/dev/null; then - rsync -avz --delete -e "ssh $SSH_OPTS" "$AIUI_DIST/" "$TARGET_HOST:/opt/archipelago/web-ui/aiui/" 2>&1 | tail -3 -else - echo " rsync not available, using tar+scp..." - TMPTAR=$(mktemp /tmp/aiui-dist-XXXXX.tar.gz) - (cd "$AIUI_DIST" && tar czf "$TMPTAR" .) - scp $SSH_OPTS "$TMPTAR" "$TARGET_HOST:/tmp/aiui-dist.tar.gz" - ssh $SSH_OPTS "$TARGET_HOST" "sudo mkdir -p /opt/archipelago/web-ui/aiui && cd /opt/archipelago/web-ui/aiui && sudo tar xzf /tmp/aiui-dist.tar.gz --overwrite" - rm -f "$TMPTAR" -fi -echo " AIUI deployed." - -# --- Verify --- -echo "" -echo "$(timestamp) ✅ Verification..." -ssh $SSH_OPTS "$TARGET_HOST" " - echo \" AIUI index: \$(ls -la /opt/archipelago/web-ui/aiui/index.html 2>/dev/null | awk '{print \$6,\$7,\$8}')\" - echo \" Nginx: \$(systemctl is-active nginx)\" - echo \" Backend: \$(systemctl is-active archipelago)\" -" - -echo "" -echo "$(timestamp) Done! AIUI deployed." -echo " Set the Claude API key (if not already set) via Settings > AIUI in" -echo " neode-ui — it now lives only at /secrets/claude-api-key." -echo " Access: http://$(echo $TARGET_HOST | cut -d@ -f2)" diff --git a/scripts/setup-https-dev.sh b/scripts/setup-https-dev.sh deleted file mode 100644 index 47630873..00000000 --- a/scripts/setup-https-dev.sh +++ /dev/null @@ -1,280 +0,0 @@ -#!/bin/bash -# -# Set up HTTPS on Archipelago dev server for PWA installability. -# Browsers require HTTPS (or localhost) to install PWAs. -# Generates a self-signed certificate and configures nginx. -# -# Run on the target server: sudo ./setup-https-dev.sh -# Or via deploy: the deploy script runs this automatically. -# - -set -e - -SSL_DIR="/etc/archipelago/ssl" -NGINX_CFG="/etc/nginx/sites-available/archipelago" -CERT="$SSL_DIR/archipelago.crt" -KEY="$SSL_DIR/archipelago.key" - -# Create SSL directory -mkdir -p "$SSL_DIR" -chmod 755 "$SSL_DIR" - -# Generate self-signed cert if missing (valid 365 days) -# SAN includes common dev IPs so cert works when accessing via IP -# Build dynamic SAN with all node IPs (LAN + Tailscale + loopback) -SAN_IPS="DNS:archipelago.local,DNS:localhost,IP:127.0.0.1" -# Add all IPv4 addresses on this machine (LAN, Tailscale, etc.) -for ip in $(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.' | grep -v '^127\.'); do - SAN_IPS="$SAN_IPS,IP:$ip" -done -# Always include common LAN IPs as fallback -for ip in 192.168.1.228 192.168.1.198 10.0.0.1; do - echo "$SAN_IPS" | grep -q "$ip" || SAN_IPS="$SAN_IPS,IP:$ip" -done - -# Regenerate cert if missing OR if current cert doesn't include this node's primary IP -REGEN=false -if [ ! -f "$CERT" ] || [ ! -f "$KEY" ]; then - REGEN=true -else - # Check if cert has this node's primary IP - MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}') - if [ -n "$MY_IP" ] && ! openssl x509 -in "$CERT" -noout -text 2>/dev/null | grep -q "$MY_IP"; then - echo " Certificate missing this node's IP ($MY_IP) — regenerating..." - REGEN=true - fi -fi - -if [ "$REGEN" = true ]; then - echo "Generating self-signed certificate for PWA (HTTPS)..." - echo " SAN: $SAN_IPS" - openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ - -keyout "$KEY" \ - -out "$CERT" \ - -subj "/CN=archipelago.local/O=Archipelago/C=US" \ - -addext "subjectAltName=$SAN_IPS" - chmod 644 "$CERT" - chmod 600 "$KEY" - echo " Certificate created at $CERT" -fi - -# PWA snippet for manifest + service worker headers (required for Android install) -NGINX_SNIPPETS="/etc/nginx/snippets" -PWA_SNIPPET="$NGINX_SNIPPETS/archipelago-pwa.conf" -mkdir -p "$NGINX_SNIPPETS" -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -if [ -f "$SCRIPT_DIR/nginx-pwa-snippet.conf" ]; then - cp "$SCRIPT_DIR/nginx-pwa-snippet.conf" "$PWA_SNIPPET" - echo " PWA nginx snippet installed at $PWA_SNIPPET" -fi - -# Add PWA snippet include to existing HTTPS block if missing -if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && [ -f "$PWA_SNIPPET" ]; then - if ! grep -q "archipelago-pwa" "$NGINX_CFG" 2>/dev/null; then - echo " Adding PWA snippet include to HTTPS block..." - # Insert include after "index index.html;" within the HTTPS server block (listen 443 to next }) - sed -i '/listen 443 ssl/,/^}$/{ - /index index.html;/a\ - include snippets/archipelago-pwa.conf; - }' "$NGINX_CFG" 2>/dev/null || true - fi -fi - -# Install app proxies snippet (mempool, fedimint, lnd, etc.) - fixes apps not opening over HTTPS (mixed content) -APPS_SNIPPET="$NGINX_SNIPPETS/archipelago-https-app-proxies.conf" -if [ -f "$SCRIPT_DIR/nginx-https-app-proxies.conf" ]; then - cp "$SCRIPT_DIR/nginx-https-app-proxies.conf" "$APPS_SNIPPET" - echo " HTTPS app proxies snippet installed at $APPS_SNIPPET" - # Add include to HTTPS block if missing - if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null && ! grep -q "archipelago-https-app-proxies" "$NGINX_CFG" 2>/dev/null; then - echo " Adding app proxies include to HTTPS block..." - sed -i '/listen 443 ssl/,/^}$/{ - /location \/ws {/i\ - include snippets/archipelago-https-app-proxies.conf; - }' "$NGINX_CFG" 2>/dev/null || true - fi -fi - -# Check if HTTPS is already configured -if grep -q "listen 443 ssl" "$NGINX_CFG" 2>/dev/null; then - echo "HTTPS already configured in nginx." - nginx -t 2>/dev/null && systemctl reload nginx - MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}') - echo "" - echo "PWA: Use https://${MY_IP:-192.168.1.228} (not http) - accept cert once, then Install app." - exit 0 -fi - -# Add HTTPS server block (duplicate of HTTP block with SSL) -# PWA requires HTTPS for install on Android -HTTPS_BLOCK=' -# HTTPS - required for PWA install (Add to Home Screen) from dev servers -server { - listen 443 ssl; - server_name _; - - ssl_certificate '"$CERT"'; - ssl_certificate_key '"$KEY"'; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; - - root /opt/archipelago/web-ui; - index index.html; - include snippets/archipelago-pwa.conf; - - location / { - try_files $uri $uri/ /index.html; - } - - location /archipelago/ { - proxy_pass http://127.0.0.1:5678; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } - - location /rpc/ { - proxy_pass http://127.0.0.1:5678; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_connect_timeout 600s; - proxy_send_timeout 600s; - proxy_read_timeout 600s; - } - - location /app/nextcloud/ { - proxy_pass http://127.0.0.1:8085/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/vaultwarden/ { - proxy_pass http://127.0.0.1:8082/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - } - location /app/immich/ { - proxy_pass http://127.0.0.1:2283/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/penpot/ { - proxy_pass http://127.0.0.1:9001/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/btcpay/ { - proxy_pass http://127.0.0.1:23000/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - } - location /app/homeassistant/ { - proxy_pass http://127.0.0.1:8123/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 86400s; - proxy_send_timeout 86400s; - } - location /app/mempool/ { - proxy_pass http://127.0.0.1:4080/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/fedimint/ { - proxy_pass http://127.0.0.1:8175/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/lnd/ { - proxy_pass http://127.0.0.1:18083/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - proxy_read_timeout 300s; - proxy_send_timeout 300s; - } - location /app/bitcoin-ui/ { - proxy_pass http://127.0.0.1:8334/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_hide_header X-Frame-Options; - proxy_hide_header Content-Security-Policy; - } - - location /ws { - proxy_pass http://127.0.0.1:5678; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $host; - proxy_read_timeout 86400s; - } -} -' - -# Append HTTPS block to nginx config -echo "$HTTPS_BLOCK" >> "$NGINX_CFG" -echo "Added HTTPS (port 443) to nginx config." - -# Test and reload -nginx -t && systemctl reload nginx -echo "" -MY_IP=$(hostname -I 2>/dev/null | awk '{print $1}') -echo "HTTPS enabled. PWA install: https://${MY_IP:-192.168.1.228} (accept the certificate warning once, then Install app)." diff --git a/scripts/setup-target-dev.sh b/scripts/setup-target-dev.sh deleted file mode 100755 index 251a53e1..00000000 --- a/scripts/setup-target-dev.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash -# -# Setup development environment on Archipelago target machine -# -# Run this ON the HP ProDesk via SSH: -# curl -sSL https://raw.githubusercontent.com/.../setup-target-dev.sh | bash -# Or copy and run locally: -# scp scripts/setup-target-dev.sh archipelago@192.168.1.228:~/ -# ssh archipelago@192.168.1.228 'bash ~/setup-target-dev.sh' -# - -set -e - -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ Setting up Archipelago Development Environment ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" - -# Update packages -echo "📦 Updating packages..." -sudo apt update - -# Install Node.js (for Vue.js frontend) -echo "" -echo "📦 Installing Node.js..." -if ! command -v node &> /dev/null; then - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - - sudo apt install -y nodejs -else - echo " Node.js already installed: $(node --version)" -fi - -# Install Rust (for backend) -echo "" -echo "📦 Installing Rust..." -if ! command -v cargo &> /dev/null; then - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - source ~/.cargo/env -else - echo " Rust already installed: $(rustc --version)" -fi - -# Install build tools -echo "" -echo "📦 Installing build tools..." -sudo apt install -y \ - build-essential \ - pkg-config \ - libssl-dev \ - git - -# Create development directory -echo "" -echo "📁 Creating development directory..." -mkdir -p ~/archy - -# Fix XDG_RUNTIME_DIR for rootless Podman (add to bashrc) -if ! grep -q "XDG_RUNTIME_DIR" ~/.bashrc; then - echo "" - echo "🔧 Fixing Podman rootless setup..." - cat >> ~/.bashrc << 'EOF' - -# Fix for rootless Podman -if [ -z "$XDG_RUNTIME_DIR" ]; then - export XDG_RUNTIME_DIR=/run/user/$(id -u) - if [ ! -d "$XDG_RUNTIME_DIR" ]; then - sudo mkdir -p "$XDG_RUNTIME_DIR" - sudo chown $(whoami):$(whoami) "$XDG_RUNTIME_DIR" - sudo chmod 700 "$XDG_RUNTIME_DIR" - fi -fi -EOF -fi - -# Enable user lingering for Podman -sudo loginctl enable-linger archipelago 2>/dev/null || true - -echo "" -echo "╔════════════════════════════════════════════════════════════════╗" -echo "║ ✅ Development environment ready! ║" -echo "╚════════════════════════════════════════════════════════════════╝" -echo "" -echo "Installed:" -echo " • Node.js: $(node --version 2>/dev/null || echo 'not found')" -echo " • npm: $(npm --version 2>/dev/null || echo 'not found')" -echo " • Rust: $(rustc --version 2>/dev/null || echo 'not found')" -echo " • Cargo: $(cargo --version 2>/dev/null || echo 'not found')" -echo "" -echo "Next steps:" -echo " 1. Log out and back in (or run: source ~/.bashrc)" -echo " 2. From your Mac, run: ./scripts/deploy-to-target.sh" -echo " 3. To start Vue.js dev server: cd ~/archy/neode-ui && npm run dev -- --host 0.0.0.0" -echo "" diff --git a/scripts/sign-catalog.sh b/scripts/sign-catalog.sh index 71ea7021..42a2d04c 100755 --- a/scripts/sign-catalog.sh +++ b/scripts/sign-catalog.sh @@ -23,6 +23,18 @@ if [[ ! -x "$BIN" ]]; then fi SIGN=("$BIN" ceremony sign "$CATALOG") +# Preflight BEFORE asking for the mnemonic. Signing is the point of no return: +# a signed catalog is authoritative for every node, and its image refs override +# the on-disk manifests. If it names a registry host the deployed fleet does not +# trust, every install fails "not from a trusted registry" — so catch that here +# rather than after publication. +if ! python3 "$REPO/scripts/check-catalog-registry-trust.py" --repo "$REPO"; then + echo + echo "✋ Refusing to sign. Nothing was changed and your mnemonic was not requested." + exit 1 +fi +echo + echo "════════════════════════════════════════════════════════════════" echo " Paste your 24-word release master mnemonic below, press Enter," echo " then press Ctrl-D on a new line." diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index b54a3074..acf75af8 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -5,7 +5,11 @@ set -euo pipefail -HOST="${1:-192.168.1.198}" +HOST="${1:-${ARCHY_HOST:-}}" +if [ -z "$HOST" ]; then + echo "usage: $0 (or set ARCHY_HOST)" >&2 + exit 2 +fi PASS=0 FAIL=0 FAILURES="" diff --git a/scripts/trust-archipelago-cert.sh b/scripts/trust-archipelago-cert.sh index 55120f2b..75afc0d1 100755 --- a/scripts/trust-archipelago-cert.sh +++ b/scripts/trust-archipelago-cert.sh @@ -1,17 +1,21 @@ #!/bin/bash # # Trust the Archipelago server's self-signed certificate on macOS. -# Run this to eliminate "Not secure" when accessing https://192.168.1.228 +# Run this to eliminate "Not secure" when accessing https:// # # Usage: ./scripts/trust-archipelago-cert.sh [host] -# Default host: 192.168.1.228 +# Host is required: pass it as $1 or set ARCHY_HOST # # Requires: SSH access to archipelago@host (uses deploy-config.sh password) # set -e -HOST="${1:-192.168.1.228}" +HOST="${1:-${ARCHY_HOST:-}}" +if [ -z "$HOST" ]; then + echo "usage: $0 (or set ARCHY_HOST)" >&2 + exit 2 +fi CERT_FILE="/tmp/archipelago-${HOST}.crt" KEYCHAIN="${HOME}/Library/Keychains/login.keychain-db" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" diff --git a/scripts/validate-app-manifest.sh b/scripts/validate-app-manifest.sh index 657957a6..b4e117df 100755 --- a/scripts/validate-app-manifest.sh +++ b/scripts/validate-app-manifest.sh @@ -47,24 +47,68 @@ check() { esac } +# Preflight the YAML parser BEFORE any check runs. This used to shell out to +# ruby with stderr discarded, so a machine without ruby reported "invalid YAML" +# and rejected every manifest that was in fact perfectly valid — the first tool +# an app developer runs, failing with a message that sent them to fix the wrong +# thing. Fail loudly about the real cause instead. +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required to validate manifests, but was not found." >&2 + exit 3 +fi +if ! python3 -c 'import yaml' >/dev/null 2>&1; then + echo "ERROR: the PyYAML module is required to validate manifests." >&2 + echo " Install it with: python3 -m pip install pyyaml" >&2 + echo " (Debian/Ubuntu: apt-get install python3-yaml)" >&2 + exit 3 +fi + +# Evaluate a path expression against the manifest's top-level `app` block. +# Missing keys yield an empty string rather than an error, so callers can write +# a plain chain like app["container"]["build"]["tag"] without guarding each hop. yaml_eval() { - ruby -ryaml -e ' - path, expr = ARGV - data = YAML.load_file(path) - app = data.is_a?(Hash) ? data["app"] : nil - abort "missing top-level app block" unless app.is_a?(Hash) - value = eval(expr) - case value - when Array - puts value.join("\n") - when Hash - puts value.to_a.map { |k, v| "#{k}=#{v}" }.join("\n") - when NilClass - puts "" - else - puts value - end - ' "$MANIFEST" "$1" + python3 -c ' +import sys, yaml + +class Nil: + """Absent value: indexes to itself, is falsy, prints as empty.""" + def __getitem__(self, key): return self + def __bool__(self): return False + def __str__(self): return "" + def __iter__(self): return iter(()) + +NIL = Nil() + +class SafeDict(dict): + def __missing__(self, key): return NIL + +def wrap(value): + if isinstance(value, dict): + return SafeDict({k: wrap(v) for k, v in value.items()}) + if isinstance(value, list): + return [wrap(v) for v in value] + return value + +path, expr = sys.argv[1], sys.argv[2] +with open(path) as fh: + data = yaml.safe_load(fh) +app = data.get("app") if isinstance(data, dict) else None +if not isinstance(app, dict): + sys.exit("missing top-level app block") +app = wrap(app) + +value = eval(expr, {"__builtins__": {}}, {"app": app}) +if isinstance(value, list): + print("\n".join(str(v) for v in value)) +elif isinstance(value, dict): + print("\n".join(f"{k}={v}" for k, v in value.items())) +elif value is None or isinstance(value, Nil): + print("") +elif isinstance(value, bool): + print("true" if value else "false") +else: + print(value) +' "$MANIFEST" "$1" } echo "Validating: $MANIFEST" @@ -76,7 +120,12 @@ if [[ ! -f "$MANIFEST" ]]; then fi check "File exists" "pass" -if ! ruby -ryaml -e 'data = YAML.load_file(ARGV[0]); exit(data.is_a?(Hash) && data["app"].is_a?(Hash) ? 0 : 1)' "$MANIFEST" 2>/dev/null; then +if ! python3 -c ' +import sys, yaml +with open(sys.argv[1]) as fh: + data = yaml.safe_load(fh) +sys.exit(0 if isinstance(data, dict) and isinstance(data.get("app"), dict) else 1) +' "$MANIFEST" 2>/dev/null; then check "Valid YAML with top-level app block" "fail" echo "" echo "Results: $PASS passed, $FAIL failed, $WARN warnings" @@ -90,9 +139,9 @@ APP_NAME="$(yaml_eval 'app["name"]')" APP_VERSION="$(yaml_eval 'app["version"]')" APP_DESCRIPTION="$(yaml_eval 'app["description"]')" APP_INTERNAL="$(yaml_eval 'app["internal"]')" -IMAGE="$(yaml_eval '(app["container"] || {})["image"]')" -BUILD_CONTEXT="$(yaml_eval '(((app["container"] || {})["build"] || {})["context"])')" -BUILD_TAG="$(yaml_eval '(((app["container"] || {})["build"] || {})["tag"])')" +IMAGE="$(yaml_eval 'app["container"]["image"]')" +BUILD_CONTEXT="$(yaml_eval 'app["container"]["build"]["context"]')" +BUILD_TAG="$(yaml_eval 'app["container"]["build"]["tag"]')" if [[ "$APP_ID" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then check "app.id is lowercase kebab-case ($APP_ID)" "pass" @@ -137,7 +186,7 @@ fi if [[ -n "$IMAGE" ]]; then TRUSTED=false - for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "146.59.87.168:3000" "localhost/"; do + for reg in "docker.io" "ghcr.io" "quay.io" "registry.hub.docker.com" "source.archipelago-foundation.org" "localhost/"; do if [[ "$IMAGE" == *"$reg"* ]]; then TRUSTED=true break @@ -164,15 +213,15 @@ if [[ -n "$IMAGE" ]]; then fi fi -MEMORY_LIMIT="$(yaml_eval '((app["resources"] || {})["memory_limit"] || (app["resources"] || {})["memory"])')" -CPU_LIMIT="$(yaml_eval '((app["resources"] || {})["cpu_limit"] || (app["resources"] || {})["cpu"])')" +MEMORY_LIMIT="$(yaml_eval 'app["resources"]["memory_limit"] or app["resources"]["memory"]')" +CPU_LIMIT="$(yaml_eval 'app["resources"]["cpu_limit"] or app["resources"]["cpu"]')" [[ -n "$MEMORY_LIMIT" ]] && check "resources.memory_limit specified ($MEMORY_LIMIT)" "pass" || check "resources.memory_limit specified" "warn" [[ -n "$CPU_LIMIT" ]] && check "resources.cpu_limit specified ($CPU_LIMIT)" "pass" || check "resources.cpu_limit specified" "warn" -READONLY_ROOT="$(yaml_eval '((app["security"] || {})["readonly_root"])')" -NO_NEW_PRIVS="$(yaml_eval '((app["security"] || {})["no_new_privileges"])')" -NETWORK_POLICY="$(yaml_eval '((app["security"] || {})["network_policy"])')" -CONTAINER_NETWORK="$(yaml_eval '((app["container"] || {})["network"])')" +READONLY_ROOT="$(yaml_eval 'app["security"]["readonly_root"]')" +NO_NEW_PRIVS="$(yaml_eval 'app["security"]["no_new_privileges"]')" +NETWORK_POLICY="$(yaml_eval 'app["security"]["network_policy"]')" +CONTAINER_NETWORK="$(yaml_eval 'app["container"]["network"]')" if [[ "$READONLY_ROOT" == "true" || -z "$READONLY_ROOT" ]]; then check "security.readonly_root true (explicit or Rust default)" "pass" @@ -200,7 +249,7 @@ else check "container.network does not share another namespace" "pass" fi -SECRET_ENV="$(yaml_eval '(app["environment"] || [])')" +SECRET_ENV="$(yaml_eval 'app["environment"]')" if echo "$SECRET_ENV" | grep -iqE '^[A-Z0-9_]*(PASSWORD|PASS|SECRET|TOKEN|API_KEY|PRIVATE_KEY)[A-Z0-9_]*=.+$'; then check "no hardcoded secret-like values in app.environment" "warn" else @@ -218,33 +267,47 @@ if [[ -n "$APP_ID" && -n "$MANIFEST" ]]; then fi fi -PORT_CHECK="$(ruby -ryaml -e ' - current = ARGV[0] - current_id = File.basename(File.dirname(current)) - ports = {} - Dir.glob("apps/*/manifest.yml").sort.each do |path| - data = YAML.load_file(path) - app = data.is_a?(Hash) ? data["app"] : nil - next unless app.is_a?(Hash) - id = app["id"] || File.basename(File.dirname(path)) - next if id == current_id - Array(app["ports"]).each do |p| - next unless p.is_a?(Hash) - proto = p["protocol"] || "tcp" - bind = p["bind"] || "" - host = p["host"] - ports[[host, proto, bind]] = id if host - end - end - data = YAML.load_file(current) - app = data["app"] - conflicts = [] - Array(app["ports"]).each do |p| - next unless p.is_a?(Hash) - key = [p["host"], p["protocol"] || "tcp", p["bind"] || ""] - conflicts << "#{key[2].empty? ? "*" : key[2]}:#{key[0]}/#{key[1]} already used by #{ports[key]}" if ports.key?(key) - end - puts conflicts.join("\n") +PORT_CHECK="$(python3 -c ' +import glob, os, sys, yaml + +def load_app(path): + try: + with open(path) as fh: + data = yaml.safe_load(fh) + except Exception: + return None + return data.get("app") if isinstance(data, dict) else None + +current = sys.argv[1] +current_id = os.path.basename(os.path.dirname(current)) + +def port_keys(app): + for entry in (app.get("ports") or []): + if not isinstance(entry, dict): + continue + host = entry.get("host") + if not host: + continue + yield (host, entry.get("protocol") or "tcp", entry.get("bind") or "") + +claimed = {} +for path in sorted(glob.glob("apps/*/manifest.yml")): + app = load_app(path) + if not isinstance(app, dict): + continue + app_id = app.get("id") or os.path.basename(os.path.dirname(path)) + if app_id == current_id: + continue + for key in port_keys(app): + claimed[key] = app_id + +app = load_app(current) +if isinstance(app, dict): + for key in port_keys(app): + if key in claimed: + host, proto, bind = key + shown = bind if bind else "*" + print(f"{shown}:{host}/{proto} already used by {claimed[key]}") ' "$MANIFEST")" if [[ -n "$PORT_CHECK" ]]; then while IFS= read -r conflict; do diff --git a/tests/lifecycle/TESTING.md b/tests/lifecycle/TESTING.md index 494bdf26..c018a390 100644 --- a/tests/lifecycle/TESTING.md +++ b/tests/lifecycle/TESTING.md @@ -28,7 +28,7 @@ The migration's aim, restated as **five pillars** (every app must satisfy all fi (install / UI reachable / stop / start / restart / reinstall / reboot-survive / archipelago-restart-survive / uninstall) **5× green on .228** — run ON the node (`ARCHY_ITERATIONS=5`). - (Multinode / fleet → `docs/multinode-testing-plan.md`, separate.) + (Multinode / fleet testing is tracked separately.) before any release. 4. **Data-driven apps** — install/uninstall needs only the app's manifest + catalog entry. **No host OS changes** (no apt, no /etc, no host units) and @@ -44,7 +44,7 @@ The migration's aim, restated as **five pillars** (every app must satisfy all fi green on .228 (run ON the node) → catalog/registry updated (`app-catalog/catalog.json` + `releases/app-catalog.json`, rebuilt image pushed to the mirror) → tracker cell ticked. Only then move to the next app. (Fleet/multinode verification is a -separate pass → `docs/multinode-testing-plan.md`.) +separate pass, tracked internally.) **.228 testing constraint:** do NOT touch `bitcoin-knots`, `electrumx`, or `lnd` on .228 — they are synced and healthy; destructive cycles there would @@ -57,7 +57,7 @@ rollout for fedimint-gateway/-clientd, icon/naming fixes) is **done and shipped**: the generated-secrets system is a platform primitive (`container.generated_secrets`, see `docs/app-manifest-spec.md`), the manifests declare it, and the single-node gate went green on .228 on -2026-06-23. Day-to-day open items live in `docs/UNIFIED-TASK-TRACKER.md` — +2026-06-23. Day-to-day open items live in the issue tracker — don't add session logs here. --- @@ -75,7 +75,7 @@ don't add session logs here. | L6 — Performance | Cold install latency, reconcile-tick cost, podman call count per lifecycle event | timed bats + Prometheus (TBD) | ~60s per benchmark | Release gate: **L0+L1+L2+L3 green × 20 iterations** on .228 (run ON the node; 5× for -now). Multinode/fleet → `docs/multinode-testing-plan.md`. L4+L5+L6 are quality gates +now). Multinode/fleet testing is a separate pass. L4+L5+L6 are quality gates we add as they mature; not blocking the v1.7.52 tag. ## Coverage matrix — current state @@ -235,13 +235,13 @@ We don't have a performance harness yet. Add as L6 lands: ## Release gates -1.8.0 ships only when ALL of (see `docs/UNIFIED-TASK-TRACKER.md` for the live +1.8.0 ships only when ALL of (see the issue tracker for the live priority-ordered list of what's still open across these): 1. ☑ Bitcoin-stops fix verified live on a fresh node (`tests/lifecycle/bats/bitcoin-knots.bats` stop/restart tier, part of the green single-node gate) 2. ☑ `ARCHY_ITERATIONS=5 tests/lifecycle/run-gate.sh` returns 0 **run ON .228** — GREEN 2026-06-23, 5/5, 0 failures -3. ☐ Multinode/fleet (.198 + others) — tracked separately in `docs/multinode-testing-plan.md`, +3. ☐ Multinode/fleet — tracked separately, the actual next exit criterion, NOT satisfied yet 4. ☐ The L3 `backend-survives-archipelago-restart` suite passes fleet-wide default-on (Phase 3 Quadlet is merged + validated but still opt-in via `ARCHIPELAGO_USE_QUADLET_BACKENDS` diff --git a/tests/lifecycle/bats/immich.bats b/tests/lifecycle/bats/immich.bats index fd305642..1f6df607 100644 --- a/tests/lifecycle/bats/immich.bats +++ b/tests/lifecycle/bats/immich.bats @@ -15,7 +15,7 @@ load '../lib/rpc.bash' -IMMICH_IMAGE="146.59.87.168:3000/lfg2025/immich-server:release" +IMMICH_IMAGE="source.archipelago-foundation.org/lfg2025/immich-server:release" setup_file() { : "${ARCHY_PASSWORD:?Set ARCHY_PASSWORD env var to the UI password}" diff --git a/tests/lifecycle/remote-lifecycle.sh b/tests/lifecycle/remote-lifecycle.sh index 2dfd0ba6..a761d106 100755 --- a/tests/lifecycle/remote-lifecycle.sh +++ b/tests/lifecycle/remote-lifecycle.sh @@ -133,29 +133,29 @@ is_pruned_node() { image_for() { case "$1" in - bitcoin-knots) echo "146.59.87.168:3000/lfg2025/bitcoin-knots:latest" ;; + bitcoin-knots) echo "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest" ;; bitcoin-core) echo "docker.io/bitcoin/bitcoin:28.4" ;; - btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.3.9" ;; - lnd) echo "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta" ;; - mempool) echo "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.0" ;; - homeassistant) echo "146.59.87.168:3000/lfg2025/home-assistant:2024.1" ;; - grafana) echo "146.59.87.168:3000/lfg2025/grafana:10.2.0" ;; - searxng) echo "146.59.87.168:3000/lfg2025/searxng:latest" ;; - ollama) echo "146.59.87.168:3000/lfg2025/ollama:latest" ;; - nextcloud) echo "146.59.87.168:3000/lfg2025/nextcloud:28" ;; - vaultwarden) echo "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine" ;; - jellyfin) echo "146.59.87.168:3000/lfg2025/jellyfin:10.8.13" ;; - photoprism) echo "146.59.87.168:3000/lfg2025/photoprism:240915" ;; - immich) echo "146.59.87.168:3000/lfg2025/immich-server:release" ;; - filebrowser) echo "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0" ;; - nginx-proxy-manager) echo "146.59.87.168:3000/lfg2025/nginx-proxy-manager:latest" ;; - portainer) echo "146.59.87.168:3000/lfg2025/portainer:latest" ;; - uptime-kuma) echo "146.59.87.168:3000/lfg2025/uptime-kuma:1" ;; - tailscale) echo "146.59.87.168:3000/lfg2025/tailscale:stable" ;; - electrumx) echo "146.59.87.168:3000/lfg2025/electrumx:v1.18.0" ;; - fedimint) echo "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0" ;; - indeedhub) echo "146.59.87.168:3000/lfg2025/indeedhub:1.0.0" ;; - botfights) echo "146.59.87.168:3000/lfg2025/botfights:1.1.0" ;; + btcpay-server) echo "docker.io/btcpayserver/btcpayserver:2.4.2" ;; + lnd) echo "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta" ;; + mempool) echo "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.0" ;; + homeassistant) echo "source.archipelago-foundation.org/lfg2025/home-assistant:2024.1" ;; + grafana) echo "source.archipelago-foundation.org/lfg2025/grafana:10.2.0" ;; + searxng) echo "source.archipelago-foundation.org/lfg2025/searxng:latest" ;; + ollama) echo "source.archipelago-foundation.org/lfg2025/ollama:latest" ;; + nextcloud) echo "source.archipelago-foundation.org/lfg2025/nextcloud:28" ;; + vaultwarden) echo "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine" ;; + jellyfin) echo "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13" ;; + photoprism) echo "source.archipelago-foundation.org/lfg2025/photoprism:240915" ;; + immich) echo "source.archipelago-foundation.org/lfg2025/immich-server:release" ;; + filebrowser) echo "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0" ;; + nginx-proxy-manager) echo "source.archipelago-foundation.org/lfg2025/nginx-proxy-manager:latest" ;; + portainer) echo "source.archipelago-foundation.org/lfg2025/portainer:latest" ;; + uptime-kuma) echo "source.archipelago-foundation.org/lfg2025/uptime-kuma:1" ;; + tailscale) echo "source.archipelago-foundation.org/lfg2025/tailscale:stable" ;; + electrumx) echo "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0" ;; + fedimint) echo "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0" ;; + indeedhub) echo "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0" ;; + botfights) echo "source.archipelago-foundation.org/lfg2025/botfights:1.1.0" ;; gitea) echo "docker.io/gitea/gitea:1.23" ;; *) return 1 ;; esac diff --git a/tests/mesh/run-mesh-tests.sh b/tests/mesh/run-mesh-tests.sh index 7911dd8e..e55597bf 100755 --- a/tests/mesh/run-mesh-tests.sh +++ b/tests/mesh/run-mesh-tests.sh @@ -11,7 +11,7 @@ # Usage: # tests/mesh/run-mesh-tests.sh # layers 1+2 # MESH_TEST_LIVE=1 MESH_TEST_PW='...' tests/mesh/run-mesh-tests.sh -# MESH_TEST_HOST=100.113.100.55 ... # live-test a remote node +# MESH_TEST_HOST=100.64.0.55 ... # live-test a remote node set -u cd "$(dirname "$0")/../.." FAIL=0 diff --git a/tests/multinode/.env.example b/tests/multinode/.env.example index 5baca825..129e1cf7 100644 --- a/tests/multinode/.env.example +++ b/tests/multinode/.env.example @@ -3,8 +3,8 @@ # NEVER commit real node passwords. # smoke.sh / repro-federation-sync.sh -A_PW=changeme # node A (default URL http://192.168.1.116) -B_PW=changeme # node B (default URL https://192.168.1.228) +A_PW=changeme # node A (default URL http://192.0.2.12) +B_PW=changeme # node B (default URL https://192.0.2.10) #C_URL=https://x.x.x.x # optional third node #C_PW=changeme diff --git a/tests/multinode/lib/multinode.bash b/tests/multinode/lib/multinode.bash index 84023802..3ed10942 100755 --- a/tests/multinode/lib/multinode.bash +++ b/tests/multinode/lib/multinode.bash @@ -11,8 +11,8 @@ # # Usage: # source tests/multinode/lib/multinode.bash -# node_register A https://192.168.1.228 "$A_PW" -# node_register B http://192.168.1.116 "$B_PW" +# node_register A https://192.0.2.10 "$A_PW" +# node_register B http://192.0.2.12 "$B_PW" # node_login A; node_login B # node_rpc A node.tor-address # node_result B federation.list-nodes diff --git a/tests/multinode/meshtastic.sh b/tests/multinode/meshtastic.sh index c5cec31e..d6713ad1 100755 --- a/tests/multinode/meshtastic.sh +++ b/tests/multinode/meshtastic.sh @@ -23,7 +23,7 @@ # # Nodes override via env (each must have a Meshtastic radio on the SAME LoRa # channel/region so they can actually hear each other): -# MA_URL MA_PW node A (sender) default .116 http / ThisIsWeb54321@ +# MA_URL MA_PW node A (sender) default .116 http / # MB_URL MB_PW node B (receiver) default .228 https / password123 # MC_URL MC_PW node C (eavesdrop) OPTIONAL — enables privacy test (4) # @@ -33,8 +33,8 @@ # # Usage: # tests/multinode/meshtastic.sh -# MA_URL=http://192.168.1.116 MB_URL=https://192.168.1.228 \ -# MC_URL=https://192.168.1.198 tests/multinode/meshtastic.sh +# MA_URL=http://192.0.2.12 MB_URL=https://192.0.2.10 \ +# MC_URL=https://192.0.2.11 tests/multinode/meshtastic.sh # # Requires: curl, jq. Exit code = number of failed assertions (0 = all green). @@ -44,8 +44,8 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$HERE/lib/multinode.bash" # ── node registration ────────────────────────────────────────────────────── -MA_URL="${MA_URL:-http://192.168.1.116}"; MA_PW="${MA_PW:?MA_PW required — export it or set tests/multinode/.env (see .env.example)}" -MB_URL="${MB_URL:-https://192.168.1.228}"; MB_PW="${MB_PW:?MB_PW required — export it or set tests/multinode/.env (see .env.example)}" +MA_URL="${MA_URL:-http://192.0.2.12}"; MA_PW="${MA_PW:?MA_PW required — export it or set tests/multinode/.env (see .env.example)}" +MB_URL="${MB_URL:-https://192.0.2.10}"; MB_PW="${MB_PW:?MB_PW required — export it or set tests/multinode/.env (see .env.example)}" MC_URL="${MC_URL:-}"; MC_PW="${MC_PW:-}" PROP_WAIT="${PROP_WAIT:-45}" MB_NAME="${MB_NAME:-}" diff --git a/tests/multinode/repro-federation-sync.sh b/tests/multinode/repro-federation-sync.sh index 7de94bff..a50a3c6c 100755 --- a/tests/multinode/repro-federation-sync.sh +++ b/tests/multinode/repro-federation-sync.sh @@ -18,8 +18,8 @@ set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$HERE/lib/multinode.bash" -A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}" -B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}" +A_URL="${A_URL:-http://192.0.2.12}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}" +B_URL="${B_URL:-https://192.0.2.10}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}" bar() { printf '\n=== %s ===\n' "$*"; } diff --git a/tests/multinode/smoke.sh b/tests/multinode/smoke.sh index 1cbbe71b..bb2c1cd1 100644 --- a/tests/multinode/smoke.sh +++ b/tests/multinode/smoke.sh @@ -21,13 +21,13 @@ # # Usage: # tests/multinode/smoke.sh -# A_URL=http://192.168.1.116 B_URL=https://192.168.1.228 tests/multinode/smoke.sh +# A_URL=http://192.0.2.12 B_URL=https://192.0.2.10 tests/multinode/smoke.sh set -uo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$HERE/lib/multinode.bash" -A_URL="${A_URL:-http://192.168.1.116}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}" -B_URL="${B_URL:-https://192.168.1.228}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}" +A_URL="${A_URL:-http://192.0.2.12}"; A_PW="${A_PW:?A_PW required — export it or set tests/multinode/.env (see .env.example)}" +B_URL="${B_URL:-https://192.0.2.10}"; B_PW="${B_PW:?B_PW required — export it or set tests/multinode/.env (see .env.example)}" C_URL="${C_URL:-}"; C_PW="${C_PW:-}" # ── tiny assertion framework ────────────────────────────────────────────── diff --git a/tests/production-quality/TRACKER.md b/tests/production-quality/TRACKER.md deleted file mode 100644 index 02d0601c..00000000 --- a/tests/production-quality/TRACKER.md +++ /dev/null @@ -1,260 +0,0 @@ -# ▶▶ SESSION SAVE / RESUME (2026-06-16) — v1.7.97-alpha CUT, mid-rollout - -**v1.7.97-alpha is BUILT + TAGGED LOCALLY but NOT yet published to the fleet.** -- Release commit `47c16971` ("chore: release v1.7.97-alpha") + tag `v1.7.97-alpha` exist on LOCAL main only. NOT pushed to gitea-vps2. Fleet still sees 1.7.96-alpha. -- Contents (14 fixes + image-opt): B5,B1,B2,B4,B14,B21,B3,B15,B7,B13,B12,B16,**B17**, B6-pruned-gate + lossless background-image optimization (bg-mesh PNG→JPEG). -- Release artifacts staged: `releases/v1.7.97-alpha/{archipelago, archipelago-frontend-1.7.97-alpha.tar.gz}` + `/tmp/archipelago-frontend-1.7.97-alpha.tar.gz` (177MB, flat layout verified, optimized images baked in, no APK). -- **Deployed (sideload, NOT fleet OTA):** .116 = on 1.7.97-alpha, healthy, B17 self-heal CONFIRMED (unit now has RequiresMountsFor, 36 containers survived restart). .198 = deploying (sideload binary+frontend). -- **Backup binaries for rollback:** `/usr/local/bin/archipelago.1.7.96-alpha.bak` on .116 and .198. - -**REMAINING (this session, user wants to do WITH them):** -1. Finish .198 sideload; then **UI-confirm fixes together on .116/.198** + close passing Gitea issues (#8,#9,#10,#11,#12,#14,#19(code-only),#20,#21,#22,#23,#24,#29). Issue map below. -2. **Publish to fleet:** `scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2` + `git push gitea-vps2 main + tag` (AFTER joint confirm — user's call). -3. **Cut a fresh ISO** (bakes B13 nginx + B17 unit + all frontend). ISO builds run on a server (deploy-to-target / .228). Then test the ISO together. - -⚠️ LESSON: never run the release binary to "check --version" — it has no such flag and BOOTS A FULL NODE (adopts containers, grabs mesh radio). Use `strings | grep version`. (Did this on .116; the instance exited on the :5678 port conflict, no harm.) - ---- - -# ▶▶ SESSION SAVE / RESUME (2026-06-15) - -**State:** v1.7.96-alpha SHIPPED. v1.7.97-alpha NOT cut yet — 10 fixes committed on **vps2 main** (`git remote: gitea-vps2`), nothing on the fleet yet. Validate on .116/.198 + UI-confirm BEFORE cutting .97. - -**Resume command (run elsewhere):** -``` -cd ~/Projects/archy && git fetch gitea-vps2 && git checkout main && git reset --hard gitea-vps2/main && cat tests/production-quality/TRACKER.md -``` -Then continue from "IN PROGRESS" below. - -**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7, **B13 (fedimint CSS self-heal — main conf + HTTPS snippet, verified .198 both paths app-icon 404→200)**, **B12 (mempool bitcoin-host detect across 3 render paths — unit-tested; live bitcoin-core validation pending)**, **B16 (bitcoin sync tile retain/Updating… — unit-tested 6/6, commit 83dbd25c)**. B6 pruned-gate already live. = 13 fixes. PLUS **image-optimization** (commit 386d4bfc — all bg images losslessly optimized, bg-mesh PNG→JPEG; user asked to include it in the .97 release). - -**IN PROGRESS — B16 DONE (commit 83dbd25c). Pick up at B6 no-node-present half.** B13 + B12 + B16 DONE (committed; see entries below). REMAINING: -1. **B6** no-node-present half, **B12b** (sibling bitcoin-host hardcodes: LND/BTCPay/electrumx/fedimint + mempool dep declaration — reuse `{{BITCOIN_HOST}}`; needs validation, esp. LND/fedimint), **B14b** (FIPS reachability depth), **B22/B23** (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature). -3. **Loose end:** 4 pre-existing prod_orchestrator test failures (generated-files/data_uid fixtures use disallowed tempdir volume sources) — see B12 NOTE; separate small fix. - -Note: .198 is running a sideloaded B13-era .97-dev binary (md5 4c83803d). The B12 binary was built (`core/target/release/archipelago`) but NOT sideloaded (mempool isn't on .198; .198 is Knots so B12 is a no-op there). Reflashing/OTA replaces the dev binary. - -**Ship .97 when ready:** ./scripts/create-release.sh 1.7.97-alpha (curate CHANGELOG ≥3 layman bullets first + run scripts/sync-whats-new.py; SKIP_RELEASE_TESTS=1 only for the 2 known-flaky vitest timing tests) → scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2 → git push gitea-vps2 main + tag. (gitea-local push fails: token rejected — non-blocking.) - ---- - -# Production-Quality Bug Tracker - -Living tracker for the post-v1.7.96 "no new features until production quality" push. -Updated continuously as we investigate → fix → test → pass. Kept in-repo so progress -survives a session cutoff. - -## Rules (from user, 2026-06-15) -- **No new features** until the OS is production / no-bugs quality. -- **Test-harness-first**: build/extend a harness for each bug before fixing. -- **Validate every fix on `.116` + `.198`** (both 192.168.1.x, pw ThisIsWeb54321@) **+ the harness** BEFORE it goes into any release. (.198 still carries the LND CORS nginx duplicate → good for fix-(a) validation; .116 does not.) -- **Priority order**: cloud/federated-nodes + mesh FIRST, then app-specific, then low-pri. - -## Status legend -`TODO` · `INVESTIGATING` · `ROOT-CAUSED` · `FIXING` · `TESTING` (on .116+harness) · `PASSED` · `SHIPPED` - -## Release status -- **v1.7.96-alpha — SHIPPED** (2026-06-15). Live on vps2 (primary OTA): manifest v1.7.96-alpha, assets HTTP 200, `main@8c3c7954` + tag present. Contents: kiosk grid removal + FIPS TCP/UDP anchor selector. NOTE: gitea-local (localhost) mirror push failed (token rejected → /login); non-blocking, needs refreshed token. -- **v1.7.97-alpha — IN PROGRESS** (this push). Will bundle the verified fixes below. - ---- - -## 🔴🔴 TOP PRIORITY - -### B5 — LND "connect your wallet" details/QR broken fleet-wide — ROOT-CAUSED -Origin: user escalation. Symptom: LND connect screen (served on app port :18083) can't load details/QR. -Two distinct root causes (confirmed live): -- **(a) Duplicate ACAO** on `/lnd-connect-info` (seen on .103): backend sets `Access-Control-Allow-Origin` (proxy.rs:108) AND nginx `add_header` adds a second → browser rejects "multiple values". nginx config drift. Fix: bootstrap.rs nginx patch must strip the redundant `add_header` from the `/lnd-connect-info` location (backend owns CORS). -- **(b) No ACAO on `/proxy/lnd/v1/*` 401** (fleet-wide): the unauth/auth-layer 401 is produced before the CORS-adding proxy handler (proxy.rs:135 `handle_lnd_proxy`). Browser → "No 'Access-Control-Allow-Origin' header". Fix: ensure auth-layer/early-return responses for `/proxy/lnd` + `/lnd-connect-info` carry CORS headers. -- `.116` `/lnd-connect-info` returns a single correct ACAO → symptom varies by node's nginx state. -- Backend CORS helper: handler/mod.rs `app_cors_origin()` (:270) — reflects Origin when its host == request host. -- Backend change → ships in .97. **Status: ✅ PASSED — verified on .116, .198, .103 (harness 4/4 each). Ready to bundle into .97.** -- Caveat: bootstrap's nginx dup-strip runs a few seconds AFTER /health goes green (async patch+reload) — converges within ~1 min of restart; not instant. Acceptable. -- **CODE CHANGES MADE (uncommitted):** - - `core/archipelago/src/bootstrap.rs`: added `NGINX_LND_DUP_CORS` const + strip in `patch_nginx_conf()` (removes the duplicate nginx `add_header` ACAO from `/lnd-connect-info` so the backend's single header wins). Idempotent; runs on startup nginx bootstrap. → fixes (a) - - `core/archipelago/src/api/handler/mod.rs`: new `unauthorized_cors(origin)` helper (:~205) + `/proxy/lnd/` route (:~505) computes origin first and returns `unauthorized_cors` so the 401 carries ACAO. → fixes (b) - - Test on **.116** for (b); test on **.103** for (a) [.116 has no dup to strip]. - - **2026-06-15 RESULT — .116 (fix b): harness 4/4 PASS** (sideloaded built binary, restarted). `/proxy/lnd/v1/*` now returns CORS on the 401. ✅ - - (Correction: an earlier "LND container MISSING" reading was a FALSE alarm — `docker` isn't in the non-interactive PATH; runtime is **podman**. Verified `lnd Up 9h` — containers SURVIVED the restart cleanly.) - - Next: deploy to .103 + run harness to confirm fix (a) (nginx dup strip). -- **Harness:** `tests/production-quality/lnd-cors-test.sh ` — asserts single correct ACAO on /lnd-connect-info + ACAO present on /proxy/lnd/v1/{getinfo,channels}. Baseline (2026-06-15): .116 = 2 pass/2 fail (proxy missing ACAO); .103 = 1 pass/3 fail (connect-info dup + proxy missing). -- **FIX PLAN (precise):** - 1. (b) handler/mod.rs:504-508 `/proxy/lnd/` returns `Self::unauthorized()` (401, NO CORS) when session check fails → browser CORS wall. Add CORS (app_cors_origin) to that 401. Same pattern for any other app-origin early-return. - 2. (a) nginx `/lnd-connect-info` location double-adds ACAO (backend + nginx `add_header`). Strip the nginx `add_header Access-Control-Allow-Origin` there; backend owns CORS. Update bootstrap.rs nginx patch to remove it on existing nodes (idempotent). - - Verify: rebuild backend, deploy to .116, run harness → expect 3/3 (or 4 assertions) PASS on .116 AND .103. - ---- - -## 🔴 PRIORITY — cloud / federation / mesh - -### B1 — Trusted-node list not clean — PASSED (onion-dedup; unit test 2/2; live .198 15→13 distinct, healthy). UI visual-confirm recommended. -Dupes, erroneous names, and non-convergent group membership across nodes. Expected: trusted nodes form a transitive group (every node connects to any newly-added trusted node; all nodes show the same set). `.103` has a long/dirty list. - -### B2 — Duplicate chat contact for one node — PASSED (resolved by load-dedup feeding mesh seed; unit-tested). UI visual-confirm recommended. -Federated peer "sapien" shows TWO chats: one "sapien" WITHOUT archy logo (looks non-federated) + one named by raw DID `did:key:z6MkoSbN5CM7fBaQg2nWbCymEkFXsHnuXvec9Mjo5RtJf9dQ`. Same node keyed by both federated identity and raw DID → merge to one. Code: core/archipelago/src/mesh + mesh/typed_messages.rs (note :233 — meshcore adverts don't carry archy pubkey). - -### B3 — Cloud peer media won't preview/play — FIXING (code done: /api/peer-content streaming proxy + playMedia streams free content) -Music/video preview files on peer nodes' cloud don't play (streaming/range/content-type over mesh+Tor peer fetch). - -### B4 — Cloud "my folders" fails (JSON parse / 502) — PASSED (content-type guard; built, guard in bundle, deployed .198). UI visual-confirm recommended. -`Unexpected token '<', "/` instead of SPA shell. Handle BOTH absent + down. - -### B14 — cloud browse transport not recorded — FIXED (record_peer_transport in 4 content handlers; build OK). NOTE: live data shows FIPS reaches only ~4/15 peers, 6 fall back to Tor genuinely → see B14b. -Browsing trusted/peer nodes in the Cloud tab connects over Tor instead of FIPS (should prefer FIPS like the rest of mesh; same for peer browsing). cf project_fips_integration, project_tor_node_to_node_works (last_transport should be fips/mesh). - ---- - -## 🟠 APP-SPECIFIC - -### B6 — ElectrumX install gate — PARTIAL (pruned-node gate already works; "no node present" half DEFERRED: false-positive risk without UI test, needs package-presence check) -Show the yellow requirement badge when no full node / only a pruned node is present (reuse existing yellow badge pattern). - -### B7 — ElectrumX UI stuck loader on top — FIXED (overlay hides + iframe shows when status stale; type-check green). UI-confirm. -UI renders but a loader sits on top; possibly stale pre-sync screen not clearing. - -### B9 — IndeedHub keeps stopping on nodes — TODO -Container won't stay running (crash-loop / reconcile stop). Check logs + restart policy + health. - -### B10 — Immich still crashes — TODO -Recurring crash ("still" → prior attempts). Check container logs + resource limits + DB/ML deps. - -### B11 — Companion app: "open in external browser" apps don't work — TODO -Apps meant to open in a new/external browser don't launch from the companion app; need the phone-default-browser request-modal pattern mobile apps use. Relates to v1.7.90 "open in new tab from companion app". - -### B12 — Mempool not connecting — FIXED (mempool host detect, 3 paths; unit-tested). Live bitcoin-core validation PENDING (no core node available). -**Bigger than the original "stacks.rs:1278" framing.** `CORE_RPC_HOST=bitcoin-knots` was hardcoded in THREE env-render paths; on a bitcoin-core node the container is named `bitcoin-core`, so mempool-api can't resolve RPC. Both Knots and Core are reachable on `archy-net` by container name — only the name differs. -- **Path 1 — legacy direct-podman** (`stacks.rs::install_mempool_stack`, used when no orchestrator): now `format!("CORE_RPC_HOST={}", detect_bitcoin_rpc_host())`. FIXED. -- **Path 2 — `config.rs::get_app_config`** (install.rs legacy path): same. FIXED. -- **Path 3 — Quadlet/manifest (THE MODERN FLEET PATH, e.g. .198)**: `prod_orchestrator` renders env from `apps/mempool-api/manifest.yml` static YAML. FIXED via a new `{{BITCOIN_HOST}}` derived-env placeholder: `HostFacts.bitcoin_host` (container/manifest.rs) + `resolve_derived_env` renders it; `prod_orchestrator::bitcoin_host()` detects Knots/Core via `podman ps` (test-injectable `set_bitcoin_host_for_test`); resolved on-demand only for manifests using the placeholder (perf). mempool-api manifest moved `CORE_RPC_HOST` from static env → `derived_env: {{BITCOIN_HOST}}`. -- New helper `dependencies::detect_bitcoin_rpc_host()` + pure `pick_bitcoin_host()`. -- **TESTS (all green):** `pick_bitcoin_host` 5 cases (knots/core/plain/none/substring-safety); container-crate `resolve_derived_env` renders `{{BITCOIN_HOST}}`; orchestrator `mempool_core_rpc_host_follows_bitcoin_node` (core→bitcoin-core, knots→bitcoin-knots). No-regression verified: picker returns `bitcoin-knots` live on .198 (so Knots nodes unchanged; existing mempool installs see no env drift). -- **VALIDATION GAP:** cannot exercise on a live bitcoin-core node (none available; .198 is Knots where the fix is a no-op). Need a Core node to confirm end-to-end. -- **FOLLOW-UP (B12b, NOT done):** same hardcode exists for siblings on bitcoin-core nodes — `config.rs` lnd(:724)/btcpay(:739)/electrumx(:782), and `prod_orchestrator::resolve_dynamic_env` fedimint `FM_BITCOIND_URL=...bitcoin-knots` (~:2425). Plus mempool-api manifest `dependencies: bitcoin-knots` (line 18) is Knots-specific bookkeeping (install-time check already accepts Core via BITCOIN_NAMES, so non-blocking). All can reuse `{{BITCOIN_HOST}}`. Deferred per user (mempool-only scope) — each needs its own validation, esp. LND/fedimint. -- **NOTE (unrelated pre-existing failures):** 4 prod_orchestrator tests fail on clean HEAD too — `install_applies_data_uid_chown_before_create`, `install_writes_manifest_generated_files_before_create`, `manifest_generated_files_{do_not_overwrite_by_default,can_overwrite_when_declared}` — their fixtures pass tempdir volume sources that `validate_bind_source` rejects (only `/var/lib/archipelago/*` + 2 sockets allowed). NOT caused by B12; worth a separate fix. -mempool can't reach the Bitcoin backend on some nodes. Investigate on .116. Check mempool→electrs→bitcoind wiring + deps. - -### B13 — Fedimint UI not applying CSS — FIXED + VERIFIED on .198 (both HTTP + HTTPS) -Root cause confirmed: the Fedimint Guardian page (served by :8175) is a server-rendered status page with ~7.8KB INLINE CSS plus image assets referenced root-rooted (`src="/assets/img/app-icons/fedimint.jpg"`, `url("/assets/img/bg-network.jpg")`). Without an asset rewrite those `/assets/...` URLs resolve against the archipelago SPA root: `bg-network.jpg` happens to exist there (shared design asset → loaded by luck) but `app-icons/fedimint.jpg` does NOT → **404** (the broken/visibly-missing icon). The `location /assets/` block uses `try_files $uri =404`, so missing fedimint assets 404 rather than fall through. - -Fix = nginx sub_filter set that reroots every root-rooted asset URL (`href="/`, `src="/`, `url("/`, and single-quote variants) under `/app/fedimint/`, plus `proxy_set_header Accept-Encoding ""` so the upstream doesn't gzip (sub_filter can't rewrite gzipped bodies). Shipped two ways: -- **Fresh ISOs** (committed a50b6df2): templates `image-recipe/configs/nginx-archipelago.conf` (HTTP) + `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS). -- **Already-deployed nodes** (bootstrap self-heal, this commit): `core/archipelago/src/bootstrap.rs::patch_nginx_conf` now heals BOTH the main conf (Style A — swaps the old single nostr-provider sub_filter tail for the full reroot set, byte-matches the shipped template) AND the HTTPS app-proxy snippet (Style B — anchors on the unique `:8175` proxy_pass and inserts the reroot set; robust to the snippet's varying trailing directive). `missing_*` flags now gated on their splice anchors so the healed snippet early-returns cleanly (no per-boot warn-skips). Idempotent via the `'href="/' 'href="/app/fedimint/'` marker. - -VERIFIED on .198 (sideloaded built binary, restart, async self-heal converged ~15s): -- HTTP `/app/fedimint/`: live conf healed byte-identical to template; app-icon **404→200 image/jpeg (41944b)**. -- HTTPS `/app/fedimint/` (snippet): healed; same app-icon **404→200**; bg-network 200; root `/assets/img/app-icons/fedimint.jpg` returns 200 **text/html** (SPA shell) — proving the reroot is necessary. -- `nginx -t` OK both times; containers survived restart (Quadlet); both files carry the marker exactly once (idempotent steady state); no warn spam in logs. -NOTE: self-healed snippet is functionally correct but NOT byte-identical to the fresh-ISO snippet template (insert-after-proxy_pass vs full block) — acceptable; nginx ignores directive order/whitespace. - -### B15 — Bitcoin UI sync progress lags — FIXED (Home.vue poll 30s→10s). UI-confirm. -Bitcoin UI doesn't update its sync progress fast enough even though the console clearly already has the block-height data. Likely a polling-interval / reactive-update gap between the status source and the UI. - -### B16 — Bitcoin sync status vanishes — FIXED + UNIT-TESTED (commit 83dbd25c). UI-confirm. -The bitcoin sync status in the Home > System container disappears when it should persist/cache and show an "updating" state. Related to B15 (Bitcoin UI sync lag). Root cause: the tile is gated `v-if="stats.bitcoinAvailable===true"` (HomeSystemCard.vue:60); a transient `bitcoin.getinfo` failure (RPC busy during heavy IBD, or a route-change/scan where the packages map is momentarily empty) could blank it. -FIX (commit 83dbd25c): added a `bitcoinStale` flag to homeStatus.ts — -- getinfo fails while the bitcoin container is **Running**, OR package data is momentarily **absent** → retain last-known value + `bitcoinStale=true` (tile stays, renders **"Updating…"** instead of a frozen figure shown as live). -- container authoritatively **Stopped/Exited** → `bitcoinAvailable=false`, `stale=false` (no stale-as-live — genuinely down is reflected). -- first-ever poll times out but container Running (syncing node) → show the tile as updating rather than staying hidden. -Wired `bitcoinStale` through Home.vue `systemStats` → HomeSystemCard prop; card shows "Updating…" (dimmed) when stale. -**Harness:** `neode-ui/src/stores/__tests__/homeStatus.test.ts` (6 cases) — RED before fix (5/6 fail), GREEN after (6/6). `vue-tsc --noEmit` exit 0. Full vitest suite: only pre-existing AppIconGrid cross-test teardown flake (passes 7/7 standalone; not my change). UI-confirm on .116/.198 still recommended (hard to trigger transient failure on demand — unit test is the authoritative harness here). - -### B17 — archipelago.service flaps on boot before starting — FIXED + VERIFIED on .198 (commit 34b1fdc1) -On some boots, `[FAILED] Failed to start archipelago.service` printed ~20× over ~5 min before starting. ROOT CAUSE (proven live on .198): on production nodes `/var/lib/archipelago` is a **separate `/dev/mapper/archipelago-data` ext4 volume** (systemd unit `var-lib-archipelago.mount`), and podman's **graphroot=`/var/lib/archipelago/containers/storage`** lives on it too. The unit ordered only `After=network-online.target` — NO mount dependency — so on cold boots the service (and its `ExecStartPre`) could start BEFORE the volume mounted, write to the bare mountpoint on rootfs, fail every podman call, exit, and be restarted every 5s (`Restart=on-failure RestartSec=5`) until the mount appeared. Smoking gun in .198's journal: `var-lib-archipelago.mount: Directory /var/lib/archipelago to mount over is not empty, mounting anyway` — the service had written there pre-mount. Dev laptop .116 has the data dir on rootfs → never flaps (explains "on some boots"). Diagnostic: every node showed `banners == "Server listening"` (process always succeeds once it runs) ⇒ failure is systemd-level, not a Rust crash. -FIX (commit 34b1fdc1): `RequiresMountsFor=/var/lib/archipelago` (adds `Requires=` + `After=` on the mount unit). -- `image-recipe/configs/archipelago.service`: ships the directive on fresh ISOs. -- `bootstrap::ensure_archipelago_mount_ordering()`: self-heals already-deployed nodes' installed `/etc/systemd/system/archipelago.service` + `daemon-reload` (boot-ordering only — effective next reboot; never restarts the running service). Idempotent; harmless on rootfs installs. -VERIFIED on .198: applied directive → `systemctl show -p After` includes `var-lib-archipelago.mount`, `systemd-analyze verify` clean → rebooted: mount@07:35:22, archipelago banner@07:35:35 (13s AFTER mount), `banners=1 listening=1 failed_to_start=0` (zero flap), directive persisted. `cargo check` EXIT 0. NOTE: self-heal CODE (auto-patch on deployed nodes) still to be exercised with the built binary on .228 (directive was applied manually on .198); residual rootfs shadow files under the mountpoint are benign. - -### B18 — Apps stop right after install (or become unstartable) — TODO -Many apps install but immediately stop, requiring a manual Start — or become unstartable entirely. Likely the install→start handoff / reconciler doesn't bring them up (or starts then they exit). Related to B9 (IndeedHub stopping), B10 (Immich). Possibly linked to the cgroup-SIGKILL-on-archipelago.service-restart issue (feedback_no_systemctl_deploy_until_quadlet) — but NOTE: on .116 (Quadlet) containers survived a service restart cleanly, so the reconciler may be fine there; reproduce on the affected nodes. Check post-install start sequencing + boot_reconciler + container restart policy + cgroup placement. - -### B19 — Failed download-update lands on Install button (should be Download) — TODO -When an update download fails, the UI sometimes shows the Install button instead of returning to the Download button — a big UX issue (user can't retry the download cleanly). Check the SystemUpdate state machine's error/failure transition. - -### B20 — Surface bitcoin-headers-over-mesh broadcast (send/receive toggles) — TODO (feature-adjacent, surfacing existing work) -We previously broadcast bitcoin block headers over mesh to archipelago nodes but never fully surfaced it. Want two switches: "send headers" (you broadcast) and "receive headers" (you accept). NOTE: this is feature-adjacent — surfacing existing functionality; the user added it during the no-new-features push, so treat as low-priority polish until the bug list is clear. Code: mesh block-headers (mesh.block-headers RPC seen in logs; core/archipelago/src/mesh). - -### B14b — FIPS reachability: many peers fall back to Tor — INVESTIGATED (needs FIPS-network depth) -Live (2026-06-15) federation sync last_transport on .116/.198: ~4 peers fips, ~6 tor, ~5 none. So beyond the recording fix (B14), FIPS genuinely doesn't reach many federated peers (they use Tor). Investigate WHY: is fips_npub known for those peers? are they FIPS-online? is the shared anchor connecting them? (cf project_fips_integration, project_tor_node_to_node_works). This is the real "Tor not FIPS" depth. -FINDINGS (.198, 2026-06-15): archipelago-fips ACTIVE; ALL 13 peers HAVE fips_npub; last_transport = 5 fips / 5 tor / 3 none. So it's NOT a missing-npub or service-down bug — FIPS genuinely reaches some peers and not others = DIAL-TIME reachability: the 'tor' peers aren't FIPS-reachable at dial time (offline, NAT, their FIPS not registered with the shared anchor), and 'none' = fully offline (X250 roam/beta/cellular). NEXT (deeper, needs FIPS-network debugging): verify a known-online peer (e.g. .228/.116) is reachable over FIPS from .198 right now; if an online FIPS peer still falls back to Tor → real anchor/registration bug; check fips daemon peer table + anchor connectivity. Likely partly peer-availability (not fully fixable in code). - -### B21 — Show Tor/FIPS transport pill on cloud browse — FIXED (build+type-check green; deploy+UI-confirm on .116/.198) -Tag whether the peer connection is Tor or FIPS and surface it as a small pill on the cloud browse screens / connection loader. Data source: federation node last_transport (now recorded by B14) exposed via federation.list-nodes; frontend renders a pill (FIPS=fast/green, Tor=slower) on PeerFiles.vue / Cloud peer view + the connection loader. Frontend-only-ish. FINDINGS: PeerFiles.vue:46 loader HARDCODES 'Connecting via Tor...' even when FIPS used (bug). Frontend types already have last_transport ('fips'|'tor'|'mesh'|'lan') federation/types.ts:31; NodeList.vue:167 already renders a transport indicator. PLAN: have content.browse-peer RETURN the transport used (B14 already computes it) → frontend shows a pill (FIPS green / Tor amber) on PeerFiles header + fix the loader text to reflect actual/attempted transport. Small backend (add transport to browse response) + frontend pill. - -### B22 — Peer cloud download/audio errors (.228→.198) — TODO (pairs with B3) -Observed 2026-06-15 browsing .228's cloud from .198: (a) downloading a peer cloud file → "Operation failed. Check server logs for details." (b) playing a peer AUDIO file → "Could not play audio. File Browser may not be running." (misleading — it's a peer file, not File Browser; that's the OLD base64/blob path B3 replaces). ACTION: (a) check content.download-peer backend error on .198 logs while downloading (likely the same Range/transport/timeout path as B3, or a peer-side 4xx); (b) verify B3 streaming fixes peer audio once deployed, and fix the misleading audioPlayer error string. Get server logs: ssh .198, journalctl -u archipelago | grep -i 'content\|peer\|download'. - -### B23 — Archipelago group chat (all nodes) broken/slow over Tor — TODO (PRIORITY, mesh) -The all-nodes "Archipelago group" chat (over Tor) doesn't seem to work. Facets: -- (a) Group delivery unreliable / "doesn't work" over Tor. -- (b) Messages may just be VERY SLOW (latency — likely Tor-only path; should use FIPS+Tor per the new transport method like B14, preferring FIPS). -- (c) Add the SENDER CONTACT NAME to each message so you can differentiate who sent what (group messages lack attribution). -- (d) Messages sometimes DUPLICATED (dedup by message id / sender_seq — cf mesh.ts:73 cross-transport identity (sender_pubkey, sender_seq); duplicate likely from receiving same msg over both transports or re-broadcast). -Code: core/archipelago/src/mesh (typed_messages, listener), frontend Mesh.vue/stores/mesh.ts. Relates to B2 (identity), B14/B14b (transport). Test on .116/.198 (+ a Tor-only peer like .228). - -### B8 — netbird app doesn't work — TODO (LOW / much later) - -(RETRACTED: CryptPad placeholder-icon — user says cryptpad is fine.) - ---- - -## 📋 vps2 Gitea issues (lfg2025/archy) — imported 2026-06-15 -- G#1 [Bug] Strange peer request behaviour — TODO (likely related to B1/federation) -- G#2 [Bug] Fix flashing USB from kiosk — TODO -- G#3 [Feature] VPN Configuration — DEFERRED (feature; no new features until production quality) -- G#4 [Bug] Bitcoind is slow — TODO -- G#5 [Feature] OpenWRT and TollGate integration — DEFERRED (feature) -- G#6 [Feature] Move dashboard/monitoring link to home screen — DEFERRED (feature) -- G#7 [Bug] Scrolling with Companion app — TODO - ---- - -## Gitea issue mapping (vps2 lfg2025/archy) -All backlog bugs now mirrored as Gitea issues: B1→#8, B2→#9, B3→#10, B4→#11, B5→#12, B6→#13, B7→#14, B8→#15, B9→#16, B10→#17, B11→#18, B12→#19, B13→#20, B14→#21, B15→#22, B16→#23, B17→#24, B18→#25, B19→#26. (Pre-existing G#1–7 remain; some overlap, e.g. G#1 strange-peer ≈ B1.) Close the Gitea issue when a bug is verified+shipped. - -## INVESTIGATION FINDINGS 2026-06-15 (B1/B2/B3/B4/B14) — cutoff insurance - -**B1 trusted-node divergence** — ROOT-CAUSED. `federation/sync.rs` `merge_transitive_peers()` (~:140) dedupes ONLY by DID; the SAME physical node appears under multiple DIDs (same `onion` + `fips_npub`) → duplicate entries ("Arch Dev" ×2, "Sapien" ×2). No background convergence → lists diverge (.103=16 nodes, .116/.198=15). Model: `federation/types.rs:24` FederatedNode (PK=did); storage `federation/storage.rs` nodes.json; add_node dedupes by DID only (:125). FIX: in merge_transitive_peers add a SECOND match arm — if no DID match, match by normalized `onion` (trim .onion); if found, treat as same node (merge fips_npub/name, don't add). Same dedup on add_node. Plus a one-time cleanup of existing dup DIDs (remove-node the stale one). TEST: after sync, all 3 nodes have identical node set, no two entries share an onion. - -**B2 duplicate chat contact** — ROOT-CAUSED (same root as B1). Two federation DIDs (same onion/fips_npub, e.g. "Sapien" dids z6MkoSbN… + z6MkeYMU…) get seeded as TWO mesh contacts: `mesh/mod.rs` `seed_federation_peers_into_mesh()` (~:94) upserts per-pubkey contact_id; frontend `Mesh.vue` `mergeKeyForPeer()` (~:492) keys by DID so two DIDs = two rows. FIX: (backend) in seed, skip a node whose onion was already seeded (HashSet of onions); (frontend) Mesh.vue merge by onion when DIDs differ but onion matches. Fixing B1's onion-dedup largely resolves this too. TEST: one "Sapien" row; `mesh.peers` has one contact for the shared onion. - -**B3 peer media won't play** — ROOT-CAUSED. `PeerFiles.vue` `playMedia()`/`loadPreview()` (~:358,:508) fetch the WHOLE file via RPC `content.preview-peer`/`content.download-peer` (`api/rpc/content.rs` :393,:213) which base64-encodes the entire file; frontend makes a Blob URL → browser can't Range-seek → video/large-audio won't play (+ 30/120s timeouts truncate big files). The peer's HTTP `/content/` handler (`api/handler/content.rs` :49) ALREADY supports Range/206 + Accept-Ranges. FIX (bigger): add a local streaming proxy endpoint `/api/peer-content/{onion}/{id}` in `api/handler/mod.rs` that forwards the browser's Range header to the peer's `/content/` (via fips::dial PeerRequest) and streams back 206 + Content-Range + Content-Type; frontend sets `