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