diff --git a/docs/TODO.md b/docs/TODO.md index 9447b08..8752172 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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. + +
Original design capture (2026-06-28) — retained for reference **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. +
+ ## [ ] 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` diff --git a/docs/fleet-management-design.md b/docs/fleet-management-design.md index 63cc389..7a7acee 100644 --- a/docs/fleet-management-design.md +++ b/docs/fleet-management-design.md @@ -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; diff --git a/docs/tpm-enrolment-design.md b/docs/tpm-enrolment-design.md index 891c6a9..c156087 100644 --- a/docs/tpm-enrolment-design.md +++ b/docs/tpm-enrolment-design.md @@ -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). diff --git a/serving/fleet-manager/.dockerignore b/serving/fleet-manager/.dockerignore new file mode 100644 index 0000000..3957bed --- /dev/null +++ b/serving/fleet-manager/.dockerignore @@ -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 diff --git a/serving/fleet-manager/.env.example b/serving/fleet-manager/.env.example new file mode 100644 index 0000000..1b1761c --- /dev/null +++ b/serving/fleet-manager/.env.example @@ -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- diff --git a/serving/fleet-manager/.gitignore b/serving/fleet-manager/.gitignore index 4a2e49d..69f8198 100644 --- a/serving/fleet-manager/.gitignore +++ b/serving/fleet-manager/.gitignore @@ -1,2 +1,6 @@ /fleet-manager *.test +# deployment secrets + issued PKI (never commit) +.env +/pki/ +/data/ diff --git a/serving/fleet-manager/DEPLOY.md b/serving/fleet-manager/DEPLOY.md new file mode 100644 index 0000000..7f96e31 --- /dev/null +++ b/serving/fleet-manager/DEPLOY.md @@ -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 --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. diff --git a/serving/fleet-manager/README.md b/serving/fleet-manager/README.md index dfa510e..94f5f26 100644 --- a/serving/fleet-manager/README.md +++ b/serving/fleet-manager/README.md @@ -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 ` → `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). diff --git a/serving/fleet-manager/docker-compose.yml b/serving/fleet-manager/docker-compose.yml new file mode 100644 index 0000000..2c3d467 --- /dev/null +++ b/serving/fleet-manager/docker-compose.yml @@ -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 # 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 diff --git a/serving/fleet-manager/server-pki.sh b/serving/fleet-manager/server-pki.sh new file mode 100755 index 0000000..e85b444 --- /dev/null +++ b/serving/fleet-manager/server-pki.sh @@ -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"