Self-host runbook, plus DORMOUSE_BIND_HOST - #398
Conversation
An assistant-run playbook for self-hosting the Dormouse `server` behind
Tailscale. Above the fold it covers the only path that exists today: build
the current checkout into a self-contained release under Application
Support, run it from a macOS LaunchAgent bound to loopback, and put
`tailscale serve` in front for private HTTPS at the laptop's tailnet name.
The always-on cloud relay (DigitalOcean + continuous deployment from `main`)
is designed but unbuilt, so it lives under `## Future` as the
`always-on-relay` scope per the AGENTS.md spec-lifecycle conventions.
Notes on two choices the runbook makes:
- The installed service listens on 3100, not 3000, because `dev:server` and
`dev:pocket-server` both take 3000 on the same laptop that runs the
installed copy.
- It requires adding `DORMOUSE_BIND_HOST`. `server/src/index.ts` calls
`serve({ fetch, port })` with no hostname today, so the server binds every
interface; the local install must not expose plaintext 3100 to the LAN or
the tailnet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deploying mouseterm with
|
| Latest commit: |
27d2fb3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://07d21041.mouseterm.pages.dev |
| Branch Preview URL: | https://tailnet-deploy.mouseterm.pages.dev |
dormouse-bot
left a comment
There was a problem hiding this comment.
Two things worth changing before an assistant runs this against real state.
The state enumeration is short by two files. docs/specs/server.md ("State files") lists four things under $DORMOUSE_STATE_DIR and says "That is the entire persistent state": account.json, hosts.json, push-subscriptions.json, and vapid.json. This runbook names only the first two — in both layout diagrams, in the "Persist both…" invariant, in the Definition of done, and in the post-enrollment checksum step. That matters here specifically because push is not opt-in on this deployment: defaultVapidSubject in server/src/push.ts returns the origin whenever it is https and non-loopback, so a https://<laptop>.<tailnet>.ts.net install enables Web Push with no configuration and mints vapid.json on first boot. An installer generated from these invariants would preserve the state directory wholesale and be fine by accident, but the manage verify check and the backup/restore rehearsal are both written against a two-file list, so losing the other two would pass every check the runbook defines. The user-visible cost isn't silent — Pocket's readback treats a row registered under a rotated VAPID key as stale and re-offers Enable — but push stops for every device on every Host until each one re-enrolls. Inline suggestions widen the four sites to the state directory.
The prerequisite hands the Host a tailnet-wide connect-src. Phase 0 derives the exact origin and every other part of the runbook pins it, but the build command widens connect-src to https://*.ts.net wss://*.ts.net — which lets the webview reach every node in the user's tailnet, not just the relay. withRemoteConnectSrc in standalone/scripts/csp.mjs does no source validation, so the wildcard goes through unchallenged, and the comment above it frames the tight default as exactly the anti-exfiltration property being traded away here. Since the worksheet already carries the origin, pinning it costs nothing; the wildcard is only needed if the user expects to point one build at several relays, which is worth saying explicitly rather than defaulting to.
Separately (outside the diff, so no inline suggestion): nothing links to this file. docs/specs/server.md → "Running it" is where a reader looking for the self-host path lands, and it still ends at the dev loop on :3000. Happy to push a one-line pointer there if you want it.
Everything else I checked held up: /api/hello (HELLO_ROUTE in server-lib-common/src/index.ts), the no-hostname serve({ fetch, port }) call that motivates DORMOUSE_BIND_HOST, the dogfood:standalone → tauri → scripts/tauri.mjs chain that makes the env var take effect, the hardcoded connect-src in vscode-ext/src/webview-html.ts with no override hook, the devEngines.runtime.version / packageManager pins and the SECURITY.md FAIL IF keyed to the former, the 3000 collision with both dev:server and dev:pocket-server, the releases/<id>/lib/dist-pocket layout matching the repoRoot resolution in server/src/index.ts, and the CI workflow name the ## Future section expects. scripts/spec-lint.mjs only walks AGENTS.md + docs/specs/*.md, so the unbuilt paths under ## Future won't trip it.
| needs a local build: | ||
|
|
||
| ```sh | ||
| DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone |
There was a problem hiding this comment.
Pin the origin the runbook already derived rather than the whole .ts.net namespace — a wildcard here lets the Host webview reach every node in the tailnet, which is the exfiltration surface standalone/scripts/csp.mjs narrows for the shipped binary.
| DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone | |
| DORMOUSE_REMOTE_CONNECT_SRC='https://<laptop>.<tailnet>.ts.net wss://<laptop>.<tailnet>.ts.net' pnpm dogfood:standalone |
The bullet title just above ("A Host build that can reach a *.ts.net origin") reads fine either way, but if one build has to reach several relays, the wildcard is the fallback — worth saying so explicitly instead of leading with it.
| account.json | ||
| hosts.json |
There was a problem hiding this comment.
| account.json | |
| hosts.json | |
| account.json | |
| hosts.json | |
| push-subscriptions.json | |
| vapid.json |
| - Persist both `account.json` and `hosts.json` outside the installed release. | ||
| Code replacement must never replace state. |
There was a problem hiding this comment.
| - Persist both `account.json` and `hosts.json` outside the installed release. | |
| Code replacement must never replace state. | |
| - Persist the whole `state` directory outside the installed release — | |
| `account.json`, `hosts.json`, `push-subscriptions.json`, and `vapid.json` | |
| (`docs/specs/server.md`, State files). Code replacement must never replace | |
| state. |
| and is unreachable when that device leaves the tailnet. | ||
| - The Pocket app is served at the same HTTPS origin. | ||
| - Port 3100 is bound only to `127.0.0.1`. | ||
| - `account.json` and `hosts.json` survive replacement of the running release. |
There was a problem hiding this comment.
| - `account.json` and `hosts.json` survive replacement of the running release. | |
| - Every file under `state` — `account.json`, `hosts.json`, | |
| `push-subscriptions.json`, `vapid.json` — survives replacement of the | |
| running release. |
| account.json | ||
| hosts.json |
There was a problem hiding this comment.
| account.json | |
| hosts.json | |
| account.json | |
| hosts.json | |
| push-subscriptions.json | |
| vapid.json |
|
|
||
| Complete Pocket passkey setup and Host enrollment using a standalone build | ||
| whose `DORMOUSE_REMOTE_CONNECT_SRC` includes `https://*.ts.net wss://*.ts.net`. | ||
| After `account.json` and `hosts.json` exist: |
There was a problem hiding this comment.
| After `account.json` and `hosts.json` exist: | |
| After the `state` directory has been populated: |
Same at the corresponding step in ## Future ("After account.json and hosts.json exist" under Step 7) — the checksum rehearsal should cover whatever the server actually wrote, since vapid.json and push-subscriptions.json will both exist once push is in use.
The server always speaks plain HTTP and expects a TLS proxy in front. When
that proxy is local — `tailscale serve` on the same laptop — the listen
interface becomes a security boundary: `serve({ fetch, port })` with no
hostname bound every interface, so the plaintext port was reachable from the
LAN and from the tailnet itself, bypassing the proxy.
`DORMOUSE_BIND_HOST` closes that. Unset still binds everything, which is what
a container wants (the namespace is the boundary and the port is published
explicitly), so this is additive.
Env parsing moves out of the entrypoint into `server/src/config.ts` so the
mapping is testable without binding a port. `bind-host.test.mjs` spawns the
real entrypoint and asserts both halves: loopback answers and a non-loopback
address does not when the var is set, and the unbound default still serves
every interface when it isn't.
Also corrects the runbook's claim about VS Code Hosts. The blocker is not the
webview CSP: `enableRemoteHost` is passed only by `standalone/src/main.tsx`,
so the shared entrypoint the extension renders never loads the relay,
enrollment, or pairing modules. A VS Code Host is a feature, not a build flag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dormouse-bot
left a comment
There was a problem hiding this comment.
The DORMOUSE_BIND_HOST change itself holds up — I built the branch and ran pnpm --filter server test (83 pass, 0 skipped, so both halves of bind-host.test.mjs actually exercised the exposure assertion rather than skipping on a runner with no non-loopback IPv4). Two things below, plus a note on the earlier threads.
This no longer merges, and the resolution isn't textual. gh api repos/diffplug/dormouse/pulls/398 reports mergeable: false / mergeable_state: dirty; server/src/index.ts and docs/specs/server.md both conflict with #396 (push hardening), which landed after this branch forked. The spec conflict is two adjacent table rows and is trivial. The entrypoint one isn't: #396 added a VAPID block that reads stateDir and origin as module-level locals (await new VapidStore(stateDir).loadOrCreate(...), defaultVapidSubject(origin)) and passes a conditional vapidPublicKey / pushSender spread into createApp({...}). This PR folds both locals into ...appConfig and calls createApp(appConfig), so keeping #396's block verbatim won't compile.
Worth deciding rather than just patching around at rebase time: DORMOUSE_VAPID_PUBLIC_KEY, DORMOUSE_VAPID_PRIVATE_KEY, and DORMOUSE_VAPID_SUBJECT are exactly the shape readConfig was extracted to hold — env in, validated values out, ConfigError on a bad pair — and the mismatched-pair check currently duplicates the process.exit(1) pattern the new loadConfig centralizes. If they stay in index.ts after the rebase, config.ts's "Environment → ServerConfig" header and the new Source of truth: line in docs/specs/server.md both overclaim, since the Configuration table they point at will list three env vars that module never sees.
A stale pointer left behind by the move (outside the diff, so no inline suggestion): server/src/app.ts still routes readers to the old location in three places — the file header's "so index.ts stays a thin env-to-config adapter", the AppConfig doc comment "see index.ts for how env maps onto this", and requireUserVerification's "(env → config in index.ts)". All three should now say config.ts. Happy to push that as a commit if you want it — it's mechanical.
The two threads from the previous review (state-file enumeration, the *.ts.net wildcard in DORMOUSE_REMOTE_CONNECT_SRC) are still open and unchanged by this commit; I'm not restating them here.
| type Env = Record<string, string | undefined>; | ||
|
|
||
| export function readConfig(env: Env = process.env): ServerConfig { | ||
| const port = Number(env.PORT ?? 3000); |
There was a problem hiding this comment.
An empty or whitespace-only PORT slips through as port 0: Number('') is 0, which is an integer in [0, 65535], so the guard below passes and the server binds an OS-assigned ephemeral port with origin defaulting to http://localhost:0. That's a plausible shape for a LaunchAgent env file or a templated server.env where the key is written but the value goes missing — and the failure is silent, so manage verify's health check against 127.0.0.1:3100 fails with no hint about why. Two lines down DORMOUSE_BIND_HOST already treats blank as unset; same treatment here keeps an explicit PORT=0 working while making blank fall back to 3000.
| const port = Number(env.PORT ?? 3000); | |
| const port = Number(env.PORT?.trim() || 3000); |
An assistant-run playbook for self-hosting the Dormouse
serverbehind Tailscale, plus the one code change it needs. Launch the runbook withread @SELF_HOST.md and walk me through it.DORMOUSE_BIND_HOST(the code change)The server always speaks plain HTTP and expects a TLS proxy in front. When that proxy is local —
tailscale serveon the same laptop — the listen interface is a security boundary.serve({ fetch, port })passed no hostname, so the plaintext port was reachable from the LAN and from the tailnet itself, straight past the proxy.server/src/config.ts, so the mapping is testable without binding a port.server/test/bind-host.test.mjsspawns the real entrypoint and checks both halves: with the var set, loopback answers and a non-loopback address does not; without it, every interface still serves.The runbook
Above the fold, the only path that exists today — the laptop: build the current checkout into a self-contained release under
~/Library/Application Support/Dormouse Server/(staged releases, atomic symlink switch, health-checked rollback), run it from a LaunchAgent bound to loopback,tailscale serve --bgin front, and amanagehelper forstatus/verify/logs/restart/show-password/rollback/uninstall. No GitHub environment, no workflow, no cloud account, no bill.Under
## Future, as thealways-on-relayscope: the DigitalOcean droplet with continuous deployment frommainvia GitHub OIDC → ephemeraltag:dormouse-cinode → Tailscale SSH, fronted bysvc:dormouse. Designed, not built.The installed service listens on 3100, not 3000, because
dev:serveranddev:pocket-serverboth take 3000 on the same laptop that runs the installed copy.Correction worth reading
An earlier draft of this PR said a VS Code Host can't reach a self-host relay because
vscode-ext/src/webview-html.tshardcodes its webviewconnect-src. That understated it.enableRemoteHostis passed only bystandalone/src/main.tsx, solib/src/main.tsx— the entrypoint the extension renders — never loads the relay, enrollment, or pairing modules at all. Remote hosting is standalone-only by construction; widening the extension's CSP would be dead code. Supporting a VS Code Host is a feature for its own PR, and the runbook now says so rather than offering a build flag.Not included
deploy/local/install-macos.shitself. The runbook specifies it in detail (idempotency, release staging, rollback, plist generation, Serve integration) but writing it is the next step, not this PR.Verification
pnpm --filter server test— 83 passing, including the 10 new ones.pnpm lint:specs— OK (23 specs, 24 files).🤖 Generated with Claude Code