diff --git a/.planning/APP-PORT-AUTH-GATE.md b/.planning/APP-PORT-AUTH-GATE.md new file mode 100644 index 00000000..3717af7b --- /dev/null +++ b/.planning/APP-PORT-AUTH-GATE.md @@ -0,0 +1,106 @@ +# 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/RESUME-2026-08-05-appgate-fixes.md b/.planning/RESUME-2026-08-05-appgate-fixes.md new file mode 100644 index 00000000..250900e9 --- /dev/null +++ b/.planning/RESUME-2026-08-05-appgate-fixes.md @@ -0,0 +1,113 @@ +# 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/config.json b/.planning/config.json new file mode 100644 index 00000000..f6fe21b5 --- /dev/null +++ b/.planning/config.json @@ -0,0 +1,5 @@ +{ + "workflow": { + "_auto_chain_active": false + } +}