Skip to content

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

Closed
discordwell wants to merge 4 commits into
manaflow-ai:mainfrom
discordwell:feat/tailnet-daemon-bind
Closed

discordwell wants to merge 4 commits into
manaflow-ai:mainfrom
discordwell:feat/tailnet-daemon-bind

Conversation

@discordwell

@discordwell discordwell commented Aug 5, 2026 •

Copy link
Copy Markdown

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.

Written for commit e11d16e. Summary will update on new commits.

Review in cubic

discordwell and others added 3 commits August 5, 2026 16:20
authorizeAdmin's fallback granted every REMOTE caller the full admin
surface — accounts, transcripts, account-import, drain — whenever
neither AdminToken nor AccountImportToken was configured. That is the
default state of a bare `subrouter serve`, so the moment such a daemon
bound a non-loopback address (the tailnet bind that team sharing calls
for, where this proxy deliberately allows plaintext HTTP), every host
the ACLs admit held admin. Two layers close it:

serve now refuses a non-loopback --addr with no admin token, at startup,
naming the fixes: --admin-token / SUBROUTER_ADMIN_TOKEN[_FILE], a
loopback bind, or the new --allow-unauthenticated-admin opt-in for a
deliberately unsecured deployment. Unparseable addresses are left for
net.Listen to report; hostnames are treated as non-loopback because
their resolution cannot be trusted at validation time.

authorizeAdmin itself now grants tokenless REMOTE admin only behind the
same explicit opt-in, so no other construction of Server can fail open.
The one legitimate consumer of the old fallback — per-tenant scoped
servers, where the MultiTenant router has already validated the tenant
key before routing only the tenant-visible control paths — carries that
delegation through the new internal tenantControlAuthorized, mirroring
tenantAccountImportAuthorized. Loopback stays unconditionally admin
(it is how the sr CLI works), and the spoofed-forwarded-header case is
pinned: the socket peer decides, X-Forwarded-For cannot claim loopback.

Tests that drove admin handlers through httptest.NewRequest were riding
the open fallback unnoticed — its default peer, 192.0.2.1, is remote.
They now say which peer they mean via loopbackAdminRequest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…min token

`serve` has allowed a non-loopback bind since e7839e5, on the condition that
an admin token is configured -- `bindAddrRequiresAdminToken` refuses to start
otherwise. `install-systemd` mirrors that with its own --admin-token. macOS
`install-daemon` did not: it rejected every address that was not localhost,
so the one topology this is for -- serving the pool over a tailnet -- was
reachable on Linux and unreachable on a Mac, even though the daemon it
installs would have run it safely.

Reuse serve's own predicate rather than writing a second spelling of "is this
loopback". Two gates that decide the same question from different code is how
C1 happened: authorizeAdmin granted full admin to any remote caller whenever
no token was set, which is exactly the state a bare `serve` starts in.

The token reaches the daemon two ways, and the difference is not cosmetic:

  --admin-token-file  writes SUBROUTER_ADMIN_TOKEN_FILE; the secret stays in
                      a file the operator controls and the plist stays 0644.
  --admin-token       writes SUBROUTER_ADMIN_TOKEN inline, and the plist is
                      then chmod 0600.

Neither puts the token in ProgramArguments. launchd argv shows up in `ps` for
every local user, so an argv token would publish the admin credential to the
audience the token exists to gate. TestLaunchAgentPlistKeepsAdminTokenOutOf
ProgramArguments pins that.

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 in the container only (./deploy/docker/test.sh) -- ./internal/proxy
and ./cmd/subrouter green. Nothing was run against this host's daemon.
…undary

The tailnet lane had no onboarding doc, and the README described the macOS
daemon as localhost-only -- true until the previous commit, misleading now.

The load-bearing paragraph is the one saying the tailnet is not the security
boundary. Tailscale decides who can reach the port; it does not decide what
they may do there, and Subrouter deliberately does not treat node identity as
authorization. Loopback is the only implicitly trusted peer. That distinction
is the whole of C1: tokenless remote admin was once the silent default, so a
tailnet bind published accounts, transcripts and account-import to every host
the ACLs admitted. Someone reasoning "it's on my tailnet, so it's fine" is
reconstructing that bug from first principles, which is why the doc argues
the point rather than just listing flags.

