The human->UI hop now authenticates with a hardware passkey (YubiKey), not an mTLS client cert: phishing-resistant, origin-bound, better UX. (manager->broker stays mTLS — service-to-service.) - webauthn.go: credential store (registered admins + passkeys + a one-time-token jti replay ledger; JSON file, atomic writes) + the webauthn.User impl + RP setup. - cli.go: 'fleet-manager register-admin <name>' mints a short-lived HMAC-signed enrolment link over a shared enrol key; the browser completes the WebAuthn create ceremony. No anyone-can-register endpoint; single-use via the ledger. - authapi.go: register/login ceremonies (usernameless discoverable login, userVerification required) + logout + whoami; short-lived in-memory ceremony store between begin/finish. - session.go: opaque server-side sessions (Secure/HttpOnly/SameSite=Strict cookie) + requireSession middleware gating /api/*, with a CSRF header check on mutations. adminName now comes from the session (audit attribution). - main.go: register-admin subcommand dispatch; admin TLS switched to SERVER-AUTH (no client cert) with a HOT-RELOADING cert (certReloader) so a Let's Encrypt DNS-01 renewal is picked up without a restart. RP = diskless-server.bitasylum.org. /api/* mounted behind requireSession; /auth/* + /healthz public. Cert strategy (per design decision): the UI HTTPS cert is a publicly-trusted LE cert via DNS-01 (mgmt-network UI isn't internet-reachable, so HTTP-01/TLS-ALPN-01 can't validate; DNS-01 proves control of bitasylum.org via TXT + supports wildcards). External ACME client issues+renews to the cert path; manager reloads. Deps: github.com/go-webauthn/webauthn (server-side). Tests: enrol-token mint/verify/tamper/expiry, session middleware (401/403/CSRF/expiry), cred-store persistence + jti ledger. build/test/vet/gofmt clean. Live passkey ceremony (virtual authenticator / real YubiKey) is a bench item.
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) |