Skip to content

Self-host runbook, plus DORMOUSE_BIND_HOST - #398

Open
nedtwigg wants to merge 2 commits into
mainfrom
tailnet-deploy
Open

Self-host runbook, plus DORMOUSE_BIND_HOST#398
nedtwigg wants to merge 2 commits into
mainfrom
tailnet-deploy

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Aug 18, 2026

Copy link
Copy Markdown
Member

An assistant-run playbook for self-hosting the Dormouse server behind Tailscale, plus the one code change it needs. Launch the runbook with read @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 serve on 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.

  • Unset still binds every interface, so containers are unaffected. Purely additive.
  • Env parsing moved out of the entrypoint into server/src/config.ts, so the mapping is testable without binding a port.
  • server/test/bind-host.test.mjs spawns 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 --bg in front, and a manage helper for status / verify / logs / restart / show-password / rollback / uninstall. No GitHub environment, no workflow, no cloud account, no bill.

Under ## Future, as the always-on-relay scope: the DigitalOcean droplet with continuous deployment from main via GitHub OIDC → ephemeral tag:dormouse-ci node → Tailscale SSH, fronted by svc:dormouse. Designed, not built.

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.

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.ts hardcodes its webview connect-src. That understated it. enableRemoteHost is passed only by standalone/src/main.tsx, so lib/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.sh itself. 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

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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 18, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: 27d2fb3
Status: ✅  Deploy successful!
Preview URL: https://07d21041.mouseterm.pages.dev
Branch Preview URL: https://tailnet-deploy.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:standalonetauriscripts/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.

Comment thread SELF_HOST.md
needs a local build:

```sh
DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:standalone

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Comment thread SELF_HOST.md
Comment on lines +98 to +99
account.json
hosts.json

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
account.json
hosts.json
account.json
hosts.json
push-subscriptions.json
vapid.json

Comment thread SELF_HOST.md
Comment on lines +115 to +116
- Persist both `account.json` and `hosts.json` outside the installed release.
Code replacement must never replace state.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- 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.

Comment thread SELF_HOST.md
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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- `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.

Comment thread SELF_HOST.md
Comment on lines +231 to +232
account.json
hosts.json

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
account.json
hosts.json
account.json
hosts.json
push-subscriptions.json
vapid.json

Comment thread SELF_HOST.md

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:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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>
@nedtwigg nedtwigg changed the title Add SELF_HOST.md: run the coordinating server on your own tailnet Self-host runbook, plus DORMOUSE_BIND_HOST Aug 18, 2026

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread server/src/config.ts
type Env = Record<string, string | undefined>;

export function readConfig(env: Env = process.env): ServerConfig {
const port = Number(env.PORT ?? 3000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const port = Number(env.PORT ?? 3000);
const port = Number(env.PORT?.trim() || 3000);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants