# Security

> Fleetdock's trust model — Manager and Agent privileges, tokens and sessions, encryption, SSRF protection, compromise scenarios, and current limitations.

Source: https://fleetdock.dev/docs/security


Fleetdock holds credentials for your entire database fleet, so its security model is
explicit — including the boundaries it does not yet cover. Read this before exposing the
control plane. To report a vulnerability, see the
[security policy](https://github.com/Fleetdock/fleetdock/blob/main/SECURITY.md).

## Trust boundaries [#trust-boundaries]

```text
        Internet / corp network
                  │
          Caddy :80/:443 (the only public HTTP surface)
                  │
        Fleetdock :8080 ── Metadata DB (Postgres, private)
        one process: API + dashboard
                  │
      ┌───────────┴────────────┐
 Managed servers          External DBs
 (agent → API, outbound)  (API → host:port, direct)
                  │
         Fleetdock Gateway (optional, off by default)
         CIDR-gated TCP proxy → instance ports
```

The control plane is a single process serving both the API and the dashboard on
one port, behind a bundled Caddy that terminates TLS. Nothing else needs to be
published: the dashboard has no port of its own, and the API is not reachable
except through the proxy.

## Manager privileges [#manager-privileges]

The Manager is the highest-value component. It stores encrypted credentials for the whole
fleet and can enqueue operations on every node. Protect it accordingly: terminate TLS in
front of it, restrict who can reach it, and monitor the **Operations** log.

## Agent privileges [#agent-privileges]

Each agent runs as **root** — provisioning drives Docker, which is root-equivalent anyway —
and executes approved operations on its own node. Its authority is scoped to that single
node and the databases on it. Treat any host running an agent as one that can act on its
local databases and Docker.

The agent's systemd unit is sandboxed to narrow what a compromised agent process can change:
`NoNewPrivileges`, `ProtectSystem=full` (read-only `/usr`, `/boot` and `/etc`),
`ProtectHome=read-only`, `PrivateTmp`, kernel tunable/module/log and control-group
protections, `RestrictSUIDSGID`, `RestrictNamespaces` and a `0077` umask. Servers enrolled
before this unit shipped pick it up when you re-run the install command.

## Docker socket access [#docker-socket-access]

Provisioning uses Docker on the node, so the agent can create and remove database containers
and their data volumes on that host. Docker access is powerful — a compromised agent has
Docker-level control of its node.

## Database credentials [#database-credentials]

Instance admin credentials and destination storage keys are **envelope-encrypted** at rest
with `FLEETDOCK_ENCRYPTION_KEY`. Use **least-privilege** database users for stored admin
credentials so a leak cannot exceed its grants.

## Gateway and public access [#gateway-and-public-access]

The **Fleetdock Gateway** (HAProxy) exposes databases to external applications through
dedicated TCP ports with CIDR enforcement at the proxy layer.

* Public access is **disabled by default** — you must explicitly enable it per database.
* CIDR allowlists are enforced in HAProxy; `0.0.0.0/0` requires an explicit choice (the UI
  warns).
* **Application credentials** (scoped users with `readonly`, `readwrite`, or `admin`
  profiles) are used for external apps — instance root credentials are never exposed for
  public use.
* Connectivity changes, credential creation, rotation, and revocation are recorded in the
  [audit log](/docs/audit-log), with every other change made through Fleetdock.

See [External database access](/docs/external-access) for setup and TLS behavior (TCP
passthrough in the current MVP).

## Tokens and their lifecycle [#tokens-and-their-lifecycle]

Fleetdock uses three token types, each with a display prefix:

| Token              | Prefix    | Purpose                      | Lifecycle                                                |
| ------------------ | --------- | ---------------------------- | -------------------------------------------------------- |
| Registration token | `fleetr_` | Enroll a new agent           | Single-use, short-lived                                  |
| Agent bearer token | `fleeta_` | Ongoing agent authentication | Issued on enrollment; revocable and rotatable per server |
| API token          | `fleetd_` | Automation / CI access       | Created, listed, and revoked in the dashboard            |

<Callout type="warn">
  Never share a real registration token or paste one into a public channel.
  Because the agent's `fleeta_` token is stored on disk in the agent state
  directory, protect that directory's permissions and restrict backup access to
  it.
</Callout>

### Creating, expiring, and revoking tokens [#creating-expiring-and-revoking-tokens]

Create API tokens scoped like sessions and set an expiry. Revoke API tokens from the
dashboard. Rotating `FLEETDOCK_JWT_SECRET` invalidates all sessions and login-derived API
tokens (but not agent tokens). Suspending a user account stops its sessions and tokens from
working immediately.

### Session invalidation on credential change [#session-invalidation-on-credential-change]

Every issued JWT carries a **token epoch** — a counter stored on the user record and
compared on every authenticated request. Changing or resetting a password increments it,
which immediately invalidates every session and login-derived token that user already had,
everywhere.

This is what makes "reset the password" a real containment step: a stolen JWT that has not
yet expired stops working the moment the password changes, without waiting out its TTL or
rotating the server-wide `FLEETDOCK_JWT_SECRET` and logging out everyone else.

### Revoking and rotating agent tokens [#revoking-and-rotating-agent-tokens]

Each server's agent token can be cut off or replaced from the server's page
(&#x2A;*Servers → server → ⋯**):

* **Revoke agent token** — the agent can no longer report in or run operations, effective
  immediately. Use it when a node or its token may be compromised.
* **Rotate agent token** — revokes the current token and shows a single-use install command,
  valid for 24 hours and bound to that server. Run it on the node to re-enroll the **same**
  server with a fresh `fleeta_` token; nothing about its databases changes.

The API equivalents are `DELETE /v1/servers/{id}/agent-token` and
`POST /v1/servers/{id}/agent-token/rotate`, both requiring `server:write`. Install commands
that were never used are listed on the **Servers** page, where they can be revoked before
anyone enrolls a host with them.

## Authentication and encrypted transport [#authentication-and-encrypted-transport]

Login uses email and password (bcrypt-hashed) and issues a JWT. **Deploy only over HTTPS** —
the bundled Caddy does this for you and obtains a certificate automatically. If you replace
it with your own proxy, terminate TLS there and publish nothing but the proxy — leaving port
8080 reachable also defeats the login rate limiter (see below).

### Session cookies [#session-cookies]

The dashboard's session is an `HttpOnly`, `SameSite=Strict` cookie (`fleetdock_session`)
set by login, so script running on the page — including injected script — cannot read it.
It is marked `Secure` whenever the request arrives over HTTPS or `FLEETDOCK_PUBLIC_URL` is
`https://`.

Every state-changing request authenticated by the cookie must also carry the header
`X-Fleetdock-CSRF: 1`, which a browser only sends cross-origin after a CORS preflight the API
does not grant; `SameSite=Strict` is a second layer. API clients authenticating with an
`Authorization: Bearer` token are unaffected. Signing out clears the cookie and ends every
session of that user.

## Outbound requests (SSRF protection) [#outbound-requests-ssrf-protection]

Notification channels let anyone with `notification:write` supply a URL that the control
plane will then fetch. Unguarded, that is a request-forgery primitive: a webhook pointed at
`http://169.254.169.254/` reads cloud instance-metadata credentials, and one pointed at
loopback reaches services that were never meant to be public.

Webhook and Slack delivery therefore goes through an SSRF-guarded HTTP client that refuses
to dial:

* loopback (`127.0.0.0/8`, `::1`)
* link-local unicast and multicast (`169.254.0.0/16`, `fe80::/10`)
* the unspecified address (`0.0.0.0`, `::`)
* cloud instance-metadata endpoints, including the IPv6 form

The check runs **at dial time against the resolved IP**, not on the URL string, so DNS
rebinding and redirect-based bypasses are blocked too — a hostname that resolves to
loopback is rejected at connect, however it got there.

<Callout type="info">
  RFC1918 private ranges (`10/8`, `172.16/12`, `192.168/16`) are deliberately
  **allowed**: a self-hosted Mattermost or alerting endpoint on your LAN is a
  legitimate webhook target. If your threat model needs those blocked too, keep
  `notification:write` restricted to trusted roles.
</Callout>

## Login rate limiting and proxy headers [#login-rate-limiting-and-proxy-headers]

Login is rate-limited per client IP. Which address counts as "the client" is controlled by
`FLEETDOCK_TRUST_PROXY_HEADERS`, and it is wrong in both directions if set carelessly:

| Setting           | Client IP taken from | Use when                                              |
| ----------------- | -------------------- | ----------------------------------------------------- |
| `false` (default) | the transport peer   | The API is reachable directly                         |
| `true`            | `X-Forwarded-For`    | The API is reachable **only** through a trusted proxy |

Leaving it `false` behind a reverse proxy makes every login attempt on the internet share a
single bucket — the proxy's address — so one attacker locks out all users. Setting it `true`
while the API port is also directly reachable is worse: a client can spoof `X-Forwarded-For`
and get an unlimited number of attempts.

The bundled Caddy deployment sets it to `true` and never publishes port 8080, which is the
combination that is safe. If you put the API behind your own proxy, publish nothing but the
proxy.

## Command authorization (RBAC) [#command-authorization-rbac]

Access is permission-based. System roles — `owner`, `admin`, `operator`, `viewer` — are
seeded from a permission catalog, and you can define custom roles from any subset of it.
Guards prevent lockout: the last active owner cannot be demoted, suspended, or deleted, and
you cannot suspend or delete yourself.

## Backup access [#backup-access]

Backups stream to object storage through **time-limited presigned URLs**. Agents move the
bytes but never receive your storage credentials — the Manager holds and encrypts them. Keep
backup buckets private and enable provider-side encryption.

## Secret storage and rotation [#secret-storage-and-rotation]

Secrets are envelope-encrypted, so rotating the master key only re-wraps each secret's data
key — payload ciphertext is untouched. Rotate with a **new key id**:

```bash
export FLEETDOCK_ENCRYPTION_KEYS_OLD="master-1=<old-secret>"   # keep old key readable
export FLEETDOCK_ENCRYPTION_KEY="<new-secret>"                 # new primary secret
export FLEETDOCK_ENCRYPTION_KEY_ID="master-2"                  # new primary id
make rotate-keys
# once it reports 0 remaining, drop FLEETDOCK_ENCRYPTION_KEYS_OLD
```

<Callout type="error">
  Never reuse a key id with a different secret — existing secrets would become
  unreadable. Always promote a new id when rotating.
</Callout>

## Production hardening [#production-hardening]

Set `FLEETDOCK_ENV=production`. In production mode the API **refuses to start** if
`FLEETDOCK_JWT_SECRET`, `FLEETDOCK_ENCRYPTION_KEY`, or `FLEETDOCK_ADMIN_PASSWORD` still use
insecure defaults. The installer generates all three, so a default install is already in
this state.

API responses carry `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,
`Referrer-Policy: no-referrer`, `Content-Security-Policy: default-src 'none'` and
`Cache-Control: no-store` — the last one matters because
API payloads include tokens, credentials and connection strings that must never be held by
an intermediary. Dashboard assets are served outside that chain so they keep their own
immutable caching, under a Content-Security-Policy that confines the dashboard to its own
origin.

Background work is safe to run on several replicas against one metadata database: jobs are
claimed with `FOR UPDATE SKIP LOCKED`, and housekeeping and migrations take Postgres
advisory locks so only one replica runs them at a time. `/healthz` and `/readyz` are unauthenticated probes.

## Node isolation and least privilege [#node-isolation-and-least-privilege]

* Keep database ports on private networks; use the Fleetdock Gateway for external access
  instead of publishing instance ports directly to the internet.
* Use least-privilege database users for stored admin credentials.
* Give people `viewer` or `operator` roles rather than `owner` by default.
* Isolate nodes from one another so an agent compromise does not spread.

## Compromise scenarios [#compromise-scenarios]

* **Manager compromise** — the largest blast radius: an attacker could reach every stored
  credential and command every agent. Restrict access and monitor operations.
* **Agent compromise** — limited to that node's databases and Docker host, but that node's
  data is exposed. Isolate nodes and use least-privilege users.
* **Database credential exposure** — bounded by the user's grants; rotate the credential and
  re-test the connection.
* **Backup credential exposure** — storage keys are encrypted on the Manager and never given
  to agents; presigned URLs are time-limited. Rotate keys and keep buckets private.

## Current limitations [#current-limitations]

* No SSO/OIDC or 2FA yet.
* The agent runs as root (required to drive Docker), inside a sandboxed systemd unit.
* Gateway TLS is passthrough only — no termination at the proxy in the current MVP.
* Gateway CIDR allowlists match the address HAProxy observes, which is not the real client
  address behind an L4 load balancer unless `FLEETDOCK_GATEWAY_SOURCE_IP_MODE=proxy-protocol`
  is set. See [External database access](/docs/external-access).
