Skip to content

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:

ssh -L 7878:localhost:7878 user@runner-host

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

./dashboard/fleetctl.sh remote lan on

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)

./dashboard/fleetctl.sh remote tailscale on

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:

  • /mcp accepts only tokens issued by that sign-in. The master token, paired devices and api-token tokens 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 pair on the runner host, so reaching the page is not enough. The code's scopes cap the token's; a plain pair code 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:

Browser → HTTPS reverse proxy (LAN) → http://127.0.0.1:7878

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:

FLEET_HOST=0.0.0.0 FLEET_READ_ONLY=1 node fleetd.js

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 .local mDNS 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:

./dashboard/fleetctl.sh api-token "claude code on laptop" --scopes read,act
  • read reads /api/v1. act runs the low-risk actions: checks and previews, drain and resume, re-run, cancel and orphan recovery. act:danger runs 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/v1 action 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,act makes 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() returns false
  • Alert dismiss from a proxied connection requires a bearer token
  • The sameOrigin check derives the origin from X-Forwarded-Proto and X-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 snapshot
  • GET /api/stream — SSE stream
  • GET /api/runner?name=X — runner detail (log tail is redacted)
  • GET /api/analytics, /api/billing, /api/autoscale, /api/admission
  • GET /api/run-status — one run's status, limited to repos this fleet serves
  • GET /api/job-class — a job's admission lane and median run time, from this database
  • GET /api/load-by-busy — host load by busy-runner count, from this database
  • GET /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.0 and 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=1 and FLEET_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 ps output.
  • 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:

cd dashboard && ./fleetctl.sh backup

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 /metrics to 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:

./healthctl.sh install

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.