Commit Graph
204 Commits
Author SHA1 Message Date
Audiobuzz 68dee79832 docs/TODO #18: record nss-mdns/chromium regression + fix; kiosk re-verified, image 1.8 GiB → 755 MiB
The '#18 render should be safe' call on the nss-mdns drop (5cc72cc) was wrong: it
bricked the kiosk on trim-rpm-48dc3e1 (chromium hard-Requires nss-mdns, so the 2d
remove cascaded chromium + its GUI stack out → 500 MB browserless image, flashing
cursor). Fixed in 2bc2daf (keep nss-mdns, mask avahi daemon; final build guard
asserts the kiosk stack survived). Re-verified booting into the kiosk on the 820;
container image measured 1.8 GiB → 755 MiB (~58%).
2026-07-04 02:24:57 +00:00
Audiobuzz de57a7de11 Merge branch 'main' into optimise-ram 2026-07-03 21:53:18 +09:30
Audiobuzz 2bc2dafb1c kiosk: stop nss-mdns removal from cascading chromium out of the image
Root cause of the trim-rpm-48dc3e1 'flashing cursor, no kiosk' + mysteriously
small (~500 MB) image: the 2d hygiene step ran 'dnf remove -y nss-mdns', but the
EPEL chromium package HARD-Requires nss-mdns (Chromium uses .local mDNS hostnames
for WebRTC ICE candidates). So dnf removed chromium as a dependent package and
then reaped its orphaned GUI stack (avahi, at-spi2, atk, adwaita, chromium-common
...) — a ~300 MB drop. The shipped image had no browser, so kiosk-session died at
the ${BROWSER:?} guard before ever starting weston: a bare KMS cursor.

The earlier nss-mdns commit checked what nss-mdns NEEDS (avahi) but not what NEEDS
nss-mdns (chromium).