Claims here 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) -- so `serve`'s own flag help, which lists drain among what
    a token gates, overstates it slightly.
  - requireAdmin returns 401, so a stale client token fails loudly rather
    than degrading to unauthenticated.
  - secretFromEnvironment treats both SUBROUTER_ADMIN_TOKEN and
    SUBROUTER_ADMIN_TOKEN_FILE being set as fatal, which is what makes the
    two install flags mutually exclusive rather than merely redundant.
  - the flag matrix is real: --admin-token-file is macOS-only,
    --admin-token-stdin is systemd-only.

Recommends binding the 100.x address rather than a MagicDNS name: the
listener resolves once at bind time and a LaunchAgent can beat tailscaled's
MagicDNS to it. KeepAlive means that race self-heals, so the doc also 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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The daemon now requires an admin token for non-loopback binds unless explicit unauthenticated access is enabled. Install commands support inline or file-based tokens. Proxy authorization distinguishes loopback, admin-token, tenant, and unauthenticated access.

Changes

Admin authentication and daemon exposure

Layer / File(s) Summary
Proxy authorization rules
internal/proxy/proxy.go, internal/proxy/multitenant.go, internal/proxy/*_test.go
Remote tokenless admin access requires explicit opt-in. Tenant-authorized control requests are supported. Tests use loopback request addresses and cover authorization behavior.
Serve bind security gate
cmd/subrouter/main.go, cmd/subrouter/serve_admin_gate_test.go
Bind addresses are classified as loopback or remotely reachable. Tokenless remote binds are rejected unless --allow-unauthenticated-admin is set.
Daemon token configuration and documentation
cmd/subrouter/install_daemon.go, cmd/subrouter/install_daemon_test.go, README.md, docs/tailscale.md
The installer supports inline and file-based admin tokens, conditional plist environment variables, and token-dependent file modes. Documentation describes Tailscale deployment.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant Daemon
  participant ProxyServer
  participant AdminEndpoint
  Operator->>Daemon: start with listen address and token settings
  Daemon->>ProxyServer: configure admin authentication
  ProxyServer->>AdminEndpoint: receive control request
  AdminEndpoint-->>ProxyServer: authorize loopback, token, tenant, or explicit opt-in
  ProxyServer-->>Operator: return control response
Loading

Possibly related PRs

Suggested reviewers: lawrencecchen

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.94% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two primary changes: blocking tokenless remote admin access and enabling secure macOS tailnet installation.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/tailscale.md`:
- Line 11: Update the authorizeAdmin description in the documentation to remove
/_subrouter/account-import and /_subrouter/drain from the remote admin-token
paths, state that account import uses its separate scoped token, and state that
drain is loopback-only regardless of token.
- Around line 34-38: Update the inline-token example in the daemon installation
instructions to define TOKEN before invoking install-daemon. Generate or assign
TOKEN using the documented token-generation command, while preserving the
existing --admin-token "$TOKEN" usage.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d78303b6-ddcd-4aea-83d5-9c586f87d8d6

📥 Commits

Reviewing files that changed from the base of the PR and between 0cb5f50 and 9a2a9da.

📒 Files selected for processing (13)
  • README.md
  • cmd/subrouter/install_daemon.go
  • cmd/subrouter/install_daemon_test.go
  • cmd/subrouter/main.go
  • cmd/subrouter/serve_admin_gate_test.go
  • docs/tailscale.md
  • internal/proxy/admin_gate_test.go
  • internal/proxy/dashboard_test.go
  • internal/proxy/multitenant.go
  • internal/proxy/multitenant_test.go
  • internal/proxy/proxy.go
  • internal/proxy/proxy_websocket_test.go
  • internal/proxy/rate_limit_reset_test.go

Comment thread docs/tailscale.md

Tailscale decides which hosts can *reach* the port. It does not decide what they can *do* once they reach it, and Subrouter does not treat tailnet membership as authorization.

`authorizeAdmin` trusts exactly one thing implicitly: a loopback peer. Every other caller — including one that is unambiguously on your tailnet, with a valid node key, admitted by your ACLs — must present the admin token to touch `/_subrouter/accounts`, `/_subrouter/transcripts`, `/_subrouter/account-import`, or `/_subrouter/drain`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the remote authorization description.

Line 11 lists /_subrouter/account-import and /_subrouter/drain as remote admin-token paths. Line 64 gives account import its separate scoped token. Line 68 makes drain loopback-only regardless of token. Remove both endpoints from this admin-token statement and state their separate restrictions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tailscale.md` at line 11, Update the authorizeAdmin description in the
documentation to remove /_subrouter/account-import and /_subrouter/drain from
the remote admin-token paths, state that account import uses its separate scoped
token, and state that drain is loopback-only regardless of token.

Comment thread docs/tailscale.md
Comment on lines +34 to +38
The alternative form puts the token inline in the plist's `EnvironmentVariables`, and the installer then writes the plist `0600` instead of `0644`:

```bash
./bin/subrouter install-daemon --addr 100.x.y.z:31415 --admin-token "$TOKEN"
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Define $TOKEN before the inline-token example.

The preceding instructions create a token file but do not assign $TOKEN. This command expands to an empty token in a new shell and the installer rejects the non-loopback bind. Add a token-generation assignment before this example.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/tailscale.md` around lines 34 - 38, Update the inline-token example in
the daemon installation instructions to define TOKEN before invoking
install-daemon. Generate or assign TOKEN using the documented token-generation
command, while preserving the existing --admin-token "$TOKEN" usage.

Review of the previous commit turned up two ways it fails late instead of
early, both specific to the token-file form.

**An unreadable token file installed cleanly.** Nothing checked that the path
existed, was a file, or held a non-empty secret. Install succeeded, launchd
started the daemon, secretFromEnvironment failed, serve exited -- and because
the plist sets KeepAlive, that is not one legible failure but an endless
restart loop whose only symptom is churn in subrouter.err.log. The operator
gets no message connecting it to the flag they just passed.

**A relative path silently pointed somewhere else.** launchd resolves it
against the LaunchAgent's WorkingDirectory, not the shell that ran the
installer, so `--admin-token-file admin-token` reads a different file or none.
The plist now always carries an absolute path.

The readability check runs through the *same* readSecretFile the daemon uses,
extracted from secretFromEnvironment for the purpose. An installer that
validated its own way could accept a file serve then rejects, which is the
same two-gates-one-question shape as the tokenless-remote-admin bug this
branch fixes -- worth not reintroducing one commit later.

Also warns when the token file is group- or world-readable. Not fatal, since
an operator may have their reasons, but readable-by-everyone gives back the
only thing --admin-token-file buys over --admin-token, so it should not pass
in silence.

Three tests added, and the existing ones now write real temp files instead of
asserting against a path that merely looked plausible. Negative-controlled:
with the new check stubbed out, TestValidateDaemonConfigRejectsUnusableAdmin
TokenFile fails, so it is load-bearing rather than decorative. Container-only
verification; ./cmd/subrouter and ./internal/proxy green.
@discordwell

Copy link
Copy Markdown
Author

Added a fourth commit after re-reviewing the token-file path. Two ways it failed late rather than early, both worth knowing about if you review the flag surface:

  • An unreadable --admin-token-file installed cleanly. Nothing checked the path existed or held a secret, so the failure surfaced at daemon boot — and with KeepAlive set that is not one legible error but an endless restart loop whose only symptom is churn in subrouter.err.log, with nothing tying it back to the flag.
  • A relative path resolved against the LaunchAgent's WorkingDirectory, not the installing shell, so it quietly read a different file or none. The plist now always carries an absolute path.

The install-time check runs through the same readSecretFile the daemon uses (extracted from secretFromEnvironment for it), so "install succeeded" implies "serve can start". Letting the installer validate its own way would have rebuilt the two-gates-one-question shape this PR exists to fix, one commit later.

Negative-controlled: with the new check stubbed out the test fails, so it is load-bearing.

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.

1 participant