Snapshot fleet-data (inventory + admins.json + enroll.key — the admin-lockout risk) and influx-data to timestamped tarballs via a throwaway alpine container (read-only, live-safe), with BACKUP_KEEP retention. Documented in DEPLOY.md Backups with a restore pointer.
270 lines
12 KiB
Markdown
270 lines
12 KiB
Markdown
# fleet-manager — deployment runbook
|
|
|
|
Bring-up of the fleet-manager (control plane + admin SPA) and its private
|
|
InfluxDB on the **management host**. This is a lab/on-prem deployment; adapt the
|
|
PKI/ACME steps to your environment. See [`README.md`](README.md) for the design
|
|
and [`docs/fleet-management-design.md`](../../docs/fleet-management-design.md) for
|
|
the architecture.
|
|
|
|
> **Where it runs.** The management host — the trusted side, alongside the
|
|
> enrol-broker and step-ca (`serving/dot1x-bench/`). **Never on the boot LAN.**
|
|
> The admin plane (passkey UI) faces admins; the agent plane (gRPC/mTLS) faces
|
|
> enrolled hosts; both are segmented from the boot network.
|
|
|
|
## Prerequisites
|
|
|
|
- Docker + compose, and the `step` CLI (smallstep) on the host.
|
|
- The **fleet step-ca** already bootstrapped (`serving/dot1x-bench/` —
|
|
`docker compose up -d step-ca && ./step-ca/bootstrap.sh`), so
|
|
`../dot1x-bench/pki/root_ca.crt` and the `enrol` provisioner exist.
|
|
- The **enrol-broker** running (`serving/dot1x-bench/` compose) with its admin
|
|
plane on `127.0.0.1:9443`.
|
|
- A DNS zone you control for the UI hostname (`bitasylum.org`) and an ACME
|
|
client that can do **DNS-01** (e.g. `lego` or `certbot` + your DNS provider).
|
|
- A **YubiKey** (or other WebAuthn authenticator) for the bootstrap admin.
|
|
|
|
## 1. Configure
|
|
|
|
```bash
|
|
cd serving/fleet-manager
|
|
cp .env.example .env
|
|
# Edit .env:
|
|
# FLEET_MGR_RP_ID / _ORIGIN -> your UI hostname (diskless-server.bitasylum.org)
|
|
# FLEET_MGR_INFLUX_TOKEN -> a long random secret (openssl rand -hex 32)
|
|
# INFLUX_ADMIN_PASSWORD -> a strong password
|
|
```
|
|
|
|
The Influx token is handed to **both** InfluxDB (its init admin token) and the
|
|
manager, so one value wires them with no copy step. `.env` is gitignored.
|
|
|
|
## 2. PKI
|
|
|
|
Three certs, three trust roots — do not conflate them (see README "two planes"):
|
|
|
|
### 2a. Agent-plane server cert (fleet CA)
|
|
|
|
Enrolled hosts dial the gRPC agent plane and verify the manager against the
|
|
**fleet root** they hold, so this cert is issued by the fleet CA:
|
|
|
|
```bash
|
|
# SANs = every name/IP a host might dial the agent plane on.
|
|
AGENT_SANS=diskless-server.bitasylum.org,10.0.0.1 \
|
|
./server-pki.sh
|
|
# -> ./pki/agent/{server.crt,server.key,fleet-ca.crt}
|
|
```
|
|
|
|
### 2b. Broker admin-client cert (broker admin CA)
|
|
|
|
The manager is an mTLS **client** of the enrol-broker's admin plane. Mint its
|
|
client cert from the broker's **dedicated admin CA** (NOT the fleet CA — reusing
|
|
the fleet CA would let any enrolled host authenticate to the admin plane). Mint
|
|
the whole set **once** into the broker's own PKI dir (so the broker gets its
|
|
`server.crt`/`server.key` too), then copy the CA + client cert to the manager so
|
|
**both sides share one admin CA**.
|
|
|
|
The broker's PKI dir + its "serve mTLS" switch differ by which stack runs it:
|
|
|
|
- **Main serving stack** (`serving/docker-compose.yml`) — broker mounts
|
|
`./enrol-pki:/pki`, and the admin plane is **plaintext by default**
|
|
(`ENROL_ADMIN_INSECURE=true` in `serving/.env`). Set it `false`, then:
|
|
```bash
|
|
cd .. # serving/
|
|
./enrol-broker/admin-pki.sh ./enrol-pki/broker fleet-manager
|
|
# -> enrol-pki/broker/{admin-ca.crt, server.crt, server.key, clients/fleet-manager.*}
|
|
docker compose up -d enrol-broker # restart so it serves mTLS (no cert error in logs)
|
|
mkdir -p fleet-manager/pki/broker
|
|
cp enrol-pki/broker/admin-ca.crt fleet-manager/pki/broker/
|
|
cp -r enrol-pki/broker/clients fleet-manager/pki/broker/
|
|
```
|
|
- **Bench stack** (`serving/dot1x-bench/`) — broker mounts `./pki:/pki` and is
|
|
mTLS-by-default (cert paths hardcoded in that compose):
|
|
```bash
|
|
../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager
|
|
cp -r ../dot1x-bench/pki/broker ./pki/broker
|
|
```
|
|
|
|
Either way the manager ends up with `./pki/broker/{admin-ca.crt,
|
|
clients/fleet-manager.{crt,key}}`. If you (re)generate a fresh admin CA, **re-copy**
|
|
it to the manager — a stale copy won't verify. Verify the mTLS end to end:
|
|
```bash
|
|
curl -s --cert ./pki/broker/clients/fleet-manager.crt \
|
|
--key ./pki/broker/clients/fleet-manager.key \
|
|
--cacert ./pki/broker/admin-ca.crt \
|
|
https://127.0.0.1:9443/pending ; echo # expect [] or null (empty queue)
|
|
```
|
|
|
|
### 2c. Admin UI cert — Let's Encrypt DNS-01 via Route 53 (the `acme` side-car)
|
|
|
|
The UI hostname resolves on the mgmt net and is **not internet-reachable**, so
|
|
HTTP-01/TLS-ALPN-01 can't validate. Use **DNS-01**, which proves control of the
|
|
zone via a TXT record. The `acme` compose service does this automatically: a
|
|
long-running side-car (`acme/renew-loop.sh`, on the `goacme/lego` image) issues
|
|
the cert on first start and renews within 30 days of expiry, writing it into
|
|
`./pki/admin/` — the same dir the manager reads — so the manager's `certReloader`
|
|
hot-swaps it live, no restart.
|
|
|
|
**IAM policy** — give the side-car AWS creds scoped to the `bitasylum.org` hosted
|
|
zone (put `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in `.env`, or attach an
|
|
instance role and omit them):
|
|
|
|
```json
|
|
{
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{ "Effect": "Allow", "Action": ["route53:GetChange"], "Resource": "arn:aws:route53:::change/*" },
|
|
{ "Effect": "Allow", "Action": ["route53:ListHostedZonesByName"], "Resource": "*" },
|
|
{ "Effect": "Allow",
|
|
"Action": ["route53:ListResourceRecordSets", "route53:ChangeResourceRecordSets"],
|
|
"Resource": "arn:aws:route53:::hostedzone/<YOUR_ZONE_ID>" }
|
|
]
|
|
}
|
|
```
|
|
|
|
`AWS_HOSTED_ZONE_ID` is **optional**: lego auto-discovers the zone via
|
|
`ListHostedZonesByName` (hence that statement). Set it in `.env` to pin the zone
|
|
— required if a **public + private** zone share `bitasylum.org` (auto-discovery
|
|
may pick the wrong one), and it lets you **drop the `ListHostedZonesByName`
|
|
statement** for a tighter policy.
|
|
|
|
**Run it** (bring the side-car up first so the cert exists before the manager):
|
|
|
|
```bash
|
|
# In .env: set ACME_EMAIL + the AWS creds above. ACME_DOMAIN is taken from
|
|
# FLEET_MGR_RP_ID. Optionally ACME_STAGING=1 for a first dry-run (untrusted, no
|
|
# rate limit) to prove Route 53 access, then re-run without it for a real cert.
|
|
docker compose up -d acme
|
|
docker compose logs -f acme # wait for "staged /out/{fullchain,privkey}.pem"
|
|
ls -l pki/admin/ # fullchain.pem + privkey.pem, owned by uid 65532
|
|
```
|
|
|
|
Then turn off insecure mode and (re)start the manager — the defaults
|
|
`FLEET_MGR_ADMIN_CERT=/pki/admin/fullchain.pem` / `_KEY=/pki/admin/privkey.pem`
|
|
already point at what the side-car wrote:
|
|
|
|
```bash
|
|
# ensure FLEET_MGR_ADMIN_INSECURE=false in .env, then:
|
|
docker compose up -d --force-recreate fleet-manager
|
|
docker compose logs fleet-manager | grep -i listening # "admin (HTTPS + passkey) listening"
|
|
```
|
|
|
|
A publicly-trusted cert also keeps the WebAuthn secure-context clean (no browser
|
|
warnings). The side-car keeps renewing on its 12-hour loop; the manager reloads
|
|
each new cert automatically.
|
|
|
|
> **Split-horizon DNS:** the side-car defaults `ACME_RESOLVERS=8.8.8.8:53` so
|
|
> lego checks TXT propagation against **public** DNS. Without it, lego uses the
|
|
> container's internal resolver, which doesn't serve the public
|
|
> `_acme-challenge` record, and the challenge times out. Override if 8.8.8.8 is
|
|
> blocked (e.g. `1.1.1.1:53`, comma-separated for several).
|
|
|
|
> **Origin match:** the cert CN/SAN (`FLEET_MGR_RP_ID`) must equal the origin the
|
|
> browser uses *and* `FLEET_MGR_RP_ORIGIN` — passkeys are origin-bound. The admin
|
|
> listener binds loopback (`127.0.0.1:9500`) by default; expose it as
|
|
> `https://diskless-server.bitasylum.org` either by binding a routable
|
|
> `FLEET_MGR_ADMIN_ADDR` or fronting it with a mgmt proxy that passes TLS through
|
|
> (don't re-terminate, or the passkey origin/cert won't match).
|
|
|
|
## 3. Start InfluxDB
|
|
|
|
```bash
|
|
docker compose up -d influxdb
|
|
docker compose ps # wait for healthy
|
|
```
|
|
|
|
First start runs setup with the org/bucket/token/retention from `.env`.
|
|
|
|
## 4. Bootstrap the first admin (passkey)
|
|
|
|
Admin auth is closed — there is no password. Mint a **one-time enrolment link**
|
|
and register your passkey in the browser:
|
|
|
|
```bash
|
|
docker compose run --rm fleet-manager register-admin "your-name"
|
|
# prints: https://diskless-server.bitasylum.org/#/enrol?token=...
|
|
```
|
|
|
|
The `register-admin` subcommand generates the shared enrol-HMAC key on first run
|
|
(into the `fleet-data` volume at `/data/enroll.key`) and mints an HMAC-signed,
|
|
single-use token (jti ledger prevents replay). Open the link on a machine with
|
|
your YubiKey, tap + PIN, and the passkey is registered. Further admins can be
|
|
enrolled from the UI once one exists.
|
|
|
|
## 5. Start the manager
|
|
|
|
```bash
|
|
docker compose up -d fleet-manager
|
|
docker compose logs -f fleet-manager
|
|
```
|
|
|
|
Healthy startup logs the four planes: admin HTTPS+passkey, agent gRPC/mTLS,
|
|
authorise (broker), and the InfluxDB gateway. Any plane whose config is absent
|
|
logs `DISABLED` and the rest still run.
|
|
|
|
Then browse to `https://diskless-server.bitasylum.org/`, log in with the passkey,
|
|
and you should see **Monitor / Authorise / Lifecycle**.
|
|
|
|
## Smoke test without hardware
|
|
|
|
To render the SPA against demo data with no passkey/PKI:
|
|
|
|
```bash
|
|
docker compose run --rm -e FLEET_MGR_ADMIN_INSECURE=true -e FLEET_MGR_SEED=true \
|
|
-e FLEET_MGR_ADMIN_ADDR=0.0.0.0:9500 -p 9500:9500 fleet-manager
|
|
# http://localhost:9500/ (whoami returns "dev-insecure"; two seeded hosts)
|
|
```
|
|
|
|
`FLEET_MGR_ADMIN_INSECURE=true` is **dev/lab only** — plaintext, no passkey.
|
|
|
|
## Backups
|
|
|
|
Back up the `fleet-data` volume (`inventory.json`, `admins.json`, `enroll.key`)
|
|
and the `influx-data` volume. Losing `admins.json` locks every admin out — you'd
|
|
re-bootstrap with `register-admin`. Losing `enroll.key` invalidates any
|
|
outstanding (unused) enrol links.
|
|
|
|
`./backup.sh [dest-dir]` tars both volumes (read-only, safe to run live) into
|
|
timestamped `.tar.gz` files (default `./backups`, keeps the newest
|
|
`BACKUP_KEEP=14`). Schedule it with a systemd timer / cron and copy the output
|
|
offsite:
|
|
|
|
```bash
|
|
./backup.sh /srv/backups/fleet-manager
|
|
# restore (manager stopped) — see the header of backup.sh
|
|
```
|
|
|
|
If you run compose under a non-default project name, pass
|
|
`COMPOSE_PROJECT_NAME=<name>` so it finds the `<name>_fleet-data` volume.
|
|
|
|
## Troubleshooting
|
|
|
|
- **`permission denied` writing the inventory on approve** — the runtime image is
|
|
distroless `nonroot` (uid **65532**), but a named volume is created root-owned,
|
|
so `/data` isn't writable. A rebuilt image pre-creates `/data` owned by nonroot
|
|
(a *fresh* volume inherits it), but an **already-created** volume must be fixed
|
|
once:
|
|
```bash
|
|
docker volume ls | grep fleet # find the name, e.g. fleet-manager_fleet-data
|
|
docker run --rm -v <vol>:/data alpine chown -R 65532:65532 /data
|
|
```
|
|
- **`authorise plane not configured (no enrol-broker wired)` in the SPA** — the
|
|
manager has the pillar disabled (`s.broker == nil`). Check
|
|
`docker compose logs fleet-manager | grep -i pillar`: a `DISABLED`/`WARNING`
|
|
line names the missing URL or cert file. After staging the broker PKI, the
|
|
container must be **recreated** (`docker compose up -d --force-recreate
|
|
fleet-manager`) — a plain restart won't re-enable a plane that soft-disabled at
|
|
boot. Also make sure only **one** manager instance is bound to `:9500`
|
|
(`docker ps | grep manager`); a stale parallel instance serves the old state.
|
|
- **`TLS connect error: wrong version number` to the broker** — the broker admin
|
|
plane is answering in **plaintext**: it's running with `ENROL_ADMIN_INSECURE=true`
|
|
(the main serving-stack default). Set it `false`, ensure the broker's
|
|
`server.crt`/`server.key`/`admin-ca.crt` exist in its PKI mount, and restart it
|
|
(see step 2b).
|
|
|
|
## What is NOT yet wired (recorded intent)
|
|
|
|
Per-host **boot-version assignment** (`desired.target_version`) is stored and
|
|
pushed to the agent, but does not yet change what a host boots: that needs
|
|
dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). Kiosk
|
|
URL / reboot / drain **do** reconcile via the agent today. The Lifecycle page
|
|
labels the version pin as recorded intent.
|