Close tokenless remote admin, and make the safe tailnet topology installable on macOS

Closed
DI
2mo ago
+757-79

Sharing one Subrouter across a few machines over a tailnet was documented as a supported shape and was, in practice, either unsafe or impossible depending on the OS. This makes it safe on both, and writes down the trust boundary so the unsafe version does not get rebuilt by someone reasoning from the tailnet inward.

What changes for an operator

A tailnet bind no longer silently publishes the admin surface. Previously a daemon bound to a tailnet address with no admin token handed every reachable host the accounts, transcripts, account-import and drain endpoints — and that was the default state of a bare serve, not a misconfiguration you had to opt into. serve now refuses to start in that state.

macOS can now serve the pool at all. install-daemon rejected every non-loopback address, so the topology was reachable on Linux via install-systemd and simply unavailable on a Mac — even though the daemon it installs would have run it safely. It now accepts a tailnet address and requires a token, matching what serve already enforced.

openssl rand -hex 32 > ~/.subrouter/admin-token && chmod 600 ~/.subrouter/admin-token
subrouter install-daemon --addr 100.x.y.z:31415 --admin-token-file ~/.subrouter/admin-token

The token stays out of places that leak it. --admin-token-file keeps it out of the plist entirely; the inline --admin-token form writes the plist 0600 instead of 0644. Neither puts it in ProgramArguments, because launchd argv is readable through ps by every local user on the box — precisely the audience the token exists to exclude.

Why the doc argues rather than lists

docs/tailscale.md spends its first section on a single claim: the tailnet is not the security boundary. Tailscale decides who can reach the port; it does not decide what they may do once they get there, and Subrouter deliberately does not treat node identity as authorization — loopback is the only implicitly trusted peer.

That is the whole of the bug fixed here. Anyone thinking "it is on my tailnet, so it is fine" is reconstructing it from first principles, and a flag list would not stop them.

Review notes — verification, and two things the existing help text gets slightly wrong

One predicate, not two. install-daemon reuses serve's own bindAddrRequiresAdminToken rather than spelling "is this loopback" a second time. Two gates answering one question from separate code is how the original hole opened: authorizeAdmin returned true for any remote caller whenever no token was configured.

Claims in the doc were checked against the code, not inferred:

  • drain 403s a non-loopback peer even with a valid admin token — handleDrain re-checks isLoopbackRemote inside the handler, after requireAdmin. serve's flag help lists drain among what the token gates, which overstates it.
  • requireAdmin returns 401, so a stale client token fails loudly rather than degrading to unauthenticated.
  • secretFromEnvironment treats SUBROUTER_ADMIN_TOKEN and SUBROUTER_ADMIN_TOKEN_FILE both being set as fatal. That is what makes the two install flags mutually exclusive rather than merely redundant — accepting both would install a plist that validates and then fails at daemon boot.
  • Flag asymmetry is real and called out: --admin-token-file is macOS-only, --admin-token-stdin is systemd-only.

Bind the 100.x address, not the MagicDNS name. The listener resolves once at bind time and a LaunchAgent can beat tailscaled's MagicDNS to it. KeepAlive makes that race self-heal, so the doc gives the tell that separates it from a genuine bind failure: restart churn in subrouter.err.log that stops on its own, versus churn that does not.

Testing. Five new tests cover the refusal across tailnet IPv4 / MagicDNS / bare :31415, both loopback spellings still installing untokened, both token forms, their mutual exclusion, and the argv leak. Verified hermetically in a container with the repo mounted read-only and --network none; ./cmd/subrouter and ./internal/proxy green on top of main. Nothing was run against a live daemon.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Secures remote admin and makes safe tailnet installs work on macOS. serve now fails closed on non-loopback binds without a token, and install-daemon accepts tailnet addresses with a validated token while keeping secrets out of places that leak.

  • New Features

    • macOS install-daemon accepts tailnet IP/MagicDNS with --admin-token-file (preferred) or --admin-token; token is set via env (never in ProgramArguments); plist perms are 0644 with token-file and 0600 with inline token.
    • Token-file is validated at install using the daemon’s reader, resolved to an absolute path, and warned if group/world-readable; avoids launchd restart loops.
    • serve adds --allow-unauthenticated-admin; otherwise a non-loopback bind requires --admin-token/SUBROUTER_ADMIN_TOKEN[_FILE] (shared gate); docs in docs/tailscale.md, README updated.
  • Bug Fixes

    • Closed tokenless remote admin: remote callers are denied by default without a token; loopback stays trusted; /_subrouter/drain is loopback-only; hostnames are treated as non-loopback at validation time.
    • Token flags are mutually exclusive; tests cover the admin gate, plist secrecy, tenant-scoped delegated admin, and token-file checks.