Fix:
- Keep nss-mdns + avahi (satisfy chromium's Requires); instead MASK the avahi
  mDNS daemon/socket so nothing listens on :5353 — that was the real
  attack-surface concern, and nss-mdns is only an NSS module (no daemon needed).
- Add a FINAL GUARD at the end of 2d: assert chromium/chromium-common/weston/mesa
  are still installed in the final image, else fail the BUILD. A missing browser
  must brick the build, never the fleet. Catches any future silent removal
  cascade, oscap package_*_removed rule, or autoremove.
2026-07-03 12:21:22 +00:00
Audiobuzz 12043d3444 stig: add overlay to fapolicyd watch_fs — actually guard the overlay root
Bench-verified the getattr allow (620a604) alone did NOT close the exec gap: with
the fapolicyd_tmpfs module installed (AVC gone, fapolicyd active, permissive=0), a
NOVEL non-RPM binary in /tmp still ran. Root cause: the diskless root is overlayfs
(stat -f / -> overlayfs), but the shipped watch_fs is
ext2,ext3,ext4,tmpfs,xfs,vfat,iso9660,btrfs — no 'overlay'. fapolicyd marks mounts
by fs type, so it never marked the overlay and intercepted no exec on / at all.
The exec guard we keep fapolicyd for was effectively OFF at runtime on every
diskless host.

Fix: the 2c fapolicyd step now appends 'overlay' to watch_fs (idempotent) so
fapolicyd watches the overlay root. Trust is path-based (integrity=none) so overlay
paths resolve against the rpmdb trust normally; the fapolicyd_tmpfs .te still
supports fapolicyd statfs'ing the overlay's tmpfs upper. Deviation #15 updated.

This turns app-allowlisting ON for the whole root at runtime for the first time.
BENCH-VERIFY HARD: boot + kiosk render + admin SSH + sudo + TPM enrolment all still
work (nothing legit untrusted), a novel /tmp binary is now BLOCKED (EPERM), no new
AVCs. enable_stig=false is the escape hatch if it regresses.
2026-07-03 11:56:13 +00:00
Audiobuzz 48dc3e11ae Merge remote-tracking branch 'origin/main' into trim-rpm 2026-07-03 20:14:51 +09:30
audiobuzz 8815fe0618 Merge pull request 'nvmeof-mounting' (#15) from nvmeof-mounting into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/15
2026-07-03 10:44:13 +00:00
Audiobuzz f882ddae1f docs/TODO #6: targeted-connect test-path bench-verified (0bfe872 mounts one image)
0bfe872 builds, deploys, and the host now mounts ONLY its own version's image
instead of connect-all'ing every served version — the targeted 'nvme connect -n
rd.nvmf.nqn' works. Each host = 1 controller on its own subsystem, which is what
lets old versions drain to 0 and spdk-loader auto-reap. Still to confirm: the reap
payoff (migrate a host, watch its old version drain + reap), and the secure path's
new step-6 connect over macsec0.
2026-07-03 10:43:24 +00:00
Audiobuzz 480d926a98 trim: fix SIGPIPE (exit 141) in the initramfs-largest-files diagnostic
853ae20's diagnostic did 'awk … | sort -rn | head -25'. head closes the pipe
after 25 lines, sort dies of SIGPIPE (141), and step 2's set -euxo pipefail turns
that into a build failure (the exact trap the lsinitrd '| grep -q' comment warns
about). The kiosk step's rpm-manifest '| head' is safe only because that step
runs plain set -e, no pipefail.

Fix with the codebase convention: sort into a file, then head the file — no live
pipe for head to break. (Bonus: the crash log confirms omit_drivers worked —
amdgpu/radeon/nouveau kmods are gone from the top-25; the new biggest initramfs
item is GenuineIntel.bin CPU microcode at 22 MB, a future trim candidate.)
2026-07-03 07:48:09 +00:00
audiobuzz 5ac03fa8b4 Merge pull request 'ci: registry-gc — read branches with the Actions token, not PACKAGE_TOKEN' (#14) from registry-gc into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/14
2026-07-03 07:45:39 +00:00
Audiobuzz 51d9dc0888 ci: registry-gc — read branches with the Actions token, not PACKAGE_TOKEN
The dry-run failed with HTTP 403 on the branches API: PACKAGE_TOKEN is a
package-scoped token (registry push/delete) and can't read repo branches. Split
by capability: branch reads now use the automatic Actions token (github.token,
repo read via 'permissions: contents: read'); package list + delete keep
PACKAGE_TOKEN. Also replaced the bare 'curl -f' failures with a get() helper that
reports the HTTP code + a token-scope hint, so an auth problem can't look like
'no results' (which would otherwise skew the reconcile).
2026-07-03 07:43:24 +00:00
audiobuzz 09a8fdd444 Merge pull request 'ci: add registry-gc — prune orphan container images for deleted branches' (#13) from registry-gc into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/13
2026-07-03 07:38:03 +00:00
Audiobuzz 0d725af716 ci: add registry-gc — prune orphan container images for deleted branches
Reconciliation workflow (schedule + workflow_dispatch): lists live branches and
every container version across the four images this repo builds (fleet-boot,
fleet-ipxe, fleet-dnsmasq, spdk-nvmf) and deletes dev/branch builds whose branch
no longer exists, via the Gitea Packages API. Not event-driven, so it catches
branches deleted any way and never depends on a 'delete' webhook.

Safety (per request — never touch release tags or latest):
- Only tags ending in the dev commit-sha suffix '-<7 hex>' are ever candidates.
  Release/version tags (1.2.3, v1.2.3, spdk v24.09, dnsmasq main-<sha>) and
  'latest' never carry that suffix, so they're structurally out of scope; there's
  also an explicit 'latest' skip. Verified by simulation across representative
  tags incl. spdk's <ver>-<branch>-<sha> form and pre-release semver.
- Keeps any image whose sanitized branch is still live (+ always 'main'); branch
  sanitization mirrors the build workflows.
- Fail-safe: aborts before deleting anything if the live-branch list can't be
  fetched (0 branches), so an API/auth blip can't trigger mass deletion.
- Conservative matching: a suffix collision only ever causes a KEEP.

DRY RUN by default: workflow_dispatch previews unless dry_run=false; scheduled
runs dry-run until armed via repo variable REGISTRY_GC_ENABLE=true (so a
scheduled dry-run can be eyeballed first). Uses the existing PACKAGE_TOKEN
(write:package). shell: bash (runner defaults to sh).
2026-07-03 07:33:46 +00:00
Audiobuzz 620a60401f stig: ship fapolicyd_tmpfs SELinux module — close overlay exec-guard gap
fapolicyd is the app-allowlisting exec guard for the writable RAM overlay
(dm-verity covers only the read-only erofs lower). It statfs's mounts to decide
which to watch, and tmpfs is in its default watch_fs. This image's root is
systemd.volatile=overlay, so statfs("/") returns the overlay upper — a tmpfs —
and the shipped policy has no 'fapolicyd_t tmpfs_t:filesystem getattr' (no
ordinary host roots on tmpfs). Under enforcing the getattr is DENIED (audit:
dev=tmpfs ino=1 name="/", permissive=0, seen 2026-07-03 on comet-beacon-54),
so fapolicyd can't fully mark the tmpfs mounts (/, /tmp, /dev/shm) and its guard
over the writable overlay is incomplete — a real exec-guard gap, not just SIEM
noise.

Ship a minimal local allow module (allow, not dontaudit — we want fapolicyd to
see the tmpfs), compiled + semodule -i'd in 2c-STIG right after the chrony one.
Registered as deviation #15.

BENCH-VERIFY: the AVC is gone on an enforcing boot AND fapolicyd blocks a non-RPM
executable dropped in /tmp (proves the guard now covers the overlay), trusted
kiosk/appliance binaries still run, and the verity hash is reproducible across
two builds (semodule rebuilds the policy store).
2026-07-03 07:18:01 +00:00
Audiobuzz 8e105c8bf1 docs/TODO #18: kiosk render bench-verified on VM + 820 (i915 firmware confirmed)
f59eb79 now fully bench-verified: the fleet SPA renders on both the VM
(virtio-gpu) and the 820 (i915), so the i915 DMC/GuC firmware loads on real
hardware and the firmware keep-list is validated end-to-end. The two later
commits (nss-mdns drop, omit_drivers + keep-list tightening) don't touch i915,
so render should be safe, but still need their own build+boot.
2026-07-03 06:58:52 +00:00
Audiobuzz bf0c61a025 docs/TODO #18: record f59eb79 built + booted successfully
The ~800 MB trim (glibc langpacks en-only + linux-firmware strip to the i915
keep-list, run pre-dracut) builds and boots — the boot/root path survives it
(firmware 678->147 MB, initramfs bundles only the keep-list). Notes still-open
checks: kiosk RENDER on real i915 hardware ('booted' != 'display up'), and that
the two later commits (nss-mdns drop 5cc72cc; omit_drivers + keep-list tightening
853ae20) are NOT in f59eb79 and need separate bench-verify.
2026-07-03 06:55:52 +00:00
Audiobuzz 853ae20e68 trim: omit unused GPU drivers + tighten firmware keep-list (TODO #18)
First trim build confirmed firmware 678 MB -> 147 MB, but the initramfs came
out 97 MB and is now kernel-MODULE-bound, not firmware: --no-hostonly +
plymouth's drm dep instmods every GPU driver (amdgpu.ko etc.) though the fleet
is i915-only.

- omit_drivers += amdgpu radeon nouveau (90-diskless.conf): drop the discrete-GPU
  kmods (amdgpu.ko is the biggest single module in the initramfs); keep i915 +
  virtio-gpu/bochs for the splash. Shrinks the initramfs/UKI pulled over PXE on
  every boot.
- Firmware keep-list tightened: drop mellanox/ (mlxsw_spectrum SWITCH-ASIC blobs,
  not NIC fw — mlx5 NIC fw is on-card) and narrow intel/ to just ice (drop
  ish/sof/ibt sensor-hub/audio/bluetooth, none loaded at boot per the audit).
- Add an 'initramfs largest files' diagnostic (sort lsinitrd.txt by size) so the
  next build shows what dominates the .img.

BENCH-VERIFY: kiosk still renders on the 820 (i915 kept); watch the new initramfs
largest-files log line for the post-omit size.
2026-07-03 06:36:32 +00:00
Audiobuzz 0bfe872828 nvmf: targeted per-version connect (rd.nvmf.nqn) instead of connect-all
Hosts connected with 'nvme connect-all', which attaches EVERY subsystem in the
discovery log. Each host then mounts only its own by-UUID verity namespaces but
leaves a live controller on every other served version's subsystem. Observed via
spdk-release list: more controllers than workstations, each box on every version
(1/3/3/1/3 across 5 versions for ~3 boxes). Beyond wasted connections
(O(hosts x versions)), it defeated the rolling-update drain/reap: no version ever
reached 0 connections while any host was up, so spdk-loader's auto-reap could
never fire and old versions accumulated without bound.

Fix — targeted connect to this build's subsystem only:
- extract-artifacts.sh bakes the per-version SUBNQN (already in verity.json
  .subnqn, matched by the serving side) into the signed UKI cmdline as
  rd.nvmf.nqn=. NQN is version identity, not per-host state, and the cmdline is
  already per-version + immutable-per-signed-UKI, so invariant #1 holds.
- nvmf-connect.sh (test path) uses 'nvme connect -n <nqn>'; legacy UKIs without
  rd.nvmf.nqn fall back to connect-all with a warning so they still boot.
- dot1x-macsec-up.sh (secure path) gains an explicit step-6 targeted connect over
  macsec0 (after it's addressed, per invariant #2), replacing the delegation to
  the dracut nvmf module's discovery connect-all.

Now each host = 1 controller on its own subsystem; versions drain to 0 when their
hosts migrate, so auto-reap works and connections scale O(hosts). Requires a
rebuild + re-sign (cmdline change, invariant #6). BENCH-VERIFY both paths;
confirm the dracut nvmf module isn't also connect-all-ing on the secure path.
CLAUDE.md invariant #2 + TODO #6 updated.
2026-07-03 06:14:08 +00:00
Audiobuzz 5cc72ccac3 trim: drop nss-mdns (+ orphaned avahi) — media-independent mDNS resolver
Investigating avahi/samba/rabbitmq in the image: ldd on libavformat.so confirms
samba (libsmbclient), librabbitmq, librist, libsrt and libzmq are all DT_NEEDED
by FFmpeg's libavformat-free — removing any breaks libavformat and thus future
HTML5 media in the kiosk, so they stay. avahi is the exception: it's absent from
libavformat's links and 'rpm -e --test avahi' shows its only reverse-dep is
nss-mdns (the mDNS/.local NSS resolver).

The fleet resolves via DHCP/DNS (boot.fleet.lab), never .local, so mDNS is dead
weight and a network-listening service unwanted on a locked-down kiosk. Remove
nss-mdns; dnf's clean_requirements_on_remove reaps avahi/avahi-glib/avahi-libs as
orphans. Belt-and-braces alongside install_weak_deps=False (which already drops
nss-mdns if it came in as a Recommends).
2026-07-03 05:55:00 +00:00
Audiobuzz f59eb7942a trim: run firmware strip pre-dracut so the UKI initramfs shrinks too
Follow-up to the linux-firmware strip (prev commit put it in the 2d hygiene
block, which runs AFTER the dracut build — so it only shrank the rootfs erofs,
not the initramfs). Move the strip to step 2, before the dracut build: with
--no-hostonly, plymouth's drm dep instmods a broad DRM set and dracut bundles
each module's firmware into the initramfs (amdgpu being the classic UKI
bloater). Trimming first means dracut never sees it, so both the rootfs erofs
AND the UKI initramfs shrink. Keep-list is unchanged (i915 + intel/ + mellanox
+ intel-ucode), so the fleet splash GPU (i915) and ice DDP firmware the
initramfs actually needs are retained; dracut just warns 'firmware not found'
for the removed drm modules and continues.

Add a post-build diagnostic (initramfs .img size + bundled firmware, expected
i915 only) so the initrd win is measured in the build log, not estimated.
2026-07-03 05:36:51 +00:00
Audiobuzz 9464e7fc2a trim: slim image ~800 MB — glibc langpacks en-only + linux-firmware strip (TODO #18)
The 2b rpm-size manifest revealed the biggest packages aren't the kiosk but
base bloat: linux-firmware 678 MB and glibc-all-langpacks 228 MB (vs chromium
370 + llvm 129). The uncompressed erofs is read over NVMe/TCP on every boot,
so this is fleet-wide dead weight + attack surface.

glibc: swap glibc-all-langpacks (every language) for glibc-minimal-langpack +
glibc-langpack-en. ~220 MB; image is English-only.

linux-firmware: a live fleet audit (modinfo -F firmware over loaded modules +
sysfs/lspci driver bindings on the real 820-class hosts and the VMs) proved the
only firmware any host loads is Intel i915 (GPU). Every NIC is firmware-free
(e1000e reports on-chip NVM, not a fw file; virtio_net loads nothing), so the
catastrophic missing-NIC-firmware case is off the table. Keep i915 + intel/
(ice DDP insurance) + mellanox + intel-ucode; strip the ~600 MB of foreign
vendor blobs (amdgpu/nvidia/radeon/ath/qcom/...). Remove iwl*-firmware (Wi-Fi,
~83 MB) on a wired-only fleet. Strip-in-place since the package can't be
removed without losing i915; du reports the result in the build log.

BENCH-VERIFY: kiosk still renders on the 820 (i915 DMC/GuC load) — the one real
risk; widen the keep-list if any host has a non-Intel GPU or an ice/mlx5 NIC.
2026-07-03 05:23:49 +00:00
Audiobuzz 67932517fb Merge remote-tracking branch 'origin/main' into trim-rpm 2026-07-03 14:28:18 +09:30
Audiobuzz 28c4ca80f6 Merge remote-tracking branch 'origin/main' into optimise-ram 2026-07-03 14:27:46 +09:30
audiobuzz 81c10b6e7a Merge pull request 'tpm-enroll' (#10) from tpm-enroll into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/10
2026-07-03 04:57:08 +00:00
Audiobuzz db0bb28ebd docs/TODO #11: Dell 820 TPM enrolment bench-verified on real hardware
Test-path enrolment (rd.dot1x=0) succeeded end-to-end on the Dell 820 — the
hard case that previously found the un-clearable OEM platform NV at 0x01800001
and silently bypassed enrolment every boot. Confirms BOTH the NV-index move
(0x01800001 -> 0x01800100) and the signing-key handle move (EK slot -> 0x81000100)
on a physical TPM, not just the Proxmox swtpm: ensure ECDSA key -> CSR -> broker
fingerprint -> approve -> leaf cert -> NV write all run on the metal.

Mark #11 in-progress ([~]). Still to verify: the enrolled cert authenticating
EAP-TLS on the secure 802.1X/MACsec path (this run was test-mode), and
tpm2_nvreadpublic on other fleet hardware models to confirm 0x01800100 is free
fleet-wide.
2026-07-03 04:55:08 +00:00
Audiobuzz 796dfe9f4e reduce overlay RAM + kill .pyc AVC churn (TODO #16 item2, #17)
Two fixes from the booted-host overlay RAM audit (2026-07-03). The writable
root overlay (systemd.volatile=overlay, tmpfs) held 124.5 MiB of runtime
writes; almost all of it is runtime-regenerated state, not real fs deltas.

1. fapolicyd db_max_size 100 -> 50 (TODO #17). /var/lib/fapolicyd/data.mdb was
   100 MiB fully allocated (~80% of the overlay). fapolicyd's LMDB trust DB is
   rebuilt at boot onto the overlay, so db_max_size is a fixed RAM reservation.
   Sed it to 50 before the fapolicyd-cli --update rebuild. 50 was fapolicyd's
   long-time upstream default (covered a full workstation rpmdb), so it has
   headroom for this smaller image. Reclaims ~50 MiB.
   BENCH-VERIFY (STIG-critical exec guard): boot + kiosk + admin SSH + sudo +
   custom-binary exec must work (undersize -> MDB_MAP_FULL bricks exec).

2. .pyc invalidation fix (TODO #16 item 2). erofs -T 0 zeroes mtimes, so
   Python thinks every baked .pyc is stale and rewrites it into the overlay at
   runtime (wasting RAM + throwing lib_t write/unlink AVCs that flood the SIEM).
   Observed trigger: dnf-makecache.timer (~10 min post-boot) re-importing the
   dnf Python stack. Fix both ends: (a) compileall --invalidation-mode
   unchecked-hash over /usr/lib*/python3.* at the end of 2d (mtime-independent
   .pyc, no rewrite, also more reproducible); (b) mask dnf-makecache.timer next
   to the existing rhsmcertd mask (removes the trigger). Firewalld enablement
   (#16 items 1 & 3) still open.
2026-07-03 04:03:36 +00:00
Audiobuzz c0a08438b2 kiosk: disable weak deps + add package-footprint audit
The kiosk dnf transaction was measured at 229 pkgs / 1.0 GB. Nearly all
of the non-core packages are Recommends: a locked-down single-URL kiosk
never uses flatpak + its ostree/bubblewrap sandbox runtime, tracker-miners
(a filesystem indexer — dead weight and a threat surface), the pipewire/
pulseaudio audio stack, or libproxy-webkitgtk4 -> webkit2gtk3-jsc (an 8.9M
second browser engine dragged into a Chromium kiosk for libproxy's PAC
runner).

Add --setopt=install_weak_deps=False to cut them, and an rpm -qa size
manifest to the build log so the next CI build reports the exact new
footprint (turning 'seems unnecessary' into data for the next trim round:
gstreamer/media codecs, or mesa-dri-drivers if the GPU path is abandoned).

The immovable floor is chromium + chromium-common + llvm-libs (mesa sw-GL)
+ gtk3/adwaita + libicu, all hard deps. BENCH-VERIFY the kiosk still
renders with weak deps off (watch for a missing font or libva).
2026-07-03 03:27:58 +00:00
Audiobuzz d9d2b50bfb stig: fix chrony SELinux .te — use raw module syntax, not policy_module() macro
The build failed: checkmodule rejected `policy_module(chrony_dhcp_dispatcher,
1.0.0)` with "Building a policy module, but no module specification found".
policy_module() is a refpolicy m4 macro expanded only by the refpolicy Makefile
(/usr/share/selinux/devel), not by checkmodule. Use the raw module-policy
declaration `module chrony_dhcp_dispatcher 1.0.0;`, which is exactly what
checkmodule -M -m parses. require{} + allow rule unchanged.
2026-07-03 02:53:57 +00:00
Audiobuzz 6fe623c727 tpm-enrol: move NV cert index off OEM-occupied 0x01800001 → 0x01800100
The Dell 820 kept "finding a cert" and bypassing enrolment despite repeated BIOS
TPM clears. tpm2_nvreadpublic revealed 0x01800001 on that hardware is a FIRMWARE
platform index (platformcreate|policydelete|writelocked, 70 bytes — not our
cert; siblings at 0x01800003/4 + 0x01810000). platformcreate survives TPM2_Clear
and policydelete needs the platform's secret policy, so it's permanent and
unusable by us — the blind check false-positived on it, and enrolment could
never write there anyway. Same class of bug as the EK-handle collision.

Move the NV cert index default 0x01800001 -> 0x01800100 (free on the 820, owner
range, overridable via rd.dot1x.nvindex): cmdline (extract-artifacts.sh),
fleet-enrol -nvindex, the dracut hooks, bench scripts, CLAUDE.md invariant #1,
and design/SSP docs. The signing-key handle (0x81000100) is unchanged.

Harden WriteNVCert: refuse a platformcreate index with a clear "pick a free
owner index via rd.dot1x.nvindex" error instead of a confusing undefine
failure. Regression simtest/TestWriteNVCertRefusesPlatformIndex; NV roundtrip +
ReadNVCert tests moved to 0x01800100. All go tests + packer validate pass.
2026-07-03 02:51:38 +00:00
Audiobuzz 33de300af8 docs/TODO: add #16 — enable a locked-down firewalld (defense-in-depth + STIG passes)
Captures the decision to re-enable a CONFIGURED firewalld (currently removed),
the required .pyc/-T0 root-cause fix (compileall --unchecked-hash), the 4 STIG
waivers it converts, and the load-bearing NVMe/TCP-survives-firewalld bench gate.
2026-07-03 01:31:03 +00:00
Audiobuzz 4a8143d0bc build: remove + mask firewalld (kickstart excludes it; weak-dep reinstall caused AVC flood)
http/ks.cfg excludes firewalld (-firewalld) and sets firewall --disabled — the
fleet's security boundary is the network (802.1X/MACsec + switch); the host
firewall is a documented OFF item (stig-deviations #4). But a provisioner dnf
pulled firewalld back as a weak dep (Recommends) and it starts by default. Its
Python then tries to rewrite site-package .pyc bytecode, which the reproducible
erofs -T 0 mtime reset makes look stale, so firewalld_t gets write/unlink AVCs
on lib_t that flood the SIEM.

Remove firewalld in 2d hygiene (weak dep, nothing hard-needs it) and mask it as
a backstop against future weak-dep reinstalls. No firewalld => no daemon, no
AVC, matches the kickstart intent.
2026-07-03 01:22:20 +00:00
Audiobuzz 835ae7bc24 stig: ship SELinux allow module for chrony DHCP-NTP dispatcher (stop chronyd AVC)
The NM dispatcher 10-chrony-dhcp-ntp runs `chronyc reload sources` to load DHCP
option-42 NTP servers. That chronyc runs in the confined custom-dispatcher
domain (NetworkManager_dispatcher_custom_t), so its reply socket carries that
label and the shipped policy has no rule for chronyd_t to sendto it. Under
enforcing, chronyd does the reload but its ack is denied — chronyc reports
failure and the AVC floods the SIEM (seen 2026-07-03; permissive=1 only because
the box was booted enforcing=0).

Ship a minimal local allow module (files/stig/chrony-dhcp-dispatcher.te):
  allow chronyd_t NetworkManager_dispatcher_custom_t:unix_dgram_socket sendto;
Built + semodule -i'd in the 2c-STIG provisioner (checkpolicy installed if
absent), with an install assertion. `allow` not `dontaudit` — it also lets the
reload ack succeed so chronyc returns cleanly. Documented as
docs/stig-deviations.md #14 (not a rule waiver — a local functional allow).

BENCH-VERIFY: enforcing boot shows no AVC, chronyc reload returns 0, a
DHCP-handed NTP server appears in `chronyc sources`; and re-check verity-hash
reproducibility (semodule rebuilds the policy store, like the semanage step).
2026-07-03 01:16:26 +00:00
Audiobuzz 0e139f5346 serving: make the SPDK nvmeof target version-agnostic (stop recreating it on IMAGE_VERSION bump)
The nvmeof (spdk-target) service carried a versioned bootstrap mount
(type: image, source ${OCI_IMAGE}:${IMAGE_VERSION} -> /artifacts) plus an
IMAGE_VERSION env. Bumping IMAGE_VERSION therefore changed the service config,
so `docker compose up -d` recreated the container -> nvmf_tgt restarted ->
every connected host's NVMe/TCP root dropped at once. That is precisely the
fleet-wide outage the long-lived-target design exists to prevent.

Remove the /artifacts image-volume and IMAGE_VERSION env from nvmeof so its
config no longer depends on the version — compose leaves it running across a
bump. Bootstrap and rolling updates are already fully covered by spdk-loader
(stages the current release into ./spdk/releases and adds it online via
JSON-RPC); spdk-target.sh's seed block is guarded, so it just no-ops when
/artifacts isn't mounted. Verified nvmeof no longer references IMAGE_VERSION
(only spdk-loader does); compose YAML validates.

BENCH-VERIFY: bump IMAGE_VERSION + up -d with a host connected -> nvmeof
container not recreated, host root survives, loader adds the new version.
2026-07-03 01:07:16 +00:00
Audiobuzz 86a9839cc4 fleet-enrol: content-aware NV check (-check-only), not blind "index defined"
The enrol trigger keyed on "is NV 0x01800001 defined/readable", so leftover
state (an earlier tpm-provision run — bench-seen on the 820: "cert present —
normal boot" with no real identity) or random bytes (tpm-selftest) silently
suppressed enrolment. Now content-aware:

- tpmkey.ReadNVCert reads the full NV contents (chunked).
- classifyNVCert (TPM-agnostic, unit-tested): parses X.509, checks validity
  window, and when a real fleet CA bundle is present verifies the cert chains
  to it. Only a valid fleet cert = enrolled.
- New `fleet-enrol -check-only` (no network/no key-gen) exits 0=valid cert,
  20=enrol needed, 1=error. The full enrol path uses the same classification
  instead of NVPresent.
- The dracut hook calls -check-only as the single gate, replacing the blind
  tpm2_nvread (and its now-dead TPM2TOOLS_TCTI export). tpm2_nvread stays in
  the initramfs for the secure path (dot1x-macsec-up.sh).

Lab fallback: the shipped radius-ca.pem is a placeholder (not a cert) ->
loadCAPool returns nil -> chain-less "parseable + unexpired" acceptance, so
reboot-converge doesn't loop; production replaces radius-ca.pem with the real
fleet CA for strict chaining.

Tests: classifyNVCert table (valid/expired/garbage/foreign/no-CA) + simtest
ReadNVCert roundtrip across the 512B chunk. All pass; packer validate clean.
2026-07-03 01:02:25 +00:00
Audiobuzz 4d6f038e3c tpm-enrol: move signing key off the EK slot 0x81010001 → 0x81000100
Bench (Proxmox vTPM, host cygnus-cruiser-99) confirmed via tpm2_readpublic that
0x81010001 — the handle our design used for the fleet signing key — is the
TCG-standard RSA Endorsement Key slot: the object there is the swtpm-provisioned
EK (restricted|decrypt|adminwithpolicy, RSA-2048/AES128CFB, canonical EK
authPolicy 837197…, EK cert at NV 0x1c00002). Most firmware/swtpm TPMs occupy
that slot, so EnsureKey could never create our key there — it errored reading
the EK as ECC and enrolment died before the CSR/POST.

Move the fleet signing key to 0x81000100, a non-reserved owner persistent
handle (clear of EK 0x81010001/2 and SRK 0x81000001/2). Threaded through:
  - fleet-enrol -handle default + new rd.dot1x.handle= cmdline knob (baked by
    extract-artifacts.sh, overridable via SMBIOS)
  - files/etc/dot1x/wpa_supplicant-dot1x.conf private_key handle
  - the dracut hook, bench tpm-provision.sh / tpm-selftest.sh
  - CLAUDE.md invariant #1 + design/SSP docs + TODO #11
NV index 0x01800001 (owner range) is unchanged.

EnsureKey hardening: refuse to evict any restricted-decrypt object (EK/SRK) as
a safety net — never clobber the TPM endorsement/storage identity (also
protects the future EK/AK attestation path). Still self-heals a genuine stale
signing key. New regression TestEnsureKeyRefusesToEvictProtectedKey; the
stale-key regression and handle constants moved to 0x81000100.

Design note records the IDevID (0x81020001) option for the later EK/AK
attestation work. Follow-up items logged: content-aware NV presence check
(verify the cert chains to the fleet CA, so leftover/foreign NV can't suppress
enrolment) and a de-enrol/TPM-reset procedure.
2026-07-02 12:19:59 +00:00
Audiobuzz 4c55ed4b26 docs/TODO #11: record stale non-ECC key bench finding + SELinux hand-run caveat 2026-07-02 11:43:31 +00:00
Audiobuzz 63cd4049ff fleet-enrol: self-heal a stale non-ECC key at the enrol handle
Bench boot failed with "fleet-enrol: ensure key at 0x81010001: key at handle
is not ECC: did not contain eccDetail (selector value was 1)": a real host had
an RSA key persisted at 0x81010001 (left by an older tpm2-tools/openssl
provisioning path), and EnsureKey fatally errored trying to read it as our ECC
signing key — so enrolment died before the CSR/POST and the host fell through
to a normal boot (no fingerprint, nothing at the broker).

EnsureKey now evicts a wrong-type/unusable key at the handle and creates a
fresh ECC key. Safe on the enrol path: it only runs on an empty-NV host, so no
issued cert references the old key. A valid ECC key is still reused (idempotent
retried enrol).

Regression: simtest TestEnsureKeyEvictsStaleNonECC pre-plants an RSA key and
asserts EnsureKey recreates a usable ECC signer (passes against the go-tpm
simulator).
2026-07-02 11:43:03 +00:00
Audiobuzz 1e1265b5a0 docs/TODO: add #15 — scope root overlay to per-subtree tmpfs (un-shadowable /usr + STIG mount options)
Captures the design + quantified STIG payoff (~21 mount/partition rules
notapplicable/waived -> pass, per reports/stig-arf.xml), the writable-set +
noexec/nosuid/nodev flag matrix, the kiosk-/tmp-noexec prerequisite, the
fstab-vs-runtime OVAL note, and that it's bench-gated after TPM enrolment (#11).
2026-07-02 11:31:33 +00:00
Audiobuzz 7f975bfa5f extract-artifacts: strip NUL from SELinux context in status echo
getfattr --only-values emits the NUL-terminated security.selinux value;
bash's $(...) then warns "ignored null byte in input". Pipe through
tr -d '\0' to silence the cosmetic warning. The label assertion above is
unaffected.
2026-07-02 11:00:58 +00:00
Audiobuzz ca6b75508c ci: pin Go-build step to bash (Gitea runner defaults run steps to sh)
The out-of-VM Go build step used `set -euo pipefail`, but the self-hosted
Gitea/act runner runs `run:` steps under sh (dash), which rejects
`-o pipefail` ("Illegal option"). Add `shell: bash` to the step. Keeping
`set -e` is load-bearing: without it a failed go build/test in the first
subshell group is masked by the trailing `file` command's success, letting
a broken binary reach the Packer build.
2026-07-02 10:07:23 +00:00
Audiobuzz 545d459c3d tpm-enrol: allow enrolment under rd.dot1x=0 (test-mode) for bench testing
Removes the hard no-op on the test path so the enrolment flow can be exercised
on a host with a TPM + a reachable broker WITHOUT standing up 802.1X/MACsec/
RADIUS. Networking is now mode-aware: in test mode ip=dhcp already owns the NIC
(and wrote resolv.conf), so the hook uses the existing lease instead of running
its own nmcli DHCP — avoiding the dracut "address already assigned" race
(CLAUDE.md "don't double-configure the NIC in test mode"). Secure mode still does
the nm-unmanaged override for the pre-auth lease. rd.fleet.enrol=0 disables
enrolment in either mode; production intent stays secure-path-only.
2026-07-02 10:00:26 +00:00
Audiobuzz 6278f2847d tpm-enrol: pivot host enrolment to a Go binary, built out-of-VM
Replaces the shell/openssl-provider host enrolment (a35f2d6) with a static
Go + go-tpm binary, and moves BOTH host Go binaries out of the Packer VM.

fleet-enrol (files/enrol/, Go + go-tpm, no CGO):
  - talks to /dev/tpmrm0 DIRECTLY via transport.FromReadWriter(os.File) — no
    linuxtpm, so go-tpm is the only dep and there is NO tpm2-tools key-gen /
    openssl tpm2 provider / curl in the image (removes the biggest bench risk,
    the provider handle: key-ref).
  - ensures the ECDSA key @0x81010001, builds the CSR (crypto.Signer over the
    TPM key), POSTs CSR+context to the broker, shows the Plymouth fingerprint,
    polls /status, writes the leaf cert to NV 0x01800001.
  - tpmkey/ is an exported package so the TPM ceremony is tested against the
    go-tpm software simulator from a SEPARATE simtest/ module (CGO stays out of
    the shipped build). Transaction logic (mock broker) tested in-package.
  - deps pinned by go.sum (not vendored): the binary now builds on the CI
    runner, which has network — reproducible via pinned Go 1.26.4 + go.sum.

Out-of-VM build (decision): both fleet-enrol AND fleet-hostapi compile in the
CI "Build host Go binaries" step (Go 1.26.4, CGO off, -trimpath) and Packer
FILE-PROVISIONS the static binaries in. Dropped the in-VM `dnf install golang`
+ the 2d removal — the Rocky build image carries no Go toolchain / go-tpm tree.

Wiring:
  - tpm-enrol.sh: now a thin GATE (empty-NV check, pre-auth DHCP on en*,
    exec /usr/sbin/fleet-enrol, reboot-to-converge). module-setup.sh bundles the
    binary (asserted) + reverts the tpm2-openssl/curl installs.
  - diskless.pkr.hcl: file provisioners for both binaries; enrol installed to
    /usr/sbin before the initramfs build + fapolicyd-trusted; lsinitrd asserts
    fleet-enrol; dnf reverted to baseline (no tpm2-openssl/curl).
  - gitea-main-ci.yaml: Set up Go + build both binaries + run the simulator
    ceremony test, before packer validate.

TESTED here: transaction unit tests + the full TPM ceremony (key-gen -> persist
-> ECDSA sign -> CSR verify -> chunked NV write -> re-enrol) against the go-tpm
simulator. BENCH-VERIFY (real/swtpm, empty owner auth, pre-auth DHCP, Plymouth,
reboot-converge, prod HTTPS) tracked in docs/TODO.md #11.
2026-07-02 09:55:54 +00:00
Audiobuzz 8e1d40a30b tpm-enrol: inline empty-NV first-boot enrolment (host side, TODO #11)
Builds the host side of TPM identity enrolment — the "inline single-UKI"
vehicle from docs/tpm-enrolment-design.md. A production (secure) UKI now
auto-enrols a fresh host with no client cert, then reboots into the normal
secure path.

New dracut hook files/dracut/95dot1x-macsec/tpm-enrol.sh (initqueue/settled
prio 25, BEFORE dot1x-macsec-up at 30):
  - no-op when a cert is already present in NV 0x01800001 (normal boot) or
    under rd.dot1x=0 (test path has no trustworthy identity to enrol);
  - on EMPTY NV: generate the signing key in the TPM @0x81010001 (openssl tpm2
    provider builds the CSR; key never leaves the TPM), pre-auth DHCP on the
    PHYSICAL NIC (nmcli re-manages en*, overriding the secure-mode unmanaged
    drop-in), POST CSR+context to rd.fleet.enrol_url, show the broker
    fingerprint on Plymouth, poll /status, write the issued leaf cert (DER) to
    NV, and reboot to converge.
  - Empty-NV and the secure path are mutually exclusive, so this never reorders
    the load-bearing boot sequence (invariant #2).

Wiring:
  - module-setup.sh: require+install the tpm2 key-gen/NV tools, curl, awk, and
    the openssl tpm2 PROVIDER (ossl-modules/tpm2.so, asserted present — tpm2-tss
    alone can't sign with a handle: key); install the hook.
  - diskless.pkr.hcl: install tpm2-openssl + curl; assert hook/tools/provider in
    the initramfs (invariant #9). New enrol_url var threaded to extract-artifacts.
  - extract-artifacts.sh: bake rd.fleet.enrol_url into the cmdline.
  - fleet.script: enrol-active/enrol-wait/enrol-done banner; fingerprint on the
    message line (public — states only, invariant #1).

Shell JSON build/parse/cert-extract pipeline (no jq in the initramfs) verified
end-to-end against a live broker. BENCH-VERIFY list in docs/TODO.md #11 (tpm2
provider key-ref on real/swtpm, empty-NV detect, pre-auth DHCP override, Plymouth
render, NV write + reboot convergence, prod HTTPS).
2026-07-02 09:00:18 +00:00
Audiobuzz a370f80332 TODO #11: step signer re-verified on the serving host (720h claim, chain) 2026-07-02 08:45:30 +00:00
Audiobuzz b8f88091f1 bootstrap: connect to step-ca via localhost, not ca.fleet.lab
The `step` CLI ran against --ca-url https://ca.fleet.lab:8443, but that name is
only served by the fleet dnsmasq to booting clients — the serving host's own
resolver can't resolve it ("no such host on 127.0.0.53"), so provisioner list +
RADIUS cert issuance failed. Connect over CA_CONNECT (default localhost, which
the compose puts in the CA cert SAN); CA_DNS stays the cert/SAN + RADIUS CN name.
2026-07-02 08:41:03 +00:00
Audiobuzz 8376f9a92e enrol-broker: run in serving stack + fix step signer path end-to-end
Verifying the step signer against a real step-ca surfaced two real bugs the
mock signer had hidden, plus made the bench CA actually initialise:

- signer.go: move --not-after from `step ca token` (validity caps ~1h ->
  "token validity too long") to `step ca sign` (it is the CERT lifetime).
  Without this the step signer never issues a cert.
- dot1x-bench compose: DOCKER_STEPCA_INIT_PASSWORD_FILE pointed at a file that
  does not exist at first init (it is an INPUT path), so `step ca init` failed
  and never wrote secrets/password. Use DOCKER_STEPCA_INIT_PASSWORD (the value)
  which the entrypoint writes to secrets/password, where bootstrap + broker read.
- bootstrap.sh: self-heal the root-owned ./pki Docker auto-creates; detect the
  CA password at $STEPPATH/password or secrets/password (varies by image
  version); raise authority.claims.maxTLSCertDuration so 720h fleet certs are
  allowed (stock cap 24h, which also blocked bootstrap's own RADIUS cert); fix
  RADIUS cert paths to write via the /export mount (were host paths run inside
  the container, so they never resolved).

- serving stack: add the enrol-broker service (temporary home; nginx already
  proxies /enrol/) defaulting to the zero-dependency lab path (mock signer +
  insecure loopback admin), with ENROL_* knobs to switch to step + mTLS.
- verify-serving.sh: exercise request -> approve -> cert through the deployed
  nginx /enrol/ proxy.
- TODO #11: record the step-signer verification + the two fixes.

Step signer VERIFIED end-to-end against a real step-ca: issued cert chains to
the fleet root, EKU clientAuth, hostnqn derived from serial.
2026-07-02 08:35:35 +00:00
Audiobuzz 9e1dbd3ef7 initial tpm broker 2026-06-28 10:33:14 +00:00
Audiobuzz 3bcf16e384 Merge remote-tracking branch 'origin/main' into tpm-enroll 2026-06-28 18:32:39 +09:30
Audiobuzz 5232b36614 tpm design 2026-06-28 18:32:36 +09:30
audiobuzz 889db569e5 Merge pull request 'workstation-api' (#9) from workstation-api into main
Reviewed-on: https://git.bitasylum.org/audiobuzz/diskless-workstation/pulls/9
2026-06-28 08:45:20 +00:00
Audiobuzz bc14b8986d fixing final SPDK deployment 2026-06-28 08:44:49 +00:00