Initial commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
@@ -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 }} \
|
||||
.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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}",
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
Executable
+38
@@ -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*
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"updateTypes": ["minor", "patch", "pin", "digest"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Executable
+118
@@ -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
|
||||
Executable
+112
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user