fleet-manager: checkpoint 7 — deployment (compose, PKI, deploy runbook, design-doc updates)
Complete the deployment story for the server-side control plane: - docker-compose.yml: private InfluxDB (published to loopback only) + a host-networked fleet-manager (reaches the loopback enrol-broker on 127.0.0.1:9443 and binds a routable gRPC agent listener, like the broker). - .env.example: RP origin (diskless-server.bitasylum.org), Influx token/DB password, cert/PKI paths; the Influx token wires influxdb + manager with one value. - server-pki.sh: issue the agent-plane server cert from the fleet CA (step) and stage the fleet root as the client-verify CA. - DEPLOY.md: end-to-end runbook — three-cert PKI (fleet-CA agent cert, broker admin-client cert, Let's Encrypt DNS-01 UI cert), register-admin passkey bootstrap, compose up, hardware-free smoke test, backups. - .dockerignore: keep host node_modules/dist/pki/secrets out of the build context so the embedded SPA is the one built in-image. - .gitignore: never commit .env / issued PKI / data. Design docs marked as-built: fleet-management-design.md as-built note (resolved forks — gRPC bidi transport, InfluxDB metrics, SPA graphs, passkey admin auth, broker fold-in "alongside"); tpm-enrolment-design.md (broker fold-in + agent transport fork resolved); TODO.md #14 → partly built, with the on-host agent + per-host boot-version assignment (#6) called out as the remaining work.
This commit is contained in:
+32
-1
@@ -1371,7 +1371,36 @@ remains documented here if a minimal/auditable cmdline is ever wanted.
|
||||
**BENCH-VERIFY:** `chrome://policy` shows `ExtensionInstallBlocklist=["*"]` active;
|
||||
extensions can't install.
|
||||
|
||||
## [ ] 14. (DESIGN) Centralised fleet management plane (health/version/usage/maintenance)
|
||||
## [~] 14. (PARTLY BUILT) Centralised fleet management plane (health/version/usage/maintenance)
|
||||
|
||||
**SERVER-SIDE CONTROL PLANE BUILT 2026-07 — `serving/fleet-manager/`** (Go service
|
||||
+ embedded Svelte SPA). See the **as-built note** at the top of
|
||||
[docs/fleet-management-design.md](fleet-management-design.md). What shipped and how
|
||||
the design forks resolved:
|
||||
- **Two planes:** admin (HTTPS + **WebAuthn/passkey**, YubiKey; server-side session
|
||||
+ CSRF; `register-admin` CLI bootstrap; UI cert via **Let's Encrypt DNS-01**,
|
||||
hot-reloaded) and agent (**bespoke gRPC bidi/mTLS**, fleet CA, reused TPM cert —
|
||||
health/metrics up, desired-state down; no inbound host port).
|
||||
- **Metrics → InfluxDB** (manager is the only reader/writer; host holds no Influx
|
||||
credential; identity-tagged writes; Flux reads behind a field whitelist + hex-
|
||||
serial guard). Graphs render **in the SPA** (bespoke SVG), not Grafana. This
|
||||
replaces the earlier Wazuh-Phase-1 / OTLP-Phase-2 sketch for *this* plane;
|
||||
Wazuh stays the audit/SIEM path.
|
||||
- **Authorise pillar:** the enrol-broker (#11) folded in "alongside" — manager
|
||||
drives its admin plane over mTLS and **births the inventory record** on approve.
|
||||
- **Deployment:** `Dockerfile` + `docker-compose.yml` (private InfluxDB +
|
||||
host-networked manager) + `server-pki.sh` + `DEPLOY.md`.
|
||||
- **STILL TO BUILD:** (a) the **on-host agent** — the gRPC report/reconcile client
|
||||
that pairs with `fleet-hostapi` (feeds this plane; nothing on the host talks to
|
||||
the manager yet); (b) **per-host boot-version assignment** — `target_version` is
|
||||
stored + pushed but does not change what a host *boots*; that needs dnsmasq
|
||||
cohort steering + multi-version UKI serving (**#6**). Kiosk URL / reboot / drain
|
||||
reconcile via the agent once (a) lands. Version pin is labelled "recorded intent"
|
||||
in the Lifecycle UI.
|
||||
|
||||
Depends on the step-ca fleet CA (#8) for the mTLS identity.
|
||||
|
||||
<details><summary>Original design capture (2026-06-28) — retained for reference</summary>
|
||||
|
||||
**DESIGN CAPTURED 2026-06-28 — [docs/fleet-management-design.md](fleet-management-design.md).**
|
||||
Server-authoritative, pull-based, TPM-identified management plane reusing existing
|
||||
@@ -1388,6 +1417,8 @@ manifest + SPDK flow. Build-time decisions (telemetry encoding, metrics/desired-
|
||||
state store, per-host vs cohort, osquery/FleetDM eval) listed in the design note.
|
||||
Depends on the step-ca fleet CA (#8) for the mTLS identity.
|
||||
|
||||
</details>
|
||||
|
||||
## [ ] 15. Scope the root overlay → per-subtree tmpfs/overlay (un-shadowable `/usr` + STIG mount options)
|
||||
|
||||
**DESIGN CAPTURED 2026-07-02.** Today the whole root is one `systemd.volatile=overlay`
|
||||
|
||||
@@ -1,7 +1,56 @@
|
||||
# Fleet management plane — design note
|
||||
|
||||
**Status:** DESIGN (not built). Captured 2026-06-28. Build phased — see "Phasing"
|
||||
and `docs/TODO.md` #14.
|
||||
**Status:** PARTLY BUILT. Captured 2026-06-28; **server-side control plane built
|
||||
2026-07** (`serving/fleet-manager/`). The sections below are the original design;
|
||||
the **as-built note** immediately following records what shipped and how the
|
||||
open forks resolved. Build still phased — see "Phasing" and `docs/TODO.md` #14.
|
||||
|
||||
## As-built note (2026-07) — `serving/fleet-manager/`
|
||||
|
||||
The **server-side control plane + admin SPA is built** and supersedes several
|
||||
"open questions" below. What shipped:
|
||||
|
||||
- **One service, two planes** (`serving/fleet-manager/`, Go): an **admin plane**
|
||||
(HTTPS + SPA + JSON API) and an **agent plane** (gRPC/mTLS). Runs as its own
|
||||
container on the mgmt host (`docker-compose.yml`: private InfluxDB +
|
||||
host-networked manager). Deployment runbook: `serving/fleet-manager/DEPLOY.md`.
|
||||
- **Resolved forks (were "open questions / decisions"):**
|
||||
- *Steady-state transport (agent ↔ manager)* → **bespoke gRPC bidi stream**
|
||||
(`proto/fleet_agent.proto`: `FleetAgent.Session`), host-dialed mTLS, health/
|
||||
metrics up + desired-state down. Chosen over OTLP+Wazuh+control-stream: one
|
||||
stream, no polling, no inbound host port. Enrolment stays plain HTTP/JSON.
|
||||
- *Metrics store* → **InfluxDB 2.x** (was VictoriaMetrics/Prometheus). The
|
||||
manager is the **only** reader/writer (host holds no Influx credential);
|
||||
writes are line-protocol tagged with the mTLS-authenticated identity, reads
|
||||
are a Flux query behind a **field whitelist + hex-serial guard**. Graphs are
|
||||
rendered **in the SPA** (bespoke SVG), **not Grafana**.
|
||||
- *Dashboards* → the **LYSANDRA restricted-violet SPA** (Svelte 5 + Tailwind 4,
|
||||
embedded via `go:embed`): Monitor / Authorise / Lifecycle. Not Grafana/Wazuh
|
||||
dashboards for this plane (Wazuh stays the audit/SIEM path).
|
||||
- *Desired-state store* → a single **JSON file** (atomic writes) behind a
|
||||
`Store` interface — not git/DB yet; a DB is a drop-in. Server-authoritative.
|
||||
- *Admin/browser identity* → **WebAuthn/passkey (YubiKey)**, not browser mTLS:
|
||||
usernameless discoverable login, UV required, server-side session + CSRF.
|
||||
Bootstrap is a closed CLI (`register-admin`, one-time HMAC enrol link). The
|
||||
UI cert is **Let's Encrypt DNS-01** (mgmt-net host, hot-reloaded). The
|
||||
*manager→broker* hop stays mTLS (service-to-service, broker admin CA).
|
||||
- *Authorise pillar* → the **enrol-broker folded in** "alongside": the broker
|
||||
stays the state machine + step-ca signer; the manager drives its admin plane
|
||||
over mTLS (Accept-gated JSON approve) and **births the inventory record** on
|
||||
approve, recording the human passkey admin as approver.
|
||||
- **Agent identity confirmed:** the agent reuses the **same TPM identity cert**
|
||||
as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert
|
||||
serial is authoritative; an unenrolled serial is refused.
|
||||
- **NOT yet built (recorded intent):** per-host **boot-version assignment**.
|
||||
`desired.target_version` is stored + pushed, but changing what a host *boots*
|
||||
needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6).
|
||||
Kiosk URL / reboot / drain reconcile via the agent today; the version pin is
|
||||
labelled "recorded intent" in the Lifecycle UI. The **on-host agent** that
|
||||
feeds this plane (extend `fleet-hostapi` with the gRPC report/reconcile client)
|
||||
is likewise still to build.
|
||||
|
||||
Everything below is the original design context; treat the as-built note as
|
||||
authoritative where they differ.
|
||||
|
||||
Centralised monitoring + maintenance of the diskless kiosk fleet: **health,
|
||||
version, usage, and maintenance actions**. This note is the authoritative design;
|
||||
|
||||
@@ -146,6 +146,18 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
|
||||
everything else on. Build the broker standalone first (fastest path to the state
|
||||
machine), but design it to fold into the mgmt service with no rewrite — same Go
|
||||
style, same step-ca anchor, same data model.
|
||||
- **AS-BUILT (2026-07): folded in "alongside", not merged.** The
|
||||
`serving/fleet-manager/` control plane drives the standalone broker's admin
|
||||
plane over mTLS (broker admin CA) rather than absorbing its code: the broker
|
||||
stays the enrolment **state machine + step-ca signer**, and gained an
|
||||
`Accept: application/json` approve response so the manager gets the decided
|
||||
enrolment back and **births the inventory record**, recording the human
|
||||
(passkey-authenticated) admin as approver. `verify-serving.sh` / browser
|
||||
callers are unchanged (still get the 204/redirect). The public
|
||||
`/enrol/request|status` edge is untouched. This keeps the one unauth,
|
||||
stdlib-only, reproducible pre-auth parser isolated while giving the manager
|
||||
the authenticated authorise pillar — same trust anchor, same data model, no
|
||||
rewrite. See `serving/fleet-manager/README.md` (Authorise pillar).
|
||||
- **host-api is not in the enrolment path** (it is loopback/post-boot; the host has
|
||||
no cert during enrolment). The tie is downstream: the fleet-manager **agent** (the
|
||||
push sibling of `fleet-hostapi`) authenticates with the *enrolled* cert over mTLS,
|
||||
@@ -200,11 +212,13 @@ fleet-manager agent) — one language, one build/posture story. Transport is spl
|
||||
- **Steady-state agent ↔ manager (telemetry + reconciliation) → gRPC over mTLS,
|
||||
host-dialed.** Streaming fits here, and it stays compatible with "no inbound port on
|
||||
hosts" because the **host dials out** and holds one long-lived mTLS stream (health
|
||||
up / desired-state down). OPEN FORK (a fleet-manager build decision, not enrolment):
|
||||
bespoke gRPC vs reuse **OTLP-gRPC for metrics + Wazuh for events** + a thin gRPC
|
||||
control stream — the latter gets VictoriaMetrics/Grafana/Wazuh for free. `Connect`
|
||||
(connectrpc.com) is an option if one protobuf IDL serving gRPC + JSON + browser is
|
||||
wanted across the authenticated surface.
|
||||
up / desired-state down). ~~OPEN FORK~~ **RESOLVED (2026-07):** a **bespoke gRPC
|
||||
bidi stream** (`serving/fleet-manager/proto/fleet_agent.proto` —
|
||||
`FleetAgent.Session`, health/metrics up + desired-state down), over the same
|
||||
reused TPM cert (fleet CA). Metrics land in **InfluxDB** via the manager (host
|
||||
holds no Influx credential), not OTLP→VictoriaMetrics; events stay on the Wazuh
|
||||
path. One stream, no polling, no inbound host port. (The on-host agent — the
|
||||
gRPC report/reconcile client that pairs with `fleet-hostapi` — is still to build.)
|
||||
- **host-api stays HTTP/JSON — locked**, because its consumer is the Chromium kiosk
|
||||
(a browser, which cannot speak native gRPC).
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# The image builds the SPA in its own stage and the Go stage embeds THAT dist —
|
||||
# keep host build artifacts, deps, secrets, and PKI out of the build context.
|
||||
ui/node_modules
|
||||
ui/dist
|
||||
node_modules
|
||||
/fleet-manager
|
||||
*.test
|
||||
.env
|
||||
pki/
|
||||
data/
|
||||
.git
|
||||
@@ -0,0 +1,46 @@
|
||||
# Copy to .env and edit. See DEPLOY.md for the full bring-up.
|
||||
# .env is gitignored — it holds the Influx token and DB password.
|
||||
|
||||
# --- WebAuthn RP (admin passkeys are ORIGIN-BOUND — must be the UI's real URL) --
|
||||
# For this deployment the UI is https://diskless-server.bitasylum.org .
|
||||
FLEET_MGR_RP_ID=diskless-server.bitasylum.org
|
||||
FLEET_MGR_RP_ORIGIN=https://diskless-server.bitasylum.org
|
||||
FLEET_MGR_RP_NAME=Fleet Command
|
||||
|
||||
# --- admin plane HTTPS ---------------------------------------------------------
|
||||
# The UI cert is a publicly-trusted Let's Encrypt cert via DNS-01 (the UI is on
|
||||
# the mgmt net, not internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate;
|
||||
# DNS-01 proves control of bitasylum.org via a TXT record). An external ACME
|
||||
# client (lego/certbot + your DNS provider) writes fullchain/privkey into
|
||||
# ./pki/admin/ ; the manager hot-reloads on renewal. See DEPLOY.md.
|
||||
FLEET_MGR_ADMIN_ADDR=127.0.0.1:9500
|
||||
FLEET_MGR_ADMIN_CERT=/pki/admin/fullchain.pem
|
||||
FLEET_MGR_ADMIN_KEY=/pki/admin/privkey.pem
|
||||
# Dev-only escape hatch: plaintext, NO passkey auth. NEVER set true in prod.
|
||||
FLEET_MGR_ADMIN_INSECURE=false
|
||||
|
||||
# --- agent plane (gRPC + mTLS, fleet CA) ---------------------------------------
|
||||
# Routable mgmt address enrolled hosts dial (":9600" = all interfaces). The
|
||||
# server cert is issued by the fleet CA (server-pki.sh); the client CA is the
|
||||
# fleet root so host TPM certs verify. Paths are fixed in docker-compose.yml.
|
||||
FLEET_MGR_AGENT_ADDR=:9600
|
||||
|
||||
# --- authorise pillar (enrol-broker admin plane over mTLS) ---------------------
|
||||
# The broker runs network_mode:host on 127.0.0.1:9443. The manager's client cert
|
||||
# is minted by ../enrol-broker/admin-pki.sh (admin CA), into ./pki/broker/ .
|
||||
FLEET_MGR_BROKER_URL=https://127.0.0.1:9443
|
||||
|
||||
# --- InfluxDB (private; the manager is the ONLY reader/writer) -----------------
|
||||
# The token is handed to BOTH influxdb (init admin token) and the manager, so a
|
||||
# single value wires the two with no copy step. Treat as a secret.
|
||||
FLEET_MGR_INFLUX_TOKEN=changeme-generate-a-long-random-token
|
||||
FLEET_MGR_INFLUX_ORG=fleet
|
||||
FLEET_MGR_INFLUX_BUCKET=fleet_health
|
||||
INFLUX_ADMIN_USER=fleet-admin
|
||||
INFLUX_ADMIN_PASSWORD=changeme-influx-admin-password
|
||||
INFLUX_RETENTION=90d
|
||||
|
||||
# --- misc ----------------------------------------------------------------------
|
||||
FLEET_MGR_SESSION_TTL_MIN=720
|
||||
# Must match the enrol-broker's HOSTNQN_BASE.
|
||||
HOSTNQN_BASE=nqn.2026-06.org.example.fleet:host-
|
||||
@@ -1,2 +1,6 @@
|
||||
/fleet-manager
|
||||
*.test
|
||||
# deployment secrets + issued PKI (never commit)
|
||||
.env
|
||||
/pki/
|
||||
/data/
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
# 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):
|
||||
|
||||
```bash
|
||||
../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager
|
||||
cp -r ../dot1x-bench/pki/broker ./pki/broker
|
||||
# -> ./pki/broker/{admin-ca.crt, clients/fleet-manager.{crt,key}}
|
||||
```
|
||||
|
||||
### 2c. Admin UI cert (Let's Encrypt DNS-01)
|
||||
|
||||
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 (and supports wildcards). Example with `lego`:
|
||||
|
||||
```bash
|
||||
# Credentials for your DNS provider are provider-specific (env vars).
|
||||
lego --dns <provider> --domains diskless-server.bitasylum.org \
|
||||
--email you@example.org --accept-tos run
|
||||
# Point the manager at the issued files (it hot-reloads on renewal):
|
||||
mkdir -p ./pki/admin
|
||||
cp .lego/certificates/diskless-server.bitasylum.org.crt ./pki/admin/fullchain.pem
|
||||
cp .lego/certificates/diskless-server.bitasylum.org.key ./pki/admin/privkey.pem
|
||||
```
|
||||
|
||||
Automate renewal (cron/systemd-timer) to rewrite those two files; the manager's
|
||||
`certReloader` notices the mtime bump and swaps the cert live — no restart. A
|
||||
publicly-trusted cert also keeps the WebAuthn secure-context clean (no browser
|
||||
warnings). *Alternative:* front the loopback admin listener with your existing
|
||||
mgmt reverse proxy and terminate the cert there; then set
|
||||
`FLEET_MGR_ADMIN_INSECURE=false` still but let the proxy hold the cert.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -137,7 +137,15 @@ inventory for offline delivery. Metrics writing is behind a `MetricsSink`
|
||||
| `FLEET_MGR_SEED` | `false` | inject demo hosts so the SPA has data — dev only |
|
||||
| `HOSTNQN_BASE` | `nqn.2026-06.org.example.fleet:host-` | hostnqn prefix (match the broker) |
|
||||
|
||||
InfluxDB env lands in checkpoint 4.
|
||||
## Deploy
|
||||
|
||||
Full bring-up (PKI, Let's Encrypt DNS-01, `register-admin`, compose) is in
|
||||
[`DEPLOY.md`](DEPLOY.md). In short: `cp .env.example .env` → `./server-pki.sh` +
|
||||
`../enrol-broker/admin-pki.sh … fleet-manager` → `docker compose up -d influxdb`
|
||||
→ `docker compose run --rm fleet-manager register-admin <you>` → `docker compose
|
||||
up -d fleet-manager`. InfluxDB is private (published to loopback only); the
|
||||
manager uses host networking to reach the loopback broker + bind a routable
|
||||
agent listener.
|
||||
|
||||
## Dev run
|
||||
|
||||
@@ -175,4 +183,9 @@ curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name,
|
||||
Lifecycle + bespoke SVG charts. Embedded via `go:embed` behind the `embedspa`
|
||||
build tag; served same-origin over the admin plane. Smoke-tested end-to-end
|
||||
(index + assets + API through the SPA origin).
|
||||
- Next: Docker/compose + PKI/RP/ACME + docs (ckpt 7).
|
||||
- **Checkpoint 7 (done):** deployment — three-stage `Dockerfile` (npm→dist→
|
||||
`go build -tags embedspa`→distroless), `docker-compose.yml` (private InfluxDB +
|
||||
host-networked manager), `.env.example`, `server-pki.sh` (agent-plane fleet-CA
|
||||
server cert), and [`DEPLOY.md`](DEPLOY.md) (PKI, LE DNS-01, `register-admin`
|
||||
bootstrap). Design docs updated (manager marked built; resolved transport/auth/
|
||||
metrics forks; per-host-versioning gap recorded).
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# =============================================================================
|
||||
# fleet-manager — server-side fleet control plane + admin SPA, plus its private
|
||||
# InfluxDB metrics store. Runs on the MANAGEMENT host (never the boot LAN).
|
||||
#
|
||||
# cp .env.example .env # set RP origin, Influx token, cert/PKI paths
|
||||
# ./server-pki.sh # issue the agent-plane server cert (fleet CA)
|
||||
# ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager
|
||||
# docker compose up -d influxdb
|
||||
# docker compose run --rm fleet-manager register-admin <you> # passkey link
|
||||
# docker compose up -d fleet-manager
|
||||
#
|
||||
# See DEPLOY.md for the full bring-up (PKI, Let's Encrypt DNS-01, register-admin).
|
||||
#
|
||||
# Trust model (two planes, see README.md):
|
||||
# - admin : HTTPS + WebAuthn passkey, loopback 127.0.0.1:9500 (front with the
|
||||
# mgmt reverse proxy that terminates the LE cert, or expose directly).
|
||||
# - agent : gRPC + mTLS (fleet CA), on a routable mgmt interface so enrolled
|
||||
# hosts can dial in (FLEET_MGR_AGENT_ADDR).
|
||||
#
|
||||
# fleet-manager uses HOST networking (like enrol-broker): it must reach the
|
||||
# broker's admin plane on 127.0.0.1:9443 and bind the agent listener on a
|
||||
# routable mgmt address. InfluxDB stays on a private bridge and is published
|
||||
# ONLY to loopback (127.0.0.1:8086), so the host-networked manager reaches it
|
||||
# while nothing else on any LAN can.
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# --- metrics store (private; only the manager reads/writes it) --------------
|
||||
influxdb:
|
||||
image: influxdb:2.7
|
||||
restart: unless-stopped
|
||||
# Publish to loopback only — the host-networked manager reaches it at
|
||||
# 127.0.0.1:8086; it is never on the boot/mgmt LAN.
|
||||
ports:
|
||||
- "127.0.0.1:8086:8086"
|
||||
environment:
|
||||
DOCKER_INFLUXDB_INIT_MODE: setup
|
||||
DOCKER_INFLUXDB_INIT_USERNAME: "${INFLUX_ADMIN_USER:-fleet-admin}"
|
||||
DOCKER_INFLUXDB_INIT_PASSWORD: "${INFLUX_ADMIN_PASSWORD:?set INFLUX_ADMIN_PASSWORD in .env}"
|
||||
DOCKER_INFLUXDB_INIT_ORG: "${FLEET_MGR_INFLUX_ORG:-fleet}"
|
||||
DOCKER_INFLUXDB_INIT_BUCKET: "${FLEET_MGR_INFLUX_BUCKET:-fleet_health}"
|
||||
DOCKER_INFLUXDB_INIT_RETENTION: "${INFLUX_RETENTION:-90d}"
|
||||
# Deterministic operator token so the manager can be handed the SAME value
|
||||
# in FLEET_MGR_INFLUX_TOKEN without a post-setup copy step. Treat as a
|
||||
# secret (it lives in .env, which is gitignored).
|
||||
DOCKER_INFLUXDB_INIT_ADMIN_TOKEN: "${FLEET_MGR_INFLUX_TOKEN:?set FLEET_MGR_INFLUX_TOKEN in .env}"
|
||||
volumes:
|
||||
- influx-data:/var/lib/influxdb2
|
||||
- influx-config:/etc/influxdb2
|
||||
networks:
|
||||
- fleet-mgmt
|
||||
healthcheck:
|
||||
test: ["CMD", "influx", "ping"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# --- the control plane + embedded SPA --------------------------------------
|
||||
fleet-manager:
|
||||
build: .
|
||||
image: fleet-manager:local
|
||||
restart: unless-stopped
|
||||
# Host networking: reach the broker on 127.0.0.1:9443 + InfluxDB on
|
||||
# 127.0.0.1:8086, and bind the agent gRPC listener on a routable mgmt addr.
|
||||
network_mode: host
|
||||
depends_on:
|
||||
influxdb:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# admin plane (HTTPS + passkey). Bind loopback; the mgmt proxy (holding the
|
||||
# LE cert) fronts it, or set the cert paths below + expose directly.
|
||||
FLEET_MGR_ADMIN_ADDR: "${FLEET_MGR_ADMIN_ADDR:-127.0.0.1:9500}"
|
||||
FLEET_MGR_ADMIN_CERT: "${FLEET_MGR_ADMIN_CERT:-/pki/admin/fullchain.pem}"
|
||||
FLEET_MGR_ADMIN_KEY: "${FLEET_MGR_ADMIN_KEY:-/pki/admin/privkey.pem}"
|
||||
FLEET_MGR_ADMIN_INSECURE: "${FLEET_MGR_ADMIN_INSECURE:-false}"
|
||||
# WebAuthn RP — MUST be the UI's real origin (origin-bound passkeys).
|
||||
FLEET_MGR_RP_ID: "${FLEET_MGR_RP_ID:?set FLEET_MGR_RP_ID in .env}"
|
||||
FLEET_MGR_RP_ORIGIN: "${FLEET_MGR_RP_ORIGIN:?set FLEET_MGR_RP_ORIGIN in .env}"
|
||||
FLEET_MGR_RP_NAME: "${FLEET_MGR_RP_NAME:-Fleet Command}"
|
||||
FLEET_MGR_ENROLL_KEY_FILE: "/data/enroll.key"
|
||||
FLEET_MGR_ADMIN_STORE: "/data/admins.json"
|
||||
FLEET_MGR_SESSION_TTL_MIN: "${FLEET_MGR_SESSION_TTL_MIN:-720}"
|
||||
|
||||
# agent plane (gRPC + mTLS, fleet CA). Bind a routable mgmt addr so hosts
|
||||
# can dial in; verify host TPM certs against the fleet root.
|
||||
FLEET_MGR_AGENT_ADDR: "${FLEET_MGR_AGENT_ADDR:-:9600}"
|
||||
FLEET_MGR_AGENT_CERT: "/pki/agent/server.crt"
|
||||
FLEET_MGR_AGENT_KEY: "/pki/agent/server.key"
|
||||
FLEET_MGR_AGENT_CLIENT_CA: "/pki/agent/fleet-ca.crt"
|
||||
|
||||
# authorise pillar — drive the enrol-broker admin plane over mTLS.
|
||||
FLEET_MGR_BROKER_URL: "${FLEET_MGR_BROKER_URL:-https://127.0.0.1:9443}"
|
||||
FLEET_MGR_BROKER_CLIENT_CERT: "/pki/broker/clients/fleet-manager.crt"
|
||||
FLEET_MGR_BROKER_CLIENT_KEY: "/pki/broker/clients/fleet-manager.key"
|
||||
FLEET_MGR_BROKER_CA: "/pki/broker/admin-ca.crt"
|
||||
|
||||
# metrics — the manager is the ONLY reader/writer; token stays server-side.
|
||||
FLEET_MGR_INFLUX_URL: "http://127.0.0.1:8086"
|
||||
FLEET_MGR_INFLUX_TOKEN: "${FLEET_MGR_INFLUX_TOKEN:?set FLEET_MGR_INFLUX_TOKEN in .env}"
|
||||
FLEET_MGR_INFLUX_ORG: "${FLEET_MGR_INFLUX_ORG:-fleet}"
|
||||
FLEET_MGR_INFLUX_BUCKET: "${FLEET_MGR_INFLUX_BUCKET:-fleet_health}"
|
||||
|
||||
FLEET_MGR_INVENTORY: "/data/inventory.json"
|
||||
HOSTNQN_BASE: "${HOSTNQN_BASE:-nqn.2026-06.org.example.fleet:host-}"
|
||||
volumes:
|
||||
# Durable state (inventory + admin passkeys + enrol key). Back this up.
|
||||
- fleet-data:/data
|
||||
# PKI, read-only: admin LE cert, agent-plane fleet-CA server cert, and the
|
||||
# broker admin-client cert. See DEPLOY.md / server-pki.sh for what lands here.
|
||||
- ./pki:/pki:ro
|
||||
|
||||
volumes:
|
||||
influx-data:
|
||||
influx-config:
|
||||
fleet-data:
|
||||
|
||||
networks:
|
||||
fleet-mgmt:
|
||||
driver: bridge
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# server-pki.sh — issue the fleet-manager AGENT-plane server cert from the fleet
|
||||
# CA, and stage the fleet root as the agent client-verify CA.
|
||||
#
|
||||
# The agent plane is gRPC + mTLS: enrolled hosts present their TPM identity cert
|
||||
# (fleet CA), and they verify the MANAGER's server cert against the same fleet
|
||||
# root they hold. So the manager's agent-plane server cert MUST be signed by the
|
||||
# fleet CA — this script issues it via `step ca certificate` against the running
|
||||
# fleet step-ca (serving/dot1x-bench/), the same CA that signs enrolment certs.
|
||||
#
|
||||
# This does NOT issue:
|
||||
# - the ADMIN-plane UI cert -> Let's Encrypt DNS-01 (external ACME client;
|
||||
# drop fullchain/privkey into ./pki/admin/). See DEPLOY.md.
|
||||
# - the manager's BROKER client cert -> ../enrol-broker/admin-pki.sh (admin CA):
|
||||
# ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager
|
||||
#
|
||||
# Usage: server-pki.sh
|
||||
# env overrides:
|
||||
# OUT=./pki/agent output dir
|
||||
# AGENT_SANS=diskless-server.bitasylum.org,10.0.0.1 SANs hosts dial (DNS/IP, comma-sep)
|
||||
# FLEET_CA_URL=https://ca.fleet.lab:8443
|
||||
# FLEET_CA_ROOT=../dot1x-bench/pki/root_ca.crt
|
||||
# STEP_PROVISIONER=enrol
|
||||
# STEP_PROVISIONER_PASSWORD_FILE=../dot1x-bench/pki/stepca/secrets/password
|
||||
# CERT_NOT_AFTER=8760h
|
||||
set -euo pipefail
|
||||
|
||||
OUT=${OUT:-./pki/agent}
|
||||
AGENT_SANS=${AGENT_SANS:-diskless-server.bitasylum.org}
|
||||
FLEET_CA_URL=${FLEET_CA_URL:-https://ca.fleet.lab:8443}
|
||||
FLEET_CA_ROOT=${FLEET_CA_ROOT:-../dot1x-bench/pki/root_ca.crt}
|
||||
STEP_PROVISIONER=${STEP_PROVISIONER:-enrol}
|
||||
STEP_PROVISIONER_PASSWORD_FILE=${STEP_PROVISIONER_PASSWORD_FILE:-../dot1x-bench/pki/stepca/secrets/password}
|
||||
CERT_NOT_AFTER=${CERT_NOT_AFTER:-8760h}
|
||||
|
||||
command -v step >/dev/null || { echo "error: 'step' CLI not found (smallstep/cli)"; exit 1; }
|
||||
[ -f "$FLEET_CA_ROOT" ] || { echo "error: fleet root not found at $FLEET_CA_ROOT (bootstrap the fleet CA first)"; exit 1; }
|
||||
[ -f "$STEP_PROVISIONER_PASSWORD_FILE" ] || { echo "error: provisioner password not found at $STEP_PROVISIONER_PASSWORD_FILE"; exit 1; }
|
||||
|
||||
mkdir -p "$OUT"
|
||||
|
||||
# The fleet root doubles as the agent-plane CLIENT CA (verifies host TPM certs).
|
||||
cp "$FLEET_CA_ROOT" "$OUT/fleet-ca.crt"
|
||||
|
||||
# First SAN is the CN; every SAN (DNS name / IP) hosts might dial goes in --san.
|
||||
primary=${AGENT_SANS%%,*}
|
||||
san_args=()
|
||||
IFS=',' read -ra sans <<<"$AGENT_SANS"
|
||||
for s in "${sans[@]}"; do san_args+=(--san "$s"); done
|
||||
|
||||
echo "==> issuing agent-plane server cert (CN=$primary, SANs=$AGENT_SANS)"
|
||||
step ca certificate "$primary" "$OUT/server.crt" "$OUT/server.key" \
|
||||
--ca-url "$FLEET_CA_URL" \
|
||||
--root "$FLEET_CA_ROOT" \
|
||||
--provisioner "$STEP_PROVISIONER" \
|
||||
--provisioner-password-file "$STEP_PROVISIONER_PASSWORD_FILE" \
|
||||
--not-after "$CERT_NOT_AFTER" \
|
||||
--force \
|
||||
"${san_args[@]}"
|
||||
|
||||
chmod 600 "$OUT/server.key"
|
||||
|
||||
echo
|
||||
echo "done. staged in $OUT :"
|
||||
echo " server.crt / server.key -> FLEET_MGR_AGENT_CERT / _KEY"
|
||||
echo " fleet-ca.crt -> FLEET_MGR_AGENT_CLIENT_CA (verifies host TPM certs)"
|
||||
echo
|
||||
echo "next:"
|
||||
echo " - admin UI cert : Let's Encrypt DNS-01 -> ./pki/admin/{fullchain,privkey}.pem (DEPLOY.md)"
|
||||
echo " - broker client : ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager"
|
||||
echo " then: cp -r ../dot1x-bench/pki/broker ./pki/broker"
|
||||
Reference in New Issue
Block a user