Bench (Proxmox vTPM, host cygnus-cruiser-99) confirmed via tpm2_readpublic that
0x81010001 — the handle our design used for the fleet signing key — is the
TCG-standard RSA Endorsement Key slot: the object there is the swtpm-provisioned
EK (restricted|decrypt|adminwithpolicy, RSA-2048/AES128CFB, canonical EK
authPolicy 837197…, EK cert at NV 0x1c00002). Most firmware/swtpm TPMs occupy
that slot, so EnsureKey could never create our key there — it errored reading
the EK as ECC and enrolment died before the CSR/POST.
Move the fleet signing key to 0x81000100, a non-reserved owner persistent
handle (clear of EK 0x81010001/2 and SRK 0x81000001/2). Threaded through:
- fleet-enrol -handle default + new rd.dot1x.handle= cmdline knob (baked by
extract-artifacts.sh, overridable via SMBIOS)
- files/etc/dot1x/wpa_supplicant-dot1x.conf private_key handle
- the dracut hook, bench tpm-provision.sh / tpm-selftest.sh
- CLAUDE.md invariant #1 + design/SSP docs + TODO #11
NV index 0x01800001 (owner range) is unchanged.
EnsureKey hardening: refuse to evict any restricted-decrypt object (EK/SRK) as
a safety net — never clobber the TPM endorsement/storage identity (also
protects the future EK/AK attestation path). Still self-heals a genuine stale
signing key. New regression TestEnsureKeyRefusesToEvictProtectedKey; the
stale-key regression and handle constants moved to 0x81000100.
Design note records the IDevID (0x81020001) option for the later EK/AK
attestation work. Follow-up items logged: content-aware NV presence check
(verify the cert chains to the fleet CA, so leftover/foreign NV can't suppress
enrolment) and a de-enrol/TPM-reset procedure.
Serving stack — deploy & test (lab)
Stands up DHCP/DNS + HTTP(UKI) + NVMe/TCP(rootfs) so a diskless client can
network-boot the release built by the pipeline, and walks through testing it.
This targets the open-network test path (rd.dot1x=0, unsigned UKI) — see
the repo README for the secure path.
0. Prerequisites
- A serving host on the same L2 as the test client (the boot VLAN / test
bridge). Note its interface (
BOOT_IFACE) and address (SERVER_IP). - Docker Engine ≥ 28 (for the
type: imageartifact volume; otherwise use the commented bind-mount fallback indocker-compose.yml). - Hugepages for SPDK:
sudo sysctl vm.nr_hugepages=1024. - Registry creds (
docker login git.bitasylum.org) — pulls both the artifact image and the SPDK target image. - The SPDK image is prebuilt by CI (
.gitea/workflows/spdk-image.yaml→${SPDK_IMAGE}:${SPDK_VERSION}); the serving host just pulls it. To roll the SPDK version, bumpSPDK_VERSION(or run that workflow manually with a tag) so the image exists in the registry, then update.env. (dnsmasqis the only service still built locally — a tiny alpine image.)
1. Build with a real NVMe discovery address ⚠ required
The UKI's rd.nvmf.discover address is baked in at build time (immutable
once signed). The default is the DNS name nvme.fleet.lab, which this stack's
dnsmasq resolves to SERVER_IP. If you built before nvmet_traddr existed, the
UKI still contains the literal NVMET_TRADDR placeholder and will not connect
NVMe — rebuild:
packer build \
-var image_version=<ver> \
-var disable_dot1x=true \
-var nvmet_traddr=nvme.fleet.lab \
-var oci_image=git.bitasylum.org/audiobuzz/fleet-boot -var oci_push=true \
.
Keep nvmet_traddr (UKI) == NVME_HOST (this stack's .env).
2. Deploy the serving stack
cd serving
cp .env.example .env # edit IMAGE_VERSION, BOOT_IFACE, SERVER_IP, DHCP range
docker login git.bitasylum.org
docker compose up -d --build
docker compose ps
IMAGE_VERSION must match the built tag (e.g. a branch build is
0.0.0-<short-sha>; a tag push is the clean version).
Verify each service:
# HTTP: UKI is served
curl -sI http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi | head -1 # 200 OK
# HTTP: kiosk SPA is served (reverse-proxied to the kiosk-app container —
# the spaceship terminal the diskless kiosk loads by default)
curl -sI http://${SERVER_IP}/kiosk/ | head -1 # 200 OK
# NTP: lab time server answers (diskless hosts get it via DHCP option 42)
chronyc -h ${SERVER_IP} tracking 2>/dev/null || ntpdate -q ${SERVER_IP}
# NVMe/TCP: target is listening + discoverable
ss -lntp | grep 4420
sudo nvme discover -t tcp -a ${SERVER_IP} -s 4420 # lists the subsystem
# DNS: discovery name resolves to the serving host
dig +short @${SERVER_IP} nvme.fleet.lab # -> ${SERVER_IP}
# Audit collector: rsyslog receiver is listening on :514 (booted hosts offload
# auditd here; records land in audit-collector/logs/<sender-ip>/)
ss -lntp | grep ':514'
logger -n ${SERVER_IP} -P 514 -T -t audispd "collector smoke test" # -> logs/<ip>/audit.log
# DHCP/boot: watch leases + bootfile handout
docker compose logs -f dnsmasq
3. Boot a test client
Create a diskless client VM (or bare metal) on the boot LAN:
- UEFI firmware (OVMF) with HTTP Boot enabled.
- Secure Boot DISABLED — the lab UKI is unsigned; firmware will reject it otherwise. (Re-enable Secure Boot only once you ship a signed UKI.)
- No disk (root comes from NVMe/TCP), NIC on the boot bridge.
- No TPM/swtpm needed in this mode (
rd.dot1x=0skips 802.1X/MACsec).
qemu example (adjust bridge/OVMF paths):
qemu-system-x86_64 -machine q35 -accel kvm -m 4096 \
-drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE.fd \
-drive if=pflash,format=raw,file=OVMF_VARS.fd \
-netdev bridge,id=n0,br=<boot-bridge> -device virtio-net-pci,netdev=n0 \
-nographic
(In the OVMF menu enable HTTP Boot / pick the NIC if it doesn't auto-boot.)
4. Expected sequence & success criteria
- DHCP lease from dnsmasq; firmware gets option 67 →
http://${SERVER_IP}/fleet-<ver>.efi. - Firmware fetches the UKI over HTTP and boots it (no GRUB/shim).
- initramfs:
rd.dot1x=0bypass → DHCP on the NIC. nvme discovertonvme.fleet.lab:4420(→SERVER_IP) → connect namespace.- dm-verity maps
/dev/mapper/root; erofs root mounts read-only. systemd.volatile=overlayadds the tmpfs overlay; systemd reaches multi-user.
Pass: the client reaches a login/multi-user state with the root served over
NVMe/TCP. Confirm on the client console: findmnt / (erofs, ro),
nvme list (the connected namespace), lsblk (dm-verity root).
5. Troubleshooting
| Symptom | Likely cause |
|---|---|
| Firmware won't load the UKI | Secure Boot still enabled (UKI is unsigned), or wrong boot file URL |
| No DHCP / no boot menu | client not on the same L2 as BOOT_IFACE; dnsmasq not bound to that iface |
| Boots, hangs at NVMe | nvmet_traddr ≠ NVME_HOST, or UKI still has the NVMET_TRADDR placeholder (rebuild) |
nvme discover empty |
SPDK not listening (check docker compose logs nvmeof; hugepages allocated?) |
| SPDK won't start | hugepages not allocated, or bdev_aio_create -r unsupported on the pinned SPDK tag |
| verity mapping fails | rootfs.erofs / verity.json mismatch — must be the same release as the UKI (they are, from one build) |