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:
@@ -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
|
||||||
@@ -34,6 +34,30 @@ on:
|
|||||||
- "**" # every branch — feature branches get a branch-name+sha tag
|
- "**" # every branch — feature branches get a branch-name+sha tag
|
||||||
tags:
|
tags:
|
||||||
- "v*" # tag push -> clean semver tag (v1.2.3 -> 1.2.3)
|
- "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
|
# One build per ref at a time; a newer push to the same branch cancels the
|
||||||
# in-flight build (each build is a ~17-min VM run, so don't stack them).
|
# in-flight build (each build is a ~17-min VM run, so don't stack them).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -9,15 +9,25 @@
|
|||||||
# Runs only when the SPDK image inputs change (path filter) or on demand — not
|
# 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.
|
# 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).
|
# Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds).
|
||||||
#
|
#
|
||||||
|
|
||||||
name: Build SPDK NVMe-oF image
|
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "main"
|
- "**" # every branch — dev branches get a disambiguated tag
|
||||||
|
tags:
|
||||||
|
- "v*"
|
||||||
paths:
|
paths:
|
||||||
- "serving/spdk/**"
|
- "serving/spdk/**"
|
||||||
- ".gitea/workflows/spdk-image.yaml"
|
- ".gitea/workflows/spdk-image.yaml"
|
||||||
@@ -28,9 +38,14 @@ on:
|
|||||||
required: false
|
required: false
|
||||||
default: "v24.09"
|
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:
|
env:
|
||||||
SPDK_IMAGE: "git.bitasylum.org/audiobuzz/spdk-nvmf"
|
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:
|
jobs:
|
||||||
spdk-build:
|
spdk-build:
|
||||||
@@ -66,12 +81,30 @@ jobs:
|
|||||||
STORAGE_DRIVER: vfs
|
STORAGE_DRIVER: vfs
|
||||||
BUILDAH_ISOLATION: chroot
|
BUILDAH_ISOLATION: chroot
|
||||||
run: |
|
run: |
|
||||||
VER="${{ inputs.spdk_version || env.SPDK_VERSION }}"
|
# SPDK release: the --build-arg + base tag (dispatch input wins).
|
||||||
echo "Building ${SPDK_IMAGE}:${VER}"
|
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 \
|
buildah bud \
|
||||||
--build-arg SPDK_VERSION="$VER" \
|
--build-arg SPDK_VERSION="$SVER" \
|
||||||
-t "${SPDK_IMAGE}:${VER}" \
|
-t "${SPDK_IMAGE}:${TAG}" \
|
||||||
-t "${SPDK_IMAGE}:latest" \
|
|
||||||
serving/spdk
|
serving/spdk
|
||||||
buildah push "${SPDK_IMAGE}:${VER}"
|
buildah push "${SPDK_IMAGE}:${TAG}"
|
||||||
buildah push "${SPDK_IMAGE}:latest"
|
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
|
||||||
|
|||||||
@@ -9,3 +9,8 @@ keys
|
|||||||
/files/secureboot/keys/*.auth
|
/files/secureboot/keys/*.auth
|
||||||
/files/secureboot/keys/*.esl
|
/files/secureboot/keys/*.esl
|
||||||
/files/secureboot/keys/*.crt
|
/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
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "serving/kiosk-app"]
|
||||||
|
path = serving/kiosk-app
|
||||||
|
url = git@git.bitasylum.org:audiobuzz/fleet-kiosk-app.git
|
||||||
@@ -33,9 +33,16 @@ files/plymouth/themes/fleet/ Custom boot splash (scripted security
|
|||||||
files/kiosk/ weston+Chromium kiosk + chromium lockdown policy
|
files/kiosk/ weston+Chromium kiosk + chromium lockdown policy
|
||||||
scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline
|
scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline
|
||||||
scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image
|
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
|
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
|
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
|
.gitea/workflows/ Gitea CI: lint -> packer build -> push OCI
|
||||||
docs/ Design PDF and supporting notes
|
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
|
SHA256SUMS}`. `rootfs.erofs` (erofs data) and `rootfs.hash` (dm-verity hash
|
||||||
tree) are **separate** namespaces because the as-built Rocky 9 systemd does
|
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
|
not support the in-file `hash-offset` verity layout. No shell, no tools, no
|
||||||
base image. Serving software (SPDK, nginx) lives in separate, independently
|
base image. Serving software (SPDK, nginx, dnsmasq) lives in separate,
|
||||||
versioned images — never merge them into the artifact image.
|
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
|
1. **Deployments are pinned by digest.** One commit per fleet release in
|
||||||
the deployment manifest; that manifest is the single source coupling
|
the deployment manifest; that manifest is the single source coupling
|
||||||
UKI ↔ namespace. Rollback = revert the commit.
|
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
|
`group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port
|
||||||
`Authorized`, macsec0 addressed, NVMe namespace connected, verity root
|
`Authorized`, macsec0 addressed, NVMe namespace connected, verity root
|
||||||
mapped, overlay writable, supplicant alive after switch-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
|
Any change to the dracut module, supplicant config, or cmdline must pass
|
||||||
this before merge. Template-only changes need `packer validate` +
|
this before merge. Template-only changes need `packer validate` +
|
||||||
|
|||||||
+208
-5
@@ -572,13 +572,67 @@ this item builds the rollout machinery around it.
|
|||||||
stay served); security rollback via dbx-revoke / TPM monotonic counter
|
stay served); security rollback via dbx-revoke / TPM monotonic counter
|
||||||
(design §6).
|
(design §6).
|
||||||
- **Drain & retire** an old version only when SPDK/nvmet reports zero connected
|
- **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
|
- **Availability**: run NVMe/TCP target replicas behind distinct discovery
|
||||||
entries (design §7) — never serve the whole fleet from one unreplicated node,
|
entries (design §7) — never serve the whole fleet from one unreplicated node,
|
||||||
especially mid-rollout (N versions + reboot bursts).
|
especially mid-rollout (N versions + reboot bursts).
|
||||||
- Note: kiosk *content* changes are server-side (the website) and need no fleet
|
- Note: kiosk *content* changes are server-side (the website) and need no fleet
|
||||||
update — only OS/UKI/rootfs changes require a reboot.
|
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)
|
## [ ] 7. Harden / evaluate the NVMe-oF target (serving-node resilience)
|
||||||
|
|
||||||
There is **no production-grade memory-safe NVMe-oF target** — the Rust NVMe work
|
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
|
- **Network-isolate :4420** so only the boot VLAN reaches the target (compose
|
||||||
with 802.1X/MACsec segmentation in prod).
|
with 802.1X/MACsec segmentation in prod).
|
||||||
- **Replicas** behind distinct discovery entries (availability is the real risk
|
- **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
|
- Revisit a memory-safe target only if/when an off-the-shelf one matures
|
||||||
(e.g. a future Rust-for-Linux `nvmet`).
|
(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"`).
|
- **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`).
|
||||||
Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0).
|
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
|
- **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot
|
||||||
by the serving dnsmasq — drop the hardcoded `192.168.50.10`.
|
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
|
- **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM
|
||||||
Secure Boot on targets. Until then the loud unsigned warning must stay.
|
(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)**
|
**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).
|
(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,
|
- **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS,
|
||||||
`disable_dot1x=false`) — the items deferred this session:
|
`disable_dot1x=false`) — the items deferred this session:
|
||||||
- NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA
|
- 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
|
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
|
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.
|
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
|
**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
|
`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
|
reasonable for a controlled fleet; the highest-assurance path is attended/firmware
|
||||||
or manufacturer pre-provisioning. Default recommendation: **separate provisioning
|
or manufacturer pre-provisioning. Default recommendation: **separate provisioning
|
||||||
UKI + isolated VLAN + read-back verify.**
|
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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Records where the as-built implementation diverges from
|
|||||||
`diskless-fleet-design-v2.pdf`. The PDF remains authoritative for the
|
`diskless-fleet-design-v2.pdf`. The PDF remains authoritative for the
|
||||||
architecture; this note captures deltas discovered bringing the build up on
|
architecture; this note captures deltas discovered bringing the build up on
|
||||||
**Rocky 9.8** until they are folded into a consolidated **v3**. Last updated
|
**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
|
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
|
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
|
[../serving/spdk-setup.sh](../serving/spdk-setup.sh) (2nd bdev/namespace), and
|
||||||
CLAUDE.md invariant #9.
|
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)
|
## 2. Implementation notes (not architectural)
|
||||||
|
|
||||||
These don't contradict the PDF (it doesn't specify them) but are worth recording.
|
These don't contradict the PDF (it doesn't specify them) but are worth recording.
|
||||||
|
|||||||
@@ -130,6 +130,39 @@ on a trusted/isolated network (see TODO #10).
|
|||||||
Only the **public** certs/`.esl`/`.auth` reach the workstation — never copy
|
Only the **public** certs/`.esl`/`.auth` reach the workstation — never copy
|
||||||
`PK.key`/`KEK.key`/`db.key` onto a host.
|
`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
|
## 4. Wire `db` into the build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -170,6 +203,57 @@ Caveats:
|
|||||||
CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal
|
CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal
|
||||||
(TODO #8).
|
(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
|
## Key hygiene & lifecycle
|
||||||
|
|
||||||
- **PK.key and KEK.key go offline / into an HSM and stay there.** They are only
|
- **PK.key and KEK.key go offline / into an HSM and stay there.** They are only
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
>
|
>
|
||||||
> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
|
> **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target
|
||||||
> **May 2026 release**.
|
> **May 2026 release**.
|
||||||
|
>
|
||||||
|
> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening
|
||||||
|
> 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 |
|
| 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) |
|
| 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 + one trusted kiosk binary) + immutable dm-verity root. | Implemented | — |
|
| 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 | — |
|
| Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — |
|
||||||
| Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — |
|
| Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — |
|
||||||
| Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — |
|
| Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — |
|
||||||
@@ -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 |
|
| 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) |
|
| 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 | — |
|
| Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — |
|
||||||
| No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — |
|
| No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — |
|
||||||
| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane not yet enforced.** | Partial | R-05 |
|
| MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane 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 |
|
| 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 | — |
|
| 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** |
|
| 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 |
|
| 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 |
|
| 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 | — |
|
| 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 | — |
|
| 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
|
### 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-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-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-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-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-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-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* |
|
| **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 /
|
*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
|
Low–Medium. The AO is asked to authorise on the basis that **R-01, R-02, R-08,
|
||||||
and R-12 are **closed before production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11
|
R-12 and R-13** (and confirming R-03's `enable_stig=true`) are **closed before
|
||||||
are either accepted with compensating controls (isolated-LAN lab) or carried on
|
production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 are either accepted with
|
||||||
the POA&M with target dates.*
|
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.
|
anything flagged **REVIEW** is an unjustified finding.
|
||||||
6. **Confirm lab-only flags are off** in the assessed build: `debug_shell=false`,
|
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.
|
`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
@@ -15,10 +15,10 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| System name | Diskless Fleet Boot System ("the fleet") |
|
| System name | Diskless Fleet Boot System ("the fleet") |
|
||||||
| Document type | System Security Plan (SSP) |
|
| Document type | System Security Plan (SSP) |
|
||||||
| Version | 0.1 (DRAFT — pre-assessment) |
|
| Version | 0.2 (DRAFT — pre-assessment) |
|
||||||
| Date | 2026-06-19 |
|
| Date | 2026-06-22 |
|
||||||
| Author / maintainer | System Owner (delegate) |
|
| 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) |
|
| 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
|
> **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)
|
### 2.2 Boot and trust chain (control flow)
|
||||||
|
|
||||||
1. **UEFI Secure Boot** measures and verifies the UKI signature (vendor/Secure
|
> **Boot transport (two paths, auto-selected):** HTTP-boot-capable firmware
|
||||||
Boot key) before execution.
|
> 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
|
2. **UKI (systemd-stub)** carries the kernel, the `--no-hostonly` initramfs, and
|
||||||
an **immutable, signed kernel command line** (includes the dm-verity root
|
an **immutable, signed kernel command line** (includes the dm-verity root
|
||||||
hash and STIG kernel parameters). No GRUB.
|
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
|
The boundary's external interfaces are: (a) the **access link** (802.1X-controlled
|
||||||
port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c)
|
port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c)
|
||||||
**outbound audit log** forwarding; (d) the **kiosk application** endpoint the
|
**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
|
### 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
|
Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M
|
||||||
source).
|
source).
|
||||||
|
|
||||||
**Current status:** Pre-assessment **DRAFT**. The system is functional in a lab
|
**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-22)**. The system
|
||||||
configuration. Several controls are **partial or deferred** and are tracked as
|
is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA
|
||||||
residual risks with treatment plans (Annex A §3). The security-critical path
|
STIG hardening applied and **bench-verified** on a real build (kiosk, key-only SSH,
|
||||||
(802.1X/MACsec) is **implemented but not yet bench-verified end-to-end** and is
|
audit offload, DHCP-named host, NVMe/verity root all good with hardening on);
|
||||||
currently disabled in test builds (`disable_dot1x=true`) — this is the single
|
USBGuard device control enabled + verified; admin confined to `staff_u`; and —
|
||||||
largest gate on a production authorisation.
|
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
|
## 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
|
- **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable
|
||||||
cmdline**; **dm-verity** read-only root (continuous cryptographic integrity);
|
cmdline**; **dm-verity** read-only root (continuous cryptographic integrity);
|
||||||
**erofs** immutable filesystem; **reproducible** build (verity hash is a pure
|
**erofs** immutable filesystem; **reproducible** build (verity hash is a pure
|
||||||
function of inputs).
|
function of inputs). Signing is **wired in CI** (`sbsign`/ukify via
|
||||||
- **System hardening:** **DISA STIG** baseline via OpenSCAP/SSG with a documented
|
`SB_KEY`/`SB_CERT` secrets) with a documented PK/KEK/db key + enrolment
|
||||||
tailoring; **SELinux enforcing**; **fapolicyd** application allow-listing (the
|
procedure ([secure-boot-keys.md](secure-boot-keys.md)) and a baked, efitools-free
|
||||||
exec guard for the writable RAM overlay that dm-verity cannot cover);
|
enrolment helper (`fleet-sb-enroll`); **not yet enforced in the lab** (SB OFF,
|
||||||
**key-only SSH**, root login disabled, hardened ciphers/MACs/KEX; **kiosk
|
unsigned — see Annex A).
|
||||||
lockdown** (single locked browser, no shell/navigation).
|
- **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
|
- **No persistent attack surface:** diskless; overlay discarded each boot; no
|
||||||
per-host secrets in the image.
|
per-host secrets in the image.
|
||||||
- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled**;
|
- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled** (the
|
||||||
NVMe/TCP and audit offload **plaintext on the isolated LAN**; NOPASSWD key-only
|
workstation; the serving host's ufw is configured); NVMe/TCP and audit offload
|
||||||
admin; **placeholder RADIUS CA**. See Annex A residual risks.
|
**plaintext on the isolated LAN**; NOPASSWD admin; **placeholder RADIUS CA**;
|
||||||
|
signing not yet enforced (SB OFF). See Annex A residual risks.
|
||||||
|
|
||||||
### 5.3 Detect
|
### 5.3 Detect
|
||||||
- **auditd** enabled with STIG audit rules; kernel `audit=1` +
|
- **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`,
|
- **Centralised log offload** via rsyslog (`audisp-syslog` → rsyslog `omfwd`,
|
||||||
disk-assisted queue) to a dedicated **audit collector** (`serving/audit-collector/`).
|
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
|
- *Gaps:* offload currently **plaintext** (TLS planned, R-07); offload is
|
||||||
**local site only**; no SIEM correlation/alerting defined yet.
|
**local site only**; no SIEM correlation/alerting defined yet.
|
||||||
|
|
||||||
@@ -215,11 +259,11 @@ AO will expect a maturity view:
|
|||||||
|
|
||||||
| Mitigation | Posture | Notes |
|
| 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. |
|
| Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. |
|
||||||
| Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. |
|
| Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. |
|
||||||
| User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. |
|
| User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. |
|
||||||
| Restrict admin privileges | **Partial** | Single key-only `wheel` admin (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. |
|
| Patch operating systems | **Process** | Same rebuild/redeploy model; immutable image means uniform, atomic OS updates. |
|
||||||
| Multi-factor authentication | **Strong (machine)** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). Human admin MFA to be addressed for the management plane. |
|
| Multi-factor authentication | **Strong (machine)** | 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. |
|
| 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-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-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-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-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-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.
|
Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
SELinux policy store, so confirm the verity hash is stable across two builds (and
|
||||||
normalize in 2d hygiene if it drifts).
|
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)
|
## Things that are NOT deviations (kept / enforced)
|
||||||
|
|
||||||
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
- **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs).
|
||||||
|
|||||||
@@ -28,8 +28,12 @@ EFIVARS=/sys/firmware/efi/efivars
|
|||||||
GUID_GLOBAL=8be4df61-93ca-11d2-aa0d-00e098032b8c # PK, KEK, SetupMode
|
GUID_GLOBAL=8be4df61-93ca-11d2-aa0d-00e098032b8c # PK, KEK, SetupMode
|
||||||
GUID_DB=d719b2cb-3d3a-4596-a3bc-dad00e67656f # db, dbx
|
GUID_DB=d719b2cb-3d3a-4596-a3bc-dad00e67656f # db, dbx
|
||||||
# EFI var attributes: NON_VOLATILE|BOOTSERVICE_ACCESS|RUNTIME_ACCESS|
|
# EFI var attributes: NON_VOLATILE|BOOTSERVICE_ACCESS|RUNTIME_ACCESS|
|
||||||
# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, little-endian 4 bytes.
|
# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, written as a 4-byte little-endian
|
||||||
ATTR=$'\x27\x00\x00\x00'
|
# 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; }
|
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
|
local f="$EFIVARS/$1-$2" tmp
|
||||||
[ -f "$3" ] || die "missing $3"
|
[ -f "$3" ] || die "missing $3"
|
||||||
tmp=$(mktemp)
|
tmp=$(mktemp)
|
||||||
printf '%s' "$ATTR" > "$tmp"
|
printf '\x27\x00\x00\x00' > "$tmp" # 4-byte LE attribute prefix (see note above)
|
||||||
cat "$3" >> "$tmp"
|
cat "$3" >> "$tmp"
|
||||||
# Existing EFI vars carry the immutable flag; clear it before overwriting.
|
# Existing EFI vars carry the immutable flag; clear it before overwriting.
|
||||||
[ -e "$f" ] && chattr -i "$f" 2>/dev/null || true
|
[ -e "$f" ] && chattr -i "$f" 2>/dev/null || true
|
||||||
|
|||||||
@@ -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_hub" selected="true"/>
|
||||||
<xccdf:select idref="xccdf_org.ssgproject.content_rule_usbguard_allow_hid_and_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:Profile>
|
||||||
</xccdf:Tailoring>
|
</xccdf:Tailoring>
|
||||||
|
|||||||
Executable
+106
@@ -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
|
||||||
@@ -17,6 +17,25 @@ ARTIFACT_SRC=../artifacts/0.0.0-2eefd97
|
|||||||
SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf
|
SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf
|
||||||
SPDK_VERSION=v24.09
|
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 -------------------------------------
|
# --- This serving host on the boot LAN -------------------------------------
|
||||||
BOOT_IFACE=ens19 # interface facing the booting client / test bridge
|
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)
|
SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe)
|
||||||
|
|||||||
@@ -2,10 +2,18 @@
|
|||||||
# the version is pinned and the entrypoint is predictable). Config is passed
|
# the version is pinned and the entrypoint is predictable). Config is passed
|
||||||
# entirely as --flags from docker-compose.yml.
|
# entirely as --flags from docker-compose.yml.
|
||||||
FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20
|
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,
|
# fleet-namer: --dhcp-script that assigns creative starship hostnames per MAC,
|
||||||
# server-side (keeps the workstation image neutral). COPY'd in root-owned, 0755
|
# 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.
|
# so dnsmasq will execute it. Uses busybox sha256sum/cut/wc — all in the image.
|
||||||
COPY fleet-namer /usr/local/bin/fleet-namer
|
COPY fleet-namer /usr/local/bin/fleet-namer
|
||||||
RUN chmod 0755 /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"]
|
||||||
|
|||||||
@@ -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 "$@"
|
||||||
@@ -37,7 +37,12 @@
|
|||||||
services:
|
services:
|
||||||
# --- DHCP + DNS ------------------------------------------------------------
|
# --- DHCP + DNS ------------------------------------------------------------
|
||||||
dnsmasq:
|
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
|
network_mode: host
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN # DHCP raw sockets
|
- NET_ADMIN # DHCP raw sockets
|
||||||
@@ -48,6 +53,34 @@ services:
|
|||||||
# + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add
|
# + 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.
|
# manual lines to pin specific hosts. RW so fleet-namer can append.
|
||||||
- ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts
|
- ./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:
|
command:
|
||||||
- --keep-in-foreground
|
- --keep-in-foreground
|
||||||
# Log to stderr so `docker compose logs` captures output. Without this,
|
# Log to stderr so `docker compose logs` captures output. Without this,
|
||||||
@@ -108,6 +141,32 @@ services:
|
|||||||
- --dhcp-vendorclass=set:httpboot,HTTPClient
|
- --dhcp-vendorclass=set:httpboot,HTTPClient
|
||||||
- --dhcp-option=tag:httpboot,60,HTTPClient
|
- --dhcp-option=tag:httpboot,60,HTTPClient
|
||||||
- --dhcp-boot=tag:httpboot,http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi
|
- --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 boot server (serves the UKI) -------------------------------------
|
||||||
http:
|
http:
|
||||||
@@ -132,11 +191,15 @@ services:
|
|||||||
|
|
||||||
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
|
# --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------
|
||||||
# Svelte + Tailwind app built into a static bundle served by its own nginx.
|
# 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
|
# Now an INDEPENDENT project — the `kiosk-app` git submodule (its own repo +
|
||||||
# only, where the host-network 'http' nginx reverse-proxies /kiosk/ to it.
|
# CI). It builds + pushes its own image (git.../fleet-kiosk); the serving stack
|
||||||
# Build: docker compose build kiosk
|
# 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:
|
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
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${KIOSK_APP_PORT:-8088}:80" # loopback only; main nginx proxies here
|
- "127.0.0.1:${KIOSK_APP_PORT:-8088}:80" # loopback only; main nginx proxies here
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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.)"
|
||||||
@@ -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
|
||||||
Executable
+79
@@ -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."
|
||||||
Executable
+129
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+61
@@ -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."
|
||||||
Submodule
+1
Submodule serving/kiosk-app added at f0d0677950
@@ -1,5 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.vite
|
|
||||||
.git
|
|
||||||
*.local
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.vite
|
|
||||||
*.local
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-2075
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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; }
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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())}`
|
|
||||||
}
|
|
||||||
@@ -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.' },
|
|
||||||
]
|
|
||||||
@@ -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 },
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
preprocess: vitePreprocess(),
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
@@ -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.
Reference in New Issue
Block a user