Project Monitor Exposure Posture
Project Monitor Exposure Posture
Section titled “Project Monitor Exposure Posture”Decision: the Project Monitor is reachable on the tailnet and nowhere else. No public DNS record, no port forwarding, no bare public port — on any machine, including the farm. Journaled as decision #1773.
The monitor (cds, and the /warp page warp viz opens) is a per-project ops console. Running it
on the farm and reading it from a laptop is an ordinary need — the farm is where long warp-drive
runs live, and a run you cannot see is a run you cannot supervise. The question this note settles is
not whether to make it reachable but how far reachable is allowed to go.
What the monitor actually is
Section titled “What the monitor actually is”Three properties decide the posture, and all three are inherent to what the monitor is for:
- It is unauthenticated. There is no login, no token, no per-viewer identity. It was built as a single-operator console, and adding an auth layer would be a different product.
- It renders the host’s filesystem. Project paths, branch names, issue bodies, session state, recent commits — the working context of whoever runs it.
- It executes on the host. Its loaders shell out to
gitandghto build what they render.
An unauthenticated page that reads local state and shells out is a fine thing to run for yourself on a machine you own. It is not a thing to put on the public internet.
Why not a public hostname
Section titled “Why not a public hostname”farm.iwpi.com was the obvious-looking alternative, and it is the one this decision rejects.
- The exposure is asymmetric. What is gained is convenience — one memorable URL, reachable from a device that is not on the tailnet. What is risked is an unauthenticated console with host command execution behind it, permanently, for everyone who can resolve a DNS name and scan a port. A public record also makes the surface discoverable: certificate transparency logs and passive DNS mean “nobody knows the hostname” is not a control.
- Tailscale already solves the actual problem. The fleet already uses it as its transport for
the cross-machine audit (#415). It supplies a
stable machine-scoped address and a MagicDNS name with no port forwarding, no firewall rule, no
certificate to manage or renew, and no listener on a routable interface. The convenience gap
between
http://farm-01.tail3bfead.ts.net:6152and a public hostname is small; the exposure gap is not. - The obvious mitigations cost more than the tailnet. Putting Cloudflare Access or an auth proxy in front of the monitor would make a public URL defensible — but that is a proxy to run, a policy to maintain, and an identity provider in the path, all to replace something Tailscale already gives for free on a network the operator is already on.
What “tailnet-only” is enforced by
Section titled “What “tailnet-only” is enforced by”The posture is not advice in a doc; it is the shape of the code
(scripts/dashboard/bind.js):
- Two bind modes exist, and neither is a wildcard.
localhost(default,127.0.0.1) andtailnet(this machine’s Tailscale address). There is no0.0.0.0mode to reach for, and an unrecognized mode string is refused rather than coerced — a typo cannot land on a wider bind. - Tailnet mode binds one address. The listener is the tailnet address alone; loopback is not
bound in that mode, and neither is the LAN or public interface. On a farm box with a routable
public IP this is directly observable: the monitor answers on
100.x.y.z:PORTand the public address answers nothing. - The resolved address is range-checked. It must fall inside Tailscale’s CGNAT range
(
100.64.0.0/10) or the server refuses to start, so a mis-parse or a straytailscaleonPATHcannot hand the server a routable address to bind. - Failure refuses; it never degrades. Tailscale absent, stopped, or reporting nothing is a
startup refusal naming its reason (
unavailable,not-running,not-tailnet-range). There is no fallback path, because the dangerous version of this feature is the one that quietly binds something wider when the tailnet is down. - The mode is opt-in per invocation.
cds --tailnet/warp viz --tailnet, orDASH_BIND=tailnetfor a service definition. Nothing changes for anyone who does not ask.
tests/test-dashboard-bind.sh (make test-dashboard-bind) holds these as regressions — the
localhost default, the wildcard refusals, each failure mode, and the single-address bind.
Where a public path genuinely belongs
Section titled “Where a public path genuinely belongs”Rejecting a public URL for the monitor is not a blanket rejection of public ingress. A GitHub webhook trigger — one narrow, HMAC-verified endpoint that accepts a signed POST and nothing else — is a different proposition from a console, and should be argued on its own merits (see the option set in #1725). The mistake this decision avoids is opening the console because something else wanted an ingress path.
What would have to change to revisit this
Section titled “What would have to change to revisit this”Not “we found it inconvenient once.” Any of the following would make the question live again:
- The monitor grows real authentication — per-viewer identity and a session, such that the page is safe to serve to an unauthenticated stranger who then cannot see anything.
- A viewer needs it who cannot join the tailnet — a genuine second operator or a stakeholder on an unmanaged device. Adding them to the tailnet is the cheaper answer until it demonstrably isn’t.
- The monitor stops executing on the host — if it became a pure reader of a published, already-sanitized artifact, the blast radius argument weakens considerably.
Even then the answer is a Tailscale Funnel or a Cloudflare Tunnel with Access in front — never a bare public port. If this is revisited, record the outcome here rather than starting the argument from scratch.
See also
Section titled “See also”- Warp Drive how-to —
warp viz, including--tailnetand how the monitor is served for a farm-hosted project. - Prime Directive — why the posture lives in code and version control rather than in someone’s memory.