diff --git a/SELF_HOST.md b/SELF_HOST.md index 30802d9a..54a98221 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -1,4 +1,4 @@ -# Run the Dormouse server behind Tailscale +# Self-host the Dormouse server > This is an assistant-run setup playbook. Start a fresh Claude instance in > this repository and say: `read @SELF_HOST.md and walk me through it`. @@ -9,9 +9,16 @@ This installs the Dormouse coordinating server on the user's own laptop, reachable only from their tailnet at `https://..ts.net`. That -is the whole self-host story today. To keep the relay up while that machine -sleeps, run the same installer on an always-on tailnet box — see "Keeping the -relay up while the laptop sleeps". +is the whole *scripted* self-host story today. To keep the relay up while that +machine sleeps, run the same installer on an always-on tailnet box — see +"Keeping the relay up while the laptop sleeps". + +**Tailscale is the front door this runbook installs, not a dependency.** Nothing +in the server or the Host is Tailscale-aware; what it supplies is a stable DNS +name, a certificate the phone already trusts, and a reverse proxy in front of +the loopback server. Another private network can supply the same, under the +contract in +[Bring your own private network](#bring-your-own-private-network). The installer already exists — one idempotent command that ships in this repository, in a macOS, a Windows and a Linux edition: @@ -93,7 +100,10 @@ known: - **A tailnet.** The user needs a Tailscale account with MagicDNS and HTTPS certificates enabled, Tailscale running on this laptop, and Tailscale on the phone that will run Pocket. A tailnet-only origin is not reachable merely - because the laptop is on the tailnet. + because the laptop is on the tailnet. All three installers refuse without the + Tailscale CLI; on another private network **never improvise around that + refusal** — read + [Bring your own private network](#bring-your-own-private-network) instead. - **macOS, Windows or Linux.** Each installer refuses to run on the other platforms. On a fourth OS, or on a Linux box without systemd, stop and design the native service manager with the user rather than translating LaunchAgent, @@ -134,8 +144,9 @@ known: `standalone/scripts/build-sidecar-proxy.mjs` and `vscode-ext/scripts/esbuild.mjs` bake that variable into their respective - Node Host bundles. The relay socket no longer lives in either webview, so - changing a webview CSP does not widen this allowlist. + Node Host bundles. A non-tailnet origin goes in whole, port included — a + source with no port matches 443 only. The relay socket no longer lives in + either webview, so changing a webview CSP does not widen this allowlist. ## What the installer does @@ -669,6 +680,48 @@ which is the one thing the above does not give. That belongs with the multi-tenant work in `docs/specs/server.md` `## Future`, not with a single-user install, and is not designed here. +## Bring your own private network + +A front door is configuration of the shipped server rather than a different +hosting model, so it needs no design: point `DORMOUSE_ORIGIN` at an `http(s)` +origin, build the Host with a matching `DORMOUSE_REMOTE_CONNECT_SRC`, and +WireGuard, a corporate VPN or a reverse proxy on a home LAN can front it. +Nothing checks any of the following for you. + +- **A DNS name, never an IP address, and an origin of at most 103 characters.** + The WebAuthn `rpId` is the origin's hostname, and an IP literal is not a valid + RP ID. Nothing refuses one: the server boots and answers, and the failure + surfaces in the phone's browser at passkey registration. The length cap is the + pairing QR's and does fail at mint time (`docs/specs/server.md` → Setup tokens + and the pairing QR); it bounds the whole origin, so the scheme and any + non-default port come out of the same 103 — 95 characters of hostname under a + portless `https://`. `rpId` itself carries no port. +- **A certificate the phone already trusts.** Passkeys need a secure context. + Public trust means owning a domain and running ACME — usually DNS-01, since + the name resolves to a private address, so you hold a renewal credential the + Tailscale path never does. A private CA also works, but every phone must + install *and* fully trust the root before Pocket will load. +- **Reachability no wider than that network.** There is no generic equivalent of + the Funnel assertion, so a proxy that starts answering from the internet does + it silently — and `SECURITY.md` → "The setup password — accepted risk" is + premised on exactly that bound. +- **The invariants the installer would have held, held by you.** The loopback + bind and the durable origin are unchanged ([Installer contract](#invariants)), + but nothing pins the first, and nothing refuses to rewrite the second, so a + DHCP-dependent name is a re-enrollment waiting to happen. + +Everything in "Definition of done" is then yours to reproduce by hand: no +`manage verify`, no release pointers, no rollback, and none of the `FAIL IF` +lines in `SECURITY.md` that `scripts/deploy-lint.mjs` enforces. The server holds +`state/` itself on unix (`0700`/`0600`); on Windows those modes are a no-op and +the installer's DACL was the only thing holding it, so there `state/` is yours +too (`SECURITY.md` → "Credentials at rest"). `config/`, `run/` and the service +registration are always yours. + +A Tailscale-CLI-compatible control plane such as Headscale is untested here. The +open question is whether it satisfies the certificate `tailscale serve` +requests, which Serve asks for only after the release switch has landed. + ## Installer contract (maintainers) The runbook above is the operator half; this section is the *spec* for the diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 7cc27fb6..72212702 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -1,6 +1,6 @@ { "AGENTS.md": 2890, - "SELF_HOST.md": 7450, + "SELF_HOST.md": 8000, "docs/specs/alert.md": 7750, "docs/specs/alert.rationale.md": 950, "docs/specs/auto-update.md": 1500,