Merge pull request 'fleet-manager' (#18) from fleet-manager into main

Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/18
This commit is contained in:
audiobuzz
2026-07-10 23:16:44 +00:00
99 changed files with 12278 additions and 98 deletions
+111
View File
@@ -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-<sha7>" (+ :latest)
# - branch push (refs/heads/<x>) -> "<x>-<sha7>" (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
# "<branch>-<sha7>" 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
+33 -8
View File
@@ -58,6 +58,7 @@ on:
- ".gitea/workflows/ipxe-image.yaml" - ".gitea/workflows/ipxe-image.yaml"
- ".gitea/workflows/dnsmasq-image.yaml" - ".gitea/workflows/dnsmasq-image.yaml"
- ".gitea/workflows/spdk-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 # 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). # 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' 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 # 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 # image never carries the Go toolchain or the go-tpm dep tree. Packer
# file-provisions the resulting static binaries into the rootfs. Runs # file-provisions the resulting static binaries into the rootfs. Runs
# BEFORE packer validate/build so the file provisioners' sources exist. # BEFORE packer validate/build so the file provisioners' sources exist.
# Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid. # 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 # fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by
# stdlib-only (GOPROXY=off). # go.sum); hostapi is stdlib-only (GOPROXY=off).
# shell: bash is explicit — the Gitea/act runner defaults run steps to sh # 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 # (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 # load-bearing: without it a failed go build/test in the first ( … ) group
@@ -218,13 +219,20 @@ jobs:
( cd files/hostapi \ ( cd files/hostapi \
&& CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \ && CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . ) 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) name: TPM ceremony + agent mTLS tests (go-tpm software simulator)
# Exercises the real key-gen/sign/NV path against a software TPM (CGO in a # Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS
# separate module, so the simulator never enters the shipped enrol build). # client-auth handshake (agent) against a software TPM (CGO in separate
run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... ) # 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 name: packer init
@@ -272,6 +280,22 @@ jobs:
echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV" echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV"
echo "Secure Boot signing material staged (signed UKI build)." 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 name: Materialize Secure Boot enrollment certs
# PUBLIC PK/KEK/db .auth files baked into the image for fleet-sb-enroll. # 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 ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \
-var sb_key="${SB_KEY_FILE:-}" \ -var sb_key="${SB_KEY_FILE:-}" \
-var sb_cert="${SB_CERT_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 kiosk_url="${{ env.KIOSK_URL }}" \
-var boot_splash=${{ env.BOOT_SPLASH }} \ -var boot_splash=${{ env.BOOT_SPLASH }} \
-var enable_stig=${{ env.ENABLE_STIG }} \ -var enable_stig=${{ env.ENABLE_STIG }} \
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
DISPATCH_DRY: ${{ inputs.dry_run }} DISPATCH_DRY: ${{ inputs.dry_run }}
GC_ENABLE: ${{ vars.REGISTRY_GC_ENABLE }} GC_ENABLE: ${{ vars.REGISTRY_GC_ENABLE }}
# Only images built by THIS repo's workflows. # 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: steps:
- name: Reconcile registry against live branches - name: Reconcile registry against live branches
# The runner defaults `run:` to sh (dash); this script uses bashisms # The runner defaults `run:` to sh (dash); this script uses bashisms
+1
View File
@@ -19,3 +19,4 @@ keys
# provisioner; built by the "Build host Go binaries" CI step). # provisioner; built by the "Build host Go binaries" CI step).
files/enrol/bin/ files/enrol/bin/
files/hostapi/bin/ files/hostapi/bin/
files/agent/bin/
+92 -3
View File
@@ -130,6 +130,12 @@ variable "kiosk_url" {
default = "http://boot.fleet.lab/kiosk/" 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" { variable "boot_splash" {
type = bool 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.)" 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 = "" 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
source "qemu" "diskless" { source "qemu" "diskless" {
@@ -231,6 +243,15 @@ build {
destination = "/tmp/etc-stage" 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). # Kiosk unit + session wrapper (installed by the kiosk provisioner below).
# Dir-without-trailing-slash upload -> /tmp/kiosk/ (mirrors the dracut module # Dir-without-trailing-slash upload -> /tmp/kiosk/ (mirrors the dracut module
# upload form, which stages reliably). # upload form, which stages reliably).
@@ -259,6 +280,20 @@ build {
destination = "/tmp/fleet-hostapi" 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 # Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
# /tmp/plymouth, installed + set default in the package step before dracut runs. # /tmp/plymouth, installed + set default in the package step before dracut runs.
provisioner "file" { provisioner "file" {
@@ -348,7 +383,10 @@ build {
"rm -rf /tmp/plymouth", "rm -rf /tmp/plymouth",
# Stage etc files into place # 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/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", "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 # 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", "restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp",
"sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf", "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", "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 # Inject the fleet NIC drivers into the dracut config
"sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", "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 -------------- # --- 2c. Admin access: sudo-enabled key user + locked root --------------
# Fleet-generic (same user + key on every host => NOT per-host state). The # 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 # 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", "semodule -i /tmp/fapolicyd_tmpfs.pp",
"rm -f /tmp/fapolicyd_tmpfs.mod /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; }", "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", "rm -rf /tmp/stig",
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'", "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 # provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on
# the bench; see docs/TODO.md. # 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", " 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", " 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 # watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the
# shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and # shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and
# therefore intercepts NO exec on / — the exec guard for the writable overlay # therefore intercepts NO exec on / — the exec guard for the writable overlay
+143 -2
View File
@@ -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** **TEMPORARY (2026-06-28): broker also added to the MAIN serving stack**
(`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in (`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 `/enrol/` endpoint up while the host side is built. Defaults to the
zero-dependency lab path — **`mock` signer** (ephemeral CA; certs NOT trusted by zero-dependency lab path — **`mock` signer** (ephemeral CA; certs NOT trusted by
RADIUS/NVMe) + **plaintext loopback admin** (`ENROL_ADMIN_INSECURE=true`). The 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; **BENCH-VERIFY:** `chrome://policy` shows `ExtensionInstallBlocklist=["*"]` active;
extensions can't install. 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=<ip> + FLEET_AGENT_SERVER_NAME=<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 <serial> -name <starship>` (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.
<details><summary>Original design capture (2026-06-28) — retained for reference</summary>
**DESIGN CAPTURED 2026-06-28 — [docs/fleet-management-design.md](fleet-management-design.md).** **DESIGN CAPTURED 2026-06-28 — [docs/fleet-management-design.md](fleet-management-design.md).**
Server-authoritative, pull-based, TPM-identified management plane reusing existing 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. 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. Depends on the step-ca fleet CA (#8) for the mTLS identity.
</details>
## [ ] 15. Scope the root overlay → per-subtree tmpfs/overlay (un-shadowable `/usr` + STIG mount options) ## [ ] 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` **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 - **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. "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 **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 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), candidates from the manifest (measure, don't guess): `iso-codes` (19 MB),
+64 -6
View File
@@ -1,7 +1,63 @@
# Fleet management plane — design note # Fleet management plane — design note
**Status:** DESIGN (not built). Captured 2026-06-28. Build phased — see "Phasing" **Status:** PARTLY BUILT. Captured 2026-06-28; **server-side control plane built
and `docs/TODO.md` #14. 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, Centralised monitoring + maintenance of the diskless kiosk fleet: **health,
version, usage, and maintenance actions**. This note is the authoritative design; 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 - **Health:** CPU/load, GPU act-freq, temps, **tmpfs overlay usage** (the
RAM-exhaustion → NVMe-keepalive risk — the single most important kiosk health RAM-exhaustion → NVMe-keepalive risk — the single most important kiosk health
signal), NVMe-root link state, 802.1X/MACsec `Authorized`, SELinux enforcing, signal), host **uptime** (server derives reboot-pending vs `reboot_at`),
chrony sync, `kiosk.service` active. 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. - **Version:** running image version (`/etc/fleet-image`), rootfs digest, kernel.
- **Usage:** kiosk URL, session uptime, **Chromium reload/crash count** (from the - **Usage:** kiosk URL, session uptime, **Chromium reload/crash count** (from the
`kiosk-session` restart loop — the exact signal behind the GPU-bug diagnosis), `kiosk-session` restart loop — the exact signal behind the GPU-bug diagnosis),
@@ -130,8 +187,9 @@ UKI).
Host (by cert-serial / hostnqn) Host (by cert-serial / hostnqn)
├─ identity: cert serial, hostnqn, MAC, starship-name (dnsmasq) ├─ identity: cert serial, hostnqn, MAC, starship-name (dnsmasq)
├─ version: image_version, rootfs digest, kernel, desired_version ├─ version: image_version, rootfs digest, kernel, desired_version
├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, nvme_root, auth/macsec, ├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, uptime, nvme_root,
│ selinux, chrony, kiosk_active, last_seen │ auth/macsec, selinux, chrony (sync + source/stratum/offset),
│ kiosk_active, last_seen
├─ usage: kiosk_url, session_uptime, chromium_crashes, input_active, display ├─ usage: kiosk_url, session_uptime, chromium_crashes, input_active, display
└─ desired: target_version, kiosk_url, reboot_at, drain (server-authoritative) └─ desired: target_version, kiosk_url, reboot_at, drain (server-authoritative)
``` ```
+59 -29
View File
@@ -9,15 +9,23 @@
> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target > **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
> **May 2026 release**. > **May 2026 release**.
> >
> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening > **Annex version:** 0.3 (updated 2026-07-10). v0.3 adds the **fleet-management
> applied + bench-verified, USBGuard device control enabled, admin `staff_u` > plane**: the in-image **`fleet-agent`** (health telemetry over TPM-mTLS —
> confinement, audit offload verified, PXE/iPXE boot path added, and **UEFI Secure > bench-verified on hardware 2026-07-10; new host interface (g)), the server-side
> Boot signing verified enforcing on real hardware** (VM HTTP + 820 PXE) — R-13 > manager with **WebAuthn-passkey admin MFA** (first human-admin MFA — updates the
> reduced from Medium to Low (residual is making signed + SB-on the standing > §2.4 MFA row and R-05), the **TPM enrolment broker** + **step-ca fleet CA**, and
> default); and (2026-06-27) a **Wazuh SIEM** (dedicated multi-node, agentless) > InfluxDB health telemetry. It also records the **three local SELinux policy
> now decodes + alerts on the fleet auditd stream — "event log analysis/alerting" > modules** (§2.3) and the **fapolicyd overlay exec-guard fix (2026-07-04)** that
> moves Planned → Implemented, R-07 reduced toward Low (residual = TLS transport + > made application control actually effective on the root. New residual risks
> retention). > **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 | | 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) | | 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 | — | | 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 | — | | 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 | — | | 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 | — | | 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 | — | | 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) | — | | 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) | — | | 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 | | 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 | | 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 | — | | 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 | — | | 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) | | 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 ### 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 | — | | 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) | | 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)** | | 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) ### 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** | | 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 | | 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 | | 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 ### 2.7 Guidelines for Networking
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | | 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 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** | | 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 | — | | 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 | | 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 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 | | 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 private key **non-exportable in TPM**; UKI signing keys in HSM/CI. | Implemented | — | | 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 | | 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 ### 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-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-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-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-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-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* | | **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-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-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-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) ### 3.1 Risk heatmap (post-treatment targets)
``` ```
Consequence → Minor Moderate Major Severe Consequence → Minor Moderate Major Severe
Likelihood ↓ Likelihood ↓
Certain Certain
Likely R-10 R-01* Likely R-10 R-01*
Possible R-05 R-07 R-03 R-02* Possible R-05 R-07 R-03 R-02*
R-12 R-06 R-08 R-12 R-14 R-06 R-08
Unlikely R-04 Unlikely R-04
R-11 R-09 R-11 R-09
Rare Rare R-15
( * = currently High pre-treatment; all targeted to Low after treatment ) ( * = 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 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 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 compensating controls (isolated-LAN lab) or carried on the POA&M with target
dates. Newly strengthened in v0.2: device control (USBGuard, now Implemented), dates. Newly recorded in v0.3: **R-14** (the management/enrolment plane is a
admin SELinux confinement (R-05), STIG applied+verified (R-03 reduced), and connected system requiring its own authorisation) and **R-15** (deferred agent
**Secure Boot signing verified enforcing on real hardware (R-13 reduced actuators — a future remote-mutation channel, inert in v1). Newly *strengthened*
Medium→Low)**.* 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. 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 8. **Witness device control** (USBGuard): a USB mass-storage device is blocked and
logged, console HID still works. 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).
--- ---
+107 -27
View File
@@ -15,11 +15,11 @@
|---|---| |---|---|
| System name | Diskless Fleet Boot System ("the fleet") | | System name | Diskless Fleet Boot System ("the fleet") |
| Document type | System Security Plan (SSP) | | Document type | System Security Plan (SSP) |
| Version | 0.2 (DRAFT — pre-assessment) | | Version | 0.3 (DRAFT — pre-assessment) |
| Date | 2026-06-27 | | Date | 2026-07-10 |
| Author / maintainer | System Owner (delegate) | | 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. | | 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); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) | | 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 > **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 > 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 → **In scope:** the boot chain (UEFI HTTP boot → signed UKI → dracut initramfs →
network authentication → encrypted root mount → verified read-only OS), the network authentication → encrypted root mount → verified read-only OS), the
runtime workstation OS image, the host's cryptographic identity (TPM), and the runtime workstation OS image, the host's cryptographic identity (TPM), the
hardening baseline applied to the image. 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/ **Out of scope (separately authorised — connected systems):** the lab serving
NVMe-oF target, audit collector — `serving/`), the 802.1X authenticator/RADIUS stack (DHCP/HTTP/NVMe-oF target, audit collector — `serving/`); the **server-side
infrastructure, the certificate enrolment authority, the network fabric, and fleet-management plane** (`serving/fleet-manager/` — the control-plane hub, its
physical security of the facility. These are dependencies referenced here but admin web UI, the InfluxDB metrics store, and the Traefik ingress); the **TPM
carry their own security documentation and authorisation. 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 ## 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 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 network-mounted root stays alive; the host derives its NVMe `hostnqn` from
the TPM certificate serial and (kiosk builds) launches the locked-down kiosk. 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 ### 2.3 Authorisation boundary
The authorisation boundary is the **running workstation host**: the UKI, the The authorisation boundary is the **running workstation host**: the UKI, the
initramfs and its custom 802.1X/MACsec dracut module, the verified erofs root, 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 tmpfs overlay, the TPM-backed identity, the hardened OS configuration, and the
The boundary's external interfaces are: (a) the **access link** (802.1X-controlled in-image **`fleet-agent`**. The boundary's external interfaces are: (a) the
port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c) **access link** (802.1X-controlled port, MACsec-encrypted); (b) the **NVMe/TCP**
**outbound audit log** forwarding; (d) the **kiosk application** endpoint the root storage session; (c) **outbound audit log** forwarding; (d) the **kiosk
browser renders; (e) the **boot transport** (UEFI HTTP boot, or PXE/TFTP→iPXE) application** endpoint the browser renders; (e) the **boot transport** (UEFI HTTP
that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuard boot, or PXE/TFTP→iPXE) that delivers the UKI; (f) **USB/peripheral** ports, now
(§5.2). Each crossing is a control point addressed in Annex A. 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 ### 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**. CI/HSM-held and **never enter the build VM or the repository**.
- **Data in transit:** access link protected by **MACsec**; NVMe/TCP root and - **Data in transit:** access link protected by **MACsec**; NVMe/TCP root and
audit-log offload currently **plaintext on an isolated lab LAN** (see residual 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. - **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 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 Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M
source). source).
**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-27)**. The system **Current status:** Pre-assessment **DRAFT (v0.3, updated 2026-07-10)**. The system
is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA 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, 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); audit offload, DHCP-named host, NVMe/verity root all good with hardening on);
USBGuard device control enabled + verified; admin confined to `staff_u`; **UEFI USBGuard device control enabled + verified; admin confined to `staff_u`; **UEFI
@@ -201,7 +245,18 @@ summarises the implementation posture.
### 5.2 Protect ### 5.2 Protect
- **Identity & authentication:** mutual **802.1X EAP-TLS** with a - **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. - **Link protection:** **MACsec** on the access link.
- **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable - **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable
cmdline**; **dm-verity** read-only root (continuous cryptographic integrity); 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 SSH**, root login disabled, hardened ciphers/MACs/KEX (note: STIG crypto policy
drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked
browser, no shell/navigation); **postfix removed** (no local MTA — audit-failure 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 - **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 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 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 2026-06-27** (`wazuh-logtest` + a live `sudo` surfacing `USER_CMD`/`CRED_*` in
the dashboard). Config is declarative (`serving/siem/`, mounted via the dashboard). Config is declarative (`serving/siem/`, mounted via
`/wazuh-config-mount`). `/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 **plaintext** (TLS planned, R-07); **log retention/archival** (ISM) not yet
configured; default Wazuh credentials to be rotated before production. 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. | | 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. | | 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. | | 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. | | 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. | | 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 ## 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-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-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-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-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-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-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-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-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. Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A.
+41
View File
@@ -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 path either way (already-privileged context), which is fapolicyd's defense-in-depth
threat model — not an unprivileged bypass. 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) ## Things that are NOT deviations (kept / enforced)
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). - **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
+19 -5
View File
@@ -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 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 machine), but design it to fold into the mgmt service with no rewrite — same Go
style, same step-ca anchor, same data model. 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 - **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 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, 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, - **Steady-state agent ↔ manager (telemetry + reconciliation) → gRPC over mTLS,
host-dialed.** Streaming fits here, and it stays compatible with "no inbound port on 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 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): up / desired-state down). ~~OPEN FORK~~ **RESOLVED (2026-07):** a **bespoke gRPC
bespoke gRPC vs reuse **OTLP-gRPC for metrics + Wazuh for events** + a thin gRPC bidi stream** (`serving/fleet-manager/proto/fleet_agent.proto` —
control stream — the latter gets VictoriaMetrics/Grafana/Wazuh for free. `Connect` `FleetAgent.Session`, health/metrics up + desired-state down), over the same
(connectrpc.com) is an option if one protobuf IDL serving gRPC + JSON + browser is reused TPM cert (fleet CA). Metrics land in **InfluxDB** via the manager (host
wanted across the authenticated surface. 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 - **host-api stays HTTP/JSON — locked**, because its consumer is the Chromium kiosk
(a browser, which cannot speak native gRPC). (a browser, which cannot speak native gRPC).
+83
View File
@@ -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 ./... )
```
+32
View File
@@ -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
+59
View File
@@ -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
+28
View File
@@ -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
)
+42
View File
@@ -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=
+96
View File
@@ -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
}
+273
View File
@@ -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 ""
}
+848
View File
@@ -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
}
+102
View File
@@ -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
}
}
+130
View File
@@ -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",
}
+21
View File
@@ -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 => ../
+26
View File
@@ -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=
+207
View File
@@ -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)")
}
}
+399
View File
@@ -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
}
+153
View File
@@ -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)
}
}
+304
View File
@@ -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
}
+38
View File
@@ -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 };
+4
View File
@@ -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
+45
View File
@@ -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
+5 -3
View File
@@ -43,10 +43,12 @@ Keep `nvmet_traddr` (UKI) == `NVME_HOST` (this stack's `.env`).
```bash ```bash
cd serving 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=<ver>\nBOOT_IFACE=<iface>\nSERVER_IP=<ip>\n' > .env # + DHCP range
docker login git.bitasylum.org docker login git.bitasylum.org
docker compose up -d --build docker compose up -d --build # or simply: make up
docker compose ps docker compose ps # or: make ps
``` ```
`IMAGE_VERSION` must match the built tag (e.g. a branch build is `IMAGE_VERSION` must match the built tag (e.g. a branch build is
+20 -1
View File
@@ -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 <cmd>
# # 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-<version>.efi). # Release being served. Must match the Packer build (fleet-<version>.efi).
IMAGE_VERSION=0.0.0-2eefd97 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 # 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. # forward rule so every host offloads auditd here; resolved to this serving host.
AUDIT_HOST=audit.fleet.lab 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 ------------------------------------------------------- # --- NVMe/TCP target -------------------------------------------------------
# Per-version subsystem NQN base. The long-lived target serves each rootfs # Per-version subsystem NQN base. The long-lived target serves each rootfs
+14 -2
View File
@@ -29,8 +29,9 @@
# docker cp $id:/artifacts/. ./artifacts/$IMAGE_VERSION/; docker rm $id # docker cp $id:/artifacts/. ./artifacts/$IMAGE_VERSION/; docker rm $id
# Private registry: docker login git.bitasylum.org first so the image pulls. # Private registry: docker login git.bitasylum.org first so the image pulls.
# #
# Usage: # Usage (config = committed defaults.env + your .env overrides, layered):
# cp .env.example .env # then edit BOOT_IFACE / SERVER_IP / DHCP range # 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 # docker compose up -d
# ============================================================================= # =============================================================================
@@ -104,6 +105,9 @@ services:
# Host-generic remote audit collector name (baked into the image config so # Host-generic remote audit collector name (baked into the image config so
# every host offloads auditd here; resolved per-environment to this host). # every host offloads auditd here; resolved per-environment to this host).
- --address=/${AUDIT_HOST}/${SERVER_IP} - --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 # 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 # 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 # 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 # 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 # 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. # 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 <boot-nic> 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) --------------------------- # --- Remote audit / log collector (RECEIVE side) ---------------------------
# rsyslog receiver: booted diskless hosts (whose RAM-only overlay root loses # rsyslog receiver: booted diskless hosts (whose RAM-only overlay root loses
+2 -2
View File
@@ -15,7 +15,7 @@
services: services:
# --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) -------- # --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) --------
step-ca: step-ca:
image: smallstep/step-ca:latest image: harbor.bitasylum.org/dockerhub/smallstep/step-ca:latest
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -38,7 +38,7 @@ services:
# --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) --- # --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) ---
freeradius: freeradius:
image: freeradius/freeradius-server:latest image: harbor.bitasylum.org/dockerhub/freeradius/freeradius-server:latest
network_mode: host # RADIUS 1812/1813 from the authenticator network_mode: host # RADIUS 1812/1813 from the authenticator
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
+8
View File
@@ -71,6 +71,14 @@ FP=$(openssl x509 -in "$PKI/root_ca.crt" -noout -fingerprint -sha256 | cut -d= -
echo " root fingerprint: $FP" echo " root fingerprint: $FP"
echo " copy to: files/etc/dot1x/radius-ca.pem (image trust anchor)" 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) ---------------------- # --- 1b. raise the CA cert-duration claim (idempotent) ----------------------
# A stock step-ca caps issued certs at 24h. Both the RADIUS server cert below # 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 # AND the enrol-broker (--not-after 720h) need longer-lived fleet certs, so the
+2 -2
View File
@@ -7,14 +7,14 @@
# #
# Pin + mirror these base tags through your registry (like the rest of the stack) # Pin + mirror these base tags through your registry (like the rest of the stack)
# if the build/serving host has no egress. # 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 WORKDIR /src
COPY go.mod . COPY go.mod .
COPY *.go wordlist.txt ./ COPY *.go wordlist.txt ./
ENV CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local ENV CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local
RUN go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /enrol-broker . 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 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 # 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 # binds loopback ports >1024 and only READS the mounted CA root + provisioner
+20 -7
View File
@@ -15,6 +15,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
) )
@@ -105,7 +106,7 @@ func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
return return
} }
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin) 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) { 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 return
} }
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin) 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. // respondDecision replies to an approve/deny. A browser form gets redirected back
func redirectOrJSON(w http.ResponseWriter, r *http.Request) { // to the queue; a caller that asks for JSON (Accept: application/json — e.g. the
if isBrowser(r) { // 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) 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 { func isBrowser(r *http.Request) bool {
+11
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
/fleet-manager
*.test
# deployment secrets + issued PKI (never commit)
.env
/pki/
/data/
+331
View File
@@ -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 <<EOF
FLEET_MGR_INFLUX_TOKEN=$(openssl rand -hex 32)
INFLUX_ADMIN_PASSWORD=$(openssl rand -hex 24)
ACME_EMAIL=you@bitasylum.org
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
# override any other default from defaults.env here, e.g. FLEET_MGR_RP_ID=...
EOF
```
`COMPOSE_ENV_FILES` requires every listed file to exist, so create `.env` before
running compose. Per-command alternative: `docker compose --env-file defaults.env
--env-file .env …`. The Influx token is handed to **both** InfluxDB (its init
admin token) and the manager, so one value wires them.
Or skip the export entirely and use the **Makefile**, which sets
`COMPOSE_ENV_FILES` for you: `make up` / `make logs SERVICE=fleet-manager` /
`make register-admin NAME=you` / `make config` (`make help` lists targets).
## 2. PKI
Three certs, three trust roots — do not conflate them (see README "two planes"):
### 2a. Agent-plane server cert (fleet CA)
Enrolled hosts dial the gRPC agent plane and verify the manager against the
**fleet root** they hold, so this cert is issued by the fleet CA. Its SANs MUST
include every name a host dials — the fleet-DNS name `manager.fleet.lab` (what the
on-host agent uses) **and** the host FQDN. A missing SAN fails the agent TLS
handshake with `certificate is valid for … not manager.fleet.lab`.
**Recommended — auto-renewer (step-ca co-located on this host).** The
`agent-cert-renewer` sidecar issues the cert once, then runs `step ca renew
--daemon` to rotate it before the CA's ~90-day cap. Renewal **preserves the
subject + SANs**, so the drift above cannot recur, and the manager hot-reloads the
rotated cert with no restart (`GetCertificate`, `certreload.go`). No `step` CLI on
the host, no manual re-mint. Tune SANs / paths via `defaults.env` (see the
`cert-auto` block); it mounts the CA root/intermediate + `enrol` provisioner
password read-only from `../dot1x-bench/pki`.
```bash
docker compose --profile cert-auto up -d agent-cert-renewer
# -> ./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/<YOUR_ZONE_ID>" }
]
}
```
`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.
<a name="network-paths"></a>
### 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=<name>` so it finds the `<name>_fleet-data` volume.
## Troubleshooting
- **`permission denied` writing the inventory on approve** — the runtime image is
distroless `nonroot` (uid **65532**), but a named volume is created root-owned,
so `/data` isn't writable. A rebuilt image pre-creates `/data` owned by nonroot
(a *fresh* volume inherits it), but an **already-created** volume must be fixed
once:
```bash
docker volume ls | grep fleet # find the name, e.g. fleet-manager_fleet-data
docker run --rm -v <vol>:/data 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.
+43
View File
@@ -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"]
+56
View File
@@ -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=<you>" >&2; exit 1; }
$(COMPOSE) run --rm fleet-manager register-admin "$(NAME)"
backup: ## Snapshot the durable volumes (backup.sh)
./backup.sh
+202
View File
@@ -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 <name>` (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).
<a name="authorise-pillar"></a>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 <you>` → `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).
+243
View File
@@ -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
}
+72
View File
@@ -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"
+323
View File
@@ -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)
}
+243
View File
@@ -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)
}
+131
View File
@@ -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")
}
}
+177
View File
@@ -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})
}
+59
View File
@@ -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 <project>_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 <project>_<key>
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."
+121
View File
@@ -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
}
+103
View File
@@ -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)
}
}
+77
View File
@@ -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
}
+109
View File
@@ -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)
}
}
+243
View File
@@ -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 <name>` (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] <name>
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] <name>")
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://<admin-ui-origin>"
}
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] <cert-serial-hex>
//
// 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] <cert-serial-hex>")
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
}
+91
View File
@@ -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 <cmd>
#
# 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-<sha7>, or a <branch>-<sha7> 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-
+212
View File
@@ -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 <you> # 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-<sha7>); 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
+41
View File
@@ -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
)
+85
View File
@@ -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=
+145
View File
@@ -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)
}
+73
View File
@@ -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")
}
}
+119
View File
@@ -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-<serial>
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)
}
+284
View File
@@ -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 <name>` 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 <serial>` 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 <name>` 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
}
@@ -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
}
@@ -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
}
}
@@ -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",
}
+98
View File
@@ -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"
+149
View File
@@ -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"
}
+37
View File
@@ -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)
})
}
+17
View File
@@ -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"))
})
}
+167
View File
@@ -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 { "<cert_serial>": {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
}
@@ -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
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.local
+45
View File
@@ -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.
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>Fleet Command</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+33
View File
@@ -0,0 +1,33 @@
<script>
import { onMount } from 'svelte'
import { session, refreshSession } from './lib/session.svelte.js'
import { router } from './lib/router.svelte.js'
import Navbar from './components/Navbar.svelte'
import StatusBar from './components/StatusBar.svelte'
import Login from './pages/Login.svelte'
import Enrol from './pages/Enrol.svelte'
import Monitor from './pages/Monitor.svelte'
import Authorise from './pages/Authorise.svelte'
import Lifecycle from './pages/Lifecycle.svelte'
const pages = { monitor: Monitor, authorise: Authorise, lifecycle: Lifecycle }
let Current = $derived(pages[router.path] || Monitor)
onMount(refreshSession)
</script>
{#if router.path === 'enrol'}
<Enrol />
{:else if !session.ready}
<div class="relative z-10 flex min-h-full items-center justify-center text-muted">
<span class="cursor">initialising fleet command</span>
</div>
{:else if !session.admin}
<Login />
{:else}
<div class="relative z-10 flex h-full flex-col">
<Navbar />
<StatusBar />
<main class="flex-1 overflow-y-auto p-4 md:p-6"><Current /></main>
</div>
{/if}
+139
View File
@@ -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; }
@@ -0,0 +1,37 @@
<script>
// Bespoke SVG sparkline — themed, no charting lib. points: [{t, v}].
let { points = [], color = 'var(--color-teal)', height = 120, label = '' } = $props()
function build(pts) {
const nums = (pts || []).map((p) => Number(p.v)).filter((v) => !Number.isNaN(v))
if (nums.length < 2) return { d: '', area: '', min: null, max: null, last: nums[0] ?? null }
const min = Math.min(...nums)
const max = Math.max(...nums)
const span = max - min || 1
const W = 100
const H = 100
const step = W / (nums.length - 1)
const coords = nums.map((v, i) => [i * step, H - ((v - min) / span) * H])
const d = coords.map((c, i) => (i ? 'L' : 'M') + c[0].toFixed(2) + ' ' + c[1].toFixed(2)).join(' ')
return { d, area: d + ` L ${W} ${H} L 0 ${H} Z`, min, max, last: nums[nums.length - 1] }
}
let g = $derived(build(points))
</script>
<div>
<div class="mb-1 flex items-baseline justify-between">
<span class="tag">{label}</span>
<span class="font-mono text-xs" style="color:{color}">{g.last ?? '—'}</span>
</div>
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%;height:{height}px" class="border border-line bg-void2">
{#if g.d}
<path d={g.area} fill={color} opacity="0.08" />
<path d={g.d} fill="none" stroke={color} stroke-width="1.2" vector-effect="non-scaling-stroke" />
{:else}
<text x="50" y="52" text-anchor="middle" fill="var(--color-faint)" font-size="6">no data</text>
{/if}
</svg>
<div class="tag mt-1 flex justify-between">
<span>min {g.min?.toFixed?.(0) ?? '—'}</span><span>max {g.max?.toFixed?.(0) ?? '—'}</span>
</div>
</div>
@@ -0,0 +1,18 @@
<script>
// Confirm-gate for high-consequence (violet/restricted) actions.
let { open = false, title = 'Confirm', message = '', confirmLabel = 'Confirm', onconfirm, oncancel } = $props()
</script>
{#if open}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="presentation" onclick={oncancel}>
<div class="panel panel-restricted w-full max-w-md p-5" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="tag mb-1">restricted action</div>
<h3 class="mb-2 font-display text-violet">{title}</h3>
<p class="mb-5 text-sm text-muted">{message}</p>
<div class="flex justify-end gap-2">
<button class="btn" onclick={oncancel}>cancel</button>
<button class="btn btn-restricted" onclick={onconfirm}>{confirmLabel}</button>
</div>
</div>
</div>
{/if}
@@ -0,0 +1,18 @@
<script>
import { router, go } from '../lib/router.svelte.js'
const tabs = [
{ id: 'monitor', label: 'MONITOR' },
{ id: 'authorise', label: 'AUTHORISE' },
{ id: 'lifecycle', label: 'LIFECYCLE' },
]
</script>
<nav class="flex items-center gap-1 border-b border-line px-4 py-2">
<div class="mr-6 font-display tracking-widest text-amber">FLEET<span class="text-violet">·</span>COMMAND</div>
{#each tabs as t}
<button
class="rounded px-3 py-1.5 font-display text-xs tracking-wider {router.path === t.id ? 'text-amber' : 'text-muted hover:text-ink'}"
style={router.path === t.id ? 'border-bottom:2px solid var(--color-amber)' : 'border-bottom:2px solid transparent'}
onclick={() => go(t.id)}>{t.label}</button>
{/each}
</nav>
@@ -0,0 +1,13 @@
<script>
let { title = '', tag = '', restricted = false, children } = $props()
</script>
<section class="panel {restricted ? 'panel-restricted' : ''} p-4">
{#if title || tag}
<header class="mb-3 flex items-baseline justify-between gap-3">
<h2 class="font-display text-sm {restricted ? 'text-violet' : 'text-amber'}">{title}</h2>
{#if tag}<span class="tag">{tag}</span>{/if}
</header>
{/if}
{@render children?.()}
</section>
@@ -0,0 +1,9 @@
<script>
let { state = 'off', label = '' } = $props()
const clsMap = { ok: 'pill-ok', warn: 'pill-warn', alert: 'pill-alert', off: 'pill-off' }
const textMap = { ok: 'verified', warn: 'degraded', alert: 'alert', off: 'offline' }
let cls = $derived(clsMap[state] || 'pill-off')
let text = $derived(label || textMap[state] || state)
</script>
<span class="pill {cls}">{text}</span>
@@ -0,0 +1,22 @@
<script>
import { session, logout } from '../lib/session.svelte.js'
let now = $state(new Date())
$effect(() => {
const id = setInterval(() => (now = new Date()), 1000)
return () => clearInterval(id)
})
let clock = $derived(now.toISOString().slice(11, 19) + 'Z')
</script>
<div class="flex items-center justify-between border-b border-line bg-void2 px-4 py-1 text-xs">
<div class="flex items-center gap-4">
<span class="pill pill-ok">● secure link</span>
<span class="tag">passkey · {session.admin || '—'}</span>
</div>
<div class="flex items-center gap-4">
<span class="font-mono text-faint">{clock}</span>
{#if session.admin && session.admin !== 'dev-insecure'}
<button class="btn" onclick={logout}>sign out</button>
{/if}
</div>
</div>
+39
View File
@@ -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),
}
+90
View File
@@ -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 · <source> · s<stratum> ·
// <offset>" — 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) }
}
@@ -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
}
@@ -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 = ''
}
@@ -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))
}
+12
View File
@@ -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') })
@@ -0,0 +1,87 @@
<script>
import { api } from '../lib/api.js'
import Panel from '../components/Panel.svelte'
import { relTime } from '../lib/fmt.js'
let pending = $state([])
let error = $state('')
let busy = $state('')
let binding = $state({})
async function load() {
try {
const d = await api.get('/api/enrol/pending')
pending = d.pending || []
for (const e of pending) if (!binding[e.id]) binding[e.id] = {} // init bind targets
error = ''
} catch (e) {
error = e?.message || 'failed to load enrolment queue'
}
}
async function approve(e) {
busy = e.id
try {
await api.post(`/api/enrol/${e.id}/approve`, binding[e.id] || {})
delete binding[e.id]
await load()
} catch (err) {
error = err?.message
} finally {
busy = ''
}
}
async function deny(e) {
busy = e.id
try {
await api.post(`/api/enrol/${e.id}/deny`)
await load()
} catch (err) {
error = err?.message
} finally {
busy = ''
}
}
$effect(() => {
load()
const id = setInterval(load, 8000)
return () => clearInterval(id)
})
</script>
<div class="mb-3 flex items-baseline justify-between">
<h1 class="font-display text-violet">ENROLMENT — PENDING AUTHORISATION</h1>
<span class="tag">{pending.length} awaiting · cross-check each fingerprint against the device splash</span>
</div>
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each pending as e (e.id)}
<div class="panel panel-restricted p-4">
<div class="tag mb-1">verify fingerprint</div>
<div class="mb-3 font-display text-lg leading-snug break-words" style="color:var(--color-green)">{e.fingerprint}</div>
<dl class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-0.5 font-mono text-xs text-muted">
<dt class="text-faint">enrol-id</dt><dd class="truncate">{e.id}</dd>
<dt class="text-faint">CN</dt><dd>{e.cn || '—'}</dd>
{#if e.context?.mac}<dt class="text-faint">MAC</dt><dd>{e.context.mac}</dd>{/if}
{#if e.context?.ek_hash}<dt class="text-faint">TPM EK</dt><dd class="truncate">{e.context.ek_hash}</dd>{/if}
<dt class="text-faint">age</dt><dd>{relTime(e.created_at)}</dd>
</dl>
<div class="mt-3 grid grid-cols-3 gap-2">
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="asset tag" bind:value={binding[e.id].asset_tag} />
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="switch port" bind:value={binding[e.id].switch_port} />
<input class="rounded border border-line bg-void2 px-2 py-1 text-xs text-ink" placeholder="location" bind:value={binding[e.id].location} />
</div>
<div class="mt-3 flex justify-end gap-2">
<button class="btn btn-alert" disabled={busy === e.id} onclick={() => deny(e)}>deny</button>
<button class="btn btn-restricted" disabled={busy === e.id} onclick={() => approve(e)}>
{busy === e.id ? 'signing…' : '⚿ approve + bind'}
</button>
</div>
</div>
{/each}
{#if !pending.length && !error}
<Panel title="queue clear" tag="no pending enrolments"><p class="text-sm text-muted">No devices are awaiting authorisation.</p></Panel>
{/if}
</div>
@@ -0,0 +1,42 @@
<script>
import { router } from '../lib/router.svelte.js'
import { passkeyRegister, passkeySupported } from '../lib/webauthn.js'
let busy = $state(false)
let done = $state(false)
let who = $state('')
let error = $state('')
async function enrol() {
busy = true
error = ''
try {
const r = await passkeyRegister(router.token)
who = r.admin
done = true
} catch (e) {
error = e?.message || 'enrolment failed'
} finally {
busy = false
}
}
</script>
<div class="relative z-10 flex min-h-full items-center justify-center p-6">
<div class="panel panel-restricted w-full max-w-md p-8 text-center">
<div class="tag mb-2">passkey enrolment</div>
<h1 class="mb-4 font-display text-xl text-amber">Register a hardware passkey</h1>
{#if done}
<p class="mb-4"><span class="pill pill-ok">enrolled · {who}</span></p>
<a class="btn" href="#/monitor">continue → sign in</a>
{:else if !router.token}
<p class="pill pill-alert">missing enrolment token — use the link from <code>register-admin</code></p>
{:else if !passkeySupported()}
<p class="pill pill-alert">this browser has no WebAuthn support</p>
{:else}
<p class="mb-6 text-sm text-muted">Insert your YubiKey and tap to bind it to this fleet console.</p>
<button class="btn btn-restricted w-full py-3" disabled={busy} onclick={enrol}>
{busy ? 'waiting for passkey…' : '⚿ register passkey'}
</button>
{/if}
{#if error}<p class="mt-4"><span class="pill pill-alert">{error}</span></p>{/if}
</div>
</div>
@@ -0,0 +1,175 @@
<script>
import { api } from '../lib/api.js'
import Panel from '../components/Panel.svelte'
import Confirm from '../components/Confirm.svelte'
import { shortSerial } from '../lib/fmt.js'
let hosts = $state([])
let error = $state('')
let okMsg = $state('')
let selected = $state(null)
let busy = $state(false)
let confirmOpen = $state(false)
let rebootMode = $state('none') // none | now | at
let customVersion = $state(false)
let form = $state({ target_version: '', kiosk_url: '', reboot_at: '', drain: false })
// Versions known across the fleet (running + already-targeted), newest first.
// A "custom…" escape covers a version not yet seen anywhere. (Future: source
// from the serving-side release inventory / deployment manifest.)
let versions = $derived.by(() => {
const s = new Set()
for (const h of hosts) {
if (h.version?.image_version) s.add(h.version.image_version)
if (h.desired?.target_version) s.add(h.desired.target_version)
}
return [...s].sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
})
function onVersionSelect(e) {
const v = e.target.value
if (v === '__custom__') {
customVersion = true
form.target_version = ''
} else {
form.target_version = v
}
}
async function load() {
try {
const d = await api.get('/api/hosts')
hosts = d.hosts || []
error = ''
} catch (e) {
error = e?.message || 'failed to load fleet'
}
}
function pick(h) {
selected = h
okMsg = ''
rebootMode = 'none'
customVersion = false
form = {
target_version: h.desired?.target_version || '',
kiosk_url: h.desired?.kiosk_url || '',
reboot_at: '',
drain: !!h.desired?.drain,
}
}
let rebootConseq = $derived(rebootMode === 'now' ? 'REBOOT it NOW' : rebootMode === 'at' && form.reboot_at ? 'schedule a REBOOT' : null)
let consequence = $derived([form.drain ? 'DRAIN this host' : null, rebootConseq].filter(Boolean).join(' and '))
async function apply() {
confirmOpen = false
busy = true
error = ''
okMsg = ''
const body = {
target_version: form.target_version || undefined,
kiosk_url: form.kiosk_url || undefined,
drain: form.drain,
}
if (rebootMode === 'now') body.reboot_at = new Date().toISOString()
else if (rebootMode === 'at' && form.reboot_at) body.reboot_at = new Date(form.reboot_at).toISOString()
try {
const res = await api.put(`/api/hosts/${selected.identity.cert_serial}/desired`, body)
okMsg = res.pushed_live ? 'applied — pushed to the live host' : 'applied — host reconciles on next connect'
await load()
selected = hosts.find((h) => h.identity.cert_serial === selected.identity.cert_serial) || selected
} catch (e) {
error = e?.message
} finally {
busy = false
}
}
function submit() {
// Gate destructive changes behind a confirm; a plain version/URL change applies directly.
if (form.drain || rebootMode === 'now' || (rebootMode === 'at' && form.reboot_at)) confirmOpen = true
else apply()
}
$effect(() => {
load()
})
</script>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div>
<h1 class="mb-3 font-display text-amber">LIFECYCLE</h1>
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
<div class="space-y-2">
{#each hosts as h (h.identity.cert_serial)}
<button class="panel w-full p-2 text-left {selected?.identity.cert_serial === h.identity.cert_serial ? 'panel-restricted' : ''}" onclick={() => pick(h)}>
<div class="flex items-center justify-between">
<span class="font-display text-sm text-ink">{h.identity.starship_name || shortSerial(h.identity.cert_serial)}</span>
<span class="tag">{h.version?.image_version || '—'}{h.desired?.drain ? ' · drain' : ''}</span>
</div>
</button>
{/each}
</div>
</div>
<div class="lg:col-span-2">
{#if selected}
<Panel title="desired state — {selected.identity.starship_name || shortSerial(selected.identity.cert_serial)}" tag="server-authoritative" restricted>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="block">
<span class="tag">target version</span>
{#if customVersion || !versions.length}
<div class="mt-1 flex items-center gap-2">
<input class="min-w-0 flex-1 rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.target_version} placeholder="e.g. 0.5.0" />
{#if versions.length}<button type="button" class="btn" onclick={() => (customVersion = false)}>list</button>{/if}
</div>
{:else}
<select class="mt-1 w-full rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" value={form.target_version} onchange={onVersionSelect}>
<option value="">— unset —</option>
{#each versions as v}<option value={v}>{v}</option>{/each}
<option value="__custom__">custom…</option>
</select>
{/if}
</label>
<label class="block">
<span class="tag">kiosk url</span>
<input class="mt-1 w-full rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.kiosk_url} placeholder="http://boot.fleet.lab/kiosk/" />
</label>
<div class="block">
<span class="tag">reboot</span>
<div class="mt-1 flex items-center gap-2">
<select class="rounded border border-line bg-void2 px-2 py-1.5 text-sm {rebootMode !== 'none' ? 'text-violet-soft' : 'text-ink'}" bind:value={rebootMode}>
<option value="none">none</option>
<option value="now">now</option>
<option value="at">at…</option>
</select>
{#if rebootMode === 'at'}
<input type="datetime-local" class="flex-1 rounded border border-line bg-void2 px-2 py-1.5 text-sm text-ink" bind:value={form.reboot_at} />
{/if}
</div>
</div>
<label class="mt-4 flex items-center gap-2">
<input type="checkbox" bind:checked={form.drain} />
<span class="font-display text-sm text-violet-soft">drain (stop taking work)</span>
</label>
</div>
<div class="mt-4 flex items-center justify-between">
{#if okMsg}<span class="pill pill-ok">{okMsg}</span>{:else}<span></span>{/if}
<button class="btn btn-restricted" disabled={busy} onclick={submit}>{busy ? 'applying…' : '⚿ apply desired state'}</button>
</div>
<p class="mt-3 text-xs leading-relaxed text-faint">
Recorded intent — kiosk URL / reboot / drain reconcile via the host agent, but per-host
<span class="text-muted">boot-version assignment</span> (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.
</p>
</Panel>
{:else}
<Panel title="lifecycle control" tag="select a host"><p class="text-sm text-muted">Select a host to set its server-authoritative desired state (version pin, kiosk URL, reboot, drain).</p></Panel>
{/if}
</div>
</div>
<Confirm
open={confirmOpen}
title="Confirm high-consequence change"
message={`This will ${consequence} on ${selected?.identity.starship_name || ''}. It is pushed to the live host immediately.`}
confirmLabel="apply"
onconfirm={apply}
oncancel={() => (confirmOpen = false)}
/>
@@ -0,0 +1,32 @@
<script>
import { passkeyLogin, passkeySupported } from '../lib/webauthn.js'
let busy = $state(false)
let error = $state('')
async function signIn() {
busy = true
error = ''
try {
await passkeyLogin()
} catch (e) {
error = e?.message || 'sign-in failed'
} finally {
busy = false
}
}
</script>
<div class="relative z-10 flex min-h-full items-center justify-center p-6">
<div class="panel panel-restricted w-full max-w-md p-8 text-center">
<div class="tag mb-2">clearance required</div>
<h1 class="mb-1 font-display text-2xl text-amber">FLEET<span class="text-violet">·</span>COMMAND</h1>
<p class="mb-6 text-sm text-muted">Authenticate with your hardware passkey to access the fleet control plane.</p>
{#if !passkeySupported()}
<p class="pill pill-alert">this browser has no WebAuthn support</p>
{:else}
<button class="btn btn-restricted w-full py-3" disabled={busy} onclick={signIn}>
{busy ? 'waiting for passkey…' : '⚿ sign in with passkey'}
</button>
{/if}
{#if error}<p class="mt-4"><span class="pill pill-alert">{error}</span></p>{/if}
</div>
</div>
@@ -0,0 +1,128 @@
<script>
import { api } from '../lib/api.js'
import Panel from '../components/Panel.svelte'
import StatePill from '../components/StatePill.svelte'
import Chart from '../components/Chart.svelte'
import { relTime, pct, num, healthState, shortSerial, dur, rebootStatus, chronySummary } from '../lib/fmt.js'
let hosts = $state([])
let error = $state('')
let selected = $state(null)
let metrics = $state({ tmpfs_used_pct: [], cpu_pct: [], temp_c: [] })
let metricsErr = $state('')
async function load() {
try {
const d = await api.get('/api/hosts')
hosts = d.hosts || []
error = ''
if (selected) selected = hosts.find((h) => h.identity.cert_serial === selected.identity.cert_serial) || selected
} catch (e) {
error = e?.message || 'failed to load fleet'
}
}
async function loadMetrics(serial) {
metricsErr = ''
for (const f of Object.keys(metrics)) {
try {
const d = await api.get(`/api/hosts/${serial}/metrics?field=${f}&range=6h`)
metrics[f] = d.points || []
} catch (e) {
metrics[f] = []
metricsErr = e?.message || ''
}
}
}
async function select(h) {
selected = h
await loadMetrics(h.identity.cert_serial)
}
// One tick: refresh the roster (+ host-detail via the re-point in load()) and,
// if a host is selected, its metric series too — so the graphs stay live
// without re-clicking.
async function refresh() {
await load()
if (selected) await loadMetrics(selected.identity.cert_serial)
}
function drift(h) {
const run = h.version?.image_version
const want = h.desired?.target_version
return want && run && want !== run
}
$effect(() => {
refresh()
const id = setInterval(refresh, 10000)
return () => clearInterval(id)
})
</script>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="mb-3 flex items-baseline justify-between">
<h1 class="font-display text-amber">FLEET ROSTER</h1>
<span class="tag">{hosts.length} hosts · {hosts.filter((h) => h.online).length} online</span>
</div>
{#if error}<div class="pill pill-alert mb-3">{error}</div>{/if}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each hosts as h (h.identity.cert_serial)}
<button class="panel p-3 text-left {selected?.identity.cert_serial === h.identity.cert_serial ? 'panel-restricted' : ''}" onclick={() => select(h)}>
<div class="flex items-center justify-between">
<span class="font-display text-ink">{h.identity.starship_name || shortSerial(h.identity.cert_serial)}</span>
<StatePill state={h.online ? healthState(h.health) : 'off'} />
</div>
<div class="tag mt-0.5">{shortSerial(h.identity.cert_serial)} · {h.binding?.location || 'unbound'}</div>
<div class="mt-2 grid grid-cols-3 gap-2 font-mono text-xs text-ink">
<div><span class="text-faint">ver </span>{h.version?.image_version || '—'}{#if drift(h)}<span class="text-amber-hot"> ⇢ {h.desired.target_version}</span>{/if}</div>
<div><span class="text-faint">tmpfs </span>{pct(h.health?.tmpfs_used_pct)}</div>
<div><span class="text-faint">seen </span>{h.online ? 'live' : relTime(h.last_seen)}</div>
</div>
<div class="mt-2 flex flex-wrap gap-1.5">
{#if h.desired?.drain}<span class="pill pill-warn">drain</span>{/if}
{#if drift(h)}<span class="pill pill-warn">version-drift</span>{/if}
{#if h.health?.selinux === 'permissive'}<span class="pill pill-alert">selinux permissive</span>{/if}
</div>
</button>
{/each}
{#if !hosts.length && !error}<p class="text-muted">no hosts enrolled yet — approve one in AUTHORISE.</p>{/if}
</div>
</div>
<div>
{#if selected}
<Panel title={selected.identity.starship_name || shortSerial(selected.identity.cert_serial)} tag="host detail">
<dl class="grid grid-cols-2 gap-y-1 font-mono text-xs">
<dt class="text-faint">hostnqn</dt><dd class="truncate text-muted">{selected.identity.hostnqn || '—'}</dd>
<dt class="text-faint">running</dt><dd class="text-ink">{selected.version?.image_version || '—'}</dd>
<dt class="text-faint">desired</dt><dd class={drift(selected) ? 'text-amber-hot' : 'text-ink'}>{selected.desired?.target_version || '—'}</dd>
<dt class="text-faint">kernel</dt><dd class="truncate text-muted">{selected.version?.kernel || '—'}</dd>
<dt class="text-faint">auth</dt><dd class="text-muted">{selected.health?.auth || '—'}</dd>
<dt class="text-faint">nvme root</dt><dd class="text-muted">{selected.health?.nvme_root || '—'}</dd>
<dt class="text-faint">selinux</dt><dd class="text-muted">{selected.health?.selinux || '—'}</dd>
<dt class="text-faint">chrony</dt><dd class="text-muted">{chronySummary(selected.health)}</dd>
<dt class="text-faint">kiosk</dt><dd class="text-muted">{selected.health?.kiosk_active ? 'active' : 'idle'} · {selected.usage?.session_uptime || '—'}</dd>
<dt class="text-faint">uptime</dt><dd class="text-muted">{selected.health?.uptime_seconds ? dur(selected.health.uptime_seconds) : '—'}</dd>
{#if rebootStatus(selected)}
{@const rb = rebootStatus(selected)}
<dt class="text-faint">reboot</dt><dd class={rb.state === 'overdue' ? 'text-amber-hot' : rb.state === 'pending' ? 'text-amber' : 'text-teal'}>{rb.label}</dd>
{/if}
<dt class="text-faint">last seen</dt><dd class="text-muted">{selected.online ? 'live' : relTime(selected.last_seen)}</dd>
</dl>
<div class="mt-4 space-y-3">
<Chart label="tmpfs used %" color="var(--color-amber)" points={metrics.tmpfs_used_pct} />
<Chart label="cpu %" color="var(--color-teal)" points={metrics.cpu_pct} />
<Chart label="temp °C" color="var(--color-amber-hot)" points={metrics.temp_c} />
{#if metricsErr}<p class="tag text-faint">metrics: {metricsErr}</p>{/if}
</div>
</Panel>
{:else}
<Panel title="host detail" tag="select a ship">
<p class="text-sm text-muted">Select a host from the roster to see live health + telemetry charts.</p>
</Panel>
{/if}
</div>
</div>
@@ -0,0 +1,3 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default { preprocess: vitePreprocess() }
+17
View File
@@ -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 },
},
},
})
+209
View File
@@ -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,
})
}