Security Hardening¶
This document covers the fleet's security model, deployment options, and recommended hardening steps.
Deployment models¶
Default: loopback only¶
The dashboard binds to 127.0.0.1:7878 by default. Access from another machine
requires an SSH tunnel:
This is the right model for most operators. The control plane executes shell commands as the current macOS user. Binding to loopback means only processes on the runner host can reach the control plane, which is the same set that already has shell access.
LAN access¶
Binds to 0.0.0.0 so any device on the trusted network can view the fleet.
Write routes still require a bearer token (master or device). Use device pairing
(./fleetctl.sh pair) so remote devices can use controls without the master
token leaving the host.
See Remote access and mobile for full details.
Tailscale (HTTPS, tailnet-only)¶
Puts the dashboard behind Tailscale Serve: HTTPS on your tailnet, never public. The daemon stays on loopback; Tailscale proxies. The service worker and "Add to Home Screen" are available over this HTTPS connection.
See Remote access and mobile for full details.
Public MCP listener (chat tools)¶
./dashboard/fleetctl.sh remote funnel on # or
./dashboard/fleetctl.sh remote cloudflare on fleet-mcp.example.com
The dashboard is never published. What a tunnel publishes is a second
listener, bound to loopback, that serves /mcp and its OAuth sign-in and
returns 404 for everything else:
/mcpaccepts only tokens issued by that sign-in. The master token, paired devices andapi-tokentokens are refused there, and an OAuth token is refused by the dashboard's own write routes.- Signing in takes a pairing code from
fleetctl.sh pairon the runner host, so reaching the page is not enough. The code's scopes cap the token's; a plainpaircode connects read-only. - PKCE is required, redirect URIs must match the client's registration exactly, and authorization codes are single use. Access tokens expire after an hour and refresh tokens rotate.
- Funnel uses port 8443, so the tailnet-only Serve on 443 is never published with it. The Cloudflare ingress passes only the listener's paths.
Turn it off with remote funnel off or remote cloudflare off. Revoke one
connector with fleetctl.sh revoke "<name> (OAuth)". See
Chat tools.
LAN access with a reverse proxy¶
If you need LAN access without the built-in helpers, put a reverse proxy (nginx,
Caddy, or Apache) in front of fleetd.js:
The proxy should:
- Terminate TLS (Let's Encrypt or a private CA)
- Add Content-Security-Policy with appropriate script-src restrictions
- Add Strict-Transport-Security
- Set X-Forwarded-Proto: https and X-Forwarded-Host so the dashboard derives
the correct origin for bearer token checks
Without TLS, the bearer token (and any data) is visible in plaintext on the LAN.
Read-only LAN status board¶
For a read-only status board accessible on the LAN without authentication:
No bearer token exists in read-only mode. Read routes are still unauthenticated (see below).
DNS-rebinding protection¶
All requests must carry a Host header the server recognises as its own
identity. If the Host is unknown the server returns 421 before any route logic
runs. The allowed set includes:
localhost,127.0.0.1,::1- The machine's hostname, its
.localmDNS name, and all local IP addresses - The Tailscale MagicDNS name (fetched at startup via
tailscale status --json) - Any extra names in
FLEET_ALLOWED_HOSTS
This prevents a malicious page in another browser tab from exploiting DNS rebinding to read unauthenticated fleet data.
Per-device tokens¶
Paired devices receive their own bearer token via a single-use 6-digit code generated by an authorised session. Device tokens are:
- Stored as SHA-256 hashes in
.fleet-device-tokens.json(mode 0600) - Revokable individually without invalidating the master token
- Accepted on all the same routes as the master token
The master token never leaves the machine. Run ./fleetctl.sh pair or use the
Control tab → Remote devices panel to generate a pairing code.
Scoped tokens for agents¶
An agent or a script should not hold a token that can do everything. Give it a scoped one:
readreads/api/v1.actruns the low-risk actions: checks and previews, drain and resume, re-run, cancel and orphan recovery.act:dangerruns everything else. See API Reference for the exact lists.- Scoped tokens are stored like device tokens: hashed, listed by
./fleetctl.sh devices, and revoked by./fleetctl.sh revoke. - Every
/api/v1action takes a preview call and a confirmation call. The confirmation is single-use and bound to the caller and the exact arguments. An agent that previews draining one runner can't use that confirmation on another, and a confirmation pasted into a chat log is useless to anyone with a different token. - Every action is recorded in the action log with the caller's name.
- Only a session that holds every scope can mint a token or start a pairing, so a scoped token can't grant itself more.
./fleetctl.sh pair --scopes read,actmakes a pairing code whose device gets only those scopes.
Tailscale proxy trust¶
When a request arrives from loopback with X-Forwarded-* or Tailscale-*
headers, the server treats the request as proxied, not local. This means:
isLocalRequest()returnsfalse- Alert dismiss from a proxied connection requires a bearer token
- The
sameOrigincheck derives the origin fromX-Forwarded-ProtoandX-Forwarded-Host, so it matches the tailnet URL the browser used
Unauthenticated read routes¶
The following routes do not require a bearer token, by design:
GET /api/state— live snapshotGET /api/stream— SSE streamGET /api/runner?name=X— runner detail (log tail is redacted)GET /api/analytics,/api/billing,/api/autoscale,/api/admissionGET /api/run-status— one run's status, limited to repos this fleet servesGET /api/job-class— a job's admission lane and median run time, from this databaseGET /api/load-by-busy— host load by busy-runner count, from this databaseGET /api/actions— action catalogue (knowing which buttons exist ≠ pressing them)- All static files under
/public/
Accepted risk: on loopback, any process on the machine can read this data. On a LAN binding, any network peer can. This is appropriate for the default loopback deployment but should be reviewed for LAN deployments.
Log tails returned by /api/runner are passed through the same redactor as
diagnostic bundles — secret patterns (tokens, keys, bearer headers, long
base64 runs) are replaced with <redacted> before serving.
Token handling¶
Control token¶
The browser control token (dashboard/.fleet-token) is generated randomly on
first start (32 bytes, hex-encoded). It authenticates every mutating action.
The token is:
- Stored in a 0o600 file on disk
- Checked via timingSafeEqual against the Authorization: Bearer header
- Cross-origin requests are rejected even with a valid token
To rotate it:
rm dashboard/.fleet-token
cd dashboard && ./fleetctl.sh restart
./fleetctl.sh token # print the new token
Paired browser tokens¶
For a phone or remote browser, run dashboard/fleetctl.sh pair from an already
trusted coordinator session. The single-use code expires after five minutes and
exchanges for a device-specific token; the master control token never leaves
the Mac. Only SHA-256 hashes are stored in
dashboard/.fleet-device-tokens.json (mode 0600). List and revoke devices with
fleetctl.sh devices and fleetctl.sh revoke.
Agent token¶
The agent token (dashboard/.fleet-agent-token) authenticates remote agents.
By default it falls back to the control token for single-host backward
compatibility. For multi-host deployments, create a separate agent token:
openssl rand -hex 32 > dashboard/.fleet-agent-token
chmod 600 dashboard/.fleet-agent-token
cd dashboard && ./fleetctl.sh restart
Separating them lets you rotate agent credentials without invalidating browser sessions, and prevents an agent host compromise from granting control-plane access.
To generate a separate agent token with one command:
cd dashboard && ./fleetctl.sh agent-token # creates and prints
cd dashboard && ./fleetctl.sh restart
Token file permissions¶
Both token files (dashboard/.fleet-token and dashboard/.fleet-agent-token)
are created with mode 0600. agentctl.sh install also writes the token to a
local file with 0600 before embedding its path in the plist — never the
token value. The plist EnvironmentVariables dict is readable by any local
user via defaults read, so embedding a token there is equivalent to writing
it world-readable.
Federation network exposure¶
- Default: coordinator binds to
127.0.0.1. Agents must use an SSH tunnel. This is the safest default and requires no additional firewall rules. - LAN deployment: set
FLEET_HOST=0.0.0.0and restrict the port at the network perimeter. Do not expose port 7878 to the internet. - No TLS by default. On a trusted LAN this is acceptable; on an untrusted network, put a TLS-terminating reverse proxy (nginx, Caddy) in front.
Remote provisioning scope¶
runner.register on an agent:
- Requires two separate opt-in flags (
FLEET_AGENT_ALLOW_COMMANDS=1andFLEET_AGENT_ALLOW_REGISTER=1), so an agent that only needs drain/resume cannot accidentally be used for provisioning. - Passes the registration token via environment variable, not argv, so it does
not appear in
psoutput. - Re-checks local headroom and the per-repo cap immediately before invoking
register.sh, regardless of what the coordinator's snapshot said. - Treats an already-present runner directory as success (idempotent on retry).
Runner credentials¶
Each runner directory contains:
- .credentials — GitHub API credentials
- .credentials_rsaparams — RSA private key authenticating this runner to GitHub
- _work/ — job checkouts (may contain secrets written by workflows)
These are excluded from git by an allowlist .gitignore that excludes
everything by default and explicitly includes only tracked fleet files.
A denylist would leak credentials the first time a new file appeared in
a runner directory.
Never run git add . or git add --all in the fleet root. Always add
files explicitly. scripts/release-check.sh enforces this before publish.
Runner threat model¶
Each runner runs as the current macOS user. This means:
- A job can read files accessible to that user, including SSH keys and other
runner credentials
- A job can call gh api using the authenticated gh session
- A job can reach the dashboard (if it knows the port and token)
For private repos with trusted code owners, this is an acceptable risk. For public repos or repos with external contributors, it is not — use GitHub-hosted runners.
localStorage risk¶
The control token—or an individually revokable paired-device token—is stored in localStorage
in the browser. If another
page on localhost can run JavaScript (e.g. a local dev server), it can read
the token from localStorage. Only open the dashboard in a browser where no
other pages are running on localhost:7878, or use a separate browser profile.
Federation security¶
Agent tokens authenticate agents to the coordinator. The coordinator never authenticates itself to agents. A man-in-the-middle between an agent and the coordinator can: - Send fake heartbeat responses (including fake commands, if commands are enabled) - Suppress real commands
For a LAN deployment where agents and coordinator are on the same network, this is a low-probability risk. For agents over untrusted networks, put a TLS proxy in front of the coordinator and have agents connect to the HTTPS endpoint.
Database backups¶
fleet.db holds run history, alert dismissals, capacity settings, and federation
state. Back it up before upgrades and on a regular schedule:
Backups are written mode 0600 under FLEET_BACKUP_DIR (default
dashboard/backups/). The online backup uses sqlite3 .backup, which is safe
while the daemon runs. Never restore while the daemon is running — use
./fleetctl.sh stop first. restore keeps a rollback copy of the current
database beside the live file.
Store backups on encrypted disk (FileVault on Mac fleet hosts). They contain repo names, branch names, and workflow metadata — not runner credentials, but still operational data worth protecting.
Prometheus metrics¶
GET /metrics (see dashboard/lib/metrics.js) exposes the
same snapshot data as /api/state in numeric form: queue causes, runner counts,
headroom, autoscale mode, and host staleness. Like the other read routes, it
does not require a bearer token.
- Loopback (default): safe — only local processes can scrape.
- LAN binding (
FLEET_HOST=0.0.0.0): restrict scrape access at the firewall or reverse proxy; do not expose/metricsto the internet. - Read-only mode (
FLEET_READ_ONLY=1): metrics remain useful for monitoring; mutating routes are already disabled.
PostgreSQL HA credentials¶
Keep the managed PostgreSQL URL in the mode-0600 file named by
FLEET_DATABASE_URL_FILE; do not put a password directly in the LaunchAgent
plist. Require provider TLS and a database role limited to the fleet database.
The shared database contains fleet snapshots, host vitals, settings, commands,
and an archive of SQLite history, but never browser, GitHub, or agent tokens.
Use fleetctl.sh agent-token --host so compromising one host does not disclose
the credentials accepted for every other host.
Production LaunchAgents¶
After the dashboard daemon (./dashboard/fleetctl.sh install), install periodic
health repair on every runner host:
This closes silent runner deaths — loaded LaunchAgents whose RunnerService.js
exited — without touching the GitHub runner plists or restarting drained
runners. See Operations → Scheduled maintenance.
SSH access¶
The recommended operator workflow is SSH + port forward. Keep the runner host's
SSH configured with key authentication only (PasswordAuthentication no in
sshd_config).
Security contact¶
See SECURITY.md for the vulnerability reporting process.