Merge pull request 'kiosk-app-separate' (#8) from kiosk-app-separate into main

Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/8
This commit is contained in:
audiobuzz
2026-06-26 00:25:05 +00:00
65 changed files with 1851 additions and 3118 deletions
+105
View File
@@ -0,0 +1,105 @@
#
# .gitea/workflows/dnsmasq-image.yaml
#
# Builds the serving-stack dnsmasq image (serving/dnsmasq) and pushes it to the
# registry, so serving hosts `docker compose pull` it instead of building it at
# deploy time (matching the SPDK image). The image is GENERIC: it carries no
# Secure Boot keys and no iPXE binary — the signed snponly.efi rides the separate
# fleet-ipxe image (ipxe-image.yaml), mounted into dnsmasq at /ipxe.
#
# 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 DNSMASQ_VERSION in serving/.env to the tag you want to run.
#
# Runs only when the dnsmasq image inputs change (path filter) or on demand.
#
# Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds).
#
name: Build dnsmasq image
run-name: ${{ gitea.actor }} builds the serving dnsmasq 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/dnsmasq/**"
- ".gitea/workflows/dnsmasq-image.yaml"
workflow_dispatch:
# One build per ref at a time; a newer push to the same ref cancels the in-flight.
concurrency:
group: dnsmasq-image-${{ gitea.ref }}
cancel-in-progress: true
env:
DNSMASQ_IMAGE: "git.bitasylum.org/audiobuzz/fleet-dnsmasq"
jobs:
dnsmasq-build:
name: Build & push dnsmasq 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 ${DNSMASQ_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 "${DNSMASQ_IMAGE}:${VER}" serving/dnsmasq
buildah push "${DNSMASQ_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 "${DNSMASQ_IMAGE}:${VER}" "${DNSMASQ_IMAGE}:latest"
buildah push "${DNSMASQ_IMAGE}:latest"
echo "Updated ${DNSMASQ_IMAGE}:latest" ;;
*)
echo "Dev branch build — :latest left untouched (run with DNSMASQ_VERSION=${VER})" ;;
esac
+24
View File
@@ -34,6 +34,30 @@ on:
- "**" # every branch — feature branches get a branch-name+sha tag
tags:
- "v*" # tag push -> clean semver tag (v1.2.3 -> 1.2.3)
# Skip this ~17-min VM build when a push touches ONLY things that are NOT
# rootfs/UKI build inputs. The image is built from diskless.pkr.hcl + http/
# (ks.cfg) + files/** + scripts/** — those are deliberately NOT listed here,
# so they always trigger. Everything below is runtime/serving infra, docs, or
# dev tooling that never lands in the rootfs. A commit that also touches a
# real build input still builds (paths-ignore only skips when EVERY changed
# path matches).
paths-ignore:
# Serving stack — served at runtime, built by its own image workflows /
# the kiosk-app submodule; none of it is baked into the rootfs.
- "serving/**"
- "serving/kiosk-app" # the submodule gitlink path (pointer bumps)
# Docs / notes / dev tooling — not build inputs.
- "docs/**"
- "*.md" # root README.md, CLAUDE.md, etc.
- ".devcontainer/**"
- ".gitignore"
- ".gitattributes"
- "LICENSE"
# Sibling image workflows build their OWN images, not the UKI. (Add new
# ones here; do NOT add gitea-main-ci.yaml — its edits must still build.)
- ".gitea/workflows/ipxe-image.yaml"
- ".gitea/workflows/dnsmasq-image.yaml"
- ".gitea/workflows/spdk-image.yaml"
# One build per ref at a time; a newer push to the same branch cancels the
# in-flight build (each build is a ~17-min VM run, so don't stack them).
+158
View File
@@ -0,0 +1,158 @@
#
# .gitea/workflows/ipxe-image.yaml
#
# Builds the fleet-ipxe image: the iPXE network bootloader (snponly.efi) for the
# PXE-only chainload path (firmware without UEFI HTTP boot, e.g. EliteBook 820
# G4), SIGNED with the fleet `db` key and packaged as a tiny FROM-scratch OCI
# image. The dnsmasq serving container mounts it at /ipxe and copies snponly.efi
# into its TFTP root — so the serving host never carries a loose, unsigned .efi
# and the bootloader is digest-pinned + signed exactly like the UKI.
#
# Kept separate from the (generic) dnsmasq image so the daemon image stays
# key-agnostic and this one is the only per-Secure-Boot-environment artifact.
# See scripts/build-ipxe-oci.sh, docs/secure-boot-keys.md, CLAUDE.md invariant #9.
#
# 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) -> "<ipxe-pkg-ver>" (+ :latest)
# - branch push (refs/heads/<x>) -> "<x>-<sha7>" (dev image, no :latest)
# - dispatch input always wins.
# Set IPXE_VERSION in serving/.env to the tag you want to run.
#
# Runs only when the iPXE inputs change (path filter) or on demand.
#
# Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds).
# Optional: SB_KEY, SB_CERT (db PRIVATE key + cert, PEM). Unset => UNSIGNED lab
# image (loud warning; Secure-Boot-enforcing hosts will reject it). PK/KEK
# never come near CI — only `db` signs (docs/secure-boot-keys.md).
#
name: Build fleet-ipxe image
run-name: ${{ gitea.actor }} builds the signed fleet-ipxe bootloader 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:
- "scripts/build-ipxe-oci.sh"
- ".gitea/workflows/ipxe-image.yaml"
workflow_dispatch:
inputs:
ipxe_version:
description: "Override the image tag (default: sanitized ipxe package version)"
required: false
concurrency:
group: ipxe-image-${{ gitea.ref }}
cancel-in-progress: true
env:
IPXE_IMAGE: "git.bitasylum.org/audiobuzz/fleet-ipxe"
jobs:
ipxe-build:
name: Build & push fleet-ipxe image
runs-on: ubuntu-latest
container:
image: git.bitasylum.org/audiobuzz/ubuntu-act-runner-qemu:main
steps:
-
name: Checkout
uses: actions/checkout@v6
-
name: Install tooling
# buildah (OCI build), sbsigntool (sbsign/sbverify — Debian singular), and
# the ipxe package which ships the prebuilt snponly.efi.
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends buildah sbsigntool ipxe
-
name: Locate snponly.efi + derive tag
id: src
run: |
# snponly.efi: UEFI x64 iPXE that rides the firmware's NIC driver (SNP),
# so it binds where iPXE's native driver won't (e.g. I219-LM). Ubuntu's
# ipxe package installs it under /usr/lib/ipxe.
SNP=$(find /usr/lib/ipxe /usr/share/ipxe -name 'snponly.efi' 2>/dev/null | head -1)
[ -n "$SNP" ] || { echo "snponly.efi not found in the ipxe package" >&2; exit 1; }
echo "snponly=$SNP" >> "$GITHUB_OUTPUT"
# Tag scheme (same as gitea-main-ci.yaml, with the ipxe pkg version as the
# main-branch identifier). :latest moves only on main / release tags.
short_sha="$(echo "${GITHUB_SHA}" | cut -c1-7)"
RAW=$(dpkg-query -W -f='${Version}' ipxe 2>/dev/null || echo unknown)
PKGVER=$(printf '%s' "$RAW" | sed 's/[^A-Za-z0-9_.-]/-/g')
push_latest=false
if [ -n "${{ inputs.ipxe_version }}" ]; then
VER="${{ inputs.ipxe_version }}"
elif [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then
VER="${GITHUB_REF#refs/tags/}"; VER="${VER#v}"; push_latest=true
elif [ "${GITHUB_REF}" = "refs/heads/main" ]; then
VER="$PKGVER"; push_latest=true
else
branch="${GITHUB_REF#refs/heads/}"
safe_branch=$(printf '%s' "$branch" \
| sed -e 's#[^A-Za-z0-9._-]#-#g' -e 's#^[.-]*##' | cut -c1-80)
[ -n "$safe_branch" ] || safe_branch="branch"
VER="${safe_branch}-${short_sha}"
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "push_latest=$push_latest" >> "$GITHUB_OUTPUT"
echo "Using snponly.efi=$SNP tag=$VER (ipxe pkg $RAW, latest=$push_latest)"
-
name: Materialize Secure Boot signing material
# Same db key that signs the UKI; written to a runner-temp file and passed
# to build-ipxe-oci.sh as a path. If SB_KEY is unset this no-ops and the
# build produces an UNSIGNED (lab) image with its loud warning.
if: ${{ secrets.SB_KEY != '' }}
env:
SB_KEY: ${{ secrets.SB_KEY }}
SB_CERT: ${{ secrets.SB_CERT }}
run: |
umask 077
d="$RUNNER_TEMP/sb"; mkdir -p "$d"
printf '%s\n' "$SB_KEY" > "$d/db.key"
printf '%s\n' "$SB_CERT" > "$d/db.crt"
echo "SB_KEY_FILE=$d/db.key" >> "$GITHUB_ENV"
echo "SB_CERT_FILE=$d/db.crt" >> "$GITHUB_ENV"
echo "Secure Boot signing material staged (signed fleet-ipxe build)."
-
name: Registry login
run: |
buildah login \
--username "${{ secrets.PACKAGE_USER }}" \
--password "${{ secrets.PACKAGE_TOKEN }}" \
git.bitasylum.org
-
name: Build & push
env:
STORAGE_DRIVER: vfs
run: |
VER="${{ steps.src.outputs.version }}"
bash scripts/build-ipxe-oci.sh \
"${{ steps.src.outputs.snponly }}" \
"${IPXE_IMAGE}" \
"$VER" \
"${SB_KEY_FILE:-}" \
"${SB_CERT_FILE:-}" \
true
# main / release tags also move :latest; dev branches never clobber it.
if [ "${{ steps.src.outputs.push_latest }}" = "true" ]; then
buildah tag "${IPXE_IMAGE}:${VER}" "${IPXE_IMAGE}:latest"
buildah push "${IPXE_IMAGE}:latest"
echo "Updated ${IPXE_IMAGE}:latest"
else
echo "Dev branch build — :latest left untouched (run with IPXE_VERSION=${VER})"
fi
-
name: Shred Secure Boot signing key
if: always()
run: shred -u "$RUNNER_TEMP/sb/db.key" 2>/dev/null || rm -f "$RUNNER_TEMP/sb/db.key" 2>/dev/null || true
+43 -10
View File
@@ -9,15 +9,25 @@
# Runs only when the SPDK image inputs change (path filter) or on demand — not
# on every commit. Bump SPDK_VERSION (or use the manual run input) to roll SPDK.
#
# Builds off ANY branch (dev branches welcome); the SPDK release (SPDK_VERSION /
# the dispatch input) is the --build-arg and the base tag. To avoid clobbering
# the main image, dev branches push "<spdk-ver>-<branch>-<sha7>" and never move
# :latest:
# - tag / main push -> "<spdk-ver>" (+ :latest)
# - branch push -> "<spdk-ver>-<branch>-<sha7>" (dev image, no :latest)
# Set SPDK_VERSION in serving/.env to the tag you want to run.
#
# Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds).
#
name: Build SPDK NVMe-oF image
run-name: ${{ gitea.actor }} builds the SPDK NVMe/TCP target image
run-name: ${{ gitea.actor }} builds the SPDK NVMe/TCP target image (${{ gitea.ref_name }})
on:
push:
branches:
- "main"
- "**" # every branch — dev branches get a disambiguated tag
tags:
- "v*"
paths:
- "serving/spdk/**"
- ".gitea/workflows/spdk-image.yaml"
@@ -28,9 +38,14 @@ on:
required: false
default: "v24.09"
# One (slow) build per ref at a time; a newer push to the same ref cancels it.
concurrency:
group: spdk-image-${{ gitea.ref }}
cancel-in-progress: true
env:
SPDK_IMAGE: "git.bitasylum.org/audiobuzz/spdk-nvmf"
SPDK_VERSION: "v24.09" # default tag for push-triggered builds
SPDK_VERSION: "v24.09" # default SPDK release for push-triggered builds
jobs:
spdk-build:
@@ -66,12 +81,30 @@ jobs:
STORAGE_DRIVER: vfs
BUILDAH_ISOLATION: chroot
run: |
VER="${{ inputs.spdk_version || env.SPDK_VERSION }}"
echo "Building ${SPDK_IMAGE}:${VER}"
# SPDK release: the --build-arg + base tag (dispatch input wins).
SVER="${{ inputs.spdk_version || env.SPDK_VERSION }}"
short_sha="$(echo "${GITHUB_SHA}" | cut -c1-7)"
# main / release tags -> "<spdk-ver>" + :latest; dev branches ->
# "<spdk-ver>-<branch>-<sha7>", no :latest (so they don't clobber main).
if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ] || [ "$GITHUB_REF" = "refs/heads/main" ]; then
TAG="$SVER"; push_latest=true
else
branch="${GITHUB_REF#refs/heads/}"
safe_branch=$(printf '%s' "$branch" \
| sed -e 's#[^A-Za-z0-9._-]#-#g' -e 's#^[.-]*##' | cut -c1-80)
[ -n "$safe_branch" ] || safe_branch="branch"
TAG="${SVER}-${safe_branch}-${short_sha}"; push_latest=false
fi
echo "Building ${SPDK_IMAGE}:${TAG} (SPDK $SVER, latest=$push_latest)"
buildah bud \
--build-arg SPDK_VERSION="$VER" \
-t "${SPDK_IMAGE}:${VER}" \
-t "${SPDK_IMAGE}:latest" \
--build-arg SPDK_VERSION="$SVER" \
-t "${SPDK_IMAGE}:${TAG}" \
serving/spdk
buildah push "${SPDK_IMAGE}:${VER}"
buildah push "${SPDK_IMAGE}:latest"
buildah push "${SPDK_IMAGE}:${TAG}"
if [ "$push_latest" = "true" ]; then
buildah tag "${SPDK_IMAGE}:${TAG}" "${SPDK_IMAGE}:latest"
buildah push "${SPDK_IMAGE}:latest"
echo "Updated ${SPDK_IMAGE}:latest"
else
echo "Dev branch build — :latest left untouched (run with SPDK_VERSION=${TAG})"
fi
+5
View File
@@ -9,3 +9,8 @@ keys
/files/secureboot/keys/*.auth
/files/secureboot/keys/*.esl
/files/secureboot/keys/*.crt
# iPXE network bootloader binaries for the PXE fallback (serving/tftp). Binary,
# and the signed one is db-derived/environment-specific — supply per environment.
#/serving/tftp/*.efi
/serving/tftp/*.kpxe
+3
View File
@@ -0,0 +1,3 @@
[submodule "serving/kiosk-app"]
path = serving/kiosk-app
url = git@git.bitasylum.org:audiobuzz/fleet-kiosk-app.git
+20 -3
View File
@@ -33,9 +33,16 @@ files/plymouth/themes/fleet/ Custom boot splash (scripted security
files/kiosk/ weston+Chromium kiosk + chromium lockdown policy
scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline
scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image
scripts/build-ipxe-oci.sh sbsign snponly.efi -> FROM-scratch fleet-ipxe image
serving/ Lab serving stack: dnsmasq + nginx + SPDK
(dnsmasq + fleet-ipxe + SPDK images are
CI-built + pulled; see .gitea/workflows/)
serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA
(kiosk endpoint; nginx reverse-proxies /kiosk/)
— GIT SUBMODULE (own repo + CI; builds
the fleet-kiosk image the stack pulls).
OS-side kiosk runtime stays in files/kiosk/
serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS +
hostapd authenticator + TPM enrolment
.gitea/workflows/ Gitea CI: lint -> packer build -> push OCI
docs/ Design PDF and supporting notes
```
@@ -128,8 +135,13 @@ instead of working around it.
SHA256SUMS}`. `rootfs.erofs` (erofs data) and `rootfs.hash` (dm-verity hash
tree) are **separate** namespaces because the as-built Rocky 9 systemd does
not support the in-file `hash-offset` verity layout. No shell, no tools, no
base image. Serving software (SPDK, nginx) lives in separate, independently
versioned images — never merge them into the artifact image.
base image. Serving software (SPDK, nginx, dnsmasq) lives in separate,
independently versioned images — never merge them into the artifact image.
The signed iPXE bootloader (`snponly.efi`) is its own FROM-scratch
**`fleet-ipxe`** image (`scripts/build-ipxe-oci.sh`, signed with `db`), mounted
into dnsmasq at `/ipxe` — kept out of *both* the artifact image and the
(generic, key-agnostic) dnsmasq image because its signature is
per-Secure-Boot-environment. Don't bake `snponly.efi` into the dnsmasq image.
1. **Deployments are pinned by digest.** One commit per fleet release in
the deployment manifest; that manifest is the single source coupling
UKI ↔ namespace. Rollback = revert the commit.
@@ -300,6 +312,11 @@ authenticator + FreeRADIUS on a veth/bridge (bridge needs
`group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port
`Authorized`, macsec0 addressed, NVMe namespace connected, verity root
mapped, overlay writable, supplicant alive after switch-root.
This harness is scaffolded in **`serving/dot1x-bench/`** (step-ca as the fleet
CA on the serving host + FreeRADIUS EAP-TLS + a `hostapd` wired authenticator +
`tpm-provision.sh`), with an **isolated bridge per workstation** (the flat VLAN
can't authenticate — EAPOL is dropped and MACsec is point-to-point). See its
README; several configs there are still BENCH-VERIFY.
Any change to the dracut module, supplicant config, or cmdline must pass
this before merge. Template-only changes need `packer validate` +
+208 -5
View File
@@ -572,13 +572,67 @@ this item builds the rollout machinery around it.
stay served); security rollback via dbx-revoke / TPM monotonic counter
(design §6).
- **Drain & retire** an old version only when SPDK/nvmet reports zero connected
controllers on it.
controllers on it. **Drain accuracy:** account for hosts powered off mid-rollout
— an assigned-but-offline host will reconnect to a version that may be gone, so
don't remove a version while any host that could return is still assigned to it.
- **Availability**: run NVMe/TCP target replicas behind distinct discovery
entries (design §7) — never serve the whole fleet from one unreplicated node,
especially mid-rollout (N versions + reboot bursts).
- Note: kiosk *content* changes are server-side (the website) and need no fleet
update — only OS/UKI/rootfs changes require a reboot.
### SPDK restart / availability is the load-bearing constraint (analysis 2026-06-25)
SPDK sits in the **live data path of every running host's root fs**, so a process
restart isn't like restarting a stateless service — it tears down **every**
NVMe/TCP association at once (fleet-wide, NOT version-scoped). The rollout design
must be built around **never restarting the process that holds live connections.**
- **Core principle:** a host changes root version *only by rebooting* (no live
swap). So: **version transitions = reboots; SPDK changes = additive/online; the
process stays up.** Routine rollouts never restart SPDK.
- **Online add/remove via JSON-RPC, no restart.** `nvmf_tgt` is fully
runtime-configurable (`bdev_aio_create`, `nvmf_create_subsystem`,
`nvmf_subsystem_add_ns`, `..._add_listener`). Add vY's namespaces live (existing
vX connections untouched); remove vX live once drained. **Per-version subsystem
(distinct NQN)** matters here: adding vY as a *new subsystem* never disturbs the
subsystem vX hosts are attached to — adding a *namespace* to a shared subsystem
fires a namespace-change AER + rescan at running hosts. Per-version subsystem
also gives clean per-release drain + per-release hostNQN allow-listing.
- **What a connection loss does to a running host:** root is **read-only erofs +
tmpfs overlay**. tmpfs writes (RAM) + page-cached reads keep working; **cold
reads block**. The nvme-tcp host driver reconnects (`reconnect_delay`) until
`ctrl_loss_tmo`. **Fast SPDK restart** (same NQN/UUID/addr within the window) →
host **survives with a stall**; **long/changed/expired** → root I/O errors, host
needs a reboot ("keep alive timeout / no route to host"). Read-only +
content-addressed makes reconnect unusually clean (no write/reservation state).
- **Cheap host-side mitigation:** tune `ctrl_loss_tmo` (even `-1` = retry forever)
+ `reconnect_delay` at `nvme connect` time (dracut nvmf step). For a diskless box
"hang and recover" beats "die" — there's nothing better to fail to.
- **Structural answer — HA + NVMe-oF multipath (ANA):** run ≥2 targets serving
**byte-identical, same-UUID** namespaces; hosts multipath across them, so one
target can be restarted/upgraded while the other serves (no root loss). The
**read-only, content-addressed root makes replication trivially coherent** (no
write-coherence problem) — this architecture is ideally suited to it. Wrinkle:
the **immutable UKI cmdline** points at one discovery address, so multipath needs
discovery to return multiple paths (or a DNS name resolving to several targets).
- **Planned SPDK restarts only via drain/failover** (multipath fail-over, or a
maintenance window with generous `ctrl_loss_tmo` so hosts recover not die). There
is **no zero-impact single-node SPDK restart.**
- **Implementation gap (current lab):** SPDK runs as a container with
`spdk-setup.sh` as entrypoint + the artifact image mounted → effectively
**single-version, configured at container start**, so changing versions =
container restart = drop all connections. The work here is a **long-lived target
+ an RPC-driven control path** ("add release / remove release / drain status")
decoupled from container lifecycle, with per-version subsystems.
- **Capacity:** each concurrently-served version costs target memory/hugepages +
file-backed bdev overhead — plan for N live versions (current + previous +
canary) during long rollouts.
- **Attach gating + discovery:** with several subsystems advertised, the dracut
connect + per-subsystem hostNQN allow-list must ensure a vX host attaches **only**
vX's namespace (the by-UUID verity ref protects *what mounts*, but gate the
*attach* too); `rd.nvmf.discover` selection must be deterministic, not connect-all.
## [ ] 7. Harden / evaluate the NVMe-oF target (serving-node resilience)
There is **no production-grade memory-safe NVMe-oF target** — the Rust NVMe work
@@ -607,7 +661,13 @@ export is read-only + single-namespace = inherently small attack surface.
- **Network-isolate :4420** so only the boot VLAN reaches the target (compose
with 802.1X/MACsec segmentation in prod).
- **Replicas** behind distinct discovery entries (availability is the real risk
— it's the fleet's disk). Ties into #6 (rolling updates) availability.
— it's the fleet's disk). The target is a **fleet-wide SPOF in every running
host's root data path**; a process restart drops *all* NVMe/TCP associations at
once. Structural fix = **HA + NVMe-oF multipath (ANA)** over byte-identical,
same-UUID, read-only namespaces (replication is coherent precisely because the
root is read-only + content-addressed); cheap mitigation = generous host
`ctrl_loss_tmo`/`reconnect_delay`. See the **"SPDK restart / availability"**
analysis under #6 (rolling updates) — same constraint, fuller treatment.
- Revisit a memory-safe target only if/when an off-the-shelf one matures
(e.g. a future Rust-for-Linux `nvmet`).
@@ -620,12 +680,86 @@ smoke test.
- **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`).
Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0).
- **Bench harness scaffolded 2026-06-22: [serving/dot1x-bench/](../serving/dot1x-bench/).**
step-ca (fleet CA, on the serving host) signs each host's **TPM-generated CSR**
+ issues the RADIUS server cert; FreeRADIUS does EAP-TLS (trusts step-ca root,
returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an
**isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped +
MACsec is P2P). `enrol/tpm-provision.sh` creates the key in the TPM at
`0x81010001` + writes the cert to NV `0x01800001`. Starting solution **signs a
CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the
documented upgrade on the same CA. **BENCH-VERIFY** items flagged in the files:
hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version.
PKI product decision (greenfield): start step-ca; OpenBao/EJBCA/Dogtag later.
- **First step (DONE 2026-06-22): `enrol/tpm-selftest.sh`** — non-destructive TPM
capability check (run on the booted open-port image, no `disable_dot1x` needed)
to de-risk the TPM half before the full path. Likely first finding: no openssl
TPM2 **provider** in the image (`tpm2-tss` ≠ the provider) — add `tpm2-openssl`.
- **Enrolment of the per-host identity** (key@`0x81010001` + cert@NV) is its own
design — see **#11** (TOFU + OOB fingerprint broker, pre-auth permit-set,
provisioning UKI). `tpm-provision.sh` is the manual bench stand-in for it.
- **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot
by the serving dnsmasq — drop the hardcoded `192.168.50.10`.
- **Signing:** wire `sb_key`/`sb_cert` (CI/HSM secrets) for a signed UKI; enable
Secure Boot on targets. Until then the loud unsigned warning must stay.
- **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM
(HTTP boot, 2026-06-20).** CI signs the UKI when `SB_KEY`/`SB_CERT` secrets are
set (`db` key); `fleet-sb-enroll` enrols db→KEK→PK from the baked public `.auth`
files; OVMF then boots the signed UKI under enforcing Secure Boot. Confirmed
end-to-end: CI-signed UKI verifies (`sbverify`), enrolment succeeds, VM boots
with `mokutil --sb-state` = enabled.
**Key generation + enrolment procedure: [secure-boot-keys.md](secure-boot-keys.md)**
(the PK/KEK/db trio; `db` = `sb_key`/`sb_cert`, PK/KEK stay offline).
- **Bug fixed during bring-up (2026-06-20):** `fleet-sb-enroll` built its 4-byte
EFI attribute prefix in a bash variable (`ATTR=$'\x27\x00\x00\x00'`). Bash
strings can't hold NULs → only `0x27` survived → efivarfs read a corrupt
attribute u32 → every write failed with `EINVAL` ("dd: Invalid argument").
Now emitted directly with `printf` at write time. **Baked fix VERIFIED on the
820 (2026-06-22): `sudo fleet-sb-enroll` enrolled db→KEK→PK cleanly, no inline
workaround.** (Red herring on the way:
Proxmox EFI disks default to `pre-enrolled-keys=1`, so `mokutil --db` shows
the MS cert even in Setup Mode — looked like a non-empty-db rejection but was
the NUL bug all along; it failed identically on a clean `pre-enrolled-keys=0`
disk.)
- **iPXE signing WIRED (2026-06-21):** `snponly.efi` is now built + signed
(`db`) + pushed by CI as a separate FROM-scratch **`fleet-ipxe`** image
(`scripts/build-ipxe-oci.sh`, `.gitea/workflows/ipxe-image.yaml`), mounted into
dnsmasq at `/ipxe`. dnsmasq itself moved to a generic CI-built/pulled image
(`.gitea/workflows/dnsmasq-image.yaml`) — no longer built at deploy time, and
carries no keys/binary. (Decision: separate `fleet-ipxe` image, not baked into
dnsmasq, so the daemon image stays key-agnostic across SB environments.)
- **820 / PXE signed chain — BENCH-VERIFIED 2026-06-22.** Real hardware (HP
EliteBook 820 G4), Secure Boot **enforcing** (`mokutil --sb-state` = enabled,
Setup Mode no, db/KEK/PK enrolled): firmware verifies the signed `snponly.efi`
(fleet-ipxe image) → signed UKI and boots. Confirms the whole `fleet-ipxe`
signing pipeline + the PXE→iPXE→UKI chain under SB on real silicon (the VM only
covered the HTTP path, which has no iPXE stage).
- **REMAINING for production:** (a) **secure path** — re-verify signed boot with
`disable_dot1x=false` (802.1X/MACsec), not just the test path; (b) confirm
signing is reproducible (verity hash stable across two signed builds).
- **PXE-only iPXE fallback — BENCH-VERIFIED on the EliteBook 820 G4 (2026-06-20).**
Firmware without UEFI HTTP boot chainloads `serving/tftp/snponly.efi` → renders
`autoexec.ipxe` (ifopen + DHCP-retry + `imgargs <real cmdline>`) → UKI over HTTP
→ NVMe/verity root. dnsmasq tags `pxe`/`ipxe`/`httpboot` give graceful per-client
fallback; HTTP-boot hosts are unaffected (firmware → UKI direct). Gotchas that
cost real time (all captured in [../serving/tftp/README.md](../serving/tftp/README.md)):
(i) the I219-LM needs **`snponly.efi`** (native iPXE driver won't bind);
(ii) **PortFast/edge on the switch port is REQUIRED** — iPXE's PHY bounce
triggers STP forwarding delay that drops DHCP (this was the real blocker);
(iii) iPXE aborts the script on any unguarded command failure (use `||`);
(iv) Secure Boot OFF makes systemd-stub take iPXE's load options as the cmdline,
so the entrypoint extracts the UKI's real `.cmdline` and feeds it via `imgargs`.
**Tidied 2026-06-20** once PortFast was identified: dropped the `autoexec.ipxe` +
`ifopen` + DHCP-retry STP workaround; `boot.ipxe` is now just
`dhcp`+`imgfetch`+`imgargs`+`boot` reached via normal iPXE autoboot. Re-verified
on the 820 G4. **SB-on simplification:** when Secure Boot is enabled the cmdline
injection is unnecessary (the stub ignores the loader cmdline) — collapse
`boot.ipxe` to a plain `chain` of the UKI and drop the `.cmdline` extraction +
`binutils` + the artifact mount from the dnsmasq service.
- **Sign iPXE for the PXE-only fallback (Secure Boot, production).** The lab
`snponly.efi` is **UNSIGNED** → that path needs **Secure Boot OFF**. For
production: `sbsign` the iPXE binary with the fleet **db** key (only `db` needed —
no MS CA / shim), so SB stays enforced firmware → iPXE → UKI; then the cmdline
contraption becomes a no-op (the stub ignores loader cmdline under SB). See
[secure-boot-keys.md](secure-boot-keys.md) "PXE-only hosts".
- **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS,
`disable_dot1x=false`) — the items deferred this session:
- NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA
@@ -695,7 +829,9 @@ full ~17-min Packer build.
Speed up bringing up new workstations: bake the **public** PK/KEK/db certs into an
image, detect Secure Boot **Setup Mode** at boot, and auto-enroll them so a
fresh/reset box adopts the fleet trust root without a manual firmware-setup visit.
Pairs with #8 (signing) + [secure-boot-keys.md](secure-boot-keys.md).
Pairs with #8 (signing) + [secure-boot-keys.md](secure-boot-keys.md). Shares the
provisioning-UKI + isolated-environment vehicle with **#11** (which enrols the
*network* identity); build them together.
**Tooling gap (found 2026-06-20): `efitools` is NOT packaged for EL9** — no
`efi-updatevar`/`efi-readvar`/`KeyTool`. Resolved for the OS-side path by writing
@@ -755,3 +891,70 @@ enrolls.
reasonable for a controlled fleet; the highest-assurance path is attended/firmware
or manufacturer pre-provisioning. Default recommendation: **separate provisioning
UKI + isolated VLAN + read-back verify.**
## [ ] 11. (DESIGN) TPM 802.1X identity enrolment (TOFU + OOB fingerprint)
How a host gets its **802.1X EAP-TLS client identity** the first time — the key at
TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come
from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot*
PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI +
isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**,
[serving/dot1x-bench/](../serving/dot1x-bench/); OpenBao/EJBCA/Dogtag later).
**Chosen pattern — trust-on-first-use with out-of-band fingerprint approval:**
on an empty TPM the host generates a key *in* the TPM, builds a CSR, submits it to
an enrolment **broker**, and shows a **fingerprint** on the Plymouth splash; an
admin visually cross-checks that fingerprint against the broker's pending-list and
approves; the broker has step-ca sign the CSR and returns the cert; the host writes
it to NV `0x01800001` and proceeds to authenticate. Sound, recognised pattern
(SSH/WireGuard/Signal-style OOB verification); good fit for invariant #1.
**Key design decisions (settled 2026-06-22):**
- **The pre-auth permit-set already exists — enrolment rides in it.** The firmware
can't do 802.1X, so the UKI fetch (DHCP/DNS/TFTP/HTTP to the serving host) is
**always pre-auth**; the secure model therefore *requires* a pre-auth permit-set
(low-impact 802.1X / pre-auth dACL / boot→secure dynamic VLAN; RADIUS returns the
secure VLAN on Access-Accept). The enrolment endpoint is just **another entry in
that set** — expose it as an `/enrol/` location on the **existing serving nginx**,
reverse-proxied to the broker. No new network mechanism.
- **The broker is the only new component.** step-ca signs but has no human-approval
queue: broker = `POST CSR + context → PENDING + fingerprint → admin approves →
step-ca signs → host polls → cert`. Exposed to *unauthenticated* devices by
design → harden: OOB-fingerprint gate (no auto-sign), rate-limit, time-boxed
pending, HTTPS (image trusts the serving CA), pre-auth permit scoped to only
these services + the serving host.
- **Trigger on EMPTY-NV only — split the three events:** empty NV → enrol (human);
valid key + expired/near-expiry cert → **renew** automatically (no human, it
already holds a trusted key); auth fails with a present cert → **alert + retry,
do NOT re-enrol** (auto-reenrol on failure would mask revocation and churn keys
on a transient RADIUS outage). Re-enrol after the first time = explicit admin
action (clear/flag the host).
- **Fingerprint: what it proves + entropy.** It proves "this CSR came from *this
physical device*" (approver reads the screen) — it does NOT prove TPM residency.
Pair it with **TPM attestation** (step-ca ACME `device-attest-01`, EK/AK) for the
residency proof: fingerprint = human authorises the device, attestation = the key
is really in a TPM (together → strong SSP R-01 story). Size for entropy — a short
hex fp is grindable; use a word-list/BIP39-style encoding (reads short, more
bits) + rate-limiting + a server nonce. Show device context (asset/port/location)
so the admin knows *what* they're approving.
- **Pre-auth traffic has no MACsec (plaintext at L2).** Fine for enrol (CSR/cert
are public; OOB fingerprint gives authenticity) — and it's exactly **why the UKI
must be Secure-Boot-signed** (#8): integrity of the boot comes from the signature,
not the network. Enrolment design + Secure Boot are two halves of the same
"pre-auth path is hostile" assumption.
- **Run it in a provisioning UKI/mode, not the production initramfs** — the boot
hook ordering is load-bearing (invariant #2); don't park it on a multi-minute
human-approval wait. Shares the #10 provisioning-UKI vehicle.
**Bench caveat:** `hostapd` wired is strictly port-based (no pre-auth per-flow
ACL) — before EAP success it passes nothing, so the bench can't reproduce
"boot + enrol pre-auth on the same port." Model the pre-auth state with a separate
provisioning bridge/VLAN on the rig; the in-place low-impact pre-auth permit is a
real-switch feature to validate on the 820's switch.
**First step DONE (2026-06-22):** `serving/dot1x-bench/enrol/tpm-selftest.sh` — a
non-destructive TPM capability check (device, key persist, NV roundtrip, openssl
TPM2 provider, CSR signed by the TPM key) to de-risk the TPM half before any of
this. Expected gap: the image installs `tpm2-tss` but no openssl TPM2 **provider**
(`tpm2-openssl`) — add it to the build once the self-test confirms.
+38 -1
View File
@@ -4,7 +4,7 @@ Records where the as-built implementation diverges from
`diskless-fleet-design-v2.pdf`. The PDF remains authoritative for the
architecture; this note captures deltas discovered bringing the build up on
**Rocky 9.8** until they are folded into a consolidated **v3**. Last updated
2026-06-15.
2026-06-22.
The architecture is unchanged: 802.1X EAP-TLS with a TPM-held key, MACsec on the
access link, dm-verity-protected erofs root over NVMe/TCP, signed UKI, and the
@@ -57,6 +57,43 @@ indices shift with the transient discovery controller.
[../serving/spdk-setup.sh](../serving/spdk-setup.sh) (2nd bdev/namespace), and
CLAUDE.md invariant #9.
## 1c. Design-level addition — pre-auth boot path & per-host identity enrolment
Two design points the PDF leaves implicit; both are load-bearing for the secure
model and are recorded here pending v3.
**The boot path is inherently pre-auth, so a pre-auth permit-set is mandatory.**
The firmware (UEFI HTTP boot / PXE→iPXE) fetches the UKI **before** any OS or
supplicant exists — there is no firmware 802.1X supplicant — so the UKI/initramfs
fetch always happens in the unauthenticated state. The secure model therefore
requires the authenticator to permit a *scoped* set of services pre-auth
(DHCP/DNS + TFTP + HTTP to the serving host) and only complete 802.1X once the
**OS** is running; RADIUS returns the secure VLAN on Access-Accept, after which
DHCP-for-the-running-system and the NVMe/TCP root ride **`macsec0`** (post-auth,
encrypted). Mechanism on a real switch: low-impact 802.1X / a pre-auth dACL /
boot→secure dynamic VLAN. Consequences:
- Pre-auth traffic has **no MACsec** (plaintext at L2). Integrity of the boot
therefore comes from **Secure Boot** (the signed UKI + signed `fleet-ipxe`), not
the network — this is *why* signing matters: the boot transport is untrusted.
- `hostapd` wired (the bench authenticator) is strictly port-based and can't model
a pre-auth per-flow permit — the in-place pre-auth ACL is a real-switch feature,
validated on the 820's switch, not the VM rig (see `serving/dot1x-bench/`).
**Per-host 802.1X identity is created by an enrolment flow, never baked in**
(invariant #1). Chosen pattern: **trust-on-first-use with out-of-band fingerprint
approval** — on an empty TPM the host generates a key *in* the TPM
(`0x81010001`), submits a CSR to an enrolment **broker** (reachable via the same
pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker →
step-ca signer), and shows a fingerprint on the Plymouth splash; an admin
cross-checks it and approves; the signed cert is written to NV `0x01800001` and
authentication proceeds. Enrol triggers on **empty-NV only** (expired cert →
automatic renew; auth-failure-with-cert → alert, not re-enrol). The fingerprint
authorises *which device*; **TPM attestation** (step-ca ACME `device-attest-01`)
proves *key residency* — use both for full assurance. Runs in a provisioning
UKI/mode, not the production initramfs. Full design + decisions: **TODO #11** (the
network-identity counterpart to **#10**, which enrols Secure Boot PK/KEK/db; both
share the provisioning-UKI vehicle).
## 2. Implementation notes (not architectural)
These don't contradict the PDF (it doesn't specify them) but are worth recording.
+84
View File
@@ -130,6 +130,39 @@ on a trusted/isolated network (see TODO #10).
Only the **public** certs/`.esl`/`.auth` reach the workstation — never copy
`PK.key`/`KEK.key`/`db.key` onto a host.
### Troubleshooting `fleet-sb-enroll` (bench, 2026-06-20)
**`dd: ... Invalid argument` (EINVAL) on the `db` write** = the firmware's
`SetVariable` rejected the payload (EFI_INVALID_PARAMETER), *not* an auth/Setup
Mode failure (that surfaces as EACCES / "Permission denied"). Diagnose top-down:
- **Is the `.auth` actually a signed authenticated variable?** First 16 bytes are
an `EFI_TIME`: `sudo od -An -tx1 -N16 db.auth` → a valid one starts with the
year little-endian (2026 = `ea 07`). If it starts with `a1 59 c0 a5 …` it's a
bare **ESL** (the `sign-efi-sig-list` step was skipped); `30 82 …` is a raw DER
cert. Re-create the `.auth` (step 2) and re-bake.
- **The 4-byte attribute prefix must contain its NUL bytes.** A bash *variable*
**cannot** hold NULs — `ATTR=$'\x27\x00\x00\x00'` silently truncates to the
single byte `0x27`, so efivarfs reads a corrupt attribute u32 and the kernel
returns EINVAL **before the firmware is even called**. This was a real bug in an
early `fleet-sb-enroll`; the fix emits the prefix with `printf '\x27\x00\x00\x00'`
at write time (never via a variable). If you hand-roll an enrolment, do the same.
- **Proxmox / OVMF red herring:** VM EFI disks default to `pre-enrolled-keys=1`,
so `mokutil --db` shows the **Microsoft** cert (`Windows Production PCA 2011`,
owner `77fa9abd-…`) *even in Setup Mode*. This looks like a "db not empty →
rejected" problem but isn't — the write fails identically on a clean
`pre-enrolled-keys=0` disk if the NUL bug is present. For a clean OVMF test bed:
`qm set <vmid> --efidisk0 <storage>:1,efitype=4m,pre-enrolled-keys=0`.
**Verified end-to-end on real hardware (2026-06-22):** `fleet-sb-enroll` (the
baked, fixed script) enrols db→KEK→PK cleanly, and both boot transports verify
under **enforcing Secure Boot** (`mokutil --sb-state` = enabled):
- **Proxmox VM — HTTP boot:** firmware → signed UKI (2026-06-20).
- **HP EliteBook 820 G4 — PXE boot:** firmware → signed `snponly.efi` (fleet-ipxe)
→ signed UKI (2026-06-22).
So the full signing pipeline (UKI + `fleet-ipxe`, both against the same enrolled
`db`) is proven on real silicon, not just in the VM.
## 4. Wire `db` into the build
```bash
@@ -170,6 +203,57 @@ Caveats:
CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal
(TODO #8).
## PXE-only hosts: iPXE chainload and Secure Boot
Firmware without UEFI HTTP boot (e.g. HP EliteBook 820 G4) falls back to PXE; the
serving stack hands those hosts **iPXE** over TFTP (`snponly.efi`), which then
chainloads the UKI over HTTP. dnsmasq selects per client automatically
(HTTP-capable → UKI direct; PXE-only → iPXE → UKI), so no per-host config.
**Packaging:** the signed `snponly.efi` is built + signed + pushed by CI as a
small FROM-scratch **`fleet-ipxe` image** (`.gitea/workflows/ipxe-image.yaml` →
`scripts/build-ipxe-oci.sh`), mounted into the dnsmasq container at `/ipxe`
(serving/docker-compose.yml). It is deliberately a **separate, per-Secure-Boot-
environment image** (its signature is `db`-key-specific) so the dnsmasq image
stays generic/key-agnostic — same reasoning as invariant #9 keeping serving
software out of the release-artifact image. For offline/lab use, drop an
`snponly.efi` in `serving/tftp/` and the entrypoint falls back to that bind mount.
Secure Boot across that chain needs **only the `db` key — the one already
enrolled for the UKI. No extra keys, no Microsoft CA, no shim** (the fleet owns
its keys):
```
firmware --(verify snponly.efi vs db)--> iPXE --(chain -> firmware LoadImage
verifies UKI vs db)--> UKI
```
- The firmware verifies the TFTP-downloaded NBP (`snponly.efi`) against `db` →
**the iPXE binary must be signed by `db`** (`sbsign`; CI does this in
`build-ipxe-oci.sh` when the `SB_KEY`/`SB_CERT` secrets are set, same key as the
UKI). Stock/unsigned iPXE is rejected under Secure Boot.
- iPXE must load the UKI as an **EFI image** (`imgfetch` + `boot`, or `chain`) so
the firmware's `LoadImage()` re-verifies it against `db` — do **not** use iPXE's
`kernel`/`boot` on a raw vmlinuz, which bypasses SB.
- **cmdline gotcha (Secure Boot OFF only):** when iPXE executes the UKI it sets the
EFI load options to the image's cmdline, or — if empty — to the image **name**.
With SB off systemd-stub uses the load options *as the kernel cmdline*,
**replacing the UKI's embedded cmdline** (roothash/nvmf/...) → dracut emergency
shell. Clearing the args does NOT help (empty → iPXE falls back to the name). The
serving stack's rendered `boot.ipxe` therefore feeds iPXE the **real cmdline**,
which the dnsmasq entrypoint extracts from the UKI's `.cmdline` PE section
(`objcopy`, artifact mounted read-only) — see `serving/tftp/README.md`. **Secure
Boot ON is immune** — the stub ignores any loader-supplied cmdline — so this is
purely a lab/SB-off concern.
**Posture:** the `fleet-ipxe` image is signed with `db` **whenever CI has the
`SB_KEY`/`SB_CERT` secrets** (same condition as the UKI), so the PXE path can stay
Secure-Boot-enforced end-to-end. An **unsigned** `fleet-ipxe` build (no secrets)
keeps its loud warning and requires Secure Boot OFF on the PXE target. The
HTTP-boot path (firmware → UKI) is unaffected and SB-clean regardless. Still
**BENCH-VERIFY** the signed PXE chain on the 820 (the VM uses HTTP boot, so the
signed iPXE stage is unverified on real hardware — TODO #8).
## Key hygiene & lifecycle
- **PK.key and KEK.key go offline / into an HSM and stay there.** They are only
+32 -12
View File
@@ -8,6 +8,13 @@
>
> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
> **May 2026 release**.
>
> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening
> applied + bench-verified, USBGuard device control enabled, admin `staff_u`
> confinement, audit offload verified, PXE/iPXE boot path added, and **UEFI Secure
> Boot signing verified enforcing on real hardware** (VM HTTP + 820 PXE) — R-13
> reduced from Medium to Low (residual is making signed + SB-on the standing
> default).
---
@@ -74,8 +81,9 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|---|---|---|---|---|---|
| Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` with documented tailoring ([stig-deviations.md](stig-deviations.md)). | Partial | R-03 (CI report-only/disabled now) |
| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + one trusted kiosk binary) + immutable dm-verity root. | Implemented | — |
| 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 | — |
| Device / removable-media control | ISM-0341*, ISM-1551* | Control USB / peripheral devices. | **USBGuard enforcing** (`ImplicitPolicyTarget=block` + hardware-independent HID/hub allowlist + LinuxAudit). Verified: USB mass storage blocked, console input preserved. | Implemented | — |
| Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — |
| Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — |
| Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — |
@@ -89,7 +97,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|---|---|---|---|---|---|
| Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81010001`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) |
| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled. | 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 | — |
| 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 |
@@ -109,7 +117,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|---|---|---|---|---|---|
| Centralised event logging | ISM-0988*, ISM-1405* | Logs centrally collected. | `audisp-syslog`→rsyslog `omfwd` (disk-assisted queue) → dedicated collector (`serving/audit-collector/`). | Partial | R-07 |
| Centralised event logging | ISM-0988*, ISM-1405* | Logs centrally collected. | `audisp-syslog`→rsyslog `omfwd` (disk-assisted queue) → dedicated collector (`serving/audit-collector/`); **bench-verified end-to-end 2026-06-20**. auditd `space_left_action`→syslog (no MTA). | Partial | R-07 |
| Required events logged | ISM-0582*, ISM-1537* | Log security-relevant events. | STIG auditd rule set; kernel `audit=1`, `audit_backlog_limit=8192`. | Implemented | — |
| Protect logs in transit | ISM-1432*, ISM-0407* | Encrypt/authenticate log transport. | **Plaintext** offload on isolated 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; retention policy & tamper-protection not yet defined. | Planned | R-07 |
@@ -144,7 +152,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the
| Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual |
|---|---|---|---|---|---|
| Media sanitisation / no data remanence | ISM-0347*, ISM-1218* | Prevent data remanence. | **Diskless** — no persistent local media; writable layer is **RAM tmpfs**, gone at power-off. | Implemented | — |
| Tamper-evidence / boot integrity | ISM-1542*, ISM-0312* | Detect firmware/boot tampering. | **UEFI Secure Boot** + signed UKI + immutable signed cmdline + dm-verity; TPM measurements available for attestation. | Partial | R-11 (remote attestation not yet implemented) |
| Tamper-evidence / boot integrity | ISM-1542*, ISM-0312* | Detect firmware/boot tampering. | Design: **UEFI Secure Boot** + signed UKI (+ signed iPXE on the PXE path) + immutable signed cmdline + dm-verity; TPM measurements available. **Verified enforcing on real hardware (2026-06-22):** CI-signed UKI under SB on a VM (HTTP) and signed iPXE→UKI under SB on an 820 (PXE), `db` enrolled via `fleet-sb-enroll`. Residual is making signed + SB-on the standing default for all builds/hosts. | Implemented (default-posture residual) | **R-13** (not yet default), R-11 (no remote attestation) |
| Device identification | ISM-1195* | Uniquely identify devices. | Identity derived from TPM cert (hostnqn from cert serial); no spoofable per-host config. | Implemented | — |
### 2.11 Guidelines for Procurement, Outsourcing & Evaluated Products
@@ -165,11 +173,12 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M).
|---|---|---|---|---|---|---|---|
| **R-01** | Secure path (802.1X EAP-TLS + MACsec) **implemented but not bench-verified end-to-end**; disabled in test builds (`disable_dot1x=true`). Until verified, devices may attach to an unauthenticated/unencrypted link. | 2.4, 2.7, 2.8 (ISM-1546*, 0520*, 1187*) | Likely × Major → **High** | Test path on an isolated lab LAN; full auth stack present (dormant); design follows ASD intent. | Full boot-chain bench test (swtpm + hostapd + FreeRADIUS): port `Authorized`, macsec0 addressed, MKA survives switch-root, NVMe root mounts. Re-enable in CI. | **Low** (once verified) | Platform team — *before production ATO* |
| **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 currently **report-only / disabled in CI** (`enable_stig=false`); shipped images may be unhardened. | 2.3 (ISM-1407*/1408*) | Possible × Major → **Medium** | Tailoring + register exist; SELinux/fapolicyd still on; re-enable is a single flag. | Flip `ENABLE_STIG="true"`; confirm scan evaluates >0 rules; triage REVIEW rows (buckets A/B/C in TODO #2). | **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-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, 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 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-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**, **single-site**, **no SIEM alerting**; logs interceptable/repudiable and not actively reviewed. | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Medium** | Isolated LAN; disk-assisted queue (no loss on collector blip); auditd local copy; per-source files. | rsyslog **TLS** (gtls/6514) + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); define retention + integrity; forward to SIEM with alerting. | **Low** | Platform/SecOps — *production* |
| **R-07** | Audit-log **offload plaintext**, **single-site**, **no SIEM alerting**; logs interceptable/repudiable and not actively reviewed. (Transport **bench-verified working** 2026-06-20.) | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Medium** | Isolated LAN; disk-assisted queue (no loss on collector blip); auditd local copy; per-source files; LinuxAudit captures USBGuard events. | rsyslog **TLS** (gtls/6514) + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); define retention + integrity; forward to SIEM with alerting. | **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-08** | **Placeholder RADIUS CA** (`files/etc/dot1x/radius-ca.pem`) — real EAP-TLS trust anchor not provisioned; auth cannot succeed and trust is unestablished. | 2.4 (ISM-1546*) | Certain (lab) × Major → **Medium** | Documented as a placeholder; environment-specific provisioning is the intended path; no real CA committed. | Provision the environment's real RADIUS CA out-of-band (not committed unless classification permits). | **Low** | Environment owner — *before production* |
| **R-09** | **Host firewall disabled**; host exposes more network surface than necessary. | 2.7 (ISM-1416*) | Possible × Moderate → **Low–Medium** | Isolated LAN; minimal services; egress controlled at the network/serving layer; no kiosk inbound. | Re-enable firewalld with explicit allow rules (NVMe/TCP `:4420`, DHCP, log offload, SSH) once the secure path is verified. | **Low** | Platform/network — *after R-01* |
| **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* |
@@ -192,10 +201,14 @@ Likelihood ↓
```
*The heatmap shows pre-treatment positions; every item has a treatment to Low /
Low–Medium. The AO is asked to authorise on the basis that R-01, R-02, R-03, R-08
and R-12 are **closed before production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11
are either accepted with compensating controls (isolated-LAN lab) or carried on
the POA&M with target dates.*
Low–Medium. The AO is asked to authorise on the basis that **R-01, R-02, R-08,
R-12 and R-13** (and confirming R-03's `enable_stig=true`) are **closed before
production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 are either accepted with
compensating controls (isolated-LAN lab) or carried on the POA&M with target
dates. Newly strengthened in v0.2: device control (USBGuard, now Implemented),
admin SELinux confinement (R-05), STIG applied+verified (R-03 reduced), and
**Secure Boot signing verified enforcing on real hardware (R-13 reduced
Medium→Low)**.*
---
@@ -215,6 +228,13 @@ the POA&M with target dates.*
anything flagged **REVIEW** is an unjustified finding.
6. **Confirm lab-only flags are off** in the assessed build: `debug_shell=false`,
`enable_stig=true`, `disable_dot1x=false`, real RADIUS CA in place.
7. **Confirm boot-chain signing is the default** (R-13): signing + Secure Boot
enforcement are **verified on hardware** (VM HTTP + 820 PXE, 2026-06-22) —
confirm the *assessed* build is signed and the *assessed* hosts have `db`
enrolled with Secure Boot ON (`mokutil --sb-state`, `sbverify` UKI + iPXE). The
PXE/iPXE SB-OFF cmdline workaround is a no-op once SB is on.
8. **Witness device control** (USBGuard): a USB mass-storage device is blocked and
logged, console HID still works.
---
+73 -28
View File
@@ -15,10 +15,10 @@
|---|---|
| System name | Diskless Fleet Boot System ("the fleet") |
| Document type | System Security Plan (SSP) |
| Version | 0.1 (DRAFT — pre-assessment) |
| Date | 2026-06-19 |
| Version | 0.2 (DRAFT — pre-assessment) |
| Date | 2026-06-22 |
| Author / maintainer | System Owner (delegate) |
| Status | Lab / pre-production. 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. |
| 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) |
> **Currency caveat (read before relying on control numbers).** This SSP and its
@@ -72,8 +72,19 @@ manifest. One commit = one release = one digest = one manifest entry.
### 2.2 Boot and trust chain (control flow)
1. **UEFI Secure Boot** measures and verifies the UKI signature (vendor/Secure
Boot key) before execution.
> **Boot transport (two paths, auto-selected):** HTTP-boot-capable firmware
> fetches the UKI directly over UEFI HTTP boot. Firmware **without** HTTP boot
> (e.g. HP EliteBook 820 G4) falls back to **PXE → iPXE → HTTP** (dnsmasq serves
> iPXE over TFTP, which chainloads the same UKI). Both land on the same signed
> UKI. **Lab status:** Secure Boot is currently **OFF** and the UKI/iPXE are
> **unsigned** — signing is wired in CI and the key procedure is documented
> ([secure-boot-keys.md](secure-boot-keys.md)) but not yet enforced (residual
> R-03/signing in Annex A).
1. **UEFI Secure Boot** (when enabled) measures and verifies the UKI signature
against the enrolled `db` key before execution. On the PXE/iPXE path the iPXE
binary is likewise verified against `db`; only the fleet's own `db` need be
enrolled (no Microsoft CA / shim).
2. **UKI (systemd-stub)** carries the kernel, the `--no-hostonly` initramfs, and
an **immutable, signed kernel command line** (includes the dm-verity root
hash and STIG kernel parameters). No GRUB.
@@ -102,7 +113,9 @@ the tmpfs overlay, the TPM-backed identity, and the hardened OS configuration.
The boundary's external interfaces are: (a) the **access link** (802.1X-controlled
port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c)
**outbound audit log** forwarding; (d) the **kiosk application** endpoint the
browser renders. Each crossing is a control point addressed in Annex A.
browser renders; (e) the **boot transport** (UEFI HTTP boot, or PXE/TFTP→iPXE)
that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuard
(§5.2). Each crossing is a control point addressed in Annex A.
### 2.4 Data and classification
@@ -151,12 +164,23 @@ This system follows the ISM A&A lifecycle:
Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M
source).
**Current status:** Pre-assessment **DRAFT**. The system is functional in a lab
configuration. Several controls are **partial or deferred** and are tracked as
residual risks with treatment plans (Annex A §3). The security-critical path
(802.1X/MACsec) is **implemented but not yet bench-verified end-to-end** and is
currently disabled in test builds (`disable_dot1x=true`) — this is the single
largest gate on a production authorisation.
**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-22)**. The system
is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA
STIG hardening applied and **bench-verified** on a real build (kiosk, key-only SSH,
audit offload, DHCP-named host, NVMe/verity root all good with hardening on);
USBGuard device control enabled + verified; admin confined to `staff_u`; and —
newly — **UEFI Secure Boot signing verified enforcing end-to-end on real
hardware**: the CI-signed UKI boots under Secure Boot on a VM (HTTP boot), and the
signed iPXE→UKI chain boots under Secure Boot on an EliteBook 820 G4 (PXE boot),
with `db`/`KEK`/`PK` enrolled via the baked `fleet-sb-enroll` helper. Several
controls remain **partial or deferred**, tracked as residual risks with treatments
(Annex A §3). The largest remaining gates on a production ATO: (a) the secure path
(**802.1X/MACsec**) is implemented but **not yet bench-verified** and is disabled
in test builds (`disable_dot1x=true`, R-01); (b) a lab-only **passwordless-root
debug shell** must be removed (R-02). Boot-chain signing / Secure Boot — a major
v0.1 gate — is now **demonstrated on hardware**; the residual (R-13) is reduced to
making signed + Secure-Boot-on the **standing default** for every production
host/build.
## 5. Security control implementation — by ISM cyber security principle
@@ -180,23 +204,43 @@ summarises the implementation posture.
- **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable
cmdline**; **dm-verity** read-only root (continuous cryptographic integrity);
**erofs** immutable filesystem; **reproducible** build (verity hash is a pure
function of inputs).
- **System hardening:** **DISA STIG** baseline via OpenSCAP/SSG with a documented
tailoring; **SELinux enforcing**; **fapolicyd** application allow-listing (the
exec guard for the writable RAM overlay that dm-verity cannot cover);
**key-only SSH**, root login disabled, hardened ciphers/MACs/KEX; **kiosk
lockdown** (single locked browser, no shell/navigation).
function of inputs). Signing is **wired in CI** (`sbsign`/ukify via
`SB_KEY`/`SB_CERT` secrets) with a documented PK/KEK/db key + enrolment
procedure ([secure-boot-keys.md](secure-boot-keys.md)) and a baked, efitools-free
enrolment helper (`fleet-sb-enroll`); **not yet enforced in the lab** (SB OFF,
unsigned — see Annex A).
- **System hardening (DISA STIG applied + bench-verified 2026-06-20):** STIG
baseline via OpenSCAP/SSG with a documented tailoring + per-build deviations
report; all REVIEW rows triaged. **SELinux enforcing**; **fapolicyd**
application allow-listing (the exec guard for the writable RAM overlay dm-verity
cannot cover); **USBGuard enforcing** (`ImplicitPolicyTarget=block` + a
hardware-independent HID/hub allowlist + LinuxAudit — verified blocks USB mass
storage while keeping console input; device/removable-media control); **key-only
SSH**, root login disabled, hardened ciphers/MACs/KEX (note: STIG crypto policy
drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked
browser, no shell/navigation); **postfix removed** (no local MTA — audit-failure
notification rides the remote offload, not email).
- **Privileged access:** single **key-only `wheel` admin**, root locked. The admin
is now **confined to the `staff_u` SELinux user** with a sudoers role/type
transition (`sysadm_r`/`sysadm_t`) so privilege elevation re-labels the SELinux
context (STIG `selinux_context_elevation_for_sudo`). Still **NOPASSWD** (the
account is passwordless/key-only) and fleet-generic — see R-05.
- **No persistent attack surface:** diskless; overlay discarded each boot; no
per-host secrets in the image.
- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled**;
NVMe/TCP and audit offload **plaintext on the isolated LAN**; NOPASSWD key-only
admin; **placeholder RADIUS CA**. See Annex A residual risks.
- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled** (the
workstation; the serving host's ufw is configured); NVMe/TCP and audit offload
**plaintext on the isolated LAN**; NOPASSWD admin; **placeholder RADIUS CA**;
signing not yet enforced (SB OFF). See Annex A residual risks.
### 5.3 Detect
- **auditd** enabled with STIG audit rules; kernel `audit=1` +
`audit_backlog_limit` in the signed cmdline.
`audit_backlog_limit` in the signed cmdline. USBGuard device events logged via
the LinuxAudit backend. auditd `space_left_action` repointed to **syslog**
(no local MTA), so audit-volume alerts ride the remote offload.
- **Centralised log offload** via rsyslog (`audisp-syslog` → rsyslog `omfwd`,
disk-assisted queue) to a dedicated **audit collector** (`serving/audit-collector/`).
**Bench-verified end-to-end 2026-06-20** (records reach the collector,
per-source-host files).
- *Gaps:* offload currently **plaintext** (TLS planned, R-07); offload is
**local site only**; no SIEM correlation/alerting defined yet.
@@ -215,11 +259,11 @@ AO will expect a maturity view:
| Mitigation | Posture | Notes |
|---|---|---|
| Application control | **Strong** | fapolicyd allow-listing + immutable verified root; only RPM-trusted (plus one trusted kiosk binary) executables run. |
| Application control | **Strong** | fapolicyd allow-listing + immutable verified root; only RPM-trusted (plus two trusted local binaries: kiosk-session, fleet-sb-enroll) executables run. USBGuard enforces device control on USB ports. |
| Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. |
| Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. |
| User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. |
| Restrict admin privileges | **Partial** | Single key-only `wheel` admin (NOPASSWD, see R-05); no per-host/rotatable model 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); no per-host/rotatable model or admin-MFA yet. |
| 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. |
| 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. |
@@ -234,13 +278,14 @@ production ATO:
|---|---|---|---|
| R-01 | Secure path (802.1X/MACsec) not yet bench-verified; currently disabled in test builds. | **High** | Treat — verify before production authorisation. |
| 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 temporarily **report-only / disabled in CI** (`enable_stig=false`). | **Medium** | Treat — re-enable 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-05 | NOPASSWD key-only admin; no per-host or rotatable access model. | **Medium** | Accept with compensating controls / roadmap. |
| 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-06 | **FIPS off**; ASD-approved-cryptography conformance not yet asserted. | **Medium** | Treat — required for PROTECTED+; revisit with signing. |
| R-07 | Audit-log offload **plaintext**, single-site, no SIEM alerting. | **Medium** | Treat — TLS offload + monitoring on roadmap. |
| R-07 | Audit-log offload **plaintext** (bench-verified working), single-site, no SIEM alerting. | **Medium** | Treat — TLS offload + monitoring on roadmap. |
| 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**. | **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). |
Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A.
+24
View File
@@ -114,6 +114,30 @@ reverts to a justified deviation; (b) reproducibility — `semanage` rebuilds th
SELinux policy store, so confirm the verity hash is stable across two builds (and
normalize in 2d hygiene if it drifts).
## 13. Ctrl+Alt+Del console reboot hotkey kept enabled (decision 2026-06-20)
The RHEL 9 STIG disables the console reboot hotkey via two rules, both **waived**
in the tailoring:
- `disable_ctrlaltdel_reboot` — masks `ctrl-alt-del.target` (single Ctrl+Alt+Del).
- `disable_ctrlaltdel_burstaction` — sets `CtrlAltDelBurstAction=none` in
`/etc/systemd/system.conf` (the rapid 7× burst reboot).
**Why waived:** the STIG intent is to stop an **unauthenticated console user from
causing an unplanned reboot/outage**. On this fleet that risk is structurally
absent — the hosts are **diskless and stateless** (immutable erofs+dm-verity root,
tmpfs volatile overlay), so a console reboot loses nothing and persists nothing,
and a **field technician needs to recover a wedged kiosk from the keyboard when
SSH is unavailable**. The two rules are unselected so `--remediate` leaves the
systemd default (the hotkey reboots).
**Compensating control / scope:** the hotkey only triggers a **clean stateless
reboot** — it cannot exfiltrate data, alter the read-only root, or bypass auth to
a shell. Authenticated reboot paths (`systemctl reboot` over the key-only admin
SSH session) are unaffected either way. The console itself is otherwise locked
down (kiosk autologin to a single locked browser; no getty shell on tty1;
`debug_shell` is lab-only and never shipped). If a future deployment wants the
hotkey disabled (e.g. a physically exposed terminal), re-select both rule IDs.
## Things that are NOT deviations (kept / enforced)
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
+7 -3
View File
@@ -28,8 +28,12 @@ EFIVARS=/sys/firmware/efi/efivars
GUID_GLOBAL=8be4df61-93ca-11d2-aa0d-00e098032b8c # PK, KEK, SetupMode
GUID_DB=d719b2cb-3d3a-4596-a3bc-dad00e67656f # db, dbx
# EFI var attributes: NON_VOLATILE|BOOTSERVICE_ACCESS|RUNTIME_ACCESS|
# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, little-endian 4 bytes.
ATTR=$'\x27\x00\x00\x00'
# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, written as a 4-byte little-endian
# u32 prefixed to the .auth payload. These 4 bytes contain NULs, so they are
# emitted with printf at WRITE TIME, never stored in a variable: a bash string
# can't hold a NUL (it truncates at the first one), which would leave only the
# 0x27 byte, corrupt the 4-byte attribute field efivarfs reads, and make the
# kernel reject the write with EINVAL ("dd: Invalid argument").
die() { echo "fleet-sb-enroll: $*" >&2; exit 1; }
@@ -57,7 +61,7 @@ enrol_one() { # $1=var name $2=guid $3=.auth path
local f="$EFIVARS/$1-$2" tmp
[ -f "$3" ] || die "missing $3"
tmp=$(mktemp)
printf '%s' "$ATTR" > "$tmp"
printf '\x27\x00\x00\x00' > "$tmp" # 4-byte LE attribute prefix (see note above)
cat "$3" >> "$tmp"
# Existing EFI vars carry the immutable flag; clear it before overwriting.
[ -e "$f" ] && chattr -i "$f" 2>/dev/null || true
+11
View File
@@ -162,5 +162,16 @@
<xccdf:select idref="xccdf_org.ssgproject.content_rule_usbguard_allow_hub" selected="true"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_usbguard_allow_hid_and_hub" selected="true"/>
<!-- Ctrl+Alt+Del reboot hotkey (decision 2026-06-20): KEEP the console
reboot hotkey working on these diskless kiosks. The STIG masks
ctrl-alt-del.target + sets CtrlAltDelBurstAction=none to stop an
unauthenticated console reboot, but the hosts are STATELESS (immutable
erofs+verity root, tmpfs overlay) so a console reboot has no data-loss
or persistence risk, and a field tech needs to recover a wedged kiosk
without SSH. Unselect both so remediation leaves the systemd default
(hotkey reboots). Justified in docs/stig-deviations.md #13. -->
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_reboot" selected="false"/>
<xccdf:select idref="xccdf_org.ssgproject.content_rule_disable_ctrlaltdel_burstaction" selected="false"/>
</xccdf:Profile>
</xccdf:Tailoring>
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]
#
# Packages the iPXE network bootloader (snponly.efi) as a tiny FROM-scratch OCI
# image for the PXE-only chainload path (firmware without UEFI HTTP boot, e.g.
# HP EliteBook 820 G4). The dnsmasq serving container mounts this image and
# copies the binary into its TFTP root — so the serving host never carries a
# loose, unsigned .efi and the bootloader is digest-pinned + signed like the UKI.
#
# This is DELIBERATELY a separate, independently versioned image (NOT merged into
# the FROM-scratch release-artifact image built by build-oci.sh): snponly.efi is
# near-static (it changes only when iPXE is rebuilt/re-signed), whereas the
# UKI/rootfs change every release. Keeping it separate matches the serving-stack
# pattern (SPDK, nginx) and the artifact-image invariant (only the 5 release
# files live there). See CLAUDE.md invariant #9 and docs/secure-boot-keys.md.
#
# Steps:
# 1. sbsign snponly.efi with the `db` key/cert if supplied (same key that signs
# the UKI — CI/HSM only, never in this repo or the build VM). Unsigned builds
# are lab-only and keep a loud warning: a Secure-Boot-enforcing host rejects
# an unsigned snponly.efi before iPXE ever runs.
# 2. build a FROM-scratch OCI image containing ONLY:
# /ipxe/snponly.efi (signed iPXE bootloader)
# /ipxe/SHA256SUMS
# No shell, no tools, no base image. Pin deployments by digest.
#
# Requires on the build host: sbsigntools (if signing) and buildah (preferred)
# or docker. sbverify (optional) is used to confirm the signature when present.
set -euo pipefail
SRC=${1:?usage: build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]}
IMAGE=${2:?image ref required, e.g. registry.example.org/fleet-ipxe}
VERSION=${3:?version required (the iPXE binary version/tag)}
SB_KEY=${4:-}
SB_CERT=${5:-}
PUSH=${6:-false}
[ -f "$SRC" ] || { echo "ERROR: snponly.efi not found at $SRC" >&2; exit 1; }
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir "$STAGE/ipxe"
OUT="$STAGE/ipxe/snponly.efi"
# --- 1. Sign (or pass through with a loud warning) ----------------------------
SIGNED=false
if [ -n "$SB_KEY" ] && [ -n "$SB_CERT" ]; then
command -v sbsign >/dev/null || { echo "ERROR: sbsigntools required for signing" >&2; exit 1; }
sbsign --key "$SB_KEY" --cert "$SB_CERT" --output "$OUT" "$SRC"
SIGNED=true
if command -v sbverify >/dev/null; then
sbverify --cert "$SB_CERT" "$OUT" >/dev/null \
|| { echo "FATAL: signed snponly.efi fails sbverify against the db cert." >&2; exit 1; }
echo "==> snponly.efi signed and verified against the db cert"
else
echo "==> snponly.efi signed (sbverify not present to confirm)"
fi
else
cp "$SRC" "$OUT"
echo "WARNING: building UNSIGNED snponly.efi image (no sb-key/sb-cert supplied)." >&2
echo " Fine for the lab (Secure Boot OFF); a Secure-Boot-enforcing host" >&2
echo " rejects an unsigned iPXE binary. Production must sign in CI/HSM." >&2
fi
( cd "$STAGE/ipxe" && sha256sum -- * > SHA256SUMS )
# --- 2. Build the OCI image ---------------------------------------------------
REF="${IMAGE}:${VERSION}"
LABELS=(
"org.opencontainers.image.title=fleet-ipxe"
"org.opencontainers.image.version=${VERSION}"
"org.fleet.ipxe.signed=${SIGNED}"
)
if command -v buildah >/dev/null; then
# vfs storage: overlay-on-overlayfs is unsupported in-container (CI runner);
# this image is tiny so vfs is fine. Override via STORAGE_DRIVER on a host
# with native overlay support. (Same rationale as build-oci.sh.)
export STORAGE_DRIVER="${STORAGE_DRIVER:-vfs}"
ctr=$(buildah from scratch)
buildah copy "$ctr" "$STAGE/ipxe" /ipxe
for l in "${LABELS[@]}"; do buildah config --label "$l" "$ctr"; done
buildah commit --format oci --rm "$ctr" "$REF"
DIGEST=$(buildah inspect --type image --format '{{.FromImageDigest}}' "$REF" || true)
[ "$PUSH" = "true" ] && buildah push "$REF"
elif command -v docker >/dev/null; then
{ printf 'FROM scratch\nCOPY ipxe /ipxe\n'
for l in "${LABELS[@]}"; do printf 'LABEL "%s"\n' "$l"; done
} > "$STAGE/Dockerfile"
docker build -t "$REF" "$STAGE"
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$REF" 2>/dev/null || true)
[ "$PUSH" = "true" ] && docker push "$REF"
else
echo "ERROR: need buildah or docker to build the OCI image" >&2; exit 1
fi
cat <<EOF
==> fleet-ipxe image built: $REF
signed: $SIGNED
digest: ${DIGEST:-"(available after push)"}
Deploy: set IPXE_IMAGE/IPXE_VERSION in serving/.env to this ref, then
\`docker compose up -d dnsmasq\` (the entrypoint copies /ipxe/snponly.efi
into the TFTP root). Pin by digest in production.
EOF
+19
View File
@@ -17,6 +17,25 @@ ARTIFACT_SRC=../artifacts/0.0.0-2eefd97
SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf
SPDK_VERSION=v24.09
# dnsmasq image — prebuilt + pushed by CI (.gitea/workflows/dnsmasq-image.yaml);
# GENERIC (no Secure Boot keys / no iPXE binary). Pulled, not built at deploy.
DNSMASQ_IMAGE=git.bitasylum.org/audiobuzz/fleet-dnsmasq
DNSMASQ_VERSION=latest
# fleet-ipxe image — the SIGNED iPXE bootloader (snponly.efi) for PXE-only hosts,
# built + signed (fleet db key) + pushed by CI (.gitea/workflows/ipxe-image.yaml).
# Mounted into dnsmasq at /ipxe. Per-environment (signature is db-key-specific);
# pin by digest in production, e.g. fleet-ipxe@sha256:…
IPXE_IMAGE=git.bitasylum.org/audiobuzz/fleet-ipxe
IPXE_VERSION=latest
# kiosk SPA image — the spaceship-terminal web app, now an INDEPENDENT project
# (the kiosk-app git submodule) with its own CI that builds + pushes this image.
# The 'kiosk' compose service pulls it; the main nginx reverse-proxies /kiosk/ to
# it. For local dev, uncomment `build: ./kiosk-app` on that service instead.
KIOSK_IMAGE=git.bitasylum.org/audiobuzz/fleet-kiosk
KIOSK_VERSION=latest
# --- This serving host on the boot LAN -------------------------------------
BOOT_IFACE=ens19 # interface facing the booting client / test bridge
SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe)
+10 -2
View File
@@ -2,10 +2,18 @@
# the version is pinned and the entrypoint is predictable). Config is passed
# entirely as --flags from docker-compose.yml.
FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20
RUN apk add --no-cache dnsmasq
# binutils (objcopy): the entrypoint extracts the UKI's .cmdline PE section so it
# can feed the REAL cmdline to iPXE (Secure Boot OFF chainload) — see entrypoint.sh.
RUN apk add --no-cache dnsmasq binutils
# fleet-namer: --dhcp-script that assigns creative starship hostnames per MAC,
# server-side (keeps the workstation image neutral). COPY'd in root-owned, 0755
# so dnsmasq will execute it. Uses busybox sha256sum/cut/wc — all in the image.
COPY fleet-namer /usr/local/bin/fleet-namer
RUN chmod 0755 /usr/local/bin/fleet-namer
ENTRYPOINT ["dnsmasq"]
# entrypoint assembles the TFTP root (copies the user's ipxe.efi from the ro
# source mount + renders boot.ipxe, which clears the iPXE load options so the
# UKI's embedded cmdline survives the chainload — see entrypoint.sh) then execs
# dnsmasq with the compose args.
COPY entrypoint.sh /usr/local/bin/dnsmasq-entrypoint
RUN chmod 0755 /usr/local/bin/dnsmasq-entrypoint
ENTRYPOINT ["/usr/local/bin/dnsmasq-entrypoint"]
+94
View File
@@ -0,0 +1,94 @@
#!/bin/sh
# dnsmasq entrypoint: assemble the TFTP root, then exec dnsmasq with the compose
# args ("$@").
#
# iPXE + Secure Boot OFF cmdline problem:
# When iPXE executes the UKI it sets the EFI LoadOptions to the image's command
# line, or — if that's empty — to the image NAME. With Secure Boot OFF,
# systemd-stub uses LoadOptions AS THE KERNEL CMDLINE, replacing the UKI's
# EMBEDDED cmdline (root/roothash/rd.nvmf.discover/rd.dot1x=0/volatile). So
# booting the UKI URL directly (or clearing the args) makes the kernel come up
# with just "fleet-<ver>.efi" as its cmdline -> dracut emergency shell.
# Fix: feed iPXE the REAL cmdline via `imgargs`. That cmdline lives only inside
# the UKI on the serving host (not in the OCI image, not as a file), so we read
# it out of the UKI's .cmdline PE section and bake it into boot.ipxe.
# (Secure Boot ON ignores any loader cmdline and uses the embedded one, so this
# whole dance is a lab/SB-OFF concern; harmless when SB is on.)
#
# Extraction: objdump -h gives the .cmdline section's size + file offset; we dd
# those bytes out of the (read-only) UKI. We deliberately avoid `objcopy`:
# --dump-section rewrites the input in place (fails on the read-only /artifacts
# mount), and `-O binary --only-section` lays the section out at its huge VMA
# (multi-GB sparse file).
set -eu
mkdir -p /srv/tftp
# iPXE binary (snponly.efi) into the TFTP root. Prefer the signed binary from the
# mounted fleet-ipxe image (/ipxe); fall back to the read-only bind mount
# (/tftp-src) for offline/lab use. The dnsmasq image carries no .efi itself.
if ls /ipxe/*.efi >/dev/null 2>&1; then
cp -f /ipxe/*.efi /srv/tftp/
echo "iPXE bootloader source: /ipxe (fleet-ipxe image)"
elif ls /tftp-src/*.efi >/dev/null 2>&1; then
cp -f /tftp-src/*.efi /srv/tftp/
echo "iPXE bootloader source: /tftp-src (bind mount, lab fallback)"
else
echo "WARN: no iPXE binary in /ipxe or /tftp-src — PXE-only clients can't chainload" >&2
fi
# autoexec.ipxe was an STP-era workaround (ifopen+retry before autoboot); it's no
# longer rendered — PortFast on the switch port is the real fix. Remove any stale
# copy left in a long-lived container's writable layer.
rm -f /srv/tftp/autoexec.ipxe
extract_cmdline() { # $1 = UKI path; echoes the cleaned cmdline (empty on failure)
uki="$1"
command -v objdump >/dev/null 2>&1 || return 0
# objdump -h columns: Idx Name Size VMA LMA File-off Algn
line=$(objdump -h "$uki" 2>/dev/null | awk '$2==".cmdline"{print $3" "$6; exit}')
[ -n "$line" ] || return 0
sz=$((0x${line% *})); off=$((0x${line#* }))
[ "$sz" -gt 0 ] || return 0
dd if="$uki" bs=1 skip="$off" count="$sz" 2>/dev/null \
| tr -d '\000' | tr '\n\t' ' ' | sed 's/ */ /g; s/^ //; s/ $//'
}
render_boot_ipxe() {
uki="fleet-${IMAGE_VERSION}.efi"
cmdline=""
[ -f "/artifacts/${uki}" ] && cmdline=$(extract_cmdline "/artifacts/${uki}")
if [ -z "$cmdline" ]; then
echo "WARN: could not extract .cmdline from /artifacts/${uki} — iPXE boot will" >&2
echo " hit a dracut shell under Secure Boot OFF (no embedded cmdline)." >&2
echo " Check the /artifacts mount + binutils, or enable Secure Boot." >&2
fi
# boot.ipxe is fetched as the DHCP bootfile (dnsmasq tag:ipxe) AFTER iPXE's
# normal autoboot DHCP has already configured the link, so it just fetches and
# boots the UKI. `imgargs` feeds the UKI's REAL cmdline (Secure Boot OFF — see
# top); without it iPXE would pass the image name as the cmdline and dracut
# would drop to an emergency shell. (every fallible step is '||'-guarded — a
# bare failure aborts the iPXE script.)
#
# REQUIRES PortFast/edge on the switch port: iPXE's autoboot opens the NIC,
# which bounces the PHY; without PortFast the switch's STP forwarding delay
# (~30s) drops the DHCP and the boot fails. (We removed the old ifopen+retry
# workaround now that PortFast is the documented requirement — serving/tftp/README.)
cat > /srv/tftp/boot.ipxe <<EOF
#!ipxe
dhcp net0 || goto err
imgfetch http://${SERVER_IP}/${uki} || goto err
imgargs ${uki} ${cmdline}
boot ${uki} || goto err
:err
echo Fleet iPXE chain failed (\${errno}) - dropping to iPXE shell
shell
EOF
echo "rendered /srv/tftp/boot.ipxe -> ${uki} (cmdline: ${cmdline:-<MISSING>})"
}
if [ -n "${SERVER_IP:-}" ] && [ -n "${IMAGE_VERSION:-}" ]; then
render_boot_ipxe
else
echo "WARN: SERVER_IP/IMAGE_VERSION unset; /srv/tftp/boot.ipxe NOT rendered" >&2
fi
exec dnsmasq "$@"
+68 -5
View File
@@ -37,7 +37,12 @@
services:
# --- DHCP + DNS ------------------------------------------------------------
dnsmasq:
build: ./dnsmasq
# Prebuilt by CI (.gitea/workflows/dnsmasq-image.yaml) and pulled here, like
# the SPDK image — the serving host no longer builds it at deploy time. The
# image is GENERIC (no Secure Boot keys, no iPXE binary): the signed iPXE
# bootloader rides the separate, per-environment fleet-ipxe image mounted at
# /ipxe below. Local-dev alternative: replace this line with `build: ./dnsmasq`.
image: ${DNSMASQ_IMAGE}:${DNSMASQ_VERSION}
network_mode: host
cap_add:
- NET_ADMIN # DHCP raw sockets
@@ -48,6 +53,34 @@ services:
# + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add
# manual lines to pin specific hosts. RW so fleet-namer can append.
- ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts
# Signed iPXE bootloader for the PXE-only chainload (firmware without UEFI
# HTTP boot, e.g. EliteBook 820 G4). Mounted from the separate, signed,
# digest-pinnable fleet-ipxe image (.gitea/workflows/ipxe-image.yaml); the
# entrypoint copies /ipxe/snponly.efi into /srv/tftp. Kept OUT of the dnsmasq
# image so the daemon image stays generic/key-agnostic — see serving/tftp/README.
- type: image
source: ${IPXE_IMAGE}:${IPXE_VERSION}
target: /ipxe
image:
subpath: ipxe
# Fallback (Docker < 28 / offline / lab): drop snponly.efi in ./tftp and bind
# it instead (the entrypoint prefers /ipxe, then falls back to /tftp-src):
# - ./tftp:/tftp-src:ro
# The release artifacts (same image volume as the http service), read-only,
# so the entrypoint can objcopy the UKI's .cmdline section into boot.ipxe
# (Secure Boot OFF needs the real cmdline fed to iPXE — see entrypoint.sh).
- type: image
source: ${OCI_IMAGE}:${IMAGE_VERSION}
target: /artifacts
image:
subpath: artifacts
# Fallback (Docker < 28 / offline): bind the local Packer output dir instead:
# - ${ARTIFACT_SRC}:/artifacts:ro
environment:
# Consumed by the entrypoint to render /srv/tftp/boot.ipxe (the iPXE chain
# script that clears load options so the UKI's embedded cmdline survives).
- SERVER_IP=${SERVER_IP}
- IMAGE_VERSION=${IMAGE_VERSION}
command:
- --keep-in-foreground
# Log to stderr so `docker compose logs` captures output. Without this,
@@ -108,6 +141,32 @@ services:
- --dhcp-vendorclass=set:httpboot,HTTPClient
- --dhcp-option=tag:httpboot,60,HTTPClient
- --dhcp-boot=tag:httpboot,http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi
# ---- PXE -> iPXE -> HTTP chainload (for firmware WITHOUT UEFI HTTP boot) ----
# Graceful fallback: HTTP-capable firmware matches 'httpboot' above and gets
# the UKI directly; PXE-only firmware (PXEClient) gets ipxe.efi over TFTP;
# once iPXE is running it re-DHCPs with user-class "iPXE" and is handed the
# SAME UKI HTTP URL, which it chainloads (.efi -> LoadImage). Each client
# gets the right thing based on what it advertises — no per-host config.
# snponly.efi = UEFI x64 iPXE that rides the FIRMWARE's NIC driver (UEFI SNP)
# instead of iPXE's own — works on virtio VMs AND real hardware where the
# native driver won't bind (e.g. EliteBook 820 G4 / Intel I219-LM, which
# fails ipxe.efi with "No configurable methods"). Built + signed by CI into
# the fleet-ipxe image (mounted at /ipxe); see serving/tftp/README.md.
# The fleet-ipxe image is signed with the fleet db key when CI has the
# secrets, so this path stays Secure-Boot-enforced end-to-end; an unsigned
# (lab) fleet-ipxe build needs Secure Boot OFF — see docs/secure-boot-keys.md.
- --enable-tftp
- --tftp-root=/srv/tftp
- --dhcp-vendorclass=set:pxe,PXEClient
- --dhcp-userclass=set:ipxe,iPXE
# firmware PXE, not yet iPXE -> hand iPXE (UEFI x64) over TFTP
- --dhcp-boot=tag:pxe,tag:!ipxe,snponly.efi
# iPXE running -> hand boot.ipxe (NOT the UKI URL directly). The script
# imgfetch'es the UKI then `imgargs`-clears the load options before boot, so
# systemd-stub uses the UKI's EMBEDDED cmdline. Booting the UKI URL directly
# makes iPXE pass the image name as the cmdline, which (Secure Boot OFF)
# REPLACES the embedded roothash/nvmf/... args -> dracut emergency shell.
- --dhcp-boot=tag:ipxe,boot.ipxe
# --- HTTP boot server (serves the UKI) -------------------------------------
http:
@@ -132,11 +191,15 @@ services:
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
# Svelte + Tailwind app built into a static bundle served by its own nginx.
# Bridge-networked (NOT host) so it doesn't fight :80; published on loopback
# only, where the host-network 'http' nginx reverse-proxies /kiosk/ to it.
# Build: docker compose build kiosk
# Now an INDEPENDENT project — the `kiosk-app` git submodule (its own repo +
# CI). It builds + pushes its own image (git.../fleet-kiosk); the serving stack
# pulls it, like dnsmasq/SPDK. Bridge-networked (NOT host) so it doesn't fight
# :80; published on loopback only, where the host-network 'http' nginx
# reverse-proxies /kiosk/ to it.
kiosk:
build: ./kiosk-app
image: ${KIOSK_IMAGE}:${KIOSK_VERSION}
# Local dev from the submodule working tree instead of pulling:
# build: ./kiosk-app
restart: unless-stopped
ports:
- "127.0.0.1:${KIOSK_APP_PORT:-8088}:80" # loopback only; main nginx proxies here
+5
View File
@@ -0,0 +1,5 @@
# Copy to .env and edit, then: docker compose up -d step-ca
CA_DNS=ca.fleet.lab # DNS name(s) clients reach step-ca on (resolved by serving dnsmasq)
CA_PORT=8443 # step-ca listen port (host networking)
CA_PASSWORD=changeme-bench # password protecting the CA keys (bench only)
RADIUS_SECRET=testing123 # shared secret between the authenticator (hostapd) and FreeRADIUS
+10
View File
@@ -0,0 +1,10 @@
# CA state + all issued key material / certs — NEVER commit. Regenerate via
# step-ca/bootstrap.sh. The bench CA is throwaway; production PKI is separate.
pki/
.env
# per-host enrolment scratch
enrol/out/
*.key
*.csr
*.der
*.pem
+126
View File
@@ -0,0 +1,126 @@
# 802.1X EAP-TLS + MACsec secure-path bench
Bench harness for the **secure path** (`disable_dot1x=false`): wired 802.1X
EAP-TLS with a **TPM-held client key**, link encryption via **MACsec/MKA**, then
DHCP + NVMe/TCP root over the encrypted link. This proves the half of the system
the open-port (`rd.dot1x=0`) lab build deliberately skips. Tracks TODO #8 / SSP
residual risk R-01.
> **Status: scaffold.** Configs marked **BENCH-VERIFY** below are best-effort and
> need confirming on your wpa_supplicant / hostapd / tpm2 builds (see CLAUDE.md
> "Known bench-verify items"). This is a lab rig, expected to be iterated.
## The three components
| Component | Runs on | What it does |
|---|---|---|
| **step-ca** (`step-ca/`) | **serving host** | the fleet CA — signs each host's **TPM-generated CSR**, issues the RADIUS server cert, and is the trust anchor baked into the image as `radius-ca.pem`. |
| **FreeRADIUS** (`freeradius/`) | **serving host** | terminates EAP-TLS, validates the client cert against step-ca's root, returns the MSK so MKA can key MACsec. |
| **authenticator** (`authenticator/`) | **a node *between* each workstation and the serving network** | `hostapd` wired 802.1X + MACsec; bridges the decrypted link to the serving VLAN. One per-workstation P2P link. |
The CA + RADIUS sit on the **trusted side** (the serving host). The authenticator
is the only thing on the **untrusted** link with each workstation. Workstations
never reach DHCP/NVMe except *through* an authenticated, MACsec-encrypted link.
## Why a separate isolated-bridge topology (not your flat VLAN)
802.1X EAPOL (`01:80:c2:00:00:03`) is a reserved multicast that switches/bridges
**drop by default**, and **MACsec is point-to-point per link** — so a single
shared bridge can't authenticate or encrypt per host, and if the serving host
shares that bridge there's no authenticator in the path at all. Keep the flat
VLAN for the open-port build; build this sandwich alongside it.
```
VM-A (supplicant) ──[vmbr-secA]── eth1 ┐
│ authenticator node
VM-B (supplicant) ──[vmbr-secB]── eth2 ┤ (hostapd-wired + MACsec;
│ bridges macsecN ↔ eth0)
820 ───[inline box / MACsec switch]── ┘
eth0 │
[vmbr-serve]──────┴── serving host (step-ca, FreeRADIUS,
dnsmasq, NVMe/TCP)
```
Per supplicant-facing bridge, EAPOL must be allowed through:
```bash
echo 8 > /sys/class/net/<bridge>/bridge/group_fwd_mask # forward 01:80:c2:00:00:03
```
### Proxmox commands (VM legs)
```bash
# One isolated bridge per workstation link (vmbr-secA, vmbr-secB) — see your
# /etc/network/interfaces or the Proxmox UI. Then, after each is up:
for b in vmbr-secA vmbr-secB; do echo 8 > /sys/class/net/$b/bridge/group_fwd_mask; done
# Attach the workstation VM's SECURE nic to its isolated bridge:
qm set <vmid-A> --net1 virtio,bridge=vmbr-secA
qm set <vmid-B> --net1 virtio,bridge=vmbr-secB
# Authenticator VM: one nic per workstation link + one on the serving bridge:
qm set <vmid-auth> --net1 virtio,bridge=vmbr-secA \
--net2 virtio,bridge=vmbr-secB \
--net0 virtio,bridge=vmbr-serve
# swtpm for each workstation VM (Proxmox vTPM):
qm set <vmid-A> --tpmstate0 <storage>:1,version=v2.0
```
## Run order
1. **Bring up the CA + RADIUS** on the serving host:
```bash
cd serving/dot1x-bench
docker compose up -d step-ca
./step-ca/bootstrap.sh # init CA, make a provisioner, issue the RADIUS cert
docker compose up -d freeradius
```
`bootstrap.sh` writes the **root** to `pki/root_ca.crt` — copy it to
`files/etc/dot1x/radius-ca.pem` for the image build (this is what the
supplicant uses to trust the RADIUS server).
2. **Build a secure-path image** — `DISABLE_DOT1X=false` (CI var or
`-var disable_dot1x=false`), with `radius-ca.pem` = step-ca root and a
`nvmet_traddr` that resolves over the serving VLAN.
3. **Enrol each workstation's TPM** (one-shot, on a provisioning network that can
reach step-ca) — see `enrol/tpm-provision.sh`: generates the key **in** the TPM
at `0x81010001`, builds a CSR with it, has step-ca sign it, writes the cert to
NV `0x01800001`. Each host gets a unique cert → unique hostnqn.
4. **Set up the authenticator** — `authenticator/setup-link.sh` (bridge + macsec
plumbing per link) then run `hostapd authenticator/hostapd-wired-<link>.conf`.
5. **Boot the workstations** on the secure-path image and check the criteria
below.
## Pass criteria (per booted workstation)
```bash
wpa_cli -p /run/wpa_supplicant -i <nic> status | grep suppPortStatus # = Authorized
ip macsec show # macsec0: GCM-AES cipher, encrypt on, TX/RX SC
ip -s macsec show macsec0 # encrypted-pkts counters incrementing
ip -4 addr show macsec0 # DHCP lease ON macsec0
cat /etc/nvme/hostnqn # unique per host: …:host-<cert-serial>
findmnt / ; nvme list # NVMe root connected + verity-mapped, overlay writable
ps -o args= -C wpa_supplicant # @wpa_supplicant ALIVE after switch-root (invariant #3)
```
Plus the **renewal** check (the reason for the network-manager port): leave it up
past the DHCP lease and confirm `macsec0` keeps its address and the link doesn't
drop (~6 s without MKA keepalives = dead link). On the authenticator,
`tcpdump -i <link> ether proto 0x88e5` should show **only** MACsec frames after
auth — proof the wire is actually encrypted.
## Multi-host point
The whole reason for 2 VMs + the 820: one **identical** image, three **distinct**
TPM certs → three **distinct** hostnqns, each authenticating independently. That
verifies invariant #1 (no per-host state in the image) and lets you test NVMe
host-filtering per NQN.
## Next step: real TPM attestation
This starting solution *signs a TPM-generated CSR* — the key never leaves the
TPM, but step-ca doesn't yet *prove* TPM residency. The upgrade is step-ca's ACME
**`device-attest-01`** provisioner (EK/AK attestation): switch the enrolment from
`step ca sign` to `step ca acme` with a TPM attestation provisioner on the same
CA. That closes the "is the key really in a TPM" gap and strengthens SSP R-01.
See `step-ca/bootstrap.sh` (the commented `acme-attest` provisioner block).
@@ -0,0 +1,33 @@
# hostapd wired 802.1X authenticator + MACsec, ONE per supplicant-facing link.
# Copy per link and set `interface=` to that link's NIC (e.g. eth1 for vmbr-secA).
# Run: hostapd -dd authenticator/hostapd-wired-<link>.conf
#
# BENCH-VERIFY: wired+MACsec authenticator directives vary by hostapd build/version
# (CONFIG_MACSEC + CONFIG_DRIVER_WIRED must be compiled in). Confirm your hostapd
# was built with MACsec; `hostapd -v` / package notes. This is the half most
# likely to need tweaking on first bring-up.
interface=eth1
driver=wired
# --- 802.1X (external RADIUS = FreeRADIUS on the serving host) ---------------
ieee8021x=1
eap_server=0
eapol_version=2
use_pae_group_addr=1 # listen on 01:80:c2:00:00:03 (PAE)
auth_server_addr=192.168.50.10 # serving host (FreeRADIUS)
auth_server_port=1812
auth_server_shared_secret=testing123 # MUST match freeradius/clients.conf + .env RADIUS_SECRET
# --- MACsec / MKA -----------------------------------------------------------
# macsec_policy=1 requires MACsec; the CAK is derived from the EAP MSK and MKA
# negotiates the SAK. This hostapd is the MKA key server for the link.
macsec_policy=1
macsec_integ_only=0 # confidentiality (encrypt), matches the supplicant
macsec_replay_protect=1
macsec_replay_window=0
mka_priority=128 # lower than the supplicant's default => we're key server
# After MKA succeeds the kernel creates a macsec iface on top of `interface`.
# setup-link.sh bridges that to the serving uplink so DHCP/NVMe flow through.
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# setup-link.sh <supplicant-nic> <uplink-nic>
#
# Prep one authenticator link before starting hostapd on it. The authenticator is
# a 2-sided node: <supplicant-nic> faces the workstation (hostapd + MACsec runs
# here), <uplink-nic> faces the serving VLAN (DHCP/NVMe/RADIUS). After MKA
# succeeds hostapd creates a macsec iface over <supplicant-nic>; the DECRYPTED
# traffic on that macsec iface must reach the serving network — we bridge it to
# <uplink-nic>.
#
# Run as root on the authenticator node (VM or inline box). BENCH-VERIFY the
# macsec iface name hostapd creates on your build (assumed "macsec0").
set -euo pipefail
SUP=${1:?usage: setup-link.sh <supplicant-nic> <uplink-nic>}
UP=${2:?uplink nic required}
MACSEC=${3:-macsec0} # iface hostapd creates after MKA (override if different)
BR=${BR:-br-auth}
ip link set "$SUP" up
ip link set "$UP" up
# Allow EAPOL through any bridge in the path (reserved PAE multicast is dropped
# by default). The supplicant-facing side is a raw NIC for hostapd, so this is
# mainly for the uplink bridge.
ip link add name "$BR" type bridge 2>/dev/null || true
echo 8 > "/sys/class/net/$BR/bridge/group_fwd_mask" 2>/dev/null || true
ip link set "$UP" master "$BR"
ip link set "$BR" up
echo "Authenticator link ready:"
echo " supplicant NIC : $SUP (start hostapd here)"
echo " uplink bridge : $BR <- $UP (serving VLAN)"
echo
echo "AFTER hostapd brings MKA up and creates '$MACSEC', bridge it to the uplink:"
echo " ip link set $MACSEC master $BR && ip link set $MACSEC up"
echo "(Do this once $MACSEC exists; automate with a hostapd 'macsec' up-hook if"
echo " your build supports one.)"
+47
View File
@@ -0,0 +1,47 @@
# =============================================================================
# Secure-path bench: step-ca (fleet CA) + FreeRADIUS (EAP-TLS), on the SERVING
# host (the trusted side). The authenticator (hostapd-wired + MACsec) is NOT
# here — it runs on a node between each workstation and this host (see
# authenticator/). Host networking so FreeRADIUS sees RADIUS/1812 from the
# authenticator and step-ca is reachable for enrolment.
#
# cp .env.example .env # set CA_DNS / CA_PASSWORD / RADIUS_SECRET
# docker compose up -d step-ca && ./step-ca/bootstrap.sh && docker compose up -d freeradius
#
# Images are pulled from Docker Hub here; mirror them through your registry
# (like the serving stack does for nginx/alpine) if you don't have egress.
# =============================================================================
services:
# --- Fleet CA (signs TPM-generated CSRs; trust anchor for the image) --------
step-ca:
image: smallstep/step-ca:latest
network_mode: host
restart: unless-stopped
environment:
# Auto-init on first start (smallstep image convention). bootstrap.sh adds
# the enrolment provisioner + issues the RADIUS server cert afterwards.
DOCKER_STEPCA_INIT_NAME: "Fleet Bench CA"
DOCKER_STEPCA_INIT_DNS_NAMES: "${CA_DNS:-ca.fleet.lab},localhost"
DOCKER_STEPCA_INIT_ADDRESS: ":${CA_PORT:-8443}"
DOCKER_STEPCA_INIT_PROVISIONER_NAME: "enrol"
DOCKER_STEPCA_INIT_PASSWORD_FILE: "/home/step/secrets/password"
volumes:
- ./pki/stepca:/home/step # CA state (root/intermediate/keys/db) — gitignored
- ./pki:/export # bootstrap.sh drops root_ca.crt here for the image/radius
# --- RADIUS (EAP-TLS; validates client cert vs step-ca root, returns MSK) ---
freeradius:
image: freeradius/freeradius-server:latest
network_mode: host # RADIUS 1812/1813 from the authenticator
restart: unless-stopped
depends_on:
- step-ca
# -X = full debug to stdout; priceless for the first bring-up. Drop to plain
# `radiusd -f` once EAP-TLS is green.
command: ["radiusd", "-X"]
volumes:
- ./freeradius/clients.conf:/etc/raddb/clients.conf:ro
- ./freeradius/mods-available/eap:/etc/raddb/mods-available/eap:ro
# certs issued by bootstrap.sh: the RADIUS server cert/key + the CA root.
- ./pki/radius:/etc/raddb/certs/fleet:ro
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# tpm-provision.sh <host-id>
#
# One-shot enrolment for ONE workstation. Models the real flow (invariant #1:
# per-host identity is created at enrolment, never baked into the image):
# 1. generate a signing key INSIDE the TPM, persist it at handle 0x81010001
# 2. build a CSR using that TPM key (private key never leaves the TPM)
# 3. have step-ca sign it (token-gated 'enrol' provisioner)
# 4. write the issued cert (DER) to TPM NV index 0x01800001
# At boot the dracut hook reads the cert from NV and wpa_supplicant signs EAP-TLS
# with the persistent-handle key. hostnqn is later derived from the cert serial.
#
# Run on the workstation (booted into a provisioning env with tpm2-tools, an
# openssl TPM2 provider, and the `step` CLI) on a network that can reach step-ca.
# Works for swtpm (VMs) and a real TPM (820) — same /dev/tpmrm0 in-guest.
#
# Needs in this dir: root_ca.crt + ca-pw (copied from the serving host's
# serving/dot1x-bench/pki/{root_ca.crt,stepca/secrets/password}).
#
# *** BENCH-VERIFY (CLAUDE.md "Known bench-verify items"): the TPM key reference
# *** for openssl AND wpa_supplicant differs by distro build — OpenSSL TPM2
# *** PROVIDER (handle:0x...) vs tpm2-pkcs11 URI. This script uses the provider
# *** form; switch to the engine/pkcs11 form if your build needs it, and keep it
# *** consistent with files/etc/dot1x/wpa_supplicant-dot1x.conf.
set -euo pipefail
HOSTID=${1:?usage: tpm-provision.sh <host-id> e.g. ws-vm-a}
HANDLE=${HANDLE:-0x81010001}
NVINDEX=${NVINDEX:-0x01800001}
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
CA_DNS=${CA_DNS:-ca.fleet.lab}; CA_PORT=${CA_PORT:-8443}
CA_URL="https://${CA_DNS}:${CA_PORT}"
cd "$(dirname "$0")"
OUT=out; mkdir -p "$OUT"
[ -f root_ca.crt ] || { echo "missing root_ca.crt (copy from serving pki/)" >&2; exit 1; }
[ -f ca-pw ] || { echo "missing ca-pw (copy pki/stepca/secrets/password)" >&2; exit 1; }
# --- 1. persistent TPM signing key ------------------------------------------
if ! tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then
echo "==> creating TPM signing key, persisting at $HANDLE"
tpm2_createprimary -C o -G ecc -g sha256 -c "$OUT/primary.ctx" >/dev/null
tpm2_create -C "$OUT/primary.ctx" -G ecc256:ecdsa-sha256 \
-u "$OUT/key.pub" -r "$OUT/key.priv" >/dev/null
tpm2_load -C "$OUT/primary.ctx" -u "$OUT/key.pub" -r "$OUT/key.priv" \
-c "$OUT/key.ctx" >/dev/null
tpm2_evictcontrol -C o -c "$OUT/key.ctx" "$HANDLE" >/dev/null
else
echo "==> reusing existing persistent key at $HANDLE"
fi
# --- 2. CSR signed by the TPM key (key stays in the TPM) ---------------------
echo "==> building CSR (CN=$HOSTID) with the TPM key via the openssl tpm2 provider"
openssl req -new -provider tpm2 -provider default \
-key "handle:$HANDLE" \
-subj "/O=fleet/CN=$HOSTID" \
-out "$OUT/$HOSTID.csr"
# --- 3. step-ca signs the CSR -----------------------------------------------
echo "==> requesting cert from step-ca"
TOKEN=$(step ca token "$HOSTID" --provisioner enrol \
--ca-url "$CA_URL" --root root_ca.crt \
--provisioner-password-file ca-pw --not-after 720h)
step ca sign "$OUT/$HOSTID.csr" "$OUT/$HOSTID.pem" \
--token "$TOKEN" --ca-url "$CA_URL" --root root_ca.crt
openssl x509 -in "$OUT/$HOSTID.pem" -outform DER -out "$OUT/$HOSTID.der"
# --- 4. write cert into TPM NV ----------------------------------------------
SZ=$(stat -c%s "$OUT/$HOSTID.der")
echo "==> writing $SZ-byte cert to NV $NVINDEX"
tpm2_nvundefine "$NVINDEX" -C o 2>/dev/null || true
tpm2_nvdefine "$NVINDEX" -C o -s "$SZ" -a 'ownerread|ownerwrite|authread|authwrite' >/dev/null
tpm2_nvwrite "$NVINDEX" -C o -i "$OUT/$HOSTID.der"
SERIAL=$(openssl x509 -in "$OUT/$HOSTID.pem" -serial -noout | cut -d= -f2 | tr 'A-F' 'a-f')
echo "==> enrolled $HOSTID"
echo " cert serial : $SERIAL"
echo " boot hostnqn: nqn.2026-06.org.example.fleet:host-$SERIAL"
echo " key @ $HANDLE, cert @ NV $NVINDEX — the secure-path image will read these."
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
# tpm-selftest.sh — non-destructive check that a workstation's TPM can do
# everything the secure path needs, BEFORE turning on disable_dot1x=false.
#
# Run as root on a booted workstation (the open-port test image is fine — it
# ships the same tooling). Uses SCRATCH handle/NV and cleans them up, so it never
# touches the real 0x81010001 / 0x01800001. Pass --use-real-handles to exercise
# the actual locations instead (still cleaned up unless --keep).
#
# It validates, in order:
# 1. TPM present + tpm2-tools work
# 2. persistent key create + evict (needed to put the EAP-TLS key at the handle)
# 3. NV define/write/read roundtrip (the cert store the boot hook reads)
# 4. an OpenSSL TPM2 provider/engine exists <-- the usual missing piece
# 5. a CSR + signature USING the TPM key (proves EAP-TLS client auth can work)
# 6. wpa_supplicant build info (macsec driver present?)
set -u
USE_REAL=0; KEEP=0
for a in "$@"; do case "$a" in
--use-real-handles) USE_REAL=1 ;;
--keep) KEEP=1 ;;
*) echo "usage: tpm-selftest.sh [--use-real-handles] [--keep]" >&2; exit 2 ;;
esac; done
export TPM2TOOLS_TCTI="${TPM2TOOLS_TCTI:-device:/dev/tpmrm0}"
if [ "$USE_REAL" = 1 ]; then HANDLE=0x81010001; NV=0x01800001
else HANDLE=0x81008000; NV=0x01500000; fi
WORK=$(mktemp -d); PASS=0; FAIL=0; SKIP=0
ok() { printf ' \033[32mPASS\033[0m %s\n' "$1"; PASS=$((PASS+1)); }
no() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); }
skip() { printf ' \033[33mSKIP\033[0m %s\n' "$1"; SKIP=$((SKIP+1)); }
# shellcheck disable=SC2329 # invoked indirectly via trap
cleanup() {
[ "$KEEP" = 1 ] && { echo "(--keep: leaving $HANDLE / $NV / $WORK in place)"; return; }
tpm2_evictcontrol -C o -c "$HANDLE" >/dev/null 2>&1 || true
tpm2_nvundefine "$NV" -C o >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
echo "== TPM self-test (handle=$HANDLE nv=$NV) =="
# --- 1. TPM present ---------------------------------------------------------
if [ -c /dev/tpmrm0 ] || [ -c /dev/tpm0 ]; then ok "TPM device present"; else
no "no /dev/tpmrm0 or /dev/tpm0 — VM needs a vTPM (qm set <id> --tpmstate0 <stor>:1,version=v2.0)"
echo; echo "Cannot continue without a TPM."; exit 1
fi
if command -v tpm2_getcap >/dev/null && tpm2_getcap properties-fixed >/dev/null 2>&1; then
ok "tpm2-tools talk to the TPM"
else
no "tpm2-tools / TPM access failed"; exit 1
fi
# pre-clean the scratch locations in case a previous run aborted
tpm2_evictcontrol -C o -c "$HANDLE" >/dev/null 2>&1 || true
tpm2_nvundefine "$NV" -C o >/dev/null 2>&1 || true
# --- 2. persistent signing key ----------------------------------------------
if tpm2_createprimary -C o -G ecc -g sha256 -c "$WORK/prim.ctx" >/dev/null 2>&1 \
&& tpm2_create -C "$WORK/prim.ctx" -G ecc256:ecdsa-sha256 -u "$WORK/k.pub" -r "$WORK/k.priv" >/dev/null 2>&1 \
&& tpm2_load -C "$WORK/prim.ctx" -u "$WORK/k.pub" -r "$WORK/k.priv" -c "$WORK/k.ctx" >/dev/null 2>&1 \
&& tpm2_evictcontrol -C o -c "$WORK/k.ctx" "$HANDLE" >/dev/null 2>&1 \
&& tpm2_readpublic -c "$HANDLE" >/dev/null 2>&1; then
ok "created + persisted an ECC signing key at $HANDLE"
else
no "could not create/persist a key at $HANDLE"
fi
# --- 3. NV roundtrip (the cert store the boot hook reads) -------------------
echo "fleet-tpm-selftest-$(date +%s 2>/dev/null || echo x)" > "$WORK/blob"
SZ=$(stat -c%s "$WORK/blob")
if tpm2_nvdefine "$NV" -C o -s "$SZ" -a 'ownerread|ownerwrite|authread|authwrite' >/dev/null 2>&1 \
&& tpm2_nvwrite "$NV" -C o -i "$WORK/blob" >/dev/null 2>&1 \
&& tpm2_nvread "$NV" -C o -o "$WORK/blob.out" >/dev/null 2>&1 \
&& cmp -s "$WORK/blob" "$WORK/blob.out"; then
ok "NV define/write/read roundtrip at $NV (matches what the boot hook does)"
else
no "NV roundtrip at $NV failed"
fi
# --- 4. OpenSSL TPM2 provider / engine --------------------------------------
TPM_OSSL=""
if openssl list -providers 2>/dev/null | grep -qi tpm2; then
TPM_OSSL=provider; ok "OpenSSL TPM2 PROVIDER present (tpm2-openssl)"
elif openssl engine -t tpm2tss 2>/dev/null | grep -qi available; then
TPM_OSSL=engine; ok "OpenSSL TPM2 ENGINE present (tpm2-tss-engine)"
else
no "no OpenSSL TPM2 provider OR engine — install 'tpm2-openssl' (provider) in the image"
echo " (this is the likely gap: tpm2-tss alone does NOT give openssl TPM access)"
fi
# --- 5. CSR + signature USING the TPM key (the EAP-TLS make-or-break) --------
if [ -n "$TPM_OSSL" ]; then
CSR_OK=1
if [ "$TPM_OSSL" = provider ]; then
openssl req -new -provider tpm2 -provider default -key "handle:$HANDLE" \
-subj "/O=fleet/CN=tpm-selftest" -out "$WORK/test.csr" >/dev/null 2>&1 || CSR_OK=0
else
openssl req -new -engine tpm2tss -keyform engine -key "$HANDLE" \
-subj "/O=fleet/CN=tpm-selftest" -out "$WORK/test.csr" >/dev/null 2>&1 || CSR_OK=0
fi
if [ "$CSR_OK" = 1 ] && openssl req -in "$WORK/test.csr" -verify -noout >/dev/null 2>&1; then
ok "built + self-verified a CSR signed BY the TPM key ($TPM_OSSL) — EAP-TLS can use it"
else
no "openssl could not sign a CSR with the TPM key via the $TPM_OSSL"
echo " -> the key reference method in wpa_supplicant-dot1x.conf needs adjusting"
fi
else
skip "CSR/sign test (no openssl TPM integration to test with)"
fi
# --- 6. wpa_supplicant build info -------------------------------------------
if command -v wpa_supplicant >/dev/null; then
V=$(wpa_supplicant -v 2>&1 | head -1)
ok "wpa_supplicant present: $V"
echo " NOTE: confirm it was built with macsec_linux + the matching TPM key"
echo " backend; full EAP-TLS/MKA can only be proven against the authenticator."
else
no "wpa_supplicant not found"
fi
echo
echo "== summary: $PASS pass, $FAIL fail, $SKIP skip =="
[ "$FAIL" = 0 ] && echo "TPM half looks ready — proceed to the authenticator/RADIUS bench." \
|| echo "Fix the FAILs above before enabling disable_dot1x=false."
exit "$FAIL"
@@ -0,0 +1,9 @@
# RADIUS clients = the authenticator(s) (hostapd). The `secret` MUST match each
# hostapd's auth_server_shared_secret (RADIUS_SECRET in .env). Scope ipaddr to
# the serving VLAN the authenticator's uplink sits on.
client authenticator {
ipaddr = 192.168.50.0/24
secret = testing123
nas_type = other
require_message_authenticator = yes
}
@@ -0,0 +1,37 @@
# EAP module, trimmed to EAP-TLS for the fleet bench. Replaces the stock eap mod.
# EAP-TLS is mutual-cert by definition: the client (TPM cert) is validated against
# ca_file (= step-ca root); the server presents server.pem so the supplicant can
# validate it against the SAME root baked into the image as radius-ca.pem. On
# success FreeRADIUS exports the MSK (MS-MPPE keys) → the authenticator derives
# the MACsec CAK (MKA). No `users` entry is needed for cert-only auth.
eap {
default_eap_type = tls
timer_expire = 60
ignore_unknown_eap_types = no
cisco_accounting_username_bug = no
max_sessions = ${max_requests}
tls-config tls-common {
certificate_file = /etc/raddb/certs/fleet/server.pem
private_key_file = /etc/raddb/certs/fleet/server.key
# private_key_password unset — step-ca issues an unencrypted key here.
ca_file = /etc/raddb/certs/fleet/ca.pem # step-ca root: validates client (TPM) certs
dh_file = ${certdir}/dh # shipped by the image's bootstrap
random_file = /dev/urandom
# BENCH-VERIFY: many wired-MACsec supplicants negotiate EAP-TLS 1.2; bump
# to 1.3 once confirmed working. Keep min>=1.2.
tls_min_version = "1.2"
tls_max_version = "1.2"
cipher_list = "DEFAULT"
cipher_server_preference = no
ecdh_curve = ""
# OCSP/CRL against step-ca for revocation can be enabled here later
# (ocsp { enable = yes ... }) — bench accepts any cert signed by the root.
}
tls {
tls = tls-common
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# bootstrap.sh — one-time setup of the bench CA after `docker compose up -d step-ca`.
#
# 1. wait for step-ca, export its root to pki/root_ca.crt (-> image radius-ca.pem)
# 2. ensure an enrolment provisioner exists (signs each host's TPM CSR)
# 3. issue the RADIUS server cert/key into pki/radius/ (FreeRADIUS mounts it)
#
# Runs the `step` CLI against the running CA. Flags vary a little across step
# versions — BENCH-VERIFY against your `step version` and adjust. Reads .env.
set -euo pipefail
cd "$(dirname "$0")/.."
[ -f .env ] && . ./.env
CA_DNS=${CA_DNS:-ca.fleet.lab}
CA_PORT=${CA_PORT:-8443}
CA_URL="https://${CA_DNS}:${CA_PORT}"
PKI=pki
mkdir -p "$PKI/radius"
# The CA's password (auto-init wrote it from this file in the container volume).
PW="$PKI/stepca/secrets/password"
[ -f "$PW" ] || { echo "CA not initialised yet ($PW missing) — is step-ca up?" >&2; exit 1; }
step() { docker compose exec -T step-ca step "$@"; }
# --- 1. export the root (becomes files/etc/dot1x/radius-ca.pem) --------------
echo "==> exporting CA root"
docker compose exec -T step-ca cat /home/step/certs/root_ca.crt > "$PKI/root_ca.crt"
FP=$(step certificate fingerprint "$PKI/root_ca.crt" 2>/dev/null || \
openssl x509 -in "$PKI/root_ca.crt" -noout -fingerprint -sha256 | cut -d= -f2)
echo " root fingerprint: $FP"
echo " copy to: files/etc/dot1x/radius-ca.pem (image trust anchor)"
# --- 2. enrolment provisioner (idempotent) ----------------------------------
# JWK provisioner 'enrol' is created by auto-init (DOCKER_STEPCA_INIT_PROVISIONER_NAME).
# Enrolment authorizes a CSR with a one-time token:
# step ca token <host-id> --provisioner enrol → passed to `step ca sign`
# That token-gated sign is what enrol/tpm-provision.sh uses.
echo "==> provisioners:"
step ca provisioner list --ca-url "$CA_URL" --root "$PKI/root_ca.crt" \
| grep -E '"(name|type)"' || true
# To UPGRADE to TPM attestation later, add an ACME attestation provisioner and
# switch enrolment to device-attest-01 (key residency is then PROVEN, not trusted):
# step ca provisioner add acme-attest --type ACME \
# --challenge device-attest-01 \
# --attestation-format tpm \
# --attestation-root <tpm-vendor-EK-roots.pem>
# (then `step ca acme` / an ACME client on the host instead of `step ca sign`.)
# --- 3. RADIUS server cert (FreeRADIUS serves this; supplicant validates it) --
echo "==> issuing RADIUS server cert (radius.${CA_DNS#ca.})"
RADIUS_CN="radius.${CA_DNS#ca.}"
step ca certificate "$RADIUS_CN" \
"$PKI/radius/server.pem" "$PKI/radius/server.key" \
--ca-url "$CA_URL" --root "$PKI/root_ca.crt" \
--provisioner enrol --provisioner-password-file "$PW" \
--not-after 720h --force
cp "$PKI/root_ca.crt" "$PKI/radius/ca.pem"
echo "==> done. FreeRADIUS will read pki/radius/{server.pem,server.key,ca.pem}."
echo " Next: cp $PKI/root_ca.crt ../../files/etc/dot1x/radius-ca.pem && rebuild secure-path image."
+1
Submodule serving/kiosk-app added at f0d0677950
-5
View File
@@ -1,5 +0,0 @@
node_modules
dist
.vite
.git
*.local
-4
View File
@@ -1,4 +0,0 @@
node_modules
dist
.vite
*.local
-27
View File
@@ -1,27 +0,0 @@
# syntax=docker/dockerfile:1
#
# Two-stage build: compile the Svelte SPA with Vite, then serve the static
# bundle from a tiny nginx. The main serving-stack nginx reverse-proxies
# /kiosk/ to this container (see ../docker-compose.yml + ../http/default.conf),
# so this image only needs to serve the built assets at its own root.
# ---- build stage ----
FROM harbor.bitasylum.org/dockerhub/library/node:22-alpine AS build
WORKDIR /app
# Install deps first for layer caching. package-lock.json is committed, so use
# `npm ci` for a reproducible, lockfile-exact install.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build # -> /app/dist (base path /kiosk/)
# ---- serve stage ----
FROM harbor.bitasylum.org/dockerhub/library/nginx:stable-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1
-54
View File
@@ -1,54 +0,0 @@
# Fleet Kiosk App
A cyber sci-fi **spaceship monitoring & control terminal** — the page the
diskless kiosk (weston + Chromium) loads at boot. Replaces the old single static
`serving/http/kiosk/index.html` test page.
Built with **Svelte 5** + **Tailwind CSS v4** + **Vite**, served as a static
bundle from its own nginx, and reverse-proxied at `/kiosk/` by the main serving
nginx (`../http/default.conf`). Fully self-contained — no CDNs or web fonts — so
it renders on the air-gapped boot LAN (the stack's dnsmasq sinkholes external
call-home).
## Pages
Top navbar with five views, each with dummy telemetry:
- **BRIDGE** — overview tiles, systems status, subsystem load, event log
- **REACTOR** — core output/temp/containment, power distribution, fuel cells
- **NAVIGATION** — heading/velocity, tactical radar plot, flight plan, FTL drive
- **LIFE SUPPORT** — atmosphere mix, per-deck environment, recyclers
- **COMMS** — channels, signal bars, message traffic
Routing is hash-based (`#/power`, `#/nav`, …) so deep links stay client-side and
the proxy needs no SPA-fallback rewrite. A shared 1 Hz clock and small
random-walk "jitter" helpers make the static data feel live.
## Develop
```bash
npm install
npm run dev # http://localhost:5173/kiosk/
```
## Build the container
It builds as part of the serving stack:
```bash
cd .. # serving/
docker compose build kiosk
docker compose up -d
# main nginx serves it at http://${SERVER_IP}/kiosk/ (kiosk_url default)
```
The container publishes on `127.0.0.1:${KIOSK_APP_PORT:-8088}`; the host-network
main nginx proxies `/kiosk/` to it. To point the diskless kiosk here, keep the
Packer `kiosk_url=http://boot.fleet.lab/kiosk/` default (resolved to the serving
host by the stack's dnsmasq).
## Replacing the dummy data
All fictional telemetry lives in [src/lib/data.js](src/lib/data.js). A future
runtime agent could swap these constants for a `fetch()` to a fleet API; the
components only read from that module.
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>FLEET // BRIDGE TERMINAL</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
-25
View File
@@ -1,25 +0,0 @@
# Internal nginx for the kiosk SPA container. The main serving nginx proxies
# /kiosk/ here with the prefix stripped (proxy_pass .../;), so requests arrive
# at this server rooted at "/". try_files gives the SPA fallback; routing is
# hash-based anyway, so this fallback only matters for a hard refresh on "/".
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Don't cache the entry document (so a new build is picked up immediately);
# the hashed assets under /assets/ are immutable and cached hard.
location = /index.html {
add_header Cache-Control "no-cache";
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}
-2075
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"name": "fleet-kiosk-app",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Cyber sci-fi spaceship monitoring/control terminal (Svelte + Tailwind) shown by the diskless kiosk.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.19.0",
"tailwindcss": "^4.0.0",
"vite": "^6.0.7"
}
}
-44
View File
@@ -1,44 +0,0 @@
<script>
import Navbar from './lib/Navbar.svelte'
import StatusBar from './lib/StatusBar.svelte'
import { nav } from './lib/router.svelte.js'
import { clock, hhmmss } from './lib/clock.svelte.js'
import Dashboard from './pages/Dashboard.svelte'
import Power from './pages/Power.svelte'
import Navigation from './pages/Navigation.svelte'
import LifeSupport from './pages/LifeSupport.svelte'
import Comms from './pages/Comms.svelte'
const pages = {
dashboard: Dashboard,
power: Power,
nav: Navigation,
life: LifeSupport,
comms: Comms,
}
let Current = $derived(pages[nav.current] ?? Dashboard)
const ticker =
'SYS // all primary buses nominal ◦ FTL capacitor charging — 61% ◦ ' +
'sensor sweep grid 7-K complete ◦ comms relay 2 degraded — rerouting ◦ ' +
'reactor output 84.2% rated ◦ next waypoint: Tarsis Station, ETA 04:12:38 ◦ '
</script>
<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>
<footer class="flex items-center gap-3 overflow-hidden border-t border-haze/20 bg-void/70 px-4 py-1.5">
<span class="shrink-0 text-[0.6rem] uppercase tracking-[0.2em] text-flare glow-sm">● LIVE</span>
<span class="shrink-0 text-[0.6rem] tabular-nums text-haze">{hhmmss(clock.now)}</span>
<div class="relative flex-1 overflow-hidden">
<span class="ticker text-[0.65rem] uppercase tracking-[0.18em] text-haze">{ticker}{ticker}</span>
</div>
</footer>
</div>
-95
View File
@@ -1,95 +0,0 @@
@import "tailwindcss";
/* Cyber sci-fi palette. Defining these as @theme tokens makes them available
as Tailwind utilities (bg-hull, text-term, border-haze, ...). Font is the
stock DejaVu Sans Mono shipped in the kiosk image — no web fonts (offline). */
@theme {
--color-void: #040a0d;
--color-hull: #081419;
--color-hull2: #0c1f27;
--color-term: #00ffd0;
--color-haze: #4a8c84;
--color-flare: #ff2e88;
--color-warn: #ffb000;
--color-ok: #2dff8a;
--color-alert: #ff3b3b;
--font-mono: "DejaVu Sans Mono", ui-monospace, "SFMono-Regular", "Courier New", monospace;
}
:root { color-scheme: dark; }
html, body, #app { height: 100%; margin: 0; padding: 0; }
body {
background-color: var(--color-void);
color: var(--color-term);
font-family: var(--font-mono);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Faint engineering-grid backdrop + center glow + vignette. */
#app::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(0, 255, 208, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 255, 208, 0.035) 1px, transparent 1px),
radial-gradient(circle at 50% 22%, rgba(0, 90, 100, 0.35), transparent 65%);
background-size: 42px 42px, 42px 42px, 100% 100%;
}
/* CRT scanline overlay sitting above content. */
#app::after {
content: "";
position: fixed;
inset: 0;
z-index: 60;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0, 0, 0, 0) 0px,
rgba(0, 0, 0, 0) 2px,
rgba(0, 0, 0, 0.16) 3px
);
mix-blend-mode: multiply;
}
.glow { text-shadow: 0 0 10px currentColor; }
.glow-sm { text-shadow: 0 0 6px currentColor; }
/* Cut-corner panel edge — the recurring "console module" silhouette. */
.cut {
clip-path: polygon(
0 0,
calc(100% - 14px) 0,
100% 14px,
100% 100%,
14px 100%,
0 calc(100% - 14px)
);
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.2; } }
.blink { animation: blink 1.4s steps(2, jump-none) infinite; }
@keyframes ticker { from { transform: translateX(100%); } to { transform: translateX(-100%); } }
.ticker { display: inline-block; white-space: nowrap; animation: ticker 32s linear infinite; }
/* Radar sweep wedge. */
@keyframes radar { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.radar-sweep {
background: conic-gradient(from 0deg, rgba(0, 255, 208, 0.28), rgba(0, 255, 208, 0) 70deg);
border-radius: 9999px;
animation: radar 4s linear infinite;
}
/* Thin neon scrollbars to match the theme. */
* { scrollbar-width: thin; scrollbar-color: rgba(0, 255, 208, 0.3) transparent; }
*::-webkit-scrollbar { width: 8px; height: 8px; }
*::-webkit-scrollbar-thumb { background: rgba(0, 255, 208, 0.3); }
*::-webkit-scrollbar-track { background: transparent; }
-15
View File
@@ -1,15 +0,0 @@
<script>
// Horizontal gauge. `tone` is a full bg-* class (statically scannable).
let { label, pct = 0, tone = 'bg-term', value = null, unit = '%' } = $props()
let clamped = $derived(Math.max(0, Math.min(100, pct)))
</script>
<div class="space-y-1">
<div class="flex items-baseline justify-between text-[0.65rem] uppercase tracking-wider">
<span class="text-haze">{label}</span>
<span class="text-term tabular-nums glow-sm">{value ?? clamped}{unit}</span>
</div>
<div class="h-2 w-full overflow-hidden border border-haze/30 bg-void">
<div class="h-full {tone} transition-[width] duration-700 ease-out" style="width:{clamped}%"></div>
</div>
</div>
-31
View File
@@ -1,31 +0,0 @@
<script>
import { navItems, ship } from './data.js'
import { nav } from './router.svelte.js'
</script>
<nav class="flex items-stretch border-b border-haze/30 bg-hull/80">
<div class="flex items-center gap-3 border-r border-haze/30 px-4 py-3">
<span class="text-flare glow text-2xl leading-none">◭</span>
<div class="leading-tight">
<div class="text-term glow-sm text-sm tracking-[0.25em]">{ship.name}</div>
<div class="text-haze text-[0.6rem] tracking-[0.2em]">{ship.registry}</div>
</div>
</div>
<div class="flex flex-1 items-stretch overflow-x-auto">
{#each navItems as item}
<a
href="#/{item.id}"
aria-current={nav.current === item.id ? 'page' : undefined}
class="group relative flex items-center gap-2 px-4 py-3 text-xs uppercase tracking-[0.2em] transition-colors
{nav.current === item.id ? 'text-term glow-sm bg-term/5' : 'text-haze hover:text-term hover:bg-term/5'}"
>
<span class="text-base leading-none">{item.glyph}</span>
<span class="hidden md:inline">{item.label}</span>
{#if nav.current === item.id}
<span class="absolute inset-x-0 bottom-0 h-[2px] bg-term glow"></span>
{/if}
</a>
{/each}
</div>
</nav>
-17
View File
@@ -1,17 +0,0 @@
<script>
// Reusable console "module": cut-corner border, header strip, body slot.
// `accent` is a full Tailwind class string so the class is statically visible
// to Tailwind's scanner (e.g. "text-flare").
let { title = '', accent = 'text-term', children } = $props()
</script>
<section class="cut relative border border-haze/30 bg-hull/70">
<div class="absolute left-0 top-0 h-[2px] w-12 bg-term/70"></div>
<header class="flex items-center gap-2 border-b border-haze/25 bg-hull2/50 px-3 py-2">
<span class="text-term/60 text-xs">▌</span>
<h2 class="text-[0.7rem] uppercase tracking-[0.3em] {accent} glow-sm">{title}</h2>
</header>
<div class="p-3 md:p-4">
{@render children?.()}
</div>
</section>
-25
View File
@@ -1,25 +0,0 @@
<script>
// Sweeping radar with positioned blips. `blips`: [{x,y,tone,label}] in %.
let { blips = [] } = $props()
</script>
<div class="relative mx-auto aspect-square w-full max-w-[300px] overflow-hidden rounded-full border border-haze/40 bg-void/60">
<div class="absolute inset-[14%] rounded-full border border-haze/20"></div>
<div class="absolute inset-[32%] rounded-full border border-haze/20"></div>
<div class="absolute inset-[48%] rounded-full border border-haze/20"></div>
<div class="absolute left-1/2 top-0 h-full w-px -translate-x-1/2 bg-haze/15"></div>
<div class="absolute top-1/2 left-0 h-px w-full -translate-y-1/2 bg-haze/15"></div>
<div class="radar-sweep absolute inset-0"></div>
<!-- own ship -->
<div class="absolute left-1/2 top-1/2 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rotate-45 bg-flare glow"></div>
{#each blips as b}
<div
class="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full {b.tone ?? 'bg-term'} glow blink"
style="left:{b.x}%; top:{b.y}%"
title={b.label}
></div>
{/each}
</div>
-11
View File
@@ -1,11 +0,0 @@
<script>
let { label, value, unit = '', tone = 'text-term', sub = '' } = $props()
</script>
<div class="flex flex-col gap-1">
<span class="text-[0.6rem] uppercase tracking-[0.2em] text-haze">{label}</span>
<span class="text-2xl md:text-3xl font-bold tabular-nums {tone} glow-sm leading-none">
{value}{#if unit}<span class="ml-1 text-sm font-normal text-haze">{unit}</span>{/if}
</span>
{#if sub}<span class="text-[0.65rem] text-haze">{sub}</span>{/if}
</div>
@@ -1,19 +0,0 @@
<script>
import { ship } from './data.js'
import { clock, stardate, hhmmss } from './clock.svelte.js'
</script>
<div class="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 border-b border-haze/20 bg-void/60 px-4 py-1.5 text-[0.65rem] uppercase tracking-[0.18em] text-haze">
<div class="flex flex-wrap items-center gap-x-6 gap-y-1">
<span><span class="text-haze/70">CLASS</span> <span class="text-term">{ship.class}</span></span>
<span><span class="text-haze/70">CMD</span> <span class="text-term">{ship.captain}</span></span>
</div>
<div class="flex flex-wrap items-center gap-x-6 gap-y-1">
<span class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full bg-ok glow blink"></span>
<span class="text-ok glow-sm">CONDITION GREEN</span>
</span>
<span><span class="text-haze/70">STARDATE</span> <span class="text-term tabular-nums">{stardate(clock.now)}</span></span>
<span><span class="text-haze/70">MISSION TIME</span> <span class="text-term tabular-nums glow-sm">{hhmmss(clock.now)}</span></span>
</div>
</div>
-16
View File
@@ -1,16 +0,0 @@
<script>
// Status pill. Literal class map so every variant survives Tailwind purging.
let { tone = 'term', children } = $props()
const map = {
term: 'border-term/60 text-term',
ok: 'border-ok/60 text-ok',
warn: 'border-warn/60 text-warn',
alert: 'border-alert/60 text-alert',
flare: 'border-flare/60 text-flare',
haze: 'border-haze/60 text-haze',
}
</script>
<span class="inline-flex items-center gap-1 border px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] glow-sm {map[tone] ?? map.term}">
{@render children?.()}
</span>
-24
View File
@@ -1,24 +0,0 @@
// Shared 1 Hz clock. Module-level $state + a single interval, so every consumer
// (status bar, footer) shares one timer. Read `clock.now` reactively.
let _now = $state(new Date())
if (typeof window !== 'undefined') {
setInterval(() => { _now = new Date() }, 1000)
}
export const clock = {
get now() { return _now },
}
// Playful "stardate" derived from the wall clock, purely cosmetic.
export function stardate(d) {
const epoch = Date.UTC(2350, 0, 1)
const days = (d.getTime() - epoch) / 86_400_000
return (47000 + days / 3.65).toFixed(1)
}
export function hhmmss(d) {
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
-137
View File
@@ -1,137 +0,0 @@
// Dummy data for the spaceship terminal. All static/fictional — no real
// telemetry source yet. A future runtime agent could replace these with a
// fetch() to a fleet API; the components only read from here.
export const navItems = [
{ id: 'dashboard', label: 'BRIDGE', glyph: '◈' },
{ id: 'power', label: 'REACTOR', glyph: '⬡' },
{ id: 'nav', label: 'NAVIGATION', glyph: '✧' },
{ id: 'life', label: 'LIFE SUPPORT', glyph: '❂' },
{ id: 'comms', label: 'COMMS', glyph: '☰' },
]
export const ship = {
name: 'ISV AURELIAN',
registry: 'FN-774-Δ',
class: 'Lancer-class Deep Survey Cruiser',
captain: 'Cmdr. V. Okonkwo',
}
// --- BRIDGE / dashboard ------------------------------------------------------
export const systemStatus = [
{ name: 'Reactor Core', state: 'NOMINAL', tone: 'ok' },
{ name: 'Shield Matrix', state: 'NOMINAL', tone: 'ok' },
{ name: 'FTL Drive', state: 'CHARGING', tone: 'warn' },
{ name: 'Life Support', state: 'NOMINAL', tone: 'ok' },
{ name: 'Hull Integrity', state: '98.4%', tone: 'ok' },
{ name: 'Weapons', state: 'STANDBY', tone: 'term' },
{ name: 'Sensor Array', state: 'NOMINAL', tone: 'ok' },
{ name: 'Comms Relay', state: 'DEGRADED', tone: 'alert' },
]
export const subsystemLoad = [
{ label: 'Propulsion', pct: 72, tone: 'bg-term' },
{ label: 'Shields', pct: 64, tone: 'bg-term' },
{ label: 'Life Support', pct: 38, tone: 'bg-ok' },
{ label: 'Sensors', pct: 51, tone: 'bg-term' },
{ label: 'FTL Charge', pct: 61, tone: 'bg-warn' },
{ label: 'Weapons', pct: 9, tone: 'bg-haze' },
]
export const logEntries = [
{ t: '07:42:11', tone: 'warn', msg: 'Jump capacitor at 61% — full charge ETA 00:18:24' },
{ t: '07:39:02', tone: 'alert', msg: 'Comms relay 2 packet loss 14% — rerouting via relay 1' },
{ t: '07:31:55', tone: 'term', msg: 'Sensor sweep complete — 3 contacts logged in grid 7-K' },
{ t: '07:28:40', tone: 'ok', msg: 'Hull micro-fracture sealed on deck 4, section C' },
{ t: '07:14:09', tone: 'ok', msg: 'Reactor output stabilised at 84.2% rated' },
{ t: '06:58:21', tone: 'term', msg: 'Course correction +0.4° applied to heading 287' },
]
// --- REACTOR / power ---------------------------------------------------------
export const reactor = {
output: 84.2, // % rated
coreTemp: 4120, // K
containment: 99.1, // % field strength
rpm: 11840, // turbine
}
export const powerDistribution = [
{ label: 'Propulsion Bus', pct: 72, tone: 'bg-term' },
{ label: 'Shield Emitters', pct: 64, tone: 'bg-term' },
{ label: 'FTL Capacitor', pct: 61, tone: 'bg-warn' },
{ label: 'Sensors / EW', pct: 51, tone: 'bg-term' },
{ label: 'Life Support', pct: 38, tone: 'bg-ok' },
{ label: 'Habitation', pct: 27, tone: 'bg-ok' },
{ label: 'Weapons Standby', pct: 9, tone: 'bg-haze' },
]
export const fuelCells = [
{ id: 'CELL-A', charge: 96, tone: 'bg-ok' },
{ id: 'CELL-B', charge: 91, tone: 'bg-ok' },
{ id: 'CELL-C', charge: 78, tone: 'bg-term' },
{ id: 'CELL-D', charge: 44, tone: 'bg-warn' },
]
// --- NAVIGATION --------------------------------------------------------------
export const navState = {
heading: 287.4, // degrees
velocity: 0.21, // c
system: 'Cygnus Reach',
sector: 'Grid 7-K',
destination: 'Tarsis Station',
eta: '04:12:38',
ftlCharge: 61,
coords: { x: '+1184.22', y: '-0473.90', z: '+0061.07' },
}
export const waypoints = [
{ name: 'Tarsis Station', dist: '2.4 AU', status: 'LOCKED', tone: 'ok' },
{ name: 'Helex Belt', dist: '0.8 AU', status: 'PASSING', tone: 'warn' },
{ name: 'Relay Buoy 19', dist: '11.6 AU', status: 'QUEUED', tone: 'term' },
{ name: 'Cygnus Gate', dist: '34.0 AU', status: 'QUEUED', tone: 'term' },
]
// blips positioned in % within the radar circle (50/50 = center = own ship)
export const contacts = [
{ x: 64, y: 38, tone: 'bg-term', label: 'Freighter "Coriol"' },
{ x: 41, y: 27, tone: 'bg-warn', label: 'Unknown — passive' },
{ x: 72, y: 66, tone: 'bg-ok', label: 'Tarsis Station' },
{ x: 30, y: 58, tone: 'bg-flare', label: 'Debris field' },
]
// --- LIFE SUPPORT ------------------------------------------------------------
export const atmosphere = [
{ label: 'Oxygen', value: 20.9, unit: '%', pct: 87, tone: 'bg-ok' },
{ label: 'CO₂', value: 0.04, unit: '%', pct: 12, tone: 'bg-ok' },
{ label: 'Cabin Press.', value: 101.1, unit: 'kPa', pct: 92, tone: 'bg-ok' },
{ label: 'Temperature', value: 21.4, unit: '°C', pct: 71, tone: 'bg-term' },
{ label: 'Humidity', value: 44, unit: '%', pct: 44, tone: 'bg-term' },
{ label: 'Water Reserve', value: 68, unit: '%', pct: 68, tone: 'bg-term' },
]
export const decks = [
{ deck: 'Deck 1 — Bridge', o2: 'OK', temp: '21.0°C', tone: 'ok' },
{ deck: 'Deck 2 — Habitation', o2: 'OK', temp: '22.3°C', tone: 'ok' },
{ deck: 'Deck 3 — Med / Sci', o2: 'OK', temp: '20.6°C', tone: 'ok' },
{ deck: 'Deck 4 — Engineering', o2: 'LOW', temp: '26.8°C', tone: 'warn' },
{ deck: 'Deck 5 — Cargo', o2: 'SEALED', temp: '8.1°C', tone: 'term' },
]
export const crew = { total: 84, onDuty: 31, medical: 2, cryo: 12 }
// --- COMMS -------------------------------------------------------------------
export const channels = [
{ name: 'Fleet Command', band: 'Q-band', status: 'ENCRYPTED', tone: 'ok' },
{ name: 'Tarsis Control', band: 'S-band', status: 'OPEN', tone: 'term' },
{ name: 'Relay Buoy 19', band: 'X-band', status: 'WEAK', tone: 'warn' },
{ name: 'Distress Guard', band: '121.5', status: 'MONITOR', tone: 'term' },
{ name: 'Internal Net', band: 'Hardline', status: 'NOMINAL', tone: 'ok' },
]
export const messages = [
{ t: '07:41', from: 'FLEET CMD', tone: 'ok', text: 'Patrol pattern delta authorised. Maintain EMCON.' },
{ t: '07:33', from: 'TARSIS CTL', tone: 'term', text: 'Docking bay 4 reserved. Transmit manifest on approach.' },
{ t: '07:19', from: 'RELAY-19', tone: 'warn', text: 'Signal degradation detected. Recommend X-band switch.' },
{ t: '06:52', from: 'CORIOL', tone: 'term', text: 'Thanks for the escort, Aurelian. Safe travels.' },
{ t: '06:40', from: 'FLEET CMD', tone: 'flare', text: 'Priority weather: ion storm forming in grid 9-B.' },
]
-17
View File
@@ -1,17 +0,0 @@
// Reactive "telemetry" helper: returns an object whose .value drifts around a
// base value on an interval, so static dummy data feels like a live readout.
// Call inside a component's script (it registers an $effect for cleanup).
export function useJitter(base, { amp = 1, decimals = 0, every = 2000 } = {}) {
let value = $state(base)
$effect(() => {
const id = setInterval(() => {
const delta = (Math.random() - 0.5) * 2 * amp
value = Number((base + delta).toFixed(decimals))
}, every)
return () => clearInterval(id)
})
return { get value() { return value } }
}
@@ -1,20 +0,0 @@
// Tiny hash-based router using a module-level rune. Hash routing keeps deep
// links client-side (they never reach nginx), so the reverse proxy needs no
// SPA-fallback rewrite. `nav.current` is reactive: read it in markup/$derived.
function parse() {
if (typeof window === 'undefined') return 'dashboard'
const raw = window.location.hash.replace(/^#\/?/, '')
return raw.split(/[/?]/)[0] || 'dashboard'
}
let _route = $state(parse())
if (typeof window !== 'undefined') {
window.addEventListener('hashchange', () => { _route = parse() })
if (!window.location.hash) window.location.hash = '#/dashboard'
}
export const nav = {
get current() { return _route },
}
-7
View File
@@ -1,7 +0,0 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, { target: document.getElementById('app') })
export default app
-62
View File
@@ -1,62 +0,0 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { channels, messages } from '../lib/data.js'
const fromTone = {
ok: 'text-ok', warn: 'text-warn', alert: 'text-alert',
flare: 'text-flare', term: 'text-term',
}
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Uplink"><Stat label="fleet command" value="ENCRYPTED" tone="text-ok" sub="Q-band · 0% loss" /></Panel>
<Panel title="Active Channels"><Stat label="monitored" value={channels.length} sub="1 degraded" /></Panel>
<Panel title="Signal Latency"><Stat label="to relay" value={418} unit="ms" tone="text-warn" sub="lightspeed lag" /></Panel>
<Panel title="EMCON"><Stat label="emissions" value="DELTA" tone="text-flare" sub="restricted transmit" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<Panel title="Channels" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Channel</th>
<th class="pb-2 font-normal">Band</th>
<th class="pb-2 text-right font-normal">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each channels as c}
<tr>
<td class="py-2 text-term">{c.name}</td>
<td class="py-2 tabular-nums text-haze">{c.band}</td>
<td class="py-2 text-right"><Tag tone={c.tone}>{c.status}</Tag></td>
</tr>
{/each}
</tbody>
</table>
<div class="mt-4 space-y-3">
<Bar label="Antenna alignment" pct={97} tone="bg-ok" />
<Bar label="Relay-19 link" pct={43} tone="bg-warn" />
</div>
</Panel>
<Panel title="Message Traffic" accent="text-flare">
<ul class="space-y-2">
{#each messages as m}
<li class="border border-haze/20 bg-void/40 px-3 py-2">
<div class="flex items-center justify-between text-[0.6rem] uppercase tracking-wider">
<span class="{fromTone[m.tone] ?? 'text-term'} glow-sm">{m.from}</span>
<span class="tabular-nums text-haze">{m.t}</span>
</div>
<p class="mt-1 text-xs leading-snug text-haze/90">{m.text}</p>
</li>
{/each}
</ul>
</Panel>
</div>
</div>
@@ -1,61 +0,0 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { systemStatus, subsystemLoad, logEntries } from '../lib/data.js'
const output = useJitter(84.2, { amp: 0.6, decimals: 1 })
const velocity = useJitter(0.21, { amp: 0.004, decimals: 3, every: 1500 })
const hull = useJitter(98.4, { amp: 0.05, decimals: 2, every: 3000 })
const shields = useJitter(91.7, { amp: 0.8, decimals: 1 })
// Literal classes so Tailwind keeps them (no dynamic interpolation).
const dot = {
term: 'text-term', ok: 'text-ok', warn: 'text-warn',
alert: 'text-alert', flare: 'text-flare',
}
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Velocity"><Stat label="relative" value={velocity.value} unit="c" sub="sublight cruise" /></Panel>
<Panel title="Hull Integrity"><Stat label="structural" value={hull.value} unit="%" tone="text-ok" sub="no active breaches" /></Panel>
<Panel title="Shield Matrix"><Stat label="forward arc" value={shields.value} unit="%" sub="harmonics stable" /></Panel>
<Panel title="Reactor Output"><Stat label="rated" value={output.value} unit="%" tone="text-warn" sub="core nominal" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Systems Status" accent="text-term">
<ul class="divide-y divide-haze/15">
{#each systemStatus as s}
<li class="flex items-center justify-between py-2 text-sm">
<span class="text-haze">{s.name}</span>
<Tag tone={s.tone}>{s.state}</Tag>
</li>
{/each}
</ul>
</Panel>
<Panel title="Subsystem Load" accent="text-term">
<div class="space-y-3">
{#each subsystemLoad as b}
<Bar label={b.label} pct={b.pct} tone={b.tone} />
{/each}
</div>
</Panel>
<Panel title="Event Log" accent="text-flare">
<ul class="space-y-2 text-xs">
{#each logEntries as e}
<li class="flex gap-2">
<span class="shrink-0 tabular-nums text-haze">{e.t}</span>
<span class="shrink-0 {dot[e.tone] ?? 'text-term'}">▍</span>
<span class="text-haze/90 leading-snug">{e.msg}</span>
</li>
{/each}
</ul>
</Panel>
</div>
</div>
@@ -1,60 +0,0 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { atmosphere, decks, crew } from '../lib/data.js'
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Crew Aboard"><Stat label="souls" value={crew.total} sub={crew.onDuty + ' on active duty'} /></Panel>
<Panel title="In Cryo"><Stat label="long-haul" value={crew.cryo} tone="text-term" sub="pods nominal" /></Panel>
<Panel title="Medical"><Stat label="infirmary" value={crew.medical} tone="text-warn" sub="under observation" /></Panel>
<Panel title="Atmosphere"><Stat label="overall" value="STABLE" tone="text-ok" sub="recyclers nominal" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Atmospheric Mix" accent="text-term">
<div class="space-y-3 lg:col-span-2">
{#each atmosphere as a}
<Bar label={a.label} pct={a.pct} tone={a.tone} value={a.value} unit={' ' + a.unit} />
{/each}
</div>
</Panel>
<Panel title="Deck Environment" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Deck</th>
<th class="pb-2 font-normal">O₂</th>
<th class="pb-2 text-right font-normal">Temp</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each decks as d}
<tr>
<td class="py-2 text-term">{d.deck}</td>
<td class="py-2"><Tag tone={d.tone}>{d.o2}</Tag></td>
<td class="py-2 text-right tabular-nums text-haze">{d.temp}</td>
</tr>
{/each}
</tbody>
</table>
</Panel>
<Panel title="Recyclers & Reserves" accent="text-flare">
<div class="space-y-3">
<Bar label="CO₂ scrubbers" pct={88} tone="bg-ok" />
<Bar label="O₂ generation" pct={82} tone="bg-ok" />
<Bar label="Water recovery" pct={68} tone="bg-term" />
<Bar label="Waste reclaim" pct={59} tone="bg-term" />
<div class="mt-3 flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze uppercase tracking-wider">Emergency O₂</span>
<Tag tone="ok">72 hrs</Tag>
</div>
</div>
</Panel>
</div>
</div>
@@ -1,80 +0,0 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import Radar from '../lib/Radar.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { navState, waypoints, contacts } from '../lib/data.js'
const heading = useJitter(navState.heading, { amp: 0.3, decimals: 1, every: 1500 })
const velocity = useJitter(navState.velocity, { amp: 0.004, decimals: 3, every: 1500 })
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Heading"><Stat label="bearing" value={heading.value} unit="°" sub="auto-helm engaged" /></Panel>
<Panel title="Velocity"><Stat label="relative" value={velocity.value} unit="c" sub="sublight" /></Panel>
<Panel title="Current System"><Stat label="position" value={navState.system} tone="text-term" sub={navState.sector} /></Panel>
<Panel title="Destination"><Stat label="next waypoint" value={navState.destination} tone="text-flare" sub={'ETA ' + navState.eta} /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Tactical Plot" accent="text-term">
<Radar blips={contacts} />
<div class="mt-3 grid grid-cols-2 gap-x-3 gap-y-1 text-[0.65rem] text-haze">
{#each contacts as c}
<div class="flex items-center gap-2">
<span class="h-2 w-2 rounded-full {c.tone} glow"></span>
<span class="truncate">{c.label}</span>
</div>
{/each}
</div>
</Panel>
<Panel title="Flight Plan" accent="text-term">
<table class="w-full text-xs">
<thead>
<tr class="text-left text-haze/70 uppercase tracking-wider">
<th class="pb-2 font-normal">Waypoint</th>
<th class="pb-2 font-normal">Range</th>
<th class="pb-2 text-right font-normal">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-haze/15">
{#each waypoints as w}
<tr>
<td class="py-2 text-term">{w.name}</td>
<td class="py-2 tabular-nums text-haze">{w.dist}</td>
<td class="py-2 text-right"><Tag tone={w.tone}>{w.status}</Tag></td>
</tr>
{/each}
</tbody>
</table>
</Panel>
<Panel title="FTL Drive" accent="text-flare">
<div class="space-y-4">
<Bar label="Jump capacitor" pct={navState.ftlCharge} tone="bg-warn" />
<div class="grid grid-cols-3 gap-2 text-center text-xs">
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">X</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.x}</div>
</div>
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">Y</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.y}</div>
</div>
<div class="border border-haze/20 bg-void/40 py-2">
<div class="text-[0.55rem] uppercase text-haze">Z</div>
<div class="tabular-nums text-term glow-sm">{navState.coords.z}</div>
</div>
</div>
<div class="flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze uppercase tracking-wider">Jump readiness</span>
<Tag tone="warn">CHARGING</Tag>
</div>
</div>
</Panel>
</div>
</div>
-62
View File
@@ -1,62 +0,0 @@
<script>
import Panel from '../lib/Panel.svelte'
import Stat from '../lib/Stat.svelte'
import Bar from '../lib/Bar.svelte'
import Tag from '../lib/Tag.svelte'
import { useJitter } from '../lib/live.svelte.js'
import { reactor, powerDistribution, fuelCells } from '../lib/data.js'
const output = useJitter(reactor.output, { amp: 0.7, decimals: 1 })
const temp = useJitter(reactor.coreTemp, { amp: 18, decimals: 0, every: 1500 })
const rpm = useJitter(reactor.rpm, { amp: 40, decimals: 0, every: 1200 })
const cont = useJitter(reactor.containment, { amp: 0.15, decimals: 1, every: 2500 })
</script>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<Panel title="Core Output"><Stat label="rated power" value={output.value} unit="%" tone="text-warn" sub="84% of 2.4 TW" /></Panel>
<Panel title="Core Temp"><Stat label="plasma" value={temp.value} unit="K" tone="text-flare" sub="within tolerance" /></Panel>
<Panel title="Containment"><Stat label="field strength" value={cont.value} unit="%" tone="text-ok" sub="magnetic bottle stable" /></Panel>
<Panel title="Turbine"><Stat label="primary spindle" value={rpm.value} unit="rpm" sub="no vibration faults" /></Panel>
</div>
<div class="grid gap-4 lg:grid-cols-3">
<Panel title="Power Distribution" accent="text-term">
<div class="space-y-3 lg:col-span-2">
{#each powerDistribution as p}
<Bar label={p.label} pct={p.pct} tone={p.tone} />
{/each}
</div>
</Panel>
<Panel title="Fuel Cells" accent="text-term">
<div class="space-y-3">
{#each fuelCells as c}
<Bar label={c.id} pct={c.charge} tone={c.tone} />
{/each}
<div class="mt-3 flex items-center justify-between border-t border-haze/20 pt-3 text-xs">
<span class="text-haze">Deuterium reserve</span>
<Tag tone="ok">77% / 14 days</Tag>
</div>
</div>
</Panel>
<Panel title="Reactor Controls" accent="text-flare">
<div class="space-y-2">
{#each [
{ l: 'Auto-throttle', v: 'ENGAGED', t: 'ok' },
{ l: 'SCRAM arming', v: 'SAFE', t: 'term' },
{ l: 'Coolant loop A', v: 'FLOW 100%', t: 'ok' },
{ l: 'Coolant loop B', v: 'STANDBY', t: 'term' },
{ l: 'Plasma injectors', v: '6 / 6 ONLINE', t: 'ok' },
{ l: 'Overload damper', v: 'ARMED', t: 'warn' },
] as r}
<div class="flex items-center justify-between border border-haze/20 bg-void/40 px-3 py-2 text-xs">
<span class="text-haze uppercase tracking-wider">{r.l}</span>
<Tag tone={r.t}>{r.v}</Tag>
</div>
{/each}
</div>
</Panel>
</div>
</div>
-5
View File
@@ -1,5 +0,0 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
preprocess: vitePreprocess(),
}
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
import tailwindcss from '@tailwindcss/vite'
// The kiosk loads the app at http://boot.fleet.lab/kiosk/ (the main serving
// nginx reverse-proxies /kiosk/ -> this container). base must match that path
// so emitted asset URLs are absolute under /kiosk/. Hash-based routing is used
// (see src/lib/router.svelte.js) so deep links never hit the server — no SPA
// fallback rewrite is needed through the proxy.
export default defineConfig({
base: '/kiosk/',
plugins: [svelte(), tailwindcss()],
build: {
target: 'es2022',
// Fully self-contained bundle (no CDN/fonts) so it renders on the
// air-gapped boot LAN — the dnsmasq sinkhole blocks external call-home.
assetsInlineLimit: 4096,
},
})
View File
+144
View File
@@ -0,0 +1,144 @@
# TFTP root — iPXE chainload for PXE-only firmware
The iPXE network bootloader (`snponly.efi`) is served over TFTP to firmware that
**lacks UEFI HTTP boot** (e.g. HP EliteBook 820 G4). HTTP-boot-capable firmware
never touches this path — it gets the UKI straight over HTTP (see the `httpboot`
tag in `docker-compose.yml`). The fallback is automatic, driven by what each
client advertises (DHCP vendor-class / client-arch).
> **Where `snponly.efi` comes from (production): the `fleet-ipxe` OCI image.**
> CI builds it, **signs it with the fleet `db` key**, and pushes a tiny
> FROM-scratch image (`.gitea/workflows/ipxe-image.yaml` →
> `scripts/build-ipxe-oci.sh`). The dnsmasq container mounts that image at
> `/ipxe` and the entrypoint copies `/ipxe/snponly.efi` into `/srv/tftp`. It's a
> **separate, per-Secure-Boot-environment image** (its signature is `db`-specific)
> so the dnsmasq image itself stays generic — see docs/secure-boot-keys.md and
> CLAUDE.md invariant #9.
>
> **This directory is the offline/lab fallback only:** if you drop an
> `snponly.efi` here and enable the commented `./tftp:/tftp-src:ro` bind in
> `docker-compose.yml`, the entrypoint uses it when `/ipxe` is empty (it prefers
> `/ipxe`, then falls back to `/tftp-src`). Handy on Docker < 28 or when iterating
> without a registry round-trip.
Flow for a PXE-only host:
```
firmware PXE (PXEClient) --TFTP--> snponly.efi (iPXE)
iPXE autoboot DHCP (user-class "iPXE") --> handed boot.ipxe (TFTP)
boot.ipxe: imgfetch UKI over HTTP -> imgargs <real cmdline> -> boot
```
> **Requires PortFast/edge on the switch port** (see the gotchas below). iPXE's
> autoboot opens the NIC, which bounces the PHY; without PortFast the switch's STP
> forwarding delay (~30 s) drops iPXE's DHCP and the boot fails. PortFast removes
> the delay. (The earlier `autoexec.ipxe` + `ifopen` + DHCP-retry workaround was
> removed once PortFast was identified as the real fix.)
`boot.ipxe` is **rendered at container start** by the dnsmasq entrypoint; only the
iPXE binary (`snponly.efi`) is supplied here. The script exists because of a sharp
edge:
> **Why not chainload the UKI URL directly?** When iPXE executes the UKI it sets
> the EFI load options to the image's command line — or, if that's empty, to the
> image **name**. With **Secure Boot OFF**, systemd-stub uses the load options *as
> the kernel cmdline*, **replacing** the UKI's embedded cmdline
> (`roothash`/`rd.nvmf.discover`/`rd.dot1x=0`/`systemd.volatile=overlay`). So
> booting the URL directly — *or clearing the args* — makes the kernel come up
> with just `fleet-<ver>.efi` as its cmdline → **dracut emergency shell**.
>
> The fix: feed iPXE the **real cmdline**. On the serving host that cmdline only
> lives *inside the UKI* (it's not in the OCI image — invariant #10 — nor shipped
> as a file), so the entrypoint **extracts the UKI's `.cmdline` PE section with
> `objcopy`** (the artifact image is mounted read-only at `/artifacts`) and bakes
> it into `boot.ipxe` as `imgargs <uki> <cmdline>`. Needs `binutils` in the image
> (added) and the artifact mount.
>
> **Secure Boot ON makes all of this moot** — the stub ignores any loader-supplied
> cmdline and uses the embedded one — so this is purely a lab/SB-OFF concern; the
> rendered script is harmless once SB is on.
## What to drop here
A UEFI x86-64 iPXE binary named **`snponly.efi`** (the name the dnsmasq config
serves). `snponly` rides the **firmware's** NIC driver via the UEFI SNP protocol
rather than iPXE's own, so it works on virtio VMs *and* real hardware where the
native driver won't bind.
```bash
# Ubuntu/Debian package (simplest — confirmed present):
sudo apt-get install -y ipxe # then copy whatever snponly it ships, e.g.:
# dpkg -L ipxe | grep -i snponly
cp /usr/lib/ipxe/snponly.efi serving/tftp/snponly.efi
# Fedora/EL: dnf install ipxe-bootimgs -> /usr/share/ipxe/ipxe-snponly-x86_64.efi
# or build: git clone https://github.com/ipxe/ipxe && cd ipxe/src
# make bin-x86_64-efi/snponly.efi
```
> **Why `snponly`, not the full `ipxe.efi`?** iPXE's own NIC drivers don't bind on
> some hardware — the **EliteBook 820 G4 (Intel I219-LM)** fails `ipxe.efi` with
> *"net0: No configurable methods"*. `snponly` inherits the firmware's working
> driver (the one that just loaded iPXE over PXE), so it's the robust default.
> `boot.ipxe.org` only publishes the full `ipxe.efi`, not `snponly` — get `snponly`
> from a distro package or build it.
> **Updating the binary requires a dnsmasq restart** — the entrypoint copies it
> from this (read-only) mount into the container's TFTP root at startup:
> `docker compose restart dnsmasq`.
(BIOS/legacy hosts would need `undionly.kpxe` + client-arch matching; this fleet
is UEFI x64.)
## Secure Boot
**Lab (now): Secure Boot OFF for the iPXE path.** `snponly.efi` here is
**unsigned**, so a Secure-Boot-enforcing machine would reject it.
**Production upgrade:** sign `snponly.efi` with the fleet **db** key (the same key
that signs the UKI), then Secure Boot stays enforced end-to-end
(firmware → verifies iPXE vs db → iPXE `chain`s the UKI → firmware LoadImage
verifies UKI vs db). Only `db` needs enrolling — no Microsoft keys / shim, since
the fleet owns its keys. See [../../docs/secure-boot-keys.md](../../docs/secure-boot-keys.md).
```bash
# production (where db.key lives — CI/HSM):
sbsign --key db.key --cert db.crt --output snponly.efi snponly.efi
```
## Bench notes / gotchas (EliteBook 820 G4 / Intel I219-LM, 2026-06-20)
Hard-won findings from bringing up the first PXE-only physical host:
- **Use `snponly.efi`, not the full `ipxe.efi`.** The 820 G4's NIC is an Intel
**I219-LM** (PCI `00:1f.6`). iPXE's native `intel` driver in the prebuilt full
build doesn't bind it (`net0: No configurable methods` / no `net0`). `snponly`
rides the firmware's driver via NII/SNP, so `net0` appears. Ubuntu's `ipxe`
package ships a usable `snponly.efi` (no build needed).
- **PortFast/edge on the access port is REQUIRED** (this was the real blocker).
iPXE's `ifopen` resets the PHY → the link bounces → a managed switch restarts
its STP forwarding delay (~30 s) and silently drops packets → iPXE's DHCP never
reaches the server (`ifstat` shows `link:down` / `Link status: Unknown`; symptom
is `No configurable methods` or a dracut shell). The firmware's own PXE works
only because it keeps the link up continuously from POST so STP already
converged. **Fix: enable PortFast/edge (or disable STP) on the workstation
port** — VERIFIED to fix it 2026-06-20. (Cisco `spanning-tree portfast`;
Aruba/HP `admin-edge-port`; UniFi "Fast"; or a dumb unmanaged switch.) The
`autoexec.ipxe` retry loop (below) is a fallback that outlasts STP but adds
~30 s to every boot — PortFast removes the delay entirely.
- **iPXE aborts the script on any unguarded command failure** (→ iPXE exits → the
firmware restarts the whole PXE process, looking like "it doesn't retry"). Every
fallible step in `boot.ipxe` is therefore `||`-guarded.
- **`boot.ipxe`** (rendered by the dnsmasq entrypoint, served as the `tag:ipxe`
DHCP bootfile) does `dhcp` + `imgfetch` UKI + `imgargs <real cmdline>` + `boot`.
The cmdline injection is needed only because **Secure Boot is OFF** (the stub
takes the loader's cmdline); under Secure Boot it's a no-op and `boot.ipxe`
could collapse to a plain `chain` of the UKI.
- **Removed STP-era workaround:** an `autoexec.ipxe` that ran `ifopen net0` + a
DHCP-retry loop before autoboot was used to wait out STP. It's gone now that
**PortFast is the fix** — if you ever run on a switch you can't set PortFast on,
that ifopen+retry approach (in git history) is the fallback.
## Note
`*.efi` here is **gitignored** (binary, and the signed one is environment-specific
/ produced from db). Keep this README + `.gitkeep`.
Binary file not shown.
Binary file not shown.