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:
@@ -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
|
||||
@@ -58,6 +58,7 @@ on:
|
||||
- ".gitea/workflows/ipxe-image.yaml"
|
||||
- ".gitea/workflows/dnsmasq-image.yaml"
|
||||
- ".gitea/workflows/spdk-image.yaml"
|
||||
- ".gitea/workflows/fleet-manager-image.yaml"
|
||||
|
||||
# One build per ref at a time; a newer push to the same branch cancels the
|
||||
# in-flight build (each build is a ~17-min VM run, so don't stack them).
|
||||
@@ -196,14 +197,14 @@ jobs:
|
||||
go-version: '1.26.4'
|
||||
|
||||
-
|
||||
name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi)
|
||||
name: Build host Go binaries out-of-VM (fleet-enrol, fleet-hostapi, fleet-agent)
|
||||
# Built HERE on the runner, NOT inside the Packer VM, so the Rocky build
|
||||
# image never carries the Go toolchain or the go-tpm dep tree. Packer
|
||||
# file-provisions the resulting static binaries into the rootfs. Runs
|
||||
# BEFORE packer validate/build so the file provisioners' sources exist.
|
||||
# Reproducible: pinned Go (1.26.4) + go.sum-pinned deps + -trimpath/no buildid.
|
||||
# fleet-enrol fetches go-tpm from the proxy (pinned by go.sum); hostapi is
|
||||
# stdlib-only (GOPROXY=off).
|
||||
# fleet-enrol + fleet-agent fetch go-tpm/grpc from the proxy (pinned by
|
||||
# go.sum); hostapi is stdlib-only (GOPROXY=off).
|
||||
# shell: bash is explicit — the Gitea/act runner defaults run steps to sh
|
||||
# (dash), where `set -o pipefail` is an illegal option. `set -e` is
|
||||
# load-bearing: without it a failed go build/test in the first ( … ) group
|
||||
@@ -218,13 +219,20 @@ jobs:
|
||||
( cd files/hostapi \
|
||||
&& CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local \
|
||||
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-hostapi . )
|
||||
file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi
|
||||
( cd files/agent \
|
||||
&& CGO_ENABLED=0 GOTOOLCHAIN=local \
|
||||
go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o bin/fleet-agent . \
|
||||
&& CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go test ./... )
|
||||
file files/enrol/bin/fleet-enrol files/hostapi/bin/fleet-hostapi files/agent/bin/fleet-agent
|
||||
|
||||
-
|
||||
name: TPM enrolment ceremony test (go-tpm software simulator)
|
||||
# Exercises the real key-gen/sign/NV path against a software TPM (CGO in a
|
||||
# separate module, so the simulator never enters the shipped enrol build).
|
||||
run: ( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... )
|
||||
name: TPM ceremony + agent mTLS tests (go-tpm software simulator)
|
||||
# Exercises the real key-gen/sign/NV path (enrol) AND the reused-TPM mTLS
|
||||
# client-auth handshake (agent) against a software TPM (CGO in separate
|
||||
# modules, so the simulator never enters the shipped enrol/agent builds).
|
||||
run: |
|
||||
( cd files/enrol/simtest && CGO_ENABLED=1 go test ./... )
|
||||
( cd files/agent/simtest && CGO_ENABLED=1 go test ./... )
|
||||
|
||||
-
|
||||
name: packer init
|
||||
@@ -272,6 +280,22 @@ jobs:
|
||||
echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV"
|
||||
echo "Secure Boot signing material staged (signed UKI build)."
|
||||
|
||||
-
|
||||
name: Materialize fleet CA (radius-ca.pem trust anchor)
|
||||
# The real fleet step-ca root is baked to /etc/dot1x/radius-ca.pem (RADIUS
|
||||
# EAP-TLS + fleet-agent server-verify). Written to a runner-temp FILE and
|
||||
# passed as a build-host PATH (var.fleet_ca). If FLEET_CA is unset this
|
||||
# no-ops and the build falls back to the committed PLACEHOLDER (auth cannot
|
||||
# succeed — lab only). Never commit real internal CA material (CLAUDE.md).
|
||||
if: ${{ secrets.FLEET_CA != '' }}
|
||||
env:
|
||||
FLEET_CA: ${{ secrets.FLEET_CA }} # fleet step-ca root PEM (root, or root+intermediate)
|
||||
run: |
|
||||
umask 077
|
||||
printf '%s\n' "$FLEET_CA" > "$RUNNER_TEMP/fleet-ca.pem"
|
||||
echo "FLEET_CA_FILE=$RUNNER_TEMP/fleet-ca.pem" >> "$GITHUB_ENV"
|
||||
echo "Fleet CA staged (real trust anchor baked into radius-ca.pem)."
|
||||
|
||||
-
|
||||
name: Materialize Secure Boot enrollment certs
|
||||
# PUBLIC PK/KEK/db .auth files baked into the image for fleet-sb-enroll.
|
||||
@@ -308,6 +332,7 @@ jobs:
|
||||
-var ssh_authorized_key="${{ secrets.SSH_AUTHORIZED_KEY }}" \
|
||||
-var sb_key="${SB_KEY_FILE:-}" \
|
||||
-var sb_cert="${SB_CERT_FILE:-}" \
|
||||
-var fleet_ca="${FLEET_CA_FILE:-files/etc/dot1x/radius-ca.pem}" \
|
||||
-var kiosk_url="${{ env.KIOSK_URL }}" \
|
||||
-var boot_splash=${{ env.BOOT_SPLASH }} \
|
||||
-var enable_stig=${{ env.ENABLE_STIG }} \
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
DISPATCH_DRY: ${{ inputs.dry_run }}
|
||||
GC_ENABLE: ${{ vars.REGISTRY_GC_ENABLE }}
|
||||
# Only images built by THIS repo's workflows.
|
||||
IMAGES: "fleet-boot fleet-ipxe fleet-dnsmasq spdk-nvmf"
|
||||
IMAGES: "fleet-boot fleet-ipxe fleet-dnsmasq spdk-nvmf fleet-manager"
|
||||
steps:
|
||||
- name: Reconcile registry against live branches
|
||||
# The runner defaults `run:` to sh (dash); this script uses bashisms
|
||||
|
||||
@@ -19,3 +19,4 @@ keys
|
||||
# provisioner; built by the "Build host Go binaries" CI step).
|
||||
files/enrol/bin/
|
||||
files/hostapi/bin/
|
||||
files/agent/bin/
|
||||
|
||||
+92
-3
@@ -130,6 +130,12 @@ variable "kiosk_url" {
|
||||
default = "http://boot.fleet.lab/kiosk/"
|
||||
}
|
||||
|
||||
variable "enable_agent" {
|
||||
type = bool
|
||||
description = "Build-time feature flag for the on-host fleet-agent (telemetry client that dials the fleet-manager over TPM-mTLS and streams health; docs/fleet-management-design.md). true (default) installs /usr/local/bin/fleet-agent + fleet-agent.service; false skips it (the auth stack still boots — the agent is a management add-on). Config is fleet-generic in files/agent/agent.env; set the manager DNS name there per environment."
|
||||
default = true
|
||||
}
|
||||
|
||||
variable "boot_splash" {
|
||||
type = bool
|
||||
description = "true (default): bake 'rhgb quiet' into the UKI cmdline so Plymouth shows a graphical boot splash (covers the network-auth + NVMe/TCP + verity phase). false: bake 'plymouth.enable=0' for a fully verbose console boot — use while debugging the boot chain. Plymouth itself is always in the image; this only flips the cmdline. (The cmdline is immutable once signed, so this is a build-time choice.)"
|
||||
@@ -167,6 +173,12 @@ variable "sb_cert" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "fleet_ca" {
|
||||
type = string
|
||||
description = "Path on the BUILD HOST to the fleet CA PEM baked to /etc/dot1x/radius-ca.pem — the trust anchor wpa_supplicant uses for RADIUS EAP-TLS AND the fleet-agent uses to verify the fleet-manager. Defaults to the COMMITTED PLACEHOLDER (files/etc/dot1x/radius-ca.pem), which is NOT a usable CA (auth/telemetry can't succeed with it). Override per-environment (CI secret -> temp file) with your real step-ca root (or a root+intermediate bundle for robustness). Never commit real internal CA material (CLAUDE.md)."
|
||||
default = "files/etc/dot1x/radius-ca.pem"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------- source
|
||||
|
||||
source "qemu" "diskless" {
|
||||
@@ -231,6 +243,15 @@ build {
|
||||
destination = "/tmp/etc-stage"
|
||||
}
|
||||
|
||||
# Fleet CA baked to /etc/dot1x/radius-ca.pem (RADIUS trust anchor + fleet-agent
|
||||
# server-verify CA). var.fleet_ca defaults to the committed placeholder; CI
|
||||
# overrides it with the real per-environment step-ca root (a build-host path).
|
||||
# Installed over the placeholder in the etc-install step below.
|
||||
provisioner "file" {
|
||||
source = var.fleet_ca
|
||||
destination = "/tmp/fleet-ca.pem"
|
||||
}
|
||||
|
||||
# Kiosk unit + session wrapper (installed by the kiosk provisioner below).
|
||||
# Dir-without-trailing-slash upload -> /tmp/kiosk/ (mirrors the dracut module
|
||||
# upload form, which stages reliably).
|
||||
@@ -259,6 +280,20 @@ build {
|
||||
destination = "/tmp/fleet-hostapi"
|
||||
}
|
||||
|
||||
# On-host fleet-agent: the systemd unit AND the baked fleet-generic config
|
||||
# (agent.env) are staged from here to /tmp/agent (installed in the 2b-agent step,
|
||||
# which runs AFTER /tmp/etc-stage is cleaned — so config must ride this upload,
|
||||
# not files/etc). The static binary (built OUT OF THE VM like enrol/hostapi) is
|
||||
# staged separately below.
|
||||
provisioner "file" {
|
||||
source = "files/agent"
|
||||
destination = "/tmp/"
|
||||
}
|
||||
provisioner "file" {
|
||||
source = "files/agent/bin/fleet-agent"
|
||||
destination = "/tmp/fleet-agent"
|
||||
}
|
||||
|
||||
# Custom 'fleet' Plymouth theme (scripted security-handshake splash). Staged to
|
||||
# /tmp/plymouth, installed + set default in the package step before dracut runs.
|
||||
provisioner "file" {
|
||||
@@ -348,7 +383,10 @@ build {
|
||||
"rm -rf /tmp/plymouth",
|
||||
|
||||
# Stage etc files into place
|
||||
"install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem",
|
||||
# radius-ca.pem comes from var.fleet_ca (placeholder by default; real fleet CA
|
||||
# per-environment via CI) — NOT the etc-stage copy, so one build input controls
|
||||
# the trust anchor for both RADIUS EAP-TLS and the fleet-agent.
|
||||
"install -D -m0644 /tmp/fleet-ca.pem /etc/dot1x/radius-ca.pem",
|
||||
"install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf",
|
||||
"install -D -m0644 /tmp/etc-stage/dracut.conf.d/90-diskless.conf /etc/dracut.conf.d/90-diskless.conf",
|
||||
# NB: the old static /etc/NetworkManager/conf.d/99-dot1x-unmanaged.conf is
|
||||
@@ -367,6 +405,17 @@ build {
|
||||
"restorecon /etc/NetworkManager/dispatcher.d/10-chrony-dhcp-ntp",
|
||||
"sed -i -E 's/^[[:space:]]*(pool|server)[[:space:]]/#&/' /etc/chrony.conf",
|
||||
"grep -q '^sourcedir /run/chrony-dhcp' /etc/chrony.conf || echo 'sourcedir /run/chrony-dhcp' >> /etc/chrony.conf",
|
||||
# Enable chrony's tracking log so the fleet-agent can report clock health
|
||||
# (source/stratum/offset) WITHOUT touching chronyd's command interface. That
|
||||
# interface is unusable by the hardened agent: the unix socket is
|
||||
# owner-write-only (0755) and the agent is DynamicUser + ProtectSystem=strict
|
||||
# (/run read-only), and STIG closes the UDP command port (cmdport 0). Instead
|
||||
# chronyd appends source/stratum/offset to /var/log/chrony/tracking.log, which
|
||||
# the agent simply READS (world-readable; no port, no socket, no SELinux domain
|
||||
# transition). Write volume is tiny (~one ~130B line per clock update, backing
|
||||
# off to ~maxpoll when stable) and capped by the chrony RPM's logrotate; on the
|
||||
# RAM overlay this is negligible. Keeps cmdport 0 intact — no STIG deviation.
|
||||
"grep -qE '^log ([^#]*[[:space:]])?tracking' /etc/chrony.conf || echo 'log tracking' >> /etc/chrony.conf",
|
||||
|
||||
# Inject the fleet NIC drivers into the dracut config
|
||||
"sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf",
|
||||
@@ -619,6 +668,32 @@ build {
|
||||
]
|
||||
}
|
||||
|
||||
# --- 2b-agent. On-host fleet-agent (Go, TPM-mTLS telemetry client) --------
|
||||
# Installs the OUT-OF-VM-built /usr/local/bin/fleet-agent (the "Build host Go
|
||||
# binaries" CI step; staged to /tmp/fleet-agent) + its systemd unit + baked,
|
||||
# fleet-generic config, and enables it. The agent dials the fleet-manager over
|
||||
# mTLS using the host's reused TPM identity (signing key at 0x81000100 + cert in
|
||||
# NV 0x01800100) and streams health; NOTHING host-specific is baked (invariant
|
||||
# #1) — the manager DNS name in agent.env is fleet-generic. Gated on
|
||||
# enable_agent so a lean/auth-only build can skip it. The binary is
|
||||
# fapolicyd-trusted in 2c-STIG-postfix (non-RPM exec, like hostapi/kiosk-session).
|
||||
provisioner "shell" {
|
||||
environment_vars = [
|
||||
"ENABLE_AGENT=${var.enable_agent}",
|
||||
]
|
||||
inline = [
|
||||
"set -euxo pipefail",
|
||||
"if [ \"$ENABLE_AGENT\" != \"true\" ]; then echo 'enable_agent=false -> skipping fleet-agent install.'; exit 0; fi",
|
||||
"install -D -m0755 /tmp/fleet-agent /usr/local/bin/fleet-agent",
|
||||
"install -D -m0644 /tmp/agent/fleet-agent.service /etc/systemd/system/fleet-agent.service",
|
||||
"install -D -m0644 /tmp/agent/agent.env /etc/fleet-agent/agent.env",
|
||||
"restorecon -R /usr/local/bin/fleet-agent /etc/systemd/system/fleet-agent.service /etc/fleet-agent 2>/dev/null || true",
|
||||
"systemctl enable fleet-agent.service",
|
||||
"rm -rf /tmp/agent /tmp/fleet-agent",
|
||||
"echo '==> fleet-agent enabled (TPM-mTLS telemetry to the fleet-manager)'",
|
||||
]
|
||||
}
|
||||
|
||||
# --- 2c. Admin access: sudo-enabled key user + locked root --------------
|
||||
# Fleet-generic (same user + key on every host => NOT per-host state). The
|
||||
# key comes from var.ssh_authorized_key (CI secret). If no key is supplied we
|
||||
@@ -835,6 +910,20 @@ build {
|
||||
"semodule -i /tmp/fapolicyd_tmpfs.pp",
|
||||
"rm -f /tmp/fapolicyd_tmpfs.mod /tmp/fapolicyd_tmpfs.pp",
|
||||
"semodule -l | grep -q '^fapolicyd_tmpfs' && echo '==> SELinux module fapolicyd_tmpfs installed' || { echo 'ERROR: fapolicyd_tmpfs module not installed' >&2; exit 1; }",
|
||||
# SELinux: let the fleet-agent READ chrony's tracking log. The agent
|
||||
# (unconfined_service_t) reports clock health by reading
|
||||
# /var/log/chrony/tracking.log (type chronyd_var_log_t), which the shipped
|
||||
# policy confines to chronyd_t despite the 0644 mode -> denied under enforcing
|
||||
# (seen on comet-beacon-54). A minimal read-only allow (no write, no domain
|
||||
# transition). Installed unconditionally like the modules above (inert without
|
||||
# the agent). checkpolicy ensured above. Same policy-store-rebuild
|
||||
# reproducibility caveat as chrony/fapolicyd. docs/stig-deviations.md #16.
|
||||
"echo '==> supplemental: SELinux module fleet_agent_chronylog (fleet-agent reads chrony tracking.log)'",
|
||||
"checkmodule -M -m -o /tmp/fleet_agent_chronylog.mod /tmp/stig/fleet-agent-chronylog.te",
|
||||
"semodule_package -o /tmp/fleet_agent_chronylog.pp -m /tmp/fleet_agent_chronylog.mod",
|
||||
"semodule -i /tmp/fleet_agent_chronylog.pp",
|
||||
"rm -f /tmp/fleet_agent_chronylog.mod /tmp/fleet_agent_chronylog.pp",
|
||||
"semodule -l | grep -q '^fleet_agent_chronylog' && echo '==> SELinux module fleet_agent_chronylog installed' || { echo 'ERROR: fleet_agent_chronylog module not installed' >&2; exit 1; }",
|
||||
"rm -rf /tmp/stig",
|
||||
"echo '==> STIG remediation complete (report at /root/stig-report.html, ARF at /root/stig-arf.xml)'",
|
||||
]
|
||||
@@ -912,9 +1001,9 @@ build {
|
||||
# provide). Can drop lower after measuring `fapolicyd-cli --dump | wc -l` on
|
||||
# the bench; see docs/TODO.md.
|
||||
" if [ -f /etc/fapolicyd/fapolicyd.conf ]; then sed -ri 's/^[[:space:]]*#?[[:space:]]*db_max_size[[:space:]]*=.*/db_max_size = 50/' /etc/fapolicyd/fapolicyd.conf; grep -q '^db_max_size' /etc/fapolicyd/fapolicyd.conf || printf 'db_max_size = 50\\n' >> /etc/fapolicyd/fapolicyd.conf; echo '==> fapolicyd: db_max_size set to 50 MiB (was EL9 default ~100)'; fi",
|
||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||
" for f in /usr/local/bin/kiosk-session /usr/local/bin/fleet-sb-enroll /usr/local/bin/fleet-hostapi /usr/local/bin/fleet-agent /usr/sbin/fleet-enrol; do { [ -f \"$f\" ] && fapolicyd-cli --file add \"$f\"; } || true; done",
|
||||
" fapolicyd-cli --update 2>/dev/null || true",
|
||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi) + rebuilt trust db'",
|
||||
" echo '==> fapolicyd: trusted custom binaries (kiosk-session, fleet-sb-enroll, fleet-hostapi, fleet-agent) + rebuilt trust db'",
|
||||
# watch_fs: the diskless root is OVERLAYFS (systemd.volatile=overlay), but the
|
||||
# shipped watch_fs lacks 'overlay', so fapolicyd marks NO overlay mount and
|
||||
# therefore intercepts NO exec on / — the exec guard for the writable overlay
|
||||
|
||||
+143
-2
@@ -1200,7 +1200,7 @@ write + **reboot converges** (2nd boot runs the secure path); (f) prod: broker o
|
||||
|
||||
**TEMPORARY (2026-06-28): broker also added to the MAIN serving stack**
|
||||
(`serving/docker-compose.yml`, `enrol-broker` service + `ENROL_*` in
|
||||
`serving/.env.example`) so a single `compose up` in `serving/` brings the
|
||||
`serving/defaults.env`) so a single `compose up` in `serving/` brings the
|
||||
`/enrol/` endpoint up while the host side is built. Defaults to the
|
||||
zero-dependency lab path — **`mock` signer** (ephemeral CA; certs NOT trusted by
|
||||
RADIUS/NVMe) + **plaintext loopback admin** (`ENROL_ADMIN_INSECURE=true`). The
|
||||
@@ -1382,7 +1382,124 @@ remains documented here if a minimal/auditable cmdline is ever wanted.
|
||||
**BENCH-VERIFY:** `chrome://policy` shows `ExtensionInstallBlocklist=["*"]` active;
|
||||
extensions can't install.
|
||||
|
||||
## [ ] 14. (DESIGN) Centralised fleet management plane (health/version/usage/maintenance)
|
||||
## [~] 14. (PARTLY BUILT) Centralised fleet management plane (health/version/usage/maintenance)
|
||||
|
||||
**SERVER-SIDE CONTROL PLANE BUILT 2026-07 — `serving/fleet-manager/`** (Go service
|
||||
+ embedded Svelte SPA). See the **as-built note** at the top of
|
||||
[docs/fleet-management-design.md](fleet-management-design.md). What shipped and how
|
||||
the design forks resolved:
|
||||
- **Two planes:** admin (HTTPS + **WebAuthn/passkey**, YubiKey; server-side session
|
||||
+ CSRF; `register-admin` CLI bootstrap; UI cert via **Let's Encrypt DNS-01**,
|
||||
hot-reloaded) and agent (**bespoke gRPC bidi/mTLS**, fleet CA, reused TPM cert —
|
||||
health/metrics up, desired-state down; no inbound host port).
|
||||
- **Metrics → InfluxDB** (manager is the only reader/writer; host holds no Influx
|
||||
credential; identity-tagged writes; Flux reads behind a field whitelist + hex-
|
||||
serial guard). Graphs render **in the SPA** (bespoke SVG), not Grafana. This
|
||||
replaces the earlier Wazuh-Phase-1 / OTLP-Phase-2 sketch for *this* plane;
|
||||
Wazuh stays the audit/SIEM path.
|
||||
- **Authorise pillar:** the enrol-broker (#11) folded in "alongside" — manager
|
||||
drives its admin plane over mTLS and **births the inventory record** on approve.
|
||||
- **Deployment:** `Dockerfile` + `docker-compose.yml` (private InfluxDB +
|
||||
host-networked manager) + `server-pki.sh` + admin-UI cert `acme` side-car (LE
|
||||
DNS-01 via Route 53, auto-renew) + CI image build (`fleet-manager-image.yaml`,
|
||||
pulled by tag) + `backup.sh` (durable volumes) + `DEPLOY.md`.
|
||||
- **Hardening still open (operator/per-env):** network-segment the admin (`:9500`)
|
||||
+ agent (`:9600`) listeners to the mgmt net; run `server-pki.sh` when the agent
|
||||
lands; decide `:9500` vs a 443 proxy; digest-pin the images (invariant #12). See
|
||||
DEPLOY.md "Hardening status".
|
||||
- **[~] (a) the on-host agent — TELEMETRY HALF BUILT 2026-07-05 (`files/agent/`).**
|
||||
The gRPC client that pairs with `fleet-hostapi` and feeds this plane. It reuses
|
||||
the host **TPM identity** for mTLS (signing key at `0x81000100` via
|
||||
`tpmkey.LoadSigner` — a read-only copy of the enrol `tpmkey`; cert from NV
|
||||
`0x01800100`), opens the long-lived `Session` stream, sends `Hello` + periodic
|
||||
`HealthSnapshot` (load/cpu/temp/mem/tmpfs/**uptime**/nvme/auth/selinux/kiosk from
|
||||
`/proc`+`/sys` + hostapi facts, plus **chrony** clock health — state/source/
|
||||
stratum/offset — read from `/var/log/chrony/tracking.log`), and — **telemetry-only v1** —
|
||||
**receives `DesiredState`/`Command` but only LOGS them** (zero host mutation).
|
||||
Static `CGO_ENABLED=0` binary built out-of-VM in CI (like enrol/hostapi), staged
|
||||
by the `2b-agent` provisioner (`enable_agent`, default on), fapolicyd-trusted,
|
||||
fleet-generic config in `files/agent/agent.env`. mTLS handshake proven
|
||||
end-to-end against the go-tpm software simulator (`files/agent/simtest`). No
|
||||
dracut/initramfs changes (post-switch-root only). **STILL TO BUILD (deferred):**
|
||||
the **reconcile actuators** — apply kiosk_url, reboot_at/REBOOT, drain — wired to
|
||||
be received but not acted on; land after the telemetry half is bench-verified.
|
||||
**BENCH-VERIFIED END-TO-END ON REAL HARDWARE 2026-07-10** (Dell 820
|
||||
`comet-beacon-54`, real TPM, image `fleet-manager-254ec79`): the agent loads its
|
||||
reused TPM identity (serial derived on-device via go-tpm), completes mTLS to the
|
||||
manager, streams health, AND receives desired-state (logged, not applied). Manager
|
||||
logs `agent connected: fed27077… (comet-beacon-54)`; the host stays connected (no
|
||||
reconnect loop). This closes the TPM-identity path (802.1X = NVMe hostnqn = agent
|
||||
mTLS, "one identity, three stages") on hardware.
|
||||
|
||||
**Deploying the agent plane — gotchas hit + FIXED in commit `a89c3d2` (don't
|
||||
rediscover):** the serving-side bring-up cost a long debug chain, each now
|
||||
automated:
|
||||
1. **Host CA was the placeholder.** `/etc/dot1x/radius-ca.pem` shipped a
|
||||
non-CA placeholder → `client TLS: no certificates parsed from fleet CA`.
|
||||
FIX: `fleet_ca` build var bakes the real step-ca root (CI secret → file →
|
||||
`-var fleet_ca`; default = placeholder, lab-only). The agent's `FLEET_AGENT_CA`
|
||||
points here; only the ROOT is needed host-side (the manager sends its chain).
|
||||
2. **`tls: certificate required`.** step-ca signs leaf certs with an
|
||||
INTERMEDIATE and enrolment stores the leaf-only cert in NV, so a root-only
|
||||
client CA makes the manager advertise only the root in its CertificateRequest →
|
||||
the Go client sends NO cert. FIX: `server-pki.sh` installs the agent CLIENT CA
|
||||
(`FLEET_MGR_AGENT_CLIENT_CA`) as **root+intermediate**; `bootstrap.sh` exports
|
||||
`intermediate_ca.crt`.
|
||||
3. **Distroless perms.** The nonroot manager (uid 65532) can't read `pki/agent/*`
|
||||
if created `0600` by the host user → `open …/server.crt: permission denied`.
|
||||
`chown 65532` the agent PKI (as with the fleet-data volume).
|
||||
4. **Manager wasn't a fleet name.** The agent's baked default pointed at the
|
||||
PUBLIC admin-UI name (`diskless-server.bitasylum.org`), which resolves to the
|
||||
serving host's OUTBOUND IP → `no route to host` from the fleet LAN. FIX: the
|
||||
agent plane is now just another `*.fleet.lab` — `manager.fleet.lab` (`MGMT_HOST`
|
||||
in serving/.env), served by the fleet dnsmasq to `SERVER_IP` exactly like
|
||||
boot/nvme/audit (`--address=/${MGMT_HOST}/${SERVER_IP}`), baked as
|
||||
`FLEET_AGENT_MANAGER` and set as the agent server cert SAN (`AGENT_SANS`). No
|
||||
per-host override, no server-name hack. (The public `diskless-server…` name
|
||||
stays the ADMIN-UI name on Traefik/:443 — a separate plane.) The transient
|
||||
bench workaround was `FLEET_AGENT_MANAGER=<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).**
|
||||
Server-authoritative, pull-based, TPM-identified management plane reusing existing
|
||||
@@ -1399,6 +1516,8 @@ manifest + SPDK flow. Build-time decisions (telemetry encoding, metrics/desired-
|
||||
state store, per-host vs cohort, osquery/FleetDM eval) listed in the design note.
|
||||
Depends on the step-ca fleet CA (#8) for the mTLS identity.
|
||||
|
||||
</details>
|
||||
|
||||
## [ ] 15. Scope the root overlay → per-subtree tmpfs/overlay (un-shadowable `/usr` + STIG mount options)
|
||||
|
||||
**DESIGN CAPTURED 2026-07-02.** Today the whole root is one `systemd.volatile=overlay`
|
||||
@@ -1620,6 +1739,28 @@ now **kernel-MODULE-bound, not firmware** (~10 MB fw): the `--no-hostonly` + ply
|
||||
- **BENCH-VERIFY:** kiosk still renders on the 820 (i915 kept); check the new
|
||||
"initramfs largest files" log line for the post-omit size + next targets.
|
||||
|
||||
**[x] FIXED — the `nss-mdns` drop bricked the kiosk (regression found+fixed 2026-07-04):**
|
||||
the "render should be safe" call above (5cc72cc) was WRONG — it broke the kiosk, just
|
||||
not via i915. `trim-rpm-48dc3e1` booted to a **flashing cursor, no kiosk**, and the image
|
||||
was a suspicious **500 MB**. Root cause: the 2d `dnf remove -y nss-mdns` removed
|
||||
**chromium** as a *dependent package* — the EPEL `chromium` **hard-Requires `nss-mdns`**
|
||||
(Chromium uses `.local` mDNS hostnames for WebRTC ICE candidates) — then reaped chromium's
|
||||
orphaned GUI stack (avahi, at-spi2, atk, adwaita, chromium-common; ~49 pkgs, 523→472).
|
||||
With no browser, `kiosk-session` died at the `${BROWSER:?}` guard in 4 ms, before weston.
|
||||
The original commit checked what `nss-mdns` NEEDS (avahi) but not what NEEDS it (chromium).
|
||||
GPU/i915/firmware/plymouth were all healthy — a red herring. Fix (`2bc2daf`):
|
||||
- **Keep `nss-mdns` + avahi** (satisfy chromium's Requires); **mask the avahi daemon +
|
||||
socket** instead — nss-mdns is only an NSS module, needs no listener, and :5353 was the
|
||||
real attack-surface concern. ~1.5 MB kept.
|
||||
- **FINAL GUARD in 2d**: `rpm -q chromium chromium-common weston mesa-{dri-drivers,libEGL,libgbm}`
|
||||
asserts the kiosk stack survived the whole pipeline, else **fail the build**. A missing
|
||||
browser must brick the BUILD, never the fleet — catches any future remove-cascade /
|
||||
oscap `package_*_removed` / autoremove. Look for `==> guard: kiosk stack intact` in CI.
|
||||
**RE-VERIFIED 2026-07-04 (`trim-rpm-2bc2daf`): boots into the kiosk on the 820; image 755 MiB.**
|
||||
|
||||
**Measured end-to-end (2026-07-04, `trim-rpm-2bc2daf`): container artifact image
|
||||
1.8 GiB → 755 MiB (~58%, >1 GB off every host's per-boot NVMe/TCP read).**
|
||||
|
||||
**Combined:** ~**800 MB** off the uncompressed erofs (langpacks + firmware), dwarfing the
|
||||
kiosk weak-deps trim (#4), plus a smaller UKI/initramfs from the DRM-driver omit. NEXT
|
||||
candidates from the manifest (measure, don't guess): `iso-codes` (19 MB),
|
||||
|
||||
@@ -1,7 +1,63 @@
|
||||
# Fleet management plane — design note
|
||||
|
||||
**Status:** DESIGN (not built). Captured 2026-06-28. Build phased — see "Phasing"
|
||||
and `docs/TODO.md` #14.
|
||||
**Status:** PARTLY BUILT. Captured 2026-06-28; **server-side control plane built
|
||||
2026-07** (`serving/fleet-manager/`). The sections below are the original design;
|
||||
the **as-built note** immediately following records what shipped and how the
|
||||
open forks resolved. Build still phased — see "Phasing" and `docs/TODO.md` #14.
|
||||
|
||||
## As-built note (2026-07) — `serving/fleet-manager/`
|
||||
|
||||
The **server-side control plane + admin SPA is built** and supersedes several
|
||||
"open questions" below. What shipped:
|
||||
|
||||
- **One service, two planes** (`serving/fleet-manager/`, Go): an **admin plane**
|
||||
(HTTPS + SPA + JSON API) and an **agent plane** (gRPC/mTLS). Runs as its own
|
||||
container on the mgmt host (`docker-compose.yml`: private InfluxDB +
|
||||
host-networked manager). Deployment runbook: `serving/fleet-manager/DEPLOY.md`.
|
||||
- **Resolved forks (were "open questions / decisions"):**
|
||||
- *Steady-state transport (agent ↔ manager)* → **bespoke gRPC bidi stream**
|
||||
(`proto/fleet_agent.proto`: `FleetAgent.Session`), host-dialed mTLS, health/
|
||||
metrics up + desired-state down. Chosen over OTLP+Wazuh+control-stream: one
|
||||
stream, no polling, no inbound host port. Enrolment stays plain HTTP/JSON.
|
||||
- *Metrics store* → **InfluxDB 2.x** (was VictoriaMetrics/Prometheus). The
|
||||
manager is the **only** reader/writer (host holds no Influx credential);
|
||||
writes are line-protocol tagged with the mTLS-authenticated identity, reads
|
||||
are a Flux query behind a **field whitelist + hex-serial guard**. Graphs are
|
||||
rendered **in the SPA** (bespoke SVG), **not Grafana**.
|
||||
- *Dashboards* → the **LYSANDRA restricted-violet SPA** (Svelte 5 + Tailwind 4,
|
||||
embedded via `go:embed`): Monitor / Authorise / Lifecycle. Not Grafana/Wazuh
|
||||
dashboards for this plane (Wazuh stays the audit/SIEM path).
|
||||
- *Desired-state store* → a single **JSON file** (atomic writes) behind a
|
||||
`Store` interface — not git/DB yet; a DB is a drop-in. Server-authoritative.
|
||||
- *Admin/browser identity* → **WebAuthn/passkey (YubiKey)**, not browser mTLS:
|
||||
usernameless discoverable login, UV required, server-side session + CSRF.
|
||||
Bootstrap is a closed CLI (`register-admin`, one-time HMAC enrol link). The
|
||||
UI cert is **Let's Encrypt DNS-01** (mgmt-net host, hot-reloaded). The
|
||||
*manager→broker* hop stays mTLS (service-to-service, broker admin CA).
|
||||
- *Authorise pillar* → the **enrol-broker folded in** "alongside": the broker
|
||||
stays the state machine + step-ca signer; the manager drives its admin plane
|
||||
over mTLS (Accept-gated JSON approve) and **births the inventory record** on
|
||||
approve, recording the human passkey admin as approver.
|
||||
- **Agent identity confirmed:** the agent reuses the **same TPM identity cert**
|
||||
as 802.1X EAP-TLS / NVMe hostnqn — "one identity, three stages". Peer cert
|
||||
serial is authoritative; an unenrolled serial is refused.
|
||||
- **On-host agent — telemetry half BUILT (2026-07-05, `files/agent/`).** The
|
||||
gRPC client that feeds this plane. Reuses the host TPM identity for mTLS (read-only
|
||||
`tpmkey.LoadSigner` at `0x81000100` + cert from NV `0x01800100`; private key never
|
||||
leaves the TPM), opens `Session`, sends `Hello` + periodic `HealthSnapshot`
|
||||
(read-only `/proc`+`/sys` probes + `fleet-hostapi` facts). **Telemetry-only:** it
|
||||
receives `DesiredState`/`Command` but only **logs** them. Static out-of-VM CI
|
||||
binary, `2b-agent` provisioner (`enable_agent`), fapolicyd-trusted; mTLS proven
|
||||
against the go-tpm simulator (`files/agent/simtest`).
|
||||
- **NOT yet built (recorded intent):** (1) the agent's **reconcile actuators** —
|
||||
apply kiosk_url, reboot_at/REBOOT, drain — deferred until the telemetry half is
|
||||
bench-verified; and (2) per-host **boot-version assignment**.
|
||||
`desired.target_version` is stored + pushed, but changing what a host *boots*
|
||||
needs dnsmasq cohort steering + multi-version UKI serving (`docs/TODO.md` #6). The
|
||||
version pin is labelled "recorded intent" in the Lifecycle UI.
|
||||
|
||||
Everything below is the original design context; treat the as-built note as
|
||||
authoritative where they differ.
|
||||
|
||||
Centralised monitoring + maintenance of the diskless kiosk fleet: **health,
|
||||
version, usage, and maintenance actions**. This note is the authoritative design;
|
||||
@@ -69,8 +125,9 @@ Reported facts:
|
||||
|
||||
- **Health:** CPU/load, GPU act-freq, temps, **tmpfs overlay usage** (the
|
||||
RAM-exhaustion → NVMe-keepalive risk — the single most important kiosk health
|
||||
signal), NVMe-root link state, 802.1X/MACsec `Authorized`, SELinux enforcing,
|
||||
chrony sync, `kiosk.service` active.
|
||||
signal), host **uptime** (server derives reboot-pending vs `reboot_at`),
|
||||
NVMe-root link state, 802.1X/MACsec `Authorized`, SELinux enforcing, chrony
|
||||
clock health (sync state + NTP source/stratum/offset), `kiosk.service` active.
|
||||
- **Version:** running image version (`/etc/fleet-image`), rootfs digest, kernel.
|
||||
- **Usage:** kiosk URL, session uptime, **Chromium reload/crash count** (from the
|
||||
`kiosk-session` restart loop — the exact signal behind the GPU-bug diagnosis),
|
||||
@@ -130,8 +187,9 @@ UKI).
|
||||
Host (by cert-serial / hostnqn)
|
||||
├─ identity: cert serial, hostnqn, MAC, starship-name (dnsmasq)
|
||||
├─ version: image_version, rootfs digest, kernel, desired_version
|
||||
├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, nvme_root, auth/macsec,
|
||||
│ selinux, chrony, kiosk_active, last_seen
|
||||
├─ health: cpu/load, gpu_freq, temps, tmpfs_used%, uptime, nvme_root,
|
||||
│ auth/macsec, selinux, chrony (sync + source/stratum/offset),
|
||||
│ kiosk_active, last_seen
|
||||
├─ usage: kiosk_url, session_uptime, chromium_crashes, input_active, display
|
||||
└─ desired: target_version, kiosk_url, reboot_at, drain (server-authoritative)
|
||||
```
|
||||
|
||||
@@ -9,15 +9,23 @@
|
||||
> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
|
||||
> **May 2026 release**.
|
||||
>
|
||||
> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening
|
||||
> applied + bench-verified, USBGuard device control enabled, admin `staff_u`
|
||||
> confinement, audit offload verified, PXE/iPXE boot path added, and **UEFI Secure
|
||||
> Boot signing verified enforcing on real hardware** (VM HTTP + 820 PXE) — R-13
|
||||
> reduced from Medium to Low (residual is making signed + SB-on the standing
|
||||
> default); and (2026-06-27) a **Wazuh SIEM** (dedicated multi-node, agentless)
|
||||
> now decodes + alerts on the fleet auditd stream — "event log analysis/alerting"
|
||||
> moves Planned → Implemented, R-07 reduced toward Low (residual = TLS transport +
|
||||
> retention).
|
||||
> **Annex version:** 0.3 (updated 2026-07-10). v0.3 adds the **fleet-management
|
||||
> plane**: the in-image **`fleet-agent`** (health telemetry over TPM-mTLS —
|
||||
> bench-verified on hardware 2026-07-10; new host interface (g)), the server-side
|
||||
> manager with **WebAuthn-passkey admin MFA** (first human-admin MFA — updates the
|
||||
> §2.4 MFA row and R-05), the **TPM enrolment broker** + **step-ca fleet CA**, and
|
||||
> InfluxDB health telemetry. It also records the **three local SELinux policy
|
||||
> modules** (§2.3) and the **fapolicyd overlay exec-guard fix (2026-07-04)** that
|
||||
> made application control actually effective on the root. New residual risks
|
||||
> **R-14** (management plane = connected system, not yet separately authorised) and
|
||||
> **R-15** (deferred agent actuators = future remote-mutation channel). The
|
||||
> server-side plane is a **connected system** carrying its own authorisation.
|
||||
>
|
||||
> v0.2 (2026-06-22/27) reflected: STIG hardening applied + bench-verified, USBGuard
|
||||
> device control, admin `staff_u` confinement, audit offload verified, PXE/iPXE
|
||||
> boot path, **UEFI Secure Boot signing verified enforcing on real hardware** (R-13
|
||||
> Medium→Low), and a **Wazuh SIEM** (agentless) decoding + alerting on the auditd
|
||||
> stream (R-07 reduced toward Low).
|
||||
|
||||
---
|
||||
|
||||
@@ -85,13 +93,13 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
|
||||
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|
||||
|---|---|---|---|---|---|
|
||||
| Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` + documented tailoring + per-build deviations report ([stig-deviations.md](stig-deviations.md)); **applied + bench-verified 2026-06-20**, all REVIEW rows triaged. | Partial | R-03 (CI must ship `enable_stig=true` + signed/SB-on) |
|
||||
| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + two trusted local binaries: kiosk-session, fleet-sb-enroll) + immutable dm-verity root. | Implemented | — |
|
||||
| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + trusted local binaries: kiosk-session, fleet-sb-enroll, fleet-agent) + immutable dm-verity root. **Effectiveness fix 2026-07-04:** the diskless root is `overlayfs`, which was missing from fapolicyd's `watch_fs` → the guard was **inactive on the root**; adding `overlay` turned allow-listing on for the whole root (verified: novel `/tmp` binary denied). | Implemented | R (residual): path-trust doesn't catch a *modified trusted path* via copy-up — TODO #15 (`integrity=sha256` option); see [stig-deviations #15](stig-deviations.md) |
|
||||
| Device / removable-media control | ISM-0341*, ISM-1551* | Control USB / peripheral devices. | **USBGuard enforcing** (`ImplicitPolicyTarget=block` + hardware-independent HID/hub allowlist + LinuxAudit). Verified: USB mass storage blocked, console input preserved. | Implemented | — |
|
||||
| Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — |
|
||||
| Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — |
|
||||
| Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — |
|
||||
| Memory/exploit mitigations | ISM-1341*, ISM-1342* | Enable platform exploit mitigations. | UKI cmdline: `slub_debug=P`, `page_poison=1`, `init_on_alloc/free=1`, `vsyscall=none`. | Implemented | — |
|
||||
| Mandatory access control | ISM-1416* | MAC enforced. | **SELinux enforcing**; labels preserved into erofs (asserted at build). | Implemented | — |
|
||||
| Mandatory access control | ISM-1416* | MAC enforced. | **SELinux enforcing**; labels preserved into erofs (asserted at build). Three **local allow modules** for architecture-specific paths (`chrony_dhcp_dispatcher`, `fapolicyd_tmpfs`, `fleet_agent_chronylog`), each minimal + read/monitoring-only and documented ([stig-deviations #14–16](stig-deviations.md)); **no permissive domains**. | Implemented | — |
|
||||
| Host-based intrusion prevention / file integrity (AIDE) | ISM-1288* | File integrity monitoring. | **N/A** — superseded by dm-verity (read-only, signed, continuous). | N/A (justified) | — |
|
||||
| Antivirus / endpoint scanning | ISM-1417* | Malware protection on endpoints. | **N/A/compensated** — immutable verified root + allow-listing means no persistent writable executable surface; overlay is RAM-only, discarded each boot. | N/A (justified) | — |
|
||||
|
||||
@@ -99,11 +107,11 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
|
||||
|
||||
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|
||||
|---|---|---|---|---|---|
|
||||
| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81000100`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) |
|
||||
| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81000100`), cert in TPM NV. The **management-plane agent reuses the same TPM identity** for mTLS (`RequireAndVerifyClientCert` to the fleet CA; server keys inventory off the verified cert serial). One non-exportable key = 802.1X + NVMe hostnqn + agent stream. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA), R-14 (mgmt plane = connected system) |
|
||||
| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled; admin **confined to `staff_u`** with a sudo `sysadm_r`/`sysadm_t` role/type transition (SELinux context elevates on privilege use). | Partial | R-05 |
|
||||
| Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — |
|
||||
| No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — |
|
||||
| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane not yet enforced.** | Partial | R-05 |
|
||||
| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management-plane UI now enforced via WebAuthn passkeys** (phishing-resistant, origin-bound, hardware-backed — the manager's admin plane, v0.3). Host **SSH** admin remains key-only (no separate human MFA). | Partial (host SSH) / Implemented (mgmt UI) | R-05 |
|
||||
| Passwordless appliance accounts | — (STIG #10) | Account lockout/aging. | `kiosk`/`fleetadmin` are key-only; password aging/faillock **exempted for these two only**, full policy system-wide otherwise. | Implemented | R-05 (folds into rotatable-access roadmap) |
|
||||
|
||||
### 2.5 Guidelines for System Management
|
||||
@@ -115,6 +123,8 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
|
||||
| Protect build/signing key material | ISM-0501*, ISM-1233* | Protect keys. | Signing keys CI/HSM-held; **never** in repo or build VM; never logged. | Implemented | — |
|
||||
| Privileged actions logged | ISM-1509* | Admin actions auditable. | auditd rules capture privileged/admin events. | Implemented | R-07 (offload protection) |
|
||||
| Disable debug/back-door access | ISM-1491* | No undocumented access paths. | `debug_shell=true` currently bakes a **passwordless root tty** (lab debugging). | Partial | **R-02 (must remove before prod)** |
|
||||
| Device enrolment & identity lifecycle | ISM-1195*, ISM-0475* | Controlled device onboarding. | **TPM enrolment broker** (v0.3): the host generates a key in-TPM, submits a CSR to the one *unauthenticated pre-auth* endpoint; an admin verifies an **OOB fingerprint** and **approves over mTLS**, then **step-ca** signs the leaf into TPM NV and the manager births the inventory record (cert-serial = key). No auto-approval; identity is human-anchored. Server-side = connected system. | Partial (connected system) | R-14 |
|
||||
| Server-authoritative management plane | ISM-1483*, ISM-1509* | Controlled remote management. | `fleet-agent` streams telemetry + receives desired-state over mTLS; **v1 logs commands but does not act** (no host mutation). Inventory/desired-state is server-authoritative. Reconcile actuators deferred. | Partial | R-14, **R-15 (actuators = future remote-mutation channel)** |
|
||||
|
||||
### 2.6 Guidelines for System Monitoring (event logging)
|
||||
|
||||
@@ -125,13 +135,14 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
|
||||
| Protect logs in transit | ISM-1432*, ISM-0407* | Encrypt/authenticate log transport. | **Plaintext** on both hops (host→collector, collector→Wazuh across a routed mgmt LAN); rsyslog TLS rules waived (see [STIG #6](stig-deviations.md)). | Waived (lab) | **R-07** |
|
||||
| Log retention & integrity | ISM-0859*, ISM-1815* | Retain & protect logs. | Per-source-host files on collector; Wazuh indexer available for retention/ISM but **archival/retention policy not yet configured**. | Planned | R-07 |
|
||||
| Event log analysis / alerting | ISM-0109*, ISM-1228* | Review logs; alert on incidents. | **Wazuh SIEM** with a custom **`fleet-auditd`** decoder + severity-tiered rules (`fleet_audit` group; auth, privilege/`USER_CMD`, SELinux). **Decode + alert bench-verified 2026-06-27** (`wazuh-logtest` + live `sudo` in the dashboard). Detection content to expand. | Implemented (content to mature) | R-07 |
|
||||
| Operational health monitoring | ISM-1228*, ISM-1526* | Monitor system health/availability. | **fleet-agent → fleet-manager** health telemetry over mTLS (v0.3): resource pressure, NVMe-root/auth/**SELinux state**/chrony/kiosk, uptime → InfluxDB time-series + admin dashboard flags (version-drift, **SELinux-permissive**, reboot-pending). Complements the auditd/Wazuh security-event path (operational, not security-event, data). Store/dashboard = connected system; host side = read-only probe + mTLS stream. | Partial (connected system) | R-14 |
|
||||
|
||||
### 2.7 Guidelines for Networking
|
||||
|
||||
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|
||||
|---|---|---|---|---|---|
|
||||
| Network access control (802.1X) | ISM-0520*, ISM-1182* | Authenticate devices to the network. | **802.1X** port-based control; unauthenticated port = no access. | Partial | R-01 |
|
||||
| Network segmentation | ISM-1181*, ISM-1532* | Segregate & control traffic. | VLAN assignment post-auth; isolated boot LAN; serving stack separated. | Partial | R-09 |
|
||||
| Network segmentation | ISM-1181*, ISM-1532* | Segregate & control traffic. | VLAN assignment post-auth; isolated boot LAN; serving stack separated. **Management plane (v0.3):** the agent reaches the manager on `:9600` **only via an OPNsense L3 rule** (authenticated host VLAN → `:9600`; never the boot LAN); the admin UI (`:443`, passkey) is on the management network only; enrolment/boot/NVMe/NTP stay on the boot LAN. Agent is **outbound-only** (no inbound host port). | Partial | R-09, R-14 |
|
||||
| Host-based firewall | ISM-1416*, ISM-1416a* | Restrict host network exposure. | **firewalld disabled** (risk to NVMe/TCP & NM during bring-up). | Waived (lab) | **R-09** |
|
||||
| Disable unused network functionality | ISM-1296* | Minimise network services. | Minimal services; no extraneous listeners; kiosk has no inbound. | Implemented | — |
|
||||
|
||||
@@ -146,8 +157,9 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
|
||||
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|
||||
|---|---|---|---|---|---|
|
||||
| ASD-Approved Cryptographic Algorithms (AACA) | ISM-0471*, ISM-1446* | Use approved algorithms. | STIG crypto policy (modern ciphers/MACs/KEX); EAP-TLS, MACsec-GCM. **Conformance to ISM AACA not formally asserted; FIPS off.** | Partial | **R-06** |
|
||||
| ASD-Approved Cryptographic Protocols (AACP) | ISM-1139*, ISM-1453* | Use approved protocols (TLS 1.2+/1.3). | EAP-TLS, NVMe/TCP & log transport currently plaintext on LAN. | Partial | R-04, R-06, R-07 |
|
||||
| Key protection / non-exportability | ISM-0501*, ISM-1091* | Protect keys in hardware where feasible. | EAP-TLS private key **non-exportable in TPM**; UKI signing keys in HSM/CI. | Implemented | — |
|
||||
| ASD-Approved Cryptographic Protocols (AACP) | ISM-1139*, ISM-1453* | Use approved protocols (TLS 1.2+/1.3). | EAP-TLS; **management-plane agent stream is mTLS 1.2+** (fleet CA) — encrypted, unlike NVMe/TCP & log transport which remain plaintext on the LAN. | Partial | R-04, R-06, R-07 |
|
||||
| Key protection / non-exportability | ISM-0501*, ISM-1091* | Protect keys in hardware where feasible. | EAP-TLS **and management-plane** private key **non-exportable in TPM** (one key, three uses); UKI signing keys in HSM/CI. The **fleet CA (step-ca)** that signs host/agent certs is a **connected-system** trust anchor — its key protection is assessed there. | Implemented (host) / R-14 (CA) | R-14 |
|
||||
| Certificate lifecycle | ISM-1091*, ISM-0501* | Managed cert issuance/renewal. | Host identity cert minted at enrolment (step-ca, into TPM NV). The manager's **agent-plane server cert auto-renews** from the co-located CA (`step ca renew --daemon` + in-process hot-reload) — preserves SANs, no 90-day lapse. Server-side = connected system. | Partial (connected system) | R-14 |
|
||||
| Cryptographic equipment / module evaluation | ISM-0467*, ISM-1277* | Use evaluated crypto modules. | TPM 2.0 hardware; module-evaluation status to confirm; FIPS module not enabled. | Partial | R-06 |
|
||||
|
||||
### 2.10 Guidelines for ICT Equipment & Media
|
||||
@@ -178,7 +190,7 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M).
|
||||
| **R-02** | `debug_shell=true` bakes a **passwordless root shell** on a tty — an undocumented privileged access path. | 2.5 (ISM-1491*) | Possible × Severe → **High** | Lab-only; physical access required; isolated environment. | Set `debug_shell=false` for all non-lab builds; assert absent in CI. | **Low** | Platform team — *before production* |
|
||||
| **R-03** | STIG hardening **applied + bench-verified** (2026-06-20, all REVIEW rows triaged); residual is ensuring **CI ships `enable_stig=true`** for production builds (it was toggled off during bring-up). | 2.3 (ISM-1407*/1408*) | Possible × Moderate → **Medium** | Tailoring + register + per-build report; SELinux/fapolicyd/USBGuard on; verified on a real hardened build. | Confirm `ENABLE_STIG="true"` in CI; keep the 0-eval guard; re-run the deviations report on the assessed build. | **Low** | Platform team — *before assessment* |
|
||||
| **R-04** | NVMe/TCP **root traffic plaintext** on the LAN — exposure of OS content / injection if the segment is compromised. | 2.9 (ISM-1139*/1453*) | Unlikely × Major → **Medium** | Isolated boot LAN; MACsec on the access link (once R-01 done) protects the first hop; dm-verity makes injected root blocks fail verification (integrity preserved even if confidentiality isn't). | Production: TLS/IPsec for the storage transport, or rely on MACsec end-to-end across the fabric; document the decision. | **Low–Medium** | Platform/network — *production* |
|
||||
| **R-05** | **NOPASSWD key-only admin**; no per-host or rotatable access model; human admin MFA on the mgmt plane not enforced. | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, **now SELinux-confined to `staff_u`** with sudo role/type elevation, fully audited; no interactive password to attack. | Per-host/rotatable access (SSH CA / config-agent) + admin MFA on the management plane. | **Low–Medium** | Platform team — *roadmap* |
|
||||
| **R-05** | **NOPASSWD key-only host admin**; no per-host or rotatable access model. (Management-plane admin MFA is now **enforced** via WebAuthn passkeys — v0.3; residual is the *host SSH* access model.) | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, **SELinux-confined to `staff_u`** with sudo role/type elevation, fully audited; no interactive password to attack. Mgmt-plane admin actions now behind a phishing-resistant passkey. | Per-host/rotatable **host** access (SSH CA / config-agent); the mgmt-plane MFA clause is now met. | **Low–Medium** | Platform team — *roadmap* |
|
||||
| **R-06** | **FIPS off**; ISM **AACA/AACP conformance not formally asserted**; crypto-module evaluation status unconfirmed. | 2.9 (ISM-0471*/1139*/1277*) | Possible × Major → **Medium** | Modern STIG crypto policy; non-exportable TPM key; no deliberately weak algorithms. Acceptable at OFFICIAL; **mandatory to resolve for PROTECTED+.** | Assert AACA/AACP conformance; decide on FIPS module / evaluated crypto alongside Secure Boot signing. | **Low** (at appropriate classification) | Security + Platform — *before PROTECTED+* |
|
||||
| **R-07** | Audit-log **offload plaintext** on both hops (host→collector, collector→Wazuh) and **log retention/archival not yet configured** — logs interceptable in transit and retention/tamper-protection undefined. (**SIEM analysis + alerting now in place** — Wazuh, decode+alert bench-verified 2026-06-27; transport bench-verified 2026-06-20.) | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Low–Medium** | Isolated/mgmt LAN; disk-assisted queue (no loss on collector/SIEM blip); auditd local copy; per-source files; **Wazuh now actively decodes + alerts** (`fleet_audit`). | rsyslog **TLS** (gtls/6514) on both hops + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); configure Wazuh **retention/archival** (ISM) + rotate default creds + restrict dashboard/API. | **Low** | Platform/SecOps — *production* |
|
||||
| **R-13** | **Boot-chain signing not yet the standing default** — signing + Secure Boot enforcement are **verified end-to-end on real hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22, SB enforcing, `db` enrolled), but unsigned/SB-OFF lab builds remain possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not every host has `db` enrolled, so a substituted artifact could still boot on an un-enrolled/unsigned host. | 2.10 (ISM-1542*/0312*) | Unlikely × Major → **Low** | Capability **proven on hardware**; dm-verity rejects a tampered *root* regardless; isolated lab LAN. | Make signed + Secure-Boot-on the **default for all production builds/hosts**: keep `SB_KEY`/`SB_CERT` CI secrets set; enrol `db` fleet-wide; treat an unsigned build as lab-only. | **Low** | Platform team — *before production* |
|
||||
@@ -187,6 +199,8 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M).
|
||||
| **R-10** | **Patch SLA undefined** — no documented cadence for rebuilding/redeploying against new CVEs. | 2.5 (ISM-1144*/1472*) | Likely × Moderate → **Medium** | Atomic rebuild/redeploy model makes patching fast & uniform; reproducible builds. | Define patch cadence & critical-CVE SLA; automate rebuild triggers. | **Low** | Platform team — *roadmap* |
|
||||
| **R-11** | **No remote attestation** of TPM boot measurements — a compromised boot can't be detected centrally. | 2.10 (ISM-1542*) | Unlikely × Major → **Low–Medium** | Secure Boot + signed UKI + dm-verity already prevent unsigned/modified boot; measurements exist locally. | Implement remote attestation (TPM quote → verifier) gating network/storage access. | **Low** | Platform team — *roadmap* |
|
||||
| **R-12** | **No formal Incident Response Plan**; volatile RAM overlay complicates live forensics. | 2.1, 2.6 (ISM-0043*/0109*) | Possible × Moderate → **Medium** | Inherent recovery (reboot → verified state; revert digest); centralised logs survive host wipe. | Author IRP + log-retention + break-glass/forensics procedure suited to stateless hosts. | **Low** | SecOps — *before production* |
|
||||
| **R-14** | **Management/enrolment plane is a connected system, built but not yet separately authorised.** The host now trusts + streams to the fleet-manager and enrols via the broker + **step-ca fleet CA** (a new trust anchor + key material); the broker exposes **one unauthenticated pre-auth CSR endpoint**; InfluxDB holds telemetry + a bearer token. These server-side controls (CA key protection, broker hardening, admin-plane exposure, data-at-rest) are outside this SSP's boundary. | 2.4, 2.5, 2.6, 2.9 (ISM-1546*/1483*/0501*) | Possible × Moderate → **Medium** | Host-side exposure is limited: **outbound-only** mTLS, TPM-bound identity, **read-only** telemetry (no user data), and **commands logged-not-applied in v1**. Broker enrolment is human-anchored (OOB fingerprint + admin mTLS approve; no auto-approval). Mgmt-plane admin UI behind passkey MFA. | Author a **separate SSP/authorisation** for the management plane (CA key protection, broker endpoint hardening + rate-limiting, InfluxDB token/data protection, admin-plane network exposure); TLS everywhere is already in place on this plane. | **Low–Medium** | Platform/SecOps — *before the mgmt plane is production* |
|
||||
| **R-15** | **Deferred agent reconcile actuators.** v1 receives `DesiredState`/`Command` but **only logs** them; when actuation (apply `kiosk_url` / `reboot` / drain) is built, `fleet-agent` becomes a **remote host-mutation channel**. | 2.5 (ISM-1483*/1509*) | Rare × Moderate → **Low** (v1) | v1 performs **no host mutation** — the receive path is inert; the command authZ path (server-authoritative desired-state, verified over mTLS) is designed but not exercised. | At build time, assess the command/authZ path (who can set desired-state, replay/authZ, blast radius of `reboot`/drain) before enabling actuators; keep it behind an explicit flag. | **Low** | Platform team — *at actuator build* |
|
||||
|
||||
### 3.1 Risk heatmap (post-treatment targets)
|
||||
|
||||
@@ -196,10 +210,10 @@ Likelihood ↓
|
||||
Certain
|
||||
Likely R-10 R-01*
|
||||
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
|
||||
R-11 R-09
|
||||
Rare
|
||||
Rare R-15
|
||||
( * = currently High pre-treatment; all targeted to Low after treatment )
|
||||
```
|
||||
|
||||
@@ -208,10 +222,14 @@ Low–Medium. The AO is asked to authorise on the basis that **R-01, R-02, R-08,
|
||||
R-12 and R-13** (and confirming R-03's `enable_stig=true`) are **closed before
|
||||
production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 are either accepted with
|
||||
compensating controls (isolated-LAN lab) or carried on the POA&M with target
|
||||
dates. Newly strengthened in v0.2: device control (USBGuard, now Implemented),
|
||||
admin SELinux confinement (R-05), STIG applied+verified (R-03 reduced), and
|
||||
**Secure Boot signing verified enforcing on real hardware (R-13 reduced
|
||||
Medium→Low)**.*
|
||||
dates. Newly recorded in v0.3: **R-14** (the management/enrolment plane is a
|
||||
connected system requiring its own authorisation) and **R-15** (deferred agent
|
||||
actuators — a future remote-mutation channel, inert in v1). Newly *strengthened*
|
||||
in v0.3: **human-admin MFA on the management plane** via WebAuthn passkeys
|
||||
(improves R-05's MFA clause), and **application control made effective on the
|
||||
overlay root** (the 2026-07-04 fapolicyd `watch_fs` fix). Carried from v0.2:
|
||||
USBGuard device control, admin SELinux confinement, STIG applied+verified, and
|
||||
Secure Boot signing verified enforcing on real hardware (R-13 Medium→Low).*
|
||||
|
||||
---
|
||||
|
||||
@@ -238,6 +256,18 @@ Medium→Low)**.*
|
||||
PXE/iPXE SB-OFF cmdline workaround is a no-op once SB is on.
|
||||
8. **Witness device control** (USBGuard): a USB mass-storage device is blocked and
|
||||
logged, console HID still works.
|
||||
9. **Scope the management plane (R-14).** Confirm whether the server-side
|
||||
fleet-manager / enrol-broker / step-ca / InfluxDB are assessed under a **separate
|
||||
authorisation**, and that the host↔plane boundary (outbound-only mTLS, TPM
|
||||
identity, telemetry-only v1) is as described. Witness: an agent mTLS session is
|
||||
rejected for a serial not in inventory; the broker will not sign without the OOB
|
||||
fingerprint + admin-mTLS approval; the admin UI requires a passkey.
|
||||
10. **Confirm application control is effective on the root** (post-2026-07-04 fix):
|
||||
on an enforcing build, a **novel non-RPM binary in `/tmp` is denied** (fapolicyd
|
||||
`FAN_DENY`) — proving the `overlay` `watch_fs` entry took (earlier builds silently
|
||||
did not guard the overlay root).
|
||||
11. **Confirm actuators are inert (R-15):** the assessed build's `fleet-agent`
|
||||
**logs but does not apply** desired-state/commands (telemetry-only v1).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+107
-27
@@ -15,11 +15,11 @@
|
||||
|---|---|
|
||||
| System name | Diskless Fleet Boot System ("the fleet") |
|
||||
| Document type | System Security Plan (SSP) |
|
||||
| Version | 0.2 (DRAFT — pre-assessment) |
|
||||
| Date | 2026-06-27 |
|
||||
| Version | 0.3 (DRAFT — pre-assessment) |
|
||||
| Date | 2026-07-10 |
|
||||
| Author / maintainer | System Owner (delegate) |
|
||||
| Status | Lab / pre-production. STIG hardening applied + bench-verified; **UEFI Secure Boot signing verified enforcing on real hardware** (VM HTTP + EliteBook 820 PXE); secure path (802.1X/MACsec) not yet bench-verified. |
|
||||
| Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) |
|
||||
| Status | Lab / pre-production. STIG hardening applied + bench-verified; **UEFI Secure Boot signing verified enforcing on real hardware** (VM HTTP + EliteBook 820 PXE); secure path (802.1X/MACsec) not yet bench-verified. **v0.3 adds the fleet-management plane** (on-host `fleet-agent` telemetry over TPM-mTLS — bench-verified on hardware 2026-07-10; server-side manager with **WebAuthn-passkey** admin MFA + TPM enrolment broker) and July hardening changes (three local SELinux modules; fapolicyd overlay exec-guard fix). |
|
||||
| Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [Fleet-management design](fleet-management-design.md); [TPM enrolment design](tpm-enrolment-design.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) |
|
||||
|
||||
> **Currency caveat (read before relying on control numbers).** This SSP and its
|
||||
> annex are drafted against the ISM structure as understood to a January 2026
|
||||
@@ -45,14 +45,20 @@ decision.
|
||||
|
||||
**In scope:** the boot chain (UEFI HTTP boot → signed UKI → dracut initramfs →
|
||||
network authentication → encrypted root mount → verified read-only OS), the
|
||||
runtime workstation OS image, the host's cryptographic identity (TPM), and the
|
||||
hardening baseline applied to the image.
|
||||
runtime workstation OS image, the host's cryptographic identity (TPM), the
|
||||
hardening baseline applied to the image, and the **on-host `fleet-agent`** (the
|
||||
in-image telemetry client that streams host health to the management plane over
|
||||
TPM-mTLS — new in v0.3; `files/agent/`).
|
||||
|
||||
**Out of scope (separately authorised):** the lab serving stack (DHCP/HTTP/
|
||||
NVMe-oF target, audit collector — `serving/`), the 802.1X authenticator/RADIUS
|
||||
infrastructure, the certificate enrolment authority, the network fabric, and
|
||||
physical security of the facility. These are dependencies referenced here but
|
||||
carry their own security documentation and authorisation.
|
||||
**Out of scope (separately authorised — connected systems):** the lab serving
|
||||
stack (DHCP/HTTP/NVMe-oF target, audit collector — `serving/`); the **server-side
|
||||
fleet-management plane** (`serving/fleet-manager/` — the control-plane hub, its
|
||||
admin web UI, the InfluxDB metrics store, and the Traefik ingress); the **TPM
|
||||
enrolment broker** (`serving/enrol-broker/`) and the **step-ca fleet certificate
|
||||
authority** it signs against; the 802.1X authenticator/RADIUS infrastructure; the
|
||||
network fabric; and physical security of the facility. These are dependencies the
|
||||
host connects to and are described here where the host interacts with them, but
|
||||
each carries its own security documentation and authorisation.
|
||||
|
||||
## 2. System description
|
||||
|
||||
@@ -104,18 +110,28 @@ manifest. One commit = one release = one digest = one manifest entry.
|
||||
7. `switch-root` into the real system; NetworkManager renews the DHCP lease so
|
||||
the network-mounted root stays alive; the host derives its NVMe `hostnqn` from
|
||||
the TPM certificate serial and (kiosk builds) launches the locked-down kiosk.
|
||||
8. **Post-boot management (v0.3):** the hardened **`fleet-agent`** service dials
|
||||
the management plane and identifies with the **same TPM key/cert** used for
|
||||
802.1X and the NVMe `hostnqn` ("one identity, three stages"), streaming host
|
||||
health over mTLS. It is telemetry-only in v1 — it receives desired-state /
|
||||
commands but **only logs** them (no host mutation). It is a
|
||||
**post-switch-root** component only (not in the initramfs).
|
||||
|
||||
### 2.3 Authorisation boundary
|
||||
|
||||
The authorisation boundary is the **running workstation host**: the UKI, the
|
||||
initramfs and its custom 802.1X/MACsec dracut module, the verified erofs root,
|
||||
the tmpfs overlay, the TPM-backed identity, and the hardened OS configuration.
|
||||
The boundary's external interfaces are: (a) the **access link** (802.1X-controlled
|
||||
port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c)
|
||||
**outbound audit log** forwarding; (d) the **kiosk application** endpoint the
|
||||
browser renders; (e) the **boot transport** (UEFI HTTP boot, or PXE/TFTP→iPXE)
|
||||
that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuard
|
||||
(§5.2). Each crossing is a control point addressed in Annex A.
|
||||
the tmpfs overlay, the TPM-backed identity, the hardened OS configuration, and the
|
||||
in-image **`fleet-agent`**. The boundary's external interfaces are: (a) the
|
||||
**access link** (802.1X-controlled port, MACsec-encrypted); (b) the **NVMe/TCP**
|
||||
root storage session; (c) **outbound audit log** forwarding; (d) the **kiosk
|
||||
application** endpoint the browser renders; (e) the **boot transport** (UEFI HTTP
|
||||
boot, or PXE/TFTP→iPXE) that delivers the UKI; (f) **USB/peripheral** ports, now
|
||||
mediated by USBGuard (§5.2); and (g) the **management plane** — an *outbound-only*
|
||||
mTLS gRPC session from `fleet-agent` to the fleet-manager (`manager.fleet.lab:9600`,
|
||||
across the OPNsense L3 hop from the host VLAN to the management network; no inbound
|
||||
host port), authenticated with the host's TPM identity cert. Each crossing is a
|
||||
control point addressed in Annex A.
|
||||
|
||||
### 2.4 Data and classification
|
||||
|
||||
@@ -127,7 +143,16 @@ that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuar
|
||||
CI/HSM-held and **never enter the build VM or the repository**.
|
||||
- **Data in transit:** access link protected by **MACsec**; NVMe/TCP root and
|
||||
audit-log offload currently **plaintext on an isolated lab LAN** (see residual
|
||||
risk R-04, R-07 in Annex A).
|
||||
risk R-04, R-07 in Annex A). The **management-plane** telemetry stream is
|
||||
**mTLS-encrypted** (fleet CA) end-to-end — unlike the NVMe/audit hops, it is not
|
||||
plaintext.
|
||||
- **Management telemetry (v0.3):** the agent sends host **health** (load/cpu/temp/
|
||||
memory/tmpfs, uptime, NVMe-root state, 802.1X/MACsec + SELinux state, chrony
|
||||
clock health, kiosk state) and **inventory facts** (image version, kernel,
|
||||
cert-serial). This is **operational metadata, not user data**, and it carries **no
|
||||
per-host secret** (identity is the reused TPM cert; the private key never leaves
|
||||
the TPM). It is retained server-side (InfluxDB) — a **connected-system**
|
||||
responsibility (see §1, R-14).
|
||||
- **Data at rest in the overlay:** ephemeral, RAM-only, discarded at power-off.
|
||||
|
||||
The **system's data classification governs this SSP's marking and the required
|
||||
@@ -164,8 +189,27 @@ This system follows the ISM A&A lifecycle:
|
||||
Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M
|
||||
source).
|
||||
|
||||
**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-27)**. The system
|
||||
is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA
|
||||
**Current status:** Pre-assessment **DRAFT (v0.3, updated 2026-07-10)**. The system
|
||||
is functional in a lab configuration.
|
||||
|
||||
**Progress since v0.2 (2026-07-10):** the **fleet-management plane** was built and,
|
||||
on the host side, **bench-verified end-to-end on real hardware** (`comet-beacon-54`):
|
||||
the in-image **`fleet-agent`** streams health to the server over **mTLS using the
|
||||
reused TPM identity** (telemetry-only — desired-state/commands are received but only
|
||||
logged, zero host mutation). The server-side manager adds a **WebAuthn-passkey**
|
||||
(phishing-resistant, origin-bound) admin plane — the first **human-admin MFA** on the
|
||||
management plane — and a **TPM enrolment broker** (unauthenticated pre-auth CSR → OOB
|
||||
fingerprint → admin-mTLS approve → step-ca sign) that births the per-host inventory
|
||||
record. Its server-side server cert now **auto-renews** from the co-located CA. Host
|
||||
hardening also advanced: **three local SELinux policy modules** were added (all
|
||||
documented deviations — [stig-deviations.md](stig-deviations.md) #14–16), and a
|
||||
**material fapolicyd finding was fixed (2026-07-04)** — the app-allow-listing exec
|
||||
guard was effectively *inactive on the overlay root* (`overlay` missing from
|
||||
`watch_fs`) and is now enforcing across the whole root (see §5.2). The server-side
|
||||
plane (manager/broker/InfluxDB/step-ca) is a **connected system** pending its own
|
||||
authorisation (R-14).
|
||||
|
||||
**Progress since v0.1 (2026-06-20):** DISA
|
||||
STIG hardening applied and **bench-verified** on a real build (kiosk, key-only SSH,
|
||||
audit offload, DHCP-named host, NVMe/verity root all good with hardening on);
|
||||
USBGuard device control enabled + verified; admin confined to `staff_u`; **UEFI
|
||||
@@ -201,7 +245,18 @@ summarises the implementation posture.
|
||||
|
||||
### 5.2 Protect
|
||||
- **Identity & authentication:** mutual **802.1X EAP-TLS** with a
|
||||
**non-exportable TPM key**; network access controlled at the switch port.
|
||||
**non-exportable TPM key**; network access controlled at the switch port. The
|
||||
**management-plane agent reuses the same TPM identity** for mTLS to the
|
||||
fleet-manager (one non-exportable key backs 802.1X, the NVMe `hostnqn`, and the
|
||||
agent stream — "one identity, three stages"), and the server keys inventory off
|
||||
the verified cert serial, so a host can only ever report as itself.
|
||||
- **Management-plane admin MFA (v0.3):** the server-side admin web UI enforces
|
||||
**WebAuthn passkeys** — phishing-resistant, origin-bound, hardware-backed
|
||||
MFA — behind a Traefik ingress that terminates a Let's Encrypt cert (DNS-01).
|
||||
This is the first human-admin MFA in the system (the manager/broker admin planes
|
||||
are a **connected system**, but the control is noted here as it closes the
|
||||
long-standing "no admin MFA" gap for management operations; host SSH admin
|
||||
remains key-only — R-05).
|
||||
- **Link protection:** **MACsec** on the access link.
|
||||
- **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable
|
||||
cmdline**; **dm-verity** read-only root (continuous cryptographic integrity);
|
||||
@@ -221,7 +276,21 @@ summarises the implementation posture.
|
||||
SSH**, root login disabled, hardened ciphers/MACs/KEX (note: STIG crypto policy
|
||||
drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked
|
||||
browser, no shell/navigation); **postfix removed** (no local MTA — audit-failure
|
||||
notification rides the remote offload, not email).
|
||||
notification rides the remote offload, not email). Three **local SELinux policy
|
||||
modules** are shipped for architecture-specific allows, each a documented
|
||||
deviation ([stig-deviations.md](stig-deviations.md) #14–16): `chrony_dhcp_dispatcher`
|
||||
(NM-dispatcher chrony reload), `fapolicyd_tmpfs` (statfs the overlay upper), and
|
||||
`fleet_agent_chronylog` (agent reads chrony's tracking log). No blanket
|
||||
permissive domains; SELinux stays enforcing.
|
||||
- **Application-control effectiveness fix (2026-07-04):** a bench finding showed
|
||||
fapolicyd's exec guard was **effectively inactive on the root** — the diskless
|
||||
root is `overlayfs`, which was **absent from fapolicyd's `watch_fs`**, so
|
||||
fapolicyd marked no mounts and intercepted no exec on `/`, `/usr` or `/tmp`.
|
||||
Adding `overlay` to `watch_fs` turned app-allow-listing **on for the whole
|
||||
overlay root for the first time** — verified: a novel non-RPM binary in `/tmp`
|
||||
is now denied (`FAN_DENY`), nothing legitimate broke. (Residual: path-based trust
|
||||
doesn't catch a *modified trusted path* via copy-up — tracked as TODO #15 /
|
||||
the `integrity=sha256` option — noted in stig-deviations #15.)
|
||||
- **Privileged access:** single **key-only `wheel` admin**, root locked. The admin
|
||||
is now **confined to the `staff_u` SELinux user** with a sudoers role/type
|
||||
transition (`sysadm_r`/`sysadm_t`) so privilege elevation re-labels the SELinux
|
||||
@@ -252,7 +321,16 @@ summarises the implementation posture.
|
||||
2026-06-27** (`wazuh-logtest` + a live `sudo` surfacing `USER_CMD`/`CRED_*` in
|
||||
the dashboard). Config is declarative (`serving/siem/`, mounted via
|
||||
`/wazuh-config-mount`).
|
||||
- *Gaps:* both offload hops (host→collector, collector→Wazuh) currently
|
||||
- **Fleet health telemetry (v0.3):** the `fleet-agent` streams host health
|
||||
(resource pressure, NVMe-root/auth/SELinux/chrony/kiosk state, uptime) to the
|
||||
manager over mTLS; the manager persists a time-series (InfluxDB) and flags
|
||||
version-drift, SELinux-permissive, and reboot-pending in an admin dashboard. This
|
||||
is an **operational-health** signal (complements the auditd/Wazuh **security-event**
|
||||
path — different purpose, different data): e.g. a host reporting `SELinux:
|
||||
permissive` or overlay RAM exhaustion is surfaced for the operator. The store and
|
||||
dashboard are a **connected system** (server-side); the host-side contribution is
|
||||
the read-only probe + mTLS stream.
|
||||
- *Gaps:* both audit-offload hops (host→collector, collector→Wazuh) currently
|
||||
**plaintext** (TLS planned, R-07); **log retention/archival** (ISM) not yet
|
||||
configured; default Wazuh credentials to be rotated before production.
|
||||
|
||||
@@ -275,9 +353,9 @@ AO will expect a maturity view:
|
||||
| Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. |
|
||||
| Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. |
|
||||
| User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. |
|
||||
| Restrict admin privileges | **Partial** | Single key-only `wheel` admin, root locked, now **SELinux-confined to `staff_u`** with a sudo role/type transition (NOPASSWD, see R-05); no per-host/rotatable model or admin-MFA yet. |
|
||||
| Restrict admin privileges | **Partial** | Single key-only `wheel` admin, root locked, now **SELinux-confined to `staff_u`** with a sudo role/type transition (NOPASSWD, see R-05); **management-plane** admin actions now require a **WebAuthn passkey** (v0.3). Still no per-host/rotatable *host* access model. |
|
||||
| Patch operating systems | **Process** | Same rebuild/redeploy model; immutable image means uniform, atomic OS updates. |
|
||||
| Multi-factor authentication | **Strong (machine)** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). Human admin MFA to be addressed for the management plane. |
|
||||
| Multi-factor authentication | **Strong (machine) + admin passkey** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). **Human admin MFA is now enforced on the management-plane UI via WebAuthn passkeys** (phishing-resistant, origin-bound) — v0.3. Host SSH admin remains key-only (no separate human MFA — R-05). |
|
||||
| Regular backups | **N/A (by design)** | Diskless, stateless hosts hold nothing to back up; recoverability is re-provisioning from a pinned digest. Backup applies to serving-side state, out of scope here. |
|
||||
|
||||
## 7. Residual risk summary
|
||||
@@ -292,12 +370,14 @@ production ATO:
|
||||
| R-02 | `debug_shell=true` bakes a **passwordless root shell** on a tty. | **High** | Treat — must be removed before production (lab-only). |
|
||||
| R-03 | STIG hardening **applied + bench-verified** (2026-06-20); residual is that CI ships **unsigned, Secure Boot OFF** and `enable_stig` must be confirmed on for production builds. | **Medium** | Treat — confirm `enable_stig=true` in CI + enforce signing/SB before assessment. |
|
||||
| R-04 | NVMe/TCP root traffic **plaintext** on the lab LAN. | **Medium** | Accept (isolated LAN) / treat for production. |
|
||||
| R-05 | NOPASSWD key-only admin (now `staff_u`-confined); no per-host/rotatable access model or admin-MFA. | **Medium** | Accept with compensating controls / roadmap. |
|
||||
| R-05 | NOPASSWD key-only **host** admin (now `staff_u`-confined); no per-host/rotatable access model. (Management-plane admin MFA now enforced via WebAuthn passkeys — v0.3.) | **Medium** | Accept with compensating controls / roadmap. |
|
||||
| R-06 | **FIPS off**; ASD-approved-cryptography conformance not yet asserted. | **Medium** | Treat — required for PROTECTED+; revisit with signing. |
|
||||
| R-07 | Audit-log offload **plaintext** on both hops (host→collector, collector→Wazuh); **log retention not yet configured**. SIEM **analysis + alerting now in place** (Wazuh, decode+alert bench-verified 2026-06-27). | **Low–Medium** | Treat — TLS both hops + ISM retention/archival + rotate Wazuh creds before production. |
|
||||
| R-08 | **Placeholder RADIUS CA** in the repo; real trust anchor not yet provisioned. | **Medium** | Treat — environment-specific, before production. |
|
||||
| R-09 | Host **firewall disabled** (workstation). | **Low–Medium** | Accept (isolated LAN) / treat with explicit allow-rules. |
|
||||
| R-13 | **Boot-chain signing not yet the standing default.** Signing + Secure Boot enforcement **verified end-to-end on hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22), but unsigned/SB-OFF lab builds are still possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not all hosts have `db` enrolled. | **Low** | Treat — make signed + Secure-Boot-on the default for all production builds/hosts (set CI secrets; enrol `db` fleet-wide). |
|
||||
| R-14 | **Management/enrolment plane is a connected system, built but not yet separately authorised.** The host now trusts + streams to the fleet-manager and enrols via the broker/**step-ca fleet CA** (a new trust anchor + key material) whose one **unauthenticated pre-auth CSR endpoint** is a new server-side surface. Host-side exposure is limited (outbound-only mTLS, read-only telemetry, commands logged-not-applied in v1). | **Medium** | Treat — separate SSP/authorisation for the management plane (CA key protection, broker endpoint hardening, InfluxDB token/data, admin-plane exposure). |
|
||||
| R-15 | **Deferred agent reconcile actuators.** v1 receives `DesiredState`/`Command` but only logs them; when actuation (apply kiosk_url / reboot / drain) is built, the agent becomes a **remote host-mutation channel** needing re-assessment. | **Low** (v1) | Treat at build time — assess the command/authZ path before enabling actuators. |
|
||||
|
||||
Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A.
|
||||
|
||||
|
||||
@@ -248,6 +248,47 @@ surface rather than detecting abuse of it. Requires a write primitive to a root-
|
||||
path either way (already-privileged context), which is fapolicyd's defense-in-depth
|
||||
threat model — not an unprivileged bypass.
|
||||
|
||||
## 16. Local SELinux policy module `fleet_agent_chronylog` (added 2026-07-10)
|
||||
|
||||
Not a STIG-rule waiver — a **minimal local read-only `allow` module** so the
|
||||
`fleet-agent` telemetry client can read chrony's tracking log under enforcing
|
||||
SELinux. Source: `files/stig/fleet-agent-chronylog.te`; built + `semodule -i`'d in
|
||||
the 2c-STIG provisioner (after #15).
|
||||
|
||||
**The rules:** `allow unconfined_service_t chronyd_var_log_t:file { open read
|
||||
getattr };` (+ `dir { search getattr open }` to reach the file).
|
||||
|
||||
**Why:** `fleet-agent` reports clock health (source/stratum/offset) by reading
|
||||
`/var/log/chrony/tracking.log` — deliberately a FILE read rather than a query to
|
||||
chronyd's command interface, which the hardened agent can't use (owner-write-only
|
||||
unix socket; STIG's `cmdport 0` closes the UDP port). But chrony's log dir + files
|
||||
are labeled `chronyd_var_log_t`, which the shipped policy confines to `chronyd_t` +
|
||||
log-admin roles. So although the file is mode `0644`, under enforcing the agent
|
||||
(`unconfined_service_t`) is **denied** by type enforcement — DAC allows, TE does
|
||||
not. Verified 2026-07-10 on `comet-beacon-54`: with SELinux **permissive** a
|
||||
non-root read of `tracking.log` succeeds; flip to **enforcing** and even a
|
||||
STIG-confined admin (`staff_t`) gets `Permission denied` (`ls -Z` on the file is
|
||||
itself blocked) — the signature of a type-enforcement block, not a DAC one.
|
||||
|
||||
**Scope:** read-only (`open`/`read`/`getattr`) on one log type + dir search to
|
||||
reach it. No write, no other chrony access, and — unlike the earlier abandoned
|
||||
approach — **no domain transition** (the agent reads a file; it does not exec
|
||||
`chronyc`). This replaces the never-shipped `fleet_agent_chronyc` nnp-transition
|
||||
module (that whole command-socket path was dropped for reading the log instead).
|
||||
|
||||
**This is the SELinux half of a two-layer requirement.** `/var/log/chrony` is
|
||||
mode `0750 chrony:chrony` (others have no access), so DAC also blocks the agent's
|
||||
`DynamicUser` from traversing the dir — a denial that produces **no AVC** (it's
|
||||
DAC, not TE). The DAC half is therefore the **`chrony` supplementary group** on
|
||||
`fleet-agent.service` (traverse the `0750` dir; the log file itself is `0644`).
|
||||
Both this module and that group are needed; verified 2026-07-10 — with the group
|
||||
but no module the read is TE-denied, and with the module but no group it's
|
||||
DAC-denied (silently, no AVC — which is what made this the hardest layer to spot).
|
||||
**BENCH-VERIFY:** (a) the manager's Monitor chrony row populates on an enforcing
|
||||
boot; (b) no other chrony-log AVC (extend here if one appears); (c)
|
||||
**reproducibility** — `semodule -i` rebuilds the policy store, re-check verity hash
|
||||
stability (same concern as #14/#15). Only shipped when `enable_stig=true`.
|
||||
|
||||
## Things that are NOT deviations (kept / enforced)
|
||||
|
||||
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
||||
|
||||
@@ -146,6 +146,18 @@ pinned for mTLS — one trust anchor across 802.1X, NVMe, and management.
|
||||
everything else on. Build the broker standalone first (fastest path to the state
|
||||
machine), but design it to fold into the mgmt service with no rewrite — same Go
|
||||
style, same step-ca anchor, same data model.
|
||||
- **AS-BUILT (2026-07): folded in "alongside", not merged.** The
|
||||
`serving/fleet-manager/` control plane drives the standalone broker's admin
|
||||
plane over mTLS (broker admin CA) rather than absorbing its code: the broker
|
||||
stays the enrolment **state machine + step-ca signer**, and gained an
|
||||
`Accept: application/json` approve response so the manager gets the decided
|
||||
enrolment back and **births the inventory record**, recording the human
|
||||
(passkey-authenticated) admin as approver. `verify-serving.sh` / browser
|
||||
callers are unchanged (still get the 204/redirect). The public
|
||||
`/enrol/request|status` edge is untouched. This keeps the one unauth,
|
||||
stdlib-only, reproducible pre-auth parser isolated while giving the manager
|
||||
the authenticated authorise pillar — same trust anchor, same data model, no
|
||||
rewrite. See `serving/fleet-manager/README.md` (Authorise pillar).
|
||||
- **host-api is not in the enrolment path** (it is loopback/post-boot; the host has
|
||||
no cert during enrolment). The tie is downstream: the fleet-manager **agent** (the
|
||||
push sibling of `fleet-hostapi`) authenticates with the *enrolled* cert over mTLS,
|
||||
@@ -200,11 +212,13 @@ fleet-manager agent) — one language, one build/posture story. Transport is spl
|
||||
- **Steady-state agent ↔ manager (telemetry + reconciliation) → gRPC over mTLS,
|
||||
host-dialed.** Streaming fits here, and it stays compatible with "no inbound port on
|
||||
hosts" because the **host dials out** and holds one long-lived mTLS stream (health
|
||||
up / desired-state down). OPEN FORK (a fleet-manager build decision, not enrolment):
|
||||
bespoke gRPC vs reuse **OTLP-gRPC for metrics + Wazuh for events** + a thin gRPC
|
||||
control stream — the latter gets VictoriaMetrics/Grafana/Wazuh for free. `Connect`
|
||||
(connectrpc.com) is an option if one protobuf IDL serving gRPC + JSON + browser is
|
||||
wanted across the authenticated surface.
|
||||
up / desired-state down). ~~OPEN FORK~~ **RESOLVED (2026-07):** a **bespoke gRPC
|
||||
bidi stream** (`serving/fleet-manager/proto/fleet_agent.proto` —
|
||||
`FleetAgent.Session`, health/metrics up + desired-state down), over the same
|
||||
reused TPM cert (fleet CA). Metrics land in **InfluxDB** via the manager (host
|
||||
holds no Influx credential), not OTLP→VictoriaMetrics; events stay on the Wazuh
|
||||
path. One stream, no polling, no inbound host port. (The on-host agent — the
|
||||
gRPC report/reconcile client that pairs with `fleet-hostapi` — is still to build.)
|
||||
- **host-api stays HTTP/JSON — locked**, because its consumer is the Chromium kiosk
|
||||
(a browser, which cannot speak native gRPC).
|
||||
|
||||
|
||||
@@ -0,0 +1,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 ./... )
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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 => ../
|
||||
@@ -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=
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
@@ -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
@@ -43,10 +43,12 @@ Keep `nvmet_traddr` (UKI) == `NVME_HOST` (this stack's `.env`).
|
||||
|
||||
```bash
|
||||
cd serving
|
||||
cp .env.example .env # edit IMAGE_VERSION, BOOT_IFACE, SERVER_IP, DHCP range
|
||||
# config = committed defaults.env + your .env overrides, layered (or use `make`):
|
||||
export COMPOSE_ENV_FILES=defaults.env,.env
|
||||
printf 'IMAGE_VERSION=<ver>\nBOOT_IFACE=<iface>\nSERVER_IP=<ip>\n' > .env # + DHCP range
|
||||
docker login git.bitasylum.org
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
docker compose up -d --build # or simply: make up
|
||||
docker compose ps # or: make ps
|
||||
```
|
||||
|
||||
`IMAGE_VERSION` must match the built tag (e.g. a branch build is
|
||||
|
||||
@@ -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).
|
||||
IMAGE_VERSION=0.0.0-2eefd97
|
||||
@@ -72,6 +84,13 @@ NVME_HOST=nvme.fleet.lab
|
||||
# Remote audit collector name. Host-generic — bake into the image's rsyslog
|
||||
# forward rule so every host offloads auditd here; resolved to this serving host.
|
||||
AUDIT_HOST=audit.fleet.lab
|
||||
# Fleet-manager AGENT plane name. Baked into the image (files/agent/agent.env
|
||||
# FLEET_AGENT_MANAGER) so every enrolled host dials the control plane here;
|
||||
# resolved per-environment to the manager's fleet-LAN address (this serving host
|
||||
# now; a routable mgmt address once the manager moves to k8s). Just another
|
||||
# *.fleet.lab like the boot/nvme/audit names. Must match the agent server cert SAN
|
||||
# (server-pki.sh AGENT_SANS).
|
||||
MGMT_HOST=manager.fleet.lab
|
||||
|
||||
# --- NVMe/TCP target -------------------------------------------------------
|
||||
# Per-version subsystem NQN base. The long-lived target serves each rootfs
|
||||
@@ -29,8 +29,9 @@
|
||||
# docker cp $id:/artifacts/. ./artifacts/$IMAGE_VERSION/; docker rm $id
|
||||
# Private registry: docker login git.bitasylum.org first so the image pulls.
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env # then edit BOOT_IFACE / SERVER_IP / DHCP range
|
||||
# Usage (config = committed defaults.env + your .env overrides, layered):
|
||||
# export COMPOSE_ENV_FILES=defaults.env,.env # or use the Makefile (make up)
|
||||
# printf 'BOOT_IFACE=...\nSERVER_IP=...\n' > .env # per-LAN overrides + secrets
|
||||
# docker compose up -d
|
||||
# =============================================================================
|
||||
|
||||
@@ -104,6 +105,9 @@ services:
|
||||
# Host-generic remote audit collector name (baked into the image config so
|
||||
# every host offloads auditd here; resolved per-environment to this host).
|
||||
- --address=/${AUDIT_HOST}/${SERVER_IP}
|
||||
# Fleet-manager AGENT plane (:9600) — baked into the image so enrolled hosts
|
||||
# dial the control plane by name; resolved here like the other fleet names.
|
||||
- --address=/${MGMT_HOST}/${SERVER_IP}
|
||||
# Kiosk is LOCAL-ONLY (loads boot.fleet.lab), so sinkhole Chromium's
|
||||
# call-home to 0.0.0.0 — telemetry can't leave the lab even if the browser
|
||||
# tries. Defense-in-depth behind the in-image Chromium policy/flags; in
|
||||
@@ -264,6 +268,14 @@ services:
|
||||
# HOST clock (the clock isn't namespaced) — intended: the serving host is the
|
||||
# lab time source. Drop SYS_TIME (and add `chronyd -x`) if the host already
|
||||
# runs its own NTP and you only want this to relay.
|
||||
#
|
||||
# FIREWALL (easy to miss — same trap as the audit collector's 514/tcp): host
|
||||
# networking means Docker does NOT open the port; the serving host's own
|
||||
# firewall governs 123/udp. Without a rule, workstations get 192.168.50.10 as
|
||||
# their NTP server (DHCP opt 42) but their packets are dropped — chrony logs
|
||||
# "Added source … / unreachable" and never syncs. Open it on the boot-LAN NIC:
|
||||
# ufw: sudo ufw allow in on <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) ---------------------------
|
||||
# rsyslog receiver: booted diskless hosts (whose RAM-only overlay root loses
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
services:
|
||||
# --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) --------
|
||||
step-ca:
|
||||
image: smallstep/step-ca:latest
|
||||
image: harbor.bitasylum.org/dockerhub/smallstep/step-ca:latest
|
||||
network_mode: host
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
|
||||
# --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) ---
|
||||
freeradius:
|
||||
image: freeradius/freeradius-server:latest
|
||||
image: harbor.bitasylum.org/dockerhub/freeradius/freeradius-server:latest
|
||||
network_mode: host # RADIUS 1812/1813 from the authenticator
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
|
||||
@@ -71,6 +71,14 @@ FP=$(openssl x509 -in "$PKI/root_ca.crt" -noout -fingerprint -sha256 | cut -d= -
|
||||
echo " root fingerprint: $FP"
|
||||
echo " copy to: files/etc/dot1x/radius-ca.pem (image trust anchor)"
|
||||
|
||||
# Also export the INTERMEDIATE: leaf certs (host TPM identities, RADIUS, the
|
||||
# fleet-manager server cert) are signed by it, so the fleet-manager agent-plane
|
||||
# CLIENT CA must be root+intermediate (server-pki.sh reads it via
|
||||
# FLEET_CA_INTERMEDIATE). Best-effort — a single-tier CA has none.
|
||||
incca cp /home/step/certs/intermediate_ca.crt /export/intermediate_ca.crt 2>/dev/null \
|
||||
&& echo " exported intermediate_ca.crt (-> server-pki.sh fleet-ca.crt bundle)" \
|
||||
|| echo " (no intermediate_ca.crt — single-tier CA)"
|
||||
|
||||
# --- 1b. raise the CA cert-duration claim (idempotent) ----------------------
|
||||
# A stock step-ca caps issued certs at 24h. Both the RADIUS server cert below
|
||||
# AND the enrol-broker (--not-after 720h) need longer-lived fleet certs, so the
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
#
|
||||
# Pin + mirror these base tags through your registry (like the rest of the stack)
|
||||
# if the build/serving host has no egress.
|
||||
FROM golang:1.23-alpine AS build
|
||||
FROM harbor.bitasylum.org/dockerhub/library/golang:1.23-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod .
|
||||
COPY *.go wordlist.txt ./
|
||||
ENV CGO_ENABLED=0 GOPROXY=off GOTOOLCHAIN=local
|
||||
RUN go build -trimpath -buildvcs=false -ldflags '-s -w -buildid=' -o /enrol-broker .
|
||||
|
||||
FROM smallstep/step-cli:latest
|
||||
FROM harbor.bitasylum.org/dockerhub/smallstep/step-cli:latest
|
||||
COPY --from=build /enrol-broker /usr/local/bin/enrol-broker
|
||||
# Non-root by default (the step-cli image runs as uid 1000 "step"); the broker
|
||||
# binds loopback ports >1024 and only READS the mounted CA root + provisioner
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -105,7 +106,7 @@ func (b *broker) handleApprove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("audit APPROVE id=%s cn=%q serial=%s hostnqn=%s admin=%q", e.ID, e.CN, e.CertSerial, e.Hostnqn, admin)
|
||||
redirectOrJSON(w, r)
|
||||
respondDecision(w, r, e)
|
||||
}
|
||||
|
||||
func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -117,17 +118,29 @@ func (b *broker) handleDeny(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
log.Printf("audit DENY id=%s cn=%q admin=%q", e.ID, e.CN, admin)
|
||||
redirectOrJSON(w, r)
|
||||
respondDecision(w, r, e)
|
||||
}
|
||||
|
||||
// redirectOrJSON sends a browser form back to the queue, or a 204 for API callers.
|
||||
func redirectOrJSON(w http.ResponseWriter, r *http.Request) {
|
||||
if isBrowser(r) {
|
||||
// respondDecision replies to an approve/deny. A browser form gets redirected back
|
||||
// to the queue; a caller that asks for JSON (Accept: application/json — e.g. the
|
||||
// fleet-manager driving the broker as its authorise plane) gets the decided
|
||||
// Enrolment (cert_serial, hostnqn, context, fingerprint — NOT the cert PEM, which
|
||||
// is unexported and only reaches the requesting host); any other API caller keeps
|
||||
// the original 204 (so verify-serving.sh and existing scripts are unaffected).
|
||||
func respondDecision(w http.ResponseWriter, r *http.Request, e *Enrolment) {
|
||||
switch {
|
||||
case isBrowser(r):
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
case wantsJSON(r):
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func wantsJSON(r *http.Request) bool {
|
||||
return strings.Contains(r.Header.Get("Accept"), "application/json")
|
||||
}
|
||||
|
||||
func isBrowser(r *http.Request) bool {
|
||||
a := r.Header.Get("Accept")
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# The image builds the SPA in its own stage and the Go stage embeds THAT dist —
|
||||
# keep host build artifacts, deps, secrets, and PKI out of the build context.
|
||||
ui/node_modules
|
||||
ui/dist
|
||||
node_modules
|
||||
/fleet-manager
|
||||
*.test
|
||||
.env
|
||||
pki/
|
||||
data/
|
||||
.git
|
||||
@@ -0,0 +1,6 @@
|
||||
/fleet-manager
|
||||
*.test
|
||||
# deployment secrets + issued PKI (never commit)
|
||||
.env
|
||||
/pki/
|
||||
/data/
|
||||
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
}
|
||||
Executable
+72
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
Executable
+59
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
Executable
+98
@@ -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"
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"))
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
+2108
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user