Skip to content

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 (bob dash serve, and the /warp page bob warp viz opens — with /bob and /briefings on the same server) 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.

Three properties decide the posture, and all three are inherent to what the monitor is for:

  1. 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.
  2. It renders the host’s filesystem. Project paths, branch names, issue bodies, session state, recent commits — the working context of whoever runs it.
  3. It executes on the host. Its loaders shell out to git and gh to 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.

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:6152 and 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.

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) and tailnet (this machine’s Tailscale address). There is no 0.0.0.0 mode 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:PORT and 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 stray tailscale on PATH cannot 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, never implicit. Per invocation — bob dash serve --tailnet / bob warp viz --tailnet (alias --tailscale), or DASH_BIND=tailnet for a service definition — or declared per machine in the operator repo’s machines.json ("dashboard": {"bind": "tailnet"}, #2636), for a headless farm box that is only ever browsed from a laptop. The order is flag → DASH_BIND → the machine’s declaration → localhost. A declaration is explicit and version-controlled like a flag; a machine that declares nothing stays on loopback, and a declared tailnet that cannot resolve an address refuses exactly as the flag does. 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 declared per-machine bind and its overrides, the wildcard refusals, each failure mode, and the single-address bind.

One address per machine: tailscale serve (#2407)

Section titled “One address per machine: tailscale serve (#2407)”

bob serve puts a machine’s dashboards behind its MagicDNS name with no port, path-routed by project (http(s)://farm-01.<tailnet>.ts.net/bob, /<project>/warp). This stays inside decision #1773; it does not reopen it.

  • The tailnet listener belongs to tailscaled, not to the monitor. A machine that declares serve in machines.json binds its dashboards to loopback (127.0.0.1), and tailscaled proxies each declared route to that port. The monitor listens on no tailnet, LAN or public interface. Machines without a serve declaration keep the single-address tailnet bind above.
  • Still no public DNS record and no public port. tailscale serve answers only to devices on the tailnet. The MagicDNS name resolves only inside it.
  • Funnel is refused in code. tailscale funnel is the public-internet version of the same listener. scripts/fleet/serve.js runs the tailscale CLI from one function, which refuses any funnel argument before anything runs. A serve declaration or a bob serve argument that names funnel is a config error (exit 2). tests/test-fleet-serve.sh holds this as a regression.
  • HTTPS names machines publicly, and that is the operator’s call. A *.ts.net certificate enters the public Certificate Transparency log, which publishes each machine’s name and the tailnet name. That does not expose the monitor; the name still resolves only on the tailnet. But it is a disclosure, so turning certificates on is a human decision (todo #3360). Until then bob serve serves HTTP on port 80 and reports https: unavailable.
  • Tailnet identity now reaches the server. Behind serve, each proxied request carries Tailscale-User-Login, Tailscale-User-Name and Tailscale-User-Profile-Pic for the person on the requesting device (not for a tagged device, and never over Funnel; Tailscale docs). The monitor does not use them yet. They are the seed of the per-viewer identity that the iBoB actions requirement (#2294) needs. They are trustworthy only because tailscaled sets them on a loopback-only server; a server reachable any other way could have them forged.

The public-internet phase is a separate decision. It needs a new decision issue that supersedes #1773 and meets at least one of the three conditions in What would have to change to revisit this. The only admissible shapes are Tailscale Funnel with authentication in front of the monitor, or a Cloudflare Tunnel with Cloudflare Access. bob serve implements neither, and the funnel refusal stays until that decision exists.

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.

Not “we found it inconvenient once.” Any of the following would make the question live again:

  1. 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.
  2. 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.
  3. 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.

  • Warp Drive how-to — bob warp viz, including --tailnet and 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.