Initial commit

This commit is contained in:
Audiobuzz
2026-06-12 01:46:29 +00:00
commit d383655875
21 changed files with 1557 additions and 0 deletions
+16
View File
@@ -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"
}
+126
View File
@@ -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-<short-sha>.
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 }} \
.
+140
View File
@@ -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=<registry>/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-<ver>.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.
+18
View File
@@ -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.
+134
View File
@@ -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/<version>/` gains `fleet-<version>.efi` (the signed
UKI) alongside `rootfs.erofs`/`verity.json`, and the build produces the
release-artifact OCI image `<oci_image>:<version>` containing only
`/artifacts/{fleet-<version>.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 `<oci_image>:<version>` (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-<version>.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=<from verity.json> \
systemd.verity_root_data=/dev/nvme0n1 \
systemd.verity_root_hash=/dev/nvme0n1 \
systemd.verity_root_options=hash-offset=<from verity.json>
```
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 <tree>`) 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.
+204
View File
@@ -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-<version>.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-<version>.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:<hex>"
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<wait>", "linuxefi /images/pxeboot/vmlinuz inst.text ",
# "inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg<enter>",
# "initrdefi /images/pxeboot/initrd.img<enter>", "boot<enter>"]
boot_wait = "5s"
boot_command = [
"<up><wait><tab>",
" inst.text inst.ks=http://{{ .HTTPIP }}:{{ .HTTPPort }}/ks.cfg",
"<enter><wait>",
]
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}",
]
}
}
+231
View File
@@ -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>[<E9`CZhP@h$dI#=WJ>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<ZorAPf\$B(;3sK$\Zq")?NT\!^]F>^_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>]>_%<VpS>"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='<h?SErYdR';E"f2TMZ6Y#ae_6UVq6C0jap9g4S)][]%'7l-MhkZ?&d?#`_`d<0J$ej`m08kM4bAV>[<$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[N7JAOm<A25P%M.84)$Iq,XO;%u":sD-Ah!JpckOnic<Fk0%Q>6]^Y&_?_+n**kP`iC[jFlalf/QVP7X^#P?Mr1I;1!!Pq&;r?,@sJ*]?DOHA%2^jCrG.0htfZQb5#+X+^gV[,0an;-P,elC0\uCE:,\F#`gD<HR`5S/id]mVt/0`=ZVs>l$#$=+!Gm#U<*5V6atGcb\3^gqmGQE)C#-\MdjSS=tnsS:eSZhSXoE:5TZ7[8j'3H2dE*f;90PMIZ<>`GLMhBgaA.k[DijaN<W4lO<IsMDg;88(*rcp(':cH)16%*Lf#RhhQ*paB\`H+[c&?H7dZP?J.`Crr`c!ht/o(D$sND_H(A/lS)K.rL,k>"O8Vu3m!:C]VILA=hoC`<kJEs[b8hG/pH/7\pR0>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<X4hQS4SF5aCNOE]s.=LK^39,J4$0bGn6t+u=lLK;EEnP*Il0S=H>?B!1k%a#6L[uJ]c8O?n\]i@i[-UT*&%@FX\ZIX9,[-hkJNB.4(Q)G(<Y[2<?YkSdTTq`'N)nOZHqb[:-5'dAGgMs"I/_'dPOaMmdV5WNc,?YJ.<P^SQVEjIj0dQmCjf=hgpn+!mHX'*pDh;Z%6\^+4]6T-`J,W+(35n'DnDRMfBG)\*!YMA>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>h<Ze`)K9KH/BO6%ko'sSGY.b`qe^K<jTIF"@2Jp;5__Q5T/j9_Ykf:'K"W1YH$m>05SdpbVSo*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>SK<KM@Ggf0HmpC?"Y'%'I2BFdEM(q3TPbmP-<IU!RY@%BuVV`]Ing"KHE`j3$M=sg\1>J=B(D+c-df=L(iI#ScRW<lq[nW3JBD3*<+AToS@qb)=YDn?Z5F^.Whmkg44]2j#ce[N))fNj'OHAHp#SKY^`L;o]Mj3EaqEG3(fLUc6n)</hOB]s@CiNaMn%D*jpWX!p\4+OAPJ7$r36F4VT714f.*gD?;n`HPes2k8gd=22Bhs+\~>endstream
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<`<kbF)>KcsL_5s$e;#.lSfbr8U4V@31bY[Z#&[$V/5.L(OEd#bJ&<+@0<^>UnBY10AM1q151[,q9:a>('q&?Bd9<X8),-W1o$/7g(DBT:p^u?&]]4]r>mh: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-1<jenYLJ!q^p'TtAb0)Jr*2rQGQCsn'r2-]qap4/<\i*A"Mo.mtY<@5*,U&9A#W$EQQm%O$C"n3$&Y!j[sXA:';`^lW<iL:E#\4-G1'F,2]DK`.Bd?gYIK,1<A7_hnnqdTt9r$k6^$K^F/bO*A]RbMWg?`lSDXKYJaJ<>Q&cMQr!-[<Bl+):/[g^p1Fgq\8=@=QugGrE(I<AkZHMcj*CU=Z#o79QfETr`g!<6jIO)J68dk&a.BcJBJfV6)89nZlCe<\G7KB::Lm_!t-<TKT,Kd"r5QJVt<YYCp'J%#<,ipRtU8</raCBu-VMBn._XeJ"D@V\4@#9t'<>"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<9cDP<q$MAW@56&Q\gYQ#^p;V,GGJS/Lc)O^qm!7t.9%oi<@AA4g:VZ#Yaa4QT^;)g\5@P5&rB9:"[`]_U12h4E3K<EH-PEj(2[^+dH[DoBnD9M@kI?`52\->r",eFLBE~>endstream
endobj
21 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3045
>>
stream
Gat=->BAQ1&UsJXfU4qQ8M-Ai;,kumM;oj/DX8>j<mo8MSIA5E&rq5F5fqDHIs\p6.?X2+A0'?5;oA`q`U:2oa<;M;Nqjd[7'@t[qT^8)V+F:>Dtu6#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[NrB2<PkR?:l4g1W[+Kr8k)NEOG5:2s@KK)5Q2qt1T^E2@0pI@Y"]^K#kB,ljZVkFo1_pBEH@i=)BI=dnggBVW.C(peO(^(>u$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?A9Tig9ub<MGN,uR<k<(P[rlaPoK,X%@el*$_o]KV=u,*c)]"s5"[7C)^^bb?&D=/bdH-^Qb+$WWr15_]rZLbbCV6UK07.q!^1I==Y[:h>r$]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=<LE4M=GfmKfmIf@4Jo%k]&LVf\[E=15kek(S8,p@$G5u''hPX&_MmX]IfSrUaFdiL+I'o<O,q?=Ze2LV>+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<Z0VuuSK!fb-=UtPR4>[+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(UBPk<CDH%>A8LIQi+"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_<R"5[h,$ZjsSWF0fZUrH1tl9NYKFOc$B0]NZSbNpfmi4!ncb=o6J)2"[)DA236!Cfu_'?]a.!8iag"r^o4RYjBDM&]-C__8MR.+-sq#%fJYbD6m:)q+dK%mq7r2s"5;apQC%\!8[0AL<V9jl:CPABk"#-Mcr#.09,0lMe"\RdJe6X60t&I8^6@bN=2!R.r?C?h_`!f).!.ZcC?!jC6cfo[4gMI:'"d.l3n<#@9?+K.NIdWtLsjotXD\\(+U#f1rBT/!DDk6>A\C3'/:)V%YZ1(_B;YM.Ng*Yi^?]6kqQp=(lq!(5Ps%R(GJWk+'E8G)r'hTD8;3O$!C(g,/7N:AL0Yp^<kL*l6\M[X/[7;mnC>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<PE(suFsM))T`cjauqo%PI:U:sIGP!''gY*UBCbXE;mq5ZE3%"n8s2/-_QZ?F6*NhNaLoF#`/k&R?BoOYIGn`NMa&`%Ot\+A<5CaJjG8,TG"Z"`U$+C2bX_KgSoSY;_:0YSg!RP)aYmLfqs.fOnu`lk^s,;S:sre(gk'd&A&*ZHUdpKHeSb#gPnh-:XKdsF9NXLk,=7nOZ$n46L=J8l6GRS_S4\rF#U@K*:$)D^l#7AQf=s4I@J>;;BH?S49A63-HhrqYHC5n0?)g2I9+a'$@:<m!SiD'j9JBR;\W_5ln="SXHFk9OukD_A!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<j+AEYjbdu"Bk^DX2a+qE\Mfg\caNcnIL^[ihhQSK#OM$TDU7YYS:M-PB3KK1a"%edS-aH_F"-&lk.H5$j7K!_3Muan3/6a_U#Z6'q9!aj#p-rtQ5+A%#BNc3TNL>]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:<Z#Mo;u>I#[]HE>cTuoEO"mq]4^lW[c]cnc0!!2rVp6kgFqEA2a)BPG-`Vlo\'`.K?"e/fQ!Rf@u#Z:%'&#cciUqiMks`Cp'8Y)<ZY,o@Omd)aU8]c@(Z5ON$oJeo!JVe7g)"e!@pR(DC+65?.0m;h7Y7bc3c0G?/NAjIH"D[3W6YTStCt#nY]SnjPk_**:EY!AM)UtA[L91`5\Qf=R]Q&=^V6I*5a_t8&3B;$.dEdqNkbrG"'/%ksPZ3L+I9gbJVH\/+em_^.T!n2VpXePinur&4C7EUT=Hek#n2sX)B0+;M5!SA9l"g0fNkhd;CWVhADE^(sbt/&FtW/@g1sX?@lc@,X&ZV>gsPW,RcCijnR\pJE63XGD&p&QkI?4a@/JXM7>\O_u9LL(^j5i^aWdsH8U3"gm$7\2YZC<W6^S*XK?<lIHrOjH#K"oc>Sa$^G[B4#<n`JE"^>&ME(p0/b=K[(9+2C'CGr5)`)uWjtP+lOo@n4ku36sXjc+P3]AF!=*M,-4Fpnc`-/Yi:#kW?LC4]81lRT?7\277Hk]#pOpJb/,>osW:<U$PUmS"BiHHBI?=ffn2)&3/@hB]3p5'M>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</N`KEQ<F35%S`u#8PJ\\rn<HB6RPNQ'o-\j#W1DCC3;.2$`9W/g+=<Wf7.*4qj@>#Z5;sI[[^DTGCWGZopD7_f`=3$Z:f0Ca\_+a`LVX6?P_*'tVQQg=KWM1QZTh2\="+7e2ft)O93CY3+=N$$aI)Y-ChB<WIJ^lk]CsTFI7,'Y&7n>:aj\r/knhgOfEf@l_m^7r<kD]-gr:g.)is"7%-;FM2O#0R%Pt8"\CZDhQg=Y,bjW\I/gIq<D03Oe*Xl^=?n#[m^XI$TE-*O_0Xa,7"+0-];A&W@%g^VQb9%6-;r=WtM"KNU__&V@a/j\gr6acpTE?nO?)6`6%D)7T_#ms3VC*7p,$3oF\d,5n[!Dk(j4<BNkLO-q4M[[0SK?lW*^NT37l/p.[91N(!.aW#E!7D`oR[c/8Un"d<:m%3PKZaN,\F.&73Ajc8:?*n8a/F4o!LICI$f9C'S5Kck]'O-t.?dMq_09]B;AR`X]$aIO957j:VQmYo^jd-PnZCptR,bT4R&u4m:unRSniT?B8l<T-"'H;8AeC2BM2`KL^fK-oKds\2A5Fa]bCG9kZ4Pea(%$\5\<';cj<KXE,[(Z@LgZcEr$:JAW1SYf$`PQ1@DN;.dd`%g7-,*6m?NT*mI</ueGQHQ-h8g*m<G-<M1)*O&;/t,]m5.>: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%(CReZKQfp<HnFC*H3E/C[URI@N0LV0B"e'q$V2\YX(/=9E&)UFU`a[r5YsR-hkicMB5QcKeipKON^lE*NM#!u8R:XM^R#"`[<Yc6M$Wt4*.?E@c!+F6H%ZX_d.q0-@+jtPa84\Abl/IZ9)h5YQd':;b0Ooe*:@7J2\*Iu$o`mo,.1#!q-+Y;Wn?OQ(^=@7X^Qr#KB"Ph,+G<2Q-P>lV*Q6^Ui.`3&hr^<7rsHfZad*+M9%Ad>ZEPjb,m&;HJuN^:"&G@SD=VA8$8"!\X(ZVs:ZGW+FH1IX<-;%F#91jPfVNp?oGf<C"\(sAX'#`37.H`0F_k^.A7`U>%*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]<CrY"&]M)dKaJfA1f>.<PZ&m+>bc<Q;`(F7Z7A<EB%l\^1lH?T.+\!51Sr(>#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<e)Gr9AT(0lR,";i>)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<UnSJufh4I2&qKO9XMN=MT#q+tj)G8"pOQ9:4oW4qeUG&;)O?SZt]<W[inh^eADo^i\,)duCCc2ufh>=T-Gg[0dnh9r,K8Xd`P&/+:W0iYIq<CbZU-n/&Jo*NSBD^lj&HcKR3Q0e#T.WZOXAG)JDJ"V?\23,1K%/o-T0*d@<^s2Bbq#L2i@WN%7pdpQ4qZ4qkDV7,aF7N7?n5ig/$3'JOOCLYNS;dWAp1"mA*C,lr-#Sl?!D$nd'=CoFlf]XDB<'mV&%]or,)ru:b,g2>Lo;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<k$*:-f`fV8b;kJ<.]'RqpAr-+XX_Tcb;92EAsOc).C#[eNpd!:gFT,;!V-sLX3!'Q`O<(UQg$$VD#S`[H5gm;D*IoRGQC7pUXle.$@W(3fq*f>.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%LHC<KZ,pBI)VTnm"Kc`bVHc#Sc[h!W2p-a$MOp+0SF2Z"#]3O4M^<Fq]-#:o&NqX&,gh%h`*g@lLh+N`e']M\_Pl\_0P<;$Qq^WiL5RYh7,D\RU1+HQE&?s[YCi-+78Qm.tGL<71ateY.&)Q6\jmnr16O)-qq9B#B4_$CuL:kJ_CulB](_*j+&bHS'W'"_.J?O*4*@aX1<V^eJZlBXBh`hXEOeK`,c:oh#Mt%Xm%XQ9)oDY+.SLL^5^DFJ.<Ud>Qa^$P_TN6o@k.2r^Mm1asnhI[(CIH#SZ,]<t]-aq+t%7pHTVa1?l,"%)fA_Cb_m-RNoN\#,9d!mBd&ai\L*)S9U6s!6ud"a_NAO"#2f^^qpIFd*]V%@'?^~>endstream
endobj
24 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3294
>>
stream
Gatm<?$"aY(4FN4\<"d%U."9.hVB&<8_QjQeZk?\+#MH?8C0[t&deVBd<,)sn$rEI.;msTgQ#nt>9n1&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$"9g9I9r<YUe_P-Sb<@.7$f#VWI06E[IMkZpTIjt$dq6sWI%F;B0F,qDI+,>jOOJ]F.k7R(,aDSE>a8>Yic-iM*P(rC9Ec5cqD+Boeb]<c?1UY]s5oQ^p_-ZTg2#q(=fL=//Qms&(knJ-ScqAMO<0dlV<k2m&TsW5P[;d,RCi$AC=[.*lPhlT(G0:`PSNB[M^Br"[=reEITtRhfB(&Km<1U-H<gEK3iB3t=?H-%16^s#c'>]#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=NddI<sJU9oi4gY"BY.4[#7GNDl_OX7%(l60k0BK<Yj;q1&D256?th[R3oEP'2hAi"r.bi8s^>BK01E]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%:>d2<?3B!b\N=1@LQMsp3?1`l/mpR=Alui.$XsnSLB+/%OZJ&#-'Qc;Z&+^V9Ue[?$8MR/O@!&&6KpNNO3-'`^[&qBjj!,c">7rJ[Wf0/H<f5Ld-1W^E"5TOF>5qV*JaC.Vea[cc#9P>$Xo\rJf/YaLjB)M`-VFu,jZ"/<a4ef7fV'<rogW_jrkNZqGRE1Ya>-&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<Cc.WfE*3c%uRP[FTYkI^i,p!bhR"a)j6X@Id[mVUf*=)iNeHj?=Jr_po5PHZJ(AGN'kT]6MXJqA@Dj84&7g3oT+V^LsUdn6KIZuP1Th"_A)&tq::a\aBuM+]$#LJLG6k;!"#e0r5XhZh*W+!nmiTWUnJpsNmU]E'+3\3"Xa:NVX6;!8`Y$_g;Y602+_9S``PQA)?S@d2S@"kr*Z5KMIp^7Q6\ZW"%aWmZ`3UbSo0PDj>?qb1ZNjdH#J9+4KBL/AFO?5[0kLW!^&\3+7ohEI2i.r0@6CIWgfX7P*n1Oe`r!/>?bMF(M#_#pTJN=A*go#SboO`S8_WnK2GNtrTT:\T5nlp'%lXV78`XFS=L<ht5]2DS^SDE,B`J5LgcKY@OX_o\MRdHi$BcrQQ%V10:RW.7qMGrA1i0&Y,=3"[P0#-&$4,><@l<C5tqmPbUu/Fd9X^`5/BDC&?HSQm&]Ym&'.'j4f'Q:AP/R<hIbP-g\p?4[bb-#qqak^0\:Q6IPtY$h%qM8IN5D4neP1JDVl&I2=-ODD=mr:\Jn[DrQij+GfbNWQ&W8sn`!46D5>bg_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]<pGlQ\g"pH>R:I?tl>R%U(;4$*?13ro$2.C[Ws-t-3-,d<h@$Su^@VhS,i1N.!&Y:ibt/^a,sJ>7&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?E<R)\B,EiW@>O#[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#ue<O$!m(lNYe+QpYGR^hKRG.P?G6.84FHKDFL!cZh;f%ooJRtdqa)U*LCFWh:rU1XXm_D>SbNHYOo`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<Ti/_c3qr*b5_jPWKg5*n.fA2g."^gB*FJBW_iq8,q0'W'bc`^(]G5%rj!oOkC2T@?X1BqnOps;0?mV7b">^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[In<nNIpP1mTJd4#Nf1_qRjL`VSiu_q'![I<cF\6%<Y^4.6/3A$L%.r-<"Y4=I>eZ9R$$f",OXWGj0n$,@VunUrMX2FEPr\Kk&1Dj%.dj>nMsaQR?P`1mgub)C@I3Z`1:3m7'H3@9mH0cc)jp0GV#%(f+gV=Tt'OD)>2_s?pQr<N2%<MGYE<Fo2XQZ9?&6CB^5i*<Uqh'IH\U9$oPUq&Co84+d21UW=ta&iI`Y_Paq`D_DRPS1^n5(.NKC:/n_TqUGip"B-o%rfV;k99f6^A`4urpB;_Fo1.gr;,aJ,nfbtKn&Z>!Ze0gP$%QYj`?=Op7BPb.IU"rSUVY?A8ec]/)1Z=6k]&#<YaPW0;WnUY+D]]F%Dp!Df78%eh\!A"dOmKl%Gf:4)MOKrRUMc4)VIms#*mZX-=ft;\eCL"7VLS]r4b&71nYC7LXlhQK]TAQnH:3=I@-iI=cFXha1WVJktCGkj+VpT#htt6"ZO_EH<=U7lK`/p'Ril1VHThX4pXb2@rO+UkhPgR>t_nOf"7I@`cE;DSBDI2/!:H8fC4^'P8EOA?'76T&:#Gat@8JWR4o'KG2ApZiN,HlWPP1#M_8*$^Hr<rA#GONp,fJ3DF'+bSrC%!@J_REY^P6"W#_p"5Y#)NbFmGfP9C$2<a<,H8=1OoOW7TN+uXpQaI`RrU!2uNA2:6,_:G%XPA1l)0@_!Ve.>f$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&<?7E(1ND&G)2pOK_[,:]6l@do48f)(E6g@DmCa;Z\9ClDg=QS*tk1-<YL!T(&<tDM;r*UZST(;`VMga4ApBQ+]\c^T<VEZ%[K/W`SB4-Xs1[mK>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<<pW9BZID&M),?JMIIn"oeGXsh[h'BL:C7ncm8@!fpXpJc?plj0%JO/uBVG(=3RK:8]rgo+''KSWNJ_I'0#kp`?T[7SXo,pVJ2Yc@a]T@9![hT/=MFaAD:o=)8jA@&A>.`pd'HQOg+2dn/GktClu<#.:/XNEA_*!h9Nj]jMC=2GVS-t2Y.nO/*T8f?gR\M0uSprH>.0H9WbJ'3FKQN<0n=`R6*JA46&76uf$*FO/TRS$%q_ar[<eD1Y`CMg%E'/^a>+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<N98ok`#HfNak(NR6Oc2P<naMBR$_]2lI[8`*qamnI$!SrUgkPW=#rU*UGEAaHj(&1&((@?unSplpE&f8s&W`NH8*BQ^UPSFBCFj-'iK)E"t?Ruk&OK5?2P!HDssg]n*L*PNbD&,$/7IP\BE5GhCqdg_'o+#$A&S.bWWd&:hITZk^:a#c+?5Pd.H>-G/-<CAr+.9%"qdLi%c#$$CKKaga\5e/jZI(j:b)W7.EKSgJgpY.=RgCN.&[&;[o,<D#b0E2qoIns`rU6)Aa+Z1+^P$F+e>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^<d'E:begEm+DVrk548Iq,3L+9'n[QqI0O7?\/5_aqA)q,1jd=!@ZI)1Z-N!cDLDo=u).3CU+.3bVosm-0en0Fdf9"5gbWf=h3'6J#:D[6bb1$&9lX1%J?V'c]m>):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<q)a=3_Z+eB\+Kp0No-<Ao;Zq6m8!.91/.Y,J@on3(7n8s.SdJ@aK[@]YS-O9LX>:B8e.-k(LZ(PA<Lt.;C?'9.j-mQ[Fa]7UfA_kQ]YUWN&!kVIU(@iFblfId]8OF8aZS$/XL)c^2"CM2n4Aa,gW!JFKB&nhT8P<;&RT/G$F72^Mi7a_5d1)`f+'eT`7lLQO]2.+TjBleOQ.PW^m83ihjP=rgYACp%D^EN1ec(4WH+o*54?DDK[>1ncr],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,'<CSL)'%/9/_/7bp3'H=3P+,-*g$EstmF>FN61l,=<UJo((0fN;d,MrNThtoop\@&MO-?:`+ok)8:SEQ5VCqt6(S3E<!)6+@nig<B>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./<M["^<4I7k<@?$)0*U4#]pVJX#$(!3Y4MOJf](q.+ji\<3E&):T3l;`@=$jCt]IS!-X$l1+O.(fhr>mnVoq-bT*)lT]kl`t'?#``]t-*j7f9cV#16^E4B9hYJ<f*l0]pB7J2C%P%s&LNTHsV/l\df<>q\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+<mQbdYaBaDApZ\Uc,"ORn.";@hiU?.=tF/P3"H?mRD*Y[:(n$0$)GQ.'/gTN,[u0?WGX#a:SXE.1U`78?C+CY`V,V3@(-O9kHICY6)"@YXX1FJO>`7"IhW370-oa"$)BG-CXcFshi@o]fD-;Q2q*O($O6g3X<o+EQ!JWhi&b;?cm5npLZa?h8[h]t0R?M#`:p2$O$?qtXcT>#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?<Qi%PUkeO4+#hGCW8GG970h")"?D/J8prN<r14TD:o`(<@=g%;-_`S3nlW92rM'Pdd=Q:0u'6,5!*uSr"+q:&pYoTjX]7('/ao7Ooe@cfg,@/i?(:,fgF0BDb%5T/QQ.a<Vk1"rmF-hOUtT;L#h@BPR4^6<QCZ-D^^#K)-RAKFY/PP?(ZrM,/!+=]nYJ+5DZ-=Jc~>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
+113
View File
@@ -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
+38
View File
@@ -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
}
@@ -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*
+2
View File
@@ -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.
+26
View File
@@ -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
}
+11
View File
@@ -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"
+42
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
{
"extends": [
"config:base"
],
"packageRules": [
{
"updateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true
}
]
}
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env bash
# build-oci.sh <artifact-dir> <image-ref> <version> [sb-key] [sb-cert] [push]
#
# Turns the extracted build artifacts into the fleet release unit:
# 1. ukify: vmlinuz + initramfs + cmdline(roothash) -> fleet-<version>.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-<version>.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 <artifact-dir> <image-ref> <version> [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 <<EOF
==> 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
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# extract-artifacts.sh <qcow2> <output-dir>
#
# 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 <qcow2> <output-dir> [disable_dot1x]}
OUT=${2:?usage: extract-artifacts.sh <qcow2> <output-dir> [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 <<EOF
Artifacts in $OUT:
vmlinuz, initramfs.img -> 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
+36
View File
@@ -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-<version>.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
+6
View File
@@ -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"]
+113
View File
@@ -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-<ver>.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"]
+54
View File
@@ -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"