Refactor dnsmasq to build as a container. Add signed iPXE image build
This commit is contained in:
@@ -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"
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]
|
||||||
|
#
|
||||||
|
# Packages the iPXE network bootloader (snponly.efi) as a tiny FROM-scratch OCI
|
||||||
|
# image for the PXE-only chainload path (firmware without UEFI HTTP boot, e.g.
|
||||||
|
# HP EliteBook 820 G4). The dnsmasq serving container mounts this image and
|
||||||
|
# copies the binary into its TFTP root — so the serving host never carries a
|
||||||
|
# loose, unsigned .efi and the bootloader is digest-pinned + signed like the UKI.
|
||||||
|
#
|
||||||
|
# This is DELIBERATELY a separate, independently versioned image (NOT merged into
|
||||||
|
# the FROM-scratch release-artifact image built by build-oci.sh): snponly.efi is
|
||||||
|
# near-static (it changes only when iPXE is rebuilt/re-signed), whereas the
|
||||||
|
# UKI/rootfs change every release. Keeping it separate matches the serving-stack
|
||||||
|
# pattern (SPDK, nginx) and the artifact-image invariant (only the 5 release
|
||||||
|
# files live there). See CLAUDE.md invariant #9 and docs/secure-boot-keys.md.
|
||||||
|
#
|
||||||
|
# Steps:
|
||||||
|
# 1. sbsign snponly.efi with the `db` key/cert if supplied (same key that signs
|
||||||
|
# the UKI — CI/HSM only, never in this repo or the build VM). Unsigned builds
|
||||||
|
# are lab-only and keep a loud warning: a Secure-Boot-enforcing host rejects
|
||||||
|
# an unsigned snponly.efi before iPXE ever runs.
|
||||||
|
# 2. build a FROM-scratch OCI image containing ONLY:
|
||||||
|
# /ipxe/snponly.efi (signed iPXE bootloader)
|
||||||
|
# /ipxe/SHA256SUMS
|
||||||
|
# No shell, no tools, no base image. Pin deployments by digest.
|
||||||
|
#
|
||||||
|
# Requires on the build host: sbsigntools (if signing) and buildah (preferred)
|
||||||
|
# or docker. sbverify (optional) is used to confirm the signature when present.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SRC=${1:?usage: build-ipxe-oci.sh <snponly.efi> <image-ref> <version> [sb-key] [sb-cert] [push]}
|
||||||
|
IMAGE=${2:?image ref required, e.g. registry.example.org/fleet-ipxe}
|
||||||
|
VERSION=${3:?version required (the iPXE binary version/tag)}
|
||||||
|
SB_KEY=${4:-}
|
||||||
|
SB_CERT=${5:-}
|
||||||
|
PUSH=${6:-false}
|
||||||
|
|
||||||
|
[ -f "$SRC" ] || { echo "ERROR: snponly.efi not found at $SRC" >&2; exit 1; }
|
||||||
|
|
||||||
|
STAGE=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
mkdir "$STAGE/ipxe"
|
||||||
|
OUT="$STAGE/ipxe/snponly.efi"
|
||||||
|
|
||||||
|
# --- 1. Sign (or pass through with a loud warning) ----------------------------
|
||||||
|
SIGNED=false
|
||||||
|
if [ -n "$SB_KEY" ] && [ -n "$SB_CERT" ]; then
|
||||||
|
command -v sbsign >/dev/null || { echo "ERROR: sbsigntools required for signing" >&2; exit 1; }
|
||||||
|
sbsign --key "$SB_KEY" --cert "$SB_CERT" --output "$OUT" "$SRC"
|
||||||
|
SIGNED=true
|
||||||
|
if command -v sbverify >/dev/null; then
|
||||||
|
sbverify --cert "$SB_CERT" "$OUT" >/dev/null \
|
||||||
|
|| { echo "FATAL: signed snponly.efi fails sbverify against the db cert." >&2; exit 1; }
|
||||||
|
echo "==> snponly.efi signed and verified against the db cert"
|
||||||
|
else
|
||||||
|
echo "==> snponly.efi signed (sbverify not present to confirm)"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
cp "$SRC" "$OUT"
|
||||||
|
echo "WARNING: building UNSIGNED snponly.efi image (no sb-key/sb-cert supplied)." >&2
|
||||||
|
echo " Fine for the lab (Secure Boot OFF); a Secure-Boot-enforcing host" >&2
|
||||||
|
echo " rejects an unsigned iPXE binary. Production must sign in CI/HSM." >&2
|
||||||
|
fi
|
||||||
|
( cd "$STAGE/ipxe" && sha256sum -- * > SHA256SUMS )
|
||||||
|
|
||||||
|
# --- 2. Build the OCI image ---------------------------------------------------
|
||||||
|
REF="${IMAGE}:${VERSION}"
|
||||||
|
LABELS=(
|
||||||
|
"org.opencontainers.image.title=fleet-ipxe"
|
||||||
|
"org.opencontainers.image.version=${VERSION}"
|
||||||
|
"org.fleet.ipxe.signed=${SIGNED}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if command -v buildah >/dev/null; then
|
||||||
|
# vfs storage: overlay-on-overlayfs is unsupported in-container (CI runner);
|
||||||
|
# this image is tiny so vfs is fine. Override via STORAGE_DRIVER on a host
|
||||||
|
# with native overlay support. (Same rationale as build-oci.sh.)
|
||||||
|
export STORAGE_DRIVER="${STORAGE_DRIVER:-vfs}"
|
||||||
|
|
||||||
|
ctr=$(buildah from scratch)
|
||||||
|
buildah copy "$ctr" "$STAGE/ipxe" /ipxe
|
||||||
|
for l in "${LABELS[@]}"; do buildah config --label "$l" "$ctr"; done
|
||||||
|
buildah commit --format oci --rm "$ctr" "$REF"
|
||||||
|
DIGEST=$(buildah inspect --type image --format '{{.FromImageDigest}}' "$REF" || true)
|
||||||
|
[ "$PUSH" = "true" ] && buildah push "$REF"
|
||||||
|
elif command -v docker >/dev/null; then
|
||||||
|
{ printf 'FROM scratch\nCOPY ipxe /ipxe\n'
|
||||||
|
for l in "${LABELS[@]}"; do printf 'LABEL "%s"\n' "$l"; done
|
||||||
|
} > "$STAGE/Dockerfile"
|
||||||
|
docker build -t "$REF" "$STAGE"
|
||||||
|
DIGEST=$(docker inspect --format '{{index .RepoDigests 0}}' "$REF" 2>/dev/null || true)
|
||||||
|
[ "$PUSH" = "true" ] && docker push "$REF"
|
||||||
|
else
|
||||||
|
echo "ERROR: need buildah or docker to build the OCI image" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
==> fleet-ipxe image built: $REF
|
||||||
|
signed: $SIGNED
|
||||||
|
digest: ${DIGEST:-"(available after push)"}
|
||||||
|
|
||||||
|
Deploy: set IPXE_IMAGE/IPXE_VERSION in serving/.env to this ref, then
|
||||||
|
\`docker compose up -d dnsmasq\` (the entrypoint copies /ipxe/snponly.efi
|
||||||
|
into the TFTP root). Pin by digest in production.
|
||||||
|
EOF
|
||||||
@@ -17,6 +17,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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
Reference in New Issue
Block a user