diff --git a/.gitea/workflows/fleet-manager-image.yaml b/.gitea/workflows/fleet-manager-image.yaml new file mode 100644 index 0000000..0b1acd3 --- /dev/null +++ b/.gitea/workflows/fleet-manager-image.yaml @@ -0,0 +1,111 @@ +# +# .gitea/workflows/fleet-manager-image.yaml +# +# Builds the serving-stack fleet-manager image (serving/fleet-manager) and pushes +# it to the registry, so mgmt hosts `docker compose pull` it instead of building +# it at deploy time (matching the dnsmasq / SPDK / kiosk images). The multi-stage +# Dockerfile builds the Svelte SPA (node) → dist/, compiles the Go binary with the +# SPA embedded (`-tags embedspa`), and ships FROM distroless static (nonroot). The +# gRPC .pb.go stubs are committed, so no protoc is needed here. +# +# NOTE on base images: the build/ui stages pull the golang + node bases from the +# harbor mirror; the runtime is gcr.io/distroless/static-debian12:nonroot. The +# runner needs reach to harbor + gcr.io (mirror distroless through harbor too if +# egress is restricted). +# +# Tagging mirrors gitea-main-ci.yaml so every branch builds a uniquely tagged, +# pushed image you can test without clobbering main: +# - tag push (refs/tags/v1.2.3) -> "1.2.3" (+ :latest) +# - main push (refs/heads/main) -> "main-" (+ :latest) +# - branch push (refs/heads/) -> "-" (dev image, no :latest) +# Set FLEET_MANAGER_VERSION in serving/fleet-manager/.env to the tag you want to run. +# +# Runs only when the fleet-manager image inputs change (path filter) or on demand. +# +# Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds). +# + +name: Build fleet-manager image +run-name: ${{ gitea.actor }} builds the fleet-manager control-plane image (${{ gitea.ref_name }}) +on: + push: + branches: + - "**" # every branch — dev branches get a branch-name+sha tag + tags: + - "v*" # tag push -> clean semver tag (v1.2.3 -> 1.2.3) + paths: + - "serving/fleet-manager/**" + - ".gitea/workflows/fleet-manager-image.yaml" + workflow_dispatch: + +# One build per ref at a time; a newer push to the same ref cancels the in-flight. +concurrency: + group: fleet-manager-image-${{ gitea.ref }} + cancel-in-progress: true + +env: + FLEET_MANAGER_IMAGE: "git.bitasylum.org/audiobuzz/fleet-manager" + +jobs: + fleet-manager-build: + name: Build & push fleet-manager image + runs-on: ubuntu-latest + container: + image: git.bitasylum.org/audiobuzz/ubuntu-act-runner-qemu:main + steps: + - + name: Checkout + uses: actions/checkout@v6 + + - + name: Derive image tag + id: vars + # Same scheme as gitea-main-ci.yaml: clean semver on a tag push, else + # "-" with the branch sanitized to the OCI tag charset. + run: | + short_sha="$(echo "${GITHUB_SHA}" | cut -c1-7)" + if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then + version="${GITHUB_REF#refs/tags/}"; version="${version#v}" + else + branch="${GITHUB_REF#refs/heads/}" + safe_branch="$(printf '%s' "$branch" \ + | sed -e 's#[^A-Za-z0-9._-]#-#g' -e 's#^[.-]*##' | cut -c1-100)" + [ -n "$safe_branch" ] || safe_branch="branch" + version="${safe_branch}-${short_sha}" + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Building ${FLEET_MANAGER_IMAGE}:${version}" + + - + name: Install buildah + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends buildah + + - + name: Registry login + run: | + buildah login \ + --username "${{ secrets.PACKAGE_USER }}" \ + --password "${{ secrets.PACKAGE_TOKEN }}" \ + git.bitasylum.org + + - + name: Build & push + # vfs storage: overlay-on-overlayfs is unsupported in-container. + env: + STORAGE_DRIVER: vfs + BUILDAH_ISOLATION: chroot + run: | + VER="${{ steps.vars.outputs.version }}" + buildah bud -t "${FLEET_MANAGER_IMAGE}:${VER}" serving/fleet-manager + buildah push "${FLEET_MANAGER_IMAGE}:${VER}" + # Only main / release tags move :latest, so dev branches never clobber it. + case "$GITHUB_REF" in + refs/heads/main|refs/tags/*) + buildah tag "${FLEET_MANAGER_IMAGE}:${VER}" "${FLEET_MANAGER_IMAGE}:latest" + buildah push "${FLEET_MANAGER_IMAGE}:latest" + echo "Updated ${FLEET_MANAGER_IMAGE}:latest" ;; + *) + echo "Dev branch build — :latest left untouched (run with FLEET_MANAGER_VERSION=${VER})" ;; + esac diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index 37a80d8..ab576f4 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -58,6 +58,7 @@ on: - ".gitea/workflows/ipxe-image.yaml" - ".gitea/workflows/dnsmasq-image.yaml" - ".gitea/workflows/spdk-image.yaml" + - ".gitea/workflows/fleet-manager-image.yaml" # One build per ref at a time; a newer push to the same branch cancels the # in-flight build (each build is a ~17-min VM run, so don't stack them). @@ -196,14 +197,14 @@ jobs: go-version: '1.26.4' - - name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi) + name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi, fleet-agent) # Built HERE on the runner, NOT inside the Packer VM, so the Rocky build # image never carries the Go toolchain or the go-tpm dep tree. Packer # file-provisions the resulting static binaries into the rootfs. Runs # BEFORE packer validate/build so the file provisioners' sources exist. # Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid. - # fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is - # stdlib-only (GOPROXY=off). + # fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by + # go.sum); hostapi is stdlib-only (GOPROXY=off). # shell: bash is explicit — the Gitea/act runner defaults run steps to sh # (dash), where `set -o pipefail` is an illegal option. `set -e` is # load-bearing: without it a failed go build/test in the first ( … ) group @@ -218,13 +219,20 @@ jobs: ( cd files/hostapi \ && CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \ go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . ) - file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi + ( cd files/agent \ + && CGO_ENABLED=0 GOTOOLCHAIN=local \ + go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . \ + && CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go test ./... ) + file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi files/agent/bin/fleet-agent - - name: TPM enrolment ceremony test (go-tpm software simulator) - # Exercises the real key-gen/sign/NV path against a software TPM (CGO in a - # separate module, so the simulator never enters the shipped enrol build). - run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) + name: TPM ceremony + agent mTLS tests (go-tpm software simulator) + # Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS + # client-auth handshake (agent) against a software TPM (CGO in separate + # modules, so the simulator never enters the shipped enrol/agent builds). + run: | + ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) + ( cd files/agent/simtest && CGO_ENABLED=1 go test ./... ) - name: packer init @@ -272,6 +280,22 @@ jobs: echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV" echo "Secure Boot signing material staged (signed UKI build)." + - + name: Materialize fleet CA (radius-ca.pem trust anchor) + # The real fleet step-ca root is baked to /etc/dot1x/radius-ca.pem (RADIUS + # EAP-TLS + fleet-agent server-verify). Written to a runner-temp FILE and + # passed as a build-host PATH (var.fleet_ca). If FLEET_CA is unset this + # no-ops and the build falls back to the committed PLACEHOLDER (auth cannot + # succeed — lab only). Never commit real internal CA material (CLAUDE.md). + if: ${{ secrets.FLEET_CA != '' }} + env: + FLEET_CA: ${{ secrets.FLEET_CA }} # fleet step-ca root PEM (root, or root+intermediate) + run: | + umask 077 + printf '%s\n' "$FLEET_CA" > "$RUNNER_TEMP/fleet-ca.pem" + echo "FLEET_CA_FILE=$RUNNER_TEMP/fleet-ca.pem" >> "$GITHUB_ENV" + echo "Fleet CA staged (real trust anchor baked into radius-ca.pem)." + - name: Materialize Secure Boot enrollment certs # PUBLIC PK/KEK/db .auth files baked into the image for fleet-sb-enroll. @@ -308,6 +332,7 @@ jobs: -var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \ -var sb_key="${SB_KEY_FILE:-}" \ -var sb_cert="${SB_CERT_FILE:-}" \ + -var fleet_ca="${FLEET_CA_FILE:-files/etc/dot1x/radius-ca.pem}" \ -var kiosk_url="${{ env.KIOSK_URL }}" \ -var boot_splash=${{ env.BOOT_SPLASH }} \ -var enable_stig=${{ env.ENABLE_STIG }} \ diff --git a/.gitea/workflows/registry-gc.yaml b/.gitea/workflows/registry-gc.yaml index 66fadf2..8eb7bf7 100644 --- a/.gitea/workflows/registry-gc.yaml +++ b/.gitea/workflows/registry-gc.yaml @@ -64,7 +64,7 @@ jobs: DISPATCH_DRY: ${{ inputs.dry_run }} GC_ENABLE: ${{ vars.REGISTRY_GC_ENABLE }} # Only images built by THIS repo's workflows. - IMAGES: "fleet-boot fleet-ipxe fleet-dnsmasq spdk-nvmf" + IMAGES: "fleet-boot fleet-ipxe fleet-dnsmasq spdk-nvmf fleet-manager" steps: - name: Reconcile registry against live branches # The runner defaults `run:` to sh (dash); this script uses bashisms diff --git a/.gitignore b/.gitignore index 36b336a..dccc3ad 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ keys # provisioner; built by the "Build host Go binaries" CI step). files/enrol/bin/ files/hostapi/bin/ +files/agent/bin/ diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl index f18ee07..eb35a47 100644 --- a/diskless.pkr.hcl +++ b/diskless.pkr.hcl @@ -130,6 +130,12 @@ variable "kiosk_url" { default = "http://boot.fleet.lab/kiosk/" } +variable "enable_agent" { + type = bool + description = "Build-time feature flag for the on-host fleet-agent (telemetry client that dials the fleet-manager over TPM-mTLS and streams health; docs/fleet-management-design.md). true (default) installs /usr/local/bin/fleet-agent + fleet-agent.service; false skips it (the auth stack still boots — the agent is a management add-on). Config is fleet-generic in files/agent/agent.env; set the manager DNS name there per environment." + default = true +} + variable "boot_splash" { type = bool description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)" @@ -167,6 +173,12 @@ variable "sb_cert" { default = "" } +variable "fleet_ca" { + type = string + description = "Path on the BUILD HOST to the fleet CA PEM baked to /etc/dot1x/radius-ca.pem — the trust anchor wpa_supplicant uses for RADIUS EAP-TLS AND the fleet-agent uses to verify the fleet-manager. Defaults to the COMMITTED PLACEHOLDER (files/etc/dot1x/radius-ca.pem), which is NOT a usable CA (auth/telemetry can't succeed with it). Override per-environment (CI secret -> temp file) with your real step-ca root (or a root+intermediate bundle for robustness). Never commit real internal CA material (CLAUDE.md)." + default = "files/etc/dot1x/radius-ca.pem" +} + # --------------------------------------------------------------------- source source "qemu" "diskless" { @@ -231,6 +243,15 @@ build { destination = "/tmp/etc-stage" } + # Fleet CA baked to /etc/dot1x/radius-ca.pem (RADIUS trust anchor + fleet-agent + # server-verify CA). var.fleet_ca defaults to the committed placeholder; CI + # overrides it with the real per-environment step-ca root (a build-host path). + # Installed over the placeholder in the etc-install step below. + provisioner "file" { + source = var.fleet_ca + destination = "/tmp/fleet-ca.pem" + } + # Kiosk unit + session wrapper (installed by the kiosk provisioner below). # Dir-without-trailing-slash upload -> /tmp/kiosk/ (mirrors the dracut module # upload form, which stages reliably). @@ -259,6 +280,20 @@ build { destination = "/tmp/fleet-hostapi" } + # On-host fleet-agent: the systemd unit AND the baked fleet-generic config + # (agent.env) are staged from here to /tmp/agent (installed in the 2b-agent step, + # which runs AFTER /tmp/etc-stage is cleaned — so config must ride this upload, + # not files/etc). The static binary (built OUT OF THE VM like enrol/hostapi) is + # staged separately below. + provisioner "file" { + source = "files/agent" + destination = "/tmp/" + } + provisioner "file" { + source = "files/agent/bin/fleet-agent" + destination = "/tmp/fleet-agent" + } + # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to # /tmp/plymouth, installed + set default in the package step before dracut runs. provisioner "file" { @@ -348,7 +383,10 @@ build { "rm -rf /tmp/plymouth", # Stage etc files into place - "install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem", + # radius-ca.pem comes from var.fleet_ca (placeholder by default; real fleet CA + # per-environment via CI) — NOT the etc-stage copy, so one build input controls + # the trust anchor for both RADIUS EAP-TLS and the fleet-agent. + "install -D -m0644 /tmp/fleet-ca.pem /etc/dot1x/radius-ca.pem", "install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf", "install -D -m0644 /tmp/etc-stage/dracut.conf.d/90-diskless.conf /etc/dracut.conf.d/90-diskless.conf", # NB: the old static /etc/NetworkManager/conf.d/99-dot1x-unmanaged.conf is @@ -367,6 +405,17 @@ build { "restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp", "sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf", "grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf", + # Enable chrony's tracking log so the fleet-agent can report clock health + # (source/stratum/offset) WITHOUT touching chronyd's command interface. That + # interface is unusable by the hardened agent: the unix socket is + # owner-write-only (0755) and the agent is DynamicUser + ProtectSystem=strict + # (/run read-only), and STIG closes the UDP command port (cmdport 0). Instead + # chronyd appends source/stratum/offset to /var/log/chrony/tracking.log, which + # the agent simply READS (world-readable; no port, no socket, no SELinux domain + # transition). Write volume is tiny (~one ~130B line per clock update, backing + # off to ~maxpoll when stable) and capped by the chrony RPM's logrotate; on the + # RAM overlay this is negligible. Keeps cmdport 0 intact — no STIG deviation. + "grep -qE '^log ([^#]*[[:space:]])?tracking' /etc/chrony.conf || echo 'log tracking' >> /etc/chrony.conf", # Inject the fleet NIC drivers into the dracut config "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", @@ -619,6 +668,32 @@ build { ] } + # --- 2b-agent. On-host fleet-agent (Go, TPM-mTLS telemetry client) -------- + # Installs the OUT-OF-VM-built /usr/local/bin/fleet-agent (the "Build host Go + # binaries" CI step; staged to /tmp/fleet-agent) + its systemd unit + baked, + # fleet-generic config, and enables it. The agent dials the fleet-manager over + # mTLS using the host's reused TPM identity (signing key at 0x81000100 + cert in + # NV 0x01800100) and streams health; NOTHING host-specific is baked (invariant + # #1) — the manager DNS name in agent.env is fleet-generic. Gated on + # enable_agent so a lean/auth-only build can skip it. The binary is + # fapolicyd-trusted in 2c-STIG-postfix (non-RPM exec, like hostapi/kiosk-session). + provisioner "shell" { + environment_vars = [ + "ENABLE_AGENT=${var.enable_agent}", + ] + inline = [ + "set -euxo pipefail", + "if [ \"$ENABLE_AGENT\" != \"true\" ]; then echo 'enable_agent=false -> skipping fleet-agent install.'; exit 0; fi", + "install -D -m0755 /tmp/fleet-agent /usr/local/bin/fleet-agent", + "install -D -m0644 /tmp/agent/fleet-agent.service /etc/systemd/system/fleet-agent.service", + "install -D -m0644 /tmp/agent/agent.env /etc/fleet-agent/agent.env", + "restorecon -R /usr/local/bin/fleet-agent /etc/systemd/system/fleet-agent.service /etc/fleet-agent 2>/dev/null || true", + "systemctl enable fleet-agent.service", + "rm -rf /tmp/agent /tmp/fleet-agent", + "echo '==> fleet-agent enabled (TPM-mTLS telemetry to the fleet-manager)'", + ] + } + # --- 2c. Admin access: sudo-enabled key user + locked root -------------- # Fleet-generic (same user + key on every host => NOT per-host state). The # key comes from var.ssh_authorized_key (CI secret). If no key is supplied we @@ -835,6 +910,20 @@ build { "semodule -i /tmp/fapolicyd_tmpfs.pp", "rm -f /tmp/fapolicyd_tmpfs.mod /tmp/fapolicyd_tmpfs.pp", "semodule -l | grep -q '^fapolicyd_tmpfs' && echo '==> SELinux module fapolicyd_tmpfs installed' || { echo 'ERROR: fapolicyd_tmpfs module not installed' >&2; exit 1; }", + # SELinux: let the fleet-agent READ chrony's tracking log. The agent + # (unconfined_service_t) reports clock health by reading + # /var/log/chrony/tracking.log (type chronyd_var_log_t), which the shipped + # policy confines to chronyd_t despite the 0644 mode -> denied under enforcing + # (seen on comet-beacon-54). A minimal read-only allow (no write, no domain + # transition). Installed unconditionally like the modules above (inert without + # the agent). checkpolicy ensured above. Same policy-store-rebuild + # reproducibility caveat as chrony/fapolicyd. docs/stig-deviations.md #16. + "echo '==> supplemental: SELinux module fleet_agent_chronylog (fleet-agent reads chrony tracking.log)'", + "checkmodule -M -m -o /tmp/fleet_agent_chronylog.mod /tmp/stig/fleet-agent-chronylog.te", + "semodule_package -o /tmp/fleet_agent_chronylog.pp -m /tmp/fleet_agent_chronylog.mod", + "semodule -i /tmp/fleet_agent_chronylog.pp", + "rm -f /tmp/fleet_agent_chronylog.mod /tmp/fleet_agent_chronylog.pp", + "semodule -l | grep -q '^fleet_agent_chronylog' && echo '==> SELinux module fleet_agent_chronylog installed' || { echo 'ERROR: fleet_agent_chronylog module not installed' >&2; exit 1; }", "rm -rf /tmp/stig", "echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'", ] @@ -912,9 +1001,9 @@ build { # provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on # the bench; see docs/TODO.md. " if [ -f /etc/fapolicyd/fapolicyd.conf ]; then sed -ri 's/^[[:space:]]*#?[[:space:]]*db_max_size[[:space:]]*=.*/db_max_size = 50/' /etc/fapolicyd/fapolicyd.conf; grep -q '^db_max_size' /etc/fapolicyd/fapolicyd.conf || printf 'db_max_size = 50\\n' >> /etc/fapolicyd/fapolicyd.conf; echo '==> fapolicyd: db_max_size set to 50 MiB (was EL9 default ~100)'; fi", - " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", + " for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done", " fapolicyd-cli --update 2>/dev/null || true", - " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'", + " echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'", # watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the # shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and # therefore intercepts NO exec on / — the exec guard for the writable overlay diff --git a/docs/TODO.md b/docs/TODO.md index 677109b..aab7d0e 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1200,7 +1200,7 @@ write + **reboot converges** (2nd boot runs the secure path); (f) prod: broker o **TEMPORARY (2026-06-28): broker also added to the MAIN serving stack** (`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in -`serving/.env.example`) so a single `compose up` in `serving/` brings the +`serving/defaults.env`) so a single `compose up` in `serving/` brings the `/enrol/` endpoint up while the host side is built. Defaults to the zero-dependency lab path — **`mock` signer** (ephemeral CA; certs NOT trusted by RADIUS/NVMe) + **plaintext loopback admin** (`ENROL_ADMIN_INSECURE=true`). The @@ -1382,7 +1382,124 @@ 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` + admin-UI cert `acme` side-car (LE + DNS-01 via Route 53, auto-renew) + CI image build (`fleet-manager-image.yaml`, + pulled by tag) + `backup.sh` (durable volumes) + `DEPLOY.md`. +- **Hardening still open (operator/per-env):** network-segment the admin (`:9500`) + + agent (`:9600`) listeners to the mgmt net; run `server-pki.sh` when the agent + lands; decide `:9500` vs a 443 proxy; digest-pin the images (invariant #12). See + DEPLOY.md "Hardening status". +- **[~] (a) the on-host agent — TELEMETRY HALF BUILT 2026-07-05 (`files/agent/`).** + The gRPC client that pairs with `fleet-hostapi` and feeds this plane. It reuses + the host **TPM identity** for mTLS (signing key at `0x81000100` via + `tpmkey.LoadSigner` — a read-only copy of the enrol `tpmkey`; cert from NV + `0x01800100`), opens the long-lived `Session` stream, sends `Hello` + periodic + `HealthSnapshot` (load/cpu/temp/mem/tmpfs/**uptime**/nvme/auth/selinux/kiosk from + `/proc`+`/sys` + hostapi facts, plus **chrony** clock health — state/source/ + stratum/offset — read from `/var/log/chrony/tracking.log`), and — **telemetry-only v1** — + **receives `DesiredState`/`Command` but only LOGS them** (zero host mutation). + Static `CGO_ENABLED=0` binary built out-of-VM in CI (like enrol/hostapi), staged + by the `2b-agent` provisioner (`enable_agent`, default on), fapolicyd-trusted, + fleet-generic config in `files/agent/agent.env`. mTLS handshake proven + end-to-end against the go-tpm software simulator (`files/agent/simtest`). No + dracut/initramfs changes (post-switch-root only). **STILL TO BUILD (deferred):** + the **reconcile actuators** — apply kiosk_url, reboot_at/REBOOT, drain — wired to + be received but not acted on; land after the telemetry half is bench-verified. + **BENCH-VERIFIED END-TO-END ON REAL HARDWARE 2026-07-10** (Dell 820 + `comet-beacon-54`, real TPM, image `fleet-manager-254ec79`): the agent loads its + reused TPM identity (serial derived on-device via go-tpm), completes mTLS to the + manager, streams health, AND receives desired-state (logged, not applied). Manager + logs `agent connected: fed27077… (comet-beacon-54)`; the host stays connected (no + reconnect loop). This closes the TPM-identity path (802.1X = NVMe hostnqn = agent + mTLS, "one identity, three stages") on hardware. + + **Deploying the agent plane — gotchas hit + FIXED in commit `a89c3d2` (don't + rediscover):** the serving-side bring-up cost a long debug chain, each now + automated: + 1. **Host CA was the placeholder.** `/etc/dot1x/radius-ca.pem` shipped a + non-CA placeholder → `client TLS: no certificates parsed from fleet CA`. + FIX: `fleet_ca` build var bakes the real step-ca root (CI secret → file → + `-var fleet_ca`; default = placeholder, lab-only). The agent's `FLEET_AGENT_CA` + points here; only the ROOT is needed host-side (the manager sends its chain). + 2. **`tls: certificate required`.** step-ca signs leaf certs with an + INTERMEDIATE and enrolment stores the leaf-only cert in NV, so a root-only + client CA makes the manager advertise only the root in its CertificateRequest → + the Go client sends NO cert. FIX: `server-pki.sh` installs the agent CLIENT CA + (`FLEET_MGR_AGENT_CLIENT_CA`) as **root+intermediate**; `bootstrap.sh` exports + `intermediate_ca.crt`. + 3. **Distroless perms.** The nonroot manager (uid 65532) can't read `pki/agent/*` + if created `0600` by the host user → `open …/server.crt: permission denied`. + `chown 65532` the agent PKI (as with the fleet-data volume). + 4. **Manager wasn't a fleet name.** The agent's baked default pointed at the + PUBLIC admin-UI name (`diskless-server.bitasylum.org`), which resolves to the + serving host's OUTBOUND IP → `no route to host` from the fleet LAN. FIX: the + agent plane is now just another `*.fleet.lab` — `manager.fleet.lab` (`MGMT_HOST` + in serving/.env), served by the fleet dnsmasq to `SERVER_IP` exactly like + boot/nvme/audit (`--address=/${MGMT_HOST}/${SERVER_IP}`), baked as + `FLEET_AGENT_MANAGER` and set as the agent server cert SAN (`AGENT_SANS`). No + per-host override, no server-name hack. (The public `diskless-server…` name + stays the ADMIN-UI name on Traefik/:443 — a separate plane.) The transient + bench workaround was `FLEET_AGENT_MANAGER= + FLEET_AGENT_SERVER_NAME=`; + no longer needed. + 5. **Host not in the manager's inventory** → `PermissionDenied` (surfaced as a + bare `EOF` on the agent before the fix). A host enrolled OUT OF BAND (bench + broker / migrated fleet) has no inventory row. FIX: `fleet-manager + register-host -name ` (writes through the real store to + `FLEET_MGR_INVENTORY`). The manager caches inventory in memory — any edit needs + a `--force-recreate`. Also: the agent now logs the real gRPC status, not `EOF`. + + **Metrics + cert auto-renewal added 2026-07-10 (`fleet-manager` branch):** + - **uptime + reboot-pending**: the agent sends `uptime_seconds`; the manager/UI + derive "reboot pending in X / rebooted" from boot_time (= last_seen − uptime) vs + `reboot_at`, all in the manager clock frame (no host↔manager clock sync needed). + Monitor metric graphs auto-refresh (10 s poll). + - **chrony clock health** (sync state + NTP source/stratum/offset). The agent reads + `/var/log/chrony/tracking.log` (image bakes `log tracking`), NOT chronyd's command + socket (owner-write-only unix socket; STIG `cmdport 0` closes the UDP port). + **Reaching that log from the hardened DynamicUser agent took a 4-layer chase, every + layer silent-failing** (memory `fleet-agent-hardened-probe-access`): (i) lab NTP + reachable — a `123/udp` rule on the serving-host firewall (host-networked ntp + container; same trap as audit `514`); (ii) DAC — `chrony` supplementary group on the + unit to traverse `/var/log/chrony` (0750); (iii) SELinux TE — + `files/stig/fleet-agent-chronylog.te` allow for `chronyd_var_log_t` (permissive + masks this); (iv) the MANAGER image must be ≥ the chrony commit — one with + uptime-but-not-chrony silently drops the fields (manager & workstation are SEPARATE + images/deploys). Isolator: read the manager `inventory.json` off the `fleet-data` + volume via a throwaway busybox (distroless manager, no shell). BENCH-VERIFIED on + `comet-beacon-54` 2026-07-10. + - **agent-plane server-cert auto-renewal**: the `agent-cert-renewer` compose sidecar + (profile `cert-auto`) issues + `step ca renew --daemon` renews the cert from a + co-located step-ca; the manager hot-reloads via `GetCertificate` (`certreload.go`), + no restart. Replaces the manual `server-pki.sh` + `docker exec … step` dance and + the 90-day re-mint. The server cert SANs MUST include `manager.fleet.lab` (the + fleet-DNS name the agent dials) — a missing SAN fails the handshake. +- **STILL TO BUILD:** (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's deferred actuator half (above). 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 @@ -1399,6 +1516,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` @@ -1620,6 +1739,28 @@ now **kernel-MODULE-bound, not firmware** (~10 MB fw): the `--no-hostonly` + ply - **BENCH-VERIFY:** kiosk still renders on the 820 (i915 kept); check the new "initramfs largest files" log line for the post-omit size + next targets. +**[x] FIXED — the `nss-mdns` drop bricked the kiosk (regression found+fixed 2026-07-04):** +the "render should be safe" call above (5cc72cc) was WRONG — it broke the kiosk, just +not via i915. `trim-rpm-48dc3e1` booted to a **flashing cursor, no kiosk**, and the image +was a suspicious **500 MB**. Root cause: the 2d `dnf remove -y nss-mdns` removed +**chromium** as a *dependent package* — the EPEL `chromium` **hard-Requires `nss-mdns`** +(Chromium uses `.local` mDNS hostnames for WebRTC ICE candidates) — then reaped chromium's +orphaned GUI stack (avahi, at-spi2, atk, adwaita, chromium-common; ~49 pkgs, 523→472). +With no browser, `kiosk-session` died at the `${BROWSER:?}` guard in 4 ms, before weston. +The original commit checked what `nss-mdns` NEEDS (avahi) but not what NEEDS it (chromium). +GPU/i915/firmware/plymouth were all healthy — a red herring. Fix (`2bc2daf`): +- **Keep `nss-mdns` + avahi** (satisfy chromium's Requires); **mask the avahi daemon + + socket** instead — nss-mdns is only an NSS module, needs no listener, and :5353 was the + real attack-surface concern. ~1.5 MB kept. +- **FINAL GUARD in 2d**: `rpm -q chromium chromium-common weston mesa-{dri-drivers,libEGL,libgbm}` + asserts the kiosk stack survived the whole pipeline, else **fail the build**. A missing + browser must brick the BUILD, never the fleet — catches any future remove-cascade / + oscap `package_*_removed` / autoremove. Look for `==> guard: kiosk stack intact` in CI. +**RE-VERIFIED 2026-07-04 (`trim-rpm-2bc2daf`): boots into the kiosk on the 820; image 755 MiB.** + +**Measured end-to-end (2026-07-04, `trim-rpm-2bc2daf`): container artifact image +1.8 GiB → 755 MiB (~58%, >1 GB off every host's per-boot NVMe/TCP read).** + **Combined:** ~**800 MB** off the uncompressed erofs (langpacks + firmware), dwarfing the kiosk weak-deps trim (#4), plus a smaller UKI/initramfs from the DRM-driver omit. NEXT candidates from the manifest (measure, don't guess): `iso-codes` (19 MB), diff --git a/docs/fleet-management-design.md b/docs/fleet-management-design.md index 63cc389..28a4493 100644 --- a/docs/fleet-management-design.md +++ b/docs/fleet-management-design.md @@ -1,7 +1,63 @@ # 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. +- **On-host agent — telemetry half BUILT (2026-07-05, `files/agent/`).** The + gRPC client that feeds this plane. Reuses the host TPM identity for mTLS (read-only + `tpmkey.LoadSigner` at `0x81000100` + cert from NV `0x01800100`; private key never + leaves the TPM), opens `Session`, sends `Hello` + periodic `HealthSnapshot` + (read-only `/proc`+`/sys` probes + `fleet-hostapi` facts). **Telemetry-only:** it + receives `DesiredState`/`Command` but only **logs** them. Static out-of-VM CI + binary, `2b-agent` provisioner (`enable_agent`), fapolicyd-trusted; mTLS proven + against the go-tpm simulator (`files/agent/simtest`). +- **NOT yet built (recorded intent):** (1) the agent's **reconcile actuators** — + apply kiosk_url, reboot_at/REBOOT, drain — deferred until the telemetry half is + bench-verified; and (2) 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). The + version pin is labelled "recorded intent" in the Lifecycle UI. + +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; @@ -69,8 +125,9 @@ Reported facts: - **Health:** CPU/load, GPU act-freq, temps, **tmpfs overlay usage** (the RAM-exhaustion → NVMe-keepalive risk — the single most important kiosk health - signal), NVMe-root link state, 802.1X/MACsec `Authorized`, SELinux enforcing, - chrony sync, `kiosk.service` active. + signal), host **uptime** (server derives reboot-pending vs `reboot_at`), + NVMe-root link state, 802.1X/MACsec `Authorized`, SELinux enforcing, chrony + clock health (sync state + NTP source/stratum/offset), `kiosk.service` active. - **Version:** running image version (`/etc/fleet-image`), rootfs digest, kernel. - **Usage:** kiosk URL, session uptime, **Chromium reload/crash count** (from the `kiosk-session` restart loop — the exact signal behind the GPU-bug diagnosis), @@ -130,8 +187,9 @@ UKI). Host (by cert-serial / hostnqn) ├─ identity: cert serial, hostnqn, MAC, starship-name (dnsmasq) ├─ version: image_version, rootfs digest, kernel, desired_version - ├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, nvme_root, auth/macsec, - │ selinux, chrony, kiosk_active, last_seen + ├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, uptime, nvme_root, + │ auth/macsec, selinux, chrony (sync + source/stratum/offset), + │ kiosk_active, last_seen ├─ usage: kiosk_url, session_uptime, chromium_crashes, input_active, display └─ desired: target_version, kiosk_url, reboot_at, drain (server-authoritative) ``` diff --git a/docs/ssp-annex-a-ism-residual-risk.md b/docs/ssp-annex-a-ism-residual-risk.md index 4d4c725..635f1af 100644 --- a/docs/ssp-annex-a-ism-residual-risk.md +++ b/docs/ssp-annex-a-ism-residual-risk.md @@ -9,15 +9,23 @@ > **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target > **May 2026 release**. > -> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening -> applied + bench-verified, USBGuard device control enabled, admin `staff_u` -> confinement, audit offload verified, PXE/iPXE boot path added, and **UEFI Secure -> Boot signing verified enforcing on real hardware** (VM HTTP + 820 PXE) — R-13 -> reduced from Medium to Low (residual is making signed + SB-on the standing -> default); and (2026-06-27) a **Wazuh SIEM** (dedicated multi-node, agentless) -> now decodes + alerts on the fleet auditd stream — "event log analysis/alerting" -> moves Planned → Implemented, R-07 reduced toward Low (residual = TLS transport + -> retention). +> **Annex version:** 0.3 (updated 2026-07-10). v0.3 adds the **fleet-management +> plane**: the in-image **`fleet-agent`** (health telemetry over TPM-mTLS — +> bench-verified on hardware 2026-07-10; new host interface (g)), the server-side +> manager with **WebAuthn-passkey admin MFA** (first human-admin MFA — updates the +> §2.4 MFA row and R-05), the **TPM enrolment broker** + **step-ca fleet CA**, and +> InfluxDB health telemetry. It also records the **three local SELinux policy +> modules** (§2.3) and the **fapolicyd overlay exec-guard fix (2026-07-04)** that +> made application control actually effective on the root. New residual risks +> **R-14** (management plane = connected system, not yet separately authorised) and +> **R-15** (deferred agent actuators = future remote-mutation channel). The +> server-side plane is a **connected system** carrying its own authorisation. +> +> v0.2 (2026-06-22/27) reflected: STIG hardening applied + bench-verified, USBGuard +> device control, admin `staff_u` confinement, audit offload verified, PXE/iPXE +> boot path, **UEFI Secure Boot signing verified enforcing on real hardware** (R-13 +> Medium→Low), and a **Wazuh SIEM** (agentless) decoding + alerting on the auditd +> stream (R-07 reduced toward Low). --- @@ -85,13 +93,13 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| | Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` + documented tailoring + per-build deviations report ([stig-deviations.md](stig-deviations.md)); **applied + bench-verified 2026-06-20**, all REVIEW rows triaged. | Partial | R-03 (CI must ship `enable_stig=true` + signed/SB-on) | -| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + two trusted local binaries: kiosk-session, fleet-sb-enroll) + immutable dm-verity root. | Implemented | — | +| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + trusted local binaries: kiosk-session, fleet-sb-enroll, fleet-agent) + immutable dm-verity root. **Effectiveness fix 2026-07-04:** the diskless root is `overlayfs`, which was missing from fapolicyd's `watch_fs` → the guard was **inactive on the root**; adding `overlay` turned allow-listing on for the whole root (verified: novel `/tmp` binary denied). | Implemented | R (residual): path-trust doesn't catch a *modified trusted path* via copy-up — TODO #15 (`integrity=sha256` option); see [stig-deviations #15](stig-deviations.md) | | Device / removable-media control | ISM-0341*, ISM-1551* | Control USB / peripheral devices. | **USBGuard enforcing** (`ImplicitPolicyTarget=block` + hardware-independent HID/hub allowlist + LinuxAudit). Verified: USB mass storage blocked, console input preserved. | Implemented | — | | Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — | | Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — | | Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — | | Memory/exploit mitigations | ISM-1341*, ISM-1342* | Enable platform exploit mitigations. | UKI cmdline: `slub_debug=P`, `page_poison=1`, `init_on_alloc/free=1`, `vsyscall=none`. | Implemented | — | -| Mandatory access control | ISM-1416* | MAC enforced. | **SELinux enforcing**; labels preserved into erofs (asserted at build). | Implemented | — | +| Mandatory access control | ISM-1416* | MAC enforced. | **SELinux enforcing**; labels preserved into erofs (asserted at build). Three **local allow modules** for architecture-specific paths (`chrony_dhcp_dispatcher`, `fapolicyd_tmpfs`, `fleet_agent_chronylog`), each minimal + read/monitoring-only and documented ([stig-deviations #14–16](stig-deviations.md)); **no permissive domains**. | Implemented | — | | Host-based intrusion prevention / file integrity (AIDE) | ISM-1288* | File integrity monitoring. | **N/A** — superseded by dm-verity (read-only, signed, continuous). | N/A (justified) | — | | Antivirus / endpoint scanning | ISM-1417* | Malware protection on endpoints. | **N/A/compensated** — immutable verified root + allow-listing means no persistent writable executable surface; overlay is RAM-only, discarded each boot. | N/A (justified) | — | @@ -99,11 +107,11 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| -| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81000100`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) | +| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81000100`), cert in TPM NV. The **management-plane agent reuses the same TPM identity** for mTLS (`RequireAndVerifyClientCert` to the fleet CA; server keys inventory off the verified cert serial). One non-exportable key = 802.1X + NVMe hostnqn + agent stream. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA), R-14 (mgmt plane = connected system) | | Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled; admin **confined to `staff_u`** with a sudo `sysadm_r`/`sysadm_t` role/type transition (SELinux context elevates on privilege use). | Partial | R-05 | | Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — | | No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — | -| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane not yet enforced.** | Partial | R-05 | +| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management-plane UI now enforced via WebAuthn passkeys** (phishing-resistant, origin-bound, hardware-backed — the manager's admin plane, v0.3). Host **SSH** admin remains key-only (no separate human MFA). | Partial (host SSH) / Implemented (mgmt UI) | R-05 | | Passwordless appliance accounts | — (STIG #10) | Account lockout/aging. | `kiosk`/`fleetadmin` are key-only; password aging/faillock **exempted for these two only**, full policy system-wide otherwise. | Implemented | R-05 (folds into rotatable-access roadmap) | ### 2.5 Guidelines for System Management @@ -115,6 +123,8 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Protect build/signing key material | ISM-0501*, ISM-1233* | Protect keys. | Signing keys CI/HSM-held; **never** in repo or build VM; never logged. | Implemented | — | | Privileged actions logged | ISM-1509* | Admin actions auditable. | auditd rules capture privileged/admin events. | Implemented | R-07 (offload protection) | | Disable debug/back-door access | ISM-1491* | No undocumented access paths. | `debug_shell=true` currently bakes a **passwordless root tty** (lab debugging). | Partial | **R-02 (must remove before prod)** | +| Device enrolment & identity lifecycle | ISM-1195*, ISM-0475* | Controlled device onboarding. | **TPM enrolment broker** (v0.3): the host generates a key in-TPM, submits a CSR to the one *unauthenticated pre-auth* endpoint; an admin verifies an **OOB fingerprint** and **approves over mTLS**, then **step-ca** signs the leaf into TPM NV and the manager births the inventory record (cert-serial = key). No auto-approval; identity is human-anchored. Server-side = connected system. | Partial (connected system) | R-14 | +| Server-authoritative management plane | ISM-1483*, ISM-1509* | Controlled remote management. | `fleet-agent` streams telemetry + receives desired-state over mTLS; **v1 logs commands but does not act** (no host mutation). Inventory/desired-state is server-authoritative. Reconcile actuators deferred. | Partial | R-14, **R-15 (actuators = future remote-mutation channel)** | ### 2.6 Guidelines for System Monitoring (event logging) @@ -125,13 +135,14 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Protect logs in transit | ISM-1432*, ISM-0407* | Encrypt/authenticate log transport. | **Plaintext** on both hops (host→collector, collector→Wazuh across a routed mgmt LAN); rsyslog TLS rules waived (see [STIG #6](stig-deviations.md)). | Waived (lab) | **R-07** | | Log retention & integrity | ISM-0859*, ISM-1815* | Retain & protect logs. | Per-source-host files on collector; Wazuh indexer available for retention/ISM but **archival/retention policy not yet configured**. | Planned | R-07 | | Event log analysis / alerting | ISM-0109*, ISM-1228* | Review logs; alert on incidents. | **Wazuh SIEM** with a custom **`fleet-auditd`** decoder + severity-tiered rules (`fleet_audit` group; auth, privilege/`USER_CMD`, SELinux). **Decode + alert bench-verified 2026-06-27** (`wazuh-logtest` + live `sudo` in the dashboard). Detection content to expand. | Implemented (content to mature) | R-07 | +| Operational health monitoring | ISM-1228*, ISM-1526* | Monitor system health/availability. | **fleet-agent → fleet-manager** health telemetry over mTLS (v0.3): resource pressure, NVMe-root/auth/**SELinux state**/chrony/kiosk, uptime → InfluxDB time-series + admin dashboard flags (version-drift, **SELinux-permissive**, reboot-pending). Complements the auditd/Wazuh security-event path (operational, not security-event, data). Store/dashboard = connected system; host side = read-only probe + mTLS stream. | Partial (connected system) | R-14 | ### 2.7 Guidelines for Networking | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| | Network access control (802.1X) | ISM-0520*, ISM-1182* | Authenticate devices to the network. | **802.1X** port-based control; unauthenticated port = no access. | Partial | R-01 | -| Network segmentation | ISM-1181*, ISM-1532* | Segregate & control traffic. | VLAN assignment post-auth; isolated boot LAN; serving stack separated. | Partial | R-09 | +| Network segmentation | ISM-1181*, ISM-1532* | Segregate & control traffic. | VLAN assignment post-auth; isolated boot LAN; serving stack separated. **Management plane (v0.3):** the agent reaches the manager on `:9600` **only via an OPNsense L3 rule** (authenticated host VLAN → `:9600`; never the boot LAN); the admin UI (`:443`, passkey) is on the management network only; enrolment/boot/NVMe/NTP stay on the boot LAN. Agent is **outbound-only** (no inbound host port). | Partial | R-09, R-14 | | Host-based firewall | ISM-1416*, ISM-1416a* | Restrict host network exposure. | **firewalld disabled** (risk to NVMe/TCP & NM during bring-up). | Waived (lab) | **R-09** | | Disable unused network functionality | ISM-1296* | Minimise network services. | Minimal services; no extraneous listeners; kiosk has no inbound. | Implemented | — | @@ -146,8 +157,9 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| | ASD-Approved Cryptographic Algorithms (AACA) | ISM-0471*, ISM-1446* | Use approved algorithms. | STIG crypto policy (modern ciphers/MACs/KEX); EAP-TLS, MACsec-GCM. **Conformance to ISM AACA not formally asserted; FIPS off.** | Partial | **R-06** | -| ASD-Approved Cryptographic Protocols (AACP) | ISM-1139*, ISM-1453* | Use approved protocols (TLS 1.2+/1.3). | EAP-TLS, NVMe/TCP & log transport currently plaintext on LAN. | Partial | R-04, R-06, R-07 | -| Key protection / non-exportability | ISM-0501*, ISM-1091* | Protect keys in hardware where feasible. | EAP-TLS private key **non-exportable in TPM**; UKI signing keys in HSM/CI. | Implemented | — | +| ASD-Approved Cryptographic Protocols (AACP) | ISM-1139*, ISM-1453* | Use approved protocols (TLS 1.2+/1.3). | EAP-TLS; **management-plane agent stream is mTLS 1.2+** (fleet CA) — encrypted, unlike NVMe/TCP & log transport which remain plaintext on the LAN. | Partial | R-04, R-06, R-07 | +| Key protection / non-exportability | ISM-0501*, ISM-1091* | Protect keys in hardware where feasible. | EAP-TLS **and management-plane** private key **non-exportable in TPM** (one key, three uses); UKI signing keys in HSM/CI. The **fleet CA (step-ca)** that signs host/agent certs is a **connected-system** trust anchor — its key protection is assessed there. | Implemented (host) / R-14 (CA) | R-14 | +| Certificate lifecycle | ISM-1091*, ISM-0501* | Managed cert issuance/renewal. | Host identity cert minted at enrolment (step-ca, into TPM NV). The manager's **agent-plane server cert auto-renews** from the co-located CA (`step ca renew --daemon` + in-process hot-reload) — preserves SANs, no 90-day lapse. Server-side = connected system. | Partial (connected system) | R-14 | | Cryptographic equipment / module evaluation | ISM-0467*, ISM-1277* | Use evaluated crypto modules. | TPM 2.0 hardware; module-evaluation status to confirm; FIPS module not enabled. | Partial | R-06 | ### 2.10 Guidelines for ICT Equipment & Media @@ -178,7 +190,7 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M). | **R-02** | `debug_shell=true` bakes a **passwordless root shell** on a tty — an undocumented privileged access path. | 2.5 (ISM-1491*) | Possible × Severe → **High** | Lab-only; physical access required; isolated environment. | Set `debug_shell=false` for all non-lab builds; assert absent in CI. | **Low** | Platform team — *before production* | | **R-03** | STIG hardening **applied + bench-verified** (2026-06-20, all REVIEW rows triaged); residual is ensuring **CI ships `enable_stig=true`** for production builds (it was toggled off during bring-up). | 2.3 (ISM-1407*/1408*) | Possible × Moderate → **Medium** | Tailoring + register + per-build report; SELinux/fapolicyd/USBGuard on; verified on a real hardened build. | Confirm `ENABLE_STIG="true"` in CI; keep the 0-eval guard; re-run the deviations report on the assessed build. | **Low** | Platform team — *before assessment* | | **R-04** | NVMe/TCP **root traffic plaintext** on the LAN — exposure of OS content / injection if the segment is compromised. | 2.9 (ISM-1139*/1453*) | Unlikely × Major → **Medium** | Isolated boot LAN; MACsec on the access link (once R-01 done) protects the first hop; dm-verity makes injected root blocks fail verification (integrity preserved even if confidentiality isn't). | Production: TLS/IPsec for the storage transport, or rely on MACsec end-to-end across the fabric; document the decision. | **Low–Medium** | Platform/network — *production* | -| **R-05** | **NOPASSWD key-only admin**; no per-host or rotatable access model; human admin MFA on the mgmt plane not enforced. | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, **now SELinux-confined to `staff_u`** with sudo role/type elevation, fully audited; no interactive password to attack. | Per-host/rotatable access (SSH CA / config-agent) + admin MFA on the management plane. | **Low–Medium** | Platform team — *roadmap* | +| **R-05** | **NOPASSWD key-only host admin**; no per-host or rotatable access model. (Management-plane admin MFA is now **enforced** via WebAuthn passkeys — v0.3; residual is the *host SSH* access model.) | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, **SELinux-confined to `staff_u`** with sudo role/type elevation, fully audited; no interactive password to attack. Mgmt-plane admin actions now behind a phishing-resistant passkey. | Per-host/rotatable **host** access (SSH CA / config-agent); the mgmt-plane MFA clause is now met. | **Low–Medium** | Platform team — *roadmap* | | **R-06** | **FIPS off**; ISM **AACA/AACP conformance not formally asserted**; crypto-module evaluation status unconfirmed. | 2.9 (ISM-0471*/1139*/1277*) | Possible × Major → **Medium** | Modern STIG crypto policy; non-exportable TPM key; no deliberately weak algorithms. Acceptable at OFFICIAL; **mandatory to resolve for PROTECTED+.** | Assert AACA/AACP conformance; decide on FIPS module / evaluated crypto alongside Secure Boot signing. | **Low** (at appropriate classification) | Security + Platform — *before PROTECTED+* | | **R-07** | Audit-log **offload plaintext** on both hops (host→collector, collector→Wazuh) and **log retention/archival not yet configured** — logs interceptable in transit and retention/tamper-protection undefined. (**SIEM analysis + alerting now in place** — Wazuh, decode+alert bench-verified 2026-06-27; transport bench-verified 2026-06-20.) | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Low–Medium** | Isolated/mgmt LAN; disk-assisted queue (no loss on collector/SIEM blip); auditd local copy; per-source files; **Wazuh now actively decodes + alerts** (`fleet_audit`). | rsyslog **TLS** (gtls/6514) on both hops + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); configure Wazuh **retention/archival** (ISM) + rotate default creds + restrict dashboard/API. | **Low** | Platform/SecOps — *production* | | **R-13** | **Boot-chain signing not yet the standing default** — signing + Secure Boot enforcement are **verified end-to-end on real hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22, SB enforcing, `db` enrolled), but unsigned/SB-OFF lab builds remain possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not every host has `db` enrolled, so a substituted artifact could still boot on an un-enrolled/unsigned host. | 2.10 (ISM-1542*/0312*) | Unlikely × Major → **Low** | Capability **proven on hardware**; dm-verity rejects a tampered *root* regardless; isolated lab LAN. | Make signed + Secure-Boot-on the **default for all production builds/hosts**: keep `SB_KEY`/`SB_CERT` CI secrets set; enrol `db` fleet-wide; treat an unsigned build as lab-only. | **Low** | Platform team — *before production* | @@ -187,19 +199,21 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M). | **R-10** | **Patch SLA undefined** — no documented cadence for rebuilding/redeploying against new CVEs. | 2.5 (ISM-1144*/1472*) | Likely × Moderate → **Medium** | Atomic rebuild/redeploy model makes patching fast & uniform; reproducible builds. | Define patch cadence & critical-CVE SLA; automate rebuild triggers. | **Low** | Platform team — *roadmap* | | **R-11** | **No remote attestation** of TPM boot measurements — a compromised boot can't be detected centrally. | 2.10 (ISM-1542*) | Unlikely × Major → **Low–Medium** | Secure Boot + signed UKI + dm-verity already prevent unsigned/modified boot; measurements exist locally. | Implement remote attestation (TPM quote → verifier) gating network/storage access. | **Low** | Platform team — *roadmap* | | **R-12** | **No formal Incident Response Plan**; volatile RAM overlay complicates live forensics. | 2.1, 2.6 (ISM-0043*/0109*) | Possible × Moderate → **Medium** | Inherent recovery (reboot → verified state; revert digest); centralised logs survive host wipe. | Author IRP + log-retention + break-glass/forensics procedure suited to stateless hosts. | **Low** | SecOps — *before production* | +| **R-14** | **Management/enrolment plane is a connected system, built but not yet separately authorised.** The host now trusts + streams to the fleet-manager and enrols via the broker + **step-ca fleet CA** (a new trust anchor + key material); the broker exposes **one unauthenticated pre-auth CSR endpoint**; InfluxDB holds telemetry + a bearer token. These server-side controls (CA key protection, broker hardening, admin-plane exposure, data-at-rest) are outside this SSP's boundary. | 2.4, 2.5, 2.6, 2.9 (ISM-1546*/1483*/0501*) | Possible × Moderate → **Medium** | Host-side exposure is limited: **outbound-only** mTLS, TPM-bound identity, **read-only** telemetry (no user data), and **commands logged-not-applied in v1**. Broker enrolment is human-anchored (OOB fingerprint + admin mTLS approve; no auto-approval). Mgmt-plane admin UI behind passkey MFA. | Author a **separate SSP/authorisation** for the management plane (CA key protection, broker endpoint hardening + rate-limiting, InfluxDB token/data protection, admin-plane network exposure); TLS everywhere is already in place on this plane. | **Low–Medium** | Platform/SecOps — *before the mgmt plane is production* | +| **R-15** | **Deferred agent reconcile actuators.** v1 receives `DesiredState`/`Command` but **only logs** them; when actuation (apply `kiosk_url` / `reboot` / drain) is built, `fleet-agent` becomes a **remote host-mutation channel**. | 2.5 (ISM-1483*/1509*) | Rare × Moderate → **Low** (v1) | v1 performs **no host mutation** — the receive path is inert; the command authZ path (server-authoritative desired-state, verified over mTLS) is designed but not exercised. | At build time, assess the command/authZ path (who can set desired-state, replay/authZ, blast radius of `reboot`/drain) before enabling actuators; keep it behind an explicit flag. | **Low** | Platform team — *at actuator build* | ### 3.1 Risk heatmap (post-treatment targets) ``` -Consequence → Minor Moderate Major Severe +Consequence → Minor Moderate Major Severe Likelihood ↓ - Certain - Likely R-10 R-01* - Possible R-05 R-07 R-03 R-02* - R-12 R-06 R-08 - Unlikely R-04 - R-11 R-09 - Rare + Certain + Likely R-10 R-01* + Possible R-05 R-07 R-03 R-02* + R-12 R-14 R-06 R-08 + Unlikely R-04 + R-11 R-09 + Rare R-15 ( * = currently High pre-treatment; all targeted to Low after treatment ) ``` @@ -208,10 +222,14 @@ Low–Medium. The AO is asked to authorise on the basis that **R-01, R-02, R-08, R-12 and R-13** (and confirming R-03's `enable_stig=true`) are **closed before production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 are either accepted with compensating controls (isolated-LAN lab) or carried on the POA&M with target -dates. Newly strengthened in v0.2: device control (USBGuard, now Implemented), -admin SELinux confinement (R-05), STIG applied+verified (R-03 reduced), and -**Secure Boot signing verified enforcing on real hardware (R-13 reduced -Medium→Low)**.* +dates. Newly recorded in v0.3: **R-14** (the management/enrolment plane is a +connected system requiring its own authorisation) and **R-15** (deferred agent +actuators — a future remote-mutation channel, inert in v1). Newly *strengthened* +in v0.3: **human-admin MFA on the management plane** via WebAuthn passkeys +(improves R-05's MFA clause), and **application control made effective on the +overlay root** (the 2026-07-04 fapolicyd `watch_fs` fix). Carried from v0.2: +USBGuard device control, admin SELinux confinement, STIG applied+verified, and +Secure Boot signing verified enforcing on real hardware (R-13 Medium→Low).* --- @@ -238,6 +256,18 @@ Medium→Low)**.* PXE/iPXE SB-OFF cmdline workaround is a no-op once SB is on. 8. **Witness device control** (USBGuard): a USB mass-storage device is blocked and logged, console HID still works. +9. **Scope the management plane (R-14).** Confirm whether the server-side + fleet-manager / enrol-broker / step-ca / InfluxDB are assessed under a **separate + authorisation**, and that the host↔plane boundary (outbound-only mTLS, TPM + identity, telemetry-only v1) is as described. Witness: an agent mTLS session is + rejected for a serial not in inventory; the broker will not sign without the OOB + fingerprint + admin-mTLS approval; the admin UI requires a passkey. +10. **Confirm application control is effective on the root** (post-2026-07-04 fix): + on an enforcing build, a **novel non-RPM binary in `/tmp` is denied** (fapolicyd + `FAN_DENY`) — proving the `overlay` `watch_fs` entry took (earlier builds silently + did not guard the overlay root). +11. **Confirm actuators are inert (R-15):** the assessed build's `fleet-agent` + **logs but does not apply** desired-state/commands (telemetry-only v1). --- diff --git a/docs/ssp.md b/docs/ssp.md index fb5e584..d8814ef 100644 --- a/docs/ssp.md +++ b/docs/ssp.md @@ -15,11 +15,11 @@ |---|---| | System name | Diskless Fleet Boot System ("the fleet") | | Document type | System Security Plan (SSP) | -| Version | 0.2 (DRAFT — pre-assessment) | -| Date | 2026-06-27 | +| Version | 0.3 (DRAFT — pre-assessment) | +| Date | 2026-07-10 | | Author / maintainer | System Owner (delegate) | -| Status | Lab / pre-production. STIG hardening applied + bench-verified; **UEFI Secure Boot signing verified enforcing on real hardware** (VM HTTP + EliteBook 820 PXE); secure path (802.1X/MACsec) not yet bench-verified. | -| Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) | +| Status | Lab / pre-production. STIG hardening applied + bench-verified; **UEFI Secure Boot signing verified enforcing on real hardware** (VM HTTP + EliteBook 820 PXE); secure path (802.1X/MACsec) not yet bench-verified. **v0.3 adds the fleet-management plane** (on-host `fleet-agent` telemetry over TPM-mTLS — bench-verified on hardware 2026-07-10; server-side manager with **WebAuthn-passkey** admin MFA + TPM enrolment broker) and July hardening changes (three local SELinux modules; fapolicyd overlay exec-guard fix). | +| Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [Fleet-management design](fleet-management-design.md); [TPM enrolment design](tpm-enrolment-design.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) | > **Currency caveat (read before relying on control numbers).** This SSP and its > annex are drafted against the ISM structure as understood to a January 2026 @@ -45,14 +45,20 @@ decision. **In scope:** the boot chain (UEFI HTTP boot → signed UKI → dracut initramfs → network authentication → encrypted root mount → verified read-only OS), the -runtime workstation OS image, the host's cryptographic identity (TPM), and the -hardening baseline applied to the image. +runtime workstation OS image, the host's cryptographic identity (TPM), the +hardening baseline applied to the image, and the **on-host `fleet-agent`** (the +in-image telemetry client that streams host health to the management plane over +TPM-mTLS — new in v0.3; `files/agent/`). -**Out of scope (separately authorised):** the lab serving stack (DHCP/HTTP/ -NVMe-oF target, audit collector — `serving/`), the 802.1X authenticator/RADIUS -infrastructure, the certificate enrolment authority, the network fabric, and -physical security of the facility. These are dependencies referenced here but -carry their own security documentation and authorisation. +**Out of scope (separately authorised — connected systems):** the lab serving +stack (DHCP/HTTP/NVMe-oF target, audit collector — `serving/`); the **server-side +fleet-management plane** (`serving/fleet-manager/` — the control-plane hub, its +admin web UI, the InfluxDB metrics store, and the Traefik ingress); the **TPM +enrolment broker** (`serving/enrol-broker/`) and the **step-ca fleet certificate +authority** it signs against; the 802.1X authenticator/RADIUS infrastructure; the +network fabric; and physical security of the facility. These are dependencies the +host connects to and are described here where the host interacts with them, but +each carries its own security documentation and authorisation. ## 2. System description @@ -104,18 +110,28 @@ manifest. One commit = one release = one digest = one manifest entry. 7. `switch-root` into the real system; NetworkManager renews the DHCP lease so the network-mounted root stays alive; the host derives its NVMe `hostnqn` from the TPM certificate serial and (kiosk builds) launches the locked-down kiosk. +8. **Post-boot management (v0.3):** the hardened **`fleet-agent`** service dials + the management plane and identifies with the **same TPM key/cert** used for + 802.1X and the NVMe `hostnqn` ("one identity, three stages"), streaming host + health over mTLS. It is telemetry-only in v1 — it receives desired-state / + commands but **only logs** them (no host mutation). It is a + **post-switch-root** component only (not in the initramfs). ### 2.3 Authorisation boundary The authorisation boundary is the **running workstation host**: the UKI, the initramfs and its custom 802.1X/MACsec dracut module, the verified erofs root, -the tmpfs overlay, the TPM-backed identity, and the hardened OS configuration. -The boundary's external interfaces are: (a) the **access link** (802.1X-controlled -port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c) -**outbound audit log** forwarding; (d) the **kiosk application** endpoint the -browser renders; (e) the **boot transport** (UEFI HTTP boot, or PXE/TFTP→iPXE) -that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuard -(§5.2). Each crossing is a control point addressed in Annex A. +the tmpfs overlay, the TPM-backed identity, the hardened OS configuration, and the +in-image **`fleet-agent`**. The boundary's external interfaces are: (a) the +**access link** (802.1X-controlled port, MACsec-encrypted); (b) the **NVMe/TCP** +root storage session; (c) **outbound audit log** forwarding; (d) the **kiosk +application** endpoint the browser renders; (e) the **boot transport** (UEFI HTTP +boot, or PXE/TFTP→iPXE) that delivers the UKI; (f) **USB/peripheral** ports, now +mediated by USBGuard (§5.2); and (g) the **management plane** — an *outbound-only* +mTLS gRPC session from `fleet-agent` to the fleet-manager (`manager.fleet.lab:9600`, +across the OPNsense L3 hop from the host VLAN to the management network; no inbound +host port), authenticated with the host's TPM identity cert. Each crossing is a +control point addressed in Annex A. ### 2.4 Data and classification @@ -127,7 +143,16 @@ that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuar CI/HSM-held and **never enter the build VM or the repository**. - **Data in transit:** access link protected by **MACsec**; NVMe/TCP root and audit-log offload currently **plaintext on an isolated lab LAN** (see residual - risk R-04, R-07 in Annex A). + risk R-04, R-07 in Annex A). The **management-plane** telemetry stream is + **mTLS-encrypted** (fleet CA) end-to-end — unlike the NVMe/audit hops, it is not + plaintext. +- **Management telemetry (v0.3):** the agent sends host **health** (load/cpu/temp/ + memory/tmpfs, uptime, NVMe-root state, 802.1X/MACsec + SELinux state, chrony + clock health, kiosk state) and **inventory facts** (image version, kernel, + cert-serial). This is **operational metadata, not user data**, and it carries **no + per-host secret** (identity is the reused TPM cert; the private key never leaves + the TPM). It is retained server-side (InfluxDB) — a **connected-system** + responsibility (see §1, R-14). - **Data at rest in the overlay:** ephemeral, RAM-only, discarded at power-off. The **system's data classification governs this SSP's marking and the required @@ -164,8 +189,27 @@ This system follows the ISM A&A lifecycle: Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M source). -**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-27)**. The system -is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA +**Current status:** Pre-assessment **DRAFT (v0.3, updated 2026-07-10)**. The system +is functional in a lab configuration. + +**Progress since v0.2 (2026-07-10):** the **fleet-management plane** was built and, +on the host side, **bench-verified end-to-end on real hardware** (`comet-beacon-54`): +the in-image **`fleet-agent`** streams health to the server over **mTLS using the +reused TPM identity** (telemetry-only — desired-state/commands are received but only +logged, zero host mutation). The server-side manager adds a **WebAuthn-passkey** +(phishing-resistant, origin-bound) admin plane — the first **human-admin MFA** on the +management plane — and a **TPM enrolment broker** (unauthenticated pre-auth CSR → OOB +fingerprint → admin-mTLS approve → step-ca sign) that births the per-host inventory +record. Its server-side server cert now **auto-renews** from the co-located CA. Host +hardening also advanced: **three local SELinux policy modules** were added (all +documented deviations — [stig-deviations.md](stig-deviations.md) #14–16), and a +**material fapolicyd finding was fixed (2026-07-04)** — the app-allow-listing exec +guard was effectively *inactive on the overlay root* (`overlay` missing from +`watch_fs`) and is now enforcing across the whole root (see §5.2). The server-side +plane (manager/broker/InfluxDB/step-ca) is a **connected system** pending its own +authorisation (R-14). + +**Progress since v0.1 (2026-06-20):** DISA STIG hardening applied and **bench-verified** on a real build (kiosk, key-only SSH, audit offload, DHCP-named host, NVMe/verity root all good with hardening on); USBGuard device control enabled + verified; admin confined to `staff_u`; **UEFI @@ -201,7 +245,18 @@ summarises the implementation posture. ### 5.2 Protect - **Identity & authentication:** mutual **802.1X EAP-TLS** with a - **non-exportable TPM key**; network access controlled at the switch port. + **non-exportable TPM key**; network access controlled at the switch port. The + **management-plane agent reuses the same TPM identity** for mTLS to the + fleet-manager (one non-exportable key backs 802.1X, the NVMe `hostnqn`, and the + agent stream — "one identity, three stages"), and the server keys inventory off + the verified cert serial, so a host can only ever report as itself. +- **Management-plane admin MFA (v0.3):** the server-side admin web UI enforces + **WebAuthn passkeys** — phishing-resistant, origin-bound, hardware-backed + MFA — behind a Traefik ingress that terminates a Let's Encrypt cert (DNS-01). + This is the first human-admin MFA in the system (the manager/broker admin planes + are a **connected system**, but the control is noted here as it closes the + long-standing "no admin MFA" gap for management operations; host SSH admin + remains key-only — R-05). - **Link protection:** **MACsec** on the access link. - **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable cmdline**; **dm-verity** read-only root (continuous cryptographic integrity); @@ -221,7 +276,21 @@ summarises the implementation posture. SSH**, root login disabled, hardened ciphers/MACs/KEX (note: STIG crypto policy drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked browser, no shell/navigation); **postfix removed** (no local MTA — audit-failure - notification rides the remote offload, not email). + notification rides the remote offload, not email). Three **local SELinux policy + modules** are shipped for architecture-specific allows, each a documented + deviation ([stig-deviations.md](stig-deviations.md) #14–16): `chrony_dhcp_dispatcher` + (NM-dispatcher chrony reload), `fapolicyd_tmpfs` (statfs the overlay upper), and + `fleet_agent_chronylog` (agent reads chrony's tracking log). No blanket + permissive domains; SELinux stays enforcing. +- **Application-control effectiveness fix (2026-07-04):** a bench finding showed + fapolicyd's exec guard was **effectively inactive on the root** — the diskless + root is `overlayfs`, which was **absent from fapolicyd's `watch_fs`**, so + fapolicyd marked no mounts and intercepted no exec on `/`, `/usr` or `/tmp`. + Adding `overlay` to `watch_fs` turned app-allow-listing **on for the whole + overlay root for the first time** — verified: a novel non-RPM binary in `/tmp` + is now denied (`FAN_DENY`), nothing legitimate broke. (Residual: path-based trust + doesn't catch a *modified trusted path* via copy-up — tracked as TODO #15 / + the `integrity=sha256` option — noted in stig-deviations #15.) - **Privileged access:** single **key-only `wheel` admin**, root locked. The admin is now **confined to the `staff_u` SELinux user** with a sudoers role/type transition (`sysadm_r`/`sysadm_t`) so privilege elevation re-labels the SELinux @@ -252,7 +321,16 @@ summarises the implementation posture. 2026-06-27** (`wazuh-logtest` + a live `sudo` surfacing `USER_CMD`/`CRED_*` in the dashboard). Config is declarative (`serving/siem/`, mounted via `/wazuh-config-mount`). -- *Gaps:* both offload hops (host→collector, collector→Wazuh) currently +- **Fleet health telemetry (v0.3):** the `fleet-agent` streams host health + (resource pressure, NVMe-root/auth/SELinux/chrony/kiosk state, uptime) to the + manager over mTLS; the manager persists a time-series (InfluxDB) and flags + version-drift, SELinux-permissive, and reboot-pending in an admin dashboard. This + is an **operational-health** signal (complements the auditd/Wazuh **security-event** + path — different purpose, different data): e.g. a host reporting `SELinux: + permissive` or overlay RAM exhaustion is surfaced for the operator. The store and + dashboard are a **connected system** (server-side); the host-side contribution is + the read-only probe + mTLS stream. +- *Gaps:* both audit-offload hops (host→collector, collector→Wazuh) currently **plaintext** (TLS planned, R-07); **log retention/archival** (ISM) not yet configured; default Wazuh credentials to be rotated before production. @@ -275,9 +353,9 @@ AO will expect a maturity view: | Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. | | Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. | | User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. | -| Restrict admin privileges | **Partial** | Single key-only `wheel` admin, root locked, now **SELinux-confined to `staff_u`** with a sudo role/type transition (NOPASSWD, see R-05); no per-host/rotatable model or admin-MFA yet. | +| Restrict admin privileges | **Partial** | Single key-only `wheel` admin, root locked, now **SELinux-confined to `staff_u`** with a sudo role/type transition (NOPASSWD, see R-05); **management-plane** admin actions now require a **WebAuthn passkey** (v0.3). Still no per-host/rotatable *host* access model. | | Patch operating systems | **Process** | Same rebuild/redeploy model; immutable image means uniform, atomic OS updates. | -| Multi-factor authentication | **Strong (machine)** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). Human admin MFA to be addressed for the management plane. | +| Multi-factor authentication | **Strong (machine) + admin passkey** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). **Human admin MFA is now enforced on the management-plane UI via WebAuthn passkeys** (phishing-resistant, origin-bound) — v0.3. Host SSH admin remains key-only (no separate human MFA — R-05). | | Regular backups | **N/A (by design)** | Diskless, stateless hosts hold nothing to back up; recoverability is re-provisioning from a pinned digest. Backup applies to serving-side state, out of scope here. | ## 7. Residual risk summary @@ -292,12 +370,14 @@ production ATO: | R-02 | `debug_shell=true` bakes a **passwordless root shell** on a tty. | **High** | Treat — must be removed before production (lab-only). | | R-03 | STIG hardening **applied + bench-verified** (2026-06-20); residual is that CI ships **unsigned, Secure Boot OFF** and `enable_stig` must be confirmed on for production builds. | **Medium** | Treat — confirm `enable_stig=true` in CI + enforce signing/SB before assessment. | | R-04 | NVMe/TCP root traffic **plaintext** on the lab LAN. | **Medium** | Accept (isolated LAN) / treat for production. | -| R-05 | NOPASSWD key-only admin (now `staff_u`-confined); no per-host/rotatable access model or admin-MFA. | **Medium** | Accept with compensating controls / roadmap. | +| R-05 | NOPASSWD key-only **host** admin (now `staff_u`-confined); no per-host/rotatable access model. (Management-plane admin MFA now enforced via WebAuthn passkeys — v0.3.) | **Medium** | Accept with compensating controls / roadmap. | | R-06 | **FIPS off**; ASD-approved-cryptography conformance not yet asserted. | **Medium** | Treat — required for PROTECTED+; revisit with signing. | | R-07 | Audit-log offload **plaintext** on both hops (host→collector, collector→Wazuh); **log retention not yet configured**. SIEM **analysis + alerting now in place** (Wazuh, decode+alert bench-verified 2026-06-27). | **Low–Medium** | Treat — TLS both hops + ISM retention/archival + rotate Wazuh creds before production. | | R-08 | **Placeholder RADIUS CA** in the repo; real trust anchor not yet provisioned. | **Medium** | Treat — environment-specific, before production. | | R-09 | Host **firewall disabled** (workstation). | **Low–Medium** | Accept (isolated LAN) / treat with explicit allow-rules. | | R-13 | **Boot-chain signing not yet the standing default.** Signing + Secure Boot enforcement **verified end-to-end on hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22), but unsigned/SB-OFF lab builds are still possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not all hosts have `db` enrolled. | **Low** | Treat — make signed + Secure-Boot-on the default for all production builds/hosts (set CI secrets; enrol `db` fleet-wide). | +| R-14 | **Management/enrolment plane is a connected system, built but not yet separately authorised.** The host now trusts + streams to the fleet-manager and enrols via the broker/**step-ca fleet CA** (a new trust anchor + key material) whose one **unauthenticated pre-auth CSR endpoint** is a new server-side surface. Host-side exposure is limited (outbound-only mTLS, read-only telemetry, commands logged-not-applied in v1). | **Medium** | Treat — separate SSP/authorisation for the management plane (CA key protection, broker endpoint hardening, InfluxDB token/data, admin-plane exposure). | +| R-15 | **Deferred agent reconcile actuators.** v1 receives `DesiredState`/`Command` but only logs them; when actuation (apply kiosk_url / reboot / drain) is built, the agent becomes a **remote host-mutation channel** needing re-assessment. | **Low** (v1) | Treat at build time — assess the command/authZ path before enabling actuators. | Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A. diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md index 5f019e8..f3f7891 100644 --- a/docs/stig-deviations.md +++ b/docs/stig-deviations.md @@ -248,6 +248,47 @@ surface rather than detecting abuse of it. Requires a write primitive to a root- path either way (already-privileged context), which is fapolicyd's defense-in-depth threat model — not an unprivileged bypass. +## 16. Local SELinux policy module `fleet_agent_chronylog` (added 2026-07-10) + +Not a STIG-rule waiver — a **minimal local read-only `allow` module** so the +`fleet-agent` telemetry client can read chrony's tracking log under enforcing +SELinux. Source: `files/stig/fleet-agent-chronylog.te`; built + `semodule -i`'d in +the 2c-STIG provisioner (after #15). + +**The rules:** `allow unconfined_service_t chronyd_var_log_t:file { open read +getattr };` (+ `dir { search getattr open }` to reach the file). + +**Why:** `fleet-agent` reports clock health (source/stratum/offset) by reading +`/var/log/chrony/tracking.log` — deliberately a FILE read rather than a query to +chronyd's command interface, which the hardened agent can't use (owner-write-only +unix socket; STIG's `cmdport 0` closes the UDP port). But chrony's log dir + files +are labeled `chronyd_var_log_t`, which the shipped policy confines to `chronyd_t` + +log-admin roles. So although the file is mode `0644`, under enforcing the agent +(`unconfined_service_t`) is **denied** by type enforcement — DAC allows, TE does +not. Verified 2026-07-10 on `comet-beacon-54`: with SELinux **permissive** a +non-root read of `tracking.log` succeeds; flip to **enforcing** and even a +STIG-confined admin (`staff_t`) gets `Permission denied` (`ls -Z` on the file is +itself blocked) — the signature of a type-enforcement block, not a DAC one. + +**Scope:** read-only (`open`/`read`/`getattr`) on one log type + dir search to +reach it. No write, no other chrony access, and — unlike the earlier abandoned +approach — **no domain transition** (the agent reads a file; it does not exec +`chronyc`). This replaces the never-shipped `fleet_agent_chronyc` nnp-transition +module (that whole command-socket path was dropped for reading the log instead). + +**This is the SELinux half of a two-layer requirement.** `/var/log/chrony` is +mode `0750 chrony:chrony` (others have no access), so DAC also blocks the agent's +`DynamicUser` from traversing the dir — a denial that produces **no AVC** (it's +DAC, not TE). The DAC half is therefore the **`chrony` supplementary group** on +`fleet-agent.service` (traverse the `0750` dir; the log file itself is `0644`). +Both this module and that group are needed; verified 2026-07-10 — with the group +but no module the read is TE-denied, and with the module but no group it's +DAC-denied (silently, no AVC — which is what made this the hardest layer to spot). +**BENCH-VERIFY:** (a) the manager's Monitor chrony row populates on an enforcing +boot; (b) no other chrony-log AVC (extend here if one appears); (c) +**reproducibility** — `semodule -i` rebuilds the policy store, re-check verity hash +stability (same concern as #14/#15). Only shipped when `enable_stig=true`. + ## Things that are NOT deviations (kept / enforced) - **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). 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/files/agent/README.md b/files/agent/README.md new file mode 100644 index 0000000..4b330a9 --- /dev/null +++ b/files/agent/README.md @@ -0,0 +1,83 @@ +# fleet-agent — on-host control-plane client (telemetry v1) + +The workstation-side half of the fleet management plane. It dials the +**fleet-manager** agent plane, identifies with the host's **TPM**, and streams +health telemetry up. It is the client peer of the server hub in +`serving/fleet-manager/` (`agent.go` + `proto/fleet_agent.proto`). + +Baked into the immutable verity image, so — like `fleet-hostapi` and `fleet-enrol` +— it is a **static, reproducible, `CGO_ENABLED=0`** Go binary built **out of the +build VM** by CI (deps proxy-fetched, `go.sum`-pinned) and file-provisioned in. + +## What it does (v1 — telemetry only) + +1. **Reuses the TPM identity for mTLS.** Loads the signing key at the persistent + handle (`0x81000100`) as a `crypto.Signer` (`tpmkey.LoadSigner` — the private + key never leaves the TPM) and the enrolment cert from NV (`0x01800100`), and + presents them as the mTLS client cert. The manager verifies the chain to the + fleet CA and derives the **cert serial = inventory key** — a host can only ever + report as itself (`agent.go`: `peerSerial`). +2. **Opens the long-lived `Session` bidi stream** (host dials out; no inbound host + port — works across the OPNsense L3 hop from the host VLAN to the mgmt plane). +3. **Sends `Hello`** (image version, kernel, kiosk URL, root hash, starship name — + the fleet-generic facts, pulled from `fleet-hostapi` on `127.0.0.1:9099`). +4. **Sends a `HealthSnapshot` every `FLEET_AGENT_INTERVAL`** — load/cpu/temp/mem/ + tmpfs, **uptime**, nvme-root, 802.1X-macsec, selinux, kiosk-active — probed + read-only from `/proc`+`/sys`, plus **chrony** clock health (sync state + + source/stratum/offset relative to the NTP source; see note). Each probe + degrades gracefully; a failure never kills the stream. `uptime` lets the + manager derive **reboot-pending** (boot_time = last_seen − uptime vs the + requested `reboot_at`, all in the manager's clock frame). +5. **Receives `DesiredState`/`Command` and LOGS them.** This build does **not** + act on them. The reconcile actuators (apply kiosk URL, reboot, drain) are a + deliberate follow-up (see `docs/TODO.md` #14) — the receive path is wired now so + adding them needs no protocol change. + +A dropped stream is retried with jittered backoff; the manager treats stream loss +as the host going **offline**. + +> **Note — the chrony probe reads a file, not the command socket.** chronyd's unix +> command socket is owner-write-only and STIG sets `cmdport 0` (no UDP command +> port), so the sandboxed agent can't query it. Instead the image enables +> `log tracking`, and the agent reads the last line of +> `/var/log/chrony/tracking.log`. That read needs **two** access layers, both wired +> in: the **`chrony` supplementary group** (DAC — `/var/log/chrony` is `0750`) and +> a minimal **SELinux allow** for the `chronyd_var_log_t` label +> (`files/stig/fleet-agent-chronylog.te`). The lab NTP server must also be +> reachable (a `123/udp` host-firewall rule on the serving host) or the log only +> carries `unsynced` lines. See `docs/stig-deviations.md` #16. + +## Prerequisite: the host must already be enrolled + +The manager rejects the stream (`PermissionDenied`) until an inventory record +exists for the host's cert serial — that record is born when an admin **approves** +the host's TPM enrolment (`fleet-enrol` → enrol-broker → manager). Enrolment +precedes the agent; this ordering is expected. + +## Config (`/etc/fleet-agent/agent.env`) + +All values are **fleet-generic** (invariant #1). See the file for the full list: +`FLEET_AGENT_MANAGER` (manager `host:port`), `FLEET_AGENT_CA` (fleet root, reused +from `/etc/dot1x/radius-ca.pem`), `FLEET_AGENT_HANDLE` / `FLEET_AGENT_NVINDEX` (TPM +identity, mirror `rd.dot1x.*`), `FLEET_AGENT_INTERVAL`, `FLEET_AGENT_HOSTAPI`. + +## Layout + +| file | purpose | +|------|---------| +| `main.go` | config, connect loop (jittered backoff), `Session` send/recv | +| `identity.go` | TPM-backed mTLS client cert + fleet-CA-verifying `tls.Config` | +| `snapshot.go` | read-only `/proc`+`/sys` health probes + hostapi facts fetch | +| `tpmkey/` | **copy** of `files/enrol/tpmkey` + read-only `LoadSigner` (see its header) | +| `proto/` | **copy** of the generated `fleet.agent.v1` package (one `.proto` contract, two peers) | +| `fleet-agent.service` | hardened unit (`tss` group for the TPM, `chrony` group to read chrony's tracking.log, INET egress) | + +## Build / test + +```bash +cd files/agent +CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . +go test ./... +# TPM handshake end-to-end (go-tpm software simulator, CGO — isolated module): +( cd simtest && CGO_ENABLED=1 go test ./... ) +``` diff --git a/files/agent/agent.env b/files/agent/agent.env new file mode 100644 index 0000000..0f762a6 --- /dev/null +++ b/files/agent/agent.env @@ -0,0 +1,32 @@ +# fleet-agent config — EnvironmentFile for fleet-agent.service. +# +# FLEET-GENERIC ONLY (invariant #1): every value here is identical for every host +# of a release. Host identity is the TPM (signing key at the persistent handle + +# cert in NV) resolved at runtime — nothing per-host is baked. Override per +# environment by replacing this file (it is part of the immutable image, so a +# change means a rebuild + re-sign, like any other baked config). + +# The fleet-manager agent plane (gRPC/mTLS). A fleet DNS name (NOT per-host), +# resolved by the serving dnsmasq like boot/nvme/audit — MGMT_HOST in serving/.env +# points it at the manager's fleet-LAN address (this serving host now; a routable +# mgmt address once the manager moves behind OPNsense L3 / k8s). Must match the +# agent server cert SAN (server-pki.sh AGENT_SANS). This is the AGENT plane, NOT +# the public admin-UI name (that's Traefik/Let's Encrypt on :443). +FLEET_AGENT_MANAGER=manager.fleet.lab:9600 + +# Fleet CA that verifies the manager's server cert. This is the SAME step-ca root +# that signs the host TPM certs + the RADIUS server cert, so we reuse the in-place +# copy rather than duplicating it. Replace per environment with your real root +# (the shipped files/etc/dot1x/radius-ca.pem is a placeholder — auth/telemetry +# cannot succeed until it is the real fleet root). +FLEET_AGENT_CA=/etc/dot1x/radius-ca.pem + +# TPM identity (overridable, mirrors rd.dot1x.handle / rd.dot1x.nvindex). These +# MUST match what enrolment used: signing key at a non-reserved owner handle, cert +# in an owner NV index. +FLEET_AGENT_HANDLE=0x81000100 +FLEET_AGENT_NVINDEX=0x01800100 + +# Health snapshot cadence and the loopback hostapi facts source. +FLEET_AGENT_INTERVAL=30s +FLEET_AGENT_HOSTAPI=http://127.0.0.1:9099/v1/host diff --git a/files/agent/fleet-agent.service b/files/agent/fleet-agent.service new file mode 100644 index 0000000..66ae2b7 --- /dev/null +++ b/files/agent/fleet-agent.service @@ -0,0 +1,59 @@ +[Unit] +Description=Fleet on-host control-plane agent (telemetry: TPM-mTLS to fleet-manager) +Documentation=https://git.bitasylum.org/audiobuzz/diskless-workstation/docs/fleet-management-design.md +# Needs the network up to dial the manager; hostapi is a SOFT dep (facts source) — +# After, not Requires, so the agent still streams health if hostapi is down. +After=network-online.target fleet-hostapi.service +Wants=network-online.target + +[Service] +Type=exec +EnvironmentFile=/etc/fleet-agent/agent.env +ExecStart=/usr/local/bin/fleet-agent +# Always retry — a diskless box has nothing better to do than reconnect. The agent +# also self-reconnects the stream; Restart covers a hard crash / TPM transient. +Restart=always +RestartSec=5 + +# --- sandboxing (STIG-friendly; looser than hostapi — needs the TPM + egress) --- +# Runs as a transient unprivileged user. Groups: +# tss — /dev/tpmrm0 (the private key stays in the TPM; the agent only signs +# the TLS handshake through it). +# chrony — TRAVERSE /var/log/chrony to read tracking.log for the clock-health +# probe. That dir is mode 0750 chrony:chrony (others have no access), so +# DAC needs group membership to even enter it — a bare 0644 on the log +# file isn't enough. (SELinux read of the chronyd_var_log_t label is a +# SEPARATE layer, granted by the fleet_agent_chronylog module.) +DynamicUser=yes +SupplementaryGroups=tss chrony +# Expose ONLY the TPM resource-manager node (no PrivateDevices — that would hide it). +DeviceAllow=/dev/tpmrm0 rw +DevicePolicy=closed +CapabilityBoundingSet= +AmbientCapabilities= +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=default +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +# INET egress to the manager (not loopback-only, unlike hostapi); AF_UNIX for the +# journal, AF_NETLINK for Go's source-address/interface selection. +RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/files/agent/go.mod b/files/agent/go.mod new file mode 100644 index 0000000..37d1819 --- /dev/null +++ b/files/agent/go.mod @@ -0,0 +1,28 @@ +// fleet-agent — the on-host steady-state control-plane client (telemetry v1). +// +// Runs in the BOOTED system (post switch-root), NOT the initramfs. It reuses the +// host's TPM identity (signing key at 0x81000100 + cert in NV 0x01800100) as an +// mTLS client cert, opens the long-lived FleetAgent.Session bidi stream to the +// fleet-manager, sends Hello + periodic HealthSnapshot, and (v1) logs but does not +// act on the desired-state/commands it receives. See docs/fleet-management-design.md. +// +// Unlike fleet-hostapi (stdlib-only) this needs gRPC + go-tpm; like fleet-enrol it +// is a static CGO_ENABLED=0 binary whose deps are proxy-fetched, go.sum-PINNED, and +// built out-of-VM in CI (never enters the reproducible image build's toolchain). +// Dep versions track serving/fleet-manager/go.mod (the peer of this wire contract). +module fleet-agent + +go 1.26 + +require ( + github.com/google/go-tpm v0.9.8 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + golang.org/x/net v0.53.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect +) diff --git a/files/agent/go.sum b/files/agent/go.sum new file mode 100644 index 0000000..4521f19 --- /dev/null +++ b/files/agent/go.sum @@ -0,0 +1,42 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/files/agent/identity.go b/files/agent/identity.go new file mode 100644 index 0000000..d9c49c7 --- /dev/null +++ b/files/agent/identity.go @@ -0,0 +1,96 @@ +// identity.go — the host's mTLS identity for the agent plane, reused from the TPM. +// +// The private key never leaves the TPM: we present the enrolment cert (DER read +// from NV 0x01800100) as the leaf and hand crypto/tls a crypto.Signer backed by the +// persistent signing key at 0x81000100 (tpmkey.LoadSigner). The manager verifies +// this chain to the fleet CA and derives the cert SERIAL as the inventory key +// (agent.go: RequireAndVerifyClientCert + peerSerial), so a host can only ever be +// itself. The server cert is verified against the same fleet CA bundle. +package main + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + + "fleet-agent/tpmkey" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// tpmIdentity holds the open TPM transport plus the loaded identity so the caller +// can close the device when the agent exits. +type tpmIdentity struct { + dev *os.File + cert tls.Certificate +} + +func (id *tpmIdentity) Close() error { + if id.dev != nil { + return id.dev.Close() + } + return nil +} + +// loadTPMIdentity opens the TPM, loads the signing key at handle, reads the cert +// from NV index, and assembles a tls.Certificate whose PrivateKey is the TPM +// signer. The cert must parse and its public key must match the loaded signer +// (a stale NV cert for a rotated key would fail the handshake anyway — we surface +// it here with a clear error instead). +func loadTPMIdentity(tpmPath string, handle, nvIndex tpm2.TPMHandle) (*tpmIdentity, error) { + // /dev/tpmrm0 (kernel resource manager) is a command/response char device; + // transport.FromReadWriter over the *os.File avoids the linuxtpm transport and + // its extra deps — the same lean approach fleet-enrol uses. + f, err := os.OpenFile(tpmPath, os.O_RDWR, 0) + if err != nil { + return nil, fmt.Errorf("open TPM %s: %w", tpmPath, err) + } + tpm := transport.FromReadWriter(f) + + signer, err := tpmkey.LoadSigner(tpm, handle) + if err != nil { + f.Close() + return nil, fmt.Errorf("load signing key: %w (host enrolled?)", err) + } + der, err := tpmkey.ReadNVCert(tpm, nvIndex) + if err != nil { + f.Close() + return nil, fmt.Errorf("read identity cert from NV %#x: %w (host enrolled?)", uint32(nvIndex), err) + } + leaf, err := x509.ParseCertificate(der) + if err != nil { + f.Close() + return nil, fmt.Errorf("parse identity cert: %w", err) + } + + return &tpmIdentity{ + dev: f, + cert: tls.Certificate{ + Certificate: [][]byte{der}, + PrivateKey: signer, // crypto.Signer — TLS signs the handshake in the TPM + Leaf: leaf, + }, + }, nil +} + +// clientTLS builds the mTLS config: our TPM-backed client cert + the fleet CA as +// the root that verifies the manager's server cert. serverName is the manager's +// DNS name (SNI + hostname verification). +func clientTLS(id *tpmIdentity, caFile, serverName string) (*tls.Config, error) { + pem, err := os.ReadFile(caFile) + if err != nil { + return nil, fmt.Errorf("read fleet CA %s: %w", caFile, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("no certificates parsed from fleet CA %s", caFile) + } + return &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{id.cert}, + RootCAs: pool, + ServerName: serverName, + }, nil +} diff --git a/files/agent/main.go b/files/agent/main.go new file mode 100644 index 0000000..dcfe9a0 --- /dev/null +++ b/files/agent/main.go @@ -0,0 +1,273 @@ +// fleet-agent — the on-host steady-state control-plane client (telemetry v1). +// +// Runs in the booted system as a systemd service. It dials the fleet-manager over +// mTLS using the host's reused TPM identity (private key stays in the TPM), opens +// the long-lived FleetAgent.Session bidi stream, sends a Hello then periodic +// HealthSnapshots, and — in this telemetry-only build — LOGS but does NOT act on +// the DesiredState/Command messages the manager pushes back. The receive path is +// wired now so the reconcile actuators (kiosk URL, reboot, drain) can be added in a +// follow-up without a protocol change. +// +// No inbound host port: the host always dials out (works behind the OPNsense L3 +// hop from the authenticated host VLAN to the mgmt plane). A dropped stream is +// retried with jittered backoff; the manager treats stream loss as "offline". +package main + +import ( + "context" + "io" + "log" + "math/rand" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + fleetpb "fleet-agent/proto" + + "github.com/google/go-tpm/tpm2" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" +) + +type config struct { + manager string // manager host:port (DNS name — fleet-generic, invariant #1 safe) + serverName string // TLS SNI / hostname to verify (defaults to manager host) + caFile string // fleet CA bundle: verifies the manager's server cert + tpmPath string + handle tpm2.TPMHandle + nvIndex tpm2.TPMHandle + interval time.Duration + hostapiURL string + kioskEnv string // path to /etc/kiosk/kiosk.env (source of KIOSK_URL) +} + +func main() { + log.SetFlags(0) + log.SetPrefix("fleet-agent: ") + cfg := loadConfig() + + id, err := loadTPMIdentity(cfg.tpmPath, cfg.handle, cfg.nvIndex) + if err != nil { + log.Fatalf("TPM identity: %v", err) + } + defer id.Close() + log.Printf("identity: cert serial %x (CN=%q) — dialing %s", + id.cert.Leaf.SerialNumber, id.cert.Leaf.Subject.CommonName, cfg.manager) + + tlsCfg, err := clientTLS(id, cfg.caFile, cfg.serverName) + if err != nil { + log.Fatalf("client TLS: %v", err) + } + cc, err := grpc.NewClient(cfg.manager, + grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)), + grpc.WithKeepaliveParams(keepalive.ClientParameters{ + Time: 20 * time.Second, // >= server MinTime (10s) + Timeout: 10 * time.Second, + PermitWithoutStream: true, + }), + ) + if err != nil { + log.Fatalf("grpc client: %v", err) + } + defer cc.Close() + client := fleetpb.NewFleetAgentClient(cc) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + p := &prober{hostapiURL: cfg.hostapiURL} + backoff := time.Second + for ctx.Err() == nil { + start := time.Now() + if err := runSession(ctx, client, cfg, p); err != nil && ctx.Err() == nil { + log.Printf("session ended: %v", err) + } + if ctx.Err() != nil { + break + } + // Reset backoff after a session that stayed up a while; otherwise grow it + // (capped) with jitter to avoid reconnect storms across the fleet. + if time.Since(start) > 30*time.Second { + backoff = time.Second + } + sleep := min(backoff+time.Duration(rand.Int63n(int64(backoff+1))), 30*time.Second) + log.Printf("reconnecting in %s", sleep.Round(time.Millisecond)) + select { + case <-ctx.Done(): + case <-time.After(sleep): + } + if backoff < 15*time.Second { + backoff *= 2 + } + } + log.Printf("shutting down") +} + +// runSession opens one Session stream, sends Hello + periodic snapshots, and logs +// any ManagerDown. It returns when the stream fails or ctx is cancelled. +func runSession(ctx context.Context, client fleetpb.FleetAgentClient, cfg config, p *prober) error { + sctx, cancel := context.WithCancel(ctx) + defer cancel() + + stream, err := client.Session(sctx) + if err != nil { + return err + } + + // Receive loop. Its error is the AUTHORITATIVE session status — when the manager + // ends the stream (e.g. PermissionDenied "host is not enrolled"), gRPC only + // returns a bare io.EOF on the SEND side, so a raw Send error is uninformative. + // We surface the recv-side status instead. v1 LOGS desired-state/commands. + recvErr := make(chan error, 1) + go func() { + for { + down, err := stream.Recv() + if err != nil { + recvErr <- err + cancel() + return + } + logDown(down) + } + }() + + // Hello first — what we're running, so the manager reconciles version drift. + facts := fetchFacts(sctx, cfg.hostapiURL) + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Hello{Hello: buildHello(facts, readKioskURL(cfg.kioskEnv))}}); err != nil { + return statusFrom(err, recvErr) + } + log.Printf("connected to %s — streaming health every %s", cfg.manager, cfg.interval) + + // Send one snapshot immediately, then on the interval. + t := time.NewTicker(cfg.interval) + defer t.Stop() + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: p.snapshot(sctx)}}); err != nil { + return statusFrom(err, recvErr) + } + for { + select { + case err := <-recvErr: + return err + case <-sctx.Done(): + // Prefer the recv-side status if that's what cancelled us. + select { + case err := <-recvErr: + return err + default: + return sctx.Err() + } + case <-t.C: + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: p.snapshot(sctx)}}); err != nil { + return statusFrom(err, recvErr) + } + } + } +} + +// statusFrom turns a Send-side io.EOF (which gRPC returns when the server has +// closed the stream) into the authoritative recv-side status — the real reason +// the manager ended the session — falling back to the original error. +func statusFrom(err error, recvErr chan error) error { + if err == io.EOF { + select { + case re := <-recvErr: + return re + case <-time.After(2 * time.Second): + } + } + return err +} + +// logDown records a manager->host message. TELEMETRY-ONLY v1: received, logged, +// NOT applied. The reconcile actuators land in a follow-up (see docs/TODO.md #14). +func logDown(down *fleetpb.ManagerDown) { + switch m := down.GetMsg().(type) { + case *fleetpb.ManagerDown_Desired: + d := m.Desired + log.Printf("desired-state (NOT applied — telemetry-only): target_version=%q kiosk_url=%q reboot_at=%d drain=%v", + d.GetTargetVersion(), d.GetKioskUrl(), d.GetRebootAtUnix(), d.GetDrain()) + case *fleetpb.ManagerDown_Command: + log.Printf("command (NOT applied — telemetry-only): id=%s kind=%s", m.Command.GetCommandId(), m.Command.GetKind()) + } +} + +// ---- config ---------------------------------------------------------------- + +func loadConfig() config { + manager := env("FLEET_AGENT_MANAGER", "manager.fleet.lab:9600") + cfg := config{ + manager: manager, + serverName: env("FLEET_AGENT_SERVER_NAME", hostOf(manager)), + caFile: env("FLEET_AGENT_CA", "/etc/dot1x/radius-ca.pem"), + tpmPath: env("FLEET_AGENT_TPM", "/dev/tpmrm0"), + handle: mustHandle(env("FLEET_AGENT_HANDLE", "0x81000100")), + nvIndex: mustHandle(env("FLEET_AGENT_NVINDEX", "0x01800100")), + interval: envDuration("FLEET_AGENT_INTERVAL", 30*time.Second), + hostapiURL: env("FLEET_AGENT_HOSTAPI", "http://127.0.0.1:9099/v1/host"), + kioskEnv: env("FLEET_AGENT_KIOSK_ENV", "/etc/kiosk/kiosk.env"), + } + return cfg +} + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func envDuration(k string, def time.Duration) time.Duration { + if v := os.Getenv(k); v != "" { + if d, err := time.ParseDuration(v); err == nil { + return d + } + log.Printf("bad %s=%q — using %s", k, v, def) + } + return def +} + +// hostOf strips the :port from a host:port, leaving the DNS name for TLS +// verification. Handles bare hosts and IPv6 in brackets conservatively. +func hostOf(hostport string) string { + if i := strings.LastIndex(hostport, ":"); i > strings.LastIndex(hostport, "]") { + return strings.Trim(hostport[:i], "[]") + } + return strings.Trim(hostport, "[]") +} + +func mustHandle(s string) tpm2.TPMHandle { + h, err := parseHandle(s) + if err != nil { + log.Fatalf("bad TPM handle/index %q: %v", s, err) + } + return h +} + +// parseHandle parses a 0x-prefixed (or bare) hex TPM handle/NV index. +func parseHandle(s string) (tpm2.TPMHandle, error) { + v, err := strconv.ParseUint(strings.TrimPrefix(strings.ToLower(s), "0x"), 16, 32) + if err != nil { + return 0, err + } + return tpm2.TPMHandle(v), nil +} + +// readKioskURL extracts KIOSK_URL from the kiosk env file (KEY=VALUE lines). +// Best-effort: "" if the file is absent (the manager still gets a Hello). +func readKioskURL(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + for line := range strings.SplitSeq(string(b), "\n") { + line = strings.TrimSpace(line) + if v, ok := strings.CutPrefix(line, "KIOSK_URL="); ok { + return strings.Trim(strings.TrimSpace(v), `"'`) + } + } + return "" +} diff --git a/files/agent/proto/fleet_agent.pb.go b/files/agent/proto/fleet_agent.pb.go new file mode 100644 index 0000000..c473e89 --- /dev/null +++ b/files/agent/proto/fleet_agent.pb.go @@ -0,0 +1,848 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Command_Kind int32 + +const ( + Command_KIND_UNSPECIFIED Command_Kind = 0 + Command_REBOOT Command_Kind = 1 // reboot now (into desired target_version) + Command_DRAIN Command_Kind = 2 // stop taking work / prepare for maintenance + Command_RESYNC Command_Kind = 3 // re-fetch + re-apply desired-state now +) + +// Enum value maps for Command_Kind. +var ( + Command_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "REBOOT", + 2: "DRAIN", + 3: "RESYNC", + } + Command_Kind_value = map[string]int32{ + "KIND_UNSPECIFIED": 0, + "REBOOT": 1, + "DRAIN": 2, + "RESYNC": 3, + } +) + +func (x Command_Kind) Enum() *Command_Kind { + p := new(Command_Kind) + *p = x + return p +} + +func (x Command_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Command_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_fleet_agent_proto_enumTypes[0].Descriptor() +} + +func (Command_Kind) Type() protoreflect.EnumType { + return &file_proto_fleet_agent_proto_enumTypes[0] +} + +func (x Command_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Command_Kind.Descriptor instead. +func (Command_Kind) EnumDescriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6, 0} +} + +type AgentUp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *AgentUp_Hello + // *AgentUp_Snapshot + // *AgentUp_Result + Msg isAgentUp_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentUp) Reset() { + *x = AgentUp{} + mi := &file_proto_fleet_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentUp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentUp) ProtoMessage() {} + +func (x *AgentUp) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentUp.ProtoReflect.Descriptor instead. +func (*AgentUp) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentUp) GetMsg() isAgentUp_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *AgentUp) GetHello() *Hello { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentUp) GetSnapshot() *HealthSnapshot { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Snapshot); ok { + return x.Snapshot + } + } + return nil +} + +func (x *AgentUp) GetResult() *CommandResult { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Result); ok { + return x.Result + } + } + return nil +} + +type isAgentUp_Msg interface { + isAgentUp_Msg() +} + +type AgentUp_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` // first frame after connect +} + +type AgentUp_Snapshot struct { + Snapshot *HealthSnapshot `protobuf:"bytes,2,opt,name=snapshot,proto3,oneof"` // periodic telemetry (also written to InfluxDB) +} + +type AgentUp_Result struct { + Result *CommandResult `protobuf:"bytes,3,opt,name=result,proto3,oneof"` // outcome of a ManagerDown.Command +} + +func (*AgentUp_Hello) isAgentUp_Msg() {} + +func (*AgentUp_Snapshot) isAgentUp_Msg() {} + +func (*AgentUp_Result) isAgentUp_Msg() {} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ImageVersion string `protobuf:"bytes,1,opt,name=image_version,json=imageVersion,proto3" json:"image_version,omitempty"` + RootfsDigest string `protobuf:"bytes,2,opt,name=rootfs_digest,json=rootfsDigest,proto3" json:"rootfs_digest,omitempty"` + Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` + KioskUrl string `protobuf:"bytes,4,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + StarshipName string `protobuf:"bytes,5,opt,name=starship_name,json=starshipName,proto3" json:"starship_name,omitempty"` // advisory; manager already knows it from inventory + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_proto_fleet_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Hello) GetImageVersion() string { + if x != nil { + return x.ImageVersion + } + return "" +} + +func (x *Hello) GetRootfsDigest() string { + if x != nil { + return x.RootfsDigest + } + return "" +} + +func (x *Hello) GetKernel() string { + if x != nil { + return x.Kernel + } + return "" +} + +func (x *Hello) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *Hello) GetStarshipName() string { + if x != nil { + return x.StarshipName + } + return "" +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +type HealthSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + AtUnix int64 `protobuf:"varint,1,opt,name=at_unix,json=atUnix,proto3" json:"at_unix,omitempty"` // sample time, unix seconds (0 = manager stamps now) + LoadAvg float64 `protobuf:"fixed64,2,opt,name=load_avg,json=loadAvg,proto3" json:"load_avg,omitempty"` + CpuPct float64 `protobuf:"fixed64,3,opt,name=cpu_pct,json=cpuPct,proto3" json:"cpu_pct,omitempty"` + TempC float64 `protobuf:"fixed64,4,opt,name=temp_c,json=tempC,proto3" json:"temp_c,omitempty"` + MemUsedPct float64 `protobuf:"fixed64,5,opt,name=mem_used_pct,json=memUsedPct,proto3" json:"mem_used_pct,omitempty"` + TmpfsUsedPct float64 `protobuf:"fixed64,6,opt,name=tmpfs_used_pct,json=tmpfsUsedPct,proto3" json:"tmpfs_used_pct,omitempty"` // overlay RAM pressure + NvmeRoot string `protobuf:"bytes,7,opt,name=nvme_root,json=nvmeRoot,proto3" json:"nvme_root,omitempty"` // ok|degraded|down + Auth string `protobuf:"bytes,8,opt,name=auth,proto3" json:"auth,omitempty"` // 802.1x/macsec: up|down + Selinux string `protobuf:"bytes,9,opt,name=selinux,proto3" json:"selinux,omitempty"` // enforcing|permissive + Chrony string `protobuf:"bytes,10,opt,name=chrony,proto3" json:"chrony,omitempty"` // synced|unsynced + KioskActive bool `protobuf:"varint,11,opt,name=kiosk_active,json=kioskActive,proto3" json:"kiosk_active,omitempty"` + SessionUptime string `protobuf:"bytes,12,opt,name=session_uptime,json=sessionUptime,proto3" json:"session_uptime,omitempty"` // KIOSK session uptime (advisory string) + ChromiumCrashes int32 `protobuf:"varint,13,opt,name=chromium_crashes,json=chromiumCrashes,proto3" json:"chromium_crashes,omitempty"` + Display string `protobuf:"bytes,14,opt,name=display,proto3" json:"display,omitempty"` + UptimeSeconds int64 `protobuf:"varint,15,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` // HOST uptime (seconds since boot) — boot_time = + // manager receive-time - this; drives reboot-pending + // chrony tracking relative to the NTP source (from `chronyc tracking`): + ChronyOffsetSeconds float64 `protobuf:"fixed64,16,opt,name=chrony_offset_seconds,json=chronyOffsetSeconds,proto3" json:"chrony_offset_seconds,omitempty"` // system time offset from NTP (signed, fast=+) + ChronySource string `protobuf:"bytes,17,opt,name=chrony_source,json=chronySource,proto3" json:"chrony_source,omitempty"` // reference source name/IP (which server) + ChronyStratum int32 `protobuf:"varint,18,opt,name=chrony_stratum,json=chronyStratum,proto3" json:"chrony_stratum,omitempty"` // hops from a reference clock (0 = unsynced) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthSnapshot) Reset() { + *x = HealthSnapshot{} + mi := &file_proto_fleet_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthSnapshot) ProtoMessage() {} + +func (x *HealthSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthSnapshot.ProtoReflect.Descriptor instead. +func (*HealthSnapshot) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *HealthSnapshot) GetAtUnix() int64 { + if x != nil { + return x.AtUnix + } + return 0 +} + +func (x *HealthSnapshot) GetLoadAvg() float64 { + if x != nil { + return x.LoadAvg + } + return 0 +} + +func (x *HealthSnapshot) GetCpuPct() float64 { + if x != nil { + return x.CpuPct + } + return 0 +} + +func (x *HealthSnapshot) GetTempC() float64 { + if x != nil { + return x.TempC + } + return 0 +} + +func (x *HealthSnapshot) GetMemUsedPct() float64 { + if x != nil { + return x.MemUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetTmpfsUsedPct() float64 { + if x != nil { + return x.TmpfsUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetNvmeRoot() string { + if x != nil { + return x.NvmeRoot + } + return "" +} + +func (x *HealthSnapshot) GetAuth() string { + if x != nil { + return x.Auth + } + return "" +} + +func (x *HealthSnapshot) GetSelinux() string { + if x != nil { + return x.Selinux + } + return "" +} + +func (x *HealthSnapshot) GetChrony() string { + if x != nil { + return x.Chrony + } + return "" +} + +func (x *HealthSnapshot) GetKioskActive() bool { + if x != nil { + return x.KioskActive + } + return false +} + +func (x *HealthSnapshot) GetSessionUptime() string { + if x != nil { + return x.SessionUptime + } + return "" +} + +func (x *HealthSnapshot) GetChromiumCrashes() int32 { + if x != nil { + return x.ChromiumCrashes + } + return 0 +} + +func (x *HealthSnapshot) GetDisplay() string { + if x != nil { + return x.Display + } + return "" +} + +func (x *HealthSnapshot) GetUptimeSeconds() int64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +func (x *HealthSnapshot) GetChronyOffsetSeconds() float64 { + if x != nil { + return x.ChronyOffsetSeconds + } + return 0 +} + +func (x *HealthSnapshot) GetChronySource() string { + if x != nil { + return x.ChronySource + } + return "" +} + +func (x *HealthSnapshot) GetChronyStratum() int32 { + if x != nil { + return x.ChronyStratum + } + return 0 +} + +type CommandResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandResult) Reset() { + *x = CommandResult{} + mi := &file_proto_fleet_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResult) ProtoMessage() {} + +func (x *CommandResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. +func (*CommandResult) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *CommandResult) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *CommandResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *CommandResult) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +type ManagerDown struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *ManagerDown_Desired + // *ManagerDown_Command + Msg isManagerDown_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ManagerDown) Reset() { + *x = ManagerDown{} + mi := &file_proto_fleet_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ManagerDown) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ManagerDown) ProtoMessage() {} + +func (x *ManagerDown) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ManagerDown.ProtoReflect.Descriptor instead. +func (*ManagerDown) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ManagerDown) GetMsg() isManagerDown_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *ManagerDown) GetDesired() *DesiredState { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Desired); ok { + return x.Desired + } + } + return nil +} + +func (x *ManagerDown) GetCommand() *Command { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Command); ok { + return x.Command + } + } + return nil +} + +type isManagerDown_Msg interface { + isManagerDown_Msg() +} + +type ManagerDown_Desired struct { + Desired *DesiredState `protobuf:"bytes,1,opt,name=desired,proto3,oneof"` // full desired-state: sent on connect + on change +} + +type ManagerDown_Command struct { + Command *Command `protobuf:"bytes,2,opt,name=command,proto3,oneof"` // imperative action +} + +func (*ManagerDown_Desired) isManagerDown_Msg() {} + +func (*ManagerDown_Command) isManagerDown_Msg() {} + +// DesiredState — server-authoritative target the agent reconciles toward. +type DesiredState struct { + state protoimpl.MessageState `protogen:"open.v1"` + TargetVersion string `protobuf:"bytes,1,opt,name=target_version,json=targetVersion,proto3" json:"target_version,omitempty"` + KioskUrl string `protobuf:"bytes,2,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + RebootAtUnix int64 `protobuf:"varint,3,opt,name=reboot_at_unix,json=rebootAtUnix,proto3" json:"reboot_at_unix,omitempty"` // 0 = no scheduled reboot + Drain bool `protobuf:"varint,4,opt,name=drain,proto3" json:"drain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DesiredState) Reset() { + *x = DesiredState{} + mi := &file_proto_fleet_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DesiredState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DesiredState) ProtoMessage() {} + +func (x *DesiredState) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DesiredState.ProtoReflect.Descriptor instead. +func (*DesiredState) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *DesiredState) GetTargetVersion() string { + if x != nil { + return x.TargetVersion + } + return "" +} + +func (x *DesiredState) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *DesiredState) GetRebootAtUnix() int64 { + if x != nil { + return x.RebootAtUnix + } + return 0 +} + +func (x *DesiredState) GetDrain() bool { + if x != nil { + return x.Drain + } + return false +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +type Command struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Kind Command_Kind `protobuf:"varint,2,opt,name=kind,proto3,enum=fleet.agent.v1.Command_Kind" json:"kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Command) Reset() { + *x = Command{} + mi := &file_proto_fleet_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Command) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Command) ProtoMessage() {} + +func (x *Command) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Command.ProtoReflect.Descriptor instead. +func (*Command) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *Command) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *Command) GetKind() Command_Kind { + if x != nil { + return x.Kind + } + return Command_KIND_UNSPECIFIED +} + +var File_proto_fleet_agent_proto protoreflect.FileDescriptor + +const file_proto_fleet_agent_proto_rawDesc = "" + + "\n" + + "\x17proto/fleet_agent.proto\x12\x0efleet.agent.v1\"\xb6\x01\n" + + "\aAgentUp\x12-\n" + + "\x05hello\x18\x01 \x01(\v2\x15.fleet.agent.v1.HelloH\x00R\x05hello\x12<\n" + + "\bsnapshot\x18\x02 \x01(\v2\x1e.fleet.agent.v1.HealthSnapshotH\x00R\bsnapshot\x127\n" + + "\x06result\x18\x03 \x01(\v2\x1d.fleet.agent.v1.CommandResultH\x00R\x06resultB\x05\n" + + "\x03msg\"\xab\x01\n" + + "\x05Hello\x12#\n" + + "\rimage_version\x18\x01 \x01(\tR\fimageVersion\x12#\n" + + "\rrootfs_digest\x18\x02 \x01(\tR\frootfsDigest\x12\x16\n" + + "\x06kernel\x18\x03 \x01(\tR\x06kernel\x12\x1b\n" + + "\tkiosk_url\x18\x04 \x01(\tR\bkioskUrl\x12#\n" + + "\rstarship_name\x18\x05 \x01(\tR\fstarshipName\"\xd5\x04\n" + + "\x0eHealthSnapshot\x12\x17\n" + + "\aat_unix\x18\x01 \x01(\x03R\x06atUnix\x12\x19\n" + + "\bload_avg\x18\x02 \x01(\x01R\aloadAvg\x12\x17\n" + + "\acpu_pct\x18\x03 \x01(\x01R\x06cpuPct\x12\x15\n" + + "\x06temp_c\x18\x04 \x01(\x01R\x05tempC\x12 \n" + + "\fmem_used_pct\x18\x05 \x01(\x01R\n" + + "memUsedPct\x12$\n" + + "\x0etmpfs_used_pct\x18\x06 \x01(\x01R\ftmpfsUsedPct\x12\x1b\n" + + "\tnvme_root\x18\a \x01(\tR\bnvmeRoot\x12\x12\n" + + "\x04auth\x18\b \x01(\tR\x04auth\x12\x18\n" + + "\aselinux\x18\t \x01(\tR\aselinux\x12\x16\n" + + "\x06chrony\x18\n" + + " \x01(\tR\x06chrony\x12!\n" + + "\fkiosk_active\x18\v \x01(\bR\vkioskActive\x12%\n" + + "\x0esession_uptime\x18\f \x01(\tR\rsessionUptime\x12)\n" + + "\x10chromium_crashes\x18\r \x01(\x05R\x0fchromiumCrashes\x12\x18\n" + + "\adisplay\x18\x0e \x01(\tR\adisplay\x12%\n" + + "\x0euptime_seconds\x18\x0f \x01(\x03R\ruptimeSeconds\x122\n" + + "\x15chrony_offset_seconds\x18\x10 \x01(\x01R\x13chronyOffsetSeconds\x12#\n" + + "\rchrony_source\x18\x11 \x01(\tR\fchronySource\x12%\n" + + "\x0echrony_stratum\x18\x12 \x01(\x05R\rchronyStratum\"V\n" + + "\rCommandResult\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x0e\n" + + "\x02ok\x18\x02 \x01(\bR\x02ok\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\"\x83\x01\n" + + "\vManagerDown\x128\n" + + "\adesired\x18\x01 \x01(\v2\x1c.fleet.agent.v1.DesiredStateH\x00R\adesired\x123\n" + + "\acommand\x18\x02 \x01(\v2\x17.fleet.agent.v1.CommandH\x00R\acommandB\x05\n" + + "\x03msg\"\x8e\x01\n" + + "\fDesiredState\x12%\n" + + "\x0etarget_version\x18\x01 \x01(\tR\rtargetVersion\x12\x1b\n" + + "\tkiosk_url\x18\x02 \x01(\tR\bkioskUrl\x12$\n" + + "\x0ereboot_at_unix\x18\x03 \x01(\x03R\frebootAtUnix\x12\x14\n" + + "\x05drain\x18\x04 \x01(\bR\x05drain\"\x9b\x01\n" + + "\aCommand\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x120\n" + + "\x04kind\x18\x02 \x01(\x0e2\x1c.fleet.agent.v1.Command.KindR\x04kind\"?\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\n" + + "\n" + + "\x06REBOOT\x10\x01\x12\t\n" + + "\x05DRAIN\x10\x02\x12\n" + + "\n" + + "\x06RESYNC\x10\x032Q\n" + + "\n" + + "FleetAgent\x12C\n" + + "\aSession\x12\x17.fleet.agent.v1.AgentUp\x1a\x1b.fleet.agent.v1.ManagerDown(\x010\x01B\x1dZ\x1bfleet-manager/proto;fleetpbb\x06proto3" + +var ( + file_proto_fleet_agent_proto_rawDescOnce sync.Once + file_proto_fleet_agent_proto_rawDescData []byte +) + +func file_proto_fleet_agent_proto_rawDescGZIP() []byte { + file_proto_fleet_agent_proto_rawDescOnce.Do(func() { + file_proto_fleet_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc))) + }) + return file_proto_fleet_agent_proto_rawDescData +} + +var file_proto_fleet_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_proto_fleet_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_proto_fleet_agent_proto_goTypes = []any{ + (Command_Kind)(0), // 0: fleet.agent.v1.Command.Kind + (*AgentUp)(nil), // 1: fleet.agent.v1.AgentUp + (*Hello)(nil), // 2: fleet.agent.v1.Hello + (*HealthSnapshot)(nil), // 3: fleet.agent.v1.HealthSnapshot + (*CommandResult)(nil), // 4: fleet.agent.v1.CommandResult + (*ManagerDown)(nil), // 5: fleet.agent.v1.ManagerDown + (*DesiredState)(nil), // 6: fleet.agent.v1.DesiredState + (*Command)(nil), // 7: fleet.agent.v1.Command +} +var file_proto_fleet_agent_proto_depIdxs = []int32{ + 2, // 0: fleet.agent.v1.AgentUp.hello:type_name -> fleet.agent.v1.Hello + 3, // 1: fleet.agent.v1.AgentUp.snapshot:type_name -> fleet.agent.v1.HealthSnapshot + 4, // 2: fleet.agent.v1.AgentUp.result:type_name -> fleet.agent.v1.CommandResult + 6, // 3: fleet.agent.v1.ManagerDown.desired:type_name -> fleet.agent.v1.DesiredState + 7, // 4: fleet.agent.v1.ManagerDown.command:type_name -> fleet.agent.v1.Command + 0, // 5: fleet.agent.v1.Command.kind:type_name -> fleet.agent.v1.Command.Kind + 1, // 6: fleet.agent.v1.FleetAgent.Session:input_type -> fleet.agent.v1.AgentUp + 5, // 7: fleet.agent.v1.FleetAgent.Session:output_type -> fleet.agent.v1.ManagerDown + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_proto_fleet_agent_proto_init() } +func file_proto_fleet_agent_proto_init() { + if File_proto_fleet_agent_proto != nil { + return + } + file_proto_fleet_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*AgentUp_Hello)(nil), + (*AgentUp_Snapshot)(nil), + (*AgentUp_Result)(nil), + } + file_proto_fleet_agent_proto_msgTypes[4].OneofWrappers = []any{ + (*ManagerDown_Desired)(nil), + (*ManagerDown_Command)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_fleet_agent_proto_goTypes, + DependencyIndexes: file_proto_fleet_agent_proto_depIdxs, + EnumInfos: file_proto_fleet_agent_proto_enumTypes, + MessageInfos: file_proto_fleet_agent_proto_msgTypes, + }.Build() + File_proto_fleet_agent_proto = out.File + file_proto_fleet_agent_proto_goTypes = nil + file_proto_fleet_agent_proto_depIdxs = nil +} diff --git a/files/agent/proto/fleet_agent.proto b/files/agent/proto/fleet_agent.proto new file mode 100644 index 0000000..72dd02d --- /dev/null +++ b/files/agent/proto/fleet_agent.proto @@ -0,0 +1,102 @@ +syntax = "proto3"; + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. +package fleet.agent.v1; + +option go_package = "fleet-manager/proto;fleetpb"; + +service FleetAgent { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + rpc Session(stream AgentUp) returns (stream ManagerDown); +} + +// ---- host -> manager ------------------------------------------------------ + +message AgentUp { + oneof msg { + Hello hello = 1; // first frame after connect + HealthSnapshot snapshot = 2; // periodic telemetry (also written to InfluxDB) + CommandResult result = 3; // outcome of a ManagerDown.Command + } +} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +message Hello { + string image_version = 1; + string rootfs_digest = 2; + string kernel = 3; + string kiosk_url = 4; + string starship_name = 5; // advisory; manager already knows it from inventory +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +message HealthSnapshot { + int64 at_unix = 1; // sample time, unix seconds (0 = manager stamps now) + double load_avg = 2; + double cpu_pct = 3; + double temp_c = 4; + double mem_used_pct = 5; + double tmpfs_used_pct = 6; // overlay RAM pressure + string nvme_root = 7; // ok|degraded|down + string auth = 8; // 802.1x/macsec: up|down + string selinux = 9; // enforcing|permissive + string chrony = 10; // synced|unsynced + bool kiosk_active = 11; + string session_uptime = 12; // KIOSK session uptime (advisory string) + int32 chromium_crashes = 13; + string display = 14; + int64 uptime_seconds = 15; // HOST uptime (seconds since boot) — boot_time = + // manager receive-time - this; drives reboot-pending + // chrony tracking relative to the NTP source (from `chronyc tracking`): + double chrony_offset_seconds = 16; // system time offset from NTP (signed, fast=+) + string chrony_source = 17; // reference source name/IP (which server) + int32 chrony_stratum = 18; // hops from a reference clock (0 = unsynced) +} + +message CommandResult { + string command_id = 1; + bool ok = 2; + string detail = 3; +} + +// ---- manager -> host ------------------------------------------------------ + +message ManagerDown { + oneof msg { + DesiredState desired = 1; // full desired-state: sent on connect + on change + Command command = 2; // imperative action + } +} + +// DesiredState — server-authoritative target the agent reconciles toward. +message DesiredState { + string target_version = 1; + string kiosk_url = 2; + int64 reboot_at_unix = 3; // 0 = no scheduled reboot + bool drain = 4; +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +message Command { + string command_id = 1; + Kind kind = 2; + + enum Kind { + KIND_UNSPECIFIED = 0; + REBOOT = 1; // reboot now (into desired target_version) + DRAIN = 2; // stop taking work / prepare for maintenance + RESYNC = 3; // re-fetch + re-apply desired-state now + } +} diff --git a/files/agent/proto/fleet_agent_grpc.pb.go b/files/agent/proto/fleet_agent_grpc.pb.go new file mode 100644 index 0000000..7e42882 --- /dev/null +++ b/files/agent/proto/fleet_agent_grpc.pb.go @@ -0,0 +1,130 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FleetAgent_Session_FullMethodName = "/fleet.agent.v1.FleetAgent/Session" +) + +// FleetAgentClient is the client API for FleetAgent service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type FleetAgentClient interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) +} + +type fleetAgentClient struct { + cc grpc.ClientConnInterface +} + +func NewFleetAgentClient(cc grpc.ClientConnInterface) FleetAgentClient { + return &fleetAgentClient{cc} +} + +func (c *fleetAgentClient) Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FleetAgent_ServiceDesc.Streams[0], FleetAgent_Session_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentUp, ManagerDown]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionClient = grpc.BidiStreamingClient[AgentUp, ManagerDown] + +// FleetAgentServer is the server API for FleetAgent service. +// All implementations must embed UnimplementedFleetAgentServer +// for forward compatibility. +type FleetAgentServer interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error + mustEmbedUnimplementedFleetAgentServer() +} + +// UnimplementedFleetAgentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFleetAgentServer struct{} + +func (UnimplementedFleetAgentServer) Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error { + return status.Error(codes.Unimplemented, "method Session not implemented") +} +func (UnimplementedFleetAgentServer) mustEmbedUnimplementedFleetAgentServer() {} +func (UnimplementedFleetAgentServer) testEmbeddedByValue() {} + +// UnsafeFleetAgentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to FleetAgentServer will +// result in compilation errors. +type UnsafeFleetAgentServer interface { + mustEmbedUnimplementedFleetAgentServer() +} + +func RegisterFleetAgentServer(s grpc.ServiceRegistrar, srv FleetAgentServer) { + // If the following call panics, it indicates UnimplementedFleetAgentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&FleetAgent_ServiceDesc, srv) +} + +func _FleetAgent_Session_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(FleetAgentServer).Session(&grpc.GenericServerStream[AgentUp, ManagerDown]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionServer = grpc.BidiStreamingServer[AgentUp, ManagerDown] + +// FleetAgent_ServiceDesc is the grpc.ServiceDesc for FleetAgent service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var FleetAgent_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "fleet.agent.v1.FleetAgent", + HandlerType: (*FleetAgentServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Session", + Handler: _FleetAgent_Session_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "proto/fleet_agent.proto", +} diff --git a/files/agent/simtest/go.mod b/files/agent/simtest/go.mod new file mode 100644 index 0000000..303b2fa --- /dev/null +++ b/files/agent/simtest/go.mod @@ -0,0 +1,21 @@ +// fleet-agent-simtest — a SEPARATE module so the go-tpm software simulator (CGO + +// the MS TPM reference C) and go-tpm-tools stay OUT of the shipped fleet-agent +// module. Run from this dir: +// go test ./... (needs CGO + a C toolchain for the simulator) +// It proves the agent's reused TPM identity (tpmkey.LoadSigner) completes a real +// mTLS client-auth handshake against a server that RequireAndVerifyClientCert — +// exactly what the fleet-manager agent plane does — so the go-tpm signer is proven +// in the TLS path off-hardware without bloating the image build. +module fleet-agent-simtest + +go 1.26 + +require ( + fleet-agent v0.0.0 + github.com/google/go-tpm v0.9.8 + github.com/google/go-tpm-tools v0.4.9 +) + +require golang.org/x/sys v0.43.0 // indirect + +replace fleet-agent => ../ diff --git a/files/agent/simtest/go.sum b/files/agent/simtest/go.sum new file mode 100644 index 0000000..f53674e --- /dev/null +++ b/files/agent/simtest/go.sum @@ -0,0 +1,26 @@ +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007 h1:DoeEFwEGBdqcawmpiWtSsSVVZ+wk3zpqvcvssO2JLmY= +github.com/GoogleCloudPlatform/confidential-space/server v0.0.0-20260522213940-e5c6d01a3007/go.mod h1:s8F0JYEods/WL03WxZaGsWCnumZeeLD+WKHzspOV9u0= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc h1:SG12DWUUM5igxm+//YX5Yq4vhdoRnOG9HkCodkOn+YU= +github.com/google/go-configfs-tsm v0.3.3-0.20240919001351-b4b5b84fdcbc/go.mod h1:EL1GTDFMb5PZQWDviGfZV9n87WeGTR/JUg13RfwkgRo= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0 h1:STyioPkz8nqMMIk3+YlyJ/WyEJZxho1YUZXu99uAbQ0= +github.com/google/go-eventlog v0.0.3-0.20260416001248-6807b85eecf0/go.mod h1:7huE5P8w2NTObSwSJjboHmB7ioBNblkijdzoVa2skfQ= +github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo= +github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d h1:Ff8goEP/ue2/rZT5qyoRicuySCYDbAXEZS8Cf1fgsUo= +github.com/google/go-tdx-guest v0.3.2-0.20250814004405-ffb0869e6f4d/go.mod h1:uHy3VaNXNXhl0fiPxKqTxieeouqQmW6A0EfLcaeCYBk= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.4.9 h1:jZEhnE4WRFbomSssBH2gWaIViIHU1gjH1jz76+xC9bI= +github.com/google/go-tpm-tools v0.4.9/go.mod h1:Omb8zosA8qY9URn1gsrO2i4b6DFqGp29BqNx18V66c4= +github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ= +github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/files/agent/simtest/tpm_tls_test.go b/files/agent/simtest/tpm_tls_test.go new file mode 100644 index 0000000..495d181 --- /dev/null +++ b/files/agent/simtest/tpm_tls_test.go @@ -0,0 +1,207 @@ +// Package simtest validates the agent's reused-TPM mTLS identity against the +// go-tpm software simulator (the MS TPM 2.0 reference). It lives in its own module +// so the CGO simulator never enters the shipped fleet-agent build; see go.mod. +// +// The crux it proves: crypto/tls can drive tpmkey's go-tpm crypto.Signer to sign +// the TLS CertificateVerify, so a host holding ONLY a TPM key (no exportable +// private key) authenticates to a server doing RequireAndVerifyClientCert against +// the fleet CA — the exact posture of the fleet-manager agent plane (agent.go). +package simtest + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "testing" + "time" + + "fleet-agent/tpmkey" + + "github.com/google/go-tpm-tools/simulator" + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +func openSim(t *testing.T) transport.TPM { + t.Helper() + sim, err := simulator.Get() + if err != nil { + t.Fatalf("simulator: %v", err) + } + t.Cleanup(func() { sim.Close() }) + return transport.FromReadWriter(sim) +} + +// testCA is a throwaway fleet CA for the handshake. +type testCA struct { + cert *x509.Certificate + key *ecdsa.PrivateKey + pool *x509.CertPool +} + +func newTestCA(t *testing.T) *testCA { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test fleet CA"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) + if err != nil { + t.Fatal(err) + } + cert, _ := x509.ParseCertificate(der) + pool := x509.NewCertPool() + pool.AddCert(cert) + return &testCA{cert: cert, key: key, pool: pool} +} + +// issue signs a leaf cert for pub with the CA (as the enrol-broker/step-ca does). +func (ca *testCA) issue(t *testing.T, tmpl *x509.Certificate, pub crypto.PublicKey) []byte { + t.Helper() + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, pub, ca.key) + if err != nil { + t.Fatalf("CA issue: %v", err) + } + return der +} + +func TestTPMClientCertHandshake(t *testing.T) { + tpm := openSim(t) + const ( + keyHandle = tpm2.TPMHandle(0x81000100) + nvIndex = tpm2.TPMHandle(0x01800100) + ) + + // Enrolment side: create the persistent signing key (once). + if _, _, err := tpmkey.EnsureKey(tpm, keyHandle); err != nil { + t.Fatalf("EnsureKey: %v", err) + } + // Agent side: load it read-only (the function the agent actually uses). + signer, err := tpmkey.LoadSigner(tpm, keyHandle) + if err != nil { + t.Fatalf("LoadSigner: %v", err) + } + + ca := newTestCA(t) + + // The host's identity cert (CA-signed over the TPM public key). Its SERIAL is + // the value the manager derives as the inventory key. + wantSerial := big.NewInt(0xC0FFEE) + leafDER := ca.issue(t, &x509.Certificate{ + SerialNumber: wantSerial, + Subject: pkix.Name{CommonName: "fleet-host"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + }, signer.Public()) + + // Exercise the NV round-trip the agent's identity path uses. + if err := tpmkey.WriteNVCert(tpm, nvIndex, leafDER); err != nil { + t.Fatalf("WriteNVCert: %v", err) + } + got, err := tpmkey.ReadNVCert(tpm, nvIndex) + if err != nil { + t.Fatalf("ReadNVCert: %v", err) + } + if string(got) != string(leafDER) { + t.Fatalf("NV cert round-trip mismatch") + } + leaf, _ := x509.ParseCertificate(leafDER) + + // TPM-backed client cert (private key = the TPM signer; never exported). + clientCert := tls.Certificate{Certificate: [][]byte{leafDER}, PrivateKey: signer, Leaf: leaf} + + // Server standing in for the manager agent plane: verify the client to the CA. + serverKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + serverDER := ca.issue(t, &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "fleet-manager"}, + DNSNames: []string{"fleet-manager"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + }, serverKey.Public()) + serverCfg := &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{serverDER}, PrivateKey: serverKey}}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: ca.pool, + MinVersion: tls.VersionTLS12, + } + clientCfg := &tls.Config{ + Certificates: []tls.Certificate{clientCert}, + RootCAs: ca.pool, + ServerName: "fleet-manager", + MinVersion: tls.VersionTLS12, + } + + ln, err := tls.Listen("tcp", "127.0.0.1:0", serverCfg) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + serialCh := make(chan *big.Int, 1) + errCh := make(chan error, 1) + go func() { + conn, err := ln.Accept() + if err != nil { + errCh <- err + return + } + defer conn.Close() + tc := conn.(*tls.Conn) + tc.SetDeadline(time.Now().Add(5 * time.Second)) + if err := tc.Handshake(); err != nil { + errCh <- err + return + } + st := tc.ConnectionState() + if len(st.PeerCertificates) == 0 { + errCh <- err + return + } + serialCh <- st.PeerCertificates[0].SerialNumber + }() + + conn, err := tls.Dial("tcp", ln.Addr().String(), clientCfg) + if err != nil { + t.Fatalf("client handshake with TPM cert failed: %v", err) + } + defer conn.Close() + + select { + case err := <-errCh: + t.Fatalf("server rejected TPM client cert: %v", err) + case serial := <-serialCh: + if serial.Cmp(wantSerial) != 0 { + t.Fatalf("server saw serial %x, want %x", serial, wantSerial) + } + case <-time.After(5 * time.Second): + t.Fatal("handshake timed out") + } +} + +// TestLoadSignerRejectsEmptyHandle proves the agent fails clearly on a host that +// isn't enrolled (no key at the handle) rather than creating one. +func TestLoadSignerRejectsEmptyHandle(t *testing.T) { + tpm := openSim(t) + if _, err := tpmkey.LoadSigner(tpm, tpm2.TPMHandle(0x81000100)); err == nil { + t.Fatal("LoadSigner on an empty handle should error (agent must not create a key)") + } +} diff --git a/files/agent/snapshot.go b/files/agent/snapshot.go new file mode 100644 index 0000000..f961838 --- /dev/null +++ b/files/agent/snapshot.go @@ -0,0 +1,399 @@ +// snapshot.go — read-only host health probes → *fleetpb.HealthSnapshot, plus the +// hostapi facts fetch used to build the connect-time Hello. +// +// Every probe degrades gracefully: on any error the field is left at its zero +// value ("" / 0) rather than failing. A probe must NEVER kill the stream — a +// missing thermal zone or an unmounted selinuxfs is normal on some targets/VMs. +// Health METRICS (load/cpu/mem/temp/nvme/auth/selinux) are read straight from +// /proc + /sys; host FACTS (image/kernel/hostname/kiosk) are reused from +// fleet-hostapi (127.0.0.1:9099) so we don't re-probe DMI/GPU. +package main + +import ( + "context" + "encoding/json" + "net/http" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + fleetpb "fleet-agent/proto" +) + +// hostFacts is the subset of fleet-hostapi's /v1/host response the agent uses. +type hostFacts struct { + OS struct { + Kernel string `json:"kernel"` + Image string `json:"image"` + } `json:"os"` + Net struct { + Hostname string `json:"hostname"` + } `json:"net"` + Kiosk struct { + Uptime string `json:"uptime"` + Chromium string `json:"chromium"` + } `json:"kiosk"` +} + +// fetchFacts GETs fleet-hostapi over loopback. Best-effort: returns a zero struct +// on any error (the agent still streams; Hello/snapshot fields just stay empty). +func fetchFacts(ctx context.Context, url string) hostFacts { + var f hostFacts + if url == "" { + return f + } + rctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(rctx, http.MethodGet, url, nil) + if err != nil { + return f + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return f + } + defer resp.Body.Close() + _ = json.NewDecoder(resp.Body).Decode(&f) + return f +} + +// buildHello assembles the connect-time Hello from hostapi facts + the kiosk URL +// + the verity root hash (best-effort). starship_name is advisory (the manager +// already knows it from inventory). +func buildHello(f hostFacts, kioskURL string) *fleetpb.Hello { + return &fleetpb.Hello{ + ImageVersion: f.OS.Image, + RootfsDigest: rootfsDigest(), + Kernel: f.OS.Kernel, + KioskUrl: kioskURL, + StarshipName: f.Net.Hostname, + } +} + +// prober carries the small state needed for rate metrics (the CPU busy delta +// between two /proc/stat samples). One prober is reused across the connection. +type prober struct { + hostapiURL string + prevTotal uint64 + prevIdle uint64 + hasPrev bool +} + +// snapshot gathers one HealthSnapshot. at_unix is left 0 so the manager stamps +// receive-time (matches agent.go's snapshotToHealth path). +func (p *prober) snapshot(ctx context.Context) *fleetpb.HealthSnapshot { + f := fetchFacts(ctx, p.hostapiURL) + ch := chronyTracking() + s := &fleetpb.HealthSnapshot{ + LoadAvg: loadAvg(), + CpuPct: p.cpuPct(), + TempC: tempC(), + MemUsedPct: memUsedPct(), + TmpfsUsedPct: fsUsedPct("/"), + NvmeRoot: nvmeRoot(), + Auth: authState(), + Selinux: selinuxState(), + Chrony: ch.state, + ChronyOffsetSeconds: ch.offset, + ChronySource: ch.source, + ChronyStratum: ch.stratum, + KioskActive: unitActive("kiosk.service"), + SessionUptime: f.Kiosk.Uptime, + UptimeSeconds: uptimeSeconds(), + } + return s +} + +// uptimeSeconds returns host uptime (seconds since boot) from /proc/uptime. The +// manager derives boot_time = receive-time - this to tell whether a scheduled +// reboot has been actioned yet. +func uptimeSeconds() int64 { + b, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + return parseUptime(b) +} + +func parseUptime(b []byte) int64 { + fields := strings.Fields(string(b)) + if len(fields) == 0 { + return 0 + } + f, _ := strconv.ParseFloat(fields[0], 64) + if f < 0 { + return 0 + } + return int64(f) +} + +// ---- individual probes (all best-effort) ---------------------------------- + +func loadAvg() float64 { + b, err := os.ReadFile("/proc/loadavg") + if err != nil { + return 0 + } + return parseLoadAvg(b) +} + +// parseLoadAvg extracts the 1-minute load from /proc/loadavg content. +func parseLoadAvg(b []byte) float64 { + fields := strings.Fields(string(b)) + if len(fields) == 0 { + return 0 + } + v, _ := strconv.ParseFloat(fields[0], 64) + return v +} + +// cpuPct returns the busy percentage since the previous sample. The first call +// (no prior sample) returns 0 and primes the delta. +func (p *prober) cpuPct() float64 { + total, idle, ok := readCPU() + if !ok { + return 0 + } + defer func() { p.prevTotal, p.prevIdle, p.hasPrev = total, idle, true }() + if !p.hasPrev { + return 0 + } + return cpuBusyPct(p.prevTotal, p.prevIdle, total, idle) +} + +// cpuBusyPct computes the busy fraction between two /proc/stat samples. +func cpuBusyPct(prevTotal, prevIdle, total, idle uint64) float64 { + if total <= prevTotal { + return 0 + } + dTotal := total - prevTotal + dIdle := min(idle-prevIdle, dTotal) + return float64(dTotal-dIdle) / float64(dTotal) * 100 +} + +// readCPU parses the aggregate "cpu" line of /proc/stat into total + idle jiffies. +func readCPU() (total, idle uint64, ok bool) { + b, err := os.ReadFile("/proc/stat") + if err != nil { + return 0, 0, false + } + return parseCPUStat(b) +} + +func parseCPUStat(b []byte) (total, idle uint64, ok bool) { + for line := range strings.SplitSeq(string(b), "\n") { + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line)[1:] // user nice system idle iowait irq softirq steal ... + for i, f := range fields { + v, err := strconv.ParseUint(f, 10, 64) + if err != nil { + continue + } + total += v + if i == 3 || i == 4 { // idle + iowait + idle += v + } + } + return total, idle, true + } + return 0, 0, false +} + +func memUsedPct() float64 { + b, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0 + } + return parseMemUsedPct(b) +} + +func parseMemUsedPct(b []byte) float64 { + var totalKB, availKB float64 + for line := range strings.SplitSeq(string(b), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + v, _ := strconv.ParseFloat(fields[1], 64) + switch fields[0] { + case "MemTotal:": + totalKB = v + case "MemAvailable:": + availKB = v + } + } + if totalKB == 0 { + return 0 + } + return (totalKB - availKB) / totalKB * 100 +} + +// fsUsedPct returns used-space percentage of the filesystem at path (the root +// overlay = tmpfs RAM pressure). +func fsUsedPct(path string) float64 { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil || st.Blocks == 0 { + return 0 + } + used := st.Blocks - st.Bfree + return float64(used) / float64(st.Blocks) * 100 +} + +// tempC returns the first readable thermal zone in Celsius (sysfs reports milli-C). +func tempC() float64 { + for i := range 16 { + b, err := os.ReadFile("/sys/class/thermal/thermal_zone" + strconv.Itoa(i) + "/temp") + if err != nil { + if i == 0 { + continue // zone0 may be absent; keep scanning a few + } + break + } + milli, err := strconv.ParseFloat(strings.TrimSpace(string(b)), 64) + if err == nil && milli > 0 { + return milli / 1000 + } + } + return 0 +} + +// nvmeRoot reports the verity root device health: ok if /dev/mapper/root exists, +// down otherwise. (A finer degraded state needs nvme-cli; v1 keeps it binary.) +func nvmeRoot() string { + if _, err := os.Stat("/dev/mapper/root"); err == nil { + return "ok" + } + return "down" +} + +// authState reports the 802.1X/MACsec link: up if macsec0 has carrier (secure +// path), "open" when there is no macsec0 (test/open-network path rd.dot1x=0). +func authState() string { + if _, err := os.Stat("/sys/class/net/macsec0"); err != nil { + return "open" + } + if b, err := os.ReadFile("/sys/class/net/macsec0/carrier"); err == nil && strings.TrimSpace(string(b)) == "1" { + return "up" + } + return "down" +} + +// selinuxState reads the enforce node; absent selinuxfs => "" (unknown). +func selinuxState() string { + b, err := os.ReadFile("/sys/fs/selinux/enforce") + if err != nil { + return "" + } + if strings.TrimSpace(string(b)) == "1" { + return "enforcing" + } + return "permissive" +} + +// chrony holds the parsed chrony tracking fields relative to the NTP source. +type chrony struct { + state string // synced|unsynced|"" (unavailable) + offset float64 // system time offset from NTP, seconds (signed: fast=+, slow=-) + source string // reference source name/IP (which server) + stratum int32 // hops from a reference clock +} + +// chronyLogPath is chrony's tracking log — enabled by `log tracking` in +// chrony.conf; chronyd appends one line per clock update. +const chronyLogPath = "/var/log/chrony/tracking.log" + +// chronyTracking reads the LAST line of chrony's tracking.log and parses the +// source/stratum/offset. Best-effort: a zero chrony{} if the log is +// absent/unreadable/empty — never fails the snapshot. +// +// We read a FILE rather than query chronyd's command socket by design: that unix +// socket is owner-write-only (0755) so the hardened non-root agent (DynamicUser, +// not the chrony user) can't connect, and STIG closes the UDP command port +// (cmdport 0). `log tracking` writes the same fields to this log and no domain +// transition is needed. Two access layers must both be satisfied (the unit does): +// the chrony group to TRAVERSE /var/log/chrony (0750) at the DAC layer, and the +// fleet_agent_chronylog SELinux module to read the chronyd_var_log_t label under +// enforcing. Tradeoff: the offset is as fresh as chrony's last update (up to +// ~maxpoll old), which is fine for a slow-moving health metric. +func chronyTracking() chrony { + b, err := os.ReadFile(chronyLogPath) + if err != nil { + return chrony{} + } + return parseTrackingLog(b) +} + +// parseTrackingLog extracts source/stratum/offset/sync-state from the LAST data +// line of chrony's tracking.log. Pure (unit-testable). +// +// Column layout (chrony `log tracking`; header written by logbanner): +// +// Date Time IP-Address St Freq Skew Offset L Co Offset-sd Rem-corr Root-delay Root-disp +// f[0] f[1] f[2] f[3] f[4] f[5] f[6] f[7] ... +// +// Offset (f[6]) carries chrony's sign convention (ahead/fast = +), matching our +// chrony_offset_seconds field. L (f[7]) is the leap/sync flag: N/+/- = synchronised, +// '?' = not synchronised. Header/banner lines (first field not a digit) are skipped; +// an unsynchronised reference logs 0.0.0.0, reported as an empty source. +// BENCH-VERIFY the offset sign against `chronyc tracking` on a synced host. +func parseTrackingLog(data []byte) chrony { + lines := strings.Split(string(data), "\n") + for i := len(lines) - 1; i >= 0; i-- { + f := strings.Fields(lines[i]) + if len(f) < 8 || f[0] == "" || f[0][0] < '0' || f[0][0] > '9' { + continue // blank, header ("Date ..."), or banner ("=====") + } + c := chrony{state: "unsynced"} + if f[7] != "?" { + c.state = "synced" + } + if f[2] != "0.0.0.0" { + c.source = f[2] + } + if n, err := strconv.Atoi(f[3]); err == nil { + c.stratum = int32(n) + } + if v, err := strconv.ParseFloat(f[6], 64); err == nil { + c.offset = v + } + return c + } + return chrony{} +} + +// unitActive reports whether a systemd unit is active (best-effort). +func unitActive(unit string) bool { + out, err := runShort("systemctl", "is-active", unit) + return err == nil && strings.TrimSpace(out) == "active" +} + +// rootfsDigest best-effort: the dm-verity root hash from the kernel cmdline (the +// content-address of the running root). Empty if not present in this layout. +func rootfsDigest() string { + b, err := os.ReadFile("/proc/cmdline") + if err != nil { + return "" + } + for tok := range strings.FieldsSeq(string(b)) { + for _, k := range []string{"systemd.verity_root_hash=", "roothash="} { + if after, ok := strings.CutPrefix(tok, k); ok { + return after + } + } + } + return "" +} + +// runShort runs a command with a short timeout and returns its stdout. Used only +// for the best-effort chrony/systemd probes; a missing binary is a soft failure. +func runShort(name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, name, args...).Output() + return string(out), err +} diff --git a/files/agent/snapshot_test.go b/files/agent/snapshot_test.go new file mode 100644 index 0000000..eeaa67d --- /dev/null +++ b/files/agent/snapshot_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "math" + "os" + "testing" +) + +func approx(a, b float64) bool { return math.Abs(a-b) < 0.01 } + +func TestParseLoadAvg(t *testing.T) { + got := parseLoadAvg([]byte("0.42 0.31 0.19 1/234 5678\n")) + if !approx(got, 0.42) { + t.Fatalf("load = %v, want 0.42", got) + } + if v := parseLoadAvg([]byte("")); v != 0 { + t.Fatalf("empty load = %v, want 0", v) + } +} + +func TestParseCPUStat(t *testing.T) { + total, idle, ok := parseCPUStat([]byte("cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4\n")) + if !ok { + t.Fatal("parseCPUStat not ok") + } + // total = 100+0+50+800+50 = 1000; idle = idle(800)+iowait(50) = 850. + if total != 1000 || idle != 850 { + t.Fatalf("total=%d idle=%d, want 1000/850", total, idle) + } +} + +func TestCPUBusyPct(t *testing.T) { + // From (total 1000, idle 850) to (total 1100, idle 900): dTotal=100, dIdle=50 + // → busy = 50/100 = 50%. + if got := cpuBusyPct(1000, 850, 1100, 900); !approx(got, 50) { + t.Fatalf("busy = %v, want 50", got) + } + // No forward progress → 0 (guards a counter reset / same sample). + if got := cpuBusyPct(1000, 850, 1000, 850); got != 0 { + t.Fatalf("no-delta busy = %v, want 0", got) + } + // idle jumping more than total is clamped, never negative. + if got := cpuBusyPct(1000, 850, 1100, 1000); got != 0 { + t.Fatalf("clamped busy = %v, want 0", got) + } +} + +func TestParseMemUsedPct(t *testing.T) { + // total 1000, available 250 → used 75%. + mem := []byte("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n") + if got := parseMemUsedPct(mem); !approx(got, 75) { + t.Fatalf("mem used = %v, want 75", got) + } + if got := parseMemUsedPct([]byte("garbage")); got != 0 { + t.Fatalf("garbage mem = %v, want 0", got) + } +} + +func TestParseUptime(t *testing.T) { + if got := parseUptime([]byte("12345.67 98765.43\n")); got != 12345 { + t.Fatalf("uptime = %d, want 12345", got) + } + if got := parseUptime([]byte("")); got != 0 { + t.Fatalf("empty uptime = %d, want 0", got) + } + if got := parseUptime([]byte("garbage")); got != 0 { + t.Fatalf("garbage uptime = %d, want 0", got) + } +} + +func TestParseTrackingLog(t *testing.T) { + // A realistic tracking.log: banner + header + two data lines. parseTrackingLog + // must take the LAST data line, skip banner/header, and read source/stratum/offset. + log := `==================================================================== + Date (UTC) Time IP Address St Freq ppm Skew ppm Offset L Co Offset sd +==================================================================== +2026-07-10 05:59:00 192.168.50.10 3 -1.234 0.100 +0.000002000 N 4 1.0e-07 +2026-07-10 06:00:00 192.168.50.10 3 -1.250 0.090 -0.000012345 N 4 1.0e-07 +` + c := parseTrackingLog([]byte(log)) + if c.state != "synced" { + t.Errorf("state = %q, want synced", c.state) + } + if c.source != "192.168.50.10" { + t.Errorf("source = %q, want 192.168.50.10", c.source) + } + if c.stratum != 3 { + t.Errorf("stratum = %d, want 3", c.stratum) + } + // last line's Offset is -0.000012345 (sign-sensitive: must not read the +2e-6 above). + if c.offset >= 0 || math.Abs(c.offset+0.000012345) > 1e-9 { + t.Errorf("offset = %v, want -0.000012345", c.offset) + } + // unsynchronised reference: L='?' and 0.0.0.0 source. + uns := "2026-07-10 06:01:00 0.0.0.0 0 0.000 0.000 +0.000000000 ? 0 0.0e+00\n" + if c := parseTrackingLog([]byte(uns)); c.state != "unsynced" || c.source != "" { + t.Errorf("unsynced parse = %+v, want state=unsynced source=''", c) + } + // empty / header-only -> zero chrony{}. + if c := parseTrackingLog([]byte(" Date (UTC) Time IP Address\n")); c.state != "" { + t.Errorf("header-only parse = %+v, want zero", c) + } +} + +func TestParseHandle(t *testing.T) { + for _, tc := range []struct { + in string + want uint32 + bad bool + }{ + {"0x81000100", 0x81000100, false}, + {"81000100", 0x81000100, false}, + {"0X01800100", 0x01800100, false}, + {"nope", 0, true}, + } { + h, err := parseHandle(tc.in) + if tc.bad { + if err == nil { + t.Errorf("parseHandle(%q) = %#x, want error", tc.in, uint32(h)) + } + continue + } + if err != nil || uint32(h) != tc.want { + t.Errorf("parseHandle(%q) = %#x, %v; want %#x", tc.in, uint32(h), err, tc.want) + } + } +} + +func TestHostOf(t *testing.T) { + for _, tc := range []struct{ in, want string }{ + {"diskless-server.bitasylum.org:9600", "diskless-server.bitasylum.org"}, + {"host", "host"}, + {"[2001:db8::1]:9600", "2001:db8::1"}, + } { + if got := hostOf(tc.in); got != tc.want { + t.Errorf("hostOf(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestReadKioskURL(t *testing.T) { + dir := t.TempDir() + f := dir + "/kiosk.env" + if err := os.WriteFile(f, []byte("# comment\nKIOSK_URL=\"http://boot.fleet.lab/kiosk/\"\nOTHER=x\n"), 0o644); err != nil { + t.Fatal(err) + } + if got := readKioskURL(f); got != "http://boot.fleet.lab/kiosk/" { + t.Fatalf("kiosk url = %q", got) + } + if got := readKioskURL(dir + "/missing"); got != "" { + t.Fatalf("missing kiosk env = %q, want empty", got) + } +} diff --git a/files/agent/tpmkey/tpmkey.go b/files/agent/tpmkey/tpmkey.go new file mode 100644 index 0000000..189c892 --- /dev/null +++ b/files/agent/tpmkey/tpmkey.go @@ -0,0 +1,304 @@ +// Package tpmkey — TPM 2.0 operations via go-tpm (pure Go, no CGO, no tpm2-tools +// / no openssl tpm2 provider). Provides: ensure a persistent ECDSA signing key +// at a non-reserved owner handle (default 0x81000100 — NOT the EK slot +// 0x81010001, see EnsureKey), a crypto.Signer backed by it (for CSR signing), +// and NV cert read-presence + write at 0x01800100. Talks to any transport.TPM, +// so the same +// code runs against /dev/tpmrm0 (production) and the software simulator (tests). +// +// Exported so the ceremony can be tested against the go-tpm simulator from a +// SEPARATE module (simtest/), keeping this module's vendored deps lean (go-tpm +// only) — the simulator (CGO + the MS TPM reference C) never enters the image build. +// +// NOTE: this is a COPY of files/enrol/tpmkey — kept byte-identical apart from the +// added read-only LoadSigner — so the reused-identity agent has the exact same, +// bench-verified TPM signing path without importing across the load-bearing +// fleet-enrol module. The agent only READS the key (LoadSigner) + NV cert +// (ReadNVCert); it never creates/evicts. A future refactor could promote tpmkey to +// a shared module depended on by both binaries. +package tpmkey + +import ( + "crypto" + "crypto/ecdsa" + "encoding/asn1" + "fmt" + "io" + "math/big" + + "github.com/google/go-tpm/tpm2" + "github.com/google/go-tpm/tpm2/transport" +) + +// eccSigningTemplate: a NON-restricted ECC P-256 ECDSA-SHA256 signing key. +// Non-restricted so it can sign an externally-computed digest (the CSR TBS) with +// a NULL validation ticket; Symmetric/KDF are explicitly NULL (sign-only key). +func eccSigningTemplate() tpm2.TPMTPublic { + return tpm2.TPMTPublic{ + Type: tpm2.TPMAlgECC, + NameAlg: tpm2.TPMAlgSHA256, + ObjectAttributes: tpm2.TPMAObject{ + FixedTPM: true, + FixedParent: true, + SensitiveDataOrigin: true, + UserWithAuth: true, + SignEncrypt: true, + NoDA: true, + }, + Parameters: tpm2.NewTPMUPublicParms(tpm2.TPMAlgECC, &tpm2.TPMSECCParms{ + Symmetric: tpm2.TPMTSymDefObject{Algorithm: tpm2.TPMAlgNull}, + Scheme: tpm2.TPMTECCScheme{ + Scheme: tpm2.TPMAlgECDSA, + Details: tpm2.NewTPMUAsymScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSigSchemeECDSA{ + HashAlg: tpm2.TPMAlgSHA256, + }), + }, + CurveID: tpm2.TPMECCNistP256, + KDF: tpm2.TPMTKDFScheme{Scheme: tpm2.TPMAlgNull}, + }), + } +} + +// Signer implements crypto.Signer using a persistent TPM key. +type Signer struct { + tpm transport.TPM + handle tpm2.TPMHandle + name tpm2.TPM2BName + pub *ecdsa.PublicKey +} + +func (s *Signer) Public() crypto.PublicKey { return s.pub } + +func (s *Signer) Sign(_ io.Reader, digest []byte, _ crypto.SignerOpts) ([]byte, error) { + rsp, err := tpm2.Sign{ + KeyHandle: tpm2.NamedHandle{Handle: s.handle, Name: s.name}, + Digest: tpm2.TPM2BDigest{Buffer: digest}, + InScheme: tpm2.TPMTSigScheme{ + Scheme: tpm2.TPMAlgECDSA, + Details: tpm2.NewTPMUSigScheme(tpm2.TPMAlgECDSA, &tpm2.TPMSSchemeHash{ + HashAlg: tpm2.TPMAlgSHA256, + }), + }, + Validation: tpm2.TPMTTKHashCheck{Tag: tpm2.TPMSTHashCheck}, + }.Execute(s.tpm) + if err != nil { + return nil, fmt.Errorf("tpm sign: %w", err) + } + ecc, err := rsp.Signature.Signature.ECDSA() + if err != nil { + return nil, fmt.Errorf("tpm sign: not ECDSA: %w", err) + } + r := new(big.Int).SetBytes(ecc.SignatureR.Buffer) + sInt := new(big.Int).SetBytes(ecc.SignatureS.Buffer) + // x509 expects an ASN.1 DER ECDSA-Sig-Value{ r, s }. + return asn1.Marshal(struct{ R, S *big.Int }{r, sInt}) +} + +func signerFromPub(tpm transport.TPM, handle tpm2.TPMHandle, name tpm2.TPM2BName, pub2b tpm2.TPM2BPublic) (*Signer, error) { + pub, err := pub2b.Contents() + if err != nil { + return nil, err + } + det, err := pub.Parameters.ECCDetail() + if err != nil { + return nil, fmt.Errorf("key at handle is not ECC: %w", err) + } + uniq, err := pub.Unique.ECC() + if err != nil { + return nil, err + } + pk, err := tpm2.ECDSAPub(det, uniq) + if err != nil { + return nil, err + } + return &Signer{tpm: tpm, handle: handle, name: name, pub: pk}, nil +} + +// isProtectedObject reports whether a persistent object is a restricted +// decryption key — i.e. an Endorsement Key or Storage Root Key. We must NEVER +// evict one of those (it would destroy the TPM's endorsement/storage identity, +// including the EK the future EK/AK attestation path depends on). Fail-safe: if +// the public area can't be parsed, treat it as protected. +func isProtectedObject(pub2b tpm2.TPM2BPublic) bool { + pub, err := pub2b.Contents() + if err != nil { + return true + } + a := pub.ObjectAttributes + return a.Restricted && a.Decrypt +} + +// ensureKey returns a signer for the persistent key at handle, creating + +// persisting a fresh ECDSA key there if none exists. Returns created=true when +// a new key was generated. handle MUST be a non-reserved owner handle (the fleet +// default is 0x81000100) — NOT the EK (0x81010001/2) or SRK (0x81000001/2) slots. +// +// If the handle already holds an object that is NOT our ECC signing key (e.g. an +// RSA signing key left by an older tpm2-tools/openssl provisioning path — go-tpm +// reports "key at handle is not ECC"), it is treated as STALE and evicted, then a +// fresh ECC key is created. Safe on the enrol path: this only runs on an empty-NV +// host, so no issued cert references the old key. Only a valid ECC key is reused. +// A restricted-decrypt object (EK/SRK) is NEVER evicted — it errors instead, which +// only happens if handle was misconfigured to a reserved slot. +func EnsureKey(tpm transport.TPM, handle tpm2.TPMHandle) (signer *Signer, created bool, err error) { + if rp, e := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm); e == nil { + // Guard FIRST: never touch a restricted-decrypt object (EK/SRK). An ECC + // EK/SRK would otherwise parse as a usable ECC key below and be "reused". + if isProtectedObject(rp.OutPublic) { + return nil, false, fmt.Errorf("refusing to evict a restricted-decrypt object (EK/SRK?) at handle %#x — configure a non-reserved owner handle (e.g. 0x81000100)", uint32(handle)) + } + if s, e2 := signerFromPub(tpm, handle, rp.Name, rp.OutPublic); e2 == nil { + return s, false, nil // our ECC signing key is already there — reuse it + } + // Stale/wrong-type signing key occupying the handle — evict so we recreate. + if _, e2 := (tpm2.EvictControl{ + Auth: tpm2.TPMRHOwner, + ObjectHandle: &tpm2.NamedHandle{Handle: handle, Name: rp.Name}, + PersistentHandle: handle, + }).Execute(tpm); e2 != nil { + return nil, false, fmt.Errorf("evict stale (non-ECC) key at handle: %w", e2) + } + } + + cp, err := tpm2.CreatePrimary{ + PrimaryHandle: tpm2.TPMRHOwner, + InPublic: tpm2.New2B(eccSigningTemplate()), + }.Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("createprimary: %w", err) + } + // Persist at the well-known handle, then flush the transient object. + _, err = tpm2.EvictControl{ + Auth: tpm2.TPMRHOwner, + ObjectHandle: &tpm2.NamedHandle{Handle: cp.ObjectHandle, Name: cp.Name}, + PersistentHandle: handle, + }.Execute(tpm) + _, _ = (tpm2.FlushContext{FlushHandle: cp.ObjectHandle}).Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("evictcontrol: %w", err) + } + rp, err := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm) + if err != nil { + return nil, false, fmt.Errorf("readpublic after persist: %w", err) + } + s, err := signerFromPub(tpm, handle, rp.Name, rp.OutPublic) + return s, true, err +} + +// LoadSigner returns a crypto.Signer for the persistent key ALREADY at handle, +// WITHOUT creating one. This is the reused-identity path (normal boot / the on-host +// agent): the fleet signing key was created at enrolment, so here we only sign the +// TLS handshake with it — the private key never leaves the TPM. Errors (never +// creates/evicts) if the handle is empty, holds a non-ECC object, or holds a +// restricted-decrypt EK/SRK. Contrast EnsureKey, which is the enrol-time create path. +func LoadSigner(tpm transport.TPM, handle tpm2.TPMHandle) (*Signer, error) { + rp, err := (tpm2.ReadPublic{ObjectHandle: handle}).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("no persistent key at handle %#x: %w", uint32(handle), err) + } + if isProtectedObject(rp.OutPublic) { + return nil, fmt.Errorf("object at handle %#x is a restricted-decrypt key (EK/SRK?), not the fleet signing key", uint32(handle)) + } + return signerFromPub(tpm, handle, rp.Name, rp.OutPublic) +} + +// nvPresent reports whether a defined NV index exists. NOTE: "defined" does NOT +// mean "holds our fleet cert" — leftover/foreign NV (e.g. an earlier +// tpm-provision run, or another OS) also reports present. Prefer ReadNVCert + +// content validation for the enrol decision; this stays for cheap existence checks. +func NVPresent(tpm transport.TPM, index tpm2.TPMHandle) bool { + _, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + return err == nil +} + +// ReadNVCert reads the full contents of NV index (chunked under the NV buffer +// limit). Returns an error if the index is undefined or unreadable — callers +// treat that as "no cert" (enrol). The bytes are the caller's to parse/validate; +// this does not assume they are a certificate. +func ReadNVCert(tpm transport.TPM, index tpm2.TPMHandle) ([]byte, error) { + rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv readpublic: %w", err) + } + pub, err := rp.NVPublic.Contents() + if err != nil { + return nil, err + } + size := int(pub.DataSize) + out := make([]byte, 0, size) + const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE + for off := 0; off < size; off += chunk { + end := min(off+chunk, size) + r, err := (tpm2.NVRead{ + AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)}, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + Size: uint16(end - off), + Offset: uint16(off), + }).Execute(tpm) + if err != nil { + return nil, fmt.Errorf("nv read @%d: %w", off, err) + } + out = append(out, r.Data.Buffer...) + } + return out, nil +} + +// nvWriteCert (re)defines the NV index sized to der and writes it under owner +// auth. Chunks the write to stay within the TPM's NV buffer limit. +func WriteNVCert(tpm transport.TPM, index tpm2.TPMHandle, der []byte) error { + // Undefine a stale index first (re-enrolment / resized cert). + if rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm); err == nil { + // A PLATFORM-created index at our handle is OEM firmware NV (bench-seen on + // a Dell 820: 0x01800001 was platformcreate|policydelete|writelocked). We + // cannot undefine it with owner auth, and TPM2_Clear/BIOS won't remove it — + // so fail LOUDLY with guidance rather than a confusing "nv undefine" error. + // The remedy is a different, free owner index via rd.dot1x.nvindex. + if pub, e := rp.NVPublic.Contents(); e == nil && pub.Attributes.PlatformCreate { + return fmt.Errorf("NV index %#x is a platform-created (firmware-owned) index — cannot be written by enrolment; pick a free owner index via rd.dot1x.nvindex / -nvindex", uint32(index)) + } + if _, err := (tpm2.NVUndefineSpace{ + AuthHandle: tpm2.TPMRHOwner, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + }).Execute(tpm); err != nil { + return fmt.Errorf("nv undefine: %w", err) + } + } + + def := tpm2.NVDefineSpace{ + AuthHandle: tpm2.TPMRHOwner, + PublicInfo: tpm2.New2B(tpm2.TPMSNVPublic{ + NVIndex: index, + NameAlg: tpm2.TPMAlgSHA256, + Attributes: tpm2.TPMANV{ + OwnerWrite: true, + OwnerRead: true, + AuthWrite: true, + AuthRead: true, + NT: tpm2.TPMNTOrdinary, + NoDA: true, + }, + DataSize: uint16(len(der)), + }), + } + if _, err := def.Execute(tpm); err != nil { + return fmt.Errorf("nv define: %w", err) + } + rp, err := (tpm2.NVReadPublic{NVIndex: index}).Execute(tpm) + if err != nil { + return fmt.Errorf("nv readpublic: %w", err) + } + + const chunk = 512 // conservatively under TPM_MAX_NV_BUFFER_SIZE + for off := 0; off < len(der); off += chunk { + end := min(off+chunk, len(der)) + if _, err := (tpm2.NVWrite{ + AuthHandle: tpm2.AuthHandle{Handle: index, Name: rp.NVName, Auth: tpm2.PasswordAuth(nil)}, + NVIndex: tpm2.NamedHandle{Handle: index, Name: rp.NVName}, + Data: tpm2.TPM2BMaxNVBuffer{Buffer: der[off:end]}, + Offset: uint16(off), + }).Execute(tpm); err != nil { + return fmt.Errorf("nv write @%d: %w", off, err) + } + } + return nil +} diff --git a/files/stig/fleet-agent-chronylog.te b/files/stig/fleet-agent-chronylog.te new file mode 100644 index 0000000..0f0d494 --- /dev/null +++ b/files/stig/fleet-agent-chronylog.te @@ -0,0 +1,38 @@ +# Raw module-policy syntax (compiled with `checkmodule -M -m`), NOT the refpolicy +# `policy_module()` m4 macro — that macro is only expanded by the refpolicy +# Makefile and checkmodule rejects it. See chrony-dhcp-dispatcher.te for the why. +module fleet_agent_chronylog 1.0.0; + +######################################## +# Let the fleet-agent READ chrony's tracking log under enforcing SELinux. +# +# fleet-agent (telemetry) reports clock health (source/stratum/offset) by reading +# /var/log/chrony/tracking.log (chronyd appends a line per clock update; enabled +# via `log tracking`). The agent runs as unconfined_service_t. chrony's log dir + +# files are labeled chronyd_var_log_t, and the shipped policy confines that type +# to chronyd_t + log-admin roles — so although the file is mode 0644, under +# enforcing the read is DENIED by type enforcement (DAC allows, TE does not). +# Verified 2026-07-10 on comet-beacon-54: with SELinux enforcing even a +# STIG-confined admin login (staff_t) gets "Permission denied" reading it, though +# a permissive-mode read succeeds — the tell-tale of a type-enforcement block. +# +# This grants ONLY read (open/read/getattr) on the chrony log type plus dir search +# to reach the file — no write, no other chrony access, and NO domain transition +# (the agent reads a file; it does not exec chronyc). This is the SELinux (TE) +# half; the DAC half is the `chrony` supplementary group in fleet-agent.service, +# needed to traverse the 0750 /var/log/chrony dir — BOTH are required to read the +# log. Minimal + auditable; justified in docs/stig-deviations.md #16. BENCH-VERIFY: +# the manager's Monitor chrony row populates on an enforcing boot; extend here only +# if a related chrony log AVC appears. Same policy-store-rebuild reproducibility +# caveat as #14/#15. +######################################## + +require { + type unconfined_service_t; + type chronyd_var_log_t; + class dir { search getattr open }; + class file { open read getattr }; +} + +allow unconfined_service_t chronyd_var_log_t:dir { search getattr open }; +allow unconfined_service_t chronyd_var_log_t:file { open read getattr }; diff --git a/serving/.gitignore b/serving/.gitignore new file mode 100644 index 0000000..357d11d --- /dev/null +++ b/serving/.gitignore @@ -0,0 +1,4 @@ +# Per-environment overrides + secrets. defaults.env (committed) holds the +# defaults; .env is layered over it (COMPOSE_ENV_FILES=defaults.env,.env) and +# holds only your per-LAN values + secrets — never commit it. +.env diff --git a/serving/Makefile b/serving/Makefile new file mode 100644 index 0000000..4898148 --- /dev/null +++ b/serving/Makefile @@ -0,0 +1,45 @@ +# Makefile — convenience wrapper for the serving stack. +# +# Sets COMPOSE_ENV_FILES so `make up` / `make logs` / etc. layer the committed +# defaults.env UNDER your .env overrides automatically — no need to export it or +# pass --env-file. (Plain `docker compose …` still works if you export +# COMPOSE_ENV_FILES=defaults.env,.env yourself.) Run from serving/. + +export COMPOSE_ENV_FILES := defaults.env,.env + +COMPOSE := docker compose +SERVICE ?= + +.DEFAULT_GOAL := help + +.PHONY: help up down restart pull logs ps config + +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +# .env must exist because COMPOSE_ENV_FILES lists it — guard with a clear message. +.env: + @echo "ERROR: .env not found — create it with your per-LAN overrides (BOOT_IFACE / SERVER_IP / DHCP range / IMAGE_VERSION; see defaults.env)." >&2 + @exit 1 + +up: .env ## Start the stack (dnsmasq + http + nvmeof) + $(COMPOSE) up -d + +down: .env ## Stop + remove the stack + $(COMPOSE) down + +restart: .env ## Recreate a service (SERVICE=dnsmasq) or all + $(COMPOSE) up -d --force-recreate $(SERVICE) + +pull: .env ## Pull the CI-built images + $(COMPOSE) pull + +logs: .env ## Follow logs (SERVICE=dnsmasq for one) + $(COMPOSE) logs -f $(SERVICE) + +ps: .env ## Show container status + $(COMPOSE) ps + +config: .env ## Print the resolved (layered) compose config + $(COMPOSE) config diff --git a/serving/README.md b/serving/README.md index 3d0d5e5..2881f72 100644 --- a/serving/README.md +++ b/serving/README.md @@ -43,10 +43,12 @@ Keep `nvmet_traddr` (UKI) == `NVME_HOST` (this stack's `.env`). ```bash cd serving -cp .env.example .env # edit IMAGE_VERSION, BOOT_IFACE, SERVER_IP, DHCP range +# config = committed defaults.env + your .env overrides, layered (or use `make`): +export COMPOSE_ENV_FILES=defaults.env,.env +printf 'IMAGE_VERSION=\nBOOT_IFACE=\nSERVER_IP=\n' > .env # + DHCP range docker login git.bitasylum.org -docker compose up -d --build -docker compose ps +docker compose up -d --build # or simply: make up +docker compose ps # or: make ps ``` `IMAGE_VERSION` must match the built tag (e.g. a branch build is diff --git a/serving/.env.example b/serving/defaults.env similarity index 84% rename from serving/.env.example rename to serving/defaults.env index d5b256f..6fdcb6d 100644 --- a/serving/.env.example +++ b/serving/defaults.env @@ -1,4 +1,16 @@ -# Copy to .env and edit for your test LAN/bridge, then: docker compose up -d +# defaults.env — the COMMITTED default configuration for the serving stack. +# +# Compose LAYERS this file, then .env (overrides), for both container env AND +# ${...} interpolation. Enable the layering once per shell (later file wins): +# +# export COMPOSE_ENV_FILES=defaults.env,.env +# # or per command: docker compose --env-file defaults.env --env-file .env +# # or just use the Makefile: `make up` / `make logs` (sets it for you) +# +# Then put ONLY per-environment overrides + secrets in .env (gitignored) — at +# least BOOT_IFACE / SERVER_IP / DHCP range / IMAGE_VERSION for your LAN. Changing +# a default here reaches every run without anyone re-copying it. (.env must exist +# when COMPOSE_ENV_FILES lists it; create it with at least those overrides.) # Release being served. Must match the Packer build (fleet-.efi). IMAGE_VERSION=0.0.0-2eefd97 @@ -72,6 +84,13 @@ NVME_HOST=nvme.fleet.lab # Remote audit collector name. Host-generic — bake into the image's rsyslog # forward rule so every host offloads auditd here; resolved to this serving host. AUDIT_HOST=audit.fleet.lab +# Fleet-manager AGENT plane name. Baked into the image (files/agent/agent.env +# FLEET_AGENT_MANAGER) so every enrolled host dials the control plane here; +# resolved per-environment to the manager's fleet-LAN address (this serving host +# now; a routable mgmt address once the manager moves to k8s). Just another +# *.fleet.lab like the boot/nvme/audit names. Must match the agent server cert SAN +# (server-pki.sh AGENT_SANS). +MGMT_HOST=manager.fleet.lab # --- NVMe/TCP target ------------------------------------------------------- # Per-version subsystem NQN base. The long-lived target serves each rootfs diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index 84c9ff8..9e052ac 100644 --- a/serving/docker-compose.yml +++ b/serving/docker-compose.yml @@ -29,8 +29,9 @@ # docker cp $id:/artifacts/. ./artifacts/$IMAGE_VERSION/; docker rm $id # Private registry: docker login git.bitasylum.org first so the image pulls. # -# Usage: -# cp .env.example .env # then edit BOOT_IFACE / SERVER_IP / DHCP range +# Usage (config = committed defaults.env + your .env overrides, layered): +# export COMPOSE_ENV_FILES=defaults.env,.env # or use the Makefile (make up) +# printf 'BOOT_IFACE=...\nSERVER_IP=...\n' > .env # per-LAN overrides + secrets # docker compose up -d # ============================================================================= @@ -104,6 +105,9 @@ services: # Host-generic remote audit collector name (baked into the image config so # every host offloads auditd here; resolved per-environment to this host). - --address=/${AUDIT_HOST}/${SERVER_IP} + # Fleet-manager AGENT plane (:9600) — baked into the image so enrolled hosts + # dial the control plane by name; resolved here like the other fleet names. + - --address=/${MGMT_HOST}/${SERVER_IP} # Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's # call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser # tries. Defense-in-depth behind the in-image Chromium policy/flags; in @@ -264,6 +268,14 @@ services: # HOST clock (the clock isn't namespaced) — intended: the serving host is the # lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already # runs its own NTP and you only want this to relay. + # + # FIREWALL (easy to miss — same trap as the audit collector's 514/tcp): host + # networking means Docker does NOT open the port; the serving host's own + # firewall governs 123/udp. Without a rule, workstations get 192.168.50.10 as + # their NTP server (DHCP opt 42) but their packets are dropped — chrony logs + # "Added source … / unreachable" and never syncs. Open it on the boot-LAN NIC: + # ufw: sudo ufw allow in on to any port 123 proto udp comment 'fleet NTP' + # firewalld: sudo firewall-cmd --permanent --add-service=ntp && sudo firewall-cmd --reload # --- Remote audit / log collector (RECEIVE side) --------------------------- # rsyslog receiver: booted diskless hosts (whose RAM-only overlay root loses diff --git a/serving/dot1x-bench/docker-compose.yml b/serving/dot1x-bench/docker-compose.yml index 75a8a59..35710ed 100644 --- a/serving/dot1x-bench/docker-compose.yml +++ b/serving/dot1x-bench/docker-compose.yml @@ -15,7 +15,7 @@ services: # --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) -------- step-ca: - image: smallstep/step-ca:latest + image: harbor.bitasylum.org/dockerhub/smallstep/step-ca:latest network_mode: host restart: unless-stopped environment: @@ -38,7 +38,7 @@ services: # --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) --- freeradius: - image: freeradius/freeradius-server:latest + image: harbor.bitasylum.org/dockerhub/freeradius/freeradius-server:latest network_mode: host # RADIUS 1812/1813 from the authenticator restart: unless-stopped depends_on: diff --git a/serving/dot1x-bench/step-ca/bootstrap.sh b/serving/dot1x-bench/step-ca/bootstrap.sh index 0fc1a6f..c1869e2 100755 --- a/serving/dot1x-bench/step-ca/bootstrap.sh +++ b/serving/dot1x-bench/step-ca/bootstrap.sh @@ -71,6 +71,14 @@ FP=$(openssl x509 -in "$PKI/root_ca.crt" -noout -fingerprint -sha256 | cut -d= - echo " root fingerprint: $FP" echo " copy to: files/etc/dot1x/radius-ca.pem (image trust anchor)" +# Also export the INTERMEDIATE: leaf certs (host TPM identities, RADIUS, the +# fleet-manager server cert) are signed by it, so the fleet-manager agent-plane +# CLIENT CA must be root+intermediate (server-pki.sh reads it via +# FLEET_CA_INTERMEDIATE). Best-effort — a single-tier CA has none. +incca cp /home/step/certs/intermediate_ca.crt /export/intermediate_ca.crt 2>/dev/null \ + && echo " exported intermediate_ca.crt (-> server-pki.sh fleet-ca.crt bundle)" \ + || echo " (no intermediate_ca.crt — single-tier CA)" + # --- 1b. raise the CA cert-duration claim (idempotent) ---------------------- # A stock step-ca caps issued certs at 24h. Both the RADIUS server cert below # AND the enrol-broker (--not-after 720h) need longer-lived fleet certs, so the diff --git a/serving/enrol-broker/Dockerfile b/serving/enrol-broker/Dockerfile index 3fa19dd..10fc591 100644 --- a/serving/enrol-broker/Dockerfile +++ b/serving/enrol-broker/Dockerfile @@ -7,14 +7,14 @@ # # Pin + mirror these base tags through your registry (like the rest of the stack) # if the build/serving host has no egress. -FROM golang:1.23-alpine AS build +FROM harbor.bitasylum.org/dockerhub/library/golang:1.23-alpine AS build WORKDIR /src COPY go.mod . COPY *.go wordlist.txt ./ ENV CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local RUN go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /enrol-broker . -FROM smallstep/step-cli:latest +FROM harbor.bitasylum.org/dockerhub/smallstep/step-cli:latest COPY --from=build /enrol-broker /usr/local/bin/enrol-broker # Non-root by default (the step-cli image runs as uid 1000 "step"); the broker # binds loopback ports >1024 and only READS the mounted CA root + provisioner diff --git a/serving/enrol-broker/admin.go b/serving/enrol-broker/admin.go index 5f4b73e..d1280bb 100644 --- a/serving/enrol-broker/admin.go +++ b/serving/enrol-broker/admin.go @@ -15,6 +15,7 @@ import ( "log" "net/http" "os" + "strings" "time" ) @@ -105,7 +106,7 @@ func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) { return } log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin) - redirectOrJSON(w, r) + respondDecision(w, r, e) } func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) { @@ -117,16 +118,28 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) { return } log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin) - redirectOrJSON(w, r) + respondDecision(w, r, e) } -// redirectOrJSON sends a browser form back to the queue, or a 204 for API callers. -func redirectOrJSON(w http.ResponseWriter, r *http.Request) { - if isBrowser(r) { +// respondDecision replies to an approve/deny. A browser form gets redirected back +// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the +// fleet-manager driving the broker as its authorise plane) gets the decided +// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which +// is unexported and only reaches the requesting host); any other API caller keeps +// the original 204 (so verify-serving.sh and existing scripts are unaffected). +func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) { + switch { + case isBrowser(r): http.Redirect(w, r, "/", http.StatusSeeOther) - return + case wantsJSON(r): + writeJSON(w, http.StatusOK, e) + default: + w.WriteHeader(http.StatusNoContent) } - w.WriteHeader(http.StatusNoContent) +} + +func wantsJSON(r *http.Request) bool { + return strings.Contains(r.Header.Get("Accept"), "application/json") } func isBrowser(r *http.Request) bool { 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/.gitignore b/serving/fleet-manager/.gitignore new file mode 100644 index 0000000..69f8198 --- /dev/null +++ b/serving/fleet-manager/.gitignore @@ -0,0 +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..7fd07a8 --- /dev/null +++ b/serving/fleet-manager/DEPLOY.md @@ -0,0 +1,331 @@ +# 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 + +Config is **layered**: `defaults.env` (committed defaults) + `.env` (your +overrides + secrets, gitignored). Compose reads both — later wins — via +`COMPOSE_ENV_FILES`, so a changed default in `defaults.env` reaches you without +re-copying anything; `.env` stays small. + +```bash +cd serving/fleet-manager +export COMPOSE_ENV_FILES=defaults.env,.env # once per shell (or a Makefile/.envrc) +# .env holds ONLY overrides + secrets — at minimum: +cat > .env < ./pki/agent/{server.crt,server.key,fleet-ca.crt}, auto-renewed thereafter. +``` + +**Manual alternative — `server-pki.sh`** (needs the `step` CLI on the host, or run +it inside the step-ca container; re-run every ~90 days). Use where step-ca is +**not** co-located: + +```bash +# SANs = every name/IP a host might dial the agent plane on. +AGENT_SANS=manager.fleet.lab,diskless-server.bitasylum.org \ + ./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 ingress — Traefik on :443 (Let's Encrypt DNS-01 via Route 53) + +The admin UI is fronted by the **`traefik`** compose service: it terminates the LE +cert on :443 and forwards plaintext to the manager (behind-proxy mode, loopback +:9500). The UI hostname resolves on the mgmt net and is **not internet-reachable**, +so only **DNS-01** can validate — Traefik automates the `_acme-challenge` TXT +record via the AWS Route 53 API and auto-renews. **Only the admin plane rides +Traefik**; the agent gRPC/mTLS plane (:9600) is direct (see [Network paths](#network-paths)). + +**Set the UI hostname** in `traefik/dynamic/fleet-manager.yml` — the `Host()` rule +(file config isn't env-interpolated), matching `FLEET_MGR_RP_ID`. + +**IAM policy** — 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/" } + ] +} +``` + +`AWS_HOSTED_ZONE_ID` is **optional**: the ACME client auto-discovers the zone via +`ListHostedZonesByName` (hence that statement). Set it (Traefik reads it from the +env) to pin the zone — required if a **public + private** zone share `bitasylum.org`, +and it lets you **drop the `ListHostedZonesByName` statement** for a tighter policy. + +**Run it:** + +```bash +# .env: ACME_EMAIL + the AWS creds above. For a first DRY-RUN vs LE staging +# (untrusted, no rate limit), add the caserver flag to the traefik `command:` in +# docker-compose.yml, prove issuance, then remove it for the real cert. +docker compose up -d traefik +docker compose logs -f traefik # watch the DNS-01 challenge succeed + cert stored +``` + +Traefik runs the propagation check against **public** resolvers +(`8.8.8.8:53,1.1.1.1:53` in the command) — needed because split-horizon DNS won't +serve the public `_acme-challenge` record. The cert persists in the `traefik-acme` +volume (`acme.json`) and Traefik renews it automatically. A publicly-trusted cert +also keeps the WebAuthn secure-context clean. + +> **Origin match:** passkeys are origin-bound, so `FLEET_MGR_RP_ORIGIN` must equal +> the browser's origin. With Traefik on :443 that's the **port-less** +> `https://diskless-server.bitasylum.org`; `FLEET_MGR_RP_ID` is the host. The +> manager binds loopback (`127.0.0.1:9500`) — only Traefik reaches it; never expose +> that port directly. + + +### Network paths (three planes, three trust stages) + +| Plane | When | From | Path | Auth | +|---|---|---|---|---| +| Enrolment (`/enrol/`) | pre-auth (no cert yet) | boot LAN | boot nginx passthrough → broker :9090 | none (the one unauth edge) | +| Agent (gRPC :9600) | post-auth (802.1X'd, holds TPM cert) | authenticated host VLAN | **direct** → manager :9600, via **OPNsense L3** (allow only host-VLAN→:9600) | **mTLS, TPM cert (fleet CA)** | +| Admin (UI :443) | anytime | mgmt/admin network | **Traefik** → manager :9500 | WebAuthn passkey | + +The agent plane does **not** go through the boot nginx or Traefik: it connects +only after EAP-TLS/MACsec, and Traefik can't terminate the fleet-CA client-cert +mTLS. Segment it with an **OPNsense firewall rule** allowing only the authenticated +host VLAN → `manager:9600`. In the k8s target this becomes a dedicated TCP/TLS- +**passthrough** ingress (the pod still sees the client cert), separate from the +admin Ingress. + +## 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 + ingress + +```bash +docker compose up -d fleet-manager traefik +docker compose logs -f fleet-manager +``` + +Healthy startup logs the four planes: admin (`passkey, behind TLS proxy` — plaintext +on loopback), agent gRPC/mTLS, authorise (broker), and the InfluxDB gateway. Any +plane whose config is absent logs `DISABLED` and the rest still run. Traefik +terminates :443 and forwards to the manager's loopback listener. + +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=` so it finds the `_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 :/data harbor.bitasylum.org/dockerhub/library/alpine:3.20 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). + +## Hardening status + +Done: distroless-nonroot runtime; passkey admin auth on :443 via the **Traefik** +mgmt ingress with an auto-renewing LE cert (Route 53 DNS-01); mTLS on both the +agent (fleet CA) and manager→broker (admin CA) planes; secrets kept server-side +(`.env`/PKI gitignored); optional planes soft-disable instead of crash-looping; CI +builds + pushes the image (`fleet-manager-image.yaml`); `backup.sh` for the durable +volumes. + +Still open (operator to close per environment): +- **Network segmentation.** The admin (`:443` Traefik, forwards to loopback `:9500`) + and agent (`:9600`) listeners must be reachable only from their zones — admin from + the mgmt network, agent from the authenticated host VLAN via **OPNsense L3** (allow + only host-VLAN → `:9600`). Never the boot LAN. Enforce at the firewall; this repo + can't. See [Network paths](#network-paths). +- **Agent-plane server cert.** Issue it (§2a) once the on-host agent is dialing in + — until then the agent plane soft-disables. On a bench where step-ca is + co-located, prefer the `cert-auto` auto-renewer so it never silently expires; + its SANs MUST include `manager.fleet.lab` (the fleet-DNS name hosts dial). +- **Digest pinning.** For a locked deployment, pin `fleet-manager`, `influxdb`, + and `traefik` by `@sha256:…` (CLAUDE.md invariant #12) rather than the tags used + here for convenience. + +## 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/Dockerfile b/serving/fleet-manager/Dockerfile new file mode 100644 index 0000000..b49f672 --- /dev/null +++ b/serving/fleet-manager/Dockerfile @@ -0,0 +1,43 @@ +# fleet-manager — server-side fleet control plane + embedded admin SPA. +# +# Two stages: build the Svelte SPA (node) → dist/, then compile the Go binary +# with the SPA embedded (`-tags embedspa`). The gRPC .pb.go stubs are committed, +# so no protoc is needed here. Ships as a small static distroless image; the same +# binary also serves the `register-admin` subcommand. +# +# docker build -t fleet-manager serving/fleet-manager + +# ---- 1. build the SPA ------------------------------------------------------ +FROM harbor.bitasylum.org/dockerhub/library/node:24-alpine AS ui +WORKDIR /ui +COPY ui/package.json ui/package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY ui/ ./ +RUN npm run build # -> /ui/dist + +# ---- 2. build the Go binary (SPA embedded) -------------------------------- +FROM harbor.bitasylum.org/dockerhub/library/golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=ui /ui/dist ./ui/dist +ENV CGO_ENABLED=0 GOTOOLCHAIN=local +RUN go build -tags embedspa -trimpath -buildvcs=false \ + -ldflags '-s -w -buildid=' -o /fleet-manager . +# Seed dir whose ownership a FRESH named volume inherits via Docker's copy-up, so +# the nonroot runtime (uid 65532) can write inventory/admins/enroll.key under +# /data without a manual chown. (An ALREADY-created root-owned volume must still +# be chowned or recreated once — see DEPLOY.md.) +RUN mkdir -p /data-seed && touch /data-seed/.keep + +# ---- 3. minimal runtime ---------------------------------------------------- +# distroless/static: static binary + ca-certificates + tzdata, non-root by +# default (uid 65532). The data dir (inventory + admin store + enrol key) is a +# mounted, writable volume — pre-created here owned by nonroot so a fresh volume +# inherits that ownership. +FROM harbor.bitasylum.org/gcr.io/distroless/static-debian12:nonroot +COPY --from=build /fleet-manager /usr/local/bin/fleet-manager +COPY --from=build --chown=65532:65532 /data-seed /data +USER nonroot:nonroot +ENTRYPOINT ["/usr/local/bin/fleet-manager"] diff --git a/serving/fleet-manager/Makefile b/serving/fleet-manager/Makefile new file mode 100644 index 0000000..ef39304 --- /dev/null +++ b/serving/fleet-manager/Makefile @@ -0,0 +1,56 @@ +# Makefile — convenience wrapper for the fleet-manager stack. +# +# Sets COMPOSE_ENV_FILES so `make up` / `make logs` / etc. layer the committed +# defaults.env UNDER your .env overrides automatically — no need to export it or +# pass --env-file. (Plain `docker compose …` still works if you export +# COMPOSE_ENV_FILES=defaults.env,.env yourself.) Run from serving/fleet-manager/. + +export COMPOSE_ENV_FILES := defaults.env,.env + +COMPOSE := docker compose +SERVICE ?= +NAME ?= + +.DEFAULT_GOAL := help + +.PHONY: help up down restart pull build logs ps config register-admin backup + +help: ## Show this help + @grep -hE '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' + +# .env must exist because COMPOSE_ENV_FILES lists it — guard with a clear message. +.env: + @echo "ERROR: .env not found — create it with your secrets/overrides (DEPLOY.md §1)." >&2 + @exit 1 + +up: .env ## Start the stack (influxdb + fleet-manager + traefik) + $(COMPOSE) up -d + +down: .env ## Stop + remove the stack + $(COMPOSE) down + +restart: .env ## Recreate a service (SERVICE=fleet-manager) or all + $(COMPOSE) up -d --force-recreate $(SERVICE) + +pull: .env ## Pull the CI-built images + $(COMPOSE) pull + +build: .env ## Build the fleet-manager image locally + $(COMPOSE) build fleet-manager + +logs: .env ## Follow logs (SERVICE=fleet-manager for one) + $(COMPOSE) logs -f $(SERVICE) + +ps: .env ## Show container status + $(COMPOSE) ps + +config: .env ## Print the resolved (layered) compose config + $(COMPOSE) config + +register-admin: .env ## Mint a passkey enrol link (NAME=you) + @test -n "$(NAME)" || { echo "usage: make register-admin NAME=" >&2; exit 1; } + $(COMPOSE) run --rm fleet-manager register-admin "$(NAME)" + +backup: ## Snapshot the durable volumes (backup.sh) + ./backup.sh diff --git a/serving/fleet-manager/README.md b/serving/fleet-manager/README.md new file mode 100644 index 0000000..89d98b0 --- /dev/null +++ b/serving/fleet-manager/README.md @@ -0,0 +1,202 @@ +# fleet-manager — server-side fleet control plane + +The management service for the diskless kiosk fleet, and the backend for the +admin SPA. It is the central place to **monitor**, **authorise**, and +**lifecycle-control** enrolled hosts. See +[`docs/fleet-management-design.md`](../../docs/fleet-management-design.md) and +[`docs/tpm-enrolment-design.md`](../../docs/tpm-enrolment-design.md) §5. + +Unlike `fleet-hostapi` / `fleet-enrol` (baked into the reproducible verity image, +stdlib-only), this runs **server-side** as its own container on the management +network, so external deps are fine (`grpc-go`, `influxdb-client-go`). + +## Two planes (distinct trust models) + +| Plane | Transport | Browser/peer identity | Who | Purpose | +|---|---|---|---|---| +| **admin** | HTTPS via **Traefik** (:443) → manager | **WebAuthn/passkey** (YubiKey) → session | admin browsers | JSON API + SPA (monitor / authorise / lifecycle) | +| **agent** | gRPC + mTLS | **fleet CA** (host TPM cert) | enrolled hosts | bidi stream: health/metrics up, desired-state down | + +The agent plane uses the host's **reused TPM identity cert** (the same one used +for 802.1X EAP-TLS and from which the NVMe `hostnqn` derives — "one identity, +three stages"). The admin plane authenticates the **human** with a hardware +**passkey** (phishing-resistant, origin-bound), not a client cert — so there's no +cert to import and nothing to phish. (The manager→broker hop is separately mTLS; +that's service-to-service — see [Authorise](#authorise-pillar).) + +### Admin auth: passkeys/WebAuthn + +- **Login** is usernameless (discoverable passkey) with `userVerification: + required` — tap the YubiKey + PIN. A successful assertion mints an opaque, + server-side session (`Secure`/`HttpOnly`/`SameSite=Strict` cookie); mutating + requests must also echo the session CSRF token in `X-Fleet-CSRF`. +- **Bootstrap** is closed: `fleet-manager register-admin ` (run on the mgmt + host) mints a one-time, HMAC-signed enrolment link; the admin opens it in the + browser and registers their passkey. Single-use (jti ledger). Further admins can + be enrolled from the UI once one exists. +- **RP is origin-bound**: `FLEET_MGR_RP_ID` / `FLEET_MGR_RP_ORIGIN` must be the + UI's real hostname — for this deployment **`diskless-server.bitasylum.org`** / + `https://diskless-server.bitasylum.org` (port-less, since Traefik serves :443). +- **TLS + cert** are owned by the **`traefik`** mgmt-plane ingress: it terminates + a publicly-trusted **Let's Encrypt** cert (**Route 53 DNS-01** — the UI is on the + mgmt network, not internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate) on + :443 and forwards plaintext to the manager, which runs + passkey auth ON, plaintext on loopback (WebAuthn only needs the origin to match, + not the app's own transport). The manager never terminates TLS itself. Traefik + is the k8s ingress-controller + cert-manager role in miniature. + +## Monitoring path (no host ever touches InfluxDB) + +``` +host agent ──gRPC bidi (mTLS, TPM cert)──▶ fleet-manager ──line protocol──▶ InfluxDB + ◀──── desired-state (target ver / kiosk_url / reboot / drain) ──┘ (+ inventory health) +SPA ──GET /api/hosts/{serial}/metrics──▶ fleet-manager ──Flux──▶ InfluxDB ──JSON──▶ SPA renders charts +``` + +Hosts hold **no InfluxDB credential** and never reach it; the manager is the only +writer/reader, holding the Influx token server-side. Identity is taken from the +mTLS cert (anti-spoofing: a host can only write its own series). Push-only, no +inbound host port. Graphs are rendered **in the SPA**, not Grafana. + +## Inventory record + +Keyed by TPM **cert serial** (= hostnqn key). Born at enrolment approval, filled +by the agent stream and admin action: + +``` +Host (cert_serial) + ├─ identity : cert_serial, hostnqn, mac, ek_hash, starship_name + ├─ binding : asset_tag, switch_port, location (recorded at approval) + ├─ enrolled : approver, at, fingerprint + ├─ version : image_version, rootfs_digest, kernel (agent-reported) + ├─ health : load, cpu, temp, tmpfs%, uptime, nvme_root, auth, selinux, chrony (state+source/stratum/offset), kiosk (latest snapshot; series in InfluxDB) + ├─ usage : kiosk_url, session_uptime, chromium_crashes, display + └─ desired : target_version, kiosk_url, reboot_at, drain (SERVER-AUTHORITATIVE) +``` + +Persisted as a single JSON file (atomic writes) on a mounted volume — stdlib-only, +inspectable, reproducible; a DB swap is a drop-in behind the `Store` interface. + +## API (admin plane) + +| Route | Auth | Status | +|---|---|---| +| `GET /healthz` | open | ✅ ckpt 1 | +| `POST /auth/register/begin\|finish` | enrol token | ✅ ckpt 5 — passkey registration ceremony | +| `POST /auth/login/begin\|finish` | passkey | ✅ ckpt 5 — usernameless discoverable login → session | +| `POST /auth/logout` | session | ✅ ckpt 5 | +| `GET /api/whoami` | session | ✅ ckpt 5 — resume session + recover CSRF token | +| `GET /api/hosts` | session | ✅ ckpt 1 — fleet overview | +| `GET /api/hosts/{serial}` | session | ✅ ckpt 1 — host detail | +| `PUT /api/hosts/{serial}/desired` | session + CSRF | ✅ ckpt 1 — set desired-state (audited to admin) | +| `GET /api/enrol/pending` | session | ✅ ckpt 3 — proxies the broker queue | +| `POST /api/enrol/{id}/approve` | session + CSRF | ✅ ckpt 3 — signs via broker + **births inventory** | +| `POST /api/enrol/{id}/deny` | session + CSRF | ✅ ckpt 3 | +| `GET /api/hosts/{serial}/metrics?field=&range=` | session | ✅ ckpt 4 — Flux time-series (field whitelist) | + +`/api/*` is gated by the passkey session (mutations also need the CSRF header). + +The authorise pillar drives the enrol-broker's +admin plane over **mTLS** (the manager is a service client, gated by the dedicated +admin CA) asking for JSON, so an approve returns the decided enrolment; the manager +records the **human** approver (the signed-in passkey admin) and births the +inventory record. The broker stays the enrolment state machine + step-ca signer +(v1 "alongside" fold-in). + +**Agent plane** — gRPC `FleetAgent.Session` bidi over mTLS (fleet CA): ✅ ckpt 2. +`Hello`/`HealthSnapshot`/`CommandResult` up; `DesiredState`/`Command` down. +Identity is the peer cert serial (never self-asserted); an unenrolled serial is +refused. Desired-state is pushed live on connect + on change, and is durable in +inventory for offline delivery. Metrics writing is behind a `MetricsSink` +(no-op now; InfluxDB in ckpt 4). + +## Config (env) + +| Var | Default | Notes | +|---|---|---| +| `FLEET_MGR_ADMIN_ADDR` | `127.0.0.1:9500` | admin listener (loopback — Traefik forwards here; always plaintext) | +| `FLEET_MGR_RP_ID` | — | WebAuthn RP ID = UI hostname (`diskless-server.bitasylum.org`) | +| `FLEET_MGR_RP_ORIGIN` | — | WebAuthn origin (`https://diskless-server.bitasylum.org`) | +| `FLEET_MGR_RP_NAME` | `Fleet Command` | RP display name | +| `FLEET_MGR_ENROLL_KEY_FILE` | — | shared HMAC key for enrolment tokens (CLI + service) | +| `FLEET_MGR_ADMIN_STORE` | `/var/lib/fleet-manager/admins.json` | registered-passkey store | +| `FLEET_MGR_SESSION_TTL_MIN` | `720` | session lifetime (minutes) | +| `FLEET_MGR_ADMIN_INSECURE` | `false` | plaintext, **no passkey auth** — dev only | +| `FLEET_MGR_AGENT_ADDR` | `127.0.0.1:9600` | gRPC agent listener | +| `FLEET_MGR_AGENT_CERT` / `_KEY` | — | agent-plane server cert/key | +| `FLEET_MGR_AGENT_CLIENT_CA` | — | **fleet CA** (verifies host TPM certs). Agent plane starts only when all three are set. | +| `FLEET_MGR_BROKER_URL` | — | enrol-broker admin base URL (e.g. `https://127.0.0.1:9443`). Authorise pillar enabled only when set. | +| `FLEET_MGR_BROKER_CLIENT_CERT` / `_KEY` | — | manager's client cert for the broker admin mTLS (admin CA) | +| `FLEET_MGR_BROKER_CA` | — | CA that signs the broker's admin server cert | +| `FLEET_MGR_INFLUX_URL` | — | InfluxDB URL (private mgmt net). Metrics enabled only when URL + token set. | +| `FLEET_MGR_INFLUX_TOKEN` | — | InfluxDB token — **server-side only**, never on hosts | +| `FLEET_MGR_INFLUX_ORG` | `fleet` | InfluxDB org | +| `FLEET_MGR_INFLUX_BUCKET` | `fleet_health` | InfluxDB bucket | +| `FLEET_MGR_INVENTORY` | `/var/lib/fleet-manager/inventory.json` | inventory file (mount a volume) | +| `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) | + +## Deploy + +Full bring-up (PKI, Let's Encrypt DNS-01, `register-admin`, compose) is in +[`DEPLOY.md`](DEPLOY.md). Config is layered: **`defaults.env`** (committed) + +**`.env`** (overrides/secrets), via `COMPOSE_ENV_FILES=defaults.env,.env`. In +short: set that + write your secrets to `.env` → `./server-pki.sh` (or, where +step-ca is co-located, the `cert-auto` **`agent-cert-renewer`** sidecar that +issues + auto-renews the agent-plane server cert and hot-reloads it — DEPLOY §2a) + +`../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 traefik`. InfluxDB is private (published to loopback only); +the manager uses host networking to reach the loopback broker + bind a routable +agent listener. + +## Dev run + +```bash +FLEET_MGR_ADMIN_INSECURE=true FLEET_MGR_SEED=true \ + FLEET_MGR_ADMIN_ADDR=127.0.0.1:9500 FLEET_MGR_INVENTORY=/tmp/inv.json \ + go run . +curl -s localhost:9500/api/hosts | jq '.hosts[] | {name:.identity.starship_name, run:.version.image_version, want:.desired.target_version, online}' +``` + +## Build status + +- **Checkpoint 1 (done):** Go skeleton, JSON-file inventory `Store`, admin JSON API + (monitor + lifecycle), admin mTLS wiring, dev seed. Stdlib-only. +- **Checkpoint 2 (done):** gRPC agent plane — proto contract, mTLS (fleet CA) + server, stream registry, identity-from-cert, snapshot→inventory, live + desired-state push, `MetricsSink` seam. Full mTLS integration test. +- **Checkpoint 3 (done):** authorise pillar — the enrol-broker gained an + `Accept`-gated JSON approve response (browsers/`verify-serving.sh` unchanged); + the manager drives it over mTLS and births the inventory record on approve. + httptest-mock-broker test. +- **Checkpoint 4 (done):** InfluxDB metrics gateway — snapshots written as points + tagged with the authenticated identity (`MetricsSink`→`influxSink`), and a + read API (`/api/hosts/{serial}/metrics`) that runs a Flux query with a **field + whitelist** + hex-serial guard (no caller input reaches the query raw). Unit + tests for point construction, query building, and unsafe-input rejection. +- **Checkpoint 5 (done):** admin **WebAuthn/passkey** auth replacing browser mTLS — + credential store, `register-admin` bootstrap CLI (one-time HMAC enrol token), + register/login ceremonies (usernameless + UV required), server-side sessions + + CSRF middleware gating `/api/*`, admin TLS switched to server-auth with a + hot-reloading cert (for LE DNS-01 renewals). Tests: enrol token, session + middleware, cred-store persistence. Live passkey ceremony is a bench item. +- **Checkpoint 6 (done):** the **SPA** ([`ui/`](ui/)) — Svelte 5 + Tailwind 4, + LYSANDRA restricted-violet theme, passkey login/enrol + Monitor/Authorise/ + 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). +- **Checkpoint 7 (done):** deployment — three-stage `Dockerfile` (npm→dist→ + `go build -tags embedspa`→distroless), `docker-compose.yml` (private InfluxDB + + host-networked manager), `defaults.env`, `server-pki.sh` (agent-plane fleet-CA + server cert) **+ the `cert-auto` `agent-cert-renewer` sidecar** (auto-issues + + auto-renews that cert from a co-located step-ca; the manager hot-reloads it via + `GetCertificate` in `certreload.go` — no restart), 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). +- **Checkpoint 8 (done):** hardening + ingress — CI image build + (`fleet-manager-image.yaml`, pulled by tag), `backup.sh`; a **behind-proxy admin + mode** (passkey over plaintext behind a TLS-terminating proxy) fronted by a + **Traefik** mgmt ingress (:443 + Route 53 DNS-01, retiring the acme side-car). + This aligns with the k8s target (Traefik → ingress controller + cert-manager); + the agent plane stays direct mTLS via OPNsense L3. See [`DEPLOY.md`](DEPLOY.md). diff --git a/serving/fleet-manager/admin.go b/serving/fleet-manager/admin.go new file mode 100644 index 0000000..60b4f67 --- /dev/null +++ b/serving/fleet-manager/admin.go @@ -0,0 +1,243 @@ +// admin.go — the session-gated admin JSON API (the SPA's backend). Mounted under +// /api/ behind requireSession; browser identity is the WebAuthn passkey session +// (session.go), so every mutating action is attributed to the signed-in admin +// (adminName), not a client cert. +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "time" +) + +// apiRoutes registers the session-gated admin API (mounted under /api/ behind +// requireSession). The public liveness + WebAuthn ceremonies live in authRoutes. +func (s *server) apiRoutes(mux *http.ServeMux) { + // Session probe (lets the SPA resume + recover its CSRF token on reload). + mux.HandleFunc("GET /api/whoami", s.handleWhoami) + + // MONITOR — fleet overview + per-host detail. + mux.HandleFunc("GET /api/hosts", s.handleListHosts) + mux.HandleFunc("GET /api/hosts/{serial}", s.handleGetHost) + + // LIFECYCLE — set server-authoritative desired-state for one host. + mux.HandleFunc("PUT /api/hosts/{serial}/desired", s.handleSetDesired) + + // AUTHORISE — enrolment queue; approval drives the broker and BIRTHS the + // inventory record (identity/binding/enrolled). + mux.HandleFunc("GET /api/enrol/pending", s.handleEnrolPending) + mux.HandleFunc("POST /api/enrol/{id}/approve", s.handleEnrolApprove) + mux.HandleFunc("POST /api/enrol/{id}/deny", s.handleEnrolDeny) + + // METRICS — time-series for one host/field, read from InfluxDB via the manager. + mux.HandleFunc("GET /api/hosts/{serial}/metrics", s.handleMetrics) +} + +func (s *server) handleListHosts(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{"hosts": s.store.List()}) +} + +func (s *server) handleGetHost(w http.ResponseWriter, r *http.Request) { + serial := r.PathValue("serial") + h, ok := s.store.Get(serial) + if !ok { + writeErr(w, http.StatusNotFound, "no such host") + return + } + writeJSON(w, http.StatusOK, h) +} + +func (s *server) handleSetDesired(w http.ResponseWriter, r *http.Request) { + serial := r.PathValue("serial") + + var d Desired + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10)).Decode(&d); err != nil { + writeErr(w, http.StatusBadRequest, "invalid desired-state JSON: "+err.Error()) + return + } + // Provenance: the signed-in admin is authoritative; ignore any body value. + d.UpdatedBy = adminName(r) + + h, ok, err := s.store.SetDesired(serial, d) + if err != nil { + writeErr(w, http.StatusInternalServerError, "persist failed: "+err.Error()) + return + } + if !ok { + writeErr(w, http.StatusNotFound, "no such host") + return + } + // Push the new desired-state down the host's live agent stream if connected; + // an offline host (or one we can't reach right now) picks it up from the + // persisted inventory record on its next connect — inventory is authoritative. + pushed := false + if s.hub != nil { + pushed = s.hub.pushDesired(serial, h.Desired) + } + writeJSON(w, http.StatusOK, map[string]any{"host": h, "pushed_live": pushed}) +} + +// ---- AUTHORISE (enrolment queue, via the broker) -------------------------- + +func (s *server) handleEnrolPending(w http.ResponseWriter, r *http.Request) { + if s.broker == nil { + writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") + return + } + pending, err := s.broker.Pending() + if err != nil { + writeErr(w, http.StatusBadGateway, "broker: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"pending": pending}) +} + +// handleEnrolApprove drives the broker's approve (which signs the CSR) and BIRTHS +// the inventory record from the returned enrolment + the optional binding the +// human records at approval time. The approver is THIS signed-in admin's identity +// (the human at the SPA), not the broker's view of the manager service. +func (s *server) handleEnrolApprove(w http.ResponseWriter, r *http.Request) { + if s.broker == nil { + writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") + return + } + id := r.PathValue("id") + + // Optional binding (asset tag / switch port / location) — the physical↔crypto + // tie the operator records. Empty body is fine. + var binding Binding + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&binding); err != nil && err != io.EOF { + writeErr(w, http.StatusBadRequest, "invalid binding JSON: "+err.Error()) + return + } + + e, err := s.broker.Approve(id) + if err != nil { + writeErr(w, http.StatusBadGateway, "broker approve: "+err.Error()) + return + } + if e.CertSerial == "" { + writeErr(w, http.StatusBadGateway, "broker approved but returned no cert serial") + return + } + + host := &Host{ + Identity: Identity{CertSerial: e.CertSerial, Hostnqn: e.Hostnqn, MAC: e.Context.MAC, EKHash: e.Context.EKHash}, + Binding: binding, + Enrolled: Enrolled{Approver: adminName(r), At: time.Now().UTC(), Fingerprint: e.Fingerprint}, + } + if err := s.store.Upsert(host); err != nil { + writeErr(w, http.StatusInternalServerError, "inventory birth: "+err.Error()) + return + } + log.Printf("audit ENROL-APPROVE serial=%s hostnqn=%s approver=%q enrol-id=%s", e.CertSerial, e.Hostnqn, adminName(r), id) + + born, _ := s.store.Get(e.CertSerial) + writeJSON(w, http.StatusOK, born) +} + +// ---- METRICS (InfluxDB time-series via the manager gateway) --------------- + +func (s *server) handleMetrics(w http.ResponseWriter, r *http.Request) { + if s.influx == nil { + writeErr(w, http.StatusServiceUnavailable, "metrics not configured (no InfluxDB wired)") + return + } + serial := r.PathValue("serial") + if _, ok := s.store.Get(serial); !ok { + writeErr(w, http.StatusNotFound, "no such host") + return + } + field := r.URL.Query().Get("field") + if field == "" { + field = "tmpfs_used_pct" + } + rng := time.Hour + if rs := r.URL.Query().Get("range"); rs != "" { + d, err := time.ParseDuration(rs) + if err != nil || d <= 0 { + writeErr(w, http.StatusBadRequest, "invalid range (use e.g. 30m, 1h, 24h)") + return + } + rng = d + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + pts, err := s.influx.QueryField(ctx, serial, field, rng) + if err != nil { + writeErr(w, http.StatusBadGateway, "metrics query: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"serial": serial, "field": field, "range": rng.String(), "points": pts}) +} + +func (s *server) handleEnrolDeny(w http.ResponseWriter, r *http.Request) { + if s.broker == nil { + writeErr(w, http.StatusServiceUnavailable, "authorise plane not configured (no enrol-broker wired)") + return + } + if err := s.broker.Deny(r.PathValue("id")); err != nil { + writeErr(w, http.StatusBadGateway, "broker deny: "+err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// ---- JSON response helpers ------------------------------------------------ + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func writeErr(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +// ---- dev seed ------------------------------------------------------------- + +// seedHosts injects a couple of demo records so the SPA has something to render +// before real enrolment/agent data exists. Gated by FLEET_MGR_SEED=true; never +// overwrites an existing record. Returns how many it added. +func seedHosts(store Store, hostnqnBase string) int { + now := time.Now().UTC() + demo := []*Host{ + { + Identity: Identity{CertSerial: "a1b2c3d4e5f60001", Hostnqn: hostnqnBase + "a1b2c3d4e5f60001", MAC: "de:ad:be:ef:00:01", StarshipName: "comet-beacon-54"}, + Binding: Binding{AssetTag: "KIOSK-0001", SwitchPort: "sw1/12", Location: "Foyer"}, + Enrolled: Enrolled{Approver: "seed", At: now.Add(-72 * time.Hour), Fingerprint: "amber-castle-river-delta-orbit-nova"}, + Version: Version{ImageVersion: "0.5.0", Kernel: "5.14.0-687.10.1.el9_8.0.1.x86_64"}, + Health: Health{LoadAvg: 0.42, CPUPct: 7, TempC: 44, MemUsedPct: 31, TmpfsUsedPct: 58, NVMeRoot: "ok", Auth: "up", SELinux: "enforcing", Chrony: "synced", KioskActive: true}, + Usage: Usage{KioskURL: "http://boot.fleet.lab/kiosk/", SessionUptime: "3h 12m", Display: "1920x1080"}, + Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://boot.fleet.lab/kiosk/"}, + Online: true, FirstSeen: now.Add(-72 * time.Hour), LastSeen: now.Add(-15 * time.Second), + }, + { + Identity: Identity{CertSerial: "a1b2c3d4e5f60002", Hostnqn: hostnqnBase + "a1b2c3d4e5f60002", MAC: "de:ad:be:ef:00:02", StarshipName: "orion-drifter-07"}, + Binding: Binding{AssetTag: "KIOSK-0002", SwitchPort: "sw1/13", Location: "Lab B"}, + Enrolled: Enrolled{Approver: "seed", At: now.Add(-48 * time.Hour), Fingerprint: "violet-harbor-signal-quartz-piano-lunar"}, + Version: Version{ImageVersion: "0.4.2", Kernel: "5.14.0-687.10.1.el9_8.0.1.x86_64"}, + Health: Health{LoadAvg: 0.11, CPUPct: 3, TempC: 39, MemUsedPct: 27, TmpfsUsedPct: 41, NVMeRoot: "ok", Auth: "up", SELinux: "enforcing", Chrony: "synced", KioskActive: true}, + Usage: Usage{KioskURL: "http://boot.fleet.lab/kiosk/", SessionUptime: "1d 4h 9m", Display: "1920x1080"}, + Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://boot.fleet.lab/kiosk/"}, // running 0.4.2 → drift + Online: false, FirstSeen: now.Add(-48 * time.Hour), LastSeen: now.Add(-9 * time.Minute), + }, + } + added := 0 + for _, h := range demo { + if _, exists := store.Get(h.Identity.CertSerial); exists { + continue + } + if store.Upsert(h) == nil { + added++ + } + } + return added +} diff --git a/serving/fleet-manager/agent-cert-renewer.sh b/serving/fleet-manager/agent-cert-renewer.sh new file mode 100755 index 0000000..699b9eb --- /dev/null +++ b/serving/fleet-manager/agent-cert-renewer.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# agent-cert-renewer.sh — issue-once + auto-renew the fleet-manager AGENT-PLANE +# server cert from the co-located fleet step-ca. Runs in the smallstep step-cli +# image as the `agent-cert-renewer` compose sidecar (profile cert-auto). +# +# This replaces the manual `server-pki.sh` + `docker exec … step ca certificate` +# dance and the 90-day re-mint chore: +# - FIRST run (no cert yet): issues the cert via the 'enrol' provisioner, with +# ALL SANs hosts might dial (manager.fleet.lab + the host FQDN). +# - THEREAFTER: `step ca renew --daemon` rotates it well before the CA's cap. +# Renewal RE-ISSUES THE SAME IDENTITY (subject + SANs preserved), so the SAN +# drift that once broke agent TLS cannot recur. +# The manager hot-reloads the rotated pair via GetCertificate (certreload.go) — +# no restart, no dropped agent streams. Files are chowned to the manager's +# nonroot uid so the distroless manager can read the key. +# +# Renewal authenticates to the CA with the CERT ITSELF (mTLS) — the provisioner +# password is only needed for the initial issuance. +set -eu + +CRT=${AGENT_CERT:-/pki/agent/server.crt} +KEY=${AGENT_KEY:-/pki/agent/server.key} +BUNDLE=${AGENT_CLIENT_CA:-/pki/agent/fleet-ca.crt} +CA_URL=${CA_URL:-https://localhost:8443} +CA_ROOT=${CA_ROOT:-/ca/root_ca.crt} +CA_INT=${CA_INTERMEDIATE:-/ca/intermediate_ca.crt} +PROVISIONER=${PROVISIONER:-enrol} +PW_FILE=${PROVISIONER_PASSWORD_FILE:-/ca/password} +SANS=${AGENT_SANS:-manager.fleet.lab} +EXPIRES_IN=${RENEW_EXPIRES_IN:-720h} # renew when < this remains (of the CA's cap) +NOT_AFTER=${CERT_NOT_AFTER:-2160h} # first-issue lifetime (must be <= CA max) +OWNER=${OWNER:-65532:65532} # the distroless manager's nonroot uid:gid + +primary=${SANS%%,*} +san_args="" +oldifs=$IFS; IFS=, +for s in $SANS; do san_args="$san_args --san $s"; done +IFS=$oldifs + +fixperms() { + chown "$OWNER" "$CRT" "$KEY" 2>/dev/null || true + chmod 0644 "$CRT" 2>/dev/null || true + chmod 0600 "$KEY" 2>/dev/null || true +} + +# Client-verify bundle (intermediate + root). Build it if absent so a fresh +# bring-up needs only this sidecar, not a separate server-pki.sh run. The manager +# loads it once at startup — the root/intermediate don't rotate on this cadence. +if [ ! -s "$BUNDLE" ]; then + { [ -s "$CA_INT" ] && cat "$CA_INT"; cat "$CA_ROOT"; } > "$BUNDLE" + chown "$OWNER" "$BUNDLE" 2>/dev/null || true + chmod 0644 "$BUNDLE" 2>/dev/null || true + echo "agent-cert-renewer: wrote client-verify bundle $BUNDLE" +fi + +# Issue once if there's no cert yet (provisioner + password). Renewal afterwards +# needs neither. +if [ ! -s "$CRT" ] || [ ! -s "$KEY" ]; then + echo "agent-cert-renewer: issuing CN=$primary (SANs=$SANS) from $CA_URL" + # shellcheck disable=SC2086 # san_args is intentionally word-split + step ca certificate "$primary" "$CRT" "$KEY" \ + --ca-url "$CA_URL" --root "$CA_ROOT" \ + --provisioner "$PROVISIONER" --provisioner-password-file "$PW_FILE" \ + --not-after "$NOT_AFTER" --force $san_args + fixperms +fi + +echo "agent-cert-renewer: watching $CRT (renew when < $EXPIRES_IN remains)" +exec step ca renew --daemon --expires-in "$EXPIRES_IN" \ + --ca-url "$CA_URL" --root "$CA_ROOT" \ + --exec "sh -c 'chown $OWNER $CRT $KEY; chmod 0644 $CRT; chmod 0600 $KEY'" \ + "$CRT" "$KEY" diff --git a/serving/fleet-manager/agent.go b/serving/fleet-manager/agent.go new file mode 100644 index 0000000..439d2ac --- /dev/null +++ b/serving/fleet-manager/agent.go @@ -0,0 +1,323 @@ +// agent.go — the gRPC agent plane: the long-lived bidi stream each host holds. +// +// mTLS is verified against the FLEET CA (the host's reused TPM identity cert), +// distinct from the admin plane's dedicated admin CA. The host's cert SERIAL is +// the inventory key, taken from the peer cert — never self-asserted — so a host +// can only report/receive its own state. +// +// Concurrency model per Session: one receive loop (host->manager) in the handler +// goroutine + one send loop draining a per-connection channel (manager->host). +// The channel decouples admin-initiated pushes (SetDesired, Command) from the +// stream's Send, which must not be called concurrently. Inventory is the source +// of truth; the live stream is only the low-latency delivery path — a host that +// is offline or whose send buffer is full simply gets the current desired-state +// on its next (re)connect. +package main + +import ( + "context" + "crypto/tls" + "crypto/x509" + "fmt" + "log" + "net" + "os" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/keepalive" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + + fleetpb "fleet-manager/proto" +) + +// MetricsSink receives each health snapshot for time-series storage. The no-op +// sink is the default; the InfluxDB sink lands in checkpoint 4. +type MetricsSink interface { + WriteSnapshot(serial, hostnqn, starship string, s *fleetpb.HealthSnapshot, at time.Time) +} + +type noopSink struct{} + +func (noopSink) WriteSnapshot(string, string, string, *fleetpb.HealthSnapshot, time.Time) {} + +// agentConn is one live host stream's downstream (manager->host) side. +type agentConn struct { + serial string + send chan *fleetpb.ManagerDown + done chan struct{} + once sync.Once +} + +func (c *agentConn) close() { c.once.Do(func() { close(c.done) }) } + +// agentHub implements the FleetAgent gRPC service and holds the live-stream +// registry that admin actions push through. +type agentHub struct { + fleetpb.UnimplementedFleetAgentServer + store Store + sink MetricsSink + + mu sync.Mutex + conns map[string]*agentConn // serial -> live connection +} + +func newAgentHub(store Store, sink MetricsSink) *agentHub { + if sink == nil { + sink = noopSink{} + } + return &agentHub{store: store, sink: sink, conns: map[string]*agentConn{}} +} + +// register installs a connection, evicting any prior stream for the same serial +// (a reconnect supersedes a lingering old one). +func (h *agentHub) register(c *agentConn) { + h.mu.Lock() + if old := h.conns[c.serial]; old != nil { + old.close() + } + h.conns[c.serial] = c + h.mu.Unlock() +} + +func (h *agentHub) deregister(c *agentConn) { + h.mu.Lock() + if h.conns[c.serial] == c { // don't clobber a newer reconnect + delete(h.conns, c.serial) + } + h.mu.Unlock() + c.close() +} + +// pushDown delivers a message to a host's live stream if connected. Returns false +// if the host is offline or its buffer is backed up (it will reconcile on its +// next connect from persisted desired-state — no message is "lost" that matters). +func (h *agentHub) pushDown(serial string, m *fleetpb.ManagerDown) bool { + h.mu.Lock() + c := h.conns[serial] + h.mu.Unlock() + if c == nil { + return false + } + select { + case c.send <- m: + return true + case <-c.done: + return false + default: + return false // slow consumer; drop — desired-state is durable in inventory + } +} + +// pushDesired sends a host its current desired-state over the live stream. +func (h *agentHub) pushDesired(serial string, d Desired) bool { + return h.pushDown(serial, desiredMsg(d)) +} + +// Session is the bidirectional stream RPC. +func (h *agentHub) Session(stream fleetpb.FleetAgent_SessionServer) error { + serial, err := peerSerial(stream.Context()) + if err != nil { + return status.Error(codes.Unauthenticated, err.Error()) + } + // A host must be enrolled (inventory record exists) before it can stream. + host, ok := h.store.Get(serial) + if !ok { + return status.Errorf(codes.PermissionDenied, "host %s is not enrolled", serial) + } + + c := &agentConn{serial: serial, send: make(chan *fleetpb.ManagerDown, 16), done: make(chan struct{})} + h.register(c) + h.store.SetOnline(serial, true, time.Now().UTC()) + log.Printf("agent connected: %s (%s)", serial, host.Identity.StarshipName) + defer func() { + h.deregister(c) + h.store.SetOnline(serial, false, time.Now().UTC()) + log.Printf("agent disconnected: %s", serial) + }() + + // Deliver current desired-state immediately on connect. + c.send <- desiredMsg(host.Desired) + + // Sender goroutine: the ONLY caller of stream.Send. + sendErr := make(chan error, 1) + go func() { + for { + select { + case m := <-c.send: + if err := stream.Send(m); err != nil { + sendErr <- err + return + } + case <-c.done: + return + } + } + }() + + // Receive loop (this goroutine). + for { + select { + case err := <-sendErr: + return err + default: + } + up, err := stream.Recv() + if err != nil { + return err // EOF / transport error -> disconnect (defer cleans up) + } + h.handleUp(serial, up) + } +} + +func (h *agentHub) handleUp(serial string, up *fleetpb.AgentUp) { + switch m := up.GetMsg().(type) { + case *fleetpb.AgentUp_Hello: + hello := m.Hello + host, ok := h.store.Get(serial) + if !ok { + return + } + _, _, _ = h.store.ReportState(serial, + Version{ImageVersion: hello.GetImageVersion(), RootfsDigest: hello.GetRootfsDigest(), Kernel: hello.GetKernel()}, + host.Health, // unchanged until first snapshot + Usage{KioskURL: hello.GetKioskUrl(), Display: host.Usage.Display}, + time.Now().UTC()) + case *fleetpb.AgentUp_Snapshot: + snap := m.Snapshot + at := time.Now().UTC() + if snap.GetAtUnix() > 0 { + at = time.Unix(snap.GetAtUnix(), 0).UTC() + } + host, ok := h.store.Get(serial) + if !ok { + return + } + h.store.ReportState(serial, host.Version, snapshotToHealth(snap), snapshotToUsage(snap, host.Usage), at) + h.sink.WriteSnapshot(serial, host.Identity.Hostnqn, host.Identity.StarshipName, snap, at) + case *fleetpb.AgentUp_Result: + res := m.Result + log.Printf("agent %s command %s result: ok=%v %s", serial, res.GetCommandId(), res.GetOk(), res.GetDetail()) + } +} + +// ---- conversions ---------------------------------------------------------- + +func desiredMsg(d Desired) *fleetpb.ManagerDown { + var rebootAt int64 + if d.RebootAt != nil { + rebootAt = d.RebootAt.Unix() + } + return &fleetpb.ManagerDown{Msg: &fleetpb.ManagerDown_Desired{Desired: &fleetpb.DesiredState{ + TargetVersion: d.TargetVersion, + KioskUrl: d.KioskURL, + RebootAtUnix: rebootAt, + Drain: d.Drain, + }}} +} + +func snapshotToHealth(s *fleetpb.HealthSnapshot) Health { + return Health{ + LoadAvg: s.GetLoadAvg(), + CPUPct: s.GetCpuPct(), + TempC: s.GetTempC(), + MemUsedPct: s.GetMemUsedPct(), + TmpfsUsedPct: s.GetTmpfsUsedPct(), + NVMeRoot: s.GetNvmeRoot(), + Auth: s.GetAuth(), + SELinux: s.GetSelinux(), + Chrony: s.GetChrony(), + ChronyOffset: s.GetChronyOffsetSeconds(), + ChronySource: s.GetChronySource(), + ChronyStratum: s.GetChronyStratum(), + KioskActive: s.GetKioskActive(), + UptimeSeconds: s.GetUptimeSeconds(), + } +} + +func snapshotToUsage(s *fleetpb.HealthSnapshot, prev Usage) Usage { + u := prev + u.SessionUptime = s.GetSessionUptime() + u.ChromiumCrashes = int(s.GetChromiumCrashes()) + if s.GetDisplay() != "" { + u.Display = s.GetDisplay() + } + return u +} + +// ---- mTLS identity -------------------------------------------------------- + +// peerSerial extracts the lowercase-hex serial of the client's mTLS leaf cert — +// the inventory key. It is trusted because RequireAndVerifyClientCert already +// verified the chain to the fleet CA. +func peerSerial(ctx context.Context) (string, error) { + p, ok := peer.FromContext(ctx) + if !ok { + return "", fmt.Errorf("no peer in context") + } + tlsInfo, ok := p.AuthInfo.(credentials.TLSInfo) + if !ok || len(tlsInfo.State.PeerCertificates) == 0 { + return "", fmt.Errorf("no client certificate") + } + return serialHex(tlsInfo.State.PeerCertificates[0]), nil +} + +// serialHex renders a cert serial as lowercase hex — the same form the broker +// stores (store.go) so the value matches the inventory key. +func serialHex(cert *x509.Certificate) string { + return fmt.Sprintf("%x", cert.SerialNumber) +} + +// ---- gRPC server wiring --------------------------------------------------- + +// agentServer builds the configured gRPC server (mTLS against the fleet CA + +// keepalive) with the hub registered. Split from the listen/serve so tests can +// drive it on an ephemeral listener. +func agentServer(cfg config, hub *agentHub) (*grpc.Server, error) { + // GetCertificate (not a fixed Certificates slice) so the agent-cert-renewer + // sidecar can rotate server.crt/key on disk and the manager picks it up on the + // next handshake without a restart. See certreload.go. + reloader, err := newCertReloader(cfg.agentCert, cfg.agentKey) + if err != nil { + return nil, fmt.Errorf("agent server cert: %w", err) + } + caPEM, err := os.ReadFile(cfg.agentClientCA) + if err != nil { + return nil, fmt.Errorf("fleet CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no certificates parsed from FLEET_MGR_AGENT_CLIENT_CA") + } + creds := credentials.NewTLS(&tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: reloader.GetCertificate, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: pool, + }) + gs := grpc.NewServer( + grpc.Creds(creds), + grpc.KeepaliveParams(keepalive.ServerParameters{Time: 30 * time.Second, Timeout: 10 * time.Second}), + grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: 10 * time.Second, PermitWithoutStream: true}), + ) + fleetpb.RegisterFleetAgentServer(gs, hub) + return gs, nil +} + +// serveAgent starts the gRPC agent listener with mTLS against the fleet CA. +func serveAgent(cfg config, hub *agentHub) error { + gs, err := agentServer(cfg, hub) + if err != nil { + return err + } + lis, err := net.Listen("tcp", cfg.agentAddr) + if err != nil { + return err + } + log.Printf("fleet-manager agent (gRPC/mTLS) listening on %s", cfg.agentAddr) + return gs.Serve(lis) +} diff --git a/serving/fleet-manager/agent_test.go b/serving/fleet-manager/agent_test.go new file mode 100644 index 0000000..e3b928c --- /dev/null +++ b/serving/fleet-manager/agent_test.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "path/filepath" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + fleetpb "fleet-manager/proto" +) + +// TestAgentSession drives the full agent plane over real mTLS: a host dials in +// with a fleet-CA client cert, the manager derives its serial from that cert, +// pushes the current desired-state on connect, ingests a Hello + HealthSnapshot +// into inventory, and delivers a live desired-state change over the same stream. +func TestAgentSession(t *testing.T) { + dir := t.TempDir() + caCert, caKey := makeCA(t) + caPath := writeFile(t, dir, "ca.crt", pemCert(caCert.Raw)) + + srvCertPEM, srvKeyPEM := makeLeaf(t, caCert, caKey, "fleet-manager", big.NewInt(1), true) + srvCrt := writeFile(t, dir, "srv.crt", srvCertPEM) + srvKey := writeFile(t, dir, "srv.key", srvKeyPEM) + + const serialHexStr = "1234abcd" + cliCertPEM, cliKeyPEM := makeLeaf(t, caCert, caKey, "fleet-host", big.NewInt(0x1234abcd), false) + cliCert, err := tls.X509KeyPair(cliCertPEM, cliKeyPEM) + if err != nil { + t.Fatalf("client keypair: %v", err) + } + + store, err := newJSONStore("") // in-memory + if err != nil { + t.Fatalf("store: %v", err) + } + if err := store.Upsert(&Host{ + Identity: Identity{CertSerial: serialHexStr, Hostnqn: "nqn.test:host-" + serialHexStr, StarshipName: "test-ship"}, + Desired: Desired{TargetVersion: "0.5.0", KioskURL: "http://k/"}, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + hub := newAgentHub(store, nil) + gs, err := agentServer(config{agentCert: srvCrt, agentKey: srvKey, agentClientCA: caPath}, hub) + if err != nil { + t.Fatalf("agentServer: %v", err) + } + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + go gs.Serve(lis) + defer gs.Stop() + + // ---- host dials in ---- + caPool := x509.NewCertPool() + caPool.AddCert(caCert) + creds := credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cliCert}, + RootCAs: caPool, + ServerName: "localhost", + }) + conn, err := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(creds)) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stream, err := fleetpb.NewFleetAgentClient(conn).Session(ctx) + if err != nil { + t.Fatalf("session: %v", err) + } + + // 1. current desired-state arrives on connect. + down, err := stream.Recv() + if err != nil { + t.Fatalf("recv desired: %v", err) + } + if got := down.GetDesired().GetTargetVersion(); got != "0.5.0" { + t.Fatalf("initial desired target_version = %q, want 0.5.0", got) + } + + // 2. host reports Hello (running an older version) + a health snapshot. + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Hello{Hello: &fleetpb.Hello{ImageVersion: "0.4.2", Kernel: "5.14.0"}}}); err != nil { + t.Fatalf("send hello: %v", err) + } + if err := stream.Send(&fleetpb.AgentUp{Msg: &fleetpb.AgentUp_Snapshot{Snapshot: &fleetpb.HealthSnapshot{ + TmpfsUsedPct: 63, NvmeRoot: "ok", Auth: "up", Selinux: "enforcing", KioskActive: true, + }}}); err != nil { + t.Fatalf("send snapshot: %v", err) + } + + // 3. inventory reflects the report + online presence. + waitFor(t, 3*time.Second, func() bool { + h, ok := store.Get(serialHexStr) + return ok && h.Online && h.Version.ImageVersion == "0.4.2" && h.Health.TmpfsUsedPct == 63 && h.Health.NVMeRoot == "ok" + }, "inventory did not reflect the agent report") + + // 4. an admin changes desired-state -> live push over the same stream. + if _, _, err := store.SetDesired(serialHexStr, Desired{TargetVersion: "0.6.0"}); err != nil { + t.Fatalf("set desired: %v", err) + } + if !hub.pushDesired(serialHexStr, Desired{TargetVersion: "0.6.0"}) { + t.Fatal("pushDesired reported the host as not live, but it is connected") + } + down2, err := stream.Recv() + if err != nil { + t.Fatalf("recv desired push: %v", err) + } + if got := down2.GetDesired().GetTargetVersion(); got != "0.6.0" { + t.Fatalf("pushed desired target_version = %q, want 0.6.0", got) + } +} + +// TestAgentRejectsUnenrolled: a valid fleet-CA cert whose serial has no inventory +// record must be refused (enrolment gates the stream). +func TestAgentRejectsUnenrolled(t *testing.T) { + dir := t.TempDir() + caCert, caKey := makeCA(t) + caPath := writeFile(t, dir, "ca.crt", pemCert(caCert.Raw)) + srvCertPEM, srvKeyPEM := makeLeaf(t, caCert, caKey, "fleet-manager", big.NewInt(1), true) + srvCrt := writeFile(t, dir, "srv.crt", srvCertPEM) + srvKey := writeFile(t, dir, "srv.key", srvKeyPEM) + cliCertPEM, cliKeyPEM := makeLeaf(t, caCert, caKey, "fleet-host", big.NewInt(0xdead), false) + cliCert, _ := tls.X509KeyPair(cliCertPEM, cliKeyPEM) + + store, _ := newJSONStore("") // empty — the host is NOT enrolled + hub := newAgentHub(store, nil) + gs, err := agentServer(config{agentCert: srvCrt, agentKey: srvKey, agentClientCA: caPath}, hub) + if err != nil { + t.Fatalf("agentServer: %v", err) + } + lis, _ := net.Listen("tcp", "127.0.0.1:0") + go gs.Serve(lis) + defer gs.Stop() + + caPool := x509.NewCertPool() + caPool.AddCert(caCert) + creds := credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{cliCert}, RootCAs: caPool, ServerName: "localhost"}) + conn, _ := grpc.NewClient(lis.Addr().String(), grpc.WithTransportCredentials(creds)) + defer conn.Close() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stream, err := fleetpb.NewFleetAgentClient(conn).Session(ctx) + if err != nil { + t.Fatalf("session open: %v", err) + } + if _, err := stream.Recv(); err == nil { + t.Fatal("expected the stream to be rejected for an unenrolled host, got nil error") + } +} + +// ---- test helpers --------------------------------------------------------- + +func makeCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ca key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-fleet-ca"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("ca cert: %v", err) + } + cert, _ := x509.ParseCertificate(der) + return cert, key +} + +func makeLeaf(t *testing.T, ca *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, serial *big.Int, server bool) (certPEM, keyPEM []byte) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("leaf key: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + if server { + tmpl.ExtKeyUsage = append(tmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth) + tmpl.DNSNames = []string{"localhost"} + tmpl.IPAddresses = []net.IP{net.ParseIP("127.0.0.1")} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca, &key.PublicKey, caKey) + if err != nil { + t.Fatalf("leaf cert: %v", err) + } + kb, _ := x509.MarshalECPrivateKey(key) + return pemCert(der), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb}) +} + +func pemCert(der []byte) []byte { + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func writeFile(t *testing.T, dir, name string, b []byte) string { + t.Helper() + p := filepath.Join(dir, name) + if err := os.WriteFile(p, b, 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p +} + +func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal(msg) +} diff --git a/serving/fleet-manager/auth_test.go b/serving/fleet-manager/auth_test.go new file mode 100644 index 0000000..88df5f5 --- /dev/null +++ b/serving/fleet-manager/auth_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +func TestEnrollTokenRoundTrip(t *testing.T) { + key := []byte("0123456789abcdef0123456789abcdef") + tok, err := mintEnrollToken(key, "alice", 10*time.Minute) + if err != nil { + t.Fatalf("mint: %v", err) + } + claims, err := verifyEnrollToken(key, tok) + if err != nil { + t.Fatalf("verify: %v", err) + } + if claims.Name != "alice" || claims.JTI == "" { + t.Fatalf("claims = %+v", claims) + } + + // wrong key rejected + if _, err := verifyEnrollToken([]byte("wrongwrongwrongwrongwrongwrongwr"), tok); err == nil { + t.Error("expected wrong-key rejection") + } + // tampered signature rejected + if _, err := verifyEnrollToken(key, tok[:len(tok)-2]+"xx"); err == nil { + t.Error("expected tamper rejection") + } + // expired rejected + exp, _ := mintEnrollToken(key, "bob", -time.Minute) + if _, err := verifyEnrollToken(key, exp); err == nil { + t.Error("expected expiry rejection") + } +} + +func TestSessionMiddleware(t *testing.T) { + s := &server{cfg: config{}, sessions: newSessionStore(time.Hour)} // adminInsecure=false + id, csrf := s.sessions.create("alice") + + var reached string + guarded := s.requireSession(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + reached = adminName(r) + w.WriteHeader(http.StatusOK) + })) + + // no cookie -> 401 + rw := httptest.NewRecorder() + guarded.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/hosts", nil)) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("no cookie: got %d want 401", rw.Code) + } + + withCookie := func(method string, csrfHdr string) *http.Request { + r := httptest.NewRequest(method, "/api/x", nil) + r.AddCookie(&http.Cookie{Name: sessionCookie, Value: id}) + if csrfHdr != "" { + r.Header.Set(csrfHeader, csrfHdr) + } + return r + } + + // valid GET -> reaches handler as alice + reached = "" + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodGet, "")) + if rw.Code != http.StatusOK || reached != "alice" { + t.Fatalf("valid GET: code=%d admin=%q", rw.Code, reached) + } + + // mutating POST without CSRF -> 403 + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodPost, "")) + if rw.Code != http.StatusForbidden { + t.Fatalf("POST no CSRF: got %d want 403", rw.Code) + } + + // mutating POST with correct CSRF -> reaches handler + reached = "" + rw = httptest.NewRecorder() + guarded.ServeHTTP(rw, withCookie(http.MethodPost, csrf)) + if rw.Code != http.StatusOK || reached != "alice" { + t.Fatalf("POST with CSRF: code=%d admin=%q", rw.Code, reached) + } + + // unknown cookie -> 401 + rw = httptest.NewRecorder() + bad := httptest.NewRequest(http.MethodGet, "/api/x", nil) + bad.AddCookie(&http.Cookie{Name: sessionCookie, Value: "nope"}) + guarded.ServeHTTP(rw, bad) + if rw.Code != http.StatusUnauthorized { + t.Fatalf("bad cookie: got %d want 401", rw.Code) + } +} + +func TestCredStorePersistence(t *testing.T) { + path := t.TempDir() + "/admins.json" + s, err := newCredStore(path) + if err != nil { + t.Fatalf("new: %v", err) + } + u := s.getOrCreate("alice") + handle := u.IDb + if err := s.addCredential("alice", webauthn.Credential{ID: []byte("cred-1"), PublicKey: []byte("pk")}); err != nil { + t.Fatalf("add: %v", err) + } + s.markTokenUsed("jti-1", time.Now().Add(time.Hour).Unix()) + + // reload from disk + s2, err := newCredStore(path) + if err != nil { + t.Fatalf("reload: %v", err) + } + if s2.count() != 1 { + t.Fatalf("admins after reload = %d", s2.count()) + } + got, ok := s2.byHandle(handle) + if !ok || got.Name != "alice" || len(got.Creds) != 1 { + t.Fatalf("byHandle after reload: ok=%v %+v", ok, got) + } + if !s2.tokenUsed("jti-1") { + t.Error("used-token ledger did not survive reload") + } + if s2.tokenUsed("jti-unknown") { + t.Error("unknown token reported used") + } +} diff --git a/serving/fleet-manager/authapi.go b/serving/fleet-manager/authapi.go new file mode 100644 index 0000000..ccc8474 --- /dev/null +++ b/serving/fleet-manager/authapi.go @@ -0,0 +1,177 @@ +// authapi.go — the WebAuthn ceremony endpoints (unauthenticated: they ARE the +// gate) + logout + whoami. register/* is authorised by the one-time enrol token +// from the CLI; login/* is usernameless (discoverable passkey). A successful +// login mints the server-side session (session.go). +package main + +import ( + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" +) + +// ceremony holds the in-flight WebAuthn SessionData between begin and finish, +// plus the enrol claims for a registration. Short-lived, in-memory. +type ceremony struct { + sess *webauthn.SessionData + name string + jti string + exp int64 + expires time.Time +} + +type ceremonyStore struct { + mu sync.Mutex + m map[string]ceremony +} + +func newCeremonyStore() *ceremonyStore { return &ceremonyStore{m: map[string]ceremony{}} } + +func (c *ceremonyStore) put(sess *webauthn.SessionData, name, jti string, exp int64) string { + id := randToken() + c.mu.Lock() + now := time.Now() + for k, v := range c.m { + if now.After(v.expires) { + delete(c.m, k) + } + } + c.m[id] = ceremony{sess: sess, name: name, jti: jti, exp: exp, expires: now.Add(5 * time.Minute)} + c.mu.Unlock() + return id +} + +func (c *ceremonyStore) take(id string) (ceremony, bool) { + c.mu.Lock() + defer c.mu.Unlock() + cer, ok := c.m[id] + if ok { + delete(c.m, id) + } + if ok && time.Now().After(cer.expires) { + return ceremony{}, false + } + return cer, ok +} + +func (s *server) authRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write([]byte("ok\n")) + }) + if s.wa == nil { + return // insecure/dev mode has no passkey ceremonies + } + mux.HandleFunc("POST /auth/register/begin", s.handleRegisterBegin) + mux.HandleFunc("POST /auth/register/finish", s.handleRegisterFinish) + mux.HandleFunc("POST /auth/login/begin", s.handleLoginBegin) + mux.HandleFunc("POST /auth/login/finish", s.handleLoginFinish) + mux.HandleFunc("POST /auth/logout", s.handleLogout) +} + +// register/begin — authorised by the one-time enrol token (?token=). +func (s *server) handleRegisterBegin(w http.ResponseWriter, r *http.Request) { + claims, err := verifyEnrollToken(s.enrollKey, r.URL.Query().Get("token")) + if err != nil { + writeErr(w, http.StatusUnauthorized, "invalid enrolment token: "+err.Error()) + return + } + if s.creds.tokenUsed(claims.JTI) { + writeErr(w, http.StatusConflict, "enrolment token already used") + return + } + user := s.creds.getOrCreate(claims.Name) + sel := protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + RequireResidentKey: protocol.ResidentKeyRequired(), + UserVerification: protocol.VerificationRequired, + } + options, sessionData, err := s.wa.BeginRegistration(user, webauthn.WithAuthenticatorSelection(sel)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "begin registration: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, claims.Name, claims.JTI, claims.Exp), "options": options}) +} + +func (s *server) handleRegisterFinish(w http.ResponseWriter, r *http.Request) { + cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) + if !ok || cer.sess == nil { + writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") + return + } + if s.creds.tokenUsed(cer.jti) { + writeErr(w, http.StatusConflict, "enrolment token already used") + return + } + user := s.creds.getOrCreate(cer.name) + cred, err := s.wa.FinishRegistration(user, *cer.sess, r) + if err != nil { + writeErr(w, http.StatusBadRequest, "registration failed: "+err.Error()) + return + } + if err := s.creds.addCredential(cer.name, *cred); err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + s.creds.markTokenUsed(cer.jti, cer.exp) + log.Printf("audit ADMIN-ENROL name=%q (passkey registered)", cer.name) + writeJSON(w, http.StatusOK, map[string]any{"admin": cer.name, "registered": true}) +} + +// login/begin — usernameless discoverable login. +func (s *server) handleLoginBegin(w http.ResponseWriter, r *http.Request) { + options, sessionData, err := s.wa.BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + writeErr(w, http.StatusInternalServerError, "begin login: "+err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"cid": s.ceremonies.put(sessionData, "", "", 0), "options": options}) +} + +func (s *server) handleLoginFinish(w http.ResponseWriter, r *http.Request) { + cer, ok := s.ceremonies.take(r.URL.Query().Get("cid")) + if !ok || cer.sess == nil { + writeErr(w, http.StatusBadRequest, "unknown or expired ceremony") + return + } + var matched *adminUser + handler := func(rawID, userHandle []byte) (webauthn.User, error) { + u, ok := s.creds.byHandle(userHandle) + if !ok { + return nil, fmt.Errorf("unknown passkey") + } + matched = u + return u, nil + } + cred, err := s.wa.FinishDiscoverableLogin(handler, *cer.sess, r) + if err != nil || matched == nil { + writeErr(w, http.StatusUnauthorized, "login failed") + return + } + s.creds.updateCredential(matched.IDb, *cred) // persist the signature counter + id, csrf := s.sessions.create(matched.Name) + setSessionCookie(w, id, s.cfg.sessionTTL) + log.Printf("audit ADMIN-LOGIN name=%q", matched.Name) + writeJSON(w, http.StatusOK, map[string]any{"admin": matched.Name, "csrf": csrf}) +} + +func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie(sessionCookie); err == nil { + s.sessions.delete(c.Value) + } + clearSessionCookie(w) + w.WriteHeader(http.StatusNoContent) +} + +// handleWhoami is session-gated (registered under /api): lets the SPA resume an +// existing session on reload and recover the CSRF token. +func (s *server) handleWhoami(w http.ResponseWriter, r *http.Request) { + sess, _ := sessionFrom(r) + writeJSON(w, http.StatusOK, map[string]any{"admin": adminName(r), "csrf": sess.csrf}) +} diff --git a/serving/fleet-manager/backup.sh b/serving/fleet-manager/backup.sh new file mode 100755 index 0000000..a78363f --- /dev/null +++ b/serving/fleet-manager/backup.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# backup.sh — snapshot the fleet-manager durable state to timestamped tarballs. +# +# Two docker named volumes hold everything that isn't reproducible from the image: +# - fleet-data : inventory.json + admins.json + enroll.key (CRITICAL — losing +# admins.json/enroll.key locks every admin out of the UI) +# - influx-data : the metrics time-series (history; regenerates going forward) +# +# Runs a throwaway alpine container to tar each volume read-only into $DEST. Safe +# to run live. Schedule it (systemd timer / cron); copy $DEST offsite. +# +# Usage: +# ./backup.sh [dest-dir] # default dest: ./backups +# COMPOSE_PROJECT_NAME=foo ./backup.sh # if you run compose under another project +# +# Restore (manager stopped): +# docker run --rm -v _fleet-data:/dest -v "$PWD/backups":/src \ +# harbor.bitasylum.org/dockerhub/library/alpine:3.20 \ +# sh -c 'rm -rf /dest/* && tar xzf /src/fleet-data-YYYYmmdd-HHMMSS.tar.gz -C /dest' +set -euo pipefail + +DEST=${1:-./backups} +PROJECT=${COMPOSE_PROJECT_NAME:-fleet-manager} # compose derives volume names as _ +STAMP=$(date -u +%Y%m%d-%H%M%S) +VOLUMES=${BACKUP_VOLUMES:-"fleet-data influx-data"} +# Non-CI images pull through the Harbor mirror; overridable for other registries. +ALPINE_IMAGE=${ALPINE_IMAGE:-harbor.bitasylum.org/dockerhub/library/alpine:3.20} + +mkdir -p "$DEST" +DEST_ABS=$(cd "$DEST" && pwd) + +for key in $VOLUMES; do + vol="${PROJECT}_${key}" + if ! docker volume inspect "$vol" >/dev/null 2>&1; then + echo "backup: SKIP $vol (no such volume — wrong COMPOSE_PROJECT_NAME?)" + continue + fi + out="${key}-${STAMP}.tar.gz" + echo "backup: $vol -> $DEST_ABS/$out" + docker run --rm \ + -v "${vol}:/src:ro" \ + -v "${DEST_ABS}:/backup" \ + "$ALPINE_IMAGE" tar czf "/backup/${out}" -C /src . +done + +# Retention: keep the newest $KEEP of each volume's tarballs (0 = keep all). +KEEP=${BACKUP_KEEP:-14} +if [ "$KEEP" -gt 0 ]; then + for key in $VOLUMES; do + # ls -t for mtime order; our filenames are safe (key + UTC timestamp). + # shellcheck disable=SC2012 + ls -1t "$DEST_ABS/${key}-"*.tar.gz 2>/dev/null | tail -n +"$((KEEP + 1))" | while read -r old; do + echo "backup: prune $old" + rm -f "$old" + done + done +fi + +echo "backup: done ($STAMP) — copy $DEST_ABS offsite." diff --git a/serving/fleet-manager/broker.go b/serving/fleet-manager/broker.go new file mode 100644 index 0000000..c42a314 --- /dev/null +++ b/serving/fleet-manager/broker.go @@ -0,0 +1,121 @@ +// broker.go — the manager's client for the enrol-broker's admin plane. +// +// The v1 "alongside" fold-in (per the plan): the manager owns the AUTHORISE UX +// (the SPA's queue + approve/deny), but the enrol-broker remains the enrolment +// state machine + step-ca signer. The manager drives the broker's admin API over +// mTLS as a service client (the dedicated admin CA gates it, same as a human +// admin), asking for JSON (Accept: application/json) so an approve returns the +// decided Enrolment — cert_serial + hostnqn + context + fingerprint — from which +// the manager BIRTHS the inventory record. Full absorption into one process is a +// later step; this keeps the bench-critical broker untouched. +package main + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +// brokerEnrolment is the subset of the broker's Enrolment JSON we consume. +type brokerEnrolment struct { + ID string `json:"id"` + State string `json:"state"` + Fingerprint string `json:"fingerprint"` + CN string `json:"cn"` + PubKeyHash string `json:"pubkey_hash"` + CertSerial string `json:"cert_serial"` + Hostnqn string `json:"hostnqn"` + Context brokerCtxContext `json:"context"` + CreatedAt time.Time `json:"created_at"` + DecidedBy string `json:"decided_by"` +} + +type brokerCtxContext struct { + MAC string `json:"mac"` + DMIUUID string `json:"dmi_uuid"` + DMISerial string `json:"dmi_serial"` + EKHash string `json:"ek_hash"` +} + +type brokerClient struct { + base string + hc *http.Client +} + +// newBrokerClient builds the mTLS client, or returns nil (authorise disabled) if +// no broker URL is configured — so the manager runs standalone in dev. +func newBrokerClient(cfg config) (*brokerClient, error) { + if cfg.brokerAdminURL == "" { + return nil, nil + } + cert, err := tls.LoadX509KeyPair(cfg.brokerClientCert, cfg.brokerClientKey) + if err != nil { + return nil, fmt.Errorf("broker client cert: %w", err) + } + caPEM, err := os.ReadFile(cfg.brokerCA) + if err != nil { + return nil, fmt.Errorf("broker CA: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEM) { + return nil, fmt.Errorf("no certificates in broker CA %s", cfg.brokerCA) + } + return &brokerClient{ + base: strings.TrimRight(cfg.brokerAdminURL, "/"), + hc: &http.Client{ + Timeout: 40 * time.Second, // approve blocks on step-ca signing + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: pool, + MinVersion: tls.VersionTLS12, + }}, + }, + }, nil +} + +func (c *brokerClient) Pending() ([]brokerEnrolment, error) { + var out []brokerEnrolment + if err := c.do(http.MethodGet, "/pending", &out); err != nil { + return nil, err + } + return out, nil +} + +// Approve signs + decides at the broker and returns the decided Enrolment. +func (c *brokerClient) Approve(id string) (brokerEnrolment, error) { + var e brokerEnrolment + err := c.do(http.MethodPost, "/approve/"+url.PathEscape(id), &e) + return e, err +} + +func (c *brokerClient) Deny(id string) error { + return c.do(http.MethodPost, "/deny/"+url.PathEscape(id), nil) +} + +func (c *brokerClient) do(method, path string, out any) error { + req, err := http.NewRequest(method, c.base+path, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/json") + resp, err := c.hc.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 300 { + return fmt.Errorf("broker %s %s -> %s: %s", method, path, resp.Status, strings.TrimSpace(string(body))) + } + if out != nil && len(body) > 0 { + return json.Unmarshal(body, out) + } + return nil +} diff --git a/serving/fleet-manager/broker_test.go b/serving/fleet-manager/broker_test.go new file mode 100644 index 0000000..cc6f9a2 --- /dev/null +++ b/serving/fleet-manager/broker_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// mockBroker stands in for the enrol-broker admin plane. It asserts the manager +// asks for JSON on approve, and returns a decided Enrolment. +func mockBroker(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /pending", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, []brokerEnrolment{{ + ID: "enr-1", State: "pending", Fingerprint: "amber-castle-river-delta-orbit-nova", + CN: "fleet-host", PubKeyHash: "pk123", Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"}, + }}) + }) + mux.HandleFunc("POST /approve/{id}", func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.Header.Get("Accept"), "application/json") { + t.Errorf("approve did not send Accept: application/json (got %q)", r.Header.Get("Accept")) + } + writeJSON(w, http.StatusOK, brokerEnrolment{ + ID: r.PathValue("id"), State: "approved", + Fingerprint: "amber-castle-river-delta-orbit-nova", CN: "fleet-host", + CertSerial: "beef0009", Hostnqn: "nqn.2026-06.org.example.fleet:host-beef0009", + Context: brokerCtxContext{MAC: "de:ad:be:ef:00:09", EKHash: "ek-abc"}, + }) + }) + mux.HandleFunc("POST /deny/{id}", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + return httptest.NewServer(mux) +} + +func TestAuthoriseApproveBirthsInventory(t *testing.T) { + bs := mockBroker(t) + defer bs.Close() + + store, _ := newJSONStore("") + s := &server{store: store, broker: &brokerClient{base: bs.URL, hc: bs.Client()}} + mux := http.NewServeMux() + s.apiRoutes(mux) + + // pending proxies through + pw := httptest.NewRecorder() + mux.ServeHTTP(pw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) + if pw.Code != http.StatusOK { + t.Fatalf("pending -> %d", pw.Code) + } + var plist struct { + Pending []brokerEnrolment `json:"pending"` + } + json.Unmarshal(pw.Body.Bytes(), &plist) + if len(plist.Pending) != 1 || plist.Pending[0].ID != "enr-1" { + t.Fatalf("pending list = %+v", plist.Pending) + } + + // approve with a binding -> inventory record born + aw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/enrol/enr-1/approve", + strings.NewReader(`{"asset_tag":"KIOSK-9","switch_port":"sw1/9","location":"Bay 3"}`)) + mux.ServeHTTP(aw, req) + if aw.Code != http.StatusOK { + t.Fatalf("approve -> %d: %s", aw.Code, aw.Body.String()) + } + + h, ok := store.Get("beef0009") + if !ok { + t.Fatal("inventory record was not born on approve") + } + if h.Identity.Hostnqn != "nqn.2026-06.org.example.fleet:host-beef0009" { + t.Errorf("hostnqn = %q", h.Identity.Hostnqn) + } + if h.Identity.MAC != "de:ad:be:ef:00:09" || h.Identity.EKHash != "ek-abc" { + t.Errorf("identity context not carried: %+v", h.Identity) + } + if h.Binding.AssetTag != "KIOSK-9" || h.Binding.SwitchPort != "sw1/9" || h.Binding.Location != "Bay 3" { + t.Errorf("binding not recorded: %+v", h.Binding) + } + if h.Enrolled.Fingerprint != "amber-castle-river-delta-orbit-nova" { + t.Errorf("fingerprint = %q", h.Enrolled.Fingerprint) + } + if h.Enrolled.Approver != "dev-insecure" { // no client cert in the test request + t.Errorf("approver = %q, want the manager admin CN", h.Enrolled.Approver) + } +} + +func TestAuthoriseDisabledWhenNoBroker(t *testing.T) { + store, _ := newJSONStore("") + s := &server{store: store, broker: nil} // authorise not configured + mux := http.NewServeMux() + s.apiRoutes(mux) + + rw := httptest.NewRecorder() + mux.ServeHTTP(rw, httptest.NewRequest(http.MethodGet, "/api/enrol/pending", nil)) + if rw.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503 when no broker, got %d", rw.Code) + } +} diff --git a/serving/fleet-manager/certreload.go b/serving/fleet-manager/certreload.go new file mode 100644 index 0000000..cb221a7 --- /dev/null +++ b/serving/fleet-manager/certreload.go @@ -0,0 +1,77 @@ +// certreload.go — hot-reloading server cert for the agent plane. +// +// The agent-plane server cert (issued by the fleet CA) is rotated OUT of process +// by the `agent-cert-renewer` sidecar running `step ca renew` (docker-compose.yml, +// profile cert-auto). For that rotation to take effect WITHOUT restarting the +// manager — which would drop every live agent stream — the TLS config serves the +// cert through GetCertificate instead of a fixed Certificates slice, and this +// reloader re-reads server.crt/server.key from disk when they change. +// +// Reload is lazy + modtime-gated: each handshake checks server.crt's modtime and +// re-reads the pair only when it advances. A failed reload (a half-written pair +// mid-renewal, transient bad perms) is logged and the LAST GOOD cert keeps +// serving — a renewer hiccup must never take the agent plane down — and because a +// failed reload does not advance the cached modtime, the next handshake retries +// until the write settles. +package main + +import ( + "crypto/tls" + "log" + "os" + "sync" + "time" +) + +type certReloader struct { + certPath, keyPath string + + mu sync.RWMutex + cert *tls.Certificate + modTime time.Time +} + +// newCertReloader loads the pair once (fail-fast if missing/bad at startup) and +// returns a reloader whose GetCertificate hook is wired into the TLS config. +func newCertReloader(certPath, keyPath string) (*certReloader, error) { + r := &certReloader{certPath: certPath, keyPath: keyPath} + if err := r.reload(); err != nil { + return nil, err + } + return r, nil +} + +func (r *certReloader) reload() error { + c, err := tls.LoadX509KeyPair(r.certPath, r.keyPath) + if err != nil { + return err + } + var mt time.Time + if fi, err := os.Stat(r.certPath); err == nil { + mt = fi.ModTime() + } + r.mu.Lock() + r.cert, r.modTime = &c, mt + r.mu.Unlock() + return nil +} + +// GetCertificate is the tls.Config hook. It reloads when server.crt's modtime has +// advanced past the cached copy, otherwise returns the cached cert unchanged. +func (r *certReloader) GetCertificate(*tls.ClientHelloInfo) (*tls.Certificate, error) { + r.mu.RLock() + cur, mt := r.cert, r.modTime + r.mu.RUnlock() + + if fi, err := os.Stat(r.certPath); err == nil && fi.ModTime().After(mt) { + if err := r.reload(); err != nil { + log.Printf("agent plane: cert reload failed, keeping previous: %v", err) + } else { + r.mu.RLock() + cur = r.cert + r.mu.RUnlock() + log.Printf("agent plane: reloaded rotated server cert %s", r.certPath) + } + } + return cur, nil +} diff --git a/serving/fleet-manager/certreload_test.go b/serving/fleet-manager/certreload_test.go new file mode 100644 index 0000000..7bc0519 --- /dev/null +++ b/serving/fleet-manager/certreload_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" +) + +// writePair mints a throwaway self-signed leaf with the given CN and writes it as +// a cert/key PEM pair, so the reloader has a real, loadable x509 key pair. +func writePair(t *testing.T, certPath, keyPath, cn string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: cn}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + kder, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kder}), 0o600); err != nil { + t.Fatal(err) + } +} + +func leafCN(t *testing.T, r *certReloader) string { + t.Helper() + c, err := r.GetCertificate(nil) + if err != nil { + t.Fatal(err) + } + leaf, err := x509.ParseCertificate(c.Certificate[0]) + if err != nil { + t.Fatal(err) + } + return leaf.Subject.CommonName +} + +func TestCertReloaderHotSwap(t *testing.T) { + dir := t.TempDir() + crt := filepath.Join(dir, "server.crt") + key := filepath.Join(dir, "server.key") + + writePair(t, crt, key, "old") + r, err := newCertReloader(crt, key) + if err != nil { + t.Fatalf("newCertReloader: %v", err) + } + if cn := leafCN(t, r); cn != "old" { + t.Fatalf("initial CN = %q, want old", cn) + } + + // Rotate the pair on disk with a strictly-later modtime — GetCertificate must + // pick it up without any explicit reload call (the renewer-rotates-in-place case). + writePair(t, crt, key, "new") + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(crt, future, future); err != nil { + t.Fatal(err) + } + if cn := leafCN(t, r); cn != "new" { + t.Fatalf("post-rotation CN = %q, want new", cn) + } +} + +func TestCertReloaderKeepsPreviousOnBadReload(t *testing.T) { + dir := t.TempDir() + crt := filepath.Join(dir, "server.crt") + key := filepath.Join(dir, "server.key") + + writePair(t, crt, key, "good") + r, err := newCertReloader(crt, key) + if err != nil { + t.Fatal(err) + } + + // Simulate a half-written rotation: cert file replaced with garbage + a newer + // modtime. Reload fails; the last good cert must keep serving. + if err := os.WriteFile(crt, []byte("not a pem"), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(crt, future, future); err != nil { + t.Fatal(err) + } + if cn := leafCN(t, r); cn != "good" { + t.Fatalf("after bad reload CN = %q, want good (kept previous)", cn) + } +} diff --git a/serving/fleet-manager/cli.go b/serving/fleet-manager/cli.go new file mode 100644 index 0000000..634a104 --- /dev/null +++ b/serving/fleet-manager/cli.go @@ -0,0 +1,243 @@ +// cli.go — the `register-admin` bootstrap subcommand and the one-time enrolment +// token it mints. +// +// WebAuthn registration is a BROWSER ceremony (the credential is born on the +// YubiKey via navigator.credentials.create) — a CLI can't do it directly. So the +// CLI's job is to AUTHORISE an enrolment: it mints a short-lived HMAC-signed token +// (over a shared enrol key on the mgmt host) and prints an enrolment URL. The +// browser completes the ceremony; the register endpoint verifies the token. This +// keeps registration closed (no anyone-can-register endpoint) while the actual +// key material never leaves the YubiKey. Single-use is enforced by the credStore +// jti ledger. +package main + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "errors" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +type enrollClaims struct { + Name string + JTI string + Exp int64 +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } +func unb64(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) } + +func hmacSum(key []byte, msg string) []byte { + m := hmac.New(sha256.New, key) + m.Write([]byte(msg)) + return m.Sum(nil) +} + +// mintEnrollToken -> base64url(name|exp|jti) "." base64url(hmac). +func mintEnrollToken(key []byte, name string, ttl time.Duration) (string, error) { + jb := make([]byte, 12) + if _, err := rand.Read(jb); err != nil { + return "", err + } + jti := hex.EncodeToString(jb) + exp := time.Now().Add(ttl).Unix() + msg := name + "|" + strconv.FormatInt(exp, 10) + "|" + jti + return b64([]byte(msg)) + "." + b64(hmacSum(key, msg)), nil +} + +func verifyEnrollToken(key []byte, token string) (enrollClaims, error) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return enrollClaims{}, fmt.Errorf("malformed token") + } + msg, err := unb64(parts[0]) + if err != nil { + return enrollClaims{}, fmt.Errorf("bad payload") + } + sig, err := unb64(parts[1]) + if err != nil { + return enrollClaims{}, fmt.Errorf("bad signature encoding") + } + if subtle.ConstantTimeCompare(sig, hmacSum(key, string(msg))) != 1 { + return enrollClaims{}, fmt.Errorf("bad signature") + } + f := strings.SplitN(string(msg), "|", 3) + if len(f) != 3 { + return enrollClaims{}, fmt.Errorf("bad claims") + } + exp, _ := strconv.ParseInt(f[1], 10, 64) + if time.Now().Unix() > exp { + return enrollClaims{}, fmt.Errorf("token expired") + } + return enrollClaims{Name: f[0], JTI: f[2], Exp: exp}, nil +} + +// loadEnrollKey reads the shared enrol key (>=32 bytes). Used by the SERVICE — it +// must already exist (the same key the CLI mints with). +func loadEnrollKey(path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("no enrol key file configured (FLEET_MGR_ENROLL_KEY_FILE)") + } + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + // The key is created by the admin bootstrap — a fresh secure-mode start + // before any admin exists lands here. Point at the fix, not the errno. + return nil, fmt.Errorf("enrol key %s not found — bootstrap the admin plane first: `fleet-manager register-admin ` (or set FLEET_MGR_ADMIN_INSECURE=true for dev)", path) + } + if err != nil { + return nil, err + } + if len(b) < 32 { + return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path) + } + return b, nil +} + +// loadOrCreateEnrollKey is the CLI's variant: it generates the key on first use. +func loadOrCreateEnrollKey(path string) ([]byte, error) { + if path == "" { + return nil, fmt.Errorf("set -key or FLEET_MGR_ENROLL_KEY_FILE") + } + if b, err := os.ReadFile(path); err == nil { + if len(b) < 32 { + return nil, fmt.Errorf("enrol key %s too short (need >=32 bytes)", path) + } + return b, nil + } else if !os.IsNotExist(err) { + return nil, err + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, err + } + if err := os.WriteFile(path, key, 0o600); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "note: generated a new enrol key at %s (mount the SAME file into the service)\n", path) + return key, nil +} + +// runRegisterAdmin implements: fleet-manager register-admin [-key F] [-origin URL] +func runRegisterAdmin(args []string) int { + fs := flag.NewFlagSet("register-admin", flag.ContinueOnError) + keyFile := fs.String("key", env("FLEET_MGR_ENROLL_KEY_FILE", ""), "shared enrol key file") + origin := fs.String("origin", env("FLEET_MGR_RP_ORIGIN", ""), "admin UI origin (for the enrolment URL)") + ttl := fs.Duration("ttl", 10*time.Minute, "enrolment token validity") + if err := fs.Parse(args); err != nil { + return 2 + } + name := fs.Arg(0) + if name == "" { + fmt.Fprintln(os.Stderr, "usage: fleet-manager register-admin [-key F] [-origin URL] [-ttl D] ") + return 2 + } + key, err := loadOrCreateEnrollKey(*keyFile) + if err != nil { + fmt.Fprintf(os.Stderr, "enrol key: %v\n", err) + return 1 + } + tok, err := mintEnrollToken(key, name, *ttl) + if err != nil { + fmt.Fprintf(os.Stderr, "mint: %v\n", err) + return 1 + } + // RP_ORIGIN may list several origins (comma-separated); the enrolment link + // uses the first one. + base := *origin + if i := strings.IndexByte(base, ','); i >= 0 { + base = base[:i] + } + base = strings.TrimRight(strings.TrimSpace(base), "/") + if base == "" { + base = "https://" + } + fmt.Printf("Enrolment link for %q (valid %s) — open on the mgmt network, then tap the YubiKey:\n\n %s/#/enrol?token=%s\n\n", name, ttl.String(), base, tok) + return 0 +} + +// runRegisterHost implements: +// +// fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] +// +// It seeds or updates an inventory record for a host enrolled OUT OF BAND (the +// standalone bench broker, or a manager inheriting an existing fleet), so the +// agent stream — which the manager refuses for an unknown serial (agent.go +// "host is not enrolled") — is accepted. Normally the record is born by the +// authorise-approve flow; this is the manual import path. It writes through the +// real jsonStore (atomic save), so it must point at the SAME inventory file the +// service uses (FLEET_MGR_INVENTORY, default /data in the container). Run it via +// `docker compose run --rm fleet-manager register-host ...`. +func runRegisterHost(args []string) int { + fs := flag.NewFlagSet("register-host", flag.ContinueOnError) + name := fs.String("name", "", "starship name (dnsmasq fleet-namer join; advisory)") + inv := fs.String("inventory", env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"), "inventory file (must match the service's FLEET_MGR_INVENTORY)") + nqn := fs.String("hostnqn", "", "host NQN (default: $HOSTNQN_BASE + serial)") + if err := fs.Parse(args); err != nil { + return 2 + } + serial := strings.ToLower(strings.TrimSpace(fs.Arg(0))) + if !isHexSerial(serial) { + fmt.Fprintln(os.Stderr, "usage: fleet-manager register-host [-name S] [-hostnqn N] [-inventory F] ") + return 2 + } + + store, err := newJSONStore(*inv) + if err != nil { + fmt.Fprintf(os.Stderr, "inventory %s: %v\n", *inv, err) + return 1 + } + hostnqn := *nqn + if hostnqn == "" { + hostnqn = env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-") + serial + } + now := time.Now().UTC() + + h, existed := store.Get(serial) + if !existed { + h = &Host{Identity: Identity{CertSerial: serial}, FirstSeen: now} + } + h.Identity.CertSerial = serial + h.Identity.Hostnqn = hostnqn + if *name != "" { + h.Identity.StarshipName = *name + } + if h.Enrolled.At.IsZero() { + h.Enrolled = Enrolled{Approver: "register-host CLI", At: now} + } + h.LastSeen = now + + if err := store.Upsert(h); err != nil { + fmt.Fprintf(os.Stderr, "write inventory %s: %v\n", *inv, err) + return 1 + } + verb := "registered" + if existed { + verb = "updated" + } + fmt.Printf("%s host %s (starship=%q hostnqn=%s) in %s\n", verb, serial, h.Identity.StarshipName, hostnqn, *inv) + return 0 +} + +// isHexSerial reports whether s is a non-empty lowercase-hex cert serial (the +// inventory key form). Serials can be odd-length hex (big-int %x), so we validate +// characters rather than decoding. +func isHexSerial(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} diff --git a/serving/fleet-manager/defaults.env b/serving/fleet-manager/defaults.env new file mode 100644 index 0000000..e9889dd --- /dev/null +++ b/serving/fleet-manager/defaults.env @@ -0,0 +1,91 @@ +# defaults.env — the COMMITTED default configuration for the fleet-manager stack. +# +# Compose LAYERS this file, then .env (overrides), for both container env AND +# ${...} interpolation. Enable the layering once per shell (later file wins): +# +# export COMPOSE_ENV_FILES=defaults.env,.env +# # or per command: docker compose --env-file defaults.env --env-file .env +# +# Then put ONLY overrides + real secrets in .env (gitignored). Changing a default +# here reaches every run without anyone re-copying it. The secret values below are +# PLACEHOLDERS — override them in .env. (.env must exist when COMPOSE_ENV_FILES +# lists it; create it with at least your secrets.) + +# --- WebAuthn RP (admin passkeys are ORIGIN-BOUND — must be the UI's real URL) -- +# The UI is fronted by Traefik on :443, so the origin is PORT-LESS. +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 (behind the Traefik ingress) ---------------------------------- +# Traefik terminates the LE cert on :443 and forwards plaintext here; the manager +# serves passkey auth over plaintext on loopback (only Traefik reaches it). The +# manager never terminates TLS itself. +FLEET_MGR_ADMIN_ADDR=127.0.0.1:9500 +# Dev-only escape hatch: plaintext, NO passkey auth. NEVER set true in prod. +FLEET_MGR_ADMIN_INSECURE=false + +# --- Traefik ACME (Let's Encrypt DNS-01 via Route 53) -------------------------- +# Traefik issues + auto-renews the :443 cert. Set the UI hostname in +# traefik/dynamic/fleet-manager.yml (== FLEET_MGR_RP_ID). Give it AWS creds scoped +# to the bitasylum.org hosted zone (IAM policy in DEPLOY.md §2c), or attach an +# instance role and leave them blank. +ACME_EMAIL=you@bitasylum.org +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION=us-east-1 +# Optional but recommended: pin the Route 53 zone (skip ListHostedZonesByName; +# disambiguate public/private zones). Traefik reads it from the env. +AWS_HOSTED_ZONE_ID= +# First dry-run vs LE staging: add the caserver flag to the traefik command (see +# docker-compose.yml) rather than an env var. + +# --- 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, or the auto-renewer +# below); the client CA is the fleet root+intermediate so host TPM certs verify. +# Paths are fixed in docker-compose.yml. +FLEET_MGR_AGENT_ADDR=:9600 + +# --- agent-plane cert auto-renewer (compose profile: cert-auto) ---------------- +# Only used when you run the `agent-cert-renewer` sidecar (step-ca co-located on +# this host). It issues the server cert once, then auto-renews before the CA cap, +# preserving the SANs. Defaults suit the bench; override host paths in .env if +# your step-ca lays out its PKI differently. +# AGENT_CERT_SANS all names hosts dial (first = CN). manager.fleet.lab +# is the fleet-DNS name; keep the host FQDN too. +# AGENT_CA_URL the co-located fleet step-ca (host-loopback). +# AGENT_CERT_NOT_AFTER first-issue lifetime (<= CA max, currently 2160h). +# AGENT_CERT_RENEW_EXPIRES_IN renew when less than this remains. +# FLEET_CA_ROOT / _INTERMEDIATE / _PROVISIONER_PW host paths to the CA material. +AGENT_CERT_SANS=manager.fleet.lab,diskless-server.bitasylum.org +AGENT_CA_URL=https://localhost:8443 +AGENT_CERT_NOT_AFTER=2160h +AGENT_CERT_RENEW_EXPIRES_IN=720h +FLEET_CA_PROVISIONER=enrol +FLEET_CA_ROOT=../dot1x-bench/pki/root_ca.crt +FLEET_CA_INTERMEDIATE=../dot1x-bench/pki/intermediate_ca.crt +FLEET_CA_PROVISIONER_PW=../dot1x-bench/pki/stepca/secrets/password + +# --- 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 ---------------------------------------------------------------------- +# CI image tag to run (from .gitea/workflows/fleet-manager-image.yaml): a release +# semver, main-, or a - dev build. Defaults to :latest. +FLEET_MANAGER_VERSION=latest +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/docker-compose.yml b/serving/fleet-manager/docker-compose.yml new file mode 100644 index 0000000..f29b8f6 --- /dev/null +++ b/serving/fleet-manager/docker-compose.yml @@ -0,0 +1,212 @@ +# ============================================================================= +# fleet-manager — server-side fleet control plane + admin SPA, plus its private +# InfluxDB metrics store. Runs on the MANAGEMENT host (never the boot LAN). +# +# export COMPOSE_ENV_FILES=defaults.env,.env # layer defaults + your overrides +# printf 'FLEET_MGR_INFLUX_TOKEN=...\nINFLUX_ADMIN_PASSWORD=...\n' > .env # secrets/overrides +# ./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 traefik +# +# Config = defaults.env (committed) + .env (gitignored overrides), layered via +# COMPOSE_ENV_FILES. Only put overrides + secrets in .env. +# +# 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: harbor.bitasylum.org/dockerhub/library/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 -------------------------------------- + # CI builds + pushes this image (.gitea/workflows/fleet-manager-image.yaml); + # `docker compose pull` fetches it. `build: .` stays so local dev can still + # `docker compose build fleet-manager`. Pin FLEET_MANAGER_VERSION in .env to a + # CI tag (e.g. main-); defaults to :latest. + fleet-manager: + image: "git.bitasylum.org/audiobuzz/fleet-manager:${FLEET_MANAGER_VERSION:-latest}" + build: . + 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 — passkey auth over PLAINTEXT behind the Traefik mgmt ingress: + # Traefik terminates the LE cert on :443 and forwards plaintext here on + # loopback, so the listener MUST stay private (127.0.0.1) — only Traefik + # reaches it. INSECURE=true is the dev/no-auth escape hatch. + FLEET_MGR_ADMIN_ADDR: "${FLEET_MGR_ADMIN_ADDR:-127.0.0.1:9500}" + FLEET_MGR_ADMIN_INSECURE: "${FLEET_MGR_ADMIN_INSECURE:-false}" + # WebAuthn RP — MUST be the UI's real origin (origin-bound passkeys). Behind + # Traefik on :443 this is the PORT-LESS https origin. + 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: agent-plane fleet-CA server cert + the broker admin-client + # cert. (UI TLS is Traefik's — no admin cert here.) See DEPLOY.md/server-pki.sh. + - ./pki:/pki:ro + + # --- agent-plane server-cert auto-renewer (optional; step-ca co-located) ----- + # Issues the manager's agent-plane server cert ONCE from the fleet step-ca, then + # runs `step ca renew --daemon` to rotate it well before the CA's ~90-day cap — + # replacing the manual server-pki.sh + `docker exec … step ca certificate` dance + # and the periodic re-mint. Renewal PRESERVES the subject + SANs of the first + # issuance, so agent-TLS SAN drift can't recur. The manager hot-reloads the + # rotated cert via GetCertificate (certreload.go) — no restart, no dropped hub. + # + # OPT-IN via `--profile cert-auto`, and ONLY valid where the fleet step-ca + # (serving/dot1x-bench) runs on THIS host: it mounts the CA root + intermediate + + # 'enrol' provisioner password read-only and reaches the CA over host-loopback. + # Where the CA is remote, leave this off and issue via server-pki.sh (or ACME). + # + # docker compose --profile cert-auto up -d agent-cert-renewer fleet-manager + agent-cert-renewer: + image: harbor.bitasylum.org/dockerhub/smallstep/step-cli:latest + profiles: ["cert-auto"] + restart: unless-stopped + network_mode: host # reach the co-located CA on ${AGENT_CA_URL} + user: "0:0" # writes into /pki/agent, then chowns to the manager uid + environment: + STEPPATH: /tmp/step + HOME: /tmp + AGENT_SANS: "${AGENT_CERT_SANS:-manager.fleet.lab,diskless-server.bitasylum.org}" + CA_URL: "${AGENT_CA_URL:-https://localhost:8443}" + PROVISIONER: "${FLEET_CA_PROVISIONER:-enrol}" + RENEW_EXPIRES_IN: "${AGENT_CERT_RENEW_EXPIRES_IN:-720h}" + CERT_NOT_AFTER: "${AGENT_CERT_NOT_AFTER:-2160h}" + OWNER: "65532:65532" + entrypoint: ["/bin/sh", "/renew/agent-cert-renewer.sh"] + volumes: + - ./pki/agent:/pki/agent # shared with the manager (rw here) + - ./agent-cert-renewer.sh:/renew/agent-cert-renewer.sh:ro + # fleet CA material (read-only), from the co-located step-ca stack. Override + # the host paths in .env if your bench lays them out differently. + - ${FLEET_CA_ROOT:-../dot1x-bench/pki/root_ca.crt}:/ca/root_ca.crt:ro + - ${FLEET_CA_INTERMEDIATE:-../dot1x-bench/pki/intermediate_ca.crt}:/ca/intermediate_ca.crt:ro + - ${FLEET_CA_PROVISIONER_PW:-../dot1x-bench/pki/stepca/secrets/password}:/ca/password:ro + + # --- management-plane ingress (Traefik) — admin UI on :443 ------------------ + # Terminates the Let's Encrypt cert (Route 53 DNS-01) on :443 and forwards + # plaintext to the manager (behind-proxy mode, loopback :9500). Host networking + # so it binds :443 on the mgmt interface and reaches the host-networked manager + # on 127.0.0.1:9500. Needs outbound reach to Let's Encrypt + the AWS Route 53 + # API. ONLY the admin plane rides Traefik — the agent gRPC/mTLS plane (:9600) is + # direct (Traefik can't terminate fleet-CA client-cert mTLS; hosts reach it via + # OPNsense L3 routing). This is the k8s ingress-controller + cert-manager role + # in miniature — the router/ACME model maps to Traefik Ingress + cert-manager. + # + # NOTE: only :443 (the admin UI is https-only; passkeys require it) so it can't + # collide with a boot-LAN nginx :80 on a single-host lab. Route 53 DNS-01 needs + # no :80. Set the Host() rule in traefik/dynamic/fleet-manager.yml to your UI host. + traefik: + image: harbor.bitasylum.org/dockerhub/library/traefik:v3.1 # mirror via harbor if no dockerhub egress + restart: unless-stopped + network_mode: host + command: + - "--global.checknewversion=false" + - "--global.sendanonymoususage=false" + - "--entrypoints.websecure.address=:443" + - "--providers.file.directory=/etc/traefik/dynamic" + - "--providers.file.watch=true" + - "--certificatesresolvers.le.acme.email=${ACME_EMAIL:?set ACME_EMAIL in .env}" + - "--certificatesresolvers.le.acme.storage=/acme/acme.json" + - "--certificatesresolvers.le.acme.dnschallenge.provider=route53" + # Public resolvers for the propagation check (split-horizon DNS won't serve + # the public _acme-challenge). First dry-run vs LE staging: also add + # --certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory + - "--certificatesresolvers.le.acme.dnschallenge.resolvers=8.8.8.8:53,1.1.1.1:53" + - "--log.level=INFO" + environment: + # Route 53 DNS-01 creds (or attach an instance role + omit). IAM policy in + # DEPLOY.md §2c; AWS_HOSTED_ZONE_ID optional (pins the zone). + AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID:-}" + AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY:-}" + AWS_REGION: "${AWS_REGION:-us-east-1}" + AWS_HOSTED_ZONE_ID: "${AWS_HOSTED_ZONE_ID:-}" + volumes: + - ./traefik/dynamic:/etc/traefik/dynamic:ro + - traefik-acme:/acme # acme.json (ACME account + issued certs) — PERSIST + depends_on: + - fleet-manager + +volumes: + influx-data: + influx-config: + fleet-data: + traefik-acme: + +networks: + fleet-mgmt: + driver: bridge diff --git a/serving/fleet-manager/go.mod b/serving/fleet-manager/go.mod new file mode 100644 index 0000000..c112a48 --- /dev/null +++ b/serving/fleet-manager/go.mod @@ -0,0 +1,41 @@ +// fleet-manager — server-side fleet control plane for the diskless kiosk fleet: +// inventory, enrolment authorisation, lifecycle (desired-state) control, and the +// metrics gateway that fronts InfluxDB. Backend for the admin SPA. See +// docs/fleet-management-design.md and docs/tpm-enrolment-design.md §5. +// +// Unlike fleet-hostapi / fleet-enrol (baked into the reproducible verity image, +// so stdlib-only), this is a SERVER-SIDE service that ships as its own container +// on the mgmt network — external deps are acceptable here. They are added at the +// build checkpoint that needs them: +// - google.golang.org/grpc + protobuf : the agent bidi stream (added ckpt 2) +// - github.com/influxdata/influxdb-client-go/v2 : metrics read/write (ckpt 4) +module fleet-manager + +go 1.25.0 + +require ( + github.com/go-webauthn/webauthn v0.17.4 + github.com/influxdata/influxdb-client-go/v2 v2.14.0 + google.golang.org/grpc v1.82.0 + google.golang.org/protobuf v1.36.11 +) + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 // indirect + github.com/oapi-codegen/runtime v1.0.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect +) diff --git a/serving/fleet-manager/go.sum b/serving/fleet-manager/go.sum new file mode 100644 index 0000000..efc1a94 --- /dev/null +++ b/serving/fleet-manager/go.sum @@ -0,0 +1,85 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/influxdata/influxdb-client-go/v2 v2.14.0 h1:AjbBfJuq+QoaXNcrova8smSjwJdUHnwvfjMF71M1iI4= +github.com/influxdata/influxdb-client-go/v2 v2.14.0/go.mod h1:Ahpm3QXKMJslpXl3IftVLVezreAUtBOTZssDrjZEFHI= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839 h1:W9WBk7wlPfJLvMCdtV4zPulc4uCPrlywQOmbFOhgQNU= +github.com/influxdata/line-protocol v0.0.0-20200327222509-2487e7298839/go.mod h1:xaLFMmpvUxqXtVkUJfg9QmT88cDaCJ3ZKgdZ78oO8Qo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.0.0 h1:P4rqFX5fMFWqRzY9M/3YF9+aPSPPB06IzP2P7oOxrWo= +github.com/oapi-codegen/runtime v1.0.0/go.mod h1:LmCUMQuPB4M/nLXilQXhHw+BLZdDb18B34OO356yJ/A= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/serving/fleet-manager/influx.go b/serving/fleet-manager/influx.go new file mode 100644 index 0000000..21b3bfd --- /dev/null +++ b/serving/fleet-manager/influx.go @@ -0,0 +1,145 @@ +// influx.go — the metrics gateway. The manager is the ONLY thing that talks to +// InfluxDB (both directions): it writes each agent HealthSnapshot as a time-series +// point (tagged with the AUTHENTICATED host identity, so a host can only write its +// own series), and it answers the SPA's read queries via Flux — hosts never hold +// an InfluxDB token or reach InfluxDB. See docs/fleet-management-design.md. +package main + +import ( + "context" + "fmt" + "log" + "regexp" + "time" + + influxdb2 "github.com/influxdata/influxdb-client-go/v2" + "github.com/influxdata/influxdb-client-go/v2/api" + "github.com/influxdata/influxdb-client-go/v2/api/write" + + fleetpb "fleet-manager/proto" +) + +const influxMeasurement = "fleet_health" + +// metricFields is the whitelist of queryable numeric fields. A read request's +// ?field= MUST be one of these — never interpolate caller input into Flux. +var metricFields = map[string]bool{ + "load_avg": true, "cpu_pct": true, "temp_c": true, "mem_used_pct": true, + "tmpfs_used_pct": true, "chromium_crashes": true, "kiosk_active": true, + "uptime_seconds": true, "chrony_offset_seconds": true, "chrony_stratum": true, +} + +var serialRe = regexp.MustCompile(`^[0-9a-fA-F]+$`) // cert serials are hex + +type influxConfig struct { + url, token, org, bucket string +} + +type influxSink struct { + client influxdb2.Client + write api.WriteAPIBlocking + query api.QueryAPI + bucket string +} + +func newInfluxSink(c influxConfig) *influxSink { + cl := influxdb2.NewClient(c.url, c.token) + return &influxSink{ + client: cl, + write: cl.WriteAPIBlocking(c.org, c.bucket), + query: cl.QueryAPI(c.org), + bucket: c.bucket, + } +} + +func (s *influxSink) Close() { s.client.Close() } + +// WriteSnapshot implements MetricsSink. +func (s *influxSink) WriteSnapshot(serial, hostnqn, starship string, snap *fleetpb.HealthSnapshot, at time.Time) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.write.WritePoint(ctx, snapshotPoint(serial, hostnqn, starship, snap, at)); err != nil { + log.Printf("influx write %s: %v", serial, err) + } +} + +// snapshotPoint builds the InfluxDB point (pure — unit-tested). Identity rides as +// TAGS (indexed, low-cardinality-ish per fleet); telemetry as FIELDS. +func snapshotPoint(serial, hostnqn, starship string, snap *fleetpb.HealthSnapshot, at time.Time) *write.Point { + tags := map[string]string{"serial": serial} + if hostnqn != "" { + tags["hostnqn"] = hostnqn + } + if starship != "" { + tags["starship"] = starship + } + fields := map[string]any{ + "load_avg": snap.GetLoadAvg(), + "cpu_pct": snap.GetCpuPct(), + "temp_c": snap.GetTempC(), + "mem_used_pct": snap.GetMemUsedPct(), + "tmpfs_used_pct": snap.GetTmpfsUsedPct(), + "chromium_crashes": int64(snap.GetChromiumCrashes()), + "kiosk_active": boolToInt(snap.GetKioskActive()), + "uptime_seconds": snap.GetUptimeSeconds(), + "chrony_offset_seconds": snap.GetChronyOffsetSeconds(), + "chrony_stratum": int64(snap.GetChronyStratum()), + "chrony_source": snap.GetChronySource(), + // string status fields kept as fields too (handy for annotating charts) + "nvme_root": snap.GetNvmeRoot(), + "auth": snap.GetAuth(), + "selinux": snap.GetSelinux(), + "chrony": snap.GetChrony(), + } + return influxdb2.NewPoint(influxMeasurement, tags, fields, at) +} + +func boolToInt(b bool) int64 { + if b { + return 1 + } + return 0 +} + +type metricPoint struct { + Time time.Time `json:"t"` + Value any `json:"v"` +} + +// QueryField returns the time-series for one whitelisted field of one host. +func (s *influxSink) QueryField(ctx context.Context, serial, field string, rng time.Duration) ([]metricPoint, error) { + if !serialRe.MatchString(serial) { + return nil, fmt.Errorf("invalid serial") + } + if !metricFields[field] { + return nil, fmt.Errorf("unknown field %q", field) + } + res, err := s.query.Query(ctx, fluxQuery(s.bucket, serial, field, rng)) + if err != nil { + return nil, err + } + defer res.Close() + var out []metricPoint + for res.Next() { + out = append(out, metricPoint{Time: res.Record().Time(), Value: res.Record().Value()}) + } + if err := res.Err(); err != nil { + return nil, err + } + return out, nil +} + +// fluxQuery builds the read query (pure — unit-tested). serial and field are +// validated by the caller (hex / whitelist), and the range is a parsed duration +// rendered as an integer second count, so NOTHING here is raw caller input. +func fluxQuery(bucket, serial, field string, rng time.Duration) string { + secs := int64(rng.Seconds()) + if secs <= 0 { + secs = 3600 + } + return fmt.Sprintf(`from(bucket:%q) + |> range(start: -%ds) + |> filter(fn: (r) => r._measurement == %q and r.serial == %q and r._field == %q) + |> keep(columns: ["_time","_value"])`, + bucket, secs, influxMeasurement, serial, field) +} diff --git a/serving/fleet-manager/influx_test.go b/serving/fleet-manager/influx_test.go new file mode 100644 index 0000000..ea4a86d --- /dev/null +++ b/serving/fleet-manager/influx_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + fleetpb "fleet-manager/proto" +) + +func TestSnapshotPoint(t *testing.T) { + at := time.Unix(1700000000, 0).UTC() + snap := &fleetpb.HealthSnapshot{ + LoadAvg: 0.5, CpuPct: 12, TmpfsUsedPct: 63, ChromiumCrashes: 2, KioskActive: true, NvmeRoot: "ok", + } + p := snapshotPoint("beef01", "nqn:host-beef01", "orion-drifter-07", snap, at) + + if p.Name() != influxMeasurement { + t.Errorf("measurement = %q", p.Name()) + } + tags := map[string]string{} + for _, tg := range p.TagList() { + tags[tg.Key] = tg.Value + } + if tags["serial"] != "beef01" || tags["starship"] != "orion-drifter-07" || tags["hostnqn"] != "nqn:host-beef01" { + t.Errorf("identity tags = %+v", tags) + } + fields := map[string]any{} + for _, f := range p.FieldList() { + fields[f.Key] = f.Value + } + if fields["tmpfs_used_pct"] != float64(63) { + t.Errorf("tmpfs_used_pct field = %v (%T)", fields["tmpfs_used_pct"], fields["tmpfs_used_pct"]) + } + if fields["kiosk_active"] != int64(1) { + t.Errorf("kiosk_active = %v", fields["kiosk_active"]) + } + if fields["chromium_crashes"] != int64(2) { + t.Errorf("chromium_crashes = %v", fields["chromium_crashes"]) + } + if fields["nvme_root"] != "ok" { + t.Errorf("nvme_root = %v", fields["nvme_root"]) + } + if !p.Time().Equal(at) { + t.Errorf("time = %v", p.Time()) + } +} + +func TestFluxQueryContentsAndQuoting(t *testing.T) { + q := fluxQuery("fleet_health", "beef01", "tmpfs_used_pct", time.Hour) + for _, want := range []string{ + `from(bucket:"fleet_health")`, + `range(start: -3600s)`, + `r._measurement == "fleet_health"`, + `r.serial == "beef01"`, + `r._field == "tmpfs_used_pct"`, + } { + if !strings.Contains(q, want) { + t.Errorf("flux query missing %q in:\n%s", want, q) + } + } +} + +func TestQueryFieldRejectsUnsafeInput(t *testing.T) { + s := &influxSink{bucket: "b"} // query is never reached for these + if _, err := s.QueryField(context.Background(), "not-hex!", "tmpfs_used_pct", time.Hour); err == nil { + t.Error("expected rejection of a non-hex serial") + } + if _, err := s.QueryField(context.Background(), "beef01", "evil) |> drop(", time.Hour); err == nil { + t.Error("expected rejection of a non-whitelisted field") + } +} diff --git a/serving/fleet-manager/inventory.go b/serving/fleet-manager/inventory.go new file mode 100644 index 0000000..9fcb6fe --- /dev/null +++ b/serving/fleet-manager/inventory.go @@ -0,0 +1,119 @@ +// inventory.go — the fleet inventory record and the Store abstraction. +// +// The Host record is the join key the rest of the fleet-manager hangs off. It is +// BORN at enrolment approval (identity/binding/enrolled), then FILLED by the +// steady-state agent stream (version/health/usage) and by admin action (desired). +// Schema mirrors docs/fleet-management-design.md "Inventory record" and +// docs/tpm-enrolment-design.md "Inventory record born at enrolment". +// +// Keyed by the TPM cert SERIAL (lowercase hex) — identical to the value the NVMe +// hostnqn derives from and the value the agent's mTLS leaf presents, so the cert +// an agent shows IS its inventory key (no identity translation). See §5 identity +// discussion. +package main + +import "time" + +// Identity — minted at enrolment, immutable afterwards. CertSerial is the primary +// key; Hostnqn = HOSTNQN_BASE + CertSerial (as the broker computes it on approve). +type Identity struct { + CertSerial string `json:"cert_serial"` // lowercase hex — PRIMARY KEY + Hostnqn string `json:"hostnqn,omitempty"` // nqn...:host- + MAC string `json:"mac,omitempty"` // advisory (DeviceContext at enrol) + EKHash string `json:"ek_hash,omitempty"` // TPM EK hash (advisory) + StarshipName string `json:"starship_name,omitempty"` // dnsmasq fleet-namer join +} + +// Binding — the physical↔crypto tie the human records during the OOB approval. +type Binding struct { + AssetTag string `json:"asset_tag,omitempty"` + SwitchPort string `json:"switch_port,omitempty"` + Location string `json:"location,omitempty"` +} + +// Enrolled — provenance of the approval that birthed this record. +type Enrolled struct { + Approver string `json:"approver,omitempty"` // admin CN from the approve mTLS + At time.Time `json:"at,omitzero"` // approval timestamp + Fingerprint string `json:"fingerprint,omitempty"` // the human-verified anchor +} + +// Version — what the host is actually running (agent-reported). +type Version struct { + ImageVersion string `json:"image_version,omitempty"` + RootfsDigest string `json:"rootfs_digest,omitempty"` + Kernel string `json:"kernel,omitempty"` +} + +// Health — the LATEST snapshot only. The full time-series lives in InfluxDB +// (written by the manager as agent reports arrive); this cached last value is +// what the fleet-overview list renders without a per-host Flux query. +type Health struct { + LoadAvg float64 `json:"load_avg,omitempty"` + CPUPct float64 `json:"cpu_pct,omitempty"` + TempC float64 `json:"temp_c,omitempty"` + MemUsedPct float64 `json:"mem_used_pct,omitempty"` + TmpfsUsedPct float64 `json:"tmpfs_used_pct,omitempty"` // overlay RAM pressure (ties to #17) + NVMeRoot string `json:"nvme_root,omitempty"` // ok|degraded|down + Auth string `json:"auth,omitempty"` // 802.1x/macsec: up|down + SELinux string `json:"selinux,omitempty"` // enforcing|permissive + Chrony string `json:"chrony,omitempty"` // synced|unsynced + ChronyOffset float64 `json:"chrony_offset_seconds,omitempty"` // system time offset from NTP (signed, fast=+) + ChronySource string `json:"chrony_source,omitempty"` // NTP reference source name/IP + ChronyStratum int32 `json:"chrony_stratum,omitempty"` // hops from a reference clock + KioskActive bool `json:"kiosk_active"` + UptimeSeconds int64 `json:"uptime_seconds,omitempty"` // host uptime at last report; boot_time = last_seen - this (reboot-pending) +} + +// Usage — kiosk-session facts (agent-reported). +type Usage struct { + KioskURL string `json:"kiosk_url,omitempty"` + SessionUptime string `json:"session_uptime,omitempty"` + ChromiumCrashes int `json:"chromium_crashes,omitempty"` + Display string `json:"display,omitempty"` +} + +// Desired — SERVER-AUTHORITATIVE target state. Set via the admin lifecycle API, +// persisted here, and pushed to the host over its live agent stream (or delivered +// on its next reconnect if offline). Inventory is the source of truth; the stream +// is only the delivery optimisation. +type Desired struct { + TargetVersion string `json:"target_version,omitempty"` + KioskURL string `json:"kiosk_url,omitempty"` + RebootAt *time.Time `json:"reboot_at,omitempty"` + Drain bool `json:"drain,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` // admin CN + UpdatedAt time.Time `json:"updated_at,omitzero"` +} + +// Host is the full inventory record. +type Host struct { + Identity Identity `json:"identity"` + Binding Binding `json:"binding"` + Enrolled Enrolled `json:"enrolled"` + Version Version `json:"version"` + Health Health `json:"health"` + Usage Usage `json:"usage"` + Desired Desired `json:"desired"` + FirstSeen time.Time `json:"first_seen"` + LastSeen time.Time `json:"last_seen"` // last agent report / stream activity + Online bool `json:"online"` // agent stream currently connected +} + +// Store is the inventory persistence abstraction. The v1 implementation is a +// JSON-file store (jsonStore); the interface keeps a DB swap a drop-in later. +// All methods are safe for concurrent use. +type Store interface { + // List returns a snapshot of every host (copies — safe to mutate). + List() []*Host + // Get returns a copy of one host by cert serial. + Get(serial string) (*Host, bool) + // Upsert creates or replaces a host record (used by enrolment approval). + Upsert(h *Host) error + // SetDesired replaces the desired-state block and returns the updated host. + SetDesired(serial string, d Desired) (*Host, bool, error) + // ReportState applies an agent snapshot (version/health/usage + last-seen). + ReportState(serial string, v Version, h Health, u Usage, at time.Time) (*Host, bool, error) + // SetOnline flips the live-stream presence flag (and stamps last-seen on connect). + SetOnline(serial string, online bool, at time.Time) +} diff --git a/serving/fleet-manager/main.go b/serving/fleet-manager/main.go new file mode 100644 index 0000000..e460f29 --- /dev/null +++ b/serving/fleet-manager/main.go @@ -0,0 +1,284 @@ +// fleet-manager — server-side control plane for the diskless kiosk fleet. +// +// Two planes with DISTINCT trust models: +// +// - ADMIN plane: HTTPS (server-auth TLS) + browser identity via WebAuthn/passkey +// (YubiKey) → server-side session. Serves the JSON API + the SPA to admins. +// (manager→broker is separately mTLS; that's service-to-service.) +// - AGENT plane: gRPC + mTLS verified by the FLEET CA — the host's reused TPM +// identity cert. Bidi stream: health/metrics up, desired-state down. +// +// A `register-admin` subcommand mints a one-time passkey-enrolment link. +package main + +import ( + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +type config struct { + // The admin plane always serves plaintext: passkey auth behind a trusted + // TLS-terminating proxy (Traefik / k8s Ingress), or no auth in insecure dev. + adminAddr string + adminInsecure bool + + agentAddr string + agentCert string + agentKey string + agentClientCA string // FLEET CA (verifies host TPM identity certs) + + // enrol-broker admin plane (the authorise pillar drives it over mTLS). + brokerAdminURL string + brokerClientCert string + brokerClientKey string + brokerCA string + + // InfluxDB (the manager is the only reader/writer; token is server-side only). + influx influxConfig + + // Admin browser auth = WebAuthn/passkey (replaces browser mTLS). Origin-bound. + rpID string + rpOrigin string + rpDisplayName string + enrollKeyFile string + credStorePath string + sessionTTL time.Duration + + inventoryPath string + seed bool + hostnqnBase string +} + +func loadConfig() config { + return config{ + adminAddr: env("FLEET_MGR_ADMIN_ADDR", "127.0.0.1:9500"), + adminInsecure: env("FLEET_MGR_ADMIN_INSECURE", "") == "true", + + agentAddr: env("FLEET_MGR_AGENT_ADDR", "127.0.0.1:9600"), + agentCert: env("FLEET_MGR_AGENT_CERT", ""), + agentKey: env("FLEET_MGR_AGENT_KEY", ""), + agentClientCA: env("FLEET_MGR_AGENT_CLIENT_CA", ""), + + brokerAdminURL: env("FLEET_MGR_BROKER_URL", ""), + brokerClientCert: env("FLEET_MGR_BROKER_CLIENT_CERT", ""), + brokerClientKey: env("FLEET_MGR_BROKER_CLIENT_KEY", ""), + brokerCA: env("FLEET_MGR_BROKER_CA", ""), + + influx: influxConfig{ + url: env("FLEET_MGR_INFLUX_URL", ""), + token: env("FLEET_MGR_INFLUX_TOKEN", ""), + org: env("FLEET_MGR_INFLUX_ORG", "fleet"), + bucket: env("FLEET_MGR_INFLUX_BUCKET", "fleet_health"), + }, + + rpID: env("FLEET_MGR_RP_ID", ""), + rpOrigin: env("FLEET_MGR_RP_ORIGIN", ""), + rpDisplayName: env("FLEET_MGR_RP_NAME", "Fleet Command"), + enrollKeyFile: env("FLEET_MGR_ENROLL_KEY_FILE", ""), + credStorePath: env("FLEET_MGR_ADMIN_STORE", "/var/lib/fleet-manager/admins.json"), + sessionTTL: time.Duration(envInt("FLEET_MGR_SESSION_TTL_MIN", 720)) * time.Minute, + + inventoryPath: env("FLEET_MGR_INVENTORY", "/var/lib/fleet-manager/inventory.json"), + seed: env("FLEET_MGR_SEED", "") == "true", + hostnqnBase: env("HOSTNQN_BASE", "nqn.2026-06.org.example.fleet:host-"), + } +} + +// server bundles the shared dependencies the handlers close over. +type server struct { + cfg config + store Store + hub *agentHub // live agent streams; nil if the agent plane isn't configured + broker *brokerClient // enrol-broker admin client; nil if authorise isn't configured + influx *influxSink // metrics gateway; nil if InfluxDB isn't configured + + // Admin passkey auth (nil in insecure dev mode). + wa *webauthn.WebAuthn + creds *credStore + sessions *sessionStore + ceremonies *ceremonyStore + enrollKey []byte +} + +func main() { + // Subcommand: `fleet-manager register-admin ` mints a passkey-enrolment + // link (no server needed) — the bootstrap for the WebAuthn admin plane. + if len(os.Args) > 1 && os.Args[1] == "register-admin" { + os.Exit(runRegisterAdmin(os.Args[2:])) + } + // Subcommand: `fleet-manager register-host ` seeds/updates an inventory + // record for a host enrolled OUT OF BAND (bench broker, migrated fleet) so its + // agent stream is accepted — the manual counterpart to the authorise-approve + // flow that normally births the record. + if len(os.Args) > 1 && os.Args[1] == "register-host" { + os.Exit(runRegisterHost(os.Args[2:])) + } + + log.SetFlags(log.LstdFlags | log.LUTC) + cfg := loadConfig() + + store, err := newJSONStore(cfg.inventoryPath) + if err != nil { + log.Fatalf("inventory: %v", err) + } + if cfg.seed { + if n := seedHosts(store, cfg.hostnqnBase); n > 0 { + log.Printf("seeded %d demo host(s) (FLEET_MGR_SEED=true — dev only)", n) + } + } + + s := &server{cfg: cfg, store: store} + + // Authorise pillar: the enrol-broker admin client. Enabled when the broker URL + // is set AND its client PKI is present. Missing PKI (not yet provisioned) + // DISABLES the pillar with a loud warning rather than crash-looping the whole + // manager — so a partial bring-up boots and the pillar lights up once the cert + // is staged. A malformed/unreadable cert still hard-fails (a real misconfig). + switch { + case cfg.brokerAdminURL == "": + log.Printf("authorise pillar DISABLED (set FLEET_MGR_BROKER_URL + client cert/key + CA to enable)") + default: + if miss := missingFiles(cfg.brokerClientCert, cfg.brokerClientKey, cfg.brokerCA); len(miss) > 0 { + log.Printf("WARNING: authorise pillar DISABLED — broker PKI not found: %s (run enrol-broker/admin-pki.sh; see DEPLOY.md)", strings.Join(miss, ", ")) + break + } + broker, err := newBrokerClient(cfg) + if err != nil { + log.Fatalf("broker client: %v", err) + } + s.broker = broker + log.Printf("authorise pillar ENABLED -> enrol-broker %s (mTLS)", cfg.brokerAdminURL) + } + + // Metrics gateway: the manager is the only reader/writer of InfluxDB. nil (a + // no-op MetricsSink) when unconfigured, so the manager runs without metrics. + var sink MetricsSink + if cfg.influx.url != "" && cfg.influx.token != "" { + s.influx = newInfluxSink(cfg.influx) + defer s.influx.Close() + sink = s.influx + log.Printf("InfluxDB metrics gateway -> %s (org=%s bucket=%s)", cfg.influx.url, cfg.influx.org, cfg.influx.bucket) + } else { + log.Printf("InfluxDB DISABLED (set FLEET_MGR_INFLUX_URL + _TOKEN to enable metrics)") + } + + // Agent plane (gRPC/mTLS, fleet CA). Started only when configured; the admin + // plane can run standalone (e.g. before any host is enrolled). The hub is the + // live-stream registry the lifecycle API pushes desired-state through, and it + // writes each snapshot to the metrics sink. + switch { + case cfg.agentCert == "" || cfg.agentKey == "" || cfg.agentClientCA == "": + log.Printf("agent plane DISABLED (set FLEET_MGR_AGENT_CERT/_KEY/_CLIENT_CA to enable)") + default: + if miss := missingFiles(cfg.agentCert, cfg.agentKey, cfg.agentClientCA); len(miss) > 0 { + // Same soft-disable as the authorise pillar: boot without the agent + // plane until its fleet-CA PKI is staged, rather than crash-looping. + log.Printf("WARNING: agent plane DISABLED — PKI not found: %s (run server-pki.sh; see DEPLOY.md)", strings.Join(miss, ", ")) + break + } + s.hub = newAgentHub(store, sink) + go func() { + if err := serveAgent(cfg, s.hub); err != nil { + log.Fatalf("agent plane: %v", err) + } + }() + } + + // Admin browser auth = WebAuthn/passkey (secure mode only). Sets up the RP, + // the credential store, the shared enrol key, sessions + ceremonies. The admin + // TLS below is server-auth only — browser identity comes from the passkey. + if !cfg.adminInsecure { + wa, err := newWebAuthn(cfg) + if err != nil { + log.Fatalf("webauthn: %v", err) + } + creds, err := newCredStore(cfg.credStorePath) + if err != nil { + log.Fatalf("admin store: %v", err) + } + key, err := loadEnrollKey(cfg.enrollKeyFile) + if err != nil { + log.Fatalf("enrol key: %v", err) + } + s.wa, s.creds, s.enrollKey = wa, creds, key + s.sessions = newSessionStore(cfg.sessionTTL) + s.ceremonies = newCeremonyStore() + if creds.count() == 0 { + log.Printf("NOTE: no admins enrolled yet — run `fleet-manager register-admin ` and open the link to add a passkey") + } + } + + // HTTP handler: public liveness + WebAuthn ceremonies at the root; the admin + // API under /api/ gated by the passkey session (requireSession). The SPA (ckpt + // 6) will be served at / from an embedded FS. + root := http.NewServeMux() + s.authRoutes(root) + apiMux := http.NewServeMux() + s.apiRoutes(apiMux) + root.Handle("/api/", s.requireSession(apiMux)) + // SPA at the root (catch-all; the more specific /api, /auth, /healthz win). + // Embedded only in `-tags embedspa` builds; a stub otherwise (see spa_*.go). + root.Handle("/", s.spaHandler()) + + srv := &http.Server{ + Addr: cfg.adminAddr, + Handler: root, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + if cfg.adminInsecure { + log.Printf("WARNING: admin plane INSECURE (plaintext, NO passkey auth) on %s — dev/lab only", cfg.adminAddr) + log.Fatal(srv.ListenAndServe()) + } + + // Secure mode: passkey auth over PLAINTEXT behind a trusted TLS-terminating + // proxy (Traefik / k8s Ingress) that owns the cert — the manager never + // terminates TLS. WebAuthn only needs RP_ORIGIN to match the browser's HTTPS + // origin, not our transport; session cookies stay Secure. The listener MUST be + // bound private (loopback / a private net) so only the proxy reaches it. + log.Printf("fleet-manager admin (passkey, behind TLS proxy) plaintext listening on %s (rp=%s) — ensure only the proxy can reach this address", cfg.adminAddr, cfg.rpID) + log.Fatal(srv.ListenAndServe()) +} + +// ---- tiny helpers --------------------------------------------------------- + +func env(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +// missingFiles returns the subset of the given paths that don't exist (or aren't +// stat-able) on disk. Used to soft-disable an optional plane whose PKI hasn't +// been provisioned yet, instead of crash-looping the whole manager. +func missingFiles(paths ...string) []string { + var miss []string + for _, p := range paths { + if p == "" { + continue + } + if _, err := os.Stat(p); err != nil { + miss = append(miss, p) + } + } + return miss +} + +func envInt(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} diff --git a/serving/fleet-manager/proto/fleet_agent.pb.go b/serving/fleet-manager/proto/fleet_agent.pb.go new file mode 100644 index 0000000..c473e89 --- /dev/null +++ b/serving/fleet-manager/proto/fleet_agent.pb.go @@ -0,0 +1,848 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Command_Kind int32 + +const ( + Command_KIND_UNSPECIFIED Command_Kind = 0 + Command_REBOOT Command_Kind = 1 // reboot now (into desired target_version) + Command_DRAIN Command_Kind = 2 // stop taking work / prepare for maintenance + Command_RESYNC Command_Kind = 3 // re-fetch + re-apply desired-state now +) + +// Enum value maps for Command_Kind. +var ( + Command_Kind_name = map[int32]string{ + 0: "KIND_UNSPECIFIED", + 1: "REBOOT", + 2: "DRAIN", + 3: "RESYNC", + } + Command_Kind_value = map[string]int32{ + "KIND_UNSPECIFIED": 0, + "REBOOT": 1, + "DRAIN": 2, + "RESYNC": 3, + } +) + +func (x Command_Kind) Enum() *Command_Kind { + p := new(Command_Kind) + *p = x + return p +} + +func (x Command_Kind) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Command_Kind) Descriptor() protoreflect.EnumDescriptor { + return file_proto_fleet_agent_proto_enumTypes[0].Descriptor() +} + +func (Command_Kind) Type() protoreflect.EnumType { + return &file_proto_fleet_agent_proto_enumTypes[0] +} + +func (x Command_Kind) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Command_Kind.Descriptor instead. +func (Command_Kind) EnumDescriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6, 0} +} + +type AgentUp struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *AgentUp_Hello + // *AgentUp_Snapshot + // *AgentUp_Result + Msg isAgentUp_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentUp) Reset() { + *x = AgentUp{} + mi := &file_proto_fleet_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentUp) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentUp) ProtoMessage() {} + +func (x *AgentUp) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentUp.ProtoReflect.Descriptor instead. +func (*AgentUp) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{0} +} + +func (x *AgentUp) GetMsg() isAgentUp_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *AgentUp) GetHello() *Hello { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Hello); ok { + return x.Hello + } + } + return nil +} + +func (x *AgentUp) GetSnapshot() *HealthSnapshot { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Snapshot); ok { + return x.Snapshot + } + } + return nil +} + +func (x *AgentUp) GetResult() *CommandResult { + if x != nil { + if x, ok := x.Msg.(*AgentUp_Result); ok { + return x.Result + } + } + return nil +} + +type isAgentUp_Msg interface { + isAgentUp_Msg() +} + +type AgentUp_Hello struct { + Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"` // first frame after connect +} + +type AgentUp_Snapshot struct { + Snapshot *HealthSnapshot `protobuf:"bytes,2,opt,name=snapshot,proto3,oneof"` // periodic telemetry (also written to InfluxDB) +} + +type AgentUp_Result struct { + Result *CommandResult `protobuf:"bytes,3,opt,name=result,proto3,oneof"` // outcome of a ManagerDown.Command +} + +func (*AgentUp_Hello) isAgentUp_Msg() {} + +func (*AgentUp_Snapshot) isAgentUp_Msg() {} + +func (*AgentUp_Result) isAgentUp_Msg() {} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +type Hello struct { + state protoimpl.MessageState `protogen:"open.v1"` + ImageVersion string `protobuf:"bytes,1,opt,name=image_version,json=imageVersion,proto3" json:"image_version,omitempty"` + RootfsDigest string `protobuf:"bytes,2,opt,name=rootfs_digest,json=rootfsDigest,proto3" json:"rootfs_digest,omitempty"` + Kernel string `protobuf:"bytes,3,opt,name=kernel,proto3" json:"kernel,omitempty"` + KioskUrl string `protobuf:"bytes,4,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + StarshipName string `protobuf:"bytes,5,opt,name=starship_name,json=starshipName,proto3" json:"starship_name,omitempty"` // advisory; manager already knows it from inventory + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Hello) Reset() { + *x = Hello{} + mi := &file_proto_fleet_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Hello) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Hello) ProtoMessage() {} + +func (x *Hello) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Hello.ProtoReflect.Descriptor instead. +func (*Hello) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *Hello) GetImageVersion() string { + if x != nil { + return x.ImageVersion + } + return "" +} + +func (x *Hello) GetRootfsDigest() string { + if x != nil { + return x.RootfsDigest + } + return "" +} + +func (x *Hello) GetKernel() string { + if x != nil { + return x.Kernel + } + return "" +} + +func (x *Hello) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *Hello) GetStarshipName() string { + if x != nil { + return x.StarshipName + } + return "" +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +type HealthSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + AtUnix int64 `protobuf:"varint,1,opt,name=at_unix,json=atUnix,proto3" json:"at_unix,omitempty"` // sample time, unix seconds (0 = manager stamps now) + LoadAvg float64 `protobuf:"fixed64,2,opt,name=load_avg,json=loadAvg,proto3" json:"load_avg,omitempty"` + CpuPct float64 `protobuf:"fixed64,3,opt,name=cpu_pct,json=cpuPct,proto3" json:"cpu_pct,omitempty"` + TempC float64 `protobuf:"fixed64,4,opt,name=temp_c,json=tempC,proto3" json:"temp_c,omitempty"` + MemUsedPct float64 `protobuf:"fixed64,5,opt,name=mem_used_pct,json=memUsedPct,proto3" json:"mem_used_pct,omitempty"` + TmpfsUsedPct float64 `protobuf:"fixed64,6,opt,name=tmpfs_used_pct,json=tmpfsUsedPct,proto3" json:"tmpfs_used_pct,omitempty"` // overlay RAM pressure + NvmeRoot string `protobuf:"bytes,7,opt,name=nvme_root,json=nvmeRoot,proto3" json:"nvme_root,omitempty"` // ok|degraded|down + Auth string `protobuf:"bytes,8,opt,name=auth,proto3" json:"auth,omitempty"` // 802.1x/macsec: up|down + Selinux string `protobuf:"bytes,9,opt,name=selinux,proto3" json:"selinux,omitempty"` // enforcing|permissive + Chrony string `protobuf:"bytes,10,opt,name=chrony,proto3" json:"chrony,omitempty"` // synced|unsynced + KioskActive bool `protobuf:"varint,11,opt,name=kiosk_active,json=kioskActive,proto3" json:"kiosk_active,omitempty"` + SessionUptime string `protobuf:"bytes,12,opt,name=session_uptime,json=sessionUptime,proto3" json:"session_uptime,omitempty"` // KIOSK session uptime (advisory string) + ChromiumCrashes int32 `protobuf:"varint,13,opt,name=chromium_crashes,json=chromiumCrashes,proto3" json:"chromium_crashes,omitempty"` + Display string `protobuf:"bytes,14,opt,name=display,proto3" json:"display,omitempty"` + UptimeSeconds int64 `protobuf:"varint,15,opt,name=uptime_seconds,json=uptimeSeconds,proto3" json:"uptime_seconds,omitempty"` // HOST uptime (seconds since boot) — boot_time = + // manager receive-time - this; drives reboot-pending + // chrony tracking relative to the NTP source (from `chronyc tracking`): + ChronyOffsetSeconds float64 `protobuf:"fixed64,16,opt,name=chrony_offset_seconds,json=chronyOffsetSeconds,proto3" json:"chrony_offset_seconds,omitempty"` // system time offset from NTP (signed, fast=+) + ChronySource string `protobuf:"bytes,17,opt,name=chrony_source,json=chronySource,proto3" json:"chrony_source,omitempty"` // reference source name/IP (which server) + ChronyStratum int32 `protobuf:"varint,18,opt,name=chrony_stratum,json=chronyStratum,proto3" json:"chrony_stratum,omitempty"` // hops from a reference clock (0 = unsynced) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *HealthSnapshot) Reset() { + *x = HealthSnapshot{} + mi := &file_proto_fleet_agent_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *HealthSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HealthSnapshot) ProtoMessage() {} + +func (x *HealthSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HealthSnapshot.ProtoReflect.Descriptor instead. +func (*HealthSnapshot) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{2} +} + +func (x *HealthSnapshot) GetAtUnix() int64 { + if x != nil { + return x.AtUnix + } + return 0 +} + +func (x *HealthSnapshot) GetLoadAvg() float64 { + if x != nil { + return x.LoadAvg + } + return 0 +} + +func (x *HealthSnapshot) GetCpuPct() float64 { + if x != nil { + return x.CpuPct + } + return 0 +} + +func (x *HealthSnapshot) GetTempC() float64 { + if x != nil { + return x.TempC + } + return 0 +} + +func (x *HealthSnapshot) GetMemUsedPct() float64 { + if x != nil { + return x.MemUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetTmpfsUsedPct() float64 { + if x != nil { + return x.TmpfsUsedPct + } + return 0 +} + +func (x *HealthSnapshot) GetNvmeRoot() string { + if x != nil { + return x.NvmeRoot + } + return "" +} + +func (x *HealthSnapshot) GetAuth() string { + if x != nil { + return x.Auth + } + return "" +} + +func (x *HealthSnapshot) GetSelinux() string { + if x != nil { + return x.Selinux + } + return "" +} + +func (x *HealthSnapshot) GetChrony() string { + if x != nil { + return x.Chrony + } + return "" +} + +func (x *HealthSnapshot) GetKioskActive() bool { + if x != nil { + return x.KioskActive + } + return false +} + +func (x *HealthSnapshot) GetSessionUptime() string { + if x != nil { + return x.SessionUptime + } + return "" +} + +func (x *HealthSnapshot) GetChromiumCrashes() int32 { + if x != nil { + return x.ChromiumCrashes + } + return 0 +} + +func (x *HealthSnapshot) GetDisplay() string { + if x != nil { + return x.Display + } + return "" +} + +func (x *HealthSnapshot) GetUptimeSeconds() int64 { + if x != nil { + return x.UptimeSeconds + } + return 0 +} + +func (x *HealthSnapshot) GetChronyOffsetSeconds() float64 { + if x != nil { + return x.ChronyOffsetSeconds + } + return 0 +} + +func (x *HealthSnapshot) GetChronySource() string { + if x != nil { + return x.ChronySource + } + return "" +} + +func (x *HealthSnapshot) GetChronyStratum() int32 { + if x != nil { + return x.ChronyStratum + } + return 0 +} + +type CommandResult struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Ok bool `protobuf:"varint,2,opt,name=ok,proto3" json:"ok,omitempty"` + Detail string `protobuf:"bytes,3,opt,name=detail,proto3" json:"detail,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CommandResult) Reset() { + *x = CommandResult{} + mi := &file_proto_fleet_agent_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CommandResult) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CommandResult) ProtoMessage() {} + +func (x *CommandResult) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CommandResult.ProtoReflect.Descriptor instead. +func (*CommandResult) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{3} +} + +func (x *CommandResult) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *CommandResult) GetOk() bool { + if x != nil { + return x.Ok + } + return false +} + +func (x *CommandResult) GetDetail() string { + if x != nil { + return x.Detail + } + return "" +} + +type ManagerDown struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Msg: + // + // *ManagerDown_Desired + // *ManagerDown_Command + Msg isManagerDown_Msg `protobuf_oneof:"msg"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ManagerDown) Reset() { + *x = ManagerDown{} + mi := &file_proto_fleet_agent_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ManagerDown) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ManagerDown) ProtoMessage() {} + +func (x *ManagerDown) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ManagerDown.ProtoReflect.Descriptor instead. +func (*ManagerDown) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{4} +} + +func (x *ManagerDown) GetMsg() isManagerDown_Msg { + if x != nil { + return x.Msg + } + return nil +} + +func (x *ManagerDown) GetDesired() *DesiredState { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Desired); ok { + return x.Desired + } + } + return nil +} + +func (x *ManagerDown) GetCommand() *Command { + if x != nil { + if x, ok := x.Msg.(*ManagerDown_Command); ok { + return x.Command + } + } + return nil +} + +type isManagerDown_Msg interface { + isManagerDown_Msg() +} + +type ManagerDown_Desired struct { + Desired *DesiredState `protobuf:"bytes,1,opt,name=desired,proto3,oneof"` // full desired-state: sent on connect + on change +} + +type ManagerDown_Command struct { + Command *Command `protobuf:"bytes,2,opt,name=command,proto3,oneof"` // imperative action +} + +func (*ManagerDown_Desired) isManagerDown_Msg() {} + +func (*ManagerDown_Command) isManagerDown_Msg() {} + +// DesiredState — server-authoritative target the agent reconciles toward. +type DesiredState struct { + state protoimpl.MessageState `protogen:"open.v1"` + TargetVersion string `protobuf:"bytes,1,opt,name=target_version,json=targetVersion,proto3" json:"target_version,omitempty"` + KioskUrl string `protobuf:"bytes,2,opt,name=kiosk_url,json=kioskUrl,proto3" json:"kiosk_url,omitempty"` + RebootAtUnix int64 `protobuf:"varint,3,opt,name=reboot_at_unix,json=rebootAtUnix,proto3" json:"reboot_at_unix,omitempty"` // 0 = no scheduled reboot + Drain bool `protobuf:"varint,4,opt,name=drain,proto3" json:"drain,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DesiredState) Reset() { + *x = DesiredState{} + mi := &file_proto_fleet_agent_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DesiredState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DesiredState) ProtoMessage() {} + +func (x *DesiredState) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DesiredState.ProtoReflect.Descriptor instead. +func (*DesiredState) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{5} +} + +func (x *DesiredState) GetTargetVersion() string { + if x != nil { + return x.TargetVersion + } + return "" +} + +func (x *DesiredState) GetKioskUrl() string { + if x != nil { + return x.KioskUrl + } + return "" +} + +func (x *DesiredState) GetRebootAtUnix() int64 { + if x != nil { + return x.RebootAtUnix + } + return 0 +} + +func (x *DesiredState) GetDrain() bool { + if x != nil { + return x.Drain + } + return false +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +type Command struct { + state protoimpl.MessageState `protogen:"open.v1"` + CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + Kind Command_Kind `protobuf:"varint,2,opt,name=kind,proto3,enum=fleet.agent.v1.Command_Kind" json:"kind,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Command) Reset() { + *x = Command{} + mi := &file_proto_fleet_agent_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Command) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Command) ProtoMessage() {} + +func (x *Command) ProtoReflect() protoreflect.Message { + mi := &file_proto_fleet_agent_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Command.ProtoReflect.Descriptor instead. +func (*Command) Descriptor() ([]byte, []int) { + return file_proto_fleet_agent_proto_rawDescGZIP(), []int{6} +} + +func (x *Command) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *Command) GetKind() Command_Kind { + if x != nil { + return x.Kind + } + return Command_KIND_UNSPECIFIED +} + +var File_proto_fleet_agent_proto protoreflect.FileDescriptor + +const file_proto_fleet_agent_proto_rawDesc = "" + + "\n" + + "\x17proto/fleet_agent.proto\x12\x0efleet.agent.v1\"\xb6\x01\n" + + "\aAgentUp\x12-\n" + + "\x05hello\x18\x01 \x01(\v2\x15.fleet.agent.v1.HelloH\x00R\x05hello\x12<\n" + + "\bsnapshot\x18\x02 \x01(\v2\x1e.fleet.agent.v1.HealthSnapshotH\x00R\bsnapshot\x127\n" + + "\x06result\x18\x03 \x01(\v2\x1d.fleet.agent.v1.CommandResultH\x00R\x06resultB\x05\n" + + "\x03msg\"\xab\x01\n" + + "\x05Hello\x12#\n" + + "\rimage_version\x18\x01 \x01(\tR\fimageVersion\x12#\n" + + "\rrootfs_digest\x18\x02 \x01(\tR\frootfsDigest\x12\x16\n" + + "\x06kernel\x18\x03 \x01(\tR\x06kernel\x12\x1b\n" + + "\tkiosk_url\x18\x04 \x01(\tR\bkioskUrl\x12#\n" + + "\rstarship_name\x18\x05 \x01(\tR\fstarshipName\"\xd5\x04\n" + + "\x0eHealthSnapshot\x12\x17\n" + + "\aat_unix\x18\x01 \x01(\x03R\x06atUnix\x12\x19\n" + + "\bload_avg\x18\x02 \x01(\x01R\aloadAvg\x12\x17\n" + + "\acpu_pct\x18\x03 \x01(\x01R\x06cpuPct\x12\x15\n" + + "\x06temp_c\x18\x04 \x01(\x01R\x05tempC\x12 \n" + + "\fmem_used_pct\x18\x05 \x01(\x01R\n" + + "memUsedPct\x12$\n" + + "\x0etmpfs_used_pct\x18\x06 \x01(\x01R\ftmpfsUsedPct\x12\x1b\n" + + "\tnvme_root\x18\a \x01(\tR\bnvmeRoot\x12\x12\n" + + "\x04auth\x18\b \x01(\tR\x04auth\x12\x18\n" + + "\aselinux\x18\t \x01(\tR\aselinux\x12\x16\n" + + "\x06chrony\x18\n" + + " \x01(\tR\x06chrony\x12!\n" + + "\fkiosk_active\x18\v \x01(\bR\vkioskActive\x12%\n" + + "\x0esession_uptime\x18\f \x01(\tR\rsessionUptime\x12)\n" + + "\x10chromium_crashes\x18\r \x01(\x05R\x0fchromiumCrashes\x12\x18\n" + + "\adisplay\x18\x0e \x01(\tR\adisplay\x12%\n" + + "\x0euptime_seconds\x18\x0f \x01(\x03R\ruptimeSeconds\x122\n" + + "\x15chrony_offset_seconds\x18\x10 \x01(\x01R\x13chronyOffsetSeconds\x12#\n" + + "\rchrony_source\x18\x11 \x01(\tR\fchronySource\x12%\n" + + "\x0echrony_stratum\x18\x12 \x01(\x05R\rchronyStratum\"V\n" + + "\rCommandResult\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x0e\n" + + "\x02ok\x18\x02 \x01(\bR\x02ok\x12\x16\n" + + "\x06detail\x18\x03 \x01(\tR\x06detail\"\x83\x01\n" + + "\vManagerDown\x128\n" + + "\adesired\x18\x01 \x01(\v2\x1c.fleet.agent.v1.DesiredStateH\x00R\adesired\x123\n" + + "\acommand\x18\x02 \x01(\v2\x17.fleet.agent.v1.CommandH\x00R\acommandB\x05\n" + + "\x03msg\"\x8e\x01\n" + + "\fDesiredState\x12%\n" + + "\x0etarget_version\x18\x01 \x01(\tR\rtargetVersion\x12\x1b\n" + + "\tkiosk_url\x18\x02 \x01(\tR\bkioskUrl\x12$\n" + + "\x0ereboot_at_unix\x18\x03 \x01(\x03R\frebootAtUnix\x12\x14\n" + + "\x05drain\x18\x04 \x01(\bR\x05drain\"\x9b\x01\n" + + "\aCommand\x12\x1d\n" + + "\n" + + "command_id\x18\x01 \x01(\tR\tcommandId\x120\n" + + "\x04kind\x18\x02 \x01(\x0e2\x1c.fleet.agent.v1.Command.KindR\x04kind\"?\n" + + "\x04Kind\x12\x14\n" + + "\x10KIND_UNSPECIFIED\x10\x00\x12\n" + + "\n" + + "\x06REBOOT\x10\x01\x12\t\n" + + "\x05DRAIN\x10\x02\x12\n" + + "\n" + + "\x06RESYNC\x10\x032Q\n" + + "\n" + + "FleetAgent\x12C\n" + + "\aSession\x12\x17.fleet.agent.v1.AgentUp\x1a\x1b.fleet.agent.v1.ManagerDown(\x010\x01B\x1dZ\x1bfleet-manager/proto;fleetpbb\x06proto3" + +var ( + file_proto_fleet_agent_proto_rawDescOnce sync.Once + file_proto_fleet_agent_proto_rawDescData []byte +) + +func file_proto_fleet_agent_proto_rawDescGZIP() []byte { + file_proto_fleet_agent_proto_rawDescOnce.Do(func() { + file_proto_fleet_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc))) + }) + return file_proto_fleet_agent_proto_rawDescData +} + +var file_proto_fleet_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_proto_fleet_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_proto_fleet_agent_proto_goTypes = []any{ + (Command_Kind)(0), // 0: fleet.agent.v1.Command.Kind + (*AgentUp)(nil), // 1: fleet.agent.v1.AgentUp + (*Hello)(nil), // 2: fleet.agent.v1.Hello + (*HealthSnapshot)(nil), // 3: fleet.agent.v1.HealthSnapshot + (*CommandResult)(nil), // 4: fleet.agent.v1.CommandResult + (*ManagerDown)(nil), // 5: fleet.agent.v1.ManagerDown + (*DesiredState)(nil), // 6: fleet.agent.v1.DesiredState + (*Command)(nil), // 7: fleet.agent.v1.Command +} +var file_proto_fleet_agent_proto_depIdxs = []int32{ + 2, // 0: fleet.agent.v1.AgentUp.hello:type_name -> fleet.agent.v1.Hello + 3, // 1: fleet.agent.v1.AgentUp.snapshot:type_name -> fleet.agent.v1.HealthSnapshot + 4, // 2: fleet.agent.v1.AgentUp.result:type_name -> fleet.agent.v1.CommandResult + 6, // 3: fleet.agent.v1.ManagerDown.desired:type_name -> fleet.agent.v1.DesiredState + 7, // 4: fleet.agent.v1.ManagerDown.command:type_name -> fleet.agent.v1.Command + 0, // 5: fleet.agent.v1.Command.kind:type_name -> fleet.agent.v1.Command.Kind + 1, // 6: fleet.agent.v1.FleetAgent.Session:input_type -> fleet.agent.v1.AgentUp + 5, // 7: fleet.agent.v1.FleetAgent.Session:output_type -> fleet.agent.v1.ManagerDown + 7, // [7:8] is the sub-list for method output_type + 6, // [6:7] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name +} + +func init() { file_proto_fleet_agent_proto_init() } +func file_proto_fleet_agent_proto_init() { + if File_proto_fleet_agent_proto != nil { + return + } + file_proto_fleet_agent_proto_msgTypes[0].OneofWrappers = []any{ + (*AgentUp_Hello)(nil), + (*AgentUp_Snapshot)(nil), + (*AgentUp_Result)(nil), + } + file_proto_fleet_agent_proto_msgTypes[4].OneofWrappers = []any{ + (*ManagerDown_Desired)(nil), + (*ManagerDown_Command)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_fleet_agent_proto_rawDesc), len(file_proto_fleet_agent_proto_rawDesc)), + NumEnums: 1, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_proto_fleet_agent_proto_goTypes, + DependencyIndexes: file_proto_fleet_agent_proto_depIdxs, + EnumInfos: file_proto_fleet_agent_proto_enumTypes, + MessageInfos: file_proto_fleet_agent_proto_msgTypes, + }.Build() + File_proto_fleet_agent_proto = out.File + file_proto_fleet_agent_proto_goTypes = nil + file_proto_fleet_agent_proto_depIdxs = nil +} diff --git a/serving/fleet-manager/proto/fleet_agent.proto b/serving/fleet-manager/proto/fleet_agent.proto new file mode 100644 index 0000000..72dd02d --- /dev/null +++ b/serving/fleet-manager/proto/fleet_agent.proto @@ -0,0 +1,102 @@ +syntax = "proto3"; + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. +package fleet.agent.v1; + +option go_package = "fleet-manager/proto;fleetpb"; + +service FleetAgent { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + rpc Session(stream AgentUp) returns (stream ManagerDown); +} + +// ---- host -> manager ------------------------------------------------------ + +message AgentUp { + oneof msg { + Hello hello = 1; // first frame after connect + HealthSnapshot snapshot = 2; // periodic telemetry (also written to InfluxDB) + CommandResult result = 3; // outcome of a ManagerDown.Command + } +} + +// Hello — what the host is running, sent once on connect so the manager can +// reconcile version drift immediately. +message Hello { + string image_version = 1; + string rootfs_digest = 2; + string kernel = 3; + string kiosk_url = 4; + string starship_name = 5; // advisory; manager already knows it from inventory +} + +// HealthSnapshot — one telemetry sample. Cached as the latest value in inventory +// AND written as a time-series point to InfluxDB (tagged with the authenticated +// cert serial / hostnqn). +message HealthSnapshot { + int64 at_unix = 1; // sample time, unix seconds (0 = manager stamps now) + double load_avg = 2; + double cpu_pct = 3; + double temp_c = 4; + double mem_used_pct = 5; + double tmpfs_used_pct = 6; // overlay RAM pressure + string nvme_root = 7; // ok|degraded|down + string auth = 8; // 802.1x/macsec: up|down + string selinux = 9; // enforcing|permissive + string chrony = 10; // synced|unsynced + bool kiosk_active = 11; + string session_uptime = 12; // KIOSK session uptime (advisory string) + int32 chromium_crashes = 13; + string display = 14; + int64 uptime_seconds = 15; // HOST uptime (seconds since boot) — boot_time = + // manager receive-time - this; drives reboot-pending + // chrony tracking relative to the NTP source (from `chronyc tracking`): + double chrony_offset_seconds = 16; // system time offset from NTP (signed, fast=+) + string chrony_source = 17; // reference source name/IP (which server) + int32 chrony_stratum = 18; // hops from a reference clock (0 = unsynced) +} + +message CommandResult { + string command_id = 1; + bool ok = 2; + string detail = 3; +} + +// ---- manager -> host ------------------------------------------------------ + +message ManagerDown { + oneof msg { + DesiredState desired = 1; // full desired-state: sent on connect + on change + Command command = 2; // imperative action + } +} + +// DesiredState — server-authoritative target the agent reconciles toward. +message DesiredState { + string target_version = 1; + string kiosk_url = 2; + int64 reboot_at_unix = 3; // 0 = no scheduled reboot + bool drain = 4; +} + +// Command — an imperative action pushed live (admin clicked a button in the SPA). +message Command { + string command_id = 1; + Kind kind = 2; + + enum Kind { + KIND_UNSPECIFIED = 0; + REBOOT = 1; // reboot now (into desired target_version) + DRAIN = 2; // stop taking work / prepare for maintenance + RESYNC = 3; // re-fetch + re-apply desired-state now + } +} diff --git a/serving/fleet-manager/proto/fleet_agent_grpc.pb.go b/serving/fleet-manager/proto/fleet_agent_grpc.pb.go new file mode 100644 index 0000000..7e42882 --- /dev/null +++ b/serving/fleet-manager/proto/fleet_agent_grpc.pb.go @@ -0,0 +1,130 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc v3.21.12 +// source: proto/fleet_agent.proto + +// The steady-state contract between the on-host fleet agent and the +// fleet-manager. ONE long-lived, host-dialed, mTLS bidi stream (health/metrics +// up, desired-state down) — the design's resolved transport fork (bespoke gRPC, +// not OTLP+Wazuh). See docs/fleet-management-design.md + tpm-enrolment-design.md. +// +// Identity is the mTLS peer cert (the host's reused TPM identity cert, fleet CA); +// the manager derives the cert serial (= inventory key) from it, so AgentUp +// messages carry NO self-asserted identity — a host can only ever be itself. + +package fleetpb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + FleetAgent_Session_FullMethodName = "/fleet.agent.v1.FleetAgent/Session" +) + +// FleetAgentClient is the client API for FleetAgent service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type FleetAgentClient interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) +} + +type fleetAgentClient struct { + cc grpc.ClientConnInterface +} + +func NewFleetAgentClient(cc grpc.ClientConnInterface) FleetAgentClient { + return &fleetAgentClient{cc} +} + +func (c *fleetAgentClient) Session(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentUp, ManagerDown], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &FleetAgent_ServiceDesc.Streams[0], FleetAgent_Session_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[AgentUp, ManagerDown]{ClientStream: stream} + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionClient = grpc.BidiStreamingClient[AgentUp, ManagerDown] + +// FleetAgentServer is the server API for FleetAgent service. +// All implementations must embed UnimplementedFleetAgentServer +// for forward compatibility. +type FleetAgentServer interface { + // Session: the single bidirectional stream. The host opens it (dials out, no + // inbound host port); the manager pushes desired-state/commands back over the + // same connection. Closing/keepalive-timeout = the host's offline signal. + Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error + mustEmbedUnimplementedFleetAgentServer() +} + +// UnimplementedFleetAgentServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedFleetAgentServer struct{} + +func (UnimplementedFleetAgentServer) Session(grpc.BidiStreamingServer[AgentUp, ManagerDown]) error { + return status.Error(codes.Unimplemented, "method Session not implemented") +} +func (UnimplementedFleetAgentServer) mustEmbedUnimplementedFleetAgentServer() {} +func (UnimplementedFleetAgentServer) testEmbeddedByValue() {} + +// UnsafeFleetAgentServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to FleetAgentServer will +// result in compilation errors. +type UnsafeFleetAgentServer interface { + mustEmbedUnimplementedFleetAgentServer() +} + +func RegisterFleetAgentServer(s grpc.ServiceRegistrar, srv FleetAgentServer) { + // If the following call panics, it indicates UnimplementedFleetAgentServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&FleetAgent_ServiceDesc, srv) +} + +func _FleetAgent_Session_Handler(srv interface{}, stream grpc.ServerStream) error { + return srv.(FleetAgentServer).Session(&grpc.GenericServerStream[AgentUp, ManagerDown]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type FleetAgent_SessionServer = grpc.BidiStreamingServer[AgentUp, ManagerDown] + +// FleetAgent_ServiceDesc is the grpc.ServiceDesc for FleetAgent service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var FleetAgent_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "fleet.agent.v1.FleetAgent", + HandlerType: (*FleetAgentServer)(nil), + Methods: []grpc.MethodDesc{}, + Streams: []grpc.StreamDesc{ + { + StreamName: "Session", + Handler: _FleetAgent_Session_Handler, + ServerStreams: true, + ClientStreams: true, + }, + }, + Metadata: "proto/fleet_agent.proto", +} diff --git a/serving/fleet-manager/server-pki.sh b/serving/fleet-manager/server-pki.sh new file mode 100755 index 0000000..ca8f9cf --- /dev/null +++ b/serving/fleet-manager/server-pki.sh @@ -0,0 +1,98 @@ +#!/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. +# +# NOTE: where the fleet step-ca is CO-LOCATED on this host, prefer the +# `agent-cert-renewer` compose sidecar (docker-compose.yml, profile cert-auto) — +# it does this issuance AND auto-renews before the CA's ~90-day cap, preserving +# SANs, with no `step` CLI on the host. This script is the manual / remote-CA path. +# +# This does NOT issue: +# - the ADMIN-plane UI cert -> owned by the Traefik ingress (Let's Encrypt +# DNS-01); the manager never terminates TLS. See DEPLOY.md §2c. +# - 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=manager.fleet.lab,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:-manager.fleet.lab} +FLEET_CA_URL=${FLEET_CA_URL:-https://ca.fleet.lab:8443} +FLEET_CA_ROOT=${FLEET_CA_ROOT:-../dot1x-bench/pki/root_ca.crt} +FLEET_CA_INTERMEDIATE=${FLEET_CA_INTERMEDIATE:-../dot1x-bench/pki/intermediate_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" + +# 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" + +# fleet-ca.crt = the agent-plane CLIENT CA that verifies host TPM identity certs. +# Host certs are stored LEAF-ONLY in TPM NV and are signed by the step-ca +# INTERMEDIATE, so this MUST be root + intermediate. Root-only fails two ways: +# (1) the manager can't build leaf->intermediate->root, and (2) it advertises only +# the root in its TLS CertificateRequest, so a Go client whose leaf is issued by +# the intermediate sends NO cert at all ("remote error: tls: certificate +# required"). Prefer an explicit intermediate file; else pull it from the chain +# step wrote into the just-issued server.crt (every cert after the leaf). +intermediate="" +if [ -f "$FLEET_CA_INTERMEDIATE" ]; then + intermediate=$(cat "$FLEET_CA_INTERMEDIATE") +else + intermediate=$(awk 'BEGIN{c=0} /-----BEGIN CERTIFICATE-----/{c++} c>1' "$OUT/server.crt") +fi +{ + [ -n "$intermediate" ] && printf '%s\n' "$intermediate" + cat "$FLEET_CA_ROOT" +} > "$OUT/fleet-ca.crt" +if [ -z "$intermediate" ]; then + echo "WARNING: no intermediate CA found (set FLEET_CA_INTERMEDIATE, or the CA has none) —" >&2 + echo " fleet-ca.crt is ROOT-ONLY; leaf-only host certs issued by an intermediate" >&2 + echo " will fail mTLS with 'tls: certificate required'." >&2 +fi + +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 : owned by the Traefik ingress (LE DNS-01) — DEPLOY.md §2c" +echo " - broker client : ../enrol-broker/admin-pki.sh ../dot1x-bench/pki/broker fleet-manager" +echo " then: cp -r ../dot1x-bench/pki/broker ./pki/broker" diff --git a/serving/fleet-manager/session.go b/serving/fleet-manager/session.go new file mode 100644 index 0000000..ac0bd44 --- /dev/null +++ b/serving/fleet-manager/session.go @@ -0,0 +1,149 @@ +// session.go — post-passkey sessions + the middleware that gates the admin API. +// +// WebAuthn authenticates a login CEREMONY; from there we carry an opaque, +// server-side session (revocable, no secrets in the cookie). Cookie is +// Secure + HttpOnly + SameSite=Strict; state-changing requests additionally must +// echo the session's CSRF token in a header (belt-and-braces beyond same-origin). +package main + +import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "net/http" + "sync" + "time" +) + +const ( + sessionCookie = "fleet_session" + csrfHeader = "X-Fleet-CSRF" +) + +type ctxKey int + +const adminCtxKey ctxKey = 0 + +type session struct { + admin string + csrf string + expires time.Time +} + +type sessionStore struct { + mu sync.Mutex + ttl time.Duration + byID map[string]session +} + +func newSessionStore(ttl time.Duration) *sessionStore { + return &sessionStore{ttl: ttl, byID: map[string]session{}} +} + +func randToken() string { + b := make([]byte, 32) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +// create mints a session for a just-authenticated admin, returning the session id +// (cookie value) and the CSRF token (returned to the SPA in the login response). +func (s *sessionStore) create(admin string) (id, csrf string) { + id, csrf = randToken(), randToken() + s.mu.Lock() + s.byID[id] = session{admin: admin, csrf: csrf, expires: time.Now().Add(s.ttl)} + s.mu.Unlock() + return id, csrf +} + +func (s *sessionStore) get(id string) (session, bool) { + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.byID[id] + if !ok { + return session{}, false + } + if time.Now().After(sess.expires) { + delete(s.byID, id) + return session{}, false + } + return sess, true +} + +func (s *sessionStore) delete(id string) { + s.mu.Lock() + delete(s.byID, id) + s.mu.Unlock() +} + +// requireSession wraps the admin API. It rejects requests without a valid session +// cookie, and (for mutating methods) without the matching CSRF header. The +// authenticated admin name is injected into the request context for audit. +func (s *server) requireSession(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Insecure dev mode has no passkey auth — open, and adminName reports the + // dev sentinel. (main refuses insecure unless explicitly enabled.) + if s.cfg.adminInsecure { + next.ServeHTTP(w, r) + return + } + c, err := r.Cookie(sessionCookie) + if err != nil { + writeErr(w, http.StatusUnauthorized, "not signed in") + return + } + sess, ok := s.sessions.get(c.Value) + if !ok { + writeErr(w, http.StatusUnauthorized, "session expired") + return + } + if isMutating(r.Method) { + if subtle.ConstantTimeCompare([]byte(r.Header.Get(csrfHeader)), []byte(sess.csrf)) != 1 { + writeErr(w, http.StatusForbidden, "missing or bad CSRF token") + return + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), adminCtxKey, sess))) + }) +} + +// sessionFrom returns the authenticated session injected by requireSession. +func sessionFrom(r *http.Request) (session, bool) { + sess, ok := r.Context().Value(adminCtxKey).(session) + return sess, ok +} + +func isMutating(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + } + return false +} + +// setSessionCookie writes the session cookie. Secure+HttpOnly+SameSite=Strict. +func setSessionCookie(w http.ResponseWriter, id string, ttl time.Duration) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookie, + Value: id, + Path: "/", + MaxAge: int(ttl.Seconds()), + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode}) +} + +// adminName returns the authenticated admin for audit attribution — the session +// identity in secure mode, or the dev sentinel in insecure mode. +func adminName(r *http.Request) string { + if sess, ok := sessionFrom(r); ok && sess.admin != "" { + return sess.admin + } + return "dev-insecure" +} diff --git a/serving/fleet-manager/spa_embed.go b/serving/fleet-manager/spa_embed.go new file mode 100644 index 0000000..a081ba7 --- /dev/null +++ b/serving/fleet-manager/spa_embed.go @@ -0,0 +1,37 @@ +//go:build embedspa + +// spa_embed.go — serves the Vite-built SPA embedded into the binary. Built only +// with `-tags embedspa` (after `npm --prefix ui ci && npm --prefix ui run build`), +// so the default `go build`/`go test` don't require ui/dist (a build artifact). +// The Dockerfile builds the SPA then compiles with this tag. +package main + +import ( + "embed" + "io/fs" + "net/http" + "strings" +) + +//go:embed all:ui/dist +var spaFiles embed.FS + +func (s *server) spaHandler() http.Handler { + sub, err := fs.Sub(spaFiles, "ui/dist") + if err != nil { + panic(err) + } + files := http.FileServer(http.FS(sub)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := strings.TrimPrefix(r.URL.Path, "/") + if p == "" { + p = "index.html" + } + // Real asset -> serve it; anything else -> SPA entry (hash routing). + if _, err := fs.Stat(sub, p); err != nil { + http.ServeFileFS(w, r, sub, "index.html") + return + } + files.ServeHTTP(w, r) + }) +} diff --git a/serving/fleet-manager/spa_stub.go b/serving/fleet-manager/spa_stub.go new file mode 100644 index 0000000..11634f4 --- /dev/null +++ b/serving/fleet-manager/spa_stub.go @@ -0,0 +1,17 @@ +//go:build !embedspa + +// spa_stub.go — the default build has no SPA embedded (so `go build`/`go test` +// don't need ui/dist). Build with `-tags embedspa` after building the SPA to +// serve the real UI (see spa_embed.go). +package main + +import "net/http" + +func (s *server) spaHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("fleet-manager: SPA not embedded in this build.\n" + + "Build with: (cd ui && npm ci && npm run build) then `go build -tags embedspa`.\n")) + }) +} diff --git a/serving/fleet-manager/store.go b/serving/fleet-manager/store.go new file mode 100644 index 0000000..b32cb33 --- /dev/null +++ b/serving/fleet-manager/store.go @@ -0,0 +1,167 @@ +// store.go — jsonStore: a mutex-guarded, JSON-file-backed inventory Store. +// +// Rationale for a file (not a DB) in v1: stdlib-only, no cgo, trivially +// reproducible/inspectable, and a fleet of kiosks is small. Writes are atomic +// (temp file + rename) so a crash mid-save can't corrupt the inventory. The Store +// interface keeps a SQLite/Postgres swap a drop-in if scale ever demands it. +// +// On disk: a single JSON object { "": {Host...}, ... } at the +// configured path (default /var/lib/fleet-manager/inventory.json), which in the +// deployed container is a mounted volume so the record survives restarts (unlike +// the broker's deliberately-ephemeral PENDING store). +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type jsonStore struct { + mu sync.Mutex + path string + items map[string]*Host // keyed by cert serial +} + +// newJSONStore loads the inventory file if present (absent = empty fleet). +func newJSONStore(path string) (*jsonStore, error) { + s := &jsonStore{path: path, items: map[string]*Host{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil // fresh fleet + } + return nil, fmt.Errorf("read inventory %s: %w", path, err) + } + if len(b) == 0 { + return s, nil + } + if err := json.Unmarshal(b, &s.items); err != nil { + return nil, fmt.Errorf("parse inventory %s: %w", path, err) + } + if s.items == nil { + s.items = map[string]*Host{} + } + return s, nil +} + +// save persists the current map atomically. Caller must hold s.mu. +func (s *jsonStore) save() error { + if s.path == "" { + return nil // in-memory mode (tests / seed-only) + } + if dir := filepath.Dir(s.path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + } + b, err := json.MarshalIndent(s.items, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o640); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +func clone(h *Host) *Host { + if h == nil { + return nil + } + c := *h + return &c +} + +func (s *jsonStore) List() []*Host { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]*Host, 0, len(s.items)) + for _, h := range s.items { + out = append(out, clone(h)) + } + // Stable order: online first, then most-recently-seen, then serial. + sort.Slice(out, func(i, j int) bool { + if out[i].Online != out[j].Online { + return out[i].Online + } + if !out[i].LastSeen.Equal(out[j].LastSeen) { + return out[i].LastSeen.After(out[j].LastSeen) + } + return out[i].Identity.CertSerial < out[j].Identity.CertSerial + }) + return out +} + +func (s *jsonStore) Get(serial string) (*Host, bool) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.items[serial] + return clone(h), ok +} + +func (s *jsonStore) Upsert(h *Host) error { + if h == nil || h.Identity.CertSerial == "" { + return fmt.Errorf("upsert: host needs a cert serial") + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.items[h.Identity.CertSerial]; ok { + h.FirstSeen = existing.FirstSeen // preserve birth time on re-approval + } else if h.FirstSeen.IsZero() { + h.FirstSeen = time.Now().UTC() + } + s.items[h.Identity.CertSerial] = clone(h) + return s.save() +} + +func (s *jsonStore) SetDesired(serial string, d Desired) (*Host, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.items[serial] + if !ok { + return nil, false, nil + } + d.UpdatedAt = time.Now().UTC() + h.Desired = d + if err := s.save(); err != nil { + return nil, true, err + } + return clone(h), true, nil +} + +func (s *jsonStore) ReportState(serial string, v Version, h Health, u Usage, at time.Time) (*Host, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + host, ok := s.items[serial] + if !ok { + return nil, false, nil + } + host.Version = v + host.Health = h + host.Usage = u + host.LastSeen = at + if err := s.save(); err != nil { + return nil, true, err + } + return clone(host), true, nil +} + +func (s *jsonStore) SetOnline(serial string, online bool, at time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + h, ok := s.items[serial] + if !ok { + return + } + h.Online = online + if online { + h.LastSeen = at + } + _ = s.save() // presence is best-effort; a failed persist isn't fatal +} diff --git a/serving/fleet-manager/traefik/dynamic/fleet-manager.yml b/serving/fleet-manager/traefik/dynamic/fleet-manager.yml new file mode 100644 index 0000000..9b3d2f0 --- /dev/null +++ b/serving/fleet-manager/traefik/dynamic/fleet-manager.yml @@ -0,0 +1,26 @@ +# Traefik dynamic config (file provider) — route the admin UI hostname to the +# fleet-manager, which runs in behind-proxy mode (plaintext on loopback :9500, +# passkey auth ON; Traefik terminates the LE cert). +# +# ONLY the admin plane rides Traefik. The agent gRPC/mTLS plane (:9600) is NOT +# here — Traefik can't terminate the fleet-CA client-cert mTLS; hosts reach it +# directly (host VLAN -> OPNsense L3 -> manager:9600). See DEPLOY.md. +# +# Change the Host() rule to your admin-UI hostname (== FLEET_MGR_RP_ID). File +# config isn't env-interpolated, so this is set per-deployment. +http: + routers: + fleet-admin: + rule: "Host(`diskless-server.bitasylum.org`)" + entryPoints: + - websecure + service: fleet-admin + tls: + certResolver: le + + services: + fleet-admin: + loadBalancer: + servers: + - url: "http://127.0.0.1:9500" # manager, behind-proxy plaintext (loopback) + passHostHeader: true # preserve Host so WebAuthn RP origin matches diff --git a/serving/fleet-manager/ui/.gitignore b/serving/fleet-manager/ui/.gitignore new file mode 100644 index 0000000..e5537be --- /dev/null +++ b/serving/fleet-manager/ui/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.local diff --git a/serving/fleet-manager/ui/README.md b/serving/fleet-manager/ui/README.md new file mode 100644 index 0000000..5d43b1d --- /dev/null +++ b/serving/fleet-manager/ui/README.md @@ -0,0 +1,45 @@ +# fleet-manager UI — the fleet command deck + +The admin SPA for `fleet-manager`. **Svelte 5 + Tailwind 4 + Vite**, themed +**LYSANDRA / restricted-violet** (a high-security evolution of the kiosk-app's +LYSANDRA flight-deck: amber deck, **violet reserved for privileged/destructive + +auth surfaces**, teal telemetry, red alert, clearance chrome). A sibling of +`serving/kiosk-app`, kept in-repo for now (may graduate to its own submodule). + +Three pillars over the manager's JSON API, plus the passkey gate: + +- **Login / Enrol** — WebAuthn ceremonies (`navigator.credentials`). Enrol is + reached via the one-time link from `fleet-manager register-admin`. +- **Monitor** — fleet roster (online / version-drift / health) + per-host detail + with bespoke SVG telemetry charts (from `/api/hosts/{serial}/metrics`). +- **Authorise** — pending-enrolment queue: cross-check the fingerprint, approve + (violet) + bind, or deny. +- **Lifecycle** — per-host desired-state (version pin, kiosk URL, reboot, drain); + destructive changes are confirm-gated. + +## Build / embed + +The built SPA is embedded into the `fleet-manager` binary via `go:embed` and +served same-origin over the admin plane. + +```bash +npm ci +npm run build # -> dist/ (gitignored) +cd .. && go build -tags embedspa # embeds dist/ into the binary +``` + +The default `go build` (no tag) ships a stub instead, so Go builds/tests don't +require `dist/`. The Dockerfile does the two-stage build (npm → dist → go build +`-tags embedspa`). + +## Dev + +```bash +npm run dev # Vite on :5173, proxies /api + /auth to :9500 +``` + +Point it at a locally-running manager. Against an **insecure+seeded** manager +(`FLEET_MGR_ADMIN_INSECURE=true FLEET_MGR_SEED=true`), `/api/whoami` returns +`dev-insecure`, so the SPA renders the pillars **without a passkey** — handy for +theme/layout work. Passkey login/enrol need the manager in secure mode with a +valid RP origin + a WebAuthn authenticator. diff --git a/serving/fleet-manager/ui/index.html b/serving/fleet-manager/ui/index.html new file mode 100644 index 0000000..638700e --- /dev/null +++ b/serving/fleet-manager/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Fleet Command + + +
+ + + diff --git a/serving/fleet-manager/ui/package-lock.json b/serving/fleet-manager/ui/package-lock.json new file mode 100644 index 0000000..dbc003e --- /dev/null +++ b/serving/fleet-manager/ui/package-lock.json @@ -0,0 +1,2108 @@ +{ + "name": "fleet-manager-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fleet-manager-ui", + "version": "0.1.0", + "devDependencies": { + "@fontsource/chakra-petch": "^5.2.7", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.19.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fontsource/chakra-petch": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/chakra-petch/-/chakra-petch-5.2.7.tgz", + "integrity": "sha512-GLbun5WZJNAEc8FtWxwcgo+4Kurqdwejvf671l+YfIjyQgPvTjyxXYP7Ffg2HAY89NGnp3oOvKQjRzfm/fBECw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.2.7.tgz", + "integrity": "sha512-MKAb8qV+CaiMQn2B0dIi1OV3565NYzp3WN5b4oT6LTkk+F0jR6j0ZN+5BKJiIhffDC3rtBULsYZE65+0018z9w==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.2.8.tgz", + "integrity": "sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.13", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", + "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/svelte": { + "version": "5.56.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", + "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/serving/fleet-manager/ui/package.json b/serving/fleet-manager/ui/package.json new file mode 100644 index 0000000..2f78b9e --- /dev/null +++ b/serving/fleet-manager/ui/package.json @@ -0,0 +1,22 @@ +{ + "name": "fleet-manager-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "Fleet command deck — the fleet-manager admin SPA (LYSANDRA restricted-violet, high-security). Passkey login + monitor/authorise/lifecycle over the manager's JSON API.", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@fontsource/chakra-petch": "^5.2.7", + "@fontsource/ibm-plex-mono": "^5.2.7", + "@fontsource/ibm-plex-sans": "^5.2.8", + "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.19.0", + "tailwindcss": "^4.0.0", + "vite": "^6.0.7" + } +} diff --git a/serving/fleet-manager/ui/src/App.svelte b/serving/fleet-manager/ui/src/App.svelte new file mode 100644 index 0000000..125bab3 --- /dev/null +++ b/serving/fleet-manager/ui/src/App.svelte @@ -0,0 +1,33 @@ + + +{#if router.path === 'enrol'} + +{:else if !session.ready} +
+ initialising fleet command +
+{:else if !session.admin} + +{:else} +
+ + +
+
+{/if} diff --git a/serving/fleet-manager/ui/src/app.css b/serving/fleet-manager/ui/src/app.css new file mode 100644 index 0000000..7c6979d --- /dev/null +++ b/serving/fleet-manager/ui/src/app.css @@ -0,0 +1,139 @@ +@import "tailwindcss"; + +/* =========================================================================== + FLEET COMMAND — "LYSANDRA / Restricted Violet" high-security design system. + Continues the kiosk's LYSANDRA flight-deck DNA (warm amber deck on a cold + deep-blue void, Chakra Petch + IBM Plex) but ELEVATED for a privileged control + plane: deeper/colder void, higher contrast, clearance chrome, and a reserved + VIOLET clearance hue used ONLY on privileged/destructive/auth surfaces — so + "high-consequence" is legible at a glance. Amber = the default deck; teal = + telemetry; red = alert; violet = restricted. + =========================================================================== */ +@theme { + /* deep blue-black void — colder + darker than the kiosk (#070A12) */ + --color-void: #05070E; + --color-void2: #080D18; + /* raised panel surfaces */ + --color-hull: #0A1220; + --color-hull2: #0F1A2E; + + /* signal palette */ + --color-amber: #FFB23E; /* PRIMARY deck — data, nominal, navigation */ + --color-amber-soft: #FFCE86; + --color-teal: #43E6C9; /* SECONDARY — telemetry / nominal data */ + --color-violet: #8B7CFF; /* RESTRICTED — privileged + destructive + auth ONLY */ + --color-violet-soft:#B3A9FF; + --color-green: #3DE07A; /* secure / verified / positive */ + --color-red: #FF3B2F; /* alert / out-of-range */ + --color-amber-hot: #FF8A3D; /* caution band */ + + /* text — tuned for legibility on the dark hull panels (AA on hull2) */ + --color-ink: #EAF1F9; /* primary text (warm off-white) */ + --color-muted: #AEC0D6; /* secondary text / data values — clearly readable */ + --color-faint: #7688A2; /* tertiary labels, timestamps, meta */ + + /* hairlines */ + --color-line: #223146; /* structural */ + --color-line2: #35507A; /* interactive edges */ + --color-vline: #3B3170; /* violet-tinted edge for restricted panels */ + + /* type roles */ + --font-display: "Chakra Petch", ui-sans-serif, system-ui, sans-serif; + --font-sans: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif; + --font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", monospace; +} + +:root { color-scheme: dark; letter-spacing: 0.012em; } +html, body, #app { height: 100%; margin: 0; padding: 0; } + +body { + background-color: var(--color-void); + color: var(--color-ink); + font-family: var(--font-mono); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +/* Ambient: cold amber wash top-right, a faint violet clearance wash bottom-left, + a fine scanline grid + sparse stars — the "secure ops" texture. */ +#app::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + radial-gradient(55% 45% at 88% 8%, rgba(255, 178, 62, 0.06), transparent 70%), + radial-gradient(60% 55% at 8% 96%, rgba(139, 124, 255, 0.07), transparent 70%), + repeating-linear-gradient(0deg, rgba(120,160,205,0.024) 0 1px, transparent 1px 3px), + radial-gradient(1px 1px at 21% 22%, rgba(233,238,246,0.35), transparent), + radial-gradient(1px 1px at 68% 15%, rgba(233,238,246,0.28), transparent), + radial-gradient(1.5px 1.5px at 83% 66%, rgba(233,238,246,0.30), transparent), + radial-gradient(1px 1px at 12% 58%, rgba(233,238,246,0.24), transparent), + radial-gradient(1px 1px at 44% 78%, rgba(233,238,246,0.22), transparent); +} + +/* ---- clearance chrome utilities ---------------------------------------- */ + +/* Panel with corner reticles — the flight-deck instrument frame. */ +.panel { + position: relative; + background: linear-gradient(180deg, var(--color-hull2), var(--color-hull)); + border: 1px solid var(--color-line); + border-radius: 4px; +} +.panel::before, .panel::after { + content: ""; + position: absolute; + width: 9px; height: 9px; + border: 1px solid var(--color-line2); + pointer-events: none; +} +.panel::before { top: -1px; left: -1px; border-right: 0; border-bottom: 0; } +.panel::after { bottom: -1px; right: -1px; border-left: 0; border-top: 0; } + +/* Restricted panel — violet-tinted edge for privileged surfaces. */ +.panel-restricted { border-color: var(--color-vline); } +.panel-restricted::before, .panel-restricted::after { border-color: var(--color-violet); } + +/* clearance label — mono, spaced, faint. */ +.tag { + font-family: var(--font-mono); + font-size: 0.66rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-faint); +} + +/* status pills */ +.pill { + display: inline-flex; align-items: center; gap: 0.35rem; + font-size: 0.66rem; letter-spacing: 0.12em; text-transform: uppercase; + padding: 0.12rem 0.5rem; border-radius: 999px; border: 1px solid transparent; +} +.pill-ok { color: var(--color-green); border-color: color-mix(in oklab, var(--color-green) 40%, transparent); background: color-mix(in oklab, var(--color-green) 10%, transparent); } +.pill-warn { color: var(--color-amber-hot); border-color: color-mix(in oklab, var(--color-amber-hot) 40%, transparent); background: color-mix(in oklab, var(--color-amber-hot) 10%, transparent); } +.pill-alert { color: var(--color-red); border-color: color-mix(in oklab, var(--color-red) 45%, transparent); background: color-mix(in oklab, var(--color-red) 12%, transparent); } +.pill-off { color: var(--color-faint); border-color: var(--color-line); } + +/* buttons */ +.btn { + font-family: var(--font-display); font-size: 0.8rem; letter-spacing: 0.04em; + border: 1px solid var(--color-line2); color: var(--color-ink); + background: color-mix(in oklab, var(--color-hull2) 80%, transparent); + padding: 0.42rem 0.9rem; border-radius: 4px; cursor: pointer; + transition: border-color .12s, background .12s, color .12s; +} +.btn:hover { border-color: var(--color-amber); color: var(--color-amber); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } +/* privileged/destructive → violet clearance */ +.btn-restricted { border-color: var(--color-vline); color: var(--color-violet-soft); } +.btn-restricted:hover { border-color: var(--color-violet); color: var(--color-violet); background: color-mix(in oklab, var(--color-violet) 12%, transparent); } +.btn-alert:hover { border-color: var(--color-red); color: var(--color-red); } + +/* a subtle blinking cursor for the terminal feel */ +@keyframes blink { 50% { opacity: 0; } } +.cursor::after { content: "▊"; margin-left: 0.15em; animation: blink 1.1s step-end infinite; color: var(--color-amber); } + +/* thin scrollbars */ +* { scrollbar-width: thin; scrollbar-color: var(--color-line2) transparent; } diff --git a/serving/fleet-manager/ui/src/components/Chart.svelte b/serving/fleet-manager/ui/src/components/Chart.svelte new file mode 100644 index 0000000..4f4b026 --- /dev/null +++ b/serving/fleet-manager/ui/src/components/Chart.svelte @@ -0,0 +1,37 @@ + + +
+
+ {label} + {g.last ?? '—'} +
+ + {#if g.d} + + + {:else} + no data + {/if} + +
+ min {g.min?.toFixed?.(0) ?? '—'}max {g.max?.toFixed?.(0) ?? '—'} +
+
diff --git a/serving/fleet-manager/ui/src/components/Confirm.svelte b/serving/fleet-manager/ui/src/components/Confirm.svelte new file mode 100644 index 0000000..66476b6 --- /dev/null +++ b/serving/fleet-manager/ui/src/components/Confirm.svelte @@ -0,0 +1,18 @@ + + +{#if open} + +{/if} diff --git a/serving/fleet-manager/ui/src/components/Navbar.svelte b/serving/fleet-manager/ui/src/components/Navbar.svelte new file mode 100644 index 0000000..46e6f07 --- /dev/null +++ b/serving/fleet-manager/ui/src/components/Navbar.svelte @@ -0,0 +1,18 @@ + + + diff --git a/serving/fleet-manager/ui/src/components/Panel.svelte b/serving/fleet-manager/ui/src/components/Panel.svelte new file mode 100644 index 0000000..611bf3a --- /dev/null +++ b/serving/fleet-manager/ui/src/components/Panel.svelte @@ -0,0 +1,13 @@ + + +
+ {#if title || tag} +
+

{title}

+ {#if tag}{tag}{/if} +
+ {/if} + {@render children?.()} +
diff --git a/serving/fleet-manager/ui/src/components/StatePill.svelte b/serving/fleet-manager/ui/src/components/StatePill.svelte new file mode 100644 index 0000000..f0b776c --- /dev/null +++ b/serving/fleet-manager/ui/src/components/StatePill.svelte @@ -0,0 +1,9 @@ + + +{text} diff --git a/serving/fleet-manager/ui/src/components/StatusBar.svelte b/serving/fleet-manager/ui/src/components/StatusBar.svelte new file mode 100644 index 0000000..80155e5 --- /dev/null +++ b/serving/fleet-manager/ui/src/components/StatusBar.svelte @@ -0,0 +1,22 @@ + + +
+
+ ● secure link + passkey · {session.admin || '—'} +
+
+ {clock} + {#if session.admin && session.admin !== 'dev-insecure'} + + {/if} +
+
diff --git a/serving/fleet-manager/ui/src/lib/api.js b/serving/fleet-manager/ui/src/lib/api.js new file mode 100644 index 0000000..339d34b --- /dev/null +++ b/serving/fleet-manager/ui/src/lib/api.js @@ -0,0 +1,39 @@ +// api.js — thin JSON fetch wrapper for the manager's admin API. Sends the session +// cookie (same-origin) and the CSRF header on mutations; surfaces {error} bodies. +import { session } from './session.svelte.js' + +export class ApiError extends Error { + constructor(status, msg) { + super(msg) + this.status = status + } +} + +async function req(method, path, body) { + const headers = {} + if (body !== undefined) headers['Content-Type'] = 'application/json' + if (method !== 'GET' && session.csrf) headers['X-Fleet-CSRF'] = session.csrf + const res = await fetch(path, { + method, + headers, + credentials: 'same-origin', + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + if (res.status === 401) { + session.admin = null + session.csrf = '' + throw new ApiError(401, 'not signed in') + } + const text = await res.text() + const data = text ? JSON.parse(text) : null + if (!res.ok) { + throw new ApiError(res.status, (data && data.error) || res.statusText) + } + return data +} + +export const api = { + get: (p) => req('GET', p), + post: (p, b) => req('POST', p, b), + put: (p, b) => req('PUT', p, b), +} diff --git a/serving/fleet-manager/ui/src/lib/fmt.js b/serving/fleet-manager/ui/src/lib/fmt.js new file mode 100644 index 0000000..0648262 --- /dev/null +++ b/serving/fleet-manager/ui/src/lib/fmt.js @@ -0,0 +1,90 @@ +// Small formatting helpers shared across the deck. + +export function relTime(iso) { + if (!iso) return '—' + const t = new Date(iso).getTime() + if (Number.isNaN(t)) return '—' + // Go's zero time (0001-01-01) serialises as a real string but parses to a + // pre-epoch instant — a born-but-never-reported host. Render it honestly. + if (t <= 0) return 'never' + const s = Math.max(0, Math.round((Date.now() - t) / 1000)) + if (s < 60) return s + 's ago' + if (s < 3600) return Math.floor(s / 60) + 'm ago' + if (s < 86400) return Math.floor(s / 3600) + 'h ago' + return Math.floor(s / 86400) + 'd ago' +} + +export function num(v, digits = 0) { + if (v === undefined || v === null || Number.isNaN(v)) return '—' + return Number(v).toFixed(digits) +} + +export function pct(v) { + if (v === undefined || v === null) return '—' + return num(v, 0) + '%' +} + +// health → one of ok | warn | alert (drives the state pills / colours). +export function healthState(h) { + if (!h) return 'off' + if (h.nvme_root === 'down' || h.auth === 'down') return 'alert' + if ((h.tmpfs_used_pct || 0) >= 90 || (h.temp_c || 0) >= 85 || h.selinux === 'permissive' || h.nvme_root === 'degraded') return 'warn' + return 'ok' +} + +export function shortSerial(s) { + return s ? s.slice(0, 8) : '—' +} + +// offsetMs formats a signed seconds offset as milliseconds ("+0.02ms" / "-1.3ms"). +export function offsetMs(sec) { + if (sec === undefined || sec === null || Number.isNaN(sec)) return null + const ms = sec * 1000 + const digits = Math.abs(ms) < 1 ? 3 : 2 + return (ms >= 0 ? '+' : '') + ms.toFixed(digits) + 'ms' +} + +// chronySummary renders the chrony health as "synced · · s · +// " — the sync state plus how it's tracking relative to the NTP source. +export function chronySummary(h) { + if (!h) return '—' + const extra = [] + if (h.chrony_source) extra.push(h.chrony_source) + if (h.chrony_stratum) extra.push('s' + h.chrony_stratum) + const off = offsetMs(h.chrony_offset_seconds) + if (off && h.chrony) extra.push(off) + const state = h.chrony || '—' + return extra.length ? `${state} · ${extra.join(' · ')}` : state +} + +// dur formats a span of SECONDS as a compact "3d 4h" / "5h 12m" / "45s". +export function dur(s) { + s = Math.max(0, Math.floor(s || 0)) + const d = Math.floor(s / 86400) + const h = Math.floor((s % 86400) / 3600) + const m = Math.floor((s % 3600) / 60) + const sec = s % 60 + if (d) return `${d}d ${h}h` + if (h) return `${h}h ${m}m` + if (m) return `${m}m ${sec}s` + return `${sec}s` +} + +// rebootStatus reports whether a scheduled reboot (desired.reboot_at) has been +// actioned, by comparing the host's boot time to the requested time. Boot time is +// derived in the manager's clock frame: last_seen (a manager-stamped snapshot +// time) minus the reported host uptime — so it needs no host/manager clock sync. +// Returns null when no reboot is scheduled. state ∈ pending | overdue | done. +export function rebootStatus(host) { + const raIso = host?.desired?.reboot_at + if (!raIso) return null + const ra = new Date(raIso).getTime() + if (Number.isNaN(ra) || ra <= 0) return null // unset / Go zero time + const now = Date.now() + const up = host?.health?.uptime_seconds || 0 + const ls = host?.last_seen ? new Date(host.last_seen).getTime() : NaN + const boot = up > 0 && ls > 0 ? ls - up * 1000 : NaN // manager-frame boot time + if (!Number.isNaN(boot) && boot >= ra) return { state: 'done', label: 'rebooted' } + if (ra > now) return { state: 'pending', label: 'reboot pending in ' + dur((ra - now) / 1000) } + return { state: 'overdue', label: 'reboot overdue by ' + dur((now - ra) / 1000) } +} diff --git a/serving/fleet-manager/ui/src/lib/router.svelte.js b/serving/fleet-manager/ui/src/lib/router.svelte.js new file mode 100644 index 0000000..bfa328e --- /dev/null +++ b/serving/fleet-manager/ui/src/lib/router.svelte.js @@ -0,0 +1,24 @@ +// Minimal hash router (Svelte 5 rune store). Routes: monitor | authorise | +// lifecycle | enrol. The enrol route carries the one-time ?token= from the CLI +// link. Hash routing keeps the SPA a single embedded index.html (no server-side +// route table needed). +function parse() { + const raw = location.hash.replace(/^#\/?/, '') + const [path, query] = raw.split('?') + return { path: path || 'monitor', query: new URLSearchParams(query || '') } +} + +const initial = parse() +export const router = $state({ path: initial.path, token: initial.query.get('token') || '' }) + +if (typeof window !== 'undefined') { + window.addEventListener('hashchange', () => { + const r = parse() + router.path = r.path + router.token = r.query.get('token') || '' + }) +} + +export function go(path) { + location.hash = '#/' + path +} diff --git a/serving/fleet-manager/ui/src/lib/session.svelte.js b/serving/fleet-manager/ui/src/lib/session.svelte.js new file mode 100644 index 0000000..22e371b --- /dev/null +++ b/serving/fleet-manager/ui/src/lib/session.svelte.js @@ -0,0 +1,42 @@ +// Auth/session state (Svelte 5 rune store). `admin` is the signed-in passkey +// identity; `csrf` is echoed on mutations. In the manager's INSECURE dev mode +// /api/whoami returns admin="dev-insecure" with no auth, so the SPA renders +// without a passkey — handy for testing against a seeded insecure backend. +export const session = $state({ admin: null, csrf: '', ready: false }) + +export async function refreshSession() { + try { + const res = await fetch('/api/whoami', { credentials: 'same-origin' }) + if (res.ok) { + const d = await res.json() + session.admin = d.admin + session.csrf = d.csrf || '' + } else { + session.admin = null + session.csrf = '' + } + } catch { + session.admin = null + session.csrf = '' + } + session.ready = true +} + +export function setSession(d) { + session.admin = d.admin + session.csrf = d.csrf || '' +} + +export async function logout() { + try { + await fetch('/auth/logout', { + method: 'POST', + credentials: 'same-origin', + headers: session.csrf ? { 'X-Fleet-CSRF': session.csrf } : {}, + }) + } catch { + /* ignore */ + } + session.admin = null + session.csrf = '' +} diff --git a/serving/fleet-manager/ui/src/lib/webauthn.js b/serving/fleet-manager/ui/src/lib/webauthn.js new file mode 100644 index 0000000..d85c074 --- /dev/null +++ b/serving/fleet-manager/ui/src/lib/webauthn.js @@ -0,0 +1,92 @@ +// webauthn.js — the browser side of the passkey ceremonies. Converts the +// manager's base64url-encoded options into the BufferSources navigator.credentials +// needs, runs the ceremony, and serialises the result back into the JSON shape +// go-webauthn parses. Login is usernameless (discoverable); registration is +// authorised by the one-time enrol token from `register-admin`. +import { api } from './api.js' +import { setSession } from './session.svelte.js' + +function b64uToBuf(s) { + s = s.replace(/-/g, '+').replace(/_/g, '/') + while (s.length % 4) s += '=' + const bin = atob(s) + const buf = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i) + return buf.buffer +} + +function bufToB64u(buf) { + const bytes = new Uint8Array(buf) + let s = '' + for (const b of bytes) s += String.fromCharCode(b) + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function prepCreation(pk) { + pk.challenge = b64uToBuf(pk.challenge) + pk.user.id = b64uToBuf(pk.user.id) + if (Array.isArray(pk.excludeCredentials)) { + for (const c of pk.excludeCredentials) c.id = b64uToBuf(c.id) + } + return pk +} + +function prepRequest(pk) { + pk.challenge = b64uToBuf(pk.challenge) + if (Array.isArray(pk.allowCredentials)) { + for (const c of pk.allowCredentials) c.id = b64uToBuf(c.id) + } + return pk +} + +function serializeAttestation(cred) { + return { + id: cred.id, + rawId: bufToB64u(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64u(cred.response.clientDataJSON), + attestationObject: bufToB64u(cred.response.attestationObject), + transports: cred.response.getTransports ? cred.response.getTransports() : undefined, + }, + } +} + +function serializeAssertion(cred) { + return { + id: cred.id, + rawId: bufToB64u(cred.rawId), + type: cred.type, + authenticatorAttachment: cred.authenticatorAttachment || undefined, + clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {}, + response: { + clientDataJSON: bufToB64u(cred.response.clientDataJSON), + authenticatorData: bufToB64u(cred.response.authenticatorData), + signature: bufToB64u(cred.response.signature), + userHandle: cred.response.userHandle ? bufToB64u(cred.response.userHandle) : undefined, + }, + } +} + +export function passkeySupported() { + return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials +} + +// passkeyLogin runs the discoverable (usernameless) login ceremony and, on +// success, sets the session. Throws on failure/cancellation. +export async function passkeyLogin() { + const { cid, options } = await api.post('/auth/login/begin') + const cred = await navigator.credentials.get({ publicKey: prepRequest(options.publicKey) }) + const res = await api.post(`/auth/login/finish?cid=${encodeURIComponent(cid)}`, serializeAssertion(cred)) + setSession(res) + return res +} + +// passkeyRegister runs the registration ceremony authorised by the enrol token. +export async function passkeyRegister(token) { + const { cid, options } = await api.post(`/auth/register/begin?token=${encodeURIComponent(token)}`) + const cred = await navigator.credentials.create({ publicKey: prepCreation(options.publicKey) }) + return api.post(`/auth/register/finish?cid=${encodeURIComponent(cid)}`, serializeAttestation(cred)) +} diff --git a/serving/fleet-manager/ui/src/main.js b/serving/fleet-manager/ui/src/main.js new file mode 100644 index 0000000..4f1683c --- /dev/null +++ b/serving/fleet-manager/ui/src/main.js @@ -0,0 +1,12 @@ +import './app.css' +// Self-hosted fonts (no CDN) — matches the kiosk-app posture. +import '@fontsource/chakra-petch/400.css' +import '@fontsource/chakra-petch/600.css' +import '@fontsource/ibm-plex-mono/400.css' +import '@fontsource/ibm-plex-mono/500.css' +import '@fontsource/ibm-plex-sans/400.css' + +import { mount } from 'svelte' +import App from './App.svelte' + +export default mount(App, { target: document.getElementById('app') }) diff --git a/serving/fleet-manager/ui/src/pages/Authorise.svelte b/serving/fleet-manager/ui/src/pages/Authorise.svelte new file mode 100644 index 0000000..a2e89fd --- /dev/null +++ b/serving/fleet-manager/ui/src/pages/Authorise.svelte @@ -0,0 +1,87 @@ + + +
+

ENROLMENT — PENDING AUTHORISATION

+ {pending.length} awaiting · cross-check each fingerprint against the device splash +
+{#if error}
{error}
{/if} + +
+ {#each pending as e (e.id)} +
+
verify fingerprint
+
{e.fingerprint}
+
+
enrol-id
{e.id}
+
CN
{e.cn || '—'}
+ {#if e.context?.mac}
MAC
{e.context.mac}
{/if} + {#if e.context?.ek_hash}
TPM EK
{e.context.ek_hash}
{/if} +
age
{relTime(e.created_at)}
+
+ +
+ + + +
+ +
+ + +
+
+ {/each} + {#if !pending.length && !error} +

No devices are awaiting authorisation.

+ {/if} +
diff --git a/serving/fleet-manager/ui/src/pages/Enrol.svelte b/serving/fleet-manager/ui/src/pages/Enrol.svelte new file mode 100644 index 0000000..0e17249 --- /dev/null +++ b/serving/fleet-manager/ui/src/pages/Enrol.svelte @@ -0,0 +1,42 @@ + + +
+
+
passkey enrolment
+

Register a hardware passkey

+ {#if done} +

enrolled · {who}

+ continue → sign in + {:else if !router.token} +

missing enrolment token — use the link from register-admin

+ {:else if !passkeySupported()} +

this browser has no WebAuthn support

+ {:else} +

Insert your YubiKey and tap to bind it to this fleet console.

+ + {/if} + {#if error}

{error}

{/if} +
+
diff --git a/serving/fleet-manager/ui/src/pages/Lifecycle.svelte b/serving/fleet-manager/ui/src/pages/Lifecycle.svelte new file mode 100644 index 0000000..12af828 --- /dev/null +++ b/serving/fleet-manager/ui/src/pages/Lifecycle.svelte @@ -0,0 +1,175 @@ + + +
+
+

LIFECYCLE

+ {#if error}
{error}
{/if} +
+ {#each hosts as h (h.identity.cert_serial)} + + {/each} +
+
+ +
+ {#if selected} + +
+ + +
+ reboot +
+ + {#if rebootMode === 'at'} + + {/if} +
+
+ +
+ +
+ {#if okMsg}{okMsg}{:else}{/if} + +
+

+ Recorded intent — kiosk URL / reboot / drain reconcile via the host agent, but per-host + boot-version assignment (dnsmasq cohort steering) is not yet wired, + so a version pin is stored and pushed but does not change what the host boots at next reboot. +

+
+ {:else} +

Select a host to set its server-authoritative desired state (version pin, kiosk URL, reboot, drain).

+ {/if} +
+
+ + (confirmOpen = false)} +/> diff --git a/serving/fleet-manager/ui/src/pages/Login.svelte b/serving/fleet-manager/ui/src/pages/Login.svelte new file mode 100644 index 0000000..fe07a2d --- /dev/null +++ b/serving/fleet-manager/ui/src/pages/Login.svelte @@ -0,0 +1,32 @@ + + +
+
+
clearance required
+

FLEET·COMMAND

+

Authenticate with your hardware passkey to access the fleet control plane.

+ {#if !passkeySupported()} +

this browser has no WebAuthn support

+ {:else} + + {/if} + {#if error}

{error}

{/if} +
+
diff --git a/serving/fleet-manager/ui/src/pages/Monitor.svelte b/serving/fleet-manager/ui/src/pages/Monitor.svelte new file mode 100644 index 0000000..793d1c3 --- /dev/null +++ b/serving/fleet-manager/ui/src/pages/Monitor.svelte @@ -0,0 +1,128 @@ + + +
+
+
+

FLEET ROSTER

+ {hosts.length} hosts · {hosts.filter((h) => h.online).length} online +
+ {#if error}
{error}
{/if} +
+ {#each hosts as h (h.identity.cert_serial)} + + {/each} + {#if !hosts.length && !error}

no hosts enrolled yet — approve one in AUTHORISE.

{/if} +
+
+ +
+ {#if selected} + +
+
hostnqn
{selected.identity.hostnqn || '—'}
+
running
{selected.version?.image_version || '—'}
+
desired
{selected.desired?.target_version || '—'}
+
kernel
{selected.version?.kernel || '—'}
+
auth
{selected.health?.auth || '—'}
+
nvme root
{selected.health?.nvme_root || '—'}
+
selinux
{selected.health?.selinux || '—'}
+
chrony
{chronySummary(selected.health)}
+
kiosk
{selected.health?.kiosk_active ? 'active' : 'idle'} · {selected.usage?.session_uptime || '—'}
+
uptime
{selected.health?.uptime_seconds ? dur(selected.health.uptime_seconds) : '—'}
+ {#if rebootStatus(selected)} + {@const rb = rebootStatus(selected)} +
reboot
{rb.label}
+ {/if} +
last seen
{selected.online ? 'live' : relTime(selected.last_seen)}
+
+
+ + + + {#if metricsErr}

metrics: {metricsErr}

{/if} +
+
+ {:else} + +

Select a host from the roster to see live health + telemetry charts.

+
+ {/if} +
+
diff --git a/serving/fleet-manager/ui/svelte.config.js b/serving/fleet-manager/ui/svelte.config.js new file mode 100644 index 0000000..6d0e14f --- /dev/null +++ b/serving/fleet-manager/ui/svelte.config.js @@ -0,0 +1,3 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' + +export default { preprocess: vitePreprocess() } diff --git a/serving/fleet-manager/ui/vite.config.js b/serving/fleet-manager/ui/vite.config.js new file mode 100644 index 0000000..26ce76d --- /dev/null +++ b/serving/fleet-manager/ui/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' +import tailwindcss from '@tailwindcss/vite' + +// The SPA is embedded into the fleet-manager binary (go:embed ui/dist) and served +// same-origin over the admin plane in production. In dev, proxy the API + auth +// ceremonies to a locally-running manager. +export default defineConfig({ + plugins: [svelte(), tailwindcss()], + build: { outDir: 'dist', emptyOutDir: true }, + server: { + proxy: { + '/api': { target: 'http://127.0.0.1:9500', changeOrigin: false }, + '/auth': { target: 'http://127.0.0.1:9500', changeOrigin: false }, + }, + }, +}) diff --git a/serving/fleet-manager/webauthn.go b/serving/fleet-manager/webauthn.go new file mode 100644 index 0000000..1c927f0 --- /dev/null +++ b/serving/fleet-manager/webauthn.go @@ -0,0 +1,209 @@ +// webauthn.go — passkey/WebAuthn admin identity: the credential store (which +// YubiKeys/passkeys are registered admins) and the webauthn.User implementation. +// +// The human→UI hop authenticates with a hardware passkey (YubiKey), NOT an mTLS +// client cert — phishing-resistant, origin-bound, better UX. (The manager→broker +// hop stays mTLS; that's service-to-service.) Registration is bootstrapped by the +// `register-admin` CLI, which mints a one-time signed enrolment token; the browser +// completes the WebAuthn create ceremony (the credential is born on the YubiKey). +package main + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/go-webauthn/webauthn/webauthn" +) + +// adminUser is a registered admin + their passkey credential(s). Implements +// webauthn.User. The ID (user handle) is a random opaque blob, NOT the name, so +// the name can change without breaking discoverable-login lookups. +type adminUser struct { + IDb []byte `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Creds []webauthn.Credential `json:"credentials"` +} + +func (u *adminUser) WebAuthnID() []byte { return u.IDb } +func (u *adminUser) WebAuthnName() string { return u.Name } +func (u *adminUser) WebAuthnDisplayName() string { return u.Name } +func (u *adminUser) WebAuthnCredentials() []webauthn.Credential { return u.Creds } + +// credStore persists registered admins + a used-enrolment-token ledger (so a +// one-time enrolment token can't be replayed). JSON file, atomic writes, mutex — +// same shape as the inventory store. The SERVICE is the only writer; the CLI only +// reads the shared enrol key (see cli.go), never this file. +type credStore struct { + mu sync.Mutex + path string + data credData + byHex map[string]*adminUser // hex(user handle) -> admin (discoverable-login lookup) +} + +type credData struct { + Admins map[string]*adminUser `json:"admins"` // by name + Used map[string]int64 `json:"used_tokens"` // jti -> expiry unix (replay ledger) +} + +func newCredStore(path string) (*credStore, error) { + s := &credStore{path: path, data: credData{Admins: map[string]*adminUser{}, Used: map[string]int64{}}, byHex: map[string]*adminUser{}} + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return s, nil + } + return nil, fmt.Errorf("read admin store %s: %w", path, err) + } + if len(b) > 0 { + if err := json.Unmarshal(b, &s.data); err != nil { + return nil, fmt.Errorf("parse admin store %s: %w", path, err) + } + } + if s.data.Admins == nil { + s.data.Admins = map[string]*adminUser{} + } + if s.data.Used == nil { + s.data.Used = map[string]int64{} + } + s.reindex() + return s, nil +} + +func (s *credStore) reindex() { + s.byHex = map[string]*adminUser{} + for _, u := range s.data.Admins { + s.byHex[hex.EncodeToString(u.IDb)] = u + } +} + +func (s *credStore) save() error { + if s.path == "" { + return nil + } + if dir := filepath.Dir(s.path); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + } + b, err := json.MarshalIndent(s.data, "", " ") + if err != nil { + return err + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, s.path) +} + +// count returns the number of registered admins (for the "no admins yet" gate). +func (s *credStore) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.data.Admins) +} + +// getOrCreate returns the admin by name, creating an unregistered shell (random +// handle, no creds yet) on first enrolment. +func (s *credStore) getOrCreate(name string) *adminUser { + s.mu.Lock() + defer s.mu.Unlock() + if u, ok := s.data.Admins[name]; ok { + return u + } + id := make([]byte, 32) + _, _ = rand.Read(id) + u := &adminUser{IDb: id, Name: name, CreatedAt: time.Now().UTC()} + s.data.Admins[name] = u + s.byHex[hex.EncodeToString(id)] = u + return u +} + +func (s *credStore) byHandle(handle []byte) (*adminUser, bool) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byHex[hex.EncodeToString(handle)] + return u, ok +} + +// addCredential appends a freshly-registered passkey and persists. +func (s *credStore) addCredential(name string, c webauthn.Credential) error { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.data.Admins[name] + if !ok { + return fmt.Errorf("no such admin %q", name) + } + u.Creds = append(u.Creds, c) + return s.save() +} + +// updateCredential persists an updated credential (e.g. the signature counter) +// after a successful login — the anti-clone protection. +func (s *credStore) updateCredential(handle []byte, c webauthn.Credential) { + s.mu.Lock() + defer s.mu.Unlock() + u, ok := s.byHex[hex.EncodeToString(handle)] + if !ok { + return + } + for i := range u.Creds { + if string(u.Creds[i].ID) == string(c.ID) { + u.Creds[i] = c + _ = s.save() + return + } + } +} + +// tokenUsed reports whether a one-time enrolment token id was already redeemed, +// and reaps expired ledger entries. +func (s *credStore) tokenUsed(jti string) bool { + s.mu.Lock() + defer s.mu.Unlock() + now := time.Now().Unix() + for j, exp := range s.data.Used { + if exp < now { + delete(s.data.Used, j) + } + } + _, used := s.data.Used[jti] + return used +} + +func (s *credStore) markTokenUsed(jti string, exp int64) { + s.mu.Lock() + defer s.mu.Unlock() + s.data.Used[jti] = exp + _ = s.save() +} + +// newWebAuthn builds the relying-party instance. WebAuthn is ORIGIN-BOUND, so the +// RP ID + origin must match the admin UI's hostname (per-environment config). +func newWebAuthn(cfg config) (*webauthn.WebAuthn, error) { + if cfg.rpID == "" || cfg.rpOrigin == "" { + return nil, fmt.Errorf("WebAuthn needs FLEET_MGR_RP_ID and FLEET_MGR_RP_ORIGIN (the admin UI hostname/origin)") + } + // FLEET_MGR_RP_ORIGIN may list several exact origins (comma-separated) — e.g. + // the direct :9500 form and a 443-proxy form — since passkeys bind to the + // exact scheme://host:port the browser used. Each must match a real origin. + var origins []string + for o := range strings.SplitSeq(cfg.rpOrigin, ",") { + if o = strings.TrimSpace(o); o != "" { + origins = append(origins, o) + } + } + return webauthn.New(&webauthn.Config{ + RPID: cfg.rpID, + RPDisplayName: cfg.rpDisplayName, + RPOrigins: origins, + }) +}