# 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`.