diff --git a/.gitea/workflows/dnsmasq-image.yaml b/.gitea/workflows/dnsmasq-image.yaml new file mode 100644 index 0000000..d787fb3 --- /dev/null +++ b/.gitea/workflows/dnsmasq-image.yaml @@ -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-" (+ :latest) +# - branch push (refs/heads/) -> "-" (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 + # "-" 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 diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml index 85f8632..76d2a62 100644 --- a/.gitea/workflows/gitea-main-ci.yaml +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -34,6 +34,30 @@ on: - "**" # every branch — feature branches get a branch-name+sha tag tags: - "v*" # tag push -> clean semver tag (v1.2.3 -> 1.2.3) + # Skip this ~17-min VM build when a push touches ONLY things that are NOT + # rootfs/UKI build inputs. The image is built from diskless.pkr.hcl + http/ + # (ks.cfg) + files/** + scripts/** — those are deliberately NOT listed here, + # so they always trigger. Everything below is runtime/serving infra, docs, or + # dev tooling that never lands in the rootfs. A commit that also touches a + # real build input still builds (paths-ignore only skips when EVERY changed + # path matches). + paths-ignore: + # Serving stack — served at runtime, built by its own image workflows / + # the kiosk-app submodule; none of it is baked into the rootfs. + - "serving/**" + - "serving/kiosk-app" # the submodule gitlink path (pointer bumps) + # Docs / notes / dev tooling — not build inputs. + - "docs/**" + - "*.md" # root README.md, CLAUDE.md, etc. + - ".devcontainer/**" + - ".gitignore" + - ".gitattributes" + - "LICENSE" + # Sibling image workflows build their OWN images, not the UKI. (Add new + # ones here; do NOT add gitea-main-ci.yaml — its edits must still build.) + - ".gitea/workflows/ipxe-image.yaml" + - ".gitea/workflows/dnsmasq-image.yaml" + - ".gitea/workflows/spdk-image.yaml" # One build per ref at a time; a newer push to the same branch cancels the # in-flight build (each build is a ~17-min VM run, so don't stack them). diff --git a/.gitea/workflows/ipxe-image.yaml b/.gitea/workflows/ipxe-image.yaml new file mode 100644 index 0000000..fc12dcd --- /dev/null +++ b/.gitea/workflows/ipxe-image.yaml @@ -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) -> "" (+ :latest) +# - branch push (refs/heads/) -> "-" (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 diff --git a/.gitea/workflows/spdk-image.yaml b/.gitea/workflows/spdk-image.yaml index 9c54a83..2b1ee36 100644 --- a/.gitea/workflows/spdk-image.yaml +++ b/.gitea/workflows/spdk-image.yaml @@ -9,15 +9,25 @@ # Runs only when the SPDK image inputs change (path filter) or on demand — not # on every commit. Bump SPDK_VERSION (or use the manual run input) to roll SPDK. # +# Builds off ANY branch (dev branches welcome); the SPDK release (SPDK_VERSION / +# the dispatch input) is the --build-arg and the base tag. To avoid clobbering +# the main image, dev branches push "--" and never move +# :latest: +# - tag / main push -> "" (+ :latest) +# - branch push -> "--" (dev image, no :latest) +# Set SPDK_VERSION in serving/.env to the tag you want to run. +# # Required Gitea repo secrets: PACKAGE_USER, PACKAGE_TOKEN (registry creds). # name: Build SPDK NVMe-oF image -run-name: ${{ gitea.actor }} builds the SPDK NVMe/TCP target image +run-name: ${{ gitea.actor }} builds the SPDK NVMe/TCP target image (${{ gitea.ref_name }}) on: push: branches: - - "main" + - "**" # every branch — dev branches get a disambiguated tag + tags: + - "v*" paths: - "serving/spdk/**" - ".gitea/workflows/spdk-image.yaml" @@ -28,9 +38,14 @@ on: required: false default: "v24.09" +# One (slow) build per ref at a time; a newer push to the same ref cancels it. +concurrency: + group: spdk-image-${{ gitea.ref }} + cancel-in-progress: true + env: SPDK_IMAGE: "git.bitasylum.org/audiobuzz/spdk-nvmf" - SPDK_VERSION: "v24.09" # default tag for push-triggered builds + SPDK_VERSION: "v24.09" # default SPDK release for push-triggered builds jobs: spdk-build: @@ -66,12 +81,30 @@ jobs: STORAGE_DRIVER: vfs BUILDAH_ISOLATION: chroot run: | - VER="${{ inputs.spdk_version || env.SPDK_VERSION }}" - echo "Building ${SPDK_IMAGE}:${VER}" + # SPDK release: the --build-arg + base tag (dispatch input wins). + SVER="${{ inputs.spdk_version || env.SPDK_VERSION }}" + short_sha="$(echo "${GITHUB_SHA}" | cut -c1-7)" + # main / release tags -> "" + :latest; dev branches -> + # "--", no :latest (so they don't clobber main). + if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ] || [ "$GITHUB_REF" = "refs/heads/main" ]; then + TAG="$SVER"; push_latest=true + else + branch="${GITHUB_REF#refs/heads/}" + safe_branch=$(printf '%s' "$branch" \ + | sed -e 's#[^A-Za-z0-9._-]#-#g' -e 's#^[.-]*##' | cut -c1-80) + [ -n "$safe_branch" ] || safe_branch="branch" + TAG="${SVER}-${safe_branch}-${short_sha}"; push_latest=false + fi + echo "Building ${SPDK_IMAGE}:${TAG} (SPDK $SVER, latest=$push_latest)" buildah bud \ - --build-arg SPDK_VERSION="$VER" \ - -t "${SPDK_IMAGE}:${VER}" \ - -t "${SPDK_IMAGE}:latest" \ + --build-arg SPDK_VERSION="$SVER" \ + -t "${SPDK_IMAGE}:${TAG}" \ serving/spdk - buildah push "${SPDK_IMAGE}:${VER}" - buildah push "${SPDK_IMAGE}:latest" + buildah push "${SPDK_IMAGE}:${TAG}" + if [ "$push_latest" = "true" ]; then + buildah tag "${SPDK_IMAGE}:${TAG}" "${SPDK_IMAGE}:latest" + buildah push "${SPDK_IMAGE}:latest" + echo "Updated ${SPDK_IMAGE}:latest" + else + echo "Dev branch build — :latest left untouched (run with SPDK_VERSION=${TAG})" + fi diff --git a/.gitignore b/.gitignore index 18f209c..a05b992 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ keys /files/secureboot/keys/*.auth /files/secureboot/keys/*.esl /files/secureboot/keys/*.crt + +# iPXE network bootloader binaries for the PXE fallback (serving/tftp). Binary, +# and the signed one is db-derived/environment-specific — supply per environment. +#/serving/tftp/*.efi +/serving/tftp/*.kpxe diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6a9e263 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "serving/kiosk-app"] + path = serving/kiosk-app + url = git@git.bitasylum.org:audiobuzz/fleet-kiosk-app.git diff --git a/CLAUDE.md b/CLAUDE.md index 017f1c7..b7118c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,9 +33,16 @@ files/plymouth/themes/fleet/ Custom boot splash (scripted security files/kiosk/ weston+Chromium kiosk + chromium lockdown policy scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image +scripts/build-ipxe-oci.sh sbsign snponly.efi -> FROM-scratch fleet-ipxe image serving/ Lab serving stack: dnsmasq + nginx + SPDK + (dnsmasq + fleet-ipxe + SPDK images are + CI-built + pulled; see .gitea/workflows/) serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA - (kiosk endpoint; nginx reverse-proxies /kiosk/) + — GIT SUBMODULE (own repo + CI; builds + the fleet-kiosk image the stack pulls). + OS-side kiosk runtime stays in files/kiosk/ +serving/dot1x-bench/ Secure-path bench: step-ca + FreeRADIUS + + hostapd authenticator + TPM enrolment .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI docs/ Design PDF and supporting notes ``` @@ -128,8 +135,13 @@ instead of working around it. SHA256SUMS}`. `rootfs.erofs` (erofs data) and `rootfs.hash` (dm-verity hash tree) are **separate** namespaces because the as-built Rocky 9 systemd does not support the in-file `hash-offset` verity layout. No shell, no tools, no - base image. Serving software (SPDK, nginx) lives in separate, independently - versioned images — never merge them into the artifact image. + base image. Serving software (SPDK, nginx, dnsmasq) lives in separate, + independently versioned images — never merge them into the artifact image. + The signed iPXE bootloader (`snponly.efi`) is its own FROM-scratch + **`fleet-ipxe`** image (`scripts/build-ipxe-oci.sh`, signed with `db`), mounted + into dnsmasq at `/ipxe` — kept out of *both* the artifact image and the + (generic, key-agnostic) dnsmasq image because its signature is + per-Secure-Boot-environment. Don't bake `snponly.efi` into the dnsmasq image. 1. **Deployments are pinned by digest.** One commit per fleet release in the deployment manifest; that manifest is the single source coupling UKI ↔ namespace. Rollback = revert the commit. @@ -300,6 +312,11 @@ authenticator + FreeRADIUS on a veth/bridge (bridge needs `group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port `Authorized`, macsec0 addressed, NVMe namespace connected, verity root mapped, overlay writable, supplicant alive after switch-root. +This harness is scaffolded in **`serving/dot1x-bench/`** (step-ca as the fleet +CA on the serving host + FreeRADIUS EAP-TLS + a `hostapd` wired authenticator + +`tpm-provision.sh`), with an **isolated bridge per workstation** (the flat VLAN +can't authenticate — EAPOL is dropped and MACsec is point-to-point). See its +README; several configs there are still BENCH-VERIFY. Any change to the dracut module, supplicant config, or cmdline must pass this before merge. Template-only changes need `packer validate` + diff --git a/docs/TODO.md b/docs/TODO.md index 471a1ba..519a2eb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -572,13 +572,67 @@ this item builds the rollout machinery around it. stay served); security rollback via dbx-revoke / TPM monotonic counter (design §6). - **Drain & retire** an old version only when SPDK/nvmet reports zero connected - controllers on it. + controllers on it. **Drain accuracy:** account for hosts powered off mid-rollout + — an assigned-but-offline host will reconnect to a version that may be gone, so + don't remove a version while any host that could return is still assigned to it. - **Availability**: run NVMe/TCP target replicas behind distinct discovery entries (design §7) — never serve the whole fleet from one unreplicated node, especially mid-rollout (N versions + reboot bursts). - Note: kiosk *content* changes are server-side (the website) and need no fleet update — only OS/UKI/rootfs changes require a reboot. +### SPDK restart / availability is the load-bearing constraint (analysis 2026-06-25) + +SPDK sits in the **live data path of every running host's root fs**, so a process +restart isn't like restarting a stateless service — it tears down **every** +NVMe/TCP association at once (fleet-wide, NOT version-scoped). The rollout design +must be built around **never restarting the process that holds live connections.** + +- **Core principle:** a host changes root version *only by rebooting* (no live + swap). So: **version transitions = reboots; SPDK changes = additive/online; the + process stays up.** Routine rollouts never restart SPDK. +- **Online add/remove via JSON-RPC, no restart.** `nvmf_tgt` is fully + runtime-configurable (`bdev_aio_create`, `nvmf_create_subsystem`, + `nvmf_subsystem_add_ns`, `..._add_listener`). Add vY's namespaces live (existing + vX connections untouched); remove vX live once drained. **Per-version subsystem + (distinct NQN)** matters here: adding vY as a *new subsystem* never disturbs the + subsystem vX hosts are attached to — adding a *namespace* to a shared subsystem + fires a namespace-change AER + rescan at running hosts. Per-version subsystem + also gives clean per-release drain + per-release hostNQN allow-listing. +- **What a connection loss does to a running host:** root is **read-only erofs + + tmpfs overlay**. tmpfs writes (RAM) + page-cached reads keep working; **cold + reads block**. The nvme-tcp host driver reconnects (`reconnect_delay`) until + `ctrl_loss_tmo`. **Fast SPDK restart** (same NQN/UUID/addr within the window) → + host **survives with a stall**; **long/changed/expired** → root I/O errors, host + needs a reboot ("keep alive timeout / no route to host"). Read-only + + content-addressed makes reconnect unusually clean (no write/reservation state). +- **Cheap host-side mitigation:** tune `ctrl_loss_tmo` (even `-1` = retry forever) + + `reconnect_delay` at `nvme connect` time (dracut nvmf step). For a diskless box + "hang and recover" beats "die" — there's nothing better to fail to. +- **Structural answer — HA + NVMe-oF multipath (ANA):** run ≥2 targets serving + **byte-identical, same-UUID** namespaces; hosts multipath across them, so one + target can be restarted/upgraded while the other serves (no root loss). The + **read-only, content-addressed root makes replication trivially coherent** (no + write-coherence problem) — this architecture is ideally suited to it. Wrinkle: + the **immutable UKI cmdline** points at one discovery address, so multipath needs + discovery to return multiple paths (or a DNS name resolving to several targets). +- **Planned SPDK restarts only via drain/failover** (multipath fail-over, or a + maintenance window with generous `ctrl_loss_tmo` so hosts recover not die). There + is **no zero-impact single-node SPDK restart.** +- **Implementation gap (current lab):** SPDK runs as a container with + `spdk-setup.sh` as entrypoint + the artifact image mounted → effectively + **single-version, configured at container start**, so changing versions = + container restart = drop all connections. The work here is a **long-lived target + + an RPC-driven control path** ("add release / remove release / drain status") + decoupled from container lifecycle, with per-version subsystems. +- **Capacity:** each concurrently-served version costs target memory/hugepages + + file-backed bdev overhead — plan for N live versions (current + previous + + canary) during long rollouts. +- **Attach gating + discovery:** with several subsystems advertised, the dracut + connect + per-subsystem hostNQN allow-list must ensure a vX host attaches **only** + vX's namespace (the by-UUID verity ref protects *what mounts*, but gate the + *attach* too); `rd.nvmf.discover` selection must be deterministic, not connect-all. + ## [ ] 7. Harden / evaluate the NVMe-oF target (serving-node resilience) There is **no production-grade memory-safe NVMe-oF target** — the Rust NVMe work @@ -607,7 +661,13 @@ export is read-only + single-namespace = inherently small attack surface. - **Network-isolate :4420** so only the boot VLAN reaches the target (compose with 802.1X/MACsec segmentation in prod). - **Replicas** behind distinct discovery entries (availability is the real risk - — it's the fleet's disk). Ties into #6 (rolling updates) availability. + — it's the fleet's disk). The target is a **fleet-wide SPOF in every running + host's root data path**; a process restart drops *all* NVMe/TCP associations at + once. Structural fix = **HA + NVMe-oF multipath (ANA)** over byte-identical, + same-UUID, read-only namespaces (replication is coherent precisely because the + root is read-only + content-addressed); cheap mitigation = generous host + `ctrl_loss_tmo`/`reconnect_delay`. See the **"SPDK restart / availability"** + analysis under #6 (rolling updates) — same constraint, fuller treatment. - Revisit a memory-safe target only if/when an off-the-shelf one matures (e.g. a future Rust-for-Linux `nvmet`). @@ -620,12 +680,86 @@ smoke test. - **Re-enable 802.1X/MACsec:** `disable_dot1x=false` (CI `DISABLE_DOT1X="false"`). Removes `rd.dot1x=0`/`ip=dhcp`; the hook owns the link (macsec0). + - **Bench harness scaffolded 2026-06-22: [serving/dot1x-bench/](../serving/dot1x-bench/).** + step-ca (fleet CA, on the serving host) signs each host's **TPM-generated CSR** + + issues the RADIUS server cert; FreeRADIUS does EAP-TLS (trusts step-ca root, + returns MSK); a `hostapd` wired authenticator does 802.1X + MACsec on an + **isolated bridge per workstation** (flat VLAN can't auth — EAPOL dropped + + MACsec is P2P). `enrol/tpm-provision.sh` creates the key in the TPM at + `0x81010001` + writes the cert to NV `0x01800001`. Starting solution **signs a + CSR** (key stays in TPM); **TPM attestation** (ACME `device-attest-01`) is the + documented upgrade on the same CA. **BENCH-VERIFY** items flagged in the files: + hostapd MACsec build, openssl tpm2 provider vs pkcs11 key ref, EAP-TLS version. + PKI product decision (greenfield): start step-ca; OpenBao/EJBCA/Dogtag later. + - **First step (DONE 2026-06-22): `enrol/tpm-selftest.sh`** — non-destructive TPM + capability check (run on the booted open-port image, no `disable_dot1x` needed) + to de-risk the TPM half before the full path. Likely first finding: no openssl + TPM2 **provider** in the image (`tpm2-tss` ≠ the provider) — add `tpm2-openssl`. + - **Enrolment of the per-host identity** (key@`0x81010001` + cert@NV) is its own + design — see **#11** (TOFU + OOB fingerprint broker, pre-auth permit-set, + provisioning UKI). `tpm-provision.sh` is the manual bench stand-in for it. - **NVMe discovery by DNS:** `nvmet_traddr=nvme.fleet.lab` (CI), resolved at boot by the serving dnsmasq — drop the hardcoded `192.168.50.10`. -- **Signing:** wire `sb_key`/`sb_cert` (CI/HSM secrets) for a signed UKI; enable - Secure Boot on targets. Until then the loud unsigned warning must stay. +- **Signing — SIGNED-UKI + SECURE-BOOT-ON PATH BENCH-VERIFIED on the Proxmox VM + (HTTP boot, 2026-06-20).** CI signs the UKI when `SB_KEY`/`SB_CERT` secrets are + set (`db` key); `fleet-sb-enroll` enrols db→KEK→PK from the baked public `.auth` + files; OVMF then boots the signed UKI under enforcing Secure Boot. Confirmed + end-to-end: CI-signed UKI verifies (`sbverify`), enrolment succeeds, VM boots + with `mokutil --sb-state` = enabled. **Key generation + enrolment procedure: [secure-boot-keys.md](secure-boot-keys.md)** (the PK/KEK/db trio; `db` = `sb_key`/`sb_cert`, PK/KEK stay offline). + - **Bug fixed during bring-up (2026-06-20):** `fleet-sb-enroll` built its 4-byte + EFI attribute prefix in a bash variable (`ATTR=$'\x27\x00\x00\x00'`). Bash + strings can't hold NULs → only `0x27` survived → efivarfs read a corrupt + attribute u32 → every write failed with `EINVAL` ("dd: Invalid argument"). + Now emitted directly with `printf` at write time. **Baked fix VERIFIED on the + 820 (2026-06-22): `sudo fleet-sb-enroll` enrolled db→KEK→PK cleanly, no inline + workaround.** (Red herring on the way: + Proxmox EFI disks default to `pre-enrolled-keys=1`, so `mokutil --db` shows + the MS cert even in Setup Mode — looked like a non-empty-db rejection but was + the NUL bug all along; it failed identically on a clean `pre-enrolled-keys=0` + disk.) + - **iPXE signing WIRED (2026-06-21):** `snponly.efi` is now built + signed + (`db`) + pushed by CI as a separate FROM-scratch **`fleet-ipxe`** image + (`scripts/build-ipxe-oci.sh`, `.gitea/workflows/ipxe-image.yaml`), mounted into + dnsmasq at `/ipxe`. dnsmasq itself moved to a generic CI-built/pulled image + (`.gitea/workflows/dnsmasq-image.yaml`) — no longer built at deploy time, and + carries no keys/binary. (Decision: separate `fleet-ipxe` image, not baked into + dnsmasq, so the daemon image stays key-agnostic across SB environments.) + - **820 / PXE signed chain — BENCH-VERIFIED 2026-06-22.** Real hardware (HP + EliteBook 820 G4), Secure Boot **enforcing** (`mokutil --sb-state` = enabled, + Setup Mode no, db/KEK/PK enrolled): firmware verifies the signed `snponly.efi` + (fleet-ipxe image) → signed UKI and boots. Confirms the whole `fleet-ipxe` + signing pipeline + the PXE→iPXE→UKI chain under SB on real silicon (the VM only + covered the HTTP path, which has no iPXE stage). + - **REMAINING for production:** (a) **secure path** — re-verify signed boot with + `disable_dot1x=false` (802.1X/MACsec), not just the test path; (b) confirm + signing is reproducible (verity hash stable across two signed builds). +- **PXE-only iPXE fallback — BENCH-VERIFIED on the EliteBook 820 G4 (2026-06-20).** + Firmware without UEFI HTTP boot chainloads `serving/tftp/snponly.efi` → renders + `autoexec.ipxe` (ifopen + DHCP-retry + `imgargs `) → UKI over HTTP + → NVMe/verity root. dnsmasq tags `pxe`/`ipxe`/`httpboot` give graceful per-client + fallback; HTTP-boot hosts are unaffected (firmware → UKI direct). Gotchas that + cost real time (all captured in [../serving/tftp/README.md](../serving/tftp/README.md)): + (i) the I219-LM needs **`snponly.efi`** (native iPXE driver won't bind); + (ii) **PortFast/edge on the switch port is REQUIRED** — iPXE's PHY bounce + triggers STP forwarding delay that drops DHCP (this was the real blocker); + (iii) iPXE aborts the script on any unguarded command failure (use `||`); + (iv) Secure Boot OFF makes systemd-stub take iPXE's load options as the cmdline, + so the entrypoint extracts the UKI's real `.cmdline` and feeds it via `imgargs`. + **Tidied 2026-06-20** once PortFast was identified: dropped the `autoexec.ipxe` + + `ifopen` + DHCP-retry STP workaround; `boot.ipxe` is now just + `dhcp`+`imgfetch`+`imgargs`+`boot` reached via normal iPXE autoboot. Re-verified + on the 820 G4. **SB-on simplification:** when Secure Boot is enabled the cmdline + injection is unnecessary (the stub ignores the loader cmdline) — collapse + `boot.ipxe` to a plain `chain` of the UKI and drop the `.cmdline` extraction + + `binutils` + the artifact mount from the dnsmasq service. +- **Sign iPXE for the PXE-only fallback (Secure Boot, production).** The lab + `snponly.efi` is **UNSIGNED** → that path needs **Secure Boot OFF**. For + production: `sbsign` the iPXE binary with the fleet **db** key (only `db` needed — + no MS CA / shim), so SB stays enforced firmware → iPXE → UKI; then the cmdline + contraption becomes a no-op (the stub ignores loader cmdline under SB). See + [secure-boot-keys.md](secure-boot-keys.md) "PXE-only hosts". - **Bench-verify on the secure path** (swtpm + hostapd + FreeRADIUS, `disable_dot1x=false`) — the items deferred this session: - NM port secure path: `en*` UNMANAGED + `macsec0` MANAGED/renewing, MKA @@ -695,7 +829,9 @@ full ~17-min Packer build. Speed up bringing up new workstations: bake the **public** PK/KEK/db certs into an image, detect Secure Boot **Setup Mode** at boot, and auto-enroll them so a fresh/reset box adopts the fleet trust root without a manual firmware-setup visit. -Pairs with #8 (signing) + [secure-boot-keys.md](secure-boot-keys.md). +Pairs with #8 (signing) + [secure-boot-keys.md](secure-boot-keys.md). Shares the +provisioning-UKI + isolated-environment vehicle with **#11** (which enrols the +*network* identity); build them together. **Tooling gap (found 2026-06-20): `efitools` is NOT packaged for EL9** — no `efi-updatevar`/`efi-readvar`/`KeyTool`. Resolved for the OS-side path by writing @@ -755,3 +891,70 @@ enrolls. reasonable for a controlled fleet; the highest-assurance path is attended/firmware or manufacturer pre-provisioning. Default recommendation: **separate provisioning UKI + isolated VLAN + read-back verify.** + +## [ ] 11. (DESIGN) TPM 802.1X identity enrolment (TOFU + OOB fingerprint) + +How a host gets its **802.1X EAP-TLS client identity** the first time — the key at +TPM handle `0x81010001` + cert at NV `0x01800001` that invariant #1 says must come +from enrolment, never the image. Distinct from #10 (that enrols *Secure Boot* +PK/KEK/db); this enrols the *network identity*. They share the provisioning UKI + +isolated-environment vehicle. Pairs with #8 + the CA decision (start **step-ca**, +[serving/dot1x-bench/](../serving/dot1x-bench/); OpenBao/EJBCA/Dogtag later). + +**Chosen pattern — trust-on-first-use with out-of-band fingerprint approval:** +on an empty TPM the host generates a key *in* the TPM, builds a CSR, submits it to +an enrolment **broker**, and shows a **fingerprint** on the Plymouth splash; an +admin visually cross-checks that fingerprint against the broker's pending-list and +approves; the broker has step-ca sign the CSR and returns the cert; the host writes +it to NV `0x01800001` and proceeds to authenticate. Sound, recognised pattern +(SSH/WireGuard/Signal-style OOB verification); good fit for invariant #1. + +**Key design decisions (settled 2026-06-22):** + +- **The pre-auth permit-set already exists — enrolment rides in it.** The firmware + can't do 802.1X, so the UKI fetch (DHCP/DNS/TFTP/HTTP to the serving host) is + **always pre-auth**; the secure model therefore *requires* a pre-auth permit-set + (low-impact 802.1X / pre-auth dACL / boot→secure dynamic VLAN; RADIUS returns the + secure VLAN on Access-Accept). The enrolment endpoint is just **another entry in + that set** — expose it as an `/enrol/` location on the **existing serving nginx**, + reverse-proxied to the broker. No new network mechanism. +- **The broker is the only new component.** step-ca signs but has no human-approval + queue: broker = `POST CSR + context → PENDING + fingerprint → admin approves → + step-ca signs → host polls → cert`. Exposed to *unauthenticated* devices by + design → harden: OOB-fingerprint gate (no auto-sign), rate-limit, time-boxed + pending, HTTPS (image trusts the serving CA), pre-auth permit scoped to only + these services + the serving host. +- **Trigger on EMPTY-NV only — split the three events:** empty NV → enrol (human); + valid key + expired/near-expiry cert → **renew** automatically (no human, it + already holds a trusted key); auth fails with a present cert → **alert + retry, + do NOT re-enrol** (auto-reenrol on failure would mask revocation and churn keys + on a transient RADIUS outage). Re-enrol after the first time = explicit admin + action (clear/flag the host). +- **Fingerprint: what it proves + entropy.** It proves "this CSR came from *this + physical device*" (approver reads the screen) — it does NOT prove TPM residency. + Pair it with **TPM attestation** (step-ca ACME `device-attest-01`, EK/AK) for the + residency proof: fingerprint = human authorises the device, attestation = the key + is really in a TPM (together → strong SSP R-01 story). Size for entropy — a short + hex fp is grindable; use a word-list/BIP39-style encoding (reads short, more + bits) + rate-limiting + a server nonce. Show device context (asset/port/location) + so the admin knows *what* they're approving. +- **Pre-auth traffic has no MACsec (plaintext at L2).** Fine for enrol (CSR/cert + are public; OOB fingerprint gives authenticity) — and it's exactly **why the UKI + must be Secure-Boot-signed** (#8): integrity of the boot comes from the signature, + not the network. Enrolment design + Secure Boot are two halves of the same + "pre-auth path is hostile" assumption. +- **Run it in a provisioning UKI/mode, not the production initramfs** — the boot + hook ordering is load-bearing (invariant #2); don't park it on a multi-minute + human-approval wait. Shares the #10 provisioning-UKI vehicle. + +**Bench caveat:** `hostapd` wired is strictly port-based (no pre-auth per-flow +ACL) — before EAP success it passes nothing, so the bench can't reproduce +"boot + enrol pre-auth on the same port." Model the pre-auth state with a separate +provisioning bridge/VLAN on the rig; the in-place low-impact pre-auth permit is a +real-switch feature to validate on the 820's switch. + +**First step DONE (2026-06-22):** `serving/dot1x-bench/enrol/tpm-selftest.sh` — a +non-destructive TPM capability check (device, key persist, NV roundtrip, openssl +TPM2 provider, CSR signed by the TPM key) to de-risk the TPM half before any of +this. Expected gap: the image installs `tpm2-tss` but no openssl TPM2 **provider** +(`tpm2-openssl`) — add it to the build once the self-test confirms. diff --git a/docs/design-v2-errata.md b/docs/design-v2-errata.md index 5c7e131..6d105c2 100644 --- a/docs/design-v2-errata.md +++ b/docs/design-v2-errata.md @@ -4,7 +4,7 @@ Records where the as-built implementation diverges from `diskless-fleet-design-v2.pdf`. The PDF remains authoritative for the architecture; this note captures deltas discovered bringing the build up on **Rocky 9.8** until they are folded into a consolidated **v3**. Last updated -2026-06-15. +2026-06-22. The architecture is unchanged: 802.1X EAP-TLS with a TPM-held key, MACsec on the access link, dm-verity-protected erofs root over NVMe/TCP, signed UKI, and the @@ -57,6 +57,43 @@ indices shift with the transient discovery controller. [../serving/spdk-setup.sh](../serving/spdk-setup.sh) (2nd bdev/namespace), and CLAUDE.md invariant #9. +## 1c. Design-level addition — pre-auth boot path & per-host identity enrolment + +Two design points the PDF leaves implicit; both are load-bearing for the secure +model and are recorded here pending v3. + +**The boot path is inherently pre-auth, so a pre-auth permit-set is mandatory.** +The firmware (UEFI HTTP boot / PXE→iPXE) fetches the UKI **before** any OS or +supplicant exists — there is no firmware 802.1X supplicant — so the UKI/initramfs +fetch always happens in the unauthenticated state. The secure model therefore +requires the authenticator to permit a *scoped* set of services pre-auth +(DHCP/DNS + TFTP + HTTP to the serving host) and only complete 802.1X once the +**OS** is running; RADIUS returns the secure VLAN on Access-Accept, after which +DHCP-for-the-running-system and the NVMe/TCP root ride **`macsec0`** (post-auth, +encrypted). Mechanism on a real switch: low-impact 802.1X / a pre-auth dACL / +boot→secure dynamic VLAN. Consequences: +- Pre-auth traffic has **no MACsec** (plaintext at L2). Integrity of the boot + therefore comes from **Secure Boot** (the signed UKI + signed `fleet-ipxe`), not + the network — this is *why* signing matters: the boot transport is untrusted. +- `hostapd` wired (the bench authenticator) is strictly port-based and can't model + a pre-auth per-flow permit — the in-place pre-auth ACL is a real-switch feature, + validated on the 820's switch, not the VM rig (see `serving/dot1x-bench/`). + +**Per-host 802.1X identity is created by an enrolment flow, never baked in** +(invariant #1). Chosen pattern: **trust-on-first-use with out-of-band fingerprint +approval** — on an empty TPM the host generates a key *in* the TPM +(`0x81010001`), submits a CSR to an enrolment **broker** (reachable via the same +pre-auth permit-set, e.g. an `/enrol/` location on the serving nginx → broker → +step-ca signer), and shows a fingerprint on the Plymouth splash; an admin +cross-checks it and approves; the signed cert is written to NV `0x01800001` and +authentication proceeds. Enrol triggers on **empty-NV only** (expired cert → +automatic renew; auth-failure-with-cert → alert, not re-enrol). The fingerprint +authorises *which device*; **TPM attestation** (step-ca ACME `device-attest-01`) +proves *key residency* — use both for full assurance. Runs in a provisioning +UKI/mode, not the production initramfs. Full design + decisions: **TODO #11** (the +network-identity counterpart to **#10**, which enrols Secure Boot PK/KEK/db; both +share the provisioning-UKI vehicle). + ## 2. Implementation notes (not architectural) These don't contradict the PDF (it doesn't specify them) but are worth recording. diff --git a/docs/secure-boot-keys.md b/docs/secure-boot-keys.md index c68595d..da02273 100644 --- a/docs/secure-boot-keys.md +++ b/docs/secure-boot-keys.md @@ -130,6 +130,39 @@ on a trusted/isolated network (see TODO #10). Only the **public** certs/`.esl`/`.auth` reach the workstation — never copy `PK.key`/`KEK.key`/`db.key` onto a host. +### Troubleshooting `fleet-sb-enroll` (bench, 2026-06-20) + +**`dd: ... Invalid argument` (EINVAL) on the `db` write** = the firmware's +`SetVariable` rejected the payload (EFI_INVALID_PARAMETER), *not* an auth/Setup +Mode failure (that surfaces as EACCES / "Permission denied"). Diagnose top-down: + +- **Is the `.auth` actually a signed authenticated variable?** First 16 bytes are + an `EFI_TIME`: `sudo od -An -tx1 -N16 db.auth` → a valid one starts with the + year little-endian (2026 = `ea 07`). If it starts with `a1 59 c0 a5 …` it's a + bare **ESL** (the `sign-efi-sig-list` step was skipped); `30 82 …` is a raw DER + cert. Re-create the `.auth` (step 2) and re-bake. +- **The 4-byte attribute prefix must contain its NUL bytes.** A bash *variable* + **cannot** hold NULs — `ATTR=$'\x27\x00\x00\x00'` silently truncates to the + single byte `0x27`, so efivarfs reads a corrupt attribute u32 and the kernel + returns EINVAL **before the firmware is even called**. This was a real bug in an + early `fleet-sb-enroll`; the fix emits the prefix with `printf '\x27\x00\x00\x00'` + at write time (never via a variable). If you hand-roll an enrolment, do the same. +- **Proxmox / OVMF red herring:** VM EFI disks default to `pre-enrolled-keys=1`, + so `mokutil --db` shows the **Microsoft** cert (`Windows Production PCA 2011`, + owner `77fa9abd-…`) *even in Setup Mode*. This looks like a "db not empty → + rejected" problem but isn't — the write fails identically on a clean + `pre-enrolled-keys=0` disk if the NUL bug is present. For a clean OVMF test bed: + `qm set --efidisk0 :1,efitype=4m,pre-enrolled-keys=0`. + +**Verified end-to-end on real hardware (2026-06-22):** `fleet-sb-enroll` (the +baked, fixed script) enrols db→KEK→PK cleanly, and both boot transports verify +under **enforcing Secure Boot** (`mokutil --sb-state` = enabled): +- **Proxmox VM — HTTP boot:** firmware → signed UKI (2026-06-20). +- **HP EliteBook 820 G4 — PXE boot:** firmware → signed `snponly.efi` (fleet-ipxe) + → signed UKI (2026-06-22). +So the full signing pipeline (UKI + `fleet-ipxe`, both against the same enrolled +`db`) is proven on real silicon, not just in the VM. + ## 4. Wire `db` into the build ```bash @@ -170,6 +203,57 @@ Caveats: CI-secret flow is a reasonable interim; the HSM flow is the invariant-#7 ideal (TODO #8). +## PXE-only hosts: iPXE chainload and Secure Boot + +Firmware without UEFI HTTP boot (e.g. HP EliteBook 820 G4) falls back to PXE; the +serving stack hands those hosts **iPXE** over TFTP (`snponly.efi`), which then +chainloads the UKI over HTTP. dnsmasq selects per client automatically +(HTTP-capable → UKI direct; PXE-only → iPXE → UKI), so no per-host config. + +**Packaging:** the signed `snponly.efi` is built + signed + pushed by CI as a +small FROM-scratch **`fleet-ipxe` image** (`.gitea/workflows/ipxe-image.yaml` → +`scripts/build-ipxe-oci.sh`), mounted into the dnsmasq container at `/ipxe` +(serving/docker-compose.yml). It is deliberately a **separate, per-Secure-Boot- +environment image** (its signature is `db`-key-specific) so the dnsmasq image +stays generic/key-agnostic — same reasoning as invariant #9 keeping serving +software out of the release-artifact image. For offline/lab use, drop an +`snponly.efi` in `serving/tftp/` and the entrypoint falls back to that bind mount. + +Secure Boot across that chain needs **only the `db` key — the one already +enrolled for the UKI. No extra keys, no Microsoft CA, no shim** (the fleet owns +its keys): + +``` +firmware --(verify snponly.efi vs db)--> iPXE --(chain -> firmware LoadImage + verifies UKI vs db)--> UKI +``` + +- The firmware verifies the TFTP-downloaded NBP (`snponly.efi`) against `db` → + **the iPXE binary must be signed by `db`** (`sbsign`; CI does this in + `build-ipxe-oci.sh` when the `SB_KEY`/`SB_CERT` secrets are set, same key as the + UKI). Stock/unsigned iPXE is rejected under Secure Boot. +- iPXE must load the UKI as an **EFI image** (`imgfetch` + `boot`, or `chain`) so + the firmware's `LoadImage()` re-verifies it against `db` — do **not** use iPXE's + `kernel`/`boot` on a raw vmlinuz, which bypasses SB. +- **cmdline gotcha (Secure Boot OFF only):** when iPXE executes the UKI it sets the + EFI load options to the image's cmdline, or — if empty — to the image **name**. + With SB off systemd-stub uses the load options *as the kernel cmdline*, + **replacing the UKI's embedded cmdline** (roothash/nvmf/...) → dracut emergency + shell. Clearing the args does NOT help (empty → iPXE falls back to the name). The + serving stack's rendered `boot.ipxe` therefore feeds iPXE the **real cmdline**, + which the dnsmasq entrypoint extracts from the UKI's `.cmdline` PE section + (`objcopy`, artifact mounted read-only) — see `serving/tftp/README.md`. **Secure + Boot ON is immune** — the stub ignores any loader-supplied cmdline — so this is + purely a lab/SB-off concern. + +**Posture:** the `fleet-ipxe` image is signed with `db` **whenever CI has the +`SB_KEY`/`SB_CERT` secrets** (same condition as the UKI), so the PXE path can stay +Secure-Boot-enforced end-to-end. An **unsigned** `fleet-ipxe` build (no secrets) +keeps its loud warning and requires Secure Boot OFF on the PXE target. The +HTTP-boot path (firmware → UKI) is unaffected and SB-clean regardless. Still +**BENCH-VERIFY** the signed PXE chain on the 820 (the VM uses HTTP boot, so the +signed iPXE stage is unverified on real hardware — TODO #8). + ## Key hygiene & lifecycle - **PK.key and KEK.key go offline / into an HSM and stay there.** They are only diff --git a/docs/ssp-annex-a-ism-residual-risk.md b/docs/ssp-annex-a-ism-residual-risk.md index aa2c150..bc59136 100644 --- a/docs/ssp-annex-a-ism-residual-risk.md +++ b/docs/ssp-annex-a-ism-residual-risk.md @@ -8,6 +8,13 @@ > > **A&A framework:** ASD *Information Security Manual* (ISM) — engagement target > **May 2026 release**. +> +> **Annex version:** 0.2 (updated 2026-06-22). v0.2 reflects: STIG hardening +> applied + bench-verified, USBGuard device control enabled, admin `staff_u` +> confinement, audit offload verified, PXE/iPXE boot path added, and **UEFI Secure +> Boot signing verified enforcing on real hardware** (VM HTTP + 820 PXE) — R-13 +> reduced from Medium to Low (residual is making signed + SB-on the standing +> default). --- @@ -74,8 +81,9 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| -| Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` with documented tailoring ([stig-deviations.md](stig-deviations.md)). | Partial | R-03 (CI report-only/disabled now) | -| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + one trusted kiosk binary) + immutable dm-verity root. | Implemented | — | +| Hardened OS to ASD/vendor guidance | ISM-1407*, ISM-1408* | Apply a recognised hardening baseline. | **DISA STIG** (SSG `profile_stig`, RHEL 9) via `oscap --remediate` + documented tailoring + per-build deviations report ([stig-deviations.md](stig-deviations.md)); **applied + bench-verified 2026-06-20**, all REVIEW rows triaged. | Partial | R-03 (CI must ship `enable_stig=true` + signed/SB-on) | +| Application control / allow-listing | ISM-0843*, ISM-1490*, ISM-1657* | Only approved executables run. | **fapolicyd** (RPM-trust + two trusted local binaries: kiosk-session, fleet-sb-enroll) + immutable dm-verity root. | Implemented | — | +| Device / removable-media control | ISM-0341*, ISM-1551* | Control USB / peripheral devices. | **USBGuard enforcing** (`ImplicitPolicyTarget=block` + hardware-independent HID/hub allowlist + LinuxAudit). Verified: USB mass storage blocked, console input preserved. | Implemented | — | | Software / OS integrity | ISM-1493* | Detect unauthorised modification. | **dm-verity** continuous block-level verification of the read-only root against a signed hash; **reproducible** build. | Implemented | — | | Disable unneeded functionality | ISM-0380*, ISM-1416* | Minimise installed software/services. | `@^minimal` install; scanner tooling removed post-scan; FROM-scratch artifact image; kiosk = single app. | Implemented | — | | Hardened user applications (browser) | ISM-1486*, ISM-1412* | Lock down high-risk apps. | Chromium enterprise lockdown policy; weston kiosk; no shell/nav/plugins. | Implemented | — | @@ -89,7 +97,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| | Strong machine authentication | ISM-1546*, ISM-0974* | Mutual, certificate-based auth. | **802.1X EAP-TLS**, client key non-exportable in **TPM** (`0x81010001`), cert in TPM NV. | Partial | R-01 (not bench-verified; disabled in test builds), R-08 (placeholder CA) | -| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled. | Partial | R-05 | +| Privileged access restricted | ISM-1507*, ISM-0445* | Limit & control admin access. | Single key-only `wheel` admin; root login disabled; admin **confined to `staff_u`** with a sudo `sysadm_r`/`sysadm_t` role/type transition (SELinux context elevates on privilege use). | Partial | R-05 | | Secure remote administration | ISM-1506*, ISM-1232* | Hardened admin channel. | SSH **key-only**, password & keyboard-interactive off, hardened ciphers/MACs/KEX (STIG crypto policy; ECDSA/RSA keys — ed25519 excluded by policy). | Implemented | — | | No default/shared credentials | ISM-1304* | No baked credentials. | No per-host secrets in any artifact; identity from TPM at runtime; `machine-id` empty; host keys regenerated at first boot. | Implemented | — | | MFA for privileged users | ISM-1173*, ISM-1504* | MFA on privileged access. | Machine auth is cert+TPM (strong). **Human admin MFA on the management plane not yet enforced.** | Partial | R-05 | @@ -109,7 +117,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| -| Centralised event logging | ISM-0988*, ISM-1405* | Logs centrally collected. | `audisp-syslog`→rsyslog `omfwd` (disk-assisted queue) → dedicated collector (`serving/audit-collector/`). | Partial | R-07 | +| Centralised event logging | ISM-0988*, ISM-1405* | Logs centrally collected. | `audisp-syslog`→rsyslog `omfwd` (disk-assisted queue) → dedicated collector (`serving/audit-collector/`); **bench-verified end-to-end 2026-06-20**. auditd `space_left_action`→syslog (no MTA). | Partial | R-07 | | Required events logged | ISM-0582*, ISM-1537* | Log security-relevant events. | STIG auditd rule set; kernel `audit=1`, `audit_backlog_limit=8192`. | Implemented | — | | Protect logs in transit | ISM-1432*, ISM-0407* | Encrypt/authenticate log transport. | **Plaintext** offload on isolated LAN (rsyslog TLS rules waived — see [STIG #6](stig-deviations.md)). | Waived (lab) | **R-07** | | Log retention & integrity | ISM-0859*, ISM-1815* | Retain & protect logs. | Per-source-host files on collector; retention policy & tamper-protection not yet defined. | Planned | R-07 | @@ -144,7 +152,7 @@ ISM/PSPF style). Ratings are **pre-treatment** unless stated; the AO accepts the | Topic | ISM (rep.) | Requirement intent | System implementation | Status | Residual | |---|---|---|---|---|---| | Media sanitisation / no data remanence | ISM-0347*, ISM-1218* | Prevent data remanence. | **Diskless** — no persistent local media; writable layer is **RAM tmpfs**, gone at power-off. | Implemented | — | -| Tamper-evidence / boot integrity | ISM-1542*, ISM-0312* | Detect firmware/boot tampering. | **UEFI Secure Boot** + signed UKI + immutable signed cmdline + dm-verity; TPM measurements available for attestation. | Partial | R-11 (remote attestation not yet implemented) | +| Tamper-evidence / boot integrity | ISM-1542*, ISM-0312* | Detect firmware/boot tampering. | Design: **UEFI Secure Boot** + signed UKI (+ signed iPXE on the PXE path) + immutable signed cmdline + dm-verity; TPM measurements available. **Verified enforcing on real hardware (2026-06-22):** CI-signed UKI under SB on a VM (HTTP) and signed iPXE→UKI under SB on an 820 (PXE), `db` enrolled via `fleet-sb-enroll`. Residual is making signed + SB-on the standing default for all builds/hosts. | Implemented (default-posture residual) | **R-13** (not yet default), R-11 (no remote attestation) | | Device identification | ISM-1195* | Uniquely identify devices. | Identity derived from TPM cert (hostnqn from cert serial); no spoofable per-host config. | Implemented | — | ### 2.11 Guidelines for Procurement, Outsourcing & Evaluated Products @@ -165,11 +173,12 @@ asked to accept. Treatments map to [docs/TODO.md](TODO.md) (working POA&M). |---|---|---|---|---|---|---|---| | **R-01** | Secure path (802.1X EAP-TLS + MACsec) **implemented but not bench-verified end-to-end**; disabled in test builds (`disable_dot1x=true`). Until verified, devices may attach to an unauthenticated/unencrypted link. | 2.4, 2.7, 2.8 (ISM-1546*, 0520*, 1187*) | Likely × Major → **High** | Test path on an isolated lab LAN; full auth stack present (dormant); design follows ASD intent. | Full boot-chain bench test (swtpm + hostapd + FreeRADIUS): port `Authorized`, macsec0 addressed, MKA survives switch-root, NVMe root mounts. Re-enable in CI. | **Low** (once verified) | Platform team — *before production ATO* | | **R-02** | `debug_shell=true` bakes a **passwordless root shell** on a tty — an undocumented privileged access path. | 2.5 (ISM-1491*) | Possible × Severe → **High** | Lab-only; physical access required; isolated environment. | Set `debug_shell=false` for all non-lab builds; assert absent in CI. | **Low** | Platform team — *before production* | -| **R-03** | STIG hardening currently **report-only / disabled in CI** (`enable_stig=false`); shipped images may be unhardened. | 2.3 (ISM-1407*/1408*) | Possible × Major → **Medium** | Tailoring + register exist; SELinux/fapolicyd still on; re-enable is a single flag. | Flip `ENABLE_STIG="true"`; confirm scan evaluates >0 rules; triage REVIEW rows (buckets A/B/C in TODO #2). | **Low** | Platform team — *before assessment* | +| **R-03** | STIG hardening **applied + bench-verified** (2026-06-20, all REVIEW rows triaged); residual is ensuring **CI ships `enable_stig=true`** for production builds (it was toggled off during bring-up). | 2.3 (ISM-1407*/1408*) | Possible × Moderate → **Medium** | Tailoring + register + per-build report; SELinux/fapolicyd/USBGuard on; verified on a real hardened build. | Confirm `ENABLE_STIG="true"` in CI; keep the 0-eval guard; re-run the deviations report on the assessed build. | **Low** | Platform team — *before assessment* | | **R-04** | NVMe/TCP **root traffic plaintext** on the LAN — exposure of OS content / injection if the segment is compromised. | 2.9 (ISM-1139*/1453*) | Unlikely × Major → **Medium** | Isolated boot LAN; MACsec on the access link (once R-01 done) protects the first hop; dm-verity makes injected root blocks fail verification (integrity preserved even if confidentiality isn't). | Production: TLS/IPsec for the storage transport, or rely on MACsec end-to-end across the fabric; document the decision. | **Low–Medium** | Platform/network — *production* | -| **R-05** | **NOPASSWD key-only admin**; no per-host or rotatable access model; human admin MFA on the mgmt plane not enforced. | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, fully audited; no interactive password to attack. | Per-host/rotatable access (SSH CA / config-agent) + admin MFA on the management plane. | **Low–Medium** | Platform team — *roadmap* | +| **R-05** | **NOPASSWD key-only admin**; no per-host or rotatable access model; human admin MFA on the mgmt plane not enforced. | 2.4 (ISM-1507*/1173*) | Possible × Moderate → **Medium** | Single fleet-generic admin, key-only, root disabled, ECDSA/RSA key, **now SELinux-confined to `staff_u`** with sudo role/type elevation, fully audited; no interactive password to attack. | Per-host/rotatable access (SSH CA / config-agent) + admin MFA on the management plane. | **Low–Medium** | Platform team — *roadmap* | | **R-06** | **FIPS off**; ISM **AACA/AACP conformance not formally asserted**; crypto-module evaluation status unconfirmed. | 2.9 (ISM-0471*/1139*/1277*) | Possible × Major → **Medium** | Modern STIG crypto policy; non-exportable TPM key; no deliberately weak algorithms. Acceptable at OFFICIAL; **mandatory to resolve for PROTECTED+.** | Assert AACA/AACP conformance; decide on FIPS module / evaluated crypto alongside Secure Boot signing. | **Low** (at appropriate classification) | Security + Platform — *before PROTECTED+* | -| **R-07** | Audit-log **offload plaintext**, **single-site**, **no SIEM alerting**; logs interceptable/repudiable and not actively reviewed. | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Medium** | Isolated LAN; disk-assisted queue (no loss on collector blip); auditd local copy; per-source files. | rsyslog **TLS** (gtls/6514) + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); define retention + integrity; forward to SIEM with alerting. | **Low** | Platform/SecOps — *production* | +| **R-07** | Audit-log **offload plaintext**, **single-site**, **no SIEM alerting**; logs interceptable/repudiable and not actively reviewed. (Transport **bench-verified working** 2026-06-20.) | 2.6 (ISM-1432*/0109*/0859*) | Possible × Moderate → **Medium** | Isolated LAN; disk-assisted queue (no loss on collector blip); auditd local copy; per-source files; LinuxAudit captures USBGuard events. | rsyslog **TLS** (gtls/6514) + bundle CA in image (re-select the 3 waived `rsyslog_encrypt_offload_*` rules); define retention + integrity; forward to SIEM with alerting. | **Low** | Platform/SecOps — *production* | +| **R-13** | **Boot-chain signing not yet the standing default** — signing + Secure Boot enforcement are **verified end-to-end on real hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22, SB enforcing, `db` enrolled), but unsigned/SB-OFF lab builds remain possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not every host has `db` enrolled, so a substituted artifact could still boot on an un-enrolled/unsigned host. | 2.10 (ISM-1542*/0312*) | Unlikely × Major → **Low** | Capability **proven on hardware**; dm-verity rejects a tampered *root* regardless; isolated lab LAN. | Make signed + Secure-Boot-on the **default for all production builds/hosts**: keep `SB_KEY`/`SB_CERT` CI secrets set; enrol `db` fleet-wide; treat an unsigned build as lab-only. | **Low** | Platform team — *before production* | | **R-08** | **Placeholder RADIUS CA** (`files/etc/dot1x/radius-ca.pem`) — real EAP-TLS trust anchor not provisioned; auth cannot succeed and trust is unestablished. | 2.4 (ISM-1546*) | Certain (lab) × Major → **Medium** | Documented as a placeholder; environment-specific provisioning is the intended path; no real CA committed. | Provision the environment's real RADIUS CA out-of-band (not committed unless classification permits). | **Low** | Environment owner — *before production* | | **R-09** | **Host firewall disabled**; host exposes more network surface than necessary. | 2.7 (ISM-1416*) | Possible × Moderate → **Low–Medium** | Isolated LAN; minimal services; egress controlled at the network/serving layer; no kiosk inbound. | Re-enable firewalld with explicit allow rules (NVMe/TCP `:4420`, DHCP, log offload, SSH) once the secure path is verified. | **Low** | Platform/network — *after R-01* | | **R-10** | **Patch SLA undefined** — no documented cadence for rebuilding/redeploying against new CVEs. | 2.5 (ISM-1144*/1472*) | Likely × Moderate → **Medium** | Atomic rebuild/redeploy model makes patching fast & uniform; reproducible builds. | Define patch cadence & critical-CVE SLA; automate rebuild triggers. | **Low** | Platform team — *roadmap* | @@ -192,10 +201,14 @@ Likelihood ↓ ``` *The heatmap shows pre-treatment positions; every item has a treatment to Low / -Low–Medium. The AO is asked to authorise on the basis that R-01, R-02, R-03, R-08 -and R-12 are **closed before production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 -are either accepted with compensating controls (isolated-LAN lab) or carried on -the POA&M with target dates.* +Low–Medium. The AO is asked to authorise on the basis that **R-01, R-02, R-08, +R-12 and R-13** (and confirming R-03's `enable_stig=true`) are **closed before +production**, and R-04/R-05/R-06/R-07/R-09/R-10/R-11 are either accepted with +compensating controls (isolated-LAN lab) or carried on the POA&M with target +dates. Newly strengthened in v0.2: device control (USBGuard, now Implemented), +admin SELinux confinement (R-05), STIG applied+verified (R-03 reduced), and +**Secure Boot signing verified enforcing on real hardware (R-13 reduced +Medium→Low)**.* --- @@ -215,6 +228,13 @@ the POA&M with target dates.* anything flagged **REVIEW** is an unjustified finding. 6. **Confirm lab-only flags are off** in the assessed build: `debug_shell=false`, `enable_stig=true`, `disable_dot1x=false`, real RADIUS CA in place. +7. **Confirm boot-chain signing is the default** (R-13): signing + Secure Boot + enforcement are **verified on hardware** (VM HTTP + 820 PXE, 2026-06-22) — + confirm the *assessed* build is signed and the *assessed* hosts have `db` + enrolled with Secure Boot ON (`mokutil --sb-state`, `sbverify` UKI + iPXE). The + PXE/iPXE SB-OFF cmdline workaround is a no-op once SB is on. +8. **Witness device control** (USBGuard): a USB mass-storage device is blocked and + logged, console HID still works. --- diff --git a/docs/ssp.md b/docs/ssp.md index 0067c3d..e4a3223 100644 --- a/docs/ssp.md +++ b/docs/ssp.md @@ -15,10 +15,10 @@ |---|---| | System name | Diskless Fleet Boot System ("the fleet") | | Document type | System Security Plan (SSP) | -| Version | 0.1 (DRAFT — pre-assessment) | -| Date | 2026-06-19 | +| Version | 0.2 (DRAFT — pre-assessment) | +| Date | 2026-06-22 | | Author / maintainer | System Owner (delegate) | -| Status | Lab / pre-production. Secure path (802.1X/MACsec) not yet bench-verified. | +| Status | Lab / pre-production. STIG hardening applied + bench-verified; **UEFI Secure Boot signing verified enforcing on real hardware** (VM HTTP + EliteBook 820 PXE); secure path (802.1X/MACsec) not yet bench-verified. | | Companion documents | [Annex A — ISM control cross-reference & residual risk](ssp-annex-a-ism-residual-risk.md); [Architecture design v2](diskless-fleet-design-v2.pdf); [Design errata](design-v2-errata.md); [STIG deviation register](stig-deviations.md); [Backlog / POA&M source](TODO.md) | > **Currency caveat (read before relying on control numbers).** This SSP and its @@ -72,8 +72,19 @@ manifest. One commit = one release = one digest = one manifest entry. ### 2.2 Boot and trust chain (control flow) -1. **UEFI Secure Boot** measures and verifies the UKI signature (vendor/Secure - Boot key) before execution. +> **Boot transport (two paths, auto-selected):** HTTP-boot-capable firmware +> fetches the UKI directly over UEFI HTTP boot. Firmware **without** HTTP boot +> (e.g. HP EliteBook 820 G4) falls back to **PXE → iPXE → HTTP** (dnsmasq serves +> iPXE over TFTP, which chainloads the same UKI). Both land on the same signed +> UKI. **Lab status:** Secure Boot is currently **OFF** and the UKI/iPXE are +> **unsigned** — signing is wired in CI and the key procedure is documented +> ([secure-boot-keys.md](secure-boot-keys.md)) but not yet enforced (residual +> R-03/signing in Annex A). + +1. **UEFI Secure Boot** (when enabled) measures and verifies the UKI signature + against the enrolled `db` key before execution. On the PXE/iPXE path the iPXE + binary is likewise verified against `db`; only the fleet's own `db` need be + enrolled (no Microsoft CA / shim). 2. **UKI (systemd-stub)** carries the kernel, the `--no-hostonly` initramfs, and an **immutable, signed kernel command line** (includes the dm-verity root hash and STIG kernel parameters). No GRUB. @@ -102,7 +113,9 @@ the tmpfs overlay, the TPM-backed identity, and the hardened OS configuration. The boundary's external interfaces are: (a) the **access link** (802.1X-controlled port, MACsec-encrypted); (b) the **NVMe/TCP** root storage session; (c) **outbound audit log** forwarding; (d) the **kiosk application** endpoint the -browser renders. Each crossing is a control point addressed in Annex A. +browser renders; (e) the **boot transport** (UEFI HTTP boot, or PXE/TFTP→iPXE) +that delivers the UKI; and (f) **USB/peripheral** ports, now mediated by USBGuard +(§5.2). Each crossing is a control point addressed in Annex A. ### 2.4 Data and classification @@ -151,12 +164,23 @@ This system follows the ISM A&A lifecycle: Milestones (POA&M)** (this repo's [TODO.md](TODO.md) is the working POA&M source). -**Current status:** Pre-assessment **DRAFT**. The system is functional in a lab -configuration. Several controls are **partial or deferred** and are tracked as -residual risks with treatment plans (Annex A §3). The security-critical path -(802.1X/MACsec) is **implemented but not yet bench-verified end-to-end** and is -currently disabled in test builds (`disable_dot1x=true`) — this is the single -largest gate on a production authorisation. +**Current status:** Pre-assessment **DRAFT (v0.2, updated 2026-06-22)**. The system +is functional in a lab configuration. **Progress since v0.1 (2026-06-20):** DISA +STIG hardening applied and **bench-verified** on a real build (kiosk, key-only SSH, +audit offload, DHCP-named host, NVMe/verity root all good with hardening on); +USBGuard device control enabled + verified; admin confined to `staff_u`; and — +newly — **UEFI Secure Boot signing verified enforcing end-to-end on real +hardware**: the CI-signed UKI boots under Secure Boot on a VM (HTTP boot), and the +signed iPXE→UKI chain boots under Secure Boot on an EliteBook 820 G4 (PXE boot), +with `db`/`KEK`/`PK` enrolled via the baked `fleet-sb-enroll` helper. Several +controls remain **partial or deferred**, tracked as residual risks with treatments +(Annex A §3). The largest remaining gates on a production ATO: (a) the secure path +(**802.1X/MACsec**) is implemented but **not yet bench-verified** and is disabled +in test builds (`disable_dot1x=true`, R-01); (b) a lab-only **passwordless-root +debug shell** must be removed (R-02). Boot-chain signing / Secure Boot — a major +v0.1 gate — is now **demonstrated on hardware**; the residual (R-13) is reduced to +making signed + Secure-Boot-on the **standing default** for every production +host/build. ## 5. Security control implementation — by ISM cyber security principle @@ -180,23 +204,43 @@ summarises the implementation posture. - **Boot & OS integrity:** **Secure Boot** + **signed UKI** with an **immutable cmdline**; **dm-verity** read-only root (continuous cryptographic integrity); **erofs** immutable filesystem; **reproducible** build (verity hash is a pure - function of inputs). -- **System hardening:** **DISA STIG** baseline via OpenSCAP/SSG with a documented - tailoring; **SELinux enforcing**; **fapolicyd** application allow-listing (the - exec guard for the writable RAM overlay that dm-verity cannot cover); - **key-only SSH**, root login disabled, hardened ciphers/MACs/KEX; **kiosk - lockdown** (single locked browser, no shell/navigation). + function of inputs). Signing is **wired in CI** (`sbsign`/ukify via + `SB_KEY`/`SB_CERT` secrets) with a documented PK/KEK/db key + enrolment + procedure ([secure-boot-keys.md](secure-boot-keys.md)) and a baked, efitools-free + enrolment helper (`fleet-sb-enroll`); **not yet enforced in the lab** (SB OFF, + unsigned — see Annex A). +- **System hardening (DISA STIG applied + bench-verified 2026-06-20):** STIG + baseline via OpenSCAP/SSG with a documented tailoring + per-build deviations + report; all REVIEW rows triaged. **SELinux enforcing**; **fapolicyd** + application allow-listing (the exec guard for the writable RAM overlay dm-verity + cannot cover); **USBGuard enforcing** (`ImplicitPolicyTarget=block` + a + hardware-independent HID/hub allowlist + LinuxAudit — verified blocks USB mass + storage while keeping console input; device/removable-media control); **key-only + SSH**, root login disabled, hardened ciphers/MACs/KEX (note: STIG crypto policy + drops ed25519 → admin key must be ECDSA/RSA); **kiosk lockdown** (single locked + browser, no shell/navigation); **postfix removed** (no local MTA — audit-failure + notification rides the remote offload, not email). +- **Privileged access:** single **key-only `wheel` admin**, root locked. The admin + is now **confined to the `staff_u` SELinux user** with a sudoers role/type + transition (`sysadm_r`/`sysadm_t`) so privilege elevation re-labels the SELinux + context (STIG `selinux_context_elevation_for_sudo`). Still **NOPASSWD** (the + account is passwordless/key-only) and fleet-generic — see R-05. - **No persistent attack surface:** diskless; overlay discarded each boot; no per-host secrets in the image. -- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled**; - NVMe/TCP and audit offload **plaintext on the isolated LAN**; NOPASSWD key-only - admin; **placeholder RADIUS CA**. See Annex A residual risks. +- *Gaps / decisions:* **FIPS OFF** (lab phase); **host firewall disabled** (the + workstation; the serving host's ufw is configured); NVMe/TCP and audit offload + **plaintext on the isolated LAN**; NOPASSWD admin; **placeholder RADIUS CA**; + signing not yet enforced (SB OFF). See Annex A residual risks. ### 5.3 Detect - **auditd** enabled with STIG audit rules; kernel `audit=1` + - `audit_backlog_limit` in the signed cmdline. + `audit_backlog_limit` in the signed cmdline. USBGuard device events logged via + the LinuxAudit backend. auditd `space_left_action` repointed to **syslog** + (no local MTA), so audit-volume alerts ride the remote offload. - **Centralised log offload** via rsyslog (`audisp-syslog` → rsyslog `omfwd`, disk-assisted queue) to a dedicated **audit collector** (`serving/audit-collector/`). + **Bench-verified end-to-end 2026-06-20** (records reach the collector, + per-source-host files). - *Gaps:* offload currently **plaintext** (TLS planned, R-07); offload is **local site only**; no SIEM correlation/alerting defined yet. @@ -215,11 +259,11 @@ AO will expect a maturity view: | Mitigation | Posture | Notes | |---|---|---| -| Application control | **Strong** | fapolicyd allow-listing + immutable verified root; only RPM-trusted (plus one trusted kiosk binary) executables run. | +| Application control | **Strong** | fapolicyd allow-listing + immutable verified root; only RPM-trusted (plus two trusted local binaries: kiosk-session, fleet-sb-enroll) executables run. USBGuard enforces device control on USB ports. | | Patch applications | **Process** | Patching = rebuild + re-sign + redeploy a new digest; cadence/SLA to be defined. | | Configure MS Office macros | **N/A** | No office suite; kiosk is a locked browser. | | User application hardening | **Strong (kiosk)** | Chromium locked down via enterprise policy; no plugins/shell/nav. | -| Restrict admin privileges | **Partial** | Single key-only `wheel` admin (NOPASSWD, see R-05); no per-host/rotatable model yet. | +| Restrict admin privileges | **Partial** | Single key-only `wheel` admin, root locked, now **SELinux-confined to `staff_u`** with a sudo role/type transition (NOPASSWD, see R-05); no per-host/rotatable model or admin-MFA yet. | | Patch operating systems | **Process** | Same rebuild/redeploy model; immutable image means uniform, atomic OS updates. | | Multi-factor authentication | **Strong (machine)** | Host auth is certificate + TPM-bound key (something-the-machine-has, non-exportable). Human admin MFA to be addressed for the management plane. | | Regular backups | **N/A (by design)** | Diskless, stateless hosts hold nothing to back up; recoverability is re-provisioning from a pinned digest. Backup applies to serving-side state, out of scope here. | @@ -234,13 +278,14 @@ production ATO: |---|---|---|---| | R-01 | Secure path (802.1X/MACsec) not yet bench-verified; currently disabled in test builds. | **High** | Treat — verify before production authorisation. | | R-02 | `debug_shell=true` bakes a **passwordless root shell** on a tty. | **High** | Treat — must be removed before production (lab-only). | -| R-03 | STIG hardening temporarily **report-only / disabled in CI** (`enable_stig=false`). | **Medium** | Treat — re-enable before assessment. | +| R-03 | STIG hardening **applied + bench-verified** (2026-06-20); residual is that CI ships **unsigned, Secure Boot OFF** and `enable_stig` must be confirmed on for production builds. | **Medium** | Treat — confirm `enable_stig=true` in CI + enforce signing/SB before assessment. | | R-04 | NVMe/TCP root traffic **plaintext** on the lab LAN. | **Medium** | Accept (isolated LAN) / treat for production. | -| R-05 | NOPASSWD key-only admin; no per-host or rotatable access model. | **Medium** | Accept with compensating controls / roadmap. | +| R-05 | NOPASSWD key-only admin (now `staff_u`-confined); no per-host/rotatable access model or admin-MFA. | **Medium** | Accept with compensating controls / roadmap. | | R-06 | **FIPS off**; ASD-approved-cryptography conformance not yet asserted. | **Medium** | Treat — required for PROTECTED+; revisit with signing. | -| R-07 | Audit-log offload **plaintext**, single-site, no SIEM alerting. | **Medium** | Treat — TLS offload + monitoring on roadmap. | +| R-07 | Audit-log offload **plaintext** (bench-verified working), single-site, no SIEM alerting. | **Medium** | Treat — TLS offload + monitoring on roadmap. | | R-08 | **Placeholder RADIUS CA** in the repo; real trust anchor not yet provisioned. | **Medium** | Treat — environment-specific, before production. | -| R-09 | Host **firewall disabled**. | **Low–Medium** | Accept (isolated LAN) / treat with explicit allow-rules. | +| R-09 | Host **firewall disabled** (workstation). | **Low–Medium** | Accept (isolated LAN) / treat with explicit allow-rules. | +| R-13 | **Boot-chain signing not yet the standing default.** Signing + Secure Boot enforcement **verified end-to-end on hardware** (VM HTTP UKI 2026-06-20; 820 PXE signed iPXE→UKI 2026-06-22), but unsigned/SB-OFF lab builds are still possible (CI signs only when `SB_KEY`/`SB_CERT` are set) and not all hosts have `db` enrolled. | **Low** | Treat — make signed + Secure-Boot-on the default for all production builds/hosts (set CI secrets; enrol `db` fleet-wide). | Each is detailed, ISM-cross-referenced, rated and assigned a treatment in Annex A. diff --git a/docs/stig-deviations.md b/docs/stig-deviations.md index c187796..db28c8e 100644 --- a/docs/stig-deviations.md +++ b/docs/stig-deviations.md @@ -114,6 +114,30 @@ reverts to a justified deviation; (b) reproducibility — `semanage` rebuilds th SELinux policy store, so confirm the verity hash is stable across two builds (and normalize in 2d hygiene if it drifts). +## 13. Ctrl+Alt+Del console reboot hotkey kept enabled (decision 2026-06-20) + +The RHEL 9 STIG disables the console reboot hotkey via two rules, both **waived** +in the tailoring: +- `disable_ctrlaltdel_reboot` — masks `ctrl-alt-del.target` (single Ctrl+Alt+Del). +- `disable_ctrlaltdel_burstaction` — sets `CtrlAltDelBurstAction=none` in + `/etc/systemd/system.conf` (the rapid 7× burst reboot). + +**Why waived:** the STIG intent is to stop an **unauthenticated console user from +causing an unplanned reboot/outage**. On this fleet that risk is structurally +absent — the hosts are **diskless and stateless** (immutable erofs+dm-verity root, +tmpfs volatile overlay), so a console reboot loses nothing and persists nothing, +and a **field technician needs to recover a wedged kiosk from the keyboard when +SSH is unavailable**. The two rules are unselected so `--remediate` leaves the +systemd default (the hotkey reboots). + +**Compensating control / scope:** the hotkey only triggers a **clean stateless +reboot** — it cannot exfiltrate data, alter the read-only root, or bypass auth to +a shell. Authenticated reboot paths (`systemctl reboot` over the key-only admin +SSH session) are unaffected either way. The console itself is otherwise locked +down (kiosk autologin to a single locked browser; no getty shell on tty1; +`debug_shell` is lab-only and never shipped). If a future deployment wants the +hotkey disabled (e.g. a physically exposed terminal), re-select both rule IDs. + ## Things that are NOT deviations (kept / enforced) - **SELinux enforcing** (`selinux --enforcing`; labels preserved into the erofs). diff --git a/files/secureboot/fleet-sb-enroll b/files/secureboot/fleet-sb-enroll index c8440af..913a36d 100644 --- a/files/secureboot/fleet-sb-enroll +++ b/files/secureboot/fleet-sb-enroll @@ -28,8 +28,12 @@ EFIVARS=/sys/firmware/efi/efivars GUID_GLOBAL=8be4df61-93ca-11d2-aa0d-00e098032b8c # PK, KEK, SetupMode GUID_DB=d719b2cb-3d3a-4596-a3bc-dad00e67656f # db, dbx # EFI var attributes: NON_VOLATILE|BOOTSERVICE_ACCESS|RUNTIME_ACCESS| -# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, little-endian 4 bytes. -ATTR=$'\x27\x00\x00\x00' +# TIME_BASED_AUTHENTICATED_WRITE_ACCESS = 0x27, written as a 4-byte little-endian +# u32 prefixed to the .auth payload. These 4 bytes contain NULs, so they are +# emitted with printf at WRITE TIME, never stored in a variable: a bash string +# can't hold a NUL (it truncates at the first one), which would leave only the +# 0x27 byte, corrupt the 4-byte attribute field efivarfs reads, and make the +# kernel reject the write with EINVAL ("dd: Invalid argument"). die() { echo "fleet-sb-enroll: $*" >&2; exit 1; } @@ -57,7 +61,7 @@ enrol_one() { # $1=var name $2=guid $3=.auth path local f="$EFIVARS/$1-$2" tmp [ -f "$3" ] || die "missing $3" tmp=$(mktemp) - printf '%s' "$ATTR" > "$tmp" + printf '\x27\x00\x00\x00' > "$tmp" # 4-byte LE attribute prefix (see note above) cat "$3" >> "$tmp" # Existing EFI vars carry the immutable flag; clear it before overwriting. [ -e "$f" ] && chattr -i "$f" 2>/dev/null || true diff --git a/files/stig/diskless-tailoring.xml b/files/stig/diskless-tailoring.xml index 2f253b5..833a242 100644 --- a/files/stig/diskless-tailoring.xml +++ b/files/stig/diskless-tailoring.xml @@ -162,5 +162,16 @@ + + + + diff --git a/scripts/build-ipxe-oci.sh b/scripts/build-ipxe-oci.sh new file mode 100755 index 0000000..126f5b2 --- /dev/null +++ b/scripts/build-ipxe-oci.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# build-ipxe-oci.sh [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 [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 < 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 diff --git a/serving/.env.example b/serving/.env.example index b059bcd..a2a5f74 100644 --- a/serving/.env.example +++ b/serving/.env.example @@ -17,6 +17,25 @@ ARTIFACT_SRC=../artifacts/0.0.0-2eefd97 SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf SPDK_VERSION=v24.09 +# dnsmasq image — prebuilt + pushed by CI (.gitea/workflows/dnsmasq-image.yaml); +# GENERIC (no Secure Boot keys / no iPXE binary). Pulled, not built at deploy. +DNSMASQ_IMAGE=git.bitasylum.org/audiobuzz/fleet-dnsmasq +DNSMASQ_VERSION=latest + +# fleet-ipxe image — the SIGNED iPXE bootloader (snponly.efi) for PXE-only hosts, +# built + signed (fleet db key) + pushed by CI (.gitea/workflows/ipxe-image.yaml). +# Mounted into dnsmasq at /ipxe. Per-environment (signature is db-key-specific); +# pin by digest in production, e.g. fleet-ipxe@sha256:… +IPXE_IMAGE=git.bitasylum.org/audiobuzz/fleet-ipxe +IPXE_VERSION=latest + +# kiosk SPA image — the spaceship-terminal web app, now an INDEPENDENT project +# (the kiosk-app git submodule) with its own CI that builds + pushes this image. +# The 'kiosk' compose service pulls it; the main nginx reverse-proxies /kiosk/ to +# it. For local dev, uncomment `build: ./kiosk-app` on that service instead. +KIOSK_IMAGE=git.bitasylum.org/audiobuzz/fleet-kiosk +KIOSK_VERSION=latest + # --- This serving host on the boot LAN ------------------------------------- BOOT_IFACE=ens19 # interface facing the booting client / test bridge SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe) diff --git a/serving/dnsmasq/Dockerfile b/serving/dnsmasq/Dockerfile index 6b40ae8..1600a61 100644 --- a/serving/dnsmasq/Dockerfile +++ b/serving/dnsmasq/Dockerfile @@ -2,10 +2,18 @@ # the version is pinned and the entrypoint is predictable). Config is passed # entirely as --flags from docker-compose.yml. FROM harbor.bitasylum.org/dockerhub/library/alpine:3.20 -RUN apk add --no-cache dnsmasq +# binutils (objcopy): the entrypoint extracts the UKI's .cmdline PE section so it +# can feed the REAL cmdline to iPXE (Secure Boot OFF chainload) — see entrypoint.sh. +RUN apk add --no-cache dnsmasq binutils # fleet-namer: --dhcp-script that assigns creative starship hostnames per MAC, # server-side (keeps the workstation image neutral). COPY'd in root-owned, 0755 # so dnsmasq will execute it. Uses busybox sha256sum/cut/wc — all in the image. COPY fleet-namer /usr/local/bin/fleet-namer RUN chmod 0755 /usr/local/bin/fleet-namer -ENTRYPOINT ["dnsmasq"] +# entrypoint assembles the TFTP root (copies the user's ipxe.efi from the ro +# source mount + renders boot.ipxe, which clears the iPXE load options so the +# UKI's embedded cmdline survives the chainload — see entrypoint.sh) then execs +# dnsmasq with the compose args. +COPY entrypoint.sh /usr/local/bin/dnsmasq-entrypoint +RUN chmod 0755 /usr/local/bin/dnsmasq-entrypoint +ENTRYPOINT ["/usr/local/bin/dnsmasq-entrypoint"] diff --git a/serving/dnsmasq/entrypoint.sh b/serving/dnsmasq/entrypoint.sh new file mode 100644 index 0000000..e17994d --- /dev/null +++ b/serving/dnsmasq/entrypoint.sh @@ -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-.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 < ${uki} (cmdline: ${cmdline:-})" +} + +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 "$@" diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index d702e8e..c0bf4a6 100644 --- a/serving/docker-compose.yml +++ b/serving/docker-compose.yml @@ -37,7 +37,12 @@ services: # --- DHCP + DNS ------------------------------------------------------------ dnsmasq: - build: ./dnsmasq + # Prebuilt by CI (.gitea/workflows/dnsmasq-image.yaml) and pulled here, like + # the SPDK image — the serving host no longer builds it at deploy time. The + # image is GENERIC (no Secure Boot keys, no iPXE binary): the signed iPXE + # bootloader rides the separate, per-environment fleet-ipxe image mounted at + # /ipxe below. Local-dev alternative: replace this line with `build: ./dnsmasq`. + image: ${DNSMASQ_IMAGE}:${DNSMASQ_VERSION} network_mode: host cap_add: - NET_ADMIN # DHCP raw sockets @@ -48,6 +53,34 @@ services: # + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add # manual lines to pin specific hosts. RW so fleet-namer can append. - ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts + # Signed iPXE bootloader for the PXE-only chainload (firmware without UEFI + # HTTP boot, e.g. EliteBook 820 G4). Mounted from the separate, signed, + # digest-pinnable fleet-ipxe image (.gitea/workflows/ipxe-image.yaml); the + # entrypoint copies /ipxe/snponly.efi into /srv/tftp. Kept OUT of the dnsmasq + # image so the daemon image stays generic/key-agnostic — see serving/tftp/README. + - type: image + source: ${IPXE_IMAGE}:${IPXE_VERSION} + target: /ipxe + image: + subpath: ipxe + # Fallback (Docker < 28 / offline / lab): drop snponly.efi in ./tftp and bind + # it instead (the entrypoint prefers /ipxe, then falls back to /tftp-src): + # - ./tftp:/tftp-src:ro + # The release artifacts (same image volume as the http service), read-only, + # so the entrypoint can objcopy the UKI's .cmdline section into boot.ipxe + # (Secure Boot OFF needs the real cmdline fed to iPXE — see entrypoint.sh). + - type: image + source: ${OCI_IMAGE}:${IMAGE_VERSION} + target: /artifacts + image: + subpath: artifacts + # Fallback (Docker < 28 / offline): bind the local Packer output dir instead: + # - ${ARTIFACT_SRC}:/artifacts:ro + environment: + # Consumed by the entrypoint to render /srv/tftp/boot.ipxe (the iPXE chain + # script that clears load options so the UKI's embedded cmdline survives). + - SERVER_IP=${SERVER_IP} + - IMAGE_VERSION=${IMAGE_VERSION} command: - --keep-in-foreground # Log to stderr so `docker compose logs` captures output. Without this, @@ -108,6 +141,32 @@ services: - --dhcp-vendorclass=set:httpboot,HTTPClient - --dhcp-option=tag:httpboot,60,HTTPClient - --dhcp-boot=tag:httpboot,http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi + # ---- PXE -> iPXE -> HTTP chainload (for firmware WITHOUT UEFI HTTP boot) ---- + # Graceful fallback: HTTP-capable firmware matches 'httpboot' above and gets + # the UKI directly; PXE-only firmware (PXEClient) gets ipxe.efi over TFTP; + # once iPXE is running it re-DHCPs with user-class "iPXE" and is handed the + # SAME UKI HTTP URL, which it chainloads (.efi -> LoadImage). Each client + # gets the right thing based on what it advertises — no per-host config. + # snponly.efi = UEFI x64 iPXE that rides the FIRMWARE's NIC driver (UEFI SNP) + # instead of iPXE's own — works on virtio VMs AND real hardware where the + # native driver won't bind (e.g. EliteBook 820 G4 / Intel I219-LM, which + # fails ipxe.efi with "No configurable methods"). Built + signed by CI into + # the fleet-ipxe image (mounted at /ipxe); see serving/tftp/README.md. + # The fleet-ipxe image is signed with the fleet db key when CI has the + # secrets, so this path stays Secure-Boot-enforced end-to-end; an unsigned + # (lab) fleet-ipxe build needs Secure Boot OFF — see docs/secure-boot-keys.md. + - --enable-tftp + - --tftp-root=/srv/tftp + - --dhcp-vendorclass=set:pxe,PXEClient + - --dhcp-userclass=set:ipxe,iPXE + # firmware PXE, not yet iPXE -> hand iPXE (UEFI x64) over TFTP + - --dhcp-boot=tag:pxe,tag:!ipxe,snponly.efi + # iPXE running -> hand boot.ipxe (NOT the UKI URL directly). The script + # imgfetch'es the UKI then `imgargs`-clears the load options before boot, so + # systemd-stub uses the UKI's EMBEDDED cmdline. Booting the UKI URL directly + # makes iPXE pass the image name as the cmdline, which (Secure Boot OFF) + # REPLACES the embedded roothash/nvmf/... args -> dracut emergency shell. + - --dhcp-boot=tag:ipxe,boot.ipxe # --- HTTP boot server (serves the UKI) ------------------------------------- http: @@ -132,11 +191,15 @@ services: # --- Kiosk SPA (spaceship terminal shown by the diskless kiosk) ------------ # Svelte + Tailwind app built into a static bundle served by its own nginx. - # Bridge-networked (NOT host) so it doesn't fight :80; published on loopback - # only, where the host-network 'http' nginx reverse-proxies /kiosk/ to it. - # Build: docker compose build kiosk + # Now an INDEPENDENT project — the `kiosk-app` git submodule (its own repo + + # CI). It builds + pushes its own image (git.../fleet-kiosk); the serving stack + # pulls it, like dnsmasq/SPDK. Bridge-networked (NOT host) so it doesn't fight + # :80; published on loopback only, where the host-network 'http' nginx + # reverse-proxies /kiosk/ to it. kiosk: - build: ./kiosk-app + image: ${KIOSK_IMAGE}:${KIOSK_VERSION} + # Local dev from the submodule working tree instead of pulling: + # build: ./kiosk-app restart: unless-stopped ports: - "127.0.0.1:${KIOSK_APP_PORT:-8088}:80" # loopback only; main nginx proxies here diff --git a/serving/dot1x-bench/.env.example b/serving/dot1x-bench/.env.example new file mode 100644 index 0000000..737867c --- /dev/null +++ b/serving/dot1x-bench/.env.example @@ -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 diff --git a/serving/dot1x-bench/.gitignore b/serving/dot1x-bench/.gitignore new file mode 100644 index 0000000..69aa3ec --- /dev/null +++ b/serving/dot1x-bench/.gitignore @@ -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 diff --git a/serving/dot1x-bench/README.md b/serving/dot1x-bench/README.md new file mode 100644 index 0000000..4346449 --- /dev/null +++ b/serving/dot1x-bench/README.md @@ -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/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 --net1 virtio,bridge=vmbr-secA +qm set --net1 virtio,bridge=vmbr-secB +# Authenticator VM: one nic per workstation link + one on the serving bridge: +qm set --net1 virtio,bridge=vmbr-secA \ + --net2 virtio,bridge=vmbr-secB \ + --net0 virtio,bridge=vmbr-serve +# swtpm for each workstation VM (Proxmox vTPM): +qm set --tpmstate0 :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-.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 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- +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 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). diff --git a/serving/dot1x-bench/authenticator/hostapd-wired.conf b/serving/dot1x-bench/authenticator/hostapd-wired.conf new file mode 100644 index 0000000..30fee89 --- /dev/null +++ b/serving/dot1x-bench/authenticator/hostapd-wired.conf @@ -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-.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. diff --git a/serving/dot1x-bench/authenticator/setup-link.sh b/serving/dot1x-bench/authenticator/setup-link.sh new file mode 100755 index 0000000..27a41ec --- /dev/null +++ b/serving/dot1x-bench/authenticator/setup-link.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# setup-link.sh +# +# Prep one authenticator link before starting hostapd on it. The authenticator is +# a 2-sided node: faces the workstation (hostapd + MACsec runs +# here), faces the serving VLAN (DHCP/NVMe/RADIUS). After MKA +# succeeds hostapd creates a macsec iface over ; the DECRYPTED +# traffic on that macsec iface must reach the serving network — we bridge it to +# . +# +# 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 } +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.)" diff --git a/serving/dot1x-bench/docker-compose.yml b/serving/dot1x-bench/docker-compose.yml new file mode 100644 index 0000000..6c2987f --- /dev/null +++ b/serving/dot1x-bench/docker-compose.yml @@ -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 diff --git a/serving/dot1x-bench/enrol/tpm-provision.sh b/serving/dot1x-bench/enrol/tpm-provision.sh new file mode 100755 index 0000000..25cfd91 --- /dev/null +++ b/serving/dot1x-bench/enrol/tpm-provision.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# tpm-provision.sh +# +# 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 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." diff --git a/serving/dot1x-bench/enrol/tpm-selftest.sh b/serving/dot1x-bench/enrol/tpm-selftest.sh new file mode 100755 index 0000000..bd28059 --- /dev/null +++ b/serving/dot1x-bench/enrol/tpm-selftest.sh @@ -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 --tpmstate0 :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" diff --git a/serving/dot1x-bench/freeradius/clients.conf b/serving/dot1x-bench/freeradius/clients.conf new file mode 100644 index 0000000..c1c6392 --- /dev/null +++ b/serving/dot1x-bench/freeradius/clients.conf @@ -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 +} diff --git a/serving/dot1x-bench/freeradius/mods-available/eap b/serving/dot1x-bench/freeradius/mods-available/eap new file mode 100644 index 0000000..9b6a8bd --- /dev/null +++ b/serving/dot1x-bench/freeradius/mods-available/eap @@ -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 + } +} diff --git a/serving/dot1x-bench/step-ca/bootstrap.sh b/serving/dot1x-bench/step-ca/bootstrap.sh new file mode 100755 index 0000000..cc81d44 --- /dev/null +++ b/serving/dot1x-bench/step-ca/bootstrap.sh @@ -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 --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 +# (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." diff --git a/serving/kiosk-app b/serving/kiosk-app new file mode 160000 index 0000000..f0d0677 --- /dev/null +++ b/serving/kiosk-app @@ -0,0 +1 @@ +Subproject commit f0d0677950c7f26595693a98e3335190d3f39023 diff --git a/serving/kiosk-app/.dockerignore b/serving/kiosk-app/.dockerignore deleted file mode 100644 index f8fcbf2..0000000 --- a/serving/kiosk-app/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -dist -.vite -.git -*.local diff --git a/serving/kiosk-app/.gitignore b/serving/kiosk-app/.gitignore deleted file mode 100644 index 1cf7629..0000000 --- a/serving/kiosk-app/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -dist -.vite -*.local diff --git a/serving/kiosk-app/Dockerfile b/serving/kiosk-app/Dockerfile deleted file mode 100644 index 6ab2f0f..0000000 --- a/serving/kiosk-app/Dockerfile +++ /dev/null @@ -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 diff --git a/serving/kiosk-app/README.md b/serving/kiosk-app/README.md deleted file mode 100644 index 374239f..0000000 --- a/serving/kiosk-app/README.md +++ /dev/null @@ -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. diff --git a/serving/kiosk-app/index.html b/serving/kiosk-app/index.html deleted file mode 100644 index 7708fed..0000000 --- a/serving/kiosk-app/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - FLEET // BRIDGE TERMINAL - - -
- - - diff --git a/serving/kiosk-app/nginx.conf b/serving/kiosk-app/nginx.conf deleted file mode 100644 index 6eff1a2..0000000 --- a/serving/kiosk-app/nginx.conf +++ /dev/null @@ -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; - } -} diff --git a/serving/kiosk-app/package-lock.json b/serving/kiosk-app/package-lock.json deleted file mode 100644 index bae8de3..0000000 --- a/serving/kiosk-app/package-lock.json +++ /dev/null @@ -1,2075 +0,0 @@ -{ - "name": "fleet-kiosk-app", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "fleet-kiosk-app", - "version": "0.1.0", - "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" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", - "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", - "jiti": "^2.7.0", - "lightningcss": "1.32.0", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", - "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-arm64": "4.3.1", - "@tailwindcss/oxide-darwin-x64": "4.3.1", - "@tailwindcss/oxide-freebsd-x64": "4.3.1", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", - "@tailwindcss/oxide-linux-x64-musl": "4.3.1", - "@tailwindcss/oxide-wasm32-wasi": "4.3.1", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", - "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", - "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", - "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", - "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", - "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", - "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", - "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", - "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", - "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", - "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.10.0", - "@emnapi/runtime": "^1.10.0", - "@emnapi/wasi-threads": "^1.2.1", - "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", - "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", - "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 20" - } - }, - "node_modules/@tailwindcss/vite": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", - "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tailwindcss/node": "4.3.1", - "@tailwindcss/oxide": "4.3.1", - "tailwindcss": "4.3.1" - }, - "peerDependencies": { - "vite": "^5.2.0 || ^6 || ^7 || ^8" - } - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/esrap": { - "version": "2.2.11", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz", - "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.6" - } - }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/svelte": { - "version": "5.56.3", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.3.tgz", - "integrity": "sha512-w7JvrM5IFl5cmfbY0TLik9o7mjRUJmRMhOR51tBPu708Gr/MjbGs7VnJnr/B0CaXeI4vtnOh7RKxDr0cwhMdDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.8.1", - "esm-env": "^1.2.1", - "esrap": "^2.2.11", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitefu": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", - "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } - } - }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/serving/kiosk-app/package.json b/serving/kiosk-app/package.json deleted file mode 100644 index dc12fbe..0000000 --- a/serving/kiosk-app/package.json +++ /dev/null @@ -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" - } -} diff --git a/serving/kiosk-app/src/App.svelte b/serving/kiosk-app/src/App.svelte deleted file mode 100644 index 2bd3067..0000000 --- a/serving/kiosk-app/src/App.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
- - - -
- -
- -
- ● LIVE - {hhmmss(clock.now)} -
- {ticker}{ticker} -
-
-
diff --git a/serving/kiosk-app/src/app.css b/serving/kiosk-app/src/app.css deleted file mode 100644 index 8b48924..0000000 --- a/serving/kiosk-app/src/app.css +++ /dev/null @@ -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; } diff --git a/serving/kiosk-app/src/lib/Bar.svelte b/serving/kiosk-app/src/lib/Bar.svelte deleted file mode 100644 index d228458..0000000 --- a/serving/kiosk-app/src/lib/Bar.svelte +++ /dev/null @@ -1,15 +0,0 @@ - - -
-
- {label} - {value ?? clamped}{unit} -
-
-
-
-
diff --git a/serving/kiosk-app/src/lib/Navbar.svelte b/serving/kiosk-app/src/lib/Navbar.svelte deleted file mode 100644 index ce99454..0000000 --- a/serving/kiosk-app/src/lib/Navbar.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/serving/kiosk-app/src/lib/Panel.svelte b/serving/kiosk-app/src/lib/Panel.svelte deleted file mode 100644 index 9534541..0000000 --- a/serving/kiosk-app/src/lib/Panel.svelte +++ /dev/null @@ -1,17 +0,0 @@ - - -
-
-
- ▌ -

