Files
diskless-workstation/serving/tftp/README.md
T

7.7 KiB

TFTP root — iPXE chainload for PXE-only firmware

The iPXE network bootloader (snponly.efi) is served over TFTP to firmware that lacks UEFI HTTP boot (e.g. HP EliteBook 820 G4). HTTP-boot-capable firmware never touches this path — it gets the UKI straight over HTTP (see the httpboot tag in docker-compose.yml). The fallback is automatic, driven by what each client advertises (DHCP vendor-class / client-arch).

Where snponly.efi comes from (production): the fleet-ipxe OCI image. CI builds it, signs it with the fleet db key, and pushes a tiny FROM-scratch image (.gitea/workflows/ipxe-image.yaml → scripts/build-ipxe-oci.sh). The dnsmasq container mounts that image at /ipxe and the entrypoint copies /ipxe/snponly.efi into /srv/tftp. It's a separate, per-Secure-Boot-environment image (its signature is db-specific) so the dnsmasq image itself stays generic — see docs/secure-boot-keys.md and CLAUDE.md invariant #9.

This directory is the offline/lab fallback only: if you drop an snponly.efi here and enable the commented ./tftp:/tftp-src:ro bind in docker-compose.yml, the entrypoint uses it when /ipxe is empty (it prefers /ipxe, then falls back to /tftp-src). Handy on Docker < 28 or when iterating without a registry round-trip.

Flow for a PXE-only host:

firmware PXE (PXEClient) --TFTP--> snponly.efi (iPXE)
   iPXE autoboot DHCP (user-class "iPXE") --> handed boot.ipxe (TFTP)
   boot.ipxe: imgfetch UKI over HTTP -> imgargs <real cmdline> -> boot

Requires PortFast/edge on the switch port (see the gotchas below). iPXE's autoboot opens the NIC, which bounces the PHY; without PortFast the switch's STP forwarding delay (~30 s) drops iPXE's DHCP and the boot fails. PortFast removes the delay. (The earlier autoexec.ipxe + ifopen + DHCP-retry workaround was removed once PortFast was identified as the real fix.)

boot.ipxe is rendered at container start by the dnsmasq entrypoint; only the iPXE binary (snponly.efi) is supplied here. The script exists because of a sharp edge:

Why not chainload the UKI URL directly? When iPXE executes the UKI it sets the EFI load options to the image's command line — or, if that's empty, to the image name. With Secure Boot OFF, systemd-stub uses the load options as the kernel cmdline, replacing the UKI's embedded cmdline (roothash/rd.nvmf.discover/rd.dot1x=0/systemd.volatile=overlay). So booting the URL directly — or clearing the args — makes the kernel come up with just fleet-<ver>.efi as its cmdline → dracut emergency shell.

The fix: feed iPXE the real cmdline. On the serving host that cmdline only lives inside the UKI (it's not in the OCI image — invariant #10 — nor shipped as a file), so the entrypoint extracts the UKI's .cmdline PE section with objcopy (the artifact image is mounted read-only at /artifacts) and bakes it into boot.ipxe as imgargs <uki> <cmdline>. Needs binutils in the image (added) and the artifact mount.

Secure Boot ON makes all of this moot — the stub ignores any loader-supplied cmdline and uses the embedded one — so this is purely a lab/SB-OFF concern; the rendered script is harmless once SB is on.

What to drop here

A UEFI x86-64 iPXE binary named snponly.efi (the name the dnsmasq config serves). snponly rides the firmware's NIC driver via the UEFI SNP protocol rather than iPXE's own, so it works on virtio VMs and real hardware where the native driver won't bind.

# 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 chains 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.

# production (where db.key lives — CI/HSM):
sbsign --key db.key --cert db.crt --output snponly.efi snponly.efi

Bench notes / gotchas (EliteBook 820 G4 / Intel I219-LM, 2026-06-20)

Hard-won findings from bringing up the first PXE-only physical host:

  • Use snponly.efi, not the full ipxe.efi. The 820 G4's NIC is an Intel I219-LM (PCI 00:1f.6). iPXE's native intel driver in the prebuilt full build doesn't bind it (net0: No configurable methods / no net0). snponly rides the firmware's driver via NII/SNP, so net0 appears. Ubuntu's ipxe package ships a usable snponly.efi (no build needed).
  • PortFast/edge on the access port is REQUIRED (this was the real blocker). iPXE's ifopen resets the PHY → the link bounces → a managed switch restarts its STP forwarding delay (~30 s) and silently drops packets → iPXE's DHCP never reaches the server (ifstat shows link:down / Link status: Unknown; symptom is No configurable methods or a dracut shell). The firmware's own PXE works only because it keeps the link up continuously from POST so STP already converged. Fix: enable PortFast/edge (or disable STP) on the workstation port — VERIFIED to fix it 2026-06-20. (Cisco spanning-tree portfast; Aruba/HP admin-edge-port; UniFi "Fast"; or a dumb unmanaged switch.) The autoexec.ipxe retry loop (below) is a fallback that outlasts STP but adds ~30 s to every boot — PortFast removes the delay entirely.
  • iPXE aborts the script on any unguarded command failure (→ iPXE exits → the firmware restarts the whole PXE process, looking like "it doesn't retry"). Every fallible step in boot.ipxe is therefore ||-guarded.
  • boot.ipxe (rendered by the dnsmasq entrypoint, served as the tag:ipxe DHCP bootfile) does dhcp + imgfetch UKI + imgargs <real cmdline> + boot. The cmdline injection is needed only because Secure Boot is OFF (the stub takes the loader's cmdline); under Secure Boot it's a no-op and boot.ipxe could collapse to a plain chain of the UKI.
  • Removed STP-era workaround: an autoexec.ipxe that ran ifopen net0 + a DHCP-retry loop before autoboot was used to wait out STP. It's gone now that PortFast is the fix — if you ever run on a switch you can't set PortFast on, that ifopen+retry approach (in git history) is the fallback.

Note

*.efi here is gitignored (binary, and the signed one is environment-specific / produced from db). Keep this README + .gitkeep.