Skip to content

docs(self-host): say what any private network must supply, not just Tailscale - #531

Closed
nedtwigg wants to merge 3 commits into
mainfrom
generic-vpn
Closed

docs(self-host): say what any private network must supply, not just Tailscale#531
nedtwigg wants to merge 3 commits into
mainfrom
generic-vpn

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 3, 2026

Copy link
Copy Markdown
Member

SELF_HOST.md read as though Tailscale were a dependency. It is not — nothing in server/, lib/src/host/ or server-lib-common/ is Tailscale-aware, DORMOUSE_ORIGIN takes any http(s) origin, and DORMOUSE_REMOTE_CONNECT_SRC is a free-form source list. What Tailscale actually supplies is a front door: a stable DNS name, a certificate the phone already trusts, and a reverse proxy in front of the loopback server.

The installers stay Tailscale-coupled, deliberately — tailscale serve is all three of those things, and the SECURITY.md FAIL IF lines that scripts/deploy-lint.mjs holds them to are pinned to concrete Tailscale controls. This genericizes the framing only.

What changed

  • New ## Bring your own private network — the contract a front door must meet, as four invariant bullets: a DNS name (never an IP, at most 103 characters), a certificate the phone already trusts, reachability no wider than that network, and the installer's loopback/durable-origin invariants held by hand. Then what is forfeited by leaving the scripted path.
  • Intro and Prerequisites state that Tailscale is the front door this runbook installs, and steer a non-Tailscale operator at the new section rather than at improvising around the installers' preflight refusal.
  • Retitled from "Run the Dormouse server behind Tailscale". Nothing referenced the old title; both lints key off the path.

Constraints documented for the first time

Each of these fails silently today, and none was written down anywhere a self-hoster would look:

  • An IP-literal origin is not a valid WebAuthn RP ID, and nothing refuses one — requireOrigin checks scheme and host only, createApp takes 10.0.0.4 as rpId, the server boots green, and the failure appears in the phone's browser at passkey registration.
  • An origin over 103 characters fails at pairing-QR mint time (PAIRING_QR_URL_MAX_LENGTH).
  • A non-443 origin must carry its port in DORMOUSE_REMOTE_CONNECT_SRCparseSource defaults a portless source to 443 and matching is exact equality.

