Remote access and mobile¶
The dashboard was originally loopback-only by design: the control plane can
restart runners and delete caches, so keeping it on 127.0.0.1 was the safest
default. The setup here extends that safely to your local network and to your
tailnet — without ever putting the dashboard on the public internet. The one
thing that can be published is the MCP server, for chat tools, on a listener
of its own that serves nothing else (Chat tools).
Reading vs controlling¶
Read routes (fleet state, runs, alerts, hosts) remain open to any address that reaches the server. A phone on the same Wi-Fi can watch the fleet without credentials.
Write routes (actions, settings, alert dismiss from a remote device) require a bearer token. The original flow — paste the master token into the Control tab once — still works locally. For remote devices, use device pairing so they get their own revokable token without the master token ever leaving the machine.
LAN access¶
Enable LAN access with one command:
This sets FLEET_HOST=0.0.0.0 in fleet.env and regenerates the LaunchAgent.
The bind address is written into the plist at install time, so a plain restart
would not pick it up. If the macOS Application Firewall is on, the command
prints the line that allows node through it:
sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "$(which node)" --unblockapp "$(which node)"
The dashboard is then reachable at:
http://<hostname>.local:7878— mDNS, works from any device on the same networkhttp://<lan-ip>:7878— explicit IP address
Run ./fleetctl.sh remote status to see all reachable URLs.
To revert:
Tailscale (remote access over your tailnet)¶
Tailscale Serve wraps the dashboard in HTTPS and makes it reachable from anywhere on your tailnet. It is not Tailscale Funnel — the dashboard never reaches the public internet.
Requirements: Tailscale installed and logged in on the runner host.
This runs tailscale serve --bg --https=443 http://127.0.0.1:7878. The daemon
stays bound to loopback (or the LAN if you enabled that separately); Tailscale
acts as a reverse proxy. The CLI is found on PATH, in Homebrew's locations, or
inside /Applications/Tailscale.app for the Mac App Store build; set
FLEET_TAILSCALE_BIN if yours lives elsewhere. If Serve has never been enabled
for your tailnet, Tailscale prints a link to enable it; follow it and rerun.
The daemon notices Serve being switched on without a restart. It re-reads its Tailscale identity every five minutes, and immediately when a request arrives under a name it does not yet know.
The dashboard is then reachable at:
To disable:
This removes only the HTTPS listener on port 443. Anything else the machine serves on your tailnet is left alone.
Offline/PWA install over Tailscale¶
Because Tailscale Serve provides HTTPS, the service worker is active when accessing the dashboard from a tailnet URL. This enables:
- "Add to Home Screen" on iOS and Android
- Offline launch — the last snapshot is shown, labelled as offline, if the host is unreachable
- App shortcuts in the home-screen launcher (Runs, Alerts)
The page itself is always fetched from the network first, with the cached copy used only when the network fails or takes longer than four seconds. Upgrading the dashboard therefore reaches phones on their next load, with no cache to clear.
The service worker is deliberately not active on plain LAN HTTP, where
isSecureContext is false (except for localhost).
Chat tools (claude.ai, ChatGPT)¶
A chat tool's custom connector calls an MCP server from the vendor's cloud, so
it needs a public HTTPS URL. The daemon can serve one without publishing the
dashboard: a second listener, on 127.0.0.1:7879, that answers only /mcp
and the OAuth sign-in in front of it. A tunnel publishes that port and nothing
else. Every other path is a 404, and every /mcp call needs a token the
sign-in issued. The master token and paired devices are refused there, so
nothing older than this listener works from the internet.
Pick one tunnel:
# Tailscale Funnel: https://<host>.<tailnet>.ts.net:8443/mcp
./dashboard/fleetctl.sh remote funnel on
# Cloudflare Tunnel, on a hostname in a zone you control
./dashboard/fleetctl.sh remote cloudflare on fleet-mcp.example.com
Both set FLEET_PUBLIC_PORT and FLEET_PUBLIC_URL in fleet.env, regenerate
the LaunchAgent, and print the URL to give the chat tool.
- Funnel publishes HTTPS port 8443 (
FLEET_FUNNEL_PORT; Funnel allows 443, 8443 and 10000). Port 443 stays with Serve, so the dashboard on your tailnet is never public. Funnel must be allowed for the machine in the tailnet policy; if it isn't, Tailscale prints the link to allow it. - Cloudflare needs
brew install cloudflaredandcloudflared tunnel loginonce. The command creates a tunnel namedfleet-mcp(FLEET_CLOUDFLARE_TUNNEL), routes the hostname to it, and runs it as a LaunchAgent. The tunnel's ingress passes on only/mcp,/oauth/…and/.well-known/oauth-…, so other paths stop at Cloudflare.
One public URL at a time. It is the OAuth issuer, so switching tunnels means signing each connector in again.
Signing a chat tool in¶
- In the chat tool, add a custom connector with the URL ending in
/mcp. - It opens the fleet's sign-in page. On the runner host, run:
- Type the six digits on the sign-in page and choose Connect.
The code decides what the connector may do. read lets it look. act also
lets it drain, resume, re-run and cancel, each after a preview it must confirm
(Running an action). A code from plain pair
connects read-only. A code that names act:danger grants everything, which a
chat tool rarely needs.
Each connector shows up in ./dashboard/fleetctl.sh devices as
<name> (OAuth), and revoke ends it. Its access token lasts an hour, and
the chat tool refreshes it. A connector unused for 90 days must sign in again.
How the sign-in works¶
The daemon is its own OAuth 2.1 authorization server, as the MCP specification describes:
- An unauthenticated
/mcpcall gets401with aWWW-Authenticateheader that points at/.well-known/oauth-protected-resource/mcp(RFC 9728), which names the daemon as the authorization server (/.well-known/oauth-authorization-server, RFC 8414). - The chat tool registers itself at
/oauth/register(RFC 7591). Registration is open, but each address may register 10 clients an hour, all addresses together 30, and the registry holds at most 100. A client unused for 30 days (a day, if it never signed in) is dropped. /oauth/authorizerequires PKCE withS256and aredirect_urithe client registered. The pairing code is checked with the same single use, expiry and rate limits as pairing a phone./oauth/tokenswaps the code for an access token and a refresh token. Refreshing replaces both. A code presented twice revokes the token it already bought.
Pairing a device for controls¶
Viewing is open. To use controls (restart runners, change settings, dismiss alerts) from a remote device, pair it:
From the terminal on the runner host:
This prints a 6-digit code and a URL. If qrencode is installed it also renders
a terminal QR code. The code expires in 5 minutes and is single-use.
The link uses an address the other device can actually open. It prefers the
Tailscale HTTPS name, then the .local Bonjour name, then a LAN IP. If the
dashboard is only reachable from this machine, the command says so instead of
handing out a localhost link that would fail on the phone.
From the Control tab in the dashboard:
- Open the Control tab on an already-unlocked session
- Click Pair a device
- Scan the QR code, or share or copy the link to the remote device
The panel counts down to the code's expiry and reports "Paired" as soon as the other device has used it.
On the remote device, either:
- open the link (scan the QR code or tap the shared link), or
- open the dashboard, go to Control, choose Enter pairing code, and type the six digits.
The device is asked for a name (a sensible default such as "iPhone · Safari" is
offered), exchanges the code for its own token, and stores it in
localStorage. The master token never leaves the host machine.
If a device's token is later revoked, its Control tab says Token no longer accepted and offers to forget it, rather than showing controls that fail.
Managing devices¶
# List paired devices
./dashboard/fleetctl.sh devices
# Revoke a device
./dashboard/fleetctl.sh revoke <name-or-key>
Device tokens can also be revoked from the Control tab → Remote devices panel.
Security model¶
What is open¶
All GET read routes are open to any address that can reach the server:
/api/state, /api/stream, /api/health, /api/alerts, etc. This matches the
loopback model — anyone who could curl the loopback could already read the
fleet. LAN and tailnet access extend that to trusted network peers.
What requires auth¶
All mutating routes require a bearer token:
- The master token (
./fleetctl.sh token) — never leaves the host - A device token — issued via pairing, stored in
.fleet-device-tokens.jsonas SHA-256 hashes, revokable individually
Alert dismiss from the local machine (loopback, no proxy) still works without a token — this is unchanged from the original design.
Pairing limits¶
A pairing code is six digits, so guessing is capped:
- each client gets 5 failed attempts a minute, after which it receives
429; - 20 failures a minute from all clients combined invalidates every pending code. Behind Tailscale Serve every client shares one address, and on a LAN an attacker can change address, so the per-client cap alone would not be enough;
POST /api/pairis also subject to the Origin check, so a page open in another tab cannot spend guesses on your behalf.
DNS-rebinding protection¶
Browser requests must carry a Host header the server recognises as its own
identity: hostname, .local mDNS name, local IP addresses, Tailscale MagicDNS
name, the host names in FLEET_COORDINATOR/FLEET_COORDINATORS, and anything
in FLEET_ALLOWED_HOSTS. An unrecognised Host gets a 421 response before any
route logic runs, and the refused name is written to the daemon log (at most
once every ten minutes per name) so you can see what to add.
Agent routes (/api/host/*) are exempt. They are machine-to-machine calls that
need a bearer token a rebinding page cannot have, and agents reach the
coordinator by whatever name their fleet.env holds.
Tailscale reverse-proxy trust¶
When a request arrives from loopback with X-Forwarded-* or Tailscale headers,
isLocalRequest() returns false. This means a phone accessing the dashboard
via Tailscale Serve cannot dismiss alerts without a token, even though the TCP
connection comes from loopback — the proxy headers disclose its true origin.
Adding custom DNS names¶
If you access the dashboard through a custom DNS name or a reverse proxy that
adds its own hostname, add the name to fleet.env:
Mobile layout¶
The dashboard detects narrow screens and switches to:
- Bottom navigation bar (Fleet, Runs, Alerts, Hosts, More)
- More sheet for secondary tabs (Analytics, Lint, Capacity, Control)
- Glance card at the top of Fleet: busy, idle, queued, and open alerts, each tappable to jump to the matching tab
- Full-screen drawer for runner and repo details; the Back gesture closes it
- Touch-friendly controls: dismiss buttons always visible, 44 px tap targets, and tap-to-show explanations for anything that has a hover tooltip on desktop
- Scrollable tables: wide tables scroll sideways inside their panel, with a shadow on whichever edge has more
- Hash-routable tabs:
#/runs,#/alerts, etc. — shareable and bookmarkable
Phones suspend background tabs. When you return to the dashboard it reconnects only if the live stream has actually gone quiet, and it keeps the last snapshot on screen, labelled Reconnecting…, until fresh data arrives.
Troubleshooting¶
| Symptom | Cause and fix |
|---|---|
Browser shows 421 / "not a recognised address" |
You reached the daemon by a name it does not know. The daemon log names it; add it to FLEET_ALLOWED_HOSTS and run ./fleetctl.sh install. |
| Phone cannot connect at all on the LAN | Check ./fleetctl.sh remote status. If it says loopback only, run remote lan on. If the firewall is on, allow node with the command it prints. |
Tailscale URL refused right after remote tailscale on |
Reload once. The daemon re-reads its Tailscale name when it sees an unknown one. |
| Control tab says Token no longer accepted | The device was revoked or the master token rotated. Choose Forget token and pair again. |
| Pairing says "too many failed attempts" | Wait a minute. If codes keep being invalidated, someone else is guessing; check the daemon log. |
| Chat tool says it cannot reach the MCP server | ./fleetctl.sh remote status shows the public URL, or off. From another network, curl https://<public host>/.well-known/oauth-authorization-server should answer. If the daemon log says the public URL is not an https origin, rerun remote funnel on or remote cloudflare on. |
| Connector worked, then stopped | It was revoked, or unused for 90 days. Remove it in the chat tool and add it again. |
Environment variables¶
| Variable | Default | Description |
|---|---|---|
FLEET_HOST |
127.0.0.1 |
Bind address. 0.0.0.0 for LAN. |
FLEET_PORT |
7878 |
Port. |
FLEET_ALLOWED_HOSTS |
(empty) | Extra hostnames to accept in Host header. |
FLEET_DEVICE_TOKENS_FILE |
.fleet-device-tokens.json |
Paired device token store. |
FLEET_TAILSCALE |
auto |
off stops the daemon from calling the Tailscale CLI. |
FLEET_TAILSCALE_BIN |
auto-detected | Path to the Tailscale CLI when it is not in a standard place. |
FLEET_PUBLIC_PORT |
(off) | Loopback port of the public MCP listener. Set by remote funnel on and remote cloudflare on to 7879. |
FLEET_PUBLIC_URL |
(off) | The https origin a tunnel publishes that port at. The OAuth issuer. |
FLEET_OAUTH_CLIENTS_FILE |
.fleet-oauth-clients.json |
Registered chat-tool clients. |
FLEET_FUNNEL_PORT |
8443 |
HTTPS port remote funnel on publishes. |
FLEET_CLOUDFLARE_TUNNEL |
fleet-mcp |
Tunnel name remote cloudflare on creates or reuses. |