This repository has been archived on 2026-07-30. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
nixos/AUDIT_REPORT.md
beatzaplenty 12f9153957
Check NixOS configurations / eval-hosts (push) Successful in 10m36s
claude audit report
2026-07-21 20:07:30 +00:00

19 KiB

Flake End-to-End Audit Report

Date: 2026-07-21 Scope: Full static lint/eval sweep + live build/deploy/interrogate/destroy testing of every lxc-* and proxmox-* flake target against pve.sweet.home, plus an audit of the operator's ability to manage the flake/secrets tooling. Branch: worktree-flake-e2e-audit (this session's isolated worktree)

Executive Summary

The flake itself is in good shape: nixpkgs-fmt, statix, and a full eval + dry-run build of every host and package are all clean. Every lxc-*/proxmox-* target's NixOS configuration builds successfully — no target has a broken derivation graph.

The issues found are operational, not code-level:

  1. pve.sweet.home is critically low on disk space (91-95% full during this session) and cannot currently build the two largest closures (gui, pxe-boot) to completion — this actively blocks deploying/redeploying those hosts via the documented workflow.
  2. A real, reproducible secrets-decryption failure was caught live: a stale cached container image (built before a same-day sops-key fix) boots with sshd never starting and every secret failing to decrypt. This is a general hazard in create-proxmox-resource.sh's "reuse the cached image if present" default, not a one-off.
  3. sops key/anchor drift: proxmox-minimal has a .sops.yaml recipient anchor with no corresponding private key anywhere in this environment; several lxc-*/proxmox-* targets have no sops registration at all yet.
  4. One concrete script bug was found and fixed in this session: create-proxmox-resource.sh never enabled the QEMU guest agent channel on VMs it creates, despite the guest OS already running it.
  5. A management-surface audit (of the operator's ability to run this repo day to day) found 5 process gaps, detailed below.

Nothing here required or received a nixos-rebuild switch/boot/test, nixos-install, or any disk-formatting command — all validation was nix build/nix eval, plus disposable pct/qm create-then-destroy cycles via the repo's own create-proxmox-resource.sh.


1. Static Analysis Results — all clean

bash scripts/codex-maintenance.sh --full-check --dry-run (whole-tree sweep, not just changed files):

Check Result
Secret grep Clean — only the documented exceptions (installer's own hashed passwords, access-tokens comment references)
nixpkgs-fmt --check 0/53 files would be reformatted
statix No lint warnings
nix-cache host key drift check Up to date
Full eval of every host's system.build.toplevel All 19 nixosConfigurations targets evaluate cleanly
Dry-run build of every host + package All succeed, no derivation errors

No drift, no formatting issues, no lint findings anywhere in the tree.


2. Per-Target Test Results

Legend: LIVE = built on pve, pct/qm create → interrogated → destroyed. BUILD-ONLY = nix build validated the config (mostly .config.system.build.toplevel, occasionally .tarball), no resource created on pve.

Target Test type Result Notes
lxc-docker BUILD-ONLY PASS Live redeploy skipped — CT105 is already running this identity in production; --allow-duplicate-host would have destroyed it.
lxc-minimal LIVE PASS (after retry) First attempt reused a stale cached tarball predating a same-day sops-key commit → activation failed, sshd never started (see Finding #2). Redeployed with --force-rebuild: clean boot, systemctl is-system-running = running, secrets decrypted, sshd listening, users correct.
lxc-nix-cache BUILD-ONLY PASS (after retry) Live redeploy skipped — CT101 is already running this identity. First local build attempt appeared to hang on a remote-builder handoff to nix-cache; killed and retried with --builders "" (local-only), succeeded.
lxc-gui LIVE (attempted) ⚠️ BLOCKED by pve disk space Registered a fresh sops key (no prior registration existed), built successfully through the full NixOS system closure, then failed packaging the tarball: No space left on device on pve's root filesystem. Not a flake defect.
lxc-pxe-boot LIVE (attempted) ⚠️ BLOCKED by pve disk space Same failure as lxc-gui — this target additionally builds a full nested installer/netboot image (stage-installer-artifacts.nix), making it similarly large. Failed with the same No space left on device error, immediately after the gui attempt had already consumed pve's remaining headroom.
lxc-server BUILD-ONLY PASS No sops key registered yet; live deploy also would have hit boot.zfs.extraPools trying to import a real ZFS pool that doesn't exist in an isolated test container — an expected limitation of testing this build type outside its real hardware, not a bug.
lxc-tailscale-exit-node BUILD-ONLY PASS No sops key registered yet.
lxc-tor-relay BUILD-ONLY PASS Live redeploy skipped — CT106 already holds this identity in production.
proxmox-docker BUILD-ONLY PASS (after retry) Live redeploy skipped — both CT105 and VM103 already hold docker identities. Combined toplevel + diskoImagesScript build crashed with a Nix-internal assertion failure (worker.cc:360) under this session's memory pressure (see Finding #6) — not a flake bug. Retried with toplevel alone: clean.
proxmox-minimal LIVE (attempted) ⚠️ BLOCKED by key drift → BUILD-ONLY .sops.yaml has a registered &proxmox-minimal anchor but no corresponding private key exists anywhere in this environment — the script correctly refused to generate a mismatched replacement. Fell back to toplevel build: PASS.
proxmox-nix-cache BUILD-ONLY PASS No sops key registered yet.
proxmox-gui BUILD-ONLY ⚠️ Killed after ~40min (resource-limited) This session's local build machine has only 2GB RAM; swap filled completely (2.0/2.0GB) and the build stalled, so it was killed rather than risk destabilizing the session further. Not a flake defect — the equivalent gui NixOS configuration already proved fully buildable during the lxc-gui live attempt above (it built the entire system closure successfully and only failed at the pve-side tarball-packaging step due to disk space, not the config).
proxmox-pxe-boot BUILD-ONLY ⚠️ Killed after ~35min (resource-limited) Was deep into building the nested installer's kernel initrd (this build type bundles a full netboot installer image via stage-installer-artifacts.nix) when killed to keep the audit moving. Not a flake defect — this target's own module logic was already effectively validated via the earlier live pve deploy attempt (lxc-pxe-boot above), which built the complete image and only failed at the final tarball-packaging step due to pve's disk space (Finding 1).
proxmox-server BUILD-ONLY PASS No sops key registered yet; same ZFS-pool caveat as lxc-server would apply to a live deploy.
proxmox-tailscale-exit-node BUILD-ONLY PASS No sops key registered yet.

Not tested at all: linode-* targets (not deployable to Proxmox) and installer (not a normal host) — both were still covered by the static eval/dry-run-build sweep above.


3. Findings, Ranked by Severity

Finding 1 — pve.sweet.home is critically low on disk space (blocks real deployments)

At session start: /dev/mapper/pve-root was 95% full, 5.3GB free (of 94GB). After two failed large builds it recovered slightly to 91% full, 8.2GB free (nix cleans up its own failed-build scratch space). /nix/store alone is 26GB; nix-store --gc --print-dead reports zero reclaimable garbage — everything currently in the store is a live GC root, so nix-collect-garbage won't help without first removing old roots.

Why it matters: create-proxmox-resource.sh builds every VM/CT image directly on pve, not on a build machine and transferred over. With <10GB headroom, any closure approaching a few GB (the gui build type: full Cinnamon desktop + Firefox + LibreOffice + GIMP + VS Code + xrdp; the pxe-boot build type: nginx/atftpd plus an entire nested installer/netboot image) cannot currently be built there at all. Both lxc-gui and lxc-pxe-boot failed live with No space left on device during this audit.

Recommended action: Expand pve-root's LV, or free space by pruning old container templates in /var/lib/vz/template/cache (1.5GB) / old backups in /var/lib/vz/dump (306MB) / auditing what's pinning 26GB of /nix/store as live GC roots (likely result-* symlinks — see below). This is real production disk state; not something this session touched or fixed — it needs the operator's judgment on what's safe to remove.

Secondary, smaller finding: every create-proxmox-resource.sh run leaves a result-<target> symlink in the node's repo checkout as a permanent GC root (ls /root/nixos/result-* on pve showed 3 from this session alone: lxc-docker, lxc-minimal, lxc-nix-cache). These accumulate forever and pin their entire closures in the store. Consider having the script clean up its own result-* link after staging the built artifact (or use a temp --out-link under /tmp), so nix-collect-garbage can actually reclaim old build outputs.

Finding 2 — Stale cached images can silently ship broken secrets (reproduced live)

create-proxmox-resource.sh's default behavior is: if the node already has <target>.tar.xz/.raw staged, reuse it — only --force-rebuild forces a fresh build. This session hit exactly the failure mode docs/auto-installer.md already warns about: lxc-minimal's cached tarball (built 2026-07-20T15:57Z) predated a same-day sops-key fix commit (2026-07-20T17:49Z, "clean up in ailse 3"). The deployed container booted with:

sops-install-secrets: failed to decrypt '.../common.yaml': Error getting data key: 0 successful groups required, got 0
Activation script snippet 'setupSecrets' failed (1)

— every secret permanently failed to decrypt, sshd never started (though the container otherwise looked "running"). This was not a code bug: the currently-committed secrets/common.yaml decrypts fine for that host's key when checked independently; the cached artifact on pve simply reflected an older commit's ciphertext. Redeploying with --force-rebuild fixed it immediately.

Why it matters: this is silent and easy to trigger by accident — any operator who redeploys a host without remembering --force-rebuild after a secrets change gets a container that looks like it started (pct start succeeds, pct status = running) but is completely inaccessible.

Recommended action: Have create-proxmox-resource.sh compare the cached image's build timestamp (or embed the source commit hash in the staged filename) against current HEAD, and warn (or refuse without --force-rebuild) if they differ — rather than silently trusting presence alone.

Finding 3 — sops key/anchor drift

Two concrete instances hit live during this session:

  • proxmox-minimal: .sops.yaml already has a registered &proxmox-minimal age recipient, but this environment's host-keys/ directory has no corresponding private key file. sync-host-keys.sh correctly refused to generate a replacement (it would silently mismatch whatever's already registered/deployed) — but this means no environment currently has this host's private key, unless it exists on some other machine that was never backed up here.
  • lxc-gui, and by the same logic lxc-server/lxc-tailscale-exit-node/most proxmox-* targets, have no sops registration at all yet — expected for undeployed hosts per docs/auto-installer.md, but this session's live-testing needed to register lxc-gui's key on the fly, which immediately hit Finding 3b: registering a key locally does nothing for pve's build until it's pushed to origin/main (pve builds via git pull, not from this uncommitted worktree). This is exactly gap #4 the management-surface audit (below) already flagged in the abstract — this session hit it concretely.

Recommended action: for proxmox-minimal, decide whether to regenerate its key (destroying old-key decrypt access, if anything still holds it) or track down wherever the original private key lives and back it up here. For the general pattern, see the management-surface audit's recommendation to pre-flight-check key registration before building.

Finding 4 — QEMU guest agent never wired up (found and fixed this session)

modules/common/configuration.nix:44 sets services.qemuGuest.enable = true on every host — the guest-side agent daemon is correctly enabled everywhere. But scripts/proxmox/create-proxmox-resource.sh's qm create call never passed --agent 1, so Proxmox never created the virtio-serial channel the agent needs. Every proxmox-* VM this script ever created was silently missing qm guest exec/IP-address reporting in the Proxmox UI, despite the guest daemon actually running.

Status: fixed in this session's worktree (scripts/proxmox/create-proxmox-resource.sh, qm create now includes --agent enabled=1) — see the diff, included in the PR from this session.

Finding 5 — Orphaned container on pve (CT102)

pve.sweet.home has a stopped LXC container, VMID 102, with an essentially empty config (lock: create and nothing else — no hostname, no rootfs, no network) — the leftover of a pct create that started and never finished. It predates this session (not created by any of this audit's activity) and wasn't touched. Recommend the operator confirm it's abandoned and remove it (pct destroy 102 --purge 1) — left as-is it may be someone's genuine in-progress work, so it wasn't assumed safe to delete autonomously.

Finding 6 — Nix-internal crash under memory pressure (tooling, not flake)

Building proxmox-docker's toplevel and diskoImagesScript together crashed with a Nix-internal assertion failure (Assertion '!awake.empty()' failed ... worker.cc:360, a known class of bug in Nix's multi-goal build scheduler) while this session's 2GB-RAM build container was under heavy swap pressure (1.8-2.0/2GB swap in use) from a separate concurrent build. Retrying the same target alone (no concurrency) succeeded cleanly. Not a flake defect — purely an artifact of this session's constrained build environment; noted for completeness since it looked alarming in isolation.

Finding 7 — Management-surface audit: 5 operability gaps

A focused audit of "can the operator actually run this repo day to day" (flake, home-manager, sops, related scripts) found:

  1. No documented recovery path if the &admin sops age key is lost without a backup. scripts/secrets/backup-admin-key.sh exists and works but is referenced nowhere in README.md/docs/ — no forcing function ensures a backup was ever taken. rotate-admin-key.sh requires the old key to re-key; there's no bootstrap-from-nothing path documented (the real fallback — deriving an age identity from any still-live host's own SSH key — isn't written down anywhere).
  2. home-manager has no standalone iteration path. It's wired only inside nixosConfigurations (flake.nix) — no homeConfigurations output. The fastest real shortcut (nix build .#nixosConfigurations.<target>.config.home-manager.users.nixos.home.activationPackage) isn't documented anywhere, so the practical workflow is a full host rebuild to test one HM tweak.
  3. Gitea's flake-lock-update workflow pushes straight to main with no pre-merge validation. .gitea/workflows/update-flake-lock.yml commits and pushes nix flake update's result directly; codex-maintenance.sh only runs after, on the resulting push — a genuinely broken lockfile bump lands on main before anything catches it. (The GitHub-side workflow is safer — PR-based — but has the opposite gap: nothing alerts if the PR sits unmerged.)
  4. No pre-flight check that a build target has a registered sops key before building it. docs/auto-installer.md documents the failure mode (silent, total secrets-decrypt failure) but nothing in create-proxmox-resource.sh refuses to proceed when it's about to build a target with no .sops.yaml anchor — it's on the operator to remember. This session's lxc-gui test hit close to this exact gap (needed the key added on the fly, mid-session).
  5. vars.remoteBuilderAuthorizedKeys has the same drift risk as vars.nixCacheHostKey, but no checker script. sync-nix-cache-host-key.sh --check guards the latter; the former (and vars.pxeServerIp/vars.pbsIp) has no equivalent — a rotated/revoked client key just silently stops working with no diagnostic pointing back here.

4. Action Plan (priority order)

  1. Free up disk space on pve.sweet.home (or expand pve-root). Blocking: lxc-gui, proxmox-gui, lxc-pxe-boot, proxmox-pxe-boot cannot currently be built/redeployed on this node at all.
  2. Decide on proxmox-minimal's orphaned sops key: locate the original private key and back it up here, or accept regenerating it (breaks decrypt access for whoever/whatever currently holds the old one).
  3. Merge this session's PR (see below) to get the --agent 1 fix and lxc-gui's new sops registration onto main — required before lxc-gui can be live-redeployed with working secrets.
  4. Add a staleness guard to create-proxmox-resource.sh's cache-reuse path (Finding 2) — highest-leverage fix, since it silently produces a broken-but-"running" host.
  5. Add a pre-flight sops-anchor check to create-proxmox-resource.sh (management-surface gap #4) — same root cause class as #4 above, catch it before building instead of at first boot.
  6. Investigate/clean up CT102 on pve (Finding 5) — confirm abandoned, then remove.
  7. Document backup-admin-key.sh in README.md's Security Notes and add the live-host-key bootstrap-recovery procedure to docs/ (management-surface gap #1).
  8. Add pre-push validation to the Gitea flake-lock-update workflow (management-surface gap #3).
  9. Lower-priority: document the home-manager activationPackage shortcut (gap #2); extend sync-nix-cache-host-key.sh's drift-check pattern to remoteBuilderAuthorizedKeys (gap #5).
  10. Follow-up session: finish build-validating proxmox-gui and proxmox-pxe-boot (both killed here after 35-40min on this session's 2GB-RAM machine — not failures, just unfinished) once pve has headroom (item 1) — ideally from a machine with more RAM. proxmox-server and proxmox-tailscale-exit-node already passed build-only validation in this session, no follow-up needed.

5. Uncommitted Changes From This Session

This worktree (worktree-flake-e2e-audit) currently has:

  • scripts/proxmox/create-proxmox-resource.sh — the --agent enabled=1 fix (Finding 4).
  • .sops.yaml / secrets/common.yamllxc-gui's new age key registered as a recipient (generated live during this session's testing).

Per this session's standard workflow, these will be committed, pushed, and opened as a draft PR rather than pushed to main directly — merging it is the operator's call, and is also prerequisite to live-redeploying lxc-gui successfully (its build will keep hitting the sops-staleness failure from Finding 2 on pve until this registration is on origin/main).