Notes for review

  • Docs only. pnpm lint:specs, pnpm lint:deploy (lint + selftest + installer-verify-test) and git diff --check are green.
  • The word budget moves 7450 → 7975; the file is at 7934. The second commit is a cleanup pass that ratcheted this down from the 8050 the first commit claimed.
  • Deliberately not in this PR, each worth its own change: a ConfigError in requireOrigin rejecting IP-literal origins so this fails at boot instead of on the phone; a docs/specs/server.md Guardrail pinning "never make the server or a Host Tailscale-aware", which is the invariant the whole new section rests on and which nothing currently guards; and rewording SECURITY.md's accepted-risk premise from "tailnet-only" to the operator's private network.
  • Also considered and skipped: moving the section into docs/specs/server.md. That spec owns the generic claim and has a rationale file to shed weight into, which SELF_HOST.md structurally cannot (spec-lint.mjs globs docs/specs/*.rationale.md only) — but this is the file a self-hoster opens. The section is sized as though it can never shrink.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk

nedtwigg and others added 2 commits September 2, 2026 17:02
…st meet

SELF_HOST.md read as though Tailscale were a dependency. It is not — nothing in
the server or the Host is Tailscale-aware, `DORMOUSE_ORIGIN` takes any absolute
URL and `DORMOUSE_REMOTE_CONNECT_SRC` any source list. What Tailscale supplies
is a front door: a stable DNS name, a certificate the phone already trusts, and
a reverse proxy in front of the loopback server.

Say that in the intro, and add "Bring your own private network" with the five
rules another network has to satisfy — a DNS name rather than an IP (an IP
literal is not a valid WebAuthn RP ID, so no passkey can be registered at all),
a trusted certificate, reachability nothing checks for you now that the Funnel
assertion has no generic equivalent, the loopback bind intact, and an origin
stable for the life of the passkey — plus what is forfeited by leaving the
scripted path.

Tailscale stays the one supported path and the title still names it: the whole
runbook body is Tailscale commands, and the installers refuse without the CLI.

The word budget rises 7450 -> 8050; the file had five words of headroom.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk
Cleanup pass over the previous commit. The contract said `DORMOUSE_ORIGIN`
takes any absolute URL — `requireOrigin` demands an `http(s)` scheme — and the
intro's "three things Tailscale supplies" contradicted the section's "all five",
so a reader provisioning from the intro shipped an all-interfaces bind. The
loopback and durable-origin bullets were third copies of invariants the
Installer contract and `SECURITY.md` already own; they collapse into the one
thing that is new off the scripted path, which is that nothing holds them for
you.

Two silent failures were missing and are now stated: an origin over 103
characters fails at pairing-QR mint time, and a non-443 origin must carry its
port in `DORMOUSE_REMOTE_CONNECT_SRC`, since `parseSource` defaults a portless
source to 443 and matches on exact equality. The IP-literal rule cited
`server/src/app.ts` as though the server refused one; it does not — it boots and
answers, and the failure appears in the phone's browser at passkey
registration.

The Headscale paragraph recommended a control plane nobody here has run. It now
states that it is untested and names the unknown, along with the fact that makes
guessing expensive: Serve requests the certificate only after the release switch
has landed.

Retitled from "Run the Dormouse server behind Tailscale", which contradicted the
new framing on the one line every reader sees. The body still opens on the
tailnet origin, so the scripted path is established immediately. Nothing
referenced the old title; both lints key off the path.

Net -106 words, and the budget ratchets 8050 -> 7975 rather than holding the
slack the first commit claimed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 3, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: af1b09a
Status: ✅  Deploy successful!
Preview URL: https://043cb42a.mouseterm.pages.dev
Branch Preview URL: https://generic-vpn.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 claims in the new section don't hold as written. Both are the same shape the section exists to prevent — a constraint an operator reads as satisfied, whose violation fails silently or late.

The 103-character cap bounds DORMOUSE_ORIGIN, not the DNS name. formatPairingInvitationUrl (server-lib-common/src/security/pairing-invitation.ts) builds ${origin}/#pair?${fragment} where the fragment is fixed at PAIRING_FRAGMENT_LENGTH = 146, so the throw at PAIRING_QR_URL_MAX_LENGTH = 256 leaves 256 − 153 = 103 for the whole origin string — https:// and any explicit port included. A 100-character DNS name reads as inside the budget under the bullet as written and mints a 108-character URL; the bullet's own "a non-default port is fine" makes it worse, since the port comes out of the same 103.

The state/ sentence is unqualified where SECURITY.md → "Credentials at rest" is not. That section says server/src/state.ts's 0o700/0o600 "buys nothing on Windows, where modes are a no-op" and that "what actually protects the installed self-host server's state is the installer's directory permissions". On the BYO path there is no installer, so a Windows operator who takes "the server still does its own half" at face value leaves state/hosts.json (a live hostToken) and vapid.json under whatever the parent directory hands down. run/ is also missing from the "yours" list.

Both suggestions together land the file at 7974 words against the 7975 budget, and pnpm lint:specs is green on them — I ran it. If you'd rather have the headroom back, the run/ mention in the second one is the cheapest thing to drop.

Everything else checked out against the code: requireOrigin really does check scheme and host only (an IP literal boots green), rpId = new URL(origin).hostname carries no port, parseSource defaults a portless secure source to 443 and matches exactly, all three installers die without the Tailscale CLI, bindHost defaults to undefined so nothing pins the loopback bind outside the installer, and no reference to the old title survives the rename.

Comment thread SELF_HOST.md Outdated
Comment thread SELF_HOST.md Outdated
Review findings, both verified against the code.

The 103-character cap bounds `DORMOUSE_ORIGIN` whole, not the DNS name:
`formatPairingInvitationUrl` builds `${origin}/#pair?${fragment}` over a fixed
146-character fragment, so the throw at 256 leaves 103 for scheme, host and
port together. A 100-character name read as inside the budget under the old
wording and mints a 108-character URL, and "a non-default port is fine" made it
worse by implying the port was free.

`state/` is not the server's own half everywhere: `state.ts`'s `0700`/`0600`
are a no-op on Windows, where the installer's DACL was the only thing holding
it — so on the BYO path a Windows operator owns `state/` too, holding a live
`hostToken` and the VAPID private key. `run/` was missing from the same list.

Budget 7975 -> 8000 rather than dropping the `run/` mention to fit; an unspent
enrollment offer redeems for a Host enrollment, so it belongs in the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UabNVcotSFB6R9WJpCxjmk
@nedtwigg

nedtwigg commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

meh, maybe someday, not worth it rn

@nedtwigg nedtwigg closed this Sep 3, 2026
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