diff --git a/.gitea/workflows/dnsmasq-image.yaml b/.gitea/workflows/dnsmasq-image.yaml new file mode 100644 index 0000000..8c75f76 --- /dev/null +++ b/.gitea/workflows/dnsmasq-image.yaml @@ -0,0 +1,68 @@ +# +# .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. +# +# 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 +on: + push: + branches: + - "main" + paths: + - "serving/dnsmasq/**" + - ".gitea/workflows/dnsmasq-image.yaml" + workflow_dispatch: + +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: 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: | + SHA="${{ gitea.sha }}"; SHA="${SHA:0:7}" + echo "Building ${DNSMASQ_IMAGE}:${SHA} (+ :latest)" + buildah bud \ + -t "${DNSMASQ_IMAGE}:${SHA}" \ + -t "${DNSMASQ_IMAGE}:latest" \ + serving/dnsmasq + buildah push "${DNSMASQ_IMAGE}:${SHA}" + buildah push "${DNSMASQ_IMAGE}:latest" diff --git a/.gitea/workflows/ipxe-image.yaml b/.gitea/workflows/ipxe-image.yaml new file mode 100644 index 0000000..887cf26 --- /dev/null +++ b/.gitea/workflows/ipxe-image.yaml @@ -0,0 +1,124 @@ +# +# .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. +# +# 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 +on: + push: + branches: + - "main" + 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 + +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: dispatch override, else the ipxe package version sanitized to the + # OCI tag charset ([A-Za-z0-9_.-]). + RAW=$(dpkg-query -W -f='${Version}' ipxe 2>/dev/null || echo unknown) + VER="${{ inputs.ipxe_version }}" + [ -n "$VER" ] || VER=$(printf '%s' "$RAW" | sed 's/[^A-Za-z0-9_.-]/-/g') + echo "version=$VER" >> "$GITHUB_OUTPUT" + echo "Using snponly.efi=$SNP tag=$VER (ipxe pkg $RAW)" + + - + 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 + # also publish :latest for lab convenience (pin by digest in production) + buildah tag "${IPXE_IMAGE}:${VER}" "${IPXE_IMAGE}:latest" + buildah push "${IPXE_IMAGE}:latest" + + - + 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/.gitignore b/.gitignore index ff59b1a..a05b992 100644 --- a/.gitignore +++ b/.gitignore @@ -12,5 +12,5 @@ keys # 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/*.efi /serving/tftp/*.kpxe diff --git a/CLAUDE.md b/CLAUDE.md index 017f1c7..42387ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,10 @@ 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/) .gitea/workflows/ Gitea CI: lint -> packer build -> push OCI @@ -128,8 +131,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. diff --git a/docs/TODO.md b/docs/TODO.md index 3542115..4d3e043 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -639,12 +639,19 @@ smoke test. 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.) - **REMAINING for production:** (a) **secure path** — re-verify signed boot with `disable_dot1x=false` (802.1X/MACsec), not just the test path; (b) **820 / PXE - path** — sign `snponly.efi` with the same `db` key so the iPXE front-end also - passes Secure Boot (the VM's HTTP path has no iPXE stage, so this is still - unverified on real PXE hardware); (c) confirm signing is reproducible (verity - hash stable across two signed builds). + path** — BENCH-VERIFY the signed `fleet-ipxe` chain on the 820 (the VM's HTTP + path has no iPXE stage, so the signed iPXE binary is still unverified on real + PXE hardware); (c) 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 diff --git a/docs/secure-boot-keys.md b/docs/secure-boot-keys.md index aeff457..da64f6b 100644 --- a/docs/secure-boot-keys.md +++ b/docs/secure-boot-keys.md @@ -202,10 +202,19 @@ Caveats: ## 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 (`serving/tftp/ipxe.efi`), -which then chainloads the UKI over HTTP. dnsmasq selects per client automatically +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): @@ -215,9 +224,10 @@ firmware --(verify snponly.efi vs db)--> iPXE --(chain -> firmware LoadImage verifies UKI vs db)--> UKI ``` -- The firmware verifies the TFTP-downloaded NBP (`ipxe.efi`) against `db` → - **the iPXE binary must be signed by `db`** (`sbsign`, same key/step as the UKI). - Stock/unsigned iPXE is rejected under Secure Boot. +- 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. @@ -232,10 +242,13 @@ firmware --(verify snponly.efi vs db)--> iPXE --(chain -> firmware LoadImage Boot ON is immune** — the stub ignores any loader-supplied cmdline — so this is purely a lab/SB-off concern. -**Lab posture (now): iPXE signing OFF → run the iPXE path with Secure Boot OFF.** -`serving/tftp/ipxe.efi` is unsigned. **Production upgrade:** sign it with `db` -and Secure Boot stays enforced end-to-end (tracked in TODO #8). The HTTP-boot path -(firmware → UKI) is unaffected and remains SB-clean regardless. +**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 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..82e8cf9 100644 --- a/serving/.env.example +++ b/serving/.env.example @@ -17,6 +17,18 @@ 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 + # --- 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/entrypoint.sh b/serving/dnsmasq/entrypoint.sh index 8d0a953..e17994d 100644 --- a/serving/dnsmasq/entrypoint.sh +++ b/serving/dnsmasq/entrypoint.sh @@ -23,8 +23,18 @@ set -eu mkdir -p /srv/tftp -# User-supplied iPXE binary (snponly.efi) from the read-only source mount. -cp -f /tftp-src/*.efi /srv/tftp/ 2>/dev/null || true +# 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. diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml index ab0e658..7271daa 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,11 +53,19 @@ 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 - # iPXE binary source (read-only) for the PXE-only chainload (firmware without - # UEFI HTTP boot, e.g. EliteBook 820 G4). Drop ipxe.efi here — see - # serving/tftp/README. The entrypoint copies it into /srv/tftp and renders - # boot.ipxe alongside it (the actual TFTP root is container-internal). - - ./tftp:/tftp-src:ro + # 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). @@ -137,11 +150,11 @@ services: # 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"). Ships in Ubuntu's `ipxe` - # package; see serving/tftp/README.md. - # NOTE (lab): the iPXE binary is UNSIGNED, so this path needs Secure Boot OFF. - # For production, sign it with the fleet db key (then SB stays enforced - # end-to-end) — see docs/secure-boot-keys.md. + # 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 diff --git a/serving/tftp/README.md b/serving/tftp/README.md index 7147826..5ad95fb 100644 --- a/serving/tftp/README.md +++ b/serving/tftp/README.md @@ -1,11 +1,25 @@ # TFTP root — iPXE chainload for PXE-only firmware -This directory is mounted read-only at `/srv/tftp` in the dnsmasq container and -serves the iPXE network bootloader 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). +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: 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