{title}

-
-
- {@render children?.()} -
-
diff --git a/serving/kiosk-app/src/lib/Radar.svelte b/serving/kiosk-app/src/lib/Radar.svelte deleted file mode 100644 index c0fb2ba..0000000 --- a/serving/kiosk-app/src/lib/Radar.svelte +++ /dev/null @@ -1,25 +0,0 @@ - - -
-
-
-
-
-
- -
- - -
- - {#each blips as b} - - {/each} -
diff --git a/serving/kiosk-app/src/lib/Stat.svelte b/serving/kiosk-app/src/lib/Stat.svelte deleted file mode 100644 index e4c3e73..0000000 --- a/serving/kiosk-app/src/lib/Stat.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -
- {label} - - {value}{#if unit}{unit}{/if} - - {#if sub}{sub}{/if} -
diff --git a/serving/kiosk-app/src/lib/StatusBar.svelte b/serving/kiosk-app/src/lib/StatusBar.svelte deleted file mode 100644 index 9e32bb5..0000000 --- a/serving/kiosk-app/src/lib/StatusBar.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - -
-
- CLASS {ship.class} - CMD {ship.captain} -
-
- - - CONDITION GREEN - - STARDATE {stardate(clock.now)} - MISSION TIME {hhmmss(clock.now)} -
-
diff --git a/serving/kiosk-app/src/lib/Tag.svelte b/serving/kiosk-app/src/lib/Tag.svelte deleted file mode 100644 index 6079e69..0000000 --- a/serving/kiosk-app/src/lib/Tag.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - - - {@render children?.()} - diff --git a/serving/kiosk-app/src/lib/clock.svelte.js b/serving/kiosk-app/src/lib/clock.svelte.js deleted file mode 100644 index d004af3..0000000 --- a/serving/kiosk-app/src/lib/clock.svelte.js +++ /dev/null @@ -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())}` -} diff --git a/serving/kiosk-app/src/lib/data.js b/serving/kiosk-app/src/lib/data.js deleted file mode 100644 index 1a29e2a..0000000 --- a/serving/kiosk-app/src/lib/data.js +++ /dev/null @@ -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.' }, -] diff --git a/serving/kiosk-app/src/lib/live.svelte.js b/serving/kiosk-app/src/lib/live.svelte.js deleted file mode 100644 index 5b39354..0000000 --- a/serving/kiosk-app/src/lib/live.svelte.js +++ /dev/null @@ -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 } } -} diff --git a/serving/kiosk-app/src/lib/router.svelte.js b/serving/kiosk-app/src/lib/router.svelte.js deleted file mode 100644 index ca9dcef..0000000 --- a/serving/kiosk-app/src/lib/router.svelte.js +++ /dev/null @@ -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 }, -} diff --git a/serving/kiosk-app/src/main.js b/serving/kiosk-app/src/main.js deleted file mode 100644 index 32b6c67..0000000 --- a/serving/kiosk-app/src/main.js +++ /dev/null @@ -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 diff --git a/serving/kiosk-app/src/pages/Comms.svelte b/serving/kiosk-app/src/pages/Comms.svelte deleted file mode 100644 index d007a5a..0000000 --- a/serving/kiosk-app/src/pages/Comms.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
-
- - - - -
- -
- - - - - - - - - - - {#each channels as c} - - - - - - {/each} - -
ChannelBandStatus
{c.name}{c.band}{c.status}
-
- - -
-
- - -
    - {#each messages as m} -
  • -
    - {m.from} - {m.t} -
    -

    {m.text}

    -
  • - {/each} -
-
-
-
diff --git a/serving/kiosk-app/src/pages/Dashboard.svelte b/serving/kiosk-app/src/pages/Dashboard.svelte deleted file mode 100644 index 9b9159d..0000000 --- a/serving/kiosk-app/src/pages/Dashboard.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -
-
- - - - -
- -
- -
    - {#each systemStatus as s} -
  • - {s.name} - {s.state} -
  • - {/each} -
-
- - -
- {#each subsystemLoad as b} - - {/each} -
-
- - -
    - {#each logEntries as e} -
  • - {e.t} - ▍ - {e.msg} -
  • - {/each} -
-
-
-
diff --git a/serving/kiosk-app/src/pages/LifeSupport.svelte b/serving/kiosk-app/src/pages/LifeSupport.svelte deleted file mode 100644 index b397e8f..0000000 --- a/serving/kiosk-app/src/pages/LifeSupport.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -
-
- - - - -
- -
- -
- {#each atmosphere as a} - - {/each} -
-
- - - - - - - - - - - - {#each decks as d} - - - - - - {/each} - -
DeckO₂Temp
{d.deck}{d.o2}{d.temp}
-
- - -
- - - - -
- Emergency O₂ - 72 hrs -
-
-
-
-
diff --git a/serving/kiosk-app/src/pages/Navigation.svelte b/serving/kiosk-app/src/pages/Navigation.svelte deleted file mode 100644 index 5322873..0000000 --- a/serving/kiosk-app/src/pages/Navigation.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -
-
- - - - -
- -
- - -
- {#each contacts as c} -
- - {c.label} -
- {/each} -
-
- - - - - - - - - - - - {#each waypoints as w} - - - - - - {/each} - -
WaypointRangeStatus
{w.name}{w.dist}{w.status}
-
- - -
- -
-
-
X
-
{navState.coords.x}
-
-
-
Y
-
{navState.coords.y}
-
-
-
Z
-
{navState.coords.z}
-
-
-
- Jump readiness - CHARGING -
-
-
-
-
diff --git a/serving/kiosk-app/src/pages/Power.svelte b/serving/kiosk-app/src/pages/Power.svelte deleted file mode 100644 index 9654b20..0000000 --- a/serving/kiosk-app/src/pages/Power.svelte +++ /dev/null @@ -1,62 +0,0 @@ - - -
-
- - - - -
- -
- -
- {#each powerDistribution as p} - - {/each} -
-
- - -
- {#each fuelCells as c} - - {/each} -
- Deuterium reserve - 77% / 14 days -
-
-
- - -
- {#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} -
- {r.l} - {r.v} -
- {/each} -
-
-
-
diff --git a/serving/kiosk-app/svelte.config.js b/serving/kiosk-app/svelte.config.js deleted file mode 100644 index 8abe436..0000000 --- a/serving/kiosk-app/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' - -export default { - preprocess: vitePreprocess(), -} diff --git a/serving/kiosk-app/vite.config.js b/serving/kiosk-app/vite.config.js deleted file mode 100644 index c87d60b..0000000 --- a/serving/kiosk-app/vite.config.js +++ /dev/null @@ -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, - }, -}) diff --git a/serving/tftp/.gitkeep b/serving/tftp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/serving/tftp/README.md b/serving/tftp/README.md new file mode 100644 index 0000000..5ad95fb --- /dev/null +++ b/serving/tftp/README.md @@ -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 -> 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-.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 `. 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 ` + `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`. diff --git a/serving/tftp/ipxe.efi b/serving/tftp/ipxe.efi new file mode 100644 index 0000000..77709d1 Binary files /dev/null and b/serving/tftp/ipxe.efi differ diff --git a/serving/tftp/snponly.efi b/serving/tftp/snponly.efi new file mode 100644 index 0000000..e8e6498 Binary files /dev/null and b/serving/tftp/snponly.efi differ