From d383655875c7a820671704bba2fd4ab2c50177f6 Mon Sep 17 00:00:00 2001 From: Audiobuzz Date: Fri, 12 Jun 2026 01:46:29 +0000 Subject: [PATCH] Initial commit --- .devcontainer/devcontainer.json | 16 ++ .gitea/workflows/gitea-main-ci.yaml | 126 ++++++++++ CLAUDE.md | 140 +++++++++++ LICENSE | 18 ++ README.md | 134 ++++++++++ diskless.pkr.hcl | 204 ++++++++++++++++ docs/diskless-fleet-design-v2.pdf | 231 ++++++++++++++++++ .../dracut/95dot1x-macsec/dot1x-macsec-up.sh | 113 +++++++++ files/dracut/95dot1x-macsec/module-setup.sh | 38 +++ .../NetworkManager/99-dot1x-unmanaged.conf | 6 + files/etc/dot1x/radius-ca.pem | 2 + files/etc/dot1x/wpa_supplicant-dot1x.conf | 26 ++ files/etc/dracut.conf.d/90-diskless.conf | 11 + http/ks.cfg | 42 ++++ renovate.json | 11 + scripts/build-oci.sh | 118 +++++++++ scripts/extract-artifacts.sh | 112 +++++++++ serving/.env.example | 36 +++ serving/dnsmasq/Dockerfile | 6 + serving/docker-compose.yml | 113 +++++++++ serving/spdk-setup.sh | 54 ++++ 21 files changed, 1557 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .gitea/workflows/gitea-main-ci.yaml create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 diskless.pkr.hcl create mode 100644 docs/diskless-fleet-design-v2.pdf create mode 100755 files/dracut/95dot1x-macsec/dot1x-macsec-up.sh create mode 100755 files/dracut/95dot1x-macsec/module-setup.sh create mode 100644 files/etc/NetworkManager/99-dot1x-unmanaged.conf create mode 100644 files/etc/dot1x/radius-ca.pem create mode 100644 files/etc/dot1x/wpa_supplicant-dot1x.conf create mode 100644 files/etc/dracut.conf.d/90-diskless.conf create mode 100644 http/ks.cfg create mode 100644 renovate.json create mode 100755 scripts/build-oci.sh create mode 100755 scripts/extract-artifacts.sh create mode 100644 serving/.env.example create mode 100644 serving/dnsmasq/Dockerfile create mode 100644 serving/docker-compose.yml create mode 100644 serving/spdk-setup.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..8c8713c --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,16 @@ +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/node:2.0.0": {}, + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}, + "ghcr.io/devcontainers/features/go:1": { + "version": "latest" + } + }, + "mounts": [ + "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume", + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached", + "source=${localEnv:HOME}/.codex,target=/home/vscode/.codex,type=bind,consistency=cached" + ], + "postCreateCommand": "npm install -g @openai/codex" +} diff --git a/.gitea/workflows/gitea-main-ci.yaml b/.gitea/workflows/gitea-main-ci.yaml new file mode 100644 index 0000000..ff33321 --- /dev/null +++ b/.gitea/workflows/gitea-main-ci.yaml @@ -0,0 +1,126 @@ +# +# .gitea/workflows/gitea-main-ci.yaml +# +# Builds the diskless fleet release: a fleet-generic UKI + erofs/dm-verity +# root, packaged as a FROM-scratch OCI artifact image (see CLAUDE.md and +# docs/diskless-fleet-design-v2.pdf). Packer kickstart-installs a throwaway +# Rocky 9 build VM (qemu), builds the --no-hostonly initramfs, then the +# shell-local post-processors extract artifacts, build the UKI (ukify), and +# build+push the OCI release image. +# +# This is an UNSIGNED (lab) build: sb_key/sb_cert are intentionally NOT passed, +# so build-oci.sh emits its loud unsigned-UKI warning. A production fleet must +# sign in CI/HSM -- see the "TODO: wire SB_KEY/SB_CERT" step below. +# +# Required Gitea repo secrets: +# REGISTRY_USERNAME - registry user for `oci_image` (oci_push=true) +# REGISTRY_TOKEN - registry password/token +# # SB_KEY, SB_CERT - (future) Secure Boot signing key + cert for signed UKIs +# +# Runner: needs KVM (qemu accelerator=kvm) and the build-host tooling installed +# below (libguestfs, erofs-utils, cryptsetup, jq, ukify, buildah). +# + +name: Build Diskless UKI +run-name: ${{ gitea.actor }} builds the diskless fleet UKI +on: + push: + branches: + - "main" + tags: + - "v*" + +env: + PRODUCT_VERSION: "1.10.0" # Packer version + PACKER_LOG: "1" + OCI_IMAGE: "git.bitasylum.org/audiobuzz/fleet-boot" + NIC_DRIVERS: "ice mlx5_core" + # =========================================================================== + # TEMPORARY: 802.1X + MACsec are DISABLED for this build (rd.dot1x=0) so the + # UKI / rootfs / NVMe-oF boot path can be validated on an OPEN switch port. + # The resulting UKI has NO link authentication or encryption — test networks + # only. Set back to "false" to restore the full secure boot chain. + # =========================================================================== + DISABLE_DOT1X: "true" + +jobs: + uki-build: + name: Diskless UKI Build + runs-on: ubuntu-latest + container: + image: git.bitasylum.org/audiobuzz/ubuntu-act-runner-qemu:main + options: --device /dev/kvm + steps: + - + name: Checkout + uses: actions/checkout@v6 + + - + name: Derive image version + id: vars + # Tag push (v1.2.3 -> 1.2.3); otherwise 0.0.0-. + run: | + ref="${GITHUB_REF#refs/*/}" + if [ "${GITHUB_REF#refs/tags/}" != "$GITHUB_REF" ]; then + version="${ref#v}" + else + version="0.0.0-$(echo "${GITHUB_SHA}" | cut -c1-7)" + fi + echo "image_version=${version}" >> "$GITHUB_OUTPUT" + echo "Building image_version=${version}" + + - + name: Install build-host tooling + # Tools the shell-local post-processors run on the RUNNER (not the VM): + # libguestfs (virt-*), erofs-utils (mkfs.erofs), cryptsetup + # (veritysetup), jq, systemd-ukify (ukify), buildah (OCI), plus qemu + + # shellcheck for the lint gate. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + qemu-system-x86 qemu-utils libguestfs-tools \ + erofs-utils cryptsetup-bin jq systemd-ukify buildah shellcheck + + - + name: Setup Packer + uses: hashicorp/setup-packer@main + with: + version: ${{ env.PRODUCT_VERSION }} + + - + name: Shell lint gate + # Matches CLAUDE.md conventions; dracut hooks + post-processor scripts. + run: | + bash -n files/dracut/95dot1x-macsec/*.sh scripts/*.sh + shellcheck files/dracut/95dot1x-macsec/*.sh scripts/*.sh + + - + name: packer init + run: packer init . + + - + name: packer validate + run: packer validate . + + - + name: Registry login + # Enables oci_push=true in the build step below. + run: | + buildah login \ + --username "${{ secrets.REGISTRY_USERNAME }}" \ + --password "${{ secrets.REGISTRY_TOKEN }}" \ + git.bitasylum.org + + - + name: packer build (UKI + OCI release image) + # UNSIGNED lab build: sb_key/sb_cert deliberately omitted. + # TODO: wire SB_KEY/SB_CERT secrets for a signed UKI: + # -var sb_key=${{ secrets.SB_KEY }} -var sb_cert=${{ secrets.SB_CERT }} + run: | + packer build -color=false -on-error=abort \ + -var image_version="${{ steps.vars.outputs.image_version }}" \ + -var nic_drivers="${{ env.NIC_DRIVERS }}" \ + -var oci_image="${{ env.OCI_IMAGE }}" \ + -var oci_push=true \ + -var disable_dot1x=${{ env.DISABLE_DOT1X }} \ + . diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..124a6e5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,140 @@ +# CLAUDE.md — Diskless Fleet Boot System + +This repo builds the boot system for a fleet of diskless hosts: +PXE/HTTP-booted, authenticating to the network with **802.1X EAP-TLS using a +TPM-held key**, running **MACsec** on the access link, and mounting an +immutable **erofs + dm-verity root over NVMe/TCP**. Each release is a signed +**UKI** plus root image packaged as a **FROM-scratch OCI artifact image**. +The authoritative architecture document is +`docs/diskless-fleet-design-v2.pdf` — read it before structural changes. + +## Repo layout + +``` +diskless.pkr.hcl Packer template (qemu builder) +http/ks.cfg Kickstart for the throwaway build VM +files/dracut/95dot1x-macsec/ Custom dracut module (the heart of the system) +files/etc/dot1x/ Supplicant config + RADIUS CA (placeholder) +files/etc/dracut.conf.d/90-diskless.conf no-hostonly + drivers + modules +files/etc/NetworkManager/ NM unmanaged-device config +scripts/extract-artifacts.sh qcow2 -> kernel/initramfs/erofs+verity/cmdline +scripts/build-oci.sh ukify + sbsign + roothash gate + OCI image +docs/ Design PDF and supporting notes +``` + +## Build & test commands + +```bash +packer init . +packer validate . # always before committing template changes +packer build -var image_version=X.Y.Z \ + -var nic_drivers="ice mlx5_core" \ + -var oci_image=/fleet-boot . # add sb_key/sb_cert for signed builds +bash -n files/dracut/95dot1x-macsec/*.sh scripts/*.sh # shell syntax gate +shellcheck files/dracut/95dot1x-macsec/*.sh scripts/*.sh +``` + +Build-host deps: qemu-kvm, libguestfs-tools, erofs-utils, cryptsetup, jq, +systemd-ukify, sbsigntools, buildah (or docker). + +## Architecture invariants — do not violate these + +These encode failure modes that brick the fleet. Treat them as hard +constraints; if a task appears to require breaking one, stop and flag it +instead of working around it. + +1. **No per-host state in any artifact, ever.** No hostnames, keys, certs, + MACs, or per-host config in the initramfs, rootfs, UKI, or OCI image. + All host identity derives from the TPM at runtime (key at persistent + handle `0x81010001`, cert at NV index `0x01800001`, hostnqn derived from + the cert serial). If a change needs per-host data, it belongs in the + enrolment flow, not the image. +1. **The hook ordering in `dot1x-macsec-up.sh` is load-bearing.** + NV-cert extraction → supplicant start → wait for `Authorized` + macsec0 → + DHCP **on macsec0** → hostnqn derivation. Never DHCP before auth + completes (pre-auth leases are invalid after VLAN assignment); never + connect NVMe before macsec0 is addressed. +1. **The supplicant must survive switch-root.** It is launched with + `exec -a @wpa_supplicant` (systemd root-storage-daemon convention) and + its state lives under `/run`. Do not “clean up” the `@`, move state off + `/run`, or hand the interface to NetworkManager — MKA times out in ~6 s + without keepalives and the root filesystem link dies. The NM + unmanaged-devices config exists for this reason; keep it in sync with + interface naming. +1. **roothash coupling is a hard gate.** `build-oci.sh` refuses to build if + the `roothash=` in the UKI cmdline ≠ `verity.json`. Never weaken, skip, + or warn-instead-of-fail this check: a mismatched UKI/rootfs pair is an + unbootable fleet. +1. **The UKI cmdline is immutable once signed.** Any cmdline change + (including `NVMET_TRADDR`) requires a rebuild + re-sign. Prefer a + per-environment DNS name for NVMe discovery over per-environment builds. +1. **Signing keys never enter the build VM or this repo.** `sb_key` / + `sb_cert` are CI/HSM-provided paths on the build host. Never commit key + material, never copy keys into provisioner steps, never echo them in + logs. Unsigned builds are lab-only and must keep their loud warning. +1. **Reproducibility of the root image is a security property.** + `mkfs.erofs` runs with fixed UUID and `-T 0`; the verity root hash must + be a pure function of the input tree. Don’t introduce timestamps, + random seeds, or build-host paths into the rootfs. +1. **The initramfs is built `--no-hostonly` and contents are asserted.** + The build VM is virtio-only; real NIC drivers, `macsec`, `nvme-tcp`, + erofs, and veritysetup are explicitly listed in + `90-diskless.conf` and checked via `lsinitrd | grep` in the template. + When adding a runtime dependency to the hook, add it to the dracut + module’s `install()` *and* extend the lsinitrd assertion. +1. **The OCI artifact image stays FROM scratch.** Only + `/artifacts/{fleet-.efi, rootfs.erofs, verity.json, SHA256SUMS}`. + No shell, no tools, no base image. Serving software (SPDK, nginx) lives + in separate, independently versioned images — never merge them into the + artifact image. +1. **Deployments are pinned by digest.** One commit per fleet release in + the deployment manifest; that manifest is the single source coupling + UKI ↔ namespace. Rollback = revert the commit. + +## Things that look like bugs but aren’t + +- `identity="fleet-host"` in the supplicant config is intentionally generic; + RADIUS derives real identity from the certificate CN/SAN. +- `/etc/machine-id` is truncated empty in the image; each host generates its + own into the tmpfs overlay at boot. +- `/etc/fstab` is effectively empty; root comes from netroot + verity. +- The build VM has no TPM. Correct — nothing host-specific exists at build + time. CI boot tests use swtpm. +- `files/etc/dot1x/radius-ca.pem` is a placeholder; builds succeed with it + but authentication cannot. Replace per environment, don’t commit real + internal CA material unless the repo’s classification allows it. + +## Known bench-verify items (check before trusting a change in production) + +- TPM key reference method in wpa_supplicant: `private_key="handle:..."` + via OpenSSL tpm2 provider vs tpm2-pkcs11 URI — depends on the distro + build; the supplicant config carries both options. +- erofs module availability on the target RHEL kernel (fallback: + journal-less ext4 in `extract-artifacts.sh`). +- `systemd.verity_root_options=hash-offset` support in the shipped systemd + (fallback: split data/hash into two namespaces). +- `network-legacy` dracut module availability (the hook’s `ifup` step needs + porting if a future release drops it). + +## Testing changes + +Full boot-chain smoke test runs in QEMU/Proxmox: swtpm (test key at the +fleet handle, test cert in NV) + hostapd as wired 802.1X/MACsec +authenticator + FreeRADIUS on a veth/bridge (bridge needs +`group_fwd_mask=8` or EAPOL is silently dropped). Pass criteria: port +`Authorized`, macsec0 addressed, NVMe namespace connected, verity root +mapped, overlay writable, supplicant alive after switch-root. + +Any change to the dracut module, supplicant config, or cmdline must pass +this before merge. Template-only changes need `packer validate` + +shellcheck at minimum. + +## Conventions + +- Shell: bash, `set -euo pipefail` in scripts (dracut hooks source + dracut-lib instead and must stay POSIX-ish bash compatible with the + initramfs environment — no GNU-only tools not installed by the module). +- Keep comments that explain *why* (timing constraints, kernel behaviours); + they encode bench findings that are expensive to rediscover. +- Versioning: semver on `image_version`; the OCI tag matches; the git tag + matches; one release == one digest == one manifest commit. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cd607ed --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2025 audiobuzz + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..de560c4 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Diskless Fleet Image Build (Packer) + +Builds the fleet-generic boot artifacts for PXE/HTTP-booted diskless hosts that +authenticate with 802.1X EAP-TLS (TPM-held key, certificate in TPM NV) and run +MACsec on the access link. The root filesystem is an immutable, reproducible +**erofs image with an appended dm-verity hash tree**, served as a single +shared **read-only NVMe/TCP namespace**, with a tmpfs overlay for runtime +state. Companion to `docs/diskless-fleet-design-v2.pdf`. + +## Layout + +``` +diskless.pkr.hcl Packer template (qemu builder) +http/ks.cfg Kickstart for the throwaway build VM +files/dracut/95dot1x-macsec/ Custom dracut module + module-setup.sh module definition + dot1x-macsec-up.sh initqueue hook: NV cert -> @wpa_supplicant + -> wait authorized + macsec0 -> DHCP + -> hostnqn derived from cert +files/etc/dot1x/wpa_supplicant-dot1x.conf Fleet-generic supplicant config +files/etc/dot1x/radius-ca.pem PLACEHOLDER - replace with real CA chain +files/etc/dracut.conf.d/90-diskless.conf no-hostonly + nvmf/verity/erofs + drivers +files/etc/NetworkManager/ marks NIC/macsec0 unmanaged post-pivot +scripts/extract-artifacts.sh qcow2 -> vmlinuz + initramfs.img + + rootfs.erofs (+verity) + cmdline +``` + +## Build host requirements + +- Packer >= 1.10, qemu plugin (`packer init .`) +- qemu-kvm with KVM available +- libguestfs-tools, **erofs-utils**, **cryptsetup**, jq (artifact extraction) +- **systemd-ukify** (+ **sbsigntools** when signing) — UKI assembly +- **buildah** (or docker) — OCI release-artifact image build + +## Build + +```bash +# 1. Replace the CA placeholder +cp /path/to/radius-ca-chain.pem files/etc/dot1x/radius-ca.pem + +# 2. Set the NIC drivers for your real fleet hardware (default covers +# common Intel + Mellanox); the build VM only has virtio, so anything +# not listed here will not be in the initramfs. +packer init . +packer build \ + -var image_version=1.0.0 \ + -var nic_drivers="ice mlx5_core" \ + -var oci_image=registry.example.org/fleet-boot \ + -var oci_push=true \ + -var sb_key=/secure/ci/fleet-sb.key \ + -var sb_cert=/secure/ci/fleet-sb.crt \ + . +``` + +Omit `sb_key`/`sb_cert` for unsigned lab builds (the pipeline warns). +Substitute the real NVMe target address for `NVMET_TRADDR` in +`scripts/extract-artifacts.sh` (or post-edit `cmdline.txt`) **before** the +OCI step: the cmdline is immutable once signed into the UKI. + +Outputs: `artifacts//` gains `fleet-.efi` (the signed +UKI) alongside `rootfs.erofs`/`verity.json`, and the build produces the +release-artifact OCI image `:` containing only +`/artifacts/{fleet-.efi, rootfs.erofs, verity.json, SHA256SUMS}` +with the roothash recorded as an image label. The build hard-fails if the +roothash baked into the UKI cmdline does not match `verity.json`. + +## Publishing a version + +The OCI image **is** the release. The serving stack stays constant +(separate, independently-patched SPDK and nginx/caddy images); each fleet +release only changes which artifact image feeds them: + +1. Push `:` (or let `oci_push=true` do it) and record + the **digest**. +2. In the fleet-boot deployment manifest (k8s Deployment or compose), + bump the artifact initContainer / image-volume reference to that digest + — one commit per fleet release; this manifest is the single place + coupling UKI <-> root namespace. +3. The initContainer copies `/artifacts` into the shared volume; nginx + serves `fleet-.efi` over HTTPS (firmware validates against + the TlsCaCertificate CA, then Secure Boot verifies the UKI signature); + SPDK exposes `rootfs.erofs` as a **read-only** NVMe/TCP namespace. +4. Rollout = digest bump; rollback = revert the commit. cosign the image + so the deploy pipeline verifies provenance (sbsign is for firmware, + cosign is for your cluster). + +## Kernel command line (per build — emitted in cmdline.txt) + +``` +rd.neednet=1 rootovl \ +rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 \ +rd.nvmf.discover=tcp,NVMET_TRADDR,4420 \ +root=/dev/mapper/root rootfstype=erofs rootflags=ro \ +systemd.verity=yes roothash= \ +systemd.verity_root_data=/dev/nvme0n1 \ +systemd.verity_root_hash=/dev/nvme0n1 \ +systemd.verity_root_options=hash-offset= +``` + +Single-file layout: data and hash tree share one namespace, split by +`hash-offset`. `rd.dot1x.if=` may pin the NIC; otherwise the first +non-loopback interface is used. + +## Bench-verify before fleet rollout + +1. **erofs module present** in the target RHEL kernel (`modprobe erofs`) — + enabled in later RHEL 9 minors for image mode; if absent, fall back to + journal-less ext4 (`mkfs.ext4 -O ^has_journal -d `) in + `extract-artifacts.sh`. +2. **systemd.verity_root_options support** in the shipped systemd/dracut + (needs a reasonably recent systemd; otherwise publish data and hash as + two separate namespaces and drop the hash-offset option). +3. **TPM key reference**: confirm wpa_supplicant accepts + `private_key="handle:0x81010001"` via the OpenSSL tpm2 provider; if not, + switch to the tpm2-pkcs11 option in the supplicant config. +4. **CONFIG_MACSEC** in the shipped wpa_supplicant build. +5. **Pivot survival**: `@wpa_supplicant` survives switch-root; MKA continuity + across it. NVMe/TCP keepalives must also ride out the pivot (they will, + if the link never drops — that is the point of the @-daemon). +6. **dracut module choice**: skeleton uses `network-legacy`; if removed in + your release, port the hook's ifup step to the available backend. +7. **hostnqn allow-listing**: if the nvmet target filters hosts, the + allowed-NQN list must be fed from the enrolment pipeline (NQNs are + derived from certificate serials — see the hook). +8. **CI smoke test**: boot artifacts in qemu/Proxmox with swtpm against + hostapd-as-authenticator + FreeRADIUS (remember bridge group_fwd_mask=8 + for EAPOL); assert macsec0 up, dm-verity root mapped, overlay rw. + +## What is deliberately NOT in the image + +- Private keys, certificates, host names, per-host config of any kind. + All identity lives in each host's TPM (persistent handle + NV index); + the NVMe hostnqn is derived from the certificate at boot. diff --git a/diskless.pkr.hcl b/diskless.pkr.hcl new file mode 100644 index 0000000..a7bf004 --- /dev/null +++ b/diskless.pkr.hcl @@ -0,0 +1,204 @@ +# ============================================================================= +# Diskless fleet image build +# +# Builds a RHEL9-family root filesystem + fleet-generic initramfs for +# PXE/HTTP-booted diskless hosts authenticating with 802.1X EAP-TLS +# (TPM-held key, cert in TPM NV) and MACsec link encryption. +# Root filesystem: erofs + dm-verity over a shared read-only NVMe/TCP +# namespace, with a tmpfs overlay for runtime state. +# +# Outputs: +# ${var.artifact_dir}/${var.image_version}/ +# vmlinuz, initramfs.img - intermediate (consumed by ukify) +# fleet-.efi - signed UKI (kernel+initramfs+cmdline+roothash) +# rootfs.erofs - erofs + appended verity tree -> nvmet RO namespace +# verity.json, cmdline.txt, SHA256SUMS +# OCI image ${var.oci_image}:${var.image_version} (FROM scratch) +# /artifacts/{fleet-.efi, rootfs.erofs, verity.json, SHA256SUMS} +# = the atomic fleet release unit; deploy as initContainer / image volume +# feeding the SPDK + HTTPS serving containers, pinned by digest. +# +# Usage: +# packer init . +# packer build -var iso_url=... -var iso_checksum=... . +# ============================================================================= + +packer { + required_version = ">= 1.10.0" + required_plugins { + qemu = { + source = "github.com/hashicorp/qemu" + version = ">= 1.1.0" + } + } +} + +# ------------------------------------------------------------------ variables + +variable "iso_url" { + type = string + description = "RHEL 9 / Rocky 9 / Alma 9 DVD ISO URL or local path" + default = "https://download.rockylinux.org/pub/rocky/9/isos/x86_64/Rocky-9-latest-x86_64-dvd.iso" +} + +variable "iso_checksum" { + type = string + description = "ISO checksum, e.g. file:.../CHECKSUM or sha256:" + default = "file:https://download.rockylinux.org/pub/rocky/9/isos/x86_64/CHECKSUM" +} + +variable "image_version" { + type = string + description = "Version stamp for the published artifact directory" + default = "0.1.0" +} + +variable "artifact_dir" { + type = string + description = "Where extracted boot artifacts and rootfs tar are written" + default = "artifacts" +} + +variable "nic_drivers" { + type = string + description = "Kernel modules for the fleet's real NICs (build VM only has virtio!)" + default = "igb igc ixgbe i40e ice mlx5_core" +} + +variable "build_password" { + type = string + description = "Throwaway root password used only during the Packer build (must match http/ks.cfg)" + default = "packer-build-only" + sensitive = true +} + +variable "oci_image" { + type = string + description = "OCI image ref for the release-artifact image (FROM scratch, holds UKI + rootfs.erofs)" + default = "registry.example.org/fleet-boot" +} + +variable "oci_push" { + type = bool + description = "Push the release-artifact image to the registry after building" + default = false +} + +variable "disable_dot1x" { + type = bool + description = "TEST ONLY: bake rd.dot1x=0 into the UKI cmdline so the host boots with 802.1X + MACsec disabled (open-port NVMe-oF boot test). Leave false for any real/secure build." + default = false +} + +variable "sb_key" { + type = string + description = "Secure Boot signing key (PEM) for the UKI. Empty = unsigned lab build. Production: CI/HSM-held; never enters the build VM." + default = "" + sensitive = true +} + +variable "sb_cert" { + type = string + description = "Secure Boot signing certificate (PEM) matching sb_key" + default = "" +} + +# --------------------------------------------------------------------- source + +source "qemu" "diskless" { + iso_url = var.iso_url + iso_checksum = var.iso_checksum + + output_directory = "output-qemu" + vm_name = "diskless-build.qcow2" + disk_size = "12G" + format = "qcow2" + accelerator = "kvm" + memory = 4096 + cpus = 2 + headless = true + + http_directory = "http" + + # BIOS/isolinux boot. For an OVMF/UEFI build VM, replace with the grub + # variant: ["c", "linuxefi /images/pxeboot/vmlinuz inst.text ", + # "inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", + # "initrdefi /images/pxeboot/initrd.img", "boot"] + boot_wait = "5s" + boot_command = [ + "", + " inst.text inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg", + "", + ] + + ssh_username = "root" + ssh_password = var.build_password + ssh_timeout = "60m" + shutdown_command = "shutdown -P now" +} + +# ---------------------------------------------------------------------- build + +build { + sources = ["source.qemu.diskless"] + + # --- 1. Stage the dracut module, configs, and CA placeholder ------------- + provisioner "file" { + source = "files/dracut/95dot1x-macsec" + destination = "/usr/lib/dracut/modules.d/" + } + + provisioner "file" { + source = "files/etc/" + destination = "/tmp/etc-stage/" + } + + # --- 2. Packages, configs, diskless adaptations -------------------------- + provisioner "shell" { + inline = [ + "set -euxo pipefail", + + "dnf -y install wpa_supplicant tpm2-tools tpm2-tss nvme-cli cryptsetup dracut-network openssl", + + # Stage etc files into place + "install -D -m0644 /tmp/etc-stage/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem", + "install -D -m0644 /tmp/etc-stage/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf", + "install -D -m0644 /tmp/etc-stage/dracut.conf.d/90-diskless.conf /etc/dracut.conf.d/90-diskless.conf", + "install -D -m0644 /tmp/etc-stage/NetworkManager/99-dot1x-unmanaged.conf /etc/NetworkManager/conf.d/99-dot1x-unmanaged.conf", + "chmod 0755 /usr/lib/dracut/modules.d/95dot1x-macsec/*.sh", + + # Inject the fleet NIC drivers into the dracut config + "sed -i 's/@NIC_DRIVERS@/${var.nic_drivers}/' /etc/dracut.conf.d/90-diskless.conf", + + # ---- Diskless adaptations ------------------------------------------ + # Per-host machine-id is generated at boot into the overlay + "truncate -s 0 /etc/machine-id", + # Read-only NFS root; runtime state on tmpfs/overlay (rootovl via the + # dracut 'overlayfs' module is pulled in by 90-diskless.conf) + "printf '%s\\n' '# diskless: root is provided by netroot; no local mounts' > /etc/fstab", + # No local bootloader regeneration wanted on kernel updates inside CI + "command -v grub2-mkconfig >/dev/null && echo 'GRUB updates not used on diskless hosts' || true", + + # ---- Build the fleet-generic initramfs ------------------------------ + # %%{ escapes the rpm query tags so Packer's HCL template engine treats + # them as literals (a bare %{...} is parsed as a template directive). + "KVER=$(rpm -q --qf '%%{VERSION}-%%{RELEASE}.%%{ARCH}\\n' kernel | sort -V | tail -1)", + "dracut -f --no-hostonly --kver \"$KVER\" /boot/initramfs-generic.img", + "lsinitrd /boot/initramfs-generic.img | grep -E 'macsec|wpa_supplicant|tpm2_nvread|nvme.tcp|erofs|veritysetup' ", + + # ---- Build hygiene --------------------------------------------------- + "dnf clean all", + "rm -rf /tmp/etc-stage /var/log/anaconda /root/anaconda-ks.cfg", + "truncate -s 0 /var/log/messages /var/log/secure 2>/dev/null || true", + ] + } + + # --- 3. Extract kernel, initramfs, rootfs from the built disk ------------ + # --- 4. Assemble + sign the UKI; build the OCI release-artifact image ---- + post-processor "shell-local" { + inline = [ + "bash scripts/extract-artifacts.sh output-qemu/diskless-build.qcow2 ${var.artifact_dir}/${var.image_version} ${var.disable_dot1x}", + "bash scripts/build-oci.sh ${var.artifact_dir}/${var.image_version} ${var.oci_image} ${var.image_version} '${var.sb_key}' '${var.sb_cert}' ${var.oci_push}", + ] + } +} diff --git a/docs/diskless-fleet-design-v2.pdf b/docs/diskless-fleet-design-v2.pdf new file mode 100644 index 0000000..ea85a7a --- /dev/null +++ b/docs/diskless-fleet-design-v2.pdf @@ -0,0 +1,231 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R /F3 5 0 R /F4 8 0 R /F5 9 0 R /F6 13 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font +>> +endobj +6 0 obj +<< +/Contents 20 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +7 0 obj +<< +/Contents 21 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +8 0 obj +<< +/BaseFont /Helvetica-Oblique /Encoding /WinAnsiEncoding /Name /F4 /Subtype /Type1 /Type /Font +>> +endobj +9 0 obj +<< +/BaseFont /Courier /Encoding /WinAnsiEncoding /Name /F5 /Subtype /Type1 /Type /Font +>> +endobj +10 0 obj +<< +/Contents 22 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +11 0 obj +<< +/Contents 23 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +12 0 obj +<< +/Contents 24 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +13 0 obj +<< +/BaseFont /ZapfDingbats /Name /F6 /Subtype /Type1 /Type /Font +>> +endobj +14 0 obj +<< +/Contents 25 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +15 0 obj +<< +/Contents 26 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 18 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +16 0 obj +<< +/PageMode /UseNone /Pages 18 0 R /Type /Catalog +>> +endobj +17 0 obj +<< +/Author (Jarrad / Claude design session) /CreationDate (D:20260612010534+00'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260612010534+00'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (Diskless Fleet 802.1X / MACsec / Verified Boot Architecture v2) /Trapped /False +>> +endobj +18 0 obj +<< +/Count 8 /Kids [ 4 0 R 6 0 R 7 0 R 10 0 R 11 0 R 12 0 R 14 0 R 15 0 R ] /Type /Pages +>> +endobj +19 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3441 +>> +stream +Gau0ECN&7Q(&dQ-EIe1F[uk?dU0]PUeEcdN2dUj%[1a7QMdSr5Ul"mSB5"LF+ht_R%\Ibm=]M^bCl+Z"cJ3KeIF=qmIU2h8Ll(!FdE-a%>`\:gjSFm4GG"*YmcaD?C1Q=_G\C.6GPoS4+1gNlI+OpJnMqO!-<&'R]YLY@4rQNCR5]2$kW8lj1r1DG3e""!6R9(:pQ:\pmQGJ^dt-\ls.g=>3]kqK^mAD=XqjWaoJTDQS`R[[)&\ecQfX)pmg\+O7X'@Bj)k%6l)3sjgYDBi/1%it(!^@LNVj[ah!l'[9Q;rZkfZ2N+R.?PdN79>S<5lPcR>[qCSlrC)5QoZ7K?,0[=kVeN44jN*t)9\6<*k=(]eo:q>+s?QkM*Jl?O:h-d.32bgo9AAJ,o2!,ZbieS@T4J/8s;tg(26O<(:;'W6`Nigpgb&PT3`96s#VDahM_u$F,di#O7W0p]\tZ3%qt/f(3@Zs5tN$Io'*[4o#99Vs6e>f4Xm_3]:5k88:jj16eASn,`*c]Z?U!;X8*=+jFLhSjQhanmSOhq+2JMpmUpgfZHKHa>3>'9;pJLAQpfQ@>fi2Ao:$e8[7mgLcUg^19:7._U5q%]TmJ&J&:a?Jr$1;YP![@+aGjAZ^egm`M*jh3g#]Z`A?KJl2*JNmIgk*-Z,#&+JN(;mF%U&@E!Z9^_X9JWH2\a:;NEdg96m*Ug>kRH7dk=2f>E(1;$M4=cUtB@5-$M(jA0T@[fRp/mfI=*[*b*j.'.^)9)4KM#t$pa]`1MEmd=uhF&nhS]b2I,:9=QkT.`Fc^L=)[c,==Y(tPL?j2-Ut,YKmD'Xh7^,NDL)35gSG.N=QX9IKm;+"o6!C<)R83M*`hG0P2r`YD;<.usA;JNLZPT(1C*GESerAL\o&rTi*s-F43U*QXp^G>]>_%"Ng`mnP3?pL.6)_oJB8`2-.85QR-Xf0!^?!@`lebU^pueb\Fua)mjbf\^?/dC`:QW)#uQO]?%(]o\flV\4S*_^E:'q\]%`+Qkc#BFeSiq9s85_KD=>j]>Kbj[ecd!g&EGJ;.BlS>fCJYUik:_FT!7CP&fq6ua56A.9)$OHA(_>TuJEejY!=/KqBb5QDg3H281NhZjaLr='[<$C<,&=\OR`dr1\chZ9.4_0)[%L[/-akG?Qgj86JC1!L^BKQ48_>t9?R@>5O\:Gu"j#='D])td#'T\K_?TOf$ug%d\3MBah9d,n'I;,b)gfA,Me90(eGpK*+\/RgVNh0hM:/Wg\I:i=Ukt`jU+;p=j\.#dRc[Hc4Ktl8!V:`$*K@c:Z=bMD<<0:]:fX+#N79MKl3.U-METK@[Sk$YPq90Qn]8Mkq]ZGi0G]ahjB"7@"DNu!^uYb^lSo$L=J$,d_.ab[)Tra;]D*)tUF7^Q"eWQU"eZQ1Y7_&#A-:4u-'BPn">,nl#>ib6!;c^A"J15g\uoPj3uQZjAU>oRUr)0?&,YH3>ZN4jR;`Ag9jpf"1S)gge/q#6L`V%-Eb7]<+_PE9CM,R!-\HEPaIBlQB]Hqc\2sqPk?\\]*<"3A2BM3/m;M0\@bBk[ma-IV_6`,#r`n?/;98->[[?qt.8`Ps4,g;t4IT;"RF`-q%D-:5oQK>=?Cf9K:N7t?&1I[jS?8$D_Ybd6_0#,Tgh.SUXPCC>2]b&El,5f2LG(QF'9R[N7JAOm6]^Y&_?_+n**kP`iC[jFlalf/QVP7X^#P?Mr1I;1!!Pq&;r?,@sJ*]?DOHA%2^jCrG.0htfZQb5#+X+^gV[,0an;-P,elC0\uCE:,\F#`gDl$#$=+!Gm#U<*5V6atGcb\3^gqmGQE)C#-\MdjSS=tnsS:eSZhSXoE:5TZ7[8j'3H2dE*f;90PMIZ<>`GLMhBgaA.k[DijaN"O8Vu3m!:C]VILA=hoC`0#$!)At_7d%`*66JkhBf4]9;Jj&ir;eO&CHLF"ZV5b\\/Cm$ui`UriRa\sO8eO>5j.hGP,c/ncg?X1(t;&DpNV;=cRpTT`ugMHaJ#Me"j0L28P#ONZ^b[F%5n=Q%=/HotMC9qiJcjt!-=V6rd+lbDqD',Pdq==&_K7>gQJ"XPpDRki&5?PLtb;8oiR>J6ENsfed5?q73\r>,]Ej4i8#cFfpfpc4XWYh-ZdV"#.-Fr%H$R9`h@na=VUqnO"aJ%q.Q(YW:b)BV-'ePu"%JX"I@^rJD4+au+h1i5#48/S`V^P?B!1k%a#6L[uJ]c8O?n\]i@i[-UT*&%@FX\ZIX9,[-hkJNB.4(Q)G(K5MAQ[JsTU@$0P7of9`pDR#@EkT#V\Oo9(joDfNCoq.3]&me__/34,ai_>PD71W%I@VF7MSu&"DGTn@Kl)lOIVi:=[WPJJoMB&OVZ"Q$&+EF([4Ln>Q&81cW!M_#SpM\H38F/eWDP.,VtQS@TLP&C]n,`'AZK>X74()JIs];CfnXjD,Q?m\&+`6,22"Dk\A_@[:Jk-,4o15S$BTO^f/eI6PK@E0a``kA/tnE5Mr.4j]VWrWV.m\C,%>7"LE;jClNB@O=X;S,.?-#q!s>h05SdpbVSo*U(h3<`(8/?bM<=s-Ks.i2Z4Hh,7VO9rd^F-9P6i*[QZX6;-Zk5&FKG;N-i*fC9Ac_`GC!8Anm&TRs@*u7El")JAbhW_h!K"!nTrD;t:L?7]#^`L>]^cbiKjS._g58>SKJ=B(D+c-df=L(iI#ScRWendstream +endobj +20 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1229 +>> +stream +Gat=)gN)%,&:N/3m%^(Q&.+LB)*^-)h57.j?dqak1t.")OFg2Y$32O*hfh@ae>8=lYrJH\m,_X$?JpHkO5IUV$K0qI`]$*V'IGj8+UMTJ#Q-=pHlI0jQpa]6G5EPDP;)KW7%s(i%"D4<`KcsL_5s$e;#.lSfbr8U4V@31bY[Z#&[$V/5.L(OEd#bJ&<+@0<^>UnBY10AM1q151[,q9:a>('q&?Bd9mh:c1^[0#\h(Yt&mT\r*e#,;Uu)DlHh`\caA5qG'gt>SU4gP@]b2@kpINd56>"Fh$,2MX7dM1,7l`.SKJ5]3OsVjiEF2!1DEuXcI=S-<8P%"@fOGjF(@9]3U9jAQ49jr!dQ!3T2fNI:HR-1Q&cMQr!-["nl.DNR_emj@Yfr5uoLo`/OJ-VK!6+ff;r;/K<_k4Le&"W:=D7XYD3a,\%6%r(SL2..g-3!YK\No&CVr?NUVfBp8eAqSK2,_MSp";APJ&kkJpCc@"iDH+^cg$u/f(s4@DAL>gPdh'V(+YI@t'\[q*,/_>\d)9Yej;Z/]&ONo`;r&0tu4\j>K+`D/AcMJ(mEc#C51YVuK8od%]ZoG0=QV(r(GoB7mYc>lXFZ9D^%@QE;JqE3o1a&m!F8MNgGiiblQ4MPA1BS%D(KYi^Cp]VSF.n`4D'a0I1de+Pelsl_EbnEj!A?Eb7HkXXNu;"+Mu%-,>p7]1_f!&^@mFK`,)NhTAV$uO"M-3o;0!u.Hcp\Om&9M>j-I<9cDPr",eFLBE~>endstream +endobj +21 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3045 +>> +stream +Gat=->BAQ1&UsJXfU4qQ8M-Ai;,kumM;oj/DX8>jDtu6#UplP_+%InoA.=q`DT=>Y'`+Cgf$JW2;1RghYX]k9.5,XDm_cgI`pVJ&caR_'3!,#.No*BUQO0eY>p_s6hcr+5cuq#(hfMIlBBNI:q!_93R7`Ugla!VV9Z8ejMn#?/dB$H[cY2l29qJ[NrB2u$aSmHQk"PTm`6L0l*7ht#PtaJB5(0!\ZgXi7*,3gp+1Mo,_NZJi6`qu_M>IC4VL79j/A8T!Hj_&]b>kKOK10+i:l;aamf`f$I&'13*>BH@5na2i7S6=VpCF*Z,](>NK&[oESuoE@(jYM0CkZWd3`?ss3lq_;=Gi@f,Hn3B/a=5louiA#%K$*FNtPY^&B3@n.E[6qr\.&[)^a6LQp3^\?A)/r(n6jf.Mc:?aJe%%_kIgH!uUDgC-6`WbW&FBO,Ut$s`h<'npO="f%293gfS7%7f9thXPDtjSJl4m%"CJs##^LRRL4&$N4A!5,\Cs7hliM#ArqrTst2-?cqAgjqRr5V8OKCjOchX_jstQ]PX.ogrhaM;,sWbA3l#E".<4f>Z4rU_P6?s"?JWZUe7Rjesa`*>LK#8kLV&h]q8!$Dl>8p+`8UjYnh'tM^dmOMXN*B?g=(XWUfoli1:90M/^+J+Z87&-4[arSpbF"Ag5[?g3RA`bWAM'mN+9-&(KX+\*kr.-;;WjX!&5)Y0a6gqXWTS%mrC$,J`H(r+=huZek+#1$SJh9"@N=?Y!`4BM(:!&'UWcIr?A9Tig9ubr$]Oa(EoQbQ8>KLVb6qTqX=U1gBa0gd3D*SHTE,i]sP[Oi+Fs)LFkTNLeSjnSVg'8d4?s@dS\PSmR_u3!IAWj#+(/5[CqgN(F=aM=T:k=XV.g0QH/L",u!Ntr5`?ca_mY!&XFolGN=+O%(JUn5hG-]KLKW?g_pZF)'mm46g'a&M+/E4d)UVaL\(Pd4]6`M.-hFfas$fHI*'(;Umncl#5VRB),Xg>Tel7o[j,MYlo^OGjObfWGP*/Sj]Kf#dX5*Y[/=6esA@)u[+6qkD#;KBT8gkd=]>)?3,;8aFjOD^-_.ZR$1Ar1d()*jCBQQT-KJ3_B`MoRp]PBWb;h.a0sBGg/g#(*7.[\KP`S)0`B%ldhnD%23"%qe\5^d2[Z&.2b"=4)4TYGr#NL74m?'+pbVNT$qk;I^uINu-t5G94`em(UBPkA8LIQi+"n`=@7pF=8M/iB.9p8*q`*T`B26=n.+)F4AkXZ=n<6rbT*-BrTF\#?3]j$`CN\kO.d3fl[ZFER36":1E][57s?S:*Y9MLeQPit)C4Bkkq%mm;mfg?i2#e/CH/'IVOIZL(3YN05\_'_gF#Wb-slLY2bZ#O]dl4LW.HA3A)#@dni!O`kXFN<2!keIp.ii&NH`mcD#XD2L:GF]fQ/:m<0X)d&W9OZ2)5TIHaiZ#td0U$&mgr0VOZ5ho$FSX<8qbqME8:s01l`D14FLKS]*LG]TQrUK3=31W=$W2'b7/pl6s3tO>,'\UHdifp(:GqK;\(F>XHo]&,MF;\XCiSg:Tri2r"j=/^Zo1Z7(eXKS;$qq=u9a7I$.$2\2g''cI0^Rn?c=)#-pFq_XT]2,^^r7+iNuSTTQSDcfk&Y+]dM+T8gF`nX8`1&e8YDD@JpGe%Tgat?&$fk(8X6+blBFa$/D7"OY.%J=4>J>6'J+`bqndP%'-DK>?7(1([OI`:_6T0-=QOFob#B=_JAf+!F_CE7C\CFS(E"B&ECX*n("THc`\e_[81eo!I:/%XndMdtNcLWo0Y5M$PaZ;31Yih]mI_A\C3'/:)V%YZ1(_B;YM.Ng*Yi^?]6kqQp=(lq!(5Ps%R(GJWk+'E8G)r'hTD8;3O$!C(g,/7N:AL0Yp^HhKW0YA9<98b.3RHW)]3A&qa54VGN48`"E=K\$rqn%j>rIU/.h9iB=_H(-(_,DC$-K!uK2GPVHT2$DNJh1[_oId&]N!5o*+9OGQ9karnWo]hn^cPpeMo?nQ5*V4\>=b$@tAY$\Dpdj^$_tH9=fg)ZlG!=N65]`TAdD2uLM]DhD/X79.Q5Ao%:`+p;QQ;;BH?S49A63-HhrqYHC5n0?)g2I9+a'$@:b_N#6\Risrs'AG[=n~>endstream +endobj +22 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3430 +>> +stream +Gatm=>>s9I(4PFJS=6FZ1:'O8B$3q)W<*0Q9uo;%;Z#D+<*O+j*HZ;N161O+J,>hdXd]>1eh^-Z/bS5\^N73q$\*$dIujZ#NoBcM#2=]K"L_rh0`D4]T6P;b@Jh.c^C`PER`Kp7mQ-go7r62]CRm/-hs#FhTY8$&$IHg@bCQ,n#:Sj%+DmM?*4'DkH36^2lh8S@hsm1[1#*gt5rD['HVSk/n+FnRY@jVVES7]NglD5O*8g)AVb?=G*pLiLO17?eE6ng3&q/2Pa"u[(jCocp26\ojEP@Y@Ht)DX=gV`9ZGB,Ms.J9/"HAAT82nLnNou#G\'9AOcj.J+pDfpb%a9Js5@=tc:I#[]HE>cTuoEO"mq]4^lW[c]cnc0!!2rVp6kgFqEA2a)BPG-`Vlo\'`.K?"e/fQ!Rf@u#Z:%'&#cciUqiMks`Cp'8Y)gsPW,RcCijnR\pJE63XGD&p&QkI?4a@/JXM7>\O_u9LL(^j5i^aWdsH8U3"gm$7\2YZCSa$^G[B4#&ME(p0/b=K[(9+2C'CGr5)`)uWjtP+lOo@n4ku36sXjc+P3]AF!=*M,-4Fpnc`-/Yi:#kW?LC4]81lRT?7\277Hk]#pOpJb/,>osW:YuAuoIT.r?j3at]VM6HOCYA"@LFM7c%b\&m2)r@\Jd7SL_@Y5fLV'Un>/*qu\=OF=L)[FtiORg\@(j?7go2+CcID0$`[?O3omXR>g[3OHcR7bbS+CQjY&;8kAU2/$B>MSIq[.Gb6R>8/11>1(Yf[MkPVZJ'93mVh:JCOeak/qm@_KR`EP,=#\&tt-2&:I,FY)CIck>.fQ%ZD=0!nMo-VNLm>BJ;FcZ6&aEWrOI$F`,?\n'&k^>eUGH$cKsY"&]$euUl@Be[m((Yr_2_R^tbYS69&NS%7h7NtW/%!`5Y/Mt^Q.?$G3Im!aU*)9op0WSWsJ?"g'\cpFK]dA4&AD47]bN\nI&&p,A;9GMq(:+^K'b`&?k=/M6%m25@94sc2=n-(&j!8-[A'Q?T"Y8`IXR#8Qj;TjiKP2dc=Hk)=*c!uedCf?S%MJqu=.W)JbaFu2P"_`8$rmn6Z]YCGRO\MJ,SnC%3P9/pf@qt"DcMVr/BBX6982Z=0Q#Z5;sI[[^DTGCWGZopD7_f`=3$Z:f0Ca\_+a`LVX6?P_*'tVQQg=KWM1QZTh2\="+7e2ft)O93CY3+=N$$aI)Y-ChB:aj\r/knhgOfEf@l_m^7r:DR%58lRroNbi3'RV'MY+uZ90k3D^PFeTkLnheG>M[`%UZM_F&0[d'0j>;k2&nS0qntF$SV`HUQ<%h'.__<&oMGo/H\i7d+8FkaGHV"KG_%Zun8X&D[s,\XI*"Q&O,:56)fl&//[nJM,J#D=]^A%BJPZ2SJl=^kp+b1DnWmi(f,Ra6AMD94LQ93N;fg@k^I+u/T3INSL8>=@4UQ7`c_rWa$jE,TO>t:sGNn-qK-qfQB(L$6(75]0^H,,_DB>I%taBcXQ)7G,Z`U5)*#[UE4[[mt1goOMS%&i=MBJQ@(n8NVO9>B00ZR&LKDFY%(CReZKQfplV*Q6^Ui.`3&hr^<7rsHfZad*+M9%Ad>ZEPjb,m&;HJuN^:"&G@SD=VA8$8"!\X(ZVs:ZGW+FH1IX<-;%F#91jPfVNp?oGf%*3U"5.+Es@%t@$''_@$O"efCY"^H,2(R00/5o\3Z`\<`''[,[Xp34KAhb3L/iUr3al/_UA[%_S/]0eB3S2^k%*K7+fsC@(#JO]6Ib+b\lmXY?--#L^7-g!:_a#B25N*cZcTgr&c-!n8A7g^0#*VVq(hijb7bP)`0CR%E6p6A`LeX/p#ipK=ZUG&lmsj#>,8n#4Z5DYe^@'XP*)b$qaq;BSRXS>H/,9)AWt)VZ&+?kee#OLE0l3dqe:YDX%lF"+de>)L7"*up\7DPWR0oKW&QS/Ol?2(^mWK!Y&Z@/gbjVj&b'uk[@ZUkPp($6Kp^8EhQ*aK.;W;%LWFFt+bH]Da0]c-7]&?r@q3L.A?1FWg?E%R-a>Xk:L.^YQ%g8kFa)n3SH`E*,mg5lMX&jl+`:j-:LR]8uDE*q^M(WjK?rEUflGDhnTPu*)O`-h!P_Q+LjdOlOPCX7p3n'b;]!rWI+jhtVEdiC^4ktQ`Emo].bc#rrDrXHkc~>endstream +endobj +23 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1441 +>> +stream +GatU29iL(3&A@ZcqA=C4M&h)"3AE)O:5')bA>]'^Mde2=)dCHUEgZe%I?G8]^f!PM)4(776]e:8_/:Kb;R2'ZNF4o4tq-++6B-Y@KrYQ6uLl6(I',32@[R__=aR5"S7I:AVeD#kt5EBol)a8P%l$(kkP9,8qKu+j@O[`m@`1\YV_/V3I%8+T.^(Bm+r6G_$C_\/$U29DYTOV/opA_XbRPRWgj%d4roGU%rW0SEDn0I3'`_cT,ps"/k55NZcN>,bC*U9lg=T-Gg[0dnh9r,K8Xd`P&/+:W0iYIqLo;oVFq^>4R:Oa2])aIj#7g01k3L5,0[@ES=\>uCWS+Wm-pK:2"WlSDPmG"oU>@\)68WPh=JKte#E"n\C@PP%'R=`$a)'9FF[s36C+-Z*[TDpjgtW?i@#P&-Wuncr"6mWPiP1^)K]=XUVMhMW/MJ*SWCWg1fN]XP7Y1"%=Tg=m,If9;bL3c^>SOThub'R7?jT$dI`>%NL69A]ICHnrkG5B:-\C\*I31ec2`/ZQrX=/mK:rCQs,bl_9FcY10JdVYc9gh.E6@YOm02mA<:1H!q)OBW%1q.[["XVbLqV/Gj^9!P4OoaG8UeDXM3ni%$PKc9k&\q!&IK(<>8V"J\%bY(WdcWKO:5[H?@NTm7M8%l/e[Q_EC%XV@WUQjVtl#DtV&*V;P#>BG9qUDfZTCf$fZ%LHCQa^$P_TN6o@k.2r^Mm1asnhI[(CIH#SZ,]endstream +endobj +24 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3294 +>> +stream +Gatm9n1&c9:=l+nb=<]&-(FoP-hN[ipG;XYL4g_15QNd$rRlcd1l(j']rhVY0]"?p#>!6OJR#0H?/C3?@e4'@M*;+KTc/Ne5Y8RYK<6"MJb6`YJ`c?;#@3$Y/C;/qg'D'THeSMh:F_e>;KagkZ)_HkKUp;P`qW.8)IfCo%l'XgIRJ*hqi)qLquM@!Bn5_YZnaN4u`G'Aj*7SZN1;j(LE>'#-Ms+FjdtlU>pmh(qI1Io`/2+%3q!S;sIGd',_@L6H:uqC\]-ZO]T*@t#-BX2^o30H#j:U_f)EY_UhmY:(;"LA:+V\d"5deY<.lgO1$7#-JRPc?F%Dd&kAXC]rS8S]G@k-n\G%$N2*j4'L>t\N^Ji_\4X3_QR4E1D!:62[Y$"9g9I9rjOOJ]F.k7R(,aDSE>a8>Yic-iM*P(rC9Ec5cqD+Boeb]]#9D'(!Emr)]'%oU:Ir2J.nXMkog&a-NG2hq_8+LH,cZTgXRDY[+bN_O\>>^28uMN&5%+sl6l/shF%fNjf@OM/,[.Pl?OBbthU2t4G3HmVAgK27cXNq\ZgHAV##FCGR'-q-lacI0LGC$oZ8"Zj2ZbKX<=N6O>M8'Al"AceY@(9C_D^fjnfEg)FSF6nYVUcfQ'WuYbD(GSIVKrf)P.a.<=jmbW:p/mmN?=jGD1E.S-b5+d)q8`:mOp-s:Ff#7EiF0ARJd\8-MJ728Gb#*)iO1aC.X4m!`)>8?VhDD'0ftMJ_%L9G)N2[R^!t1k";eF9s)75SL-3g!1?66Q>K>+Fo_/"!CGH4]RqsbBh;Pknk1J;X2=NddIBK01E]O(U3b14]ZNY0,sdiqXM($W!e0uMFP"45_W4J7BPZP@cB7SO2kDma=%b:C:4oL00JHDL>$fJuffKb",L;ZW=Pa"CGK6Bl/OR^nJkR?L#V1WIutO%nJaQXs&2^9@L$c1`2NP#oXYbBi2Pgp)22:N8WEpT%kXELLY7W(9^4-&]W%:>d27rJ[Wf0/H5qV*JaC.Vea[cc#9P>$Xo\rJf/YaLjB)M`-VFu,jZ"/-&6[KZJI5NCY*a"J+B(sMkS8a^!Vd`G1ot(d4QqOZ+ll@1qj<&S@JS\.OPd?QF+H.+UD*e(]#4"S]8E@4j6]Fs].qaH+@J?qb1ZNjdH#J9+4KBL/AFO?5[0kLW!^&\3+7ohEI2i.r0@6CIWgfX7P*n1Oe`r!/>?bMF(M#_#pTJN=A*go#SboO`S8_WnK2GNtrTT:\T5nlp'%lXV78`XFS=L<@lbg_Uk3!*4],>ml?+:Y4,Au^(_nWX+Mm^F[L;am*T`R-(9Yq#(1H,;47>#-[+Y?!STi%X8cXQ?d?Mj`a,K^D^Sao;,Wn$V,mVB5(.LcZ&%.Wn)cR_A*[:kjQXQ_g\^"V>_:G30GW;W1(`KQ7A?HfqaOoS:h9J.qRJ5.D-C^#A&BbsRPd;0-oNe?'A7Xmu[Me#INW$P10Kh]>5OcIM4rEh?F4PHH5GVb5H+(![tcq55+.'[o]R:I?tl>R%U(;4$*?13ro$2.C[Ws-t-3-,d7&a!)epCR=&R=t!uDp*f4+("b.W5[p,7!jcL7Q@"6s/,[Q8O.2dX`EUQR_db;6]t(DpW4]`?Si2+EMLnEqsVpF)#/G)/t8>'C2l;k)_`8E`,YV.HTNf?EO#[k%`e9`Jt<_S,WIg\fcV(i%1Z'DZip&hsmNF]%qZ+Ap2X+rNHE>%_Gh0;bP`fr!8)2/'b6td;[j4Xt;nUAK'm!M(6c\)=4M^IMP2KjZsM:\h'*8>Ltrd'M:T_)i%n"\G>^h1b1R?GYN.tDF\R87<^ar>`l!D/A-,s'qfF9F%>d5!ht=8l2o#Dl1?XY?kC[\3/78\94KGrG;d;qY!'r#ifcIkHtfll5!YU)MI'-Xs.20_..Q9WYB7UbmR'ZE#ueSbNHYOo`2M8^Mnc\40ISHsP,Rs@+\/'scpn)tqGdW,,pKehRNSDb%0X6Y&?%eDIEqu(G[+-)u-icLO$I($6Cjr?o^:qP#_S\9LHECjG>biWai!`#A!FqV$*8A`3eM7F_%gfk>sYO3<-_<4(5P?32c)pHkP5fH$*3>9HE[J6u)mh*s-fXJZ;O3o8IX=BbBna43(gX&&#kN8N::&H^X,R>mL"*\;ArslJ[J;FY=dPZV:abq\jh8m5Bp]MlB,Gc1/4>Y]+Y)^-A-jYDtJcS5st05<]gO7K~>endstream +endobj +25 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 3600 +>> +stream +GauHM?$#!p(4Gq\\5iFc8O,`H"P>S3EL:acld$D[OpBUM)QA9/956mIJ_>hVp-#l2"F+e(7AVa\)l-NlgO8MA\Lj.Opi5]Y+=?r19Li_J\K6a.jE@%'NA'Pfpu%3cC8C$7H"^%1G<3EdVsTYrmC7&[a616Vp%jotG_0-kn+r&/n-1MQ4&3udp)kQV_b\E;7a`,7MknS]hXc-[[lh_0SW;q!FD,380Ag<;VFlU8Zdq.-B#.a61<>AIQ89<5#9&)inIN:&D4OdWhsGa%3cDu2;K$?*e*D;8c@G[m^Q/irERjXDm["%BFErsa(>nYPXul+X'N;KsGIi4+\%*=&khoOXZrZXBeZ>A24+6"1!AB0J[tVkRQfn$&?$s'uY0Ji9>1DN]*h@S\J[IneZ9R$$f",OXWGj0n$,@VunUrMX2FEPr\Kk&1Dj%.dj>nMsaQR?P`1mgub)C@I3Z`1:3m7'H3@9mH0cc)jp0GV#%(f+gV=Tt'OD)>2_s?pQr!Ze0gP$%QYj`?=Op7BPb.IU"rSUVY?A8ec]/)1Z=6k]&#t_nOf"7I@`cE;DSBDI2/!:H8fC4^'P8EOA?'76T&:#Gat@8JWR4o'KG2ApZiN,HlWPP1#M_8*$^Hrf$uf.QJ50$R$afsOG'U;0j"2U?/3W7414(0:<&2^Oag8ira3lM.64iY%m,4"Y!S/u:"Gmsr]DZgPQS/$Ym`P;?"r77s0N\T`G2#r2ZlKu>)+fD"7@(!RJB+]mYW#f4HC9kRMl?9eR7PRWKZPPG-_1`#Z)UoFO;_oh1('I./e\RnM25.sh_%^.CL+do2+t#Lg`Mj@DV_/-445oY_"(m]AB*B3[nWMo8_?pIC/tX]L'[643-7%WHFtjb,".O%4j8]>>,)6b'+,\[64e2D3CO(%9QR3B9-U8uh2^C%q.HC$#gX&Q=!GY]raln<]+SmE-qJMNr(HFm_F6bdZ._EgNlHe?O6@3S?sMcY_QK`YJmqW(Ak[mP`o9(RAC[8adbm"ZA^KlX&r]qQEY27[.g!lloJHA)RcKPf(UY<>Cj@S[?J;hA$4F+>$Yt&^$=Bcl9<.`pd'HQOg+2dn/GktClu<#.:/XNEA_*!h9Nj]jMC=2GVS-t2Y.nO/*T8f?gR\M0uSprH>.0H9WbJ'3FKQN<0n=`R6*JA46&76uf$*FO/TRS$%q_ar[+EGX/hA92[i9dK.]V]aTHBWJ1ck!:O:%4931+V;^983m8;bjg>];>UGPMu&CH"3QR4##.0kEq3+3EM%g=Xo([ol"Y;[a+pA;.^oS=B68V$nRIGS*-1LX$fsK<3YA.S:NTc_I=;iNff2-F1/S1HN_G>X9Z$d`QUOR&AdifRrM69?as-YLXQ@XS:$*o\t/e:`=AMQK&'Opr,0UrG9X:p\Rmm6`W5m5\XM3C$_43WI[HTA:1VQ\)I&R4T^3%(X^:2/2WO/p]89!eAjFrObG-97C-G/-O.LWJhb,KT:20QBQWJKh12q:FO53:PG4?)Y.^2jb04WliIr-nq+&C#6Ku?hO%#8(7edH"8r)WL+ILTo+KfR`7"`dAM(>bed$rCjpY>><85g3+1mXf+4+6Qu)i9ULk3Vc,VoF/B7JsMd`IljF,a@Y0e[\EOfu*RO[U1-@ljiQ:!p(ntTQdWWT0=-C]S@JrFs<26;@$`lWdM;jGq]d%N)YgnOI#c_Ol+=&lmCL7O%L+Ujllf3_Pa:F\^K[dK05'\OZ'))a-sO.0'c7Md.`W21#T.VZJN`(qo."Ve^):WPLAS]&6pV67qr>c."Ac!<_)G%U'oBjHt[5G18]=%=ieg%@(iff"s+hk%5HuEX'Qh=1%GGr1mioY`+LbJ(fm`mT_Z6&R[LV1mY?tX#S)=VhSr67M(fl!hkf,^+$a2@Sh)N8$13tYr-qmW+%\ISQhP@._1:FW7^%HfKk7"0WV'%O#`Sm/7@.=Rr:m;2p'3htQE:B8e.-k(LZ(PA1ncr],2:K^:<)TY*[7)iRiZ.,81$fN$6,PWEY>9bYkh,d5=kP[hfY&9NLG`nmUc)c4l:Z$OfB+PSbq4l47G<3WSZ[K;8rN85fuHRcmdjP4><1Jd&B/drlF7M9g,'FN61l,=Uu9a2henHL0+_rVS7uLJL(_5,H.2TU2%_5#%'dk..au@QrYuh;p`Ec'Vka~>endstream +endobj +26 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 1449 +>> +stream +GauI59lJcG&;KZL'm"J?/Qn80fjYJpPBP\+>rQb]4Tm0HC1M<9OU=P1ao;,Wb)53p>r7<\#S:,Q5A$L4B8/Tpo]PuKGn'7T$'Vh^p]2oRn3]-fjX$EM:[Uu408UY$:Y@GiL.N:YI;kt.pQLu9=mRF`kd=^S&%b?:)eVJ!__cHu5>#UI)FeA6h,N%8n?tY?E#Sp7UgE!UJ&;DlB(HcurV1tJ'EW?#UQg=tk+scQ9rE!G@N1iSnfM!5B$E>"PH7RY./mnVoq-bT*)lT]kl`t'?#``]t-*j7f9cV#16^E4B9hYJq\enC=mg^#4bbN8(8YdP9^=eder,W$7rkT7S9oKOs^hToKuH_pWIYEi0MB62RWFMO;15cIc6)r70AP3IA78.N&MZ%C^B(1PI/d/oLl+it,>QFEcqTb+g'-^LbiP1Ac4HYq0%"Q[ttd0`OT'ro`uekF09!.ul(7.0Q-hBtCejP$m)JNk%F*,JsTP1XZ6gEc%47dVJd[M2G3X9\D*()N0mo8?2S3Wcl-lk.od:lPY=>lSpM3Ar:,Pl(PQ4/TpuXt.>rC/n\M=UL]]$`;/Nb\+^aj;Z7r'c)\ncrd?N'r!D.?kk'RMb7f*"WtJZB+`7"IhW370-oa"$)BG-CXcFshi@o]fD-;Q2q*O($O6g3X#GY>[#[-P,'^RXIth$%QN#[??Pi#'HY@3&+iBEDZIXj&(#36R9bppgfBOE+LN[=!R[uEhXN;i.C"DtDm/?)d'#[tSY5)8fnujod2h.X4VdnIPX<-I!&DW^oio`VZN5=(Jasq7$;9bO6o._X3X;DE7`mBMnI;(dVV93(,#?_rbOSsF@1d5C6:88p>l';ql9g27`./j.2ikb6mE]S12#Asp/PjFJ$T3&,;klro38Q0BS#_%,8mir.(#MW-"(G5*S]95^`Wq=o.D(rC7Wt-4^;r@ha$Zf<,9\MM6\S?gqqLRoeUUmE28qT%n%Rb5*q/BhaRuMSa?endstream +endobj +xref +0 27 +0000000000 65535 f +0000000061 00000 n +0000000143 00000 n +0000000250 00000 n +0000000362 00000 n +0000000567 00000 n +0000000644 00000 n +0000000849 00000 n +0000001054 00000 n +0000001169 00000 n +0000001274 00000 n +0000001480 00000 n +0000001686 00000 n +0000001892 00000 n +0000001976 00000 n +0000002182 00000 n +0000002388 00000 n +0000002458 00000 n +0000002805 00000 n +0000002912 00000 n +0000006445 00000 n +0000007766 00000 n +0000010903 00000 n +0000014425 00000 n +0000015958 00000 n +0000019344 00000 n +0000023036 00000 n +trailer +<< +/ID +[<898daef16c7a4d422afef4572dfaf74f><898daef16c7a4d422afef4572dfaf74f>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 17 0 R +/Root 16 0 R +/Size 27 +>> +startxref +24577 +%%EOF diff --git a/files/dracut/95dot1x-macsec/dot1x-macsec-up.sh b/files/dracut/95dot1x-macsec/dot1x-macsec-up.sh new file mode 100755 index 0000000..2717c7e --- /dev/null +++ b/files/dracut/95dot1x-macsec/dot1x-macsec-up.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# dot1x-macsec-up.sh — initqueue/settled hook. +# Order of operations is load-bearing: +# 1. pull client cert from TPM NV into /run +# 2. start wpa_supplicant (argv[0]=@wpa_supplicant => survives switch-root) +# 3. wait for EAP success + macsec0 +# 4. DHCP on macsec0 (the pre-auth lease, if any, is invalid post-auth/VLAN) +# 5. derive hostnqn/hostid from the cert so dracut's nvmf module can +# connect the NVMe/TCP root namespace over macsec0 +# Idempotent: guarded by a stamp file because initqueue may re-run hooks. + +type getarg >/dev/null 2>&1 || . /lib/dracut-lib.sh + +STAMP=/run/dot1x/.done +[ -e "$STAMP" ] && exit 0 +mkdir -p /run/dot1x /run/wpa_supplicant + +# --- knobs (override on the kernel cmdline) --------------------------------- +NIC=$(getarg rd.dot1x.if=) +# shellcheck disable=SC2010 # /sys/class/net names are kernel ifnames; ls|grep is safe here +[ -n "$NIC" ] || NIC=$(ls /sys/class/net | grep -vE '^(lo|macsec)' | head -1) +NVINDEX=$(getarg rd.dot1x.nvindex=); [ -n "$NVINDEX" ] || NVINDEX=0x01800001 +TIMEOUT=$(getarg rd.dot1x.timeout=); [ -n "$TIMEOUT" ] || TIMEOUT=60 + +info "dot1x-macsec: using interface $NIC, NV index $NVINDEX" + +# --- 0. TEMPORARY test bypass (rd.dot1x=0) ---------------------------------- +# Disables 802.1X + MACsec so the UKI / rootfs / NVMe-oF boot path can be +# validated on an OPEN switch port before the auth layer is brought online. +# The physical NIC is brought up with DHCP directly and dracut's cmdline-driven +# nvmf module connects the root namespace over it — there is no macsec0, no +# supplicant, no TPM cert read. The rest of the boot (rd.nvmf.discover, verity, +# rootovl) is unchanged because it is driven by the kernel cmdline, not by this +# hook. SECURITY: no link authentication or encryption in this mode — lab/test +# networks only. Remove rd.dot1x=0 from the cmdline to restore full security. +if [ "$(getarg rd.dot1x=)" = "0" ]; then + warn "dot1x-macsec: 802.1X/MACsec DISABLED (rd.dot1x=0) — TEST MODE, no link security" + ip link set "$NIC" up + # Plain DHCP on the physical NIC via network-legacy (mirrors how the secure + # path addresses macsec0); satisfies rd.neednet for the nvmf module. + ifup "$NIC" + # Static test NVMe identity (the secure path derives this from the TPM + # cert). Match these on the nvmet target if it filters by host NQN. + if [ ! -s /etc/nvme/hostnqn ]; then + mkdir -p /etc/nvme + echo "nqn.2026-06.org.example.fleet:test-host" > /etc/nvme/hostnqn + echo "11111111-1111-1111-1111-111111111111" > /etc/nvme/hostid + info "dot1x-macsec: test hostnqn $(cat /etc/nvme/hostnqn)" + fi + : > "$STAMP" + exit 0 +fi + +# --- 1. client certificate out of TPM NV ------------------------------------ +if [ ! -s /run/dot1x/client.pem ]; then + if ! tpm2_nvread -T device:/dev/tpmrm0 "$NVINDEX" -o /run/dot1x/client.der; then + warn "dot1x-macsec: tpm2_nvread failed — cannot authenticate" + exit 1 + fi + openssl x509 -inform DER -in /run/dot1x/client.der -out /run/dot1x/client.pem +fi + +# --- 2. supplicant as a root storage daemon --------------------------------- +ip link set "$NIC" up +if [ ! -e /run/dot1x/wpa.pid ]; then + # bash exec -a sets argv[0]; '@' prefix tells systemd's switch-root + # killing spree to spare this process (root storage daemon convention). + setsid bash -c "exec -a @wpa_supplicant /usr/sbin/wpa_supplicant \ + -Dmacsec_linux -i $NIC \ + -c /etc/dot1x/wpa_supplicant-dot1x.conf \ + -P /run/dot1x/wpa.pid" & +fi + +# --- 3. wait for authentication + macsec0 ----------------------------------- +i=0 +while [ "$i" -lt "$TIMEOUT" ]; do + if wpa_cli -p /run/wpa_supplicant -i "$NIC" status 2>/dev/null \ + | grep -q '^suppPortStatus=Authorized' \ + && ip link show macsec0 >/dev/null 2>&1; then + break + fi + sleep 1; i=$((i + 1)) +done +if [ "$i" -ge "$TIMEOUT" ]; then + warn "dot1x-macsec: authentication / MACsec timeout after ${TIMEOUT}s" + exit 1 +fi +info "dot1x-macsec: port authorized, macsec0 up after ${i}s" + +# --- 4. address the secure interface; netroot rides on macsec0 -------------- +ip link set macsec0 up +# Hand macsec0 to dracut's network stack for DHCP + netroot resolution +ifup macsec0 + +# --- 5. derive per-host NVMe identity from the TPM certificate -------------- +# Keeps the image fleet-generic: the host NQN is a function of the machine +# identity already proven via 802.1X, not a baked-in value. +if [ ! -s /etc/nvme/hostnqn ]; then + mkdir -p /etc/nvme + SERIAL=$(openssl x509 -in /run/dot1x/client.pem -serial -noout \ + | cut -d= -f2 | tr 'A-F' 'a-f') + # Adjust the NQN domain/date to your org's convention (must match the + # allow-list on the nvmet target if host filtering is enabled). + echo "nqn.2026-06.org.example.fleet:host-${SERIAL}" > /etc/nvme/hostnqn + # Stable hostid derived from the same serial (UUID-shaped) + printf '%s' "$SERIAL" | sha256sum | cut -c1-32 \ + | sed 's/\(.\{8\}\)\(.\{4\}\)\(.\{4\}\)\(.\{4\}\)\(.\{12\}\).*/\1-\2-\3-\4-\5/' \ + > /etc/nvme/hostid + info "dot1x-macsec: hostnqn $(cat /etc/nvme/hostnqn)" +fi + +: > "$STAMP" +exit 0 diff --git a/files/dracut/95dot1x-macsec/module-setup.sh b/files/dracut/95dot1x-macsec/module-setup.sh new file mode 100755 index 0000000..6e675c7 --- /dev/null +++ b/files/dracut/95dot1x-macsec/module-setup.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# 95dot1x-macsec: authenticate the port with 802.1X EAP-TLS (TPM-held key, +# client cert in TPM NV) and bring up MACsec before netroot is mounted. +# The supplicant is launched as a systemd "root storage daemon" +# (argv[0] = @wpa_supplicant) so it survives switch-root and keeps the +# MKA session — and therefore the link carrying the root fs — alive. + +check() { + require_binaries wpa_supplicant wpa_cli tpm2_nvread openssl || return 1 + return 0 +} + +depends() { + echo "network-legacy bash" + return 0 +} + +installkernel() { + instmods macsec + # tpm_crb/tpm_tis are usually built in; assert anyway + instmods tpm_crb tpm_tis +} + +install() { + inst_multiple wpa_supplicant wpa_cli tpm2_nvread openssl ip setsid + + # TSS libraries that tpm2_nvread dlopens + inst_libdir_file "libtss2-*" + + # Fleet-generic supplicant config + RADIUS CA + inst_simple /etc/dot1x/wpa_supplicant-dot1x.conf /etc/dot1x/wpa_supplicant-dot1x.conf + inst_simple /etc/dot1x/radius-ca.pem /etc/dot1x/radius-ca.pem + + # Hook: runs from the initqueue once the NIC exists, before netroot + inst_hook initqueue/settled 30 "$moddir/dot1x-macsec-up.sh" + + dracut_need_initqueue +} diff --git a/files/etc/NetworkManager/99-dot1x-unmanaged.conf b/files/etc/NetworkManager/99-dot1x-unmanaged.conf new file mode 100644 index 0000000..b321770 --- /dev/null +++ b/files/etc/NetworkManager/99-dot1x-unmanaged.conf @@ -0,0 +1,6 @@ +# The physical NIC and macsec0 are owned by the initramfs-launched +# @wpa_supplicant (root storage daemon). NetworkManager must not touch +# them, or it will spawn a competing supplicant and drop the MKA session +# carrying the root filesystem. +[keyfile] +unmanaged-devices=interface-name:macsec0;interface-name:en* diff --git a/files/etc/dot1x/radius-ca.pem b/files/etc/dot1x/radius-ca.pem new file mode 100644 index 0000000..ca1c3c3 --- /dev/null +++ b/files/etc/dot1x/radius-ca.pem @@ -0,0 +1,2 @@ +# PLACEHOLDER - replace with your RADIUS server CA chain (PEM) before building. +# The build will succeed with this placeholder but authentication will not. diff --git a/files/etc/dot1x/wpa_supplicant-dot1x.conf b/files/etc/dot1x/wpa_supplicant-dot1x.conf new file mode 100644 index 0000000..7a39cda --- /dev/null +++ b/files/etc/dot1x/wpa_supplicant-dot1x.conf @@ -0,0 +1,26 @@ +# Fleet-generic wired 802.1X EAP-TLS + MACsec configuration. +# Identical on every host: per-host identity comes from the TPM +# (key at persistent handle, cert extracted from NV at boot). +ctrl_interface=/run/wpa_supplicant +eapol_version=3 +ap_scan=0 + +network={ + key_mgmt=IEEE8021X + eap=TLS + # RADIUS derives the real identity from the certificate CN/SAN + identity="fleet-host" + ca_cert="/etc/dot1x/radius-ca.pem" + client_cert="/run/dot1x/client.pem" + + # BENCH-VERIFY: TPM key reference method on your wpa_supplicant build. + # Option A (OpenSSL tpm2 provider, direct persistent handle): + private_key="handle:0x81010001" + # Option B (tpm2-pkcs11 with a store regenerated into /run at boot): + # private_key="pkcs11:token=fleet;object=dot1x-key;type=private;pin-value=..." + + macsec_policy=1 # require MACsec + macsec_integ_only=0 # confidentiality (encrypt), not integrity-only + macsec_replay_protect=1 + macsec_replay_window=0 +} diff --git a/files/etc/dracut.conf.d/90-diskless.conf b/files/etc/dracut.conf.d/90-diskless.conf new file mode 100644 index 0000000..330b199 --- /dev/null +++ b/files/etc/dracut.conf.d/90-diskless.conf @@ -0,0 +1,11 @@ +# Fleet-generic initramfs for diskless 802.1X/MACsec hosts. +# Root = erofs image on a shared read-only NVMe/TCP namespace, +# integrity-verified by dm-verity, tmpfs overlay for runtime state. +# Built with --no-hostonly; assert everything the real hardware needs, +# because the build VM only has virtio devices. +hostonly="no" +add_dracutmodules+=" 95dot1x-macsec network-legacy nvmf systemd-veritysetup overlayfs " +filesystems+=" erofs overlay " +add_drivers+=" macsec nvme-tcp nvme-fabrics dm-verity @NIC_DRIVERS@ " +install_items+=" /etc/dot1x/radius-ca.pem /etc/dot1x/wpa_supplicant-dot1x.conf " +compress="zstd" diff --git a/http/ks.cfg b/http/ks.cfg new file mode 100644 index 0000000..dfc4f02 --- /dev/null +++ b/http/ks.cfg @@ -0,0 +1,42 @@ +# Kickstart for the Packer build VM only — this machine is thrown away; +# its disk contents become the diskless root tree. +text +lang en_AU.UTF-8 +keyboard us +timezone Australia/Adelaide --utc + +# Throwaway credential for the Packer SSH communicator. +# MUST match var.build_password in diskless.pkr.hcl. +rootpw --plaintext packer-build-only + +network --bootproto=dhcp --activate +firewall --disabled +selinux --enforcing + +# Simple single partition: we only ship the file tree, not the disk layout +zerombr +clearpart --all --initlabel +part / --fstype=xfs --grow + +bootloader --location=mbr +reboot + +%packages --excludedocs +@^minimal-environment +kernel +dracut-network +openssh-server +wpa_supplicant +tpm2-tools +nvme-cli +cryptsetup +chrony +-plymouth +-firewalld +%end + +%post +# Permit the Packer communicator (image hygiene step disables this later +# if desired; diskless hosts should get real SSH config from your config mgmt) +sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config +%end diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..fc16663 --- /dev/null +++ b/renovate.json @@ -0,0 +1,11 @@ +{ + "extends": [ + "config:base" + ], + "packageRules": [ + { + "updateTypes": ["minor", "patch", "pin", "digest"], + "automerge": true + } + ] +} \ No newline at end of file diff --git a/scripts/build-oci.sh b/scripts/build-oci.sh new file mode 100755 index 0000000..491d329 --- /dev/null +++ b/scripts/build-oci.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# build-oci.sh [sb-key] [sb-cert] [push] +# +# Turns the extracted build artifacts into the fleet release unit: +# 1. ukify: vmlinuz + initramfs + cmdline(roothash) -> fleet-.efi +# 2. sbsign via ukify if a Secure Boot key/cert pair is supplied +# (key should live in CI/HSM -- it is deliberately NEVER inside the +# Packer build VM, which is why this runs on the build host) +# 3. consistency gate: roothash inside the UKI cmdline must match +# verity.json -- a mismatched pair is an unbootable fleet +# 4. builds a FROM-scratch OCI image containing ONLY: +# /artifacts/fleet-.efi (the signed UKI) +# /artifacts/rootfs.erofs (erofs + appended verity tree) +# /artifacts/verity.json +# /artifacts/SHA256SUMS +# Deploy as an initContainer (or k8s image volume) feeding the +# SPDK + nginx serving containers. Pin deployments by digest. +# +# Requires on the build host: systemd-ukify, sbsigntools (if signing), +# jq, and buildah (preferred) or docker. +set -euo pipefail + +ART=${1:?usage: build-oci.sh [sb-key] [sb-cert] [push]} +IMAGE=${2:?image ref required, e.g. registry.example.org/fleet-boot} +VERSION=${3:?version required} +SB_KEY=${4:-} +SB_CERT=${5:-} +PUSH=${6:-false} + +for bin in ukify jq; do + command -v "$bin" >/dev/null || { echo "ERROR: $bin not found on build host" >&2; exit 1; } +done + +UKI="$ART/fleet-${VERSION}.efi" +CMDLINE=$(cat "$ART/cmdline.txt") + +if printf '%s' "$CMDLINE" | grep -q NVMET_TRADDR; then + echo "WARNING: cmdline still contains the NVMET_TRADDR placeholder." >&2 + echo " Substitute the real target address before a production build;" >&2 + echo " the cmdline is immutable once signed into the UKI." >&2 +fi + +# --- 1+2. Build (and sign) the UKI ------------------------------------------- +UKIFY_ARGS=(build + --linux "$ART/vmlinuz" + --initrd "$ART/initramfs.img" + --cmdline "$CMDLINE" + --os-release "@NAME=FleetDiskless@VERSION=${VERSION}@" + --output "$UKI") + +if [ -n "$SB_KEY" ] && [ -n "$SB_CERT" ]; then + command -v sbsign >/dev/null || { echo "ERROR: sbsigntools required for signing" >&2; exit 1; } + UKIFY_ARGS+=(--signtool sbsign + --secureboot-private-key "$SB_KEY" + --secureboot-certificate "$SB_CERT") +else + echo "WARNING: building UNSIGNED UKI (no sb-key/sb-cert supplied)." >&2 + echo " Fine for the lab; a production fleet must sign in CI/HSM." >&2 +fi + +ukify "${UKIFY_ARGS[@]}" + +# --- 3. Roothash coupling gate ------------------------------------------------ +WANT=$(jq -r .root_hash "$ART/verity.json") +GOT=$(printf '%s' "$CMDLINE" | grep -o 'roothash=[0-9a-f]*' | cut -d= -f2) +if [ "$WANT" != "$GOT" ]; then + echo "FATAL: roothash in UKI cmdline ($GOT) != verity.json ($WANT)." >&2 + echo " Refusing to build a mismatched (unbootable) release image." >&2 + exit 1 +fi +echo "==> roothash coupling verified: $WANT" + +# --- 4. Build the OCI release-artifact image ---------------------------------- +STAGE=$(mktemp -d) +trap 'rm -rf "$STAGE"' EXIT +mkdir "$STAGE/artifacts" +cp "$UKI" "$ART/rootfs.erofs" "$ART/verity.json" "$STAGE/artifacts/" +( cd "$STAGE/artifacts" && sha256sum -- * > SHA256SUMS ) + +REF="${IMAGE}:${VERSION}" +LABELS=( + "org.opencontainers.image.title=fleet-boot-artifacts" + "org.opencontainers.image.version=${VERSION}" + "org.fleet.roothash=${WANT}" + "org.fleet.uki=fleet-${VERSION}.efi" +) + +if command -v buildah >/dev/null; then + ctr=$(buildah from scratch) + buildah copy "$ctr" "$STAGE/artifacts" /artifacts + 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 artifacts /artifacts\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 < Release image built: $REF + roothash: $WANT + digest: ${DIGEST:-"(available after push)"} + +Next steps: + - cosign sign the image for supply-chain provenance (the UKI's sbsign + signature is for firmware; cosign is for your deployment pipeline) + - update the fleet-boot Deployment/compose manifest in git to this + version's DIGEST (one commit per fleet release; that manifest is the + single place coupling UKI <-> root namespace) +EOF diff --git a/scripts/extract-artifacts.sh b/scripts/extract-artifacts.sh new file mode 100755 index 0000000..f09e412 --- /dev/null +++ b/scripts/extract-artifacts.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# extract-artifacts.sh +# +# Pulls the diskless deliverables out of the Packer-built disk image: +# vmlinuz -> HTTPS boot server +# initramfs.img -> HTTPS boot server (the fleet-generic image) +# rootfs.erofs -> nvmet target: shared READ-ONLY NVMe/TCP namespace +# (erofs data + dm-verity hash tree appended in-file) +# verity.json -> root hash + hash offset (feeds the kernel cmdline) +# cmdline.txt -> ready-made kernel cmdline fragment for this build +# +# Requires on the build host: libguestfs-tools, erofs-utils (mkfs.erofs), +# cryptsetup (veritysetup), jq. +set -euo pipefail + +QCOW2=${1:?usage: extract-artifacts.sh [disable_dot1x]} +OUT=${2:?usage: extract-artifacts.sh [disable_dot1x]} +# TEMPORARY test toggle: when "true", bake rd.dot1x=0 into the cmdline so the +# UKI boots with 802.1X + MACsec disabled (open-port NVMe-oF boot test). +DISABLE_DOT1X=${3:-false} + +for bin in virt-copy-out virt-tar-out virt-ls mkfs.erofs veritysetup jq; do + command -v "$bin" >/dev/null || { echo "ERROR: $bin not found on build host" >&2; exit 1; } +done + +mkdir -p "$OUT" +export LIBGUESTFS_BACKEND=direct +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +echo "==> Locating kernel and generic initramfs in $QCOW2" +KERNEL=$(virt-ls -a "$QCOW2" /boot | grep '^vmlinuz-' | grep -v rescue | sort -V | tail -1) +[ -n "$KERNEL" ] || { echo "ERROR: no kernel found in image" >&2; exit 1; } + +virt-copy-out -a "$QCOW2" "/boot/$KERNEL" "/boot/initramfs-generic.img" "$OUT/" +mv "$OUT/$KERNEL" "$OUT/vmlinuz" +mv "$OUT/initramfs-generic.img" "$OUT/initramfs.img" + +echo "==> Exporting root tree" +virt-tar-out -a "$QCOW2" / "$WORK/rootfs.tar" +mkdir "$WORK/root" +tar -C "$WORK/root" --numeric-owner -xpf "$WORK/rootfs.tar" +rm -rf "$WORK/root/boot/"* "$WORK/rootfs.tar" # diskless hosts don't need /boot + +echo "==> Building reproducible erofs image" +# -T0 + fixed UUID => deterministic output => stable dm-verity root hash +# for identical input trees. lz4hc: good ratio, cheap decompress; saves +# fabric bandwidth (and MACsec cycles) on every read. +FIXED_UUID="6c10a7f0-1d2e-4f5a-9b3c-000000000001" +mkfs.erofs -z lz4hc -T 0 -U "$FIXED_UUID" --all-root \ + "$WORK/rootfs.erofs" "$WORK/root" + +echo "==> Appending dm-verity hash tree (single-file / single-namespace layout)" +DATA_BYTES=$(stat -c %s "$WORK/rootfs.erofs") +# Align hash area to 4 KiB after the data +HASH_OFFSET=$(( (DATA_BYTES + 4095) / 4096 * 4096 )) +VERITY_OUT=$(veritysetup format "$WORK/rootfs.erofs" "$WORK/rootfs.erofs" \ + --hash-offset="$HASH_OFFSET" \ + --data-block-size=4096 --hash-block-size=4096) +ROOT_HASH=$(printf '%s\n' "$VERITY_OUT" | awk '/Root hash:/ {print $3}') +[ -n "$ROOT_HASH" ] || { echo "ERROR: veritysetup produced no root hash" >&2; exit 1; } + +mv "$WORK/rootfs.erofs" "$OUT/rootfs.erofs" + +jq -n --arg hash "$ROOT_HASH" --argjson offset "$HASH_OFFSET" \ + --arg uuid "$FIXED_UUID" \ + '{root_hash: $hash, hash_offset: $offset, erofs_uuid: $uuid, + data_block_size: 4096, hash_block_size: 4096}' \ + > "$OUT/verity.json" + +# Kernel cmdline fragment for the boot server. Single line: this file is +# consumed verbatim by ukify in build-oci.sh and baked into the signed UKI. +# The device node assumes the root namespace is the host's first/only +# NVMe/TCP namespace; adjust if not. NVMET_TRADDR: set per environment +# *before* the UKI build, since the cmdline is immutable once signed. +DOT1X_FRAGMENT="" +if [ "$DISABLE_DOT1X" = "true" ]; then + echo "==> WARNING: building TEST cmdline with rd.dot1x=0 (802.1X + MACsec DISABLED)" >&2 + echo " open-port NVMe-oF boot test only — do NOT ship this UKI." >&2 + DOT1X_FRAGMENT="rd.dot1x=0 " +fi +printf '%s' \ + "$DOT1X_FRAGMENT" \ + "rd.neednet=1 rootovl " \ + "rd.dot1x.nvindex=0x01800001 rd.dot1x.timeout=60 " \ + "rd.nvmf.discover=tcp,NVMET_TRADDR,4420 " \ + "root=/dev/mapper/root rootfstype=erofs rootflags=ro " \ + "systemd.verity=yes roothash=${ROOT_HASH} " \ + "systemd.verity_root_data=/dev/nvme0n1 " \ + "systemd.verity_root_hash=/dev/nvme0n1 " \ + "systemd.verity_root_options=hash-offset=${HASH_OFFSET}" \ + > "$OUT/cmdline.txt" + +echo "==> Checksums" +( cd "$OUT" && sha256sum vmlinuz initramfs.img rootfs.erofs | tee SHA256SUMS ) + +cat < publish to the HTTPS boot server (versioned dir, + flip a symlink for atomic rollout/rollback) + rootfs.erofs -> expose as a READ-ONLY nvmet namespace; version + rollout = publish new namespace, repoint hosts + cmdline.txt -> kernel cmdline for this build. The roothash binds + the root fs to the boot artifacts: serve via a + signed UKI or signed GRUB config so Secure Boot + covers it, or the verity guarantee is advisory. + +Remember: identity is NOT in these artifacts. Hosts authenticate with the +TPM key at the fleet persistent handle and the cert in the fleet NV index; +the hostnqn is derived from that cert at boot. +EOF diff --git a/serving/.env.example b/serving/.env.example new file mode 100644 index 0000000..6b9c6dd --- /dev/null +++ b/serving/.env.example @@ -0,0 +1,36 @@ +# Copy to .env and edit for your test LAN/bridge, then: docker compose up -d + +# Release being served. Must match the Packer build (fleet-.efi). +IMAGE_VERSION=0.1.0 + +# OCI release-artifact image (built by scripts/build-oci.sh). Mounted directly +# as a Docker image volume — the default artifact source. Pin by digest in +# production, e.g. OCI_IMAGE=git.bitasylum.org/audiobuzz/fleet-boot@sha256:… +OCI_IMAGE=git.bitasylum.org/audiobuzz/fleet-boot + +# Fallback only (Docker < 28 / offline): local Packer output dir, used by the +# commented bind-mounts in docker-compose.yml. Ignored when image volumes work. +ARTIFACT_SRC=../artifacts/0.1.0 + +# --- This serving host on the boot LAN ------------------------------------- +BOOT_IFACE=eth0 # interface facing the booting client / test bridge +SERVER_IP=192.168.50.10 # this host's address on BOOT_IFACE (DHCP/DNS/HTTP/NVMe) +GATEWAY_IP=192.168.50.1 +UPSTREAM_DNS=1.1.1.1 + +# --- DHCP scope ------------------------------------------------------------ +DHCP_RANGE_START=192.168.50.100 +DHCP_RANGE_END=192.168.50.200 +DHCP_LEASE=12h + +# --- DNS names ------------------------------------------------------------- +BOOT_DOMAIN=fleet.lab +BOOT_HOST=boot.fleet.lab # HTTP boot server name (UKI) +# NVMe/TCP discovery name. Put THIS value in the UKI cmdline as NVMET_TRADDR +# (extract-artifacts.sh) so rd.nvmf.discover resolves here per environment. +NVME_HOST=nvme.fleet.lab + +# --- NVMe/TCP target ------------------------------------------------------- +SUBNQN=nqn.2026-06.org.example.fleet:rootfs +TARGET_PORT=4420 +SPDK_CPUMASK=0x1 # one polling core is plenty for a read-only boot fs diff --git a/serving/dnsmasq/Dockerfile b/serving/dnsmasq/Dockerfile new file mode 100644 index 0000000..e6ed6e5 --- /dev/null +++ b/serving/dnsmasq/Dockerfile @@ -0,0 +1,6 @@ +# Minimal, reliable dnsmasq image (Hub dnsmasq images vary; build our own so +# the version is pinned and the entrypoint is predictable). Config is passed +# entirely as --flags from docker-compose.yml. +FROM alpine:3.20 +RUN apk add --no-cache dnsmasq +ENTRYPOINT ["dnsmasq"] diff --git a/serving/docker-compose.yml b/serving/docker-compose.yml new file mode 100644 index 0000000..15f23a3 --- /dev/null +++ b/serving/docker-compose.yml @@ -0,0 +1,113 @@ +# ============================================================================= +# Diskless fleet serving stack (lab) +# +# Stands up the three things a diskless host needs to network-boot: +# dnsmasq - DHCP (hands UEFI HTTP-Boot clients the UKI URL) + DNS +# http - nginx serving the signed/unsigned UKI over HTTP +# nvmeof - SPDK nvmf_tgt serving rootfs.erofs as a READ-ONLY NVMe/TCP +# namespace on 4420 (design doc §5, §7) +# +# This matches docs/diskless-fleet-design-v2.pdf, with two LAB simplifications: +# * HTTP (not HTTPS) boot - no TlsCaCertificate to enrol into firmware +# * 802.1X / MACsec disabled - pairs with the rd.dot1x=0 test UKI +# Do NOT use as-is for a secured fleet. +# +# Networking: all three services use the host network stack so dnsmasq can see +# DHCP broadcasts and SPDK gets a clean NVMe/TCP path. The Docker host must sit +# on the same L2 as the booting client (e.g. the test bridge / boot VLAN), and +# BOOT_IFACE / SERVER_IP must be that interface and its address. +# +# Artifacts are mounted straight from the FROM-scratch OCI release image using +# a Docker "image volume" (type: image) — the compose analog of the k8s +# image-volume source in the design doc (§7). The image's /artifacts dir is +# mounted read-only; nothing in it executes, so FROM scratch is fine. Pin by +# digest (OCI_IMAGE@sha256:…) for immutable, content-addressed releases. +# Requires Docker Engine >= 28.0 and a recent Compose. On older Docker, use +# the commented bind-mount fallback in each service (ARTIFACT_SRC = local +# Packer output dir), or extract once with: +# id=$(docker create $OCI_IMAGE:$IMAGE_VERSION); \ +# docker cp $id:/artifacts/. ./artifacts/$IMAGE_VERSION/; docker rm $id +# Private registry: docker login git.bitasylum.org first so the image pulls. +# +# Usage: +# cp .env.example .env # then edit BOOT_IFACE / SERVER_IP / DHCP range +# docker compose up -d +# ============================================================================= + +services: + # --- DHCP + DNS ------------------------------------------------------------ + dnsmasq: + build: ./dnsmasq + network_mode: host + cap_add: + - NET_ADMIN # DHCP raw sockets + - NET_RAW + restart: unless-stopped + command: + - --keep-in-foreground + - --log-dhcp + - --log-queries + # Bind only to the boot interface so we don't fight the host resolver. + - --interface=${BOOT_IFACE} + - --bind-interfaces + - --except-interface=lo + # ---- DNS ---- + - --domain=${BOOT_DOMAIN} + - --no-resolv + - --server=${UPSTREAM_DNS} + # NVMe/TCP discovery address used in the UKI cmdline (rd.nvmf.discover): + # resolve the per-environment DNS name to this serving host. + - --address=/${NVME_HOST}/${SERVER_IP} + - --address=/${BOOT_HOST}/${SERVER_IP} + # ---- DHCP ---- + - --dhcp-authoritative + - --dhcp-range=${DHCP_RANGE_START},${DHCP_RANGE_END},${DHCP_LEASE} + - --dhcp-option=option:router,${GATEWAY_IP} + - --dhcp-option=option:dns-server,${SERVER_IP} + # ---- UEFI HTTP Boot ---- + # Firmware sends vendor-class "HTTPClient"; we must echo it back (opt 60) + # and hand over the UKI URL (opt 67). IP in the URL avoids a DNS round-trip + # during boot. client-arch 16 = x86-64 UEFI HTTP. + - --dhcp-vendorclass=set:httpboot,HTTPClient + - --dhcp-option=tag:httpboot,60,HTTPClient + - --dhcp-boot=tag:httpboot,http://${SERVER_IP}/fleet-${IMAGE_VERSION}.efi + + # --- HTTP boot server (serves the UKI) ------------------------------------- + http: + image: nginx:stable + network_mode: host # listens on :80 -> http://${SERVER_IP}/fleet-.efi + restart: unless-stopped + volumes: + - type: image + source: ${OCI_IMAGE}:${IMAGE_VERSION} + target: /usr/share/nginx/html + image: + subpath: artifacts # mount only /artifacts from the image + # Fallback (Docker < 28 / offline): bind the local Packer output dir: + # - ${ARTIFACT_SRC}:/usr/share/nginx/html:ro + # For HTTPS boot (production), terminate TLS here with the boot-infra CA's + # cert and switch the dhcp-boot URL above to https://. Left HTTP for the lab. + + # --- NVMe/TCP target (serves rootfs.erofs, read-only) ---------------------- + nvmeof: + image: spdk/spdk:v24.09 # pin to a tag you've tested + network_mode: host # NVMe/TCP listener on ${TARGET_PORT} + privileged: true # hugepages + IPC_LOCK/SYS_ADMIN for SPDK + restart: unless-stopped + ulimits: + memlock: -1 + volumes: + - /dev/hugepages:/dev/hugepages + - type: image + source: ${OCI_IMAGE}:${IMAGE_VERSION} + target: /artifacts # -> /artifacts/rootfs.erofs for spdk-setup.sh + image: + subpath: artifacts + # Fallback (Docker < 28 / offline): - ${ARTIFACT_SRC}:/artifacts:ro + - ./spdk-setup.sh:/spdk-setup.sh:ro + environment: + SUBNQN: ${SUBNQN} + TARGET_ADDR: ${SERVER_IP} + TARGET_PORT: ${TARGET_PORT} + SPDK_CPUMASK: ${SPDK_CPUMASK} + entrypoint: ["/bin/bash", "/spdk-setup.sh"] diff --git a/serving/spdk-setup.sh b/serving/spdk-setup.sh new file mode 100644 index 0000000..cc7960e --- /dev/null +++ b/serving/spdk-setup.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Configure SPDK nvmf_tgt to serve the diskless root filesystem as a single +# READ-ONLY NVMe/TCP namespace (design doc §5, §7). +# +# rootfs.erofs is the erofs image with the dm-verity hash tree appended in-file; +# we expose the WHOLE file as one namespace, and the host splits data/hash with +# systemd.verity_root_options=hash-offset= from the kernel cmdline. Integrity is +# enforced end-to-end by dm-verity on the host; the target serves it read-only. +set -euo pipefail + +ROOTFS="/artifacts/rootfs.erofs" +SUBNQN="${SUBNQN:-nqn.2026-06.org.example.fleet:rootfs}" +TADDR="${TARGET_ADDR:-0.0.0.0}" +TPORT="${TARGET_PORT:-4420}" +CPUMASK="${SPDK_CPUMASK:-0x1}" + +[ -s "$ROOTFS" ] || { echo "ERROR: $ROOTFS not found/empty — is ARTIFACT_SRC populated?" >&2; exit 1; } + +# Binary locations differ across SPDK images; fall back to the common path. +TGT=$(command -v nvmf_tgt || echo /usr/libexec/spdk/spdk/build/bin/nvmf_tgt) +RPC=$(command -v rpc.py || echo /usr/libexec/spdk/scripts/rpc.py) + +echo "==> starting nvmf_tgt (cpumask ${CPUMASK})" +"$TGT" -m "$CPUMASK" & +TGT_PID=$! +trap 'kill "$TGT_PID" 2>/dev/null || true' EXIT + +# Wait for the RPC socket to come up. +for _ in $(seq 1 30); do + "$RPC" framework_get_config >/dev/null 2>&1 && break + sleep 1 +done + +echo "==> creating TCP transport" +"$RPC" nvmf_create_transport -t TCP -u 16384 -m 8 -c 8192 + +echo "==> exposing $ROOTFS as bdev 'rootfs' (4 KiB blocks)" +# -r opens the backing file read-only where supported; harmless if ignored. +"$RPC" bdev_aio_create "$ROOTFS" rootfs 4096 -r \ + || "$RPC" bdev_aio_create "$ROOTFS" rootfs 4096 + +echo "==> creating subsystem $SUBNQN (allow any host — lab/no-auth)" +"$RPC" nvmf_create_subsystem "$SUBNQN" -a -s SPDKLAB0000000001 -d FleetRootfs + +echo "==> adding namespace 1 (read-only)" +# Newer SPDK accepts --read-only on the namespace; fall back if unsupported. +"$RPC" nvmf_subsystem_add_ns "$SUBNQN" rootfs --read-only \ + || "$RPC" nvmf_subsystem_add_ns "$SUBNQN" rootfs + +echo "==> adding TCP listener ${TADDR}:${TPORT} (also serves discovery)" +"$RPC" nvmf_subsystem_add_listener "$SUBNQN" -t tcp -a "$TADDR" -s "$TPORT" + +echo "==> SPDK serving $SUBNQN over tcp/${TPORT}; rd.nvmf.discover=tcp,${TADDR},${TPORT}" +wait "$TGT_PID"