Refactor dnsmasq to build as a container. Add signed iPXE image build

This commit is contained in:
Audiobuzz
2026-06-21 09:11:23 +00:00
parent 535793228e
commit 3226a44d5a
13 changed files with 410 additions and 35 deletions
+68
View File
@@ -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"
+124
View File
@@ -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
+1 -1
View File
@@ -12,5 +12,5 @@ keys
# iPXE network bootloader binaries for the PXE fallback (serving/tftp). Binary, # iPXE network bootloader binaries for the PXE fallback (serving/tftp). Binary,
# and the signed one is db-derived/environment-specific — supply per environment. # and the signed one is db-derived/environment-specific — supply per environment.
/serving/tftp/*.efi #/serving/tftp/*.efi
/serving/tftp/*.kpxe /serving/tftp/*.kpxe
+10 -2
View File
@@ -33,7 +33,10 @@ files/plymouth/themes/fleet/ Custom boot splash (scripted security
files/kiosk/ weston+Chromium kiosk + chromium lockdown policy files/kiosk/ weston+Chromium kiosk + chromium lockdown policy
scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline
scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image
scripts/build-ipxe-oci.sh sbsign snponly.efi -> FROM-scratch fleet-ipxe image
serving/ Lab serving stack: dnsmasq + nginx + SPDK serving/ Lab serving stack: dnsmasq + nginx + SPDK
(dnsmasq + fleet-ipxe + SPDK images are
CI-built + pulled; see .gitea/workflows/)
serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA serving/kiosk-app/ Svelte+Tailwind spaceship-terminal SPA
(kiosk endpoint; nginx reverse-proxies /kiosk/) (kiosk endpoint; nginx reverse-proxies /kiosk/)
.gitea/workflows/ Gitea CI: lint -> packer build -> push OCI .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 SHA256SUMS}`. `rootfs.erofs` (erofs data) and `rootfs.hash` (dm-verity hash
tree) are **separate** namespaces because the as-built Rocky 9 systemd does tree) are **separate** namespaces because the as-built Rocky 9 systemd does
not support the in-file `hash-offset` verity layout. No shell, no tools, no not support the in-file `hash-offset` verity layout. No shell, no tools, no
base image. Serving software (SPDK, nginx) lives in separate, independently base image. Serving software (SPDK, nginx, dnsmasq) lives in separate,
versioned images — never merge them into the artifact image. independently versioned images — never merge them into the artifact image.
The signed iPXE bootloader (`snponly.efi`) is its own FROM-scratch
**`fleet-ipxe`** image (`scripts/build-ipxe-oci.sh`, signed with `db`), mounted
into dnsmasq at `/ipxe` — kept out of *both* the artifact image and the
(generic, key-agnostic) dnsmasq image because its signature is
per-Secure-Boot-environment. Don't bake `snponly.efi` into the dnsmasq image.
1. **Deployments are pinned by digest.** One commit per fleet release in 1. **Deployments are pinned by digest.** One commit per fleet release in
the deployment manifest; that manifest is the single source coupling the deployment manifest; that manifest is the single source coupling
UKI ↔ namespace. Rollback = revert the commit. UKI ↔ namespace. Rollback = revert the commit.
+11 -4
View File
@@ -639,12 +639,19 @@ smoke test.
the MS cert even in Setup Mode — looked like a non-empty-db rejection but was 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` the NUL bug all along; it failed identically on a clean `pre-enrolled-keys=0`
disk.) 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 - **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 `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 path** — BENCH-VERIFY the signed `fleet-ipxe` chain on the 820 (the VM's HTTP
passes Secure Boot (the VM's HTTP path has no iPXE stage, so this is still path has no iPXE stage, so the signed iPXE binary is still unverified on real
unverified on real PXE hardware); (c) confirm signing is reproducible (verity PXE hardware); (c) confirm signing is reproducible (verity hash stable across
hash stable across two signed builds). two signed builds).
- **PXE-only iPXE fallback — BENCH-VERIFIED on the EliteBook 820 G4 (2026-06-20).** - **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 Firmware without UEFI HTTP boot chainloads `serving/tftp/snponly.efi` → renders
`autoexec.ipxe` (ifopen + DHCP-retry + `imgargs <real cmdline>`) → UKI over HTTP `autoexec.ipxe` (ifopen + DHCP-retry + `imgargs <real cmdline>`) → UKI over HTTP
+22 -9
View File
@@ -202,10 +202,19 @@ Caveats:
## PXE-only hosts: iPXE chainload and Secure Boot ## PXE-only hosts: iPXE chainload and Secure Boot
Firmware without UEFI HTTP boot (e.g. HP EliteBook 820 G4) falls back to PXE; the 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`), serving stack hands those hosts **iPXE** over TFTP (`snponly.efi`), which then
which then chainloads the UKI over HTTP. dnsmasq selects per client automatically chainloads the UKI over HTTP. dnsmasq selects per client automatically
(HTTP-capable → UKI direct; PXE-only → iPXE → UKI), so no per-host config. (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 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 enrolled for the UKI. No extra keys, no Microsoft CA, no shim** (the fleet owns
its keys): its keys):
@@ -215,9 +224,10 @@ firmware --(verify snponly.efi vs db)--> iPXE --(chain -> firmware LoadImage
verifies UKI vs db)--> UKI verifies UKI vs db)--> UKI
``` ```
- The firmware verifies the TFTP-downloaded NBP (`ipxe.efi`) against `db` → - The firmware verifies the TFTP-downloaded NBP (`snponly.efi`) against `db` →
**the iPXE binary must be signed by `db`** (`sbsign`, same key/step as the UKI). **the iPXE binary must be signed by `db`** (`sbsign`; CI does this in
Stock/unsigned iPXE is rejected under Secure Boot. `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 - 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 the firmware's `LoadImage()` re-verifies it against `db` — do **not** use iPXE's
`kernel`/`boot` on a raw vmlinuz, which bypasses SB. `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 Boot ON is immune** — the stub ignores any loader-supplied cmdline — so this is
purely a lab/SB-off concern. purely a lab/SB-off concern.
**Lab posture (now): iPXE signing OFF → run the iPXE path with Secure Boot OFF.** **Posture:** the `fleet-ipxe` image is signed with `db` **whenever CI has the
`serving/tftp/ipxe.efi` is unsigned. **Production upgrade:** sign it with `db` `SB_KEY`/`SB_CERT` secrets** (same condition as the UKI), so the PXE path can stay
and Secure Boot stays enforced end-to-end (tracked in TODO #8). The HTTP-boot path Secure-Boot-enforced end-to-end. An **unsigned** `fleet-ipxe` build (no secrets)
(firmware → UKI) is unaffected and remains SB-clean regardless. keeps its loud warning and requires Secure Boot OFF on the PXE target. The
HTTP-boot path (firmware → UKI) is unaffected and SB-clean regardless. Still
**BENCH-VERIFY** the signed PXE chain on the 820 (the VM uses HTTP boot, so the
signed iPXE stage is unverified on real hardware — TODO #8).
## Key hygiene & lifecycle ## Key hygiene & lifecycle
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]
#
# Packages the iPXE network bootloader (snponly.efi) as a tiny FROM-scratch OCI
# image for the PXE-only chainload path (firmware without UEFI HTTP boot, e.g.
# HP EliteBook 820 G4). The dnsmasq serving container mounts this image and
# copies the binary into its TFTP root — so the serving host never carries a
# loose, unsigned .efi and the bootloader is digest-pinned + signed like the UKI.
#
# This is DELIBERATELY a separate, independently versioned image (NOT merged into
# the FROM-scratch release-artifact image built by build-oci.sh): snponly.efi is
# near-static (it changes only when iPXE is rebuilt/re-signed), whereas the
# UKI/rootfs change every release. Keeping it separate matches the serving-stack
# pattern (SPDK, nginx) and the artifact-image invariant (only the 5 release
# files live there). See CLAUDE.md invariant #9 and docs/secure-boot-keys.md.
#
# Steps:
# 1. sbsign snponly.efi with the `db` key/cert if supplied (same key that signs
# the UKI — CI/HSM only, never in this repo or the build VM). Unsigned builds
# are lab-only and keep a loud warning: a Secure-Boot-enforcing host rejects
# an unsigned snponly.efi before iPXE ever runs.
# 2. build a FROM-scratch OCI image containing ONLY:
# /ipxe/snponly.efi (signed iPXE bootloader)
# /ipxe/SHA256SUMS
# No shell, no tools, no base image. Pin deployments by digest.
#
# Requires on the build host: sbsigntools (if signing) and buildah (preferred)
# or docker. sbverify (optional) is used to confirm the signature when present.
set -euo pipefail
SRC=${1:?usage: build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]}
IMAGE=${2:?image ref required, e.g. registry.example.org/fleet-ipxe}
VERSION=${3:?version required (the iPXE binary version/tag)}
SB_KEY=${4:-}
SB_CERT=${5:-}
PUSH=${6:-false}
[ -f "$SRC" ] || { echo "ERROR: snponly.efi not found at $SRC" >&2; exit 1; }
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir "$STAGE/ipxe"
OUT="$STAGE/ipxe/snponly.efi"
# --- 1. Sign (or pass through with a loud warning) ----------------------------
SIGNED=false
if [ -n "$SB_KEY" ] && [ -n "$SB_CERT" ]; then
command -v sbsign >/dev/null || { echo "ERROR: sbsigntools required for signing" >&2; exit 1; }
sbsign --key "$SB_KEY" --cert "$SB_CERT" --output "$OUT" "$SRC"
SIGNED=true
if command -v sbverify >/dev/null; then
sbverify --cert "$SB_CERT" "$OUT" >/dev/null \
|| { echo "FATAL: signed snponly.efi fails sbverify against the db cert." >&2; exit 1; }
echo "==> snponly.efi signed and verified against the db cert"
else
echo "==> snponly.efi signed (sbverify not present to confirm)"
fi
else
cp "$SRC" "$OUT"
echo "WARNING: building UNSIGNED snponly.efi image (no sb-key/sb-cert supplied)." >&2
echo " Fine for the lab (Secure Boot OFF); a Secure-Boot-enforcing host" >&2
echo " rejects an unsigned iPXE binary. Production must sign in CI/HSM." >&2
fi
( cd "$STAGE/ipxe" && sha256sum -- * > SHA256SUMS )
# --- 2. Build the OCI image ---------------------------------------------------
REF="${IMAGE}:${VERSION}"
LABELS=(
"org.opencontainers.image.title=fleet-ipxe"
"org.opencontainers.image.version=${VERSION}"
"org.fleet.ipxe.signed=${SIGNED}"
)
if command -v buildah >/dev/null; then
# vfs storage: overlay-on-overlayfs is unsupported in-container (CI runner);
# this image is tiny so vfs is fine. Override via STORAGE_DRIVER on a host
# with native overlay support. (Same rationale as build-oci.sh.)
export STORAGE_DRIVER="${STORAGE_DRIVER:-vfs}"
ctr=$(buildah from scratch)
buildah copy "$ctr" "$STAGE/ipxe" /ipxe
for l in "${LABELS[@]}"; do buildah config --label "$l" "$ctr"; done
buildah commit --format oci --rm "$ctr" "$REF"
DIGEST=$(buildah inspect --type image --format '{{.FromImageDigest}}' "$REF" || true)
[ "$PUSH" = "true" ] && buildah push "$REF"
elif command -v docker >/dev/null; then
{ printf 'FROM scratch\nCOPY ipxe /ipxe\n'
for l in "${LABELS[@]}"; do printf 'LABEL "%s"\n' "$l"; done
} > "$STAGE/Dockerfile"
docker build -t "$REF" "$STAGE"
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$REF" 2>/dev/null || true)
[ "$PUSH" = "true" ] && docker push "$REF"
else
echo "ERROR: need buildah or docker to build the OCI image" >&2; exit 1
fi
cat <<EOF
==> fleet-ipxe image built: $REF
signed: $SIGNED
digest: ${DIGEST:-"(available after push)"}
Deploy: set IPXE_IMAGE/IPXE_VERSION in serving/.env to this ref, then
\`docker compose up -d dnsmasq\` (the entrypoint copies /ipxe/snponly.efi
into the TFTP root). Pin by digest in production.
EOF
+12
View File
@@ -17,6 +17,18 @@ ARTIFACT_SRC=../artifacts/0.0.0-2eefd97
SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf SPDK_IMAGE=git.bitasylum.org/audiobuzz/spdk-nvmf
SPDK_VERSION=v24.09 SPDK_VERSION=v24.09
# dnsmasq image — prebuilt + pushed by CI (.gitea/workflows/dnsmasq-image.yaml);
# GENERIC (no Secure Boot keys / no iPXE binary). Pulled, not built at deploy.
DNSMASQ_IMAGE=git.bitasylum.org/audiobuzz/fleet-dnsmasq
DNSMASQ_VERSION=latest
# fleet-ipxe image — the SIGNED iPXE bootloader (snponly.efi) for PXE-only hosts,
# built + signed (fleet db key) + pushed by CI (.gitea/workflows/ipxe-image.yaml).
# Mounted into dnsmasq at /ipxe. Per-environment (signature is db-key-specific);
# pin by digest in production, e.g. fleet-ipxe@sha256:…
IPXE_IMAGE=git.bitasylum.org/audiobuzz/fleet-ipxe
IPXE_VERSION=latest
# --- This serving host on the boot LAN ------------------------------------- # --- This serving host on the boot LAN -------------------------------------
BOOT_IFACE=ens19 # interface facing the booting client / test bridge BOOT_IFACE=ens19 # interface facing the booting client / test bridge
SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe) SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe)
+12 -2
View File
@@ -23,8 +23,18 @@
set -eu set -eu
mkdir -p /srv/tftp mkdir -p /srv/tftp
# User-supplied iPXE binary (snponly.efi) from the read-only source mount. # iPXE binary (snponly.efi) into the TFTP root. Prefer the signed binary from the
cp -f /tftp-src/*.efi /srv/tftp/ 2>/dev/null || true # 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 # 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 # longer rendered — PortFast on the switch port is the real fix. Remove any stale
# copy left in a long-lived container's writable layer. # copy left in a long-lived container's writable layer.
+24 -11
View File
@@ -37,7 +37,12 @@
services: services:
# --- DHCP + DNS ------------------------------------------------------------ # --- DHCP + DNS ------------------------------------------------------------
dnsmasq: dnsmasq:
build: ./dnsmasq # Prebuilt by CI (.gitea/workflows/dnsmasq-image.yaml) and pulled here, like
# the SPDK image — the serving host no longer builds it at deploy time. The
# image is GENERIC (no Secure Boot keys, no iPXE binary): the signed iPXE
# bootloader rides the separate, per-environment fleet-ipxe image mounted at
# /ipxe below. Local-dev alternative: replace this line with `build: ./dnsmasq`.
image: ${DNSMASQ_IMAGE}:${DNSMASQ_VERSION}
network_mode: host network_mode: host
cap_add: cap_add:
- NET_ADMIN # DHCP raw sockets - NET_ADMIN # DHCP raw sockets
@@ -48,11 +53,19 @@ services:
# + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add # + DNS A/PTR. Auto-populated by fleet-namer (creative names per MAC); add
# manual lines to pin specific hosts. RW so fleet-namer can append. # manual lines to pin specific hosts. RW so fleet-namer can append.
- ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts - ./dnsmasq/dhcp-hosts:/etc/dnsmasq-hosts
# iPXE binary source (read-only) for the PXE-only chainload (firmware without # Signed iPXE bootloader for the PXE-only chainload (firmware without UEFI
# UEFI HTTP boot, e.g. EliteBook 820 G4). Drop ipxe.efi here — see # HTTP boot, e.g. EliteBook 820 G4). Mounted from the separate, signed,
# serving/tftp/README. The entrypoint copies it into /srv/tftp and renders # digest-pinnable fleet-ipxe image (.gitea/workflows/ipxe-image.yaml); the
# boot.ipxe alongside it (the actual TFTP root is container-internal). # entrypoint copies /ipxe/snponly.efi into /srv/tftp. Kept OUT of the dnsmasq
- ./tftp:/tftp-src:ro # 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, # 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 # 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). # (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) # 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 # 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 # 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` # fails ipxe.efi with "No configurable methods"). Built + signed by CI into
# package; see serving/tftp/README.md. # the fleet-ipxe image (mounted at /ipxe); see serving/tftp/README.md.
# NOTE (lab): the iPXE binary is UNSIGNED, so this path needs Secure Boot OFF. # The fleet-ipxe image is signed with the fleet db key when CI has the
# For production, sign it with the fleet db key (then SB stays enforced # secrets, so this path stays Secure-Boot-enforced end-to-end; an unsigned
# end-to-end) — see docs/secure-boot-keys.md. # (lab) fleet-ipxe build needs Secure Boot OFF — see docs/secure-boot-keys.md.
- --enable-tftp - --enable-tftp
- --tftp-root=/srv/tftp - --tftp-root=/srv/tftp
- --dhcp-vendorclass=set:pxe,PXEClient - --dhcp-vendorclass=set:pxe,PXEClient
+20 -6
View File
@@ -1,11 +1,25 @@
# TFTP root — iPXE chainload for PXE-only firmware # TFTP root — iPXE chainload for PXE-only firmware
This directory is mounted read-only at `/srv/tftp` in the dnsmasq container and The iPXE network bootloader (`snponly.efi`) is served over TFTP to firmware that
serves the iPXE network bootloader over TFTP to firmware that **lacks UEFI HTTP **lacks UEFI HTTP boot** (e.g. HP EliteBook 820 G4). HTTP-boot-capable firmware
boot** (e.g. HP EliteBook 820 G4). HTTP-boot-capable firmware never touches this never touches this path — it gets the UKI straight over HTTP (see the `httpboot`
path — it gets the UKI straight over HTTP (see the `httpboot` tag in tag in `docker-compose.yml`). The fallback is automatic, driven by what each
`docker-compose.yml`). The fallback is automatic, driven by what each client client advertises (DHCP vendor-class / client-arch).
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: Flow for a PXE-only host:
Binary file not shown.
Binary file not shown.