Files
felhom.eu/documentation/audits/SPIKE-universal-iso-2026-07-31.md
T
admin ea00976403 SPIKE: a universal ISO needs a different disk strategy and a locked root
Findings only — no script, profile or build file changed; no ISO built, nothing published.
documentation/audits/SPIKE-universal-iso-2026-07-31.md

- R-139 (HIGH): a disk filter matching >1 device does NOT fail safe. Observed in a nested VM —
  the installer silently picked one of two matching disks and wiped it; validate-answer accepts
  such an answer. The 'filter did not match any devices' guard covers the ZERO-match case only.
- No udev property distinguishes an internal system disk from external media. Measured on
  demo-felhom with its 1TB external attached: ID_BUS='ata' for BOTH, lsblk RM=0 for both, and
  device-info exposes no removability property. demo-hp's NVMe carries no ID_BUS/ID_TYPE at all.
- R-141 (HIGH): the answer schema makes a root credential mandatory, but root-password-hashed='*'
  validates AND installs to completion. [first-boot].ordering accepts 'before-network', the only
  ordering that closes the exposure window structurally.
- Q3: prepare-iso leaves grub.cfg byte-identical to stock (15 entries, automated AND interactive)
  — a two-entry menu is purely a Felhom grub.cfg.tmpl change.
- R-129 resolved: demo-hp's key is the operator's own, added post-install; demo-felhom's IS baked
  by an uncommitted profile.

The reachable-before-rotation measurement FAILED twice and is recorded as failed, not inferred.

Opens R-139..R-147; restates R-128.
2026-07-31 12:12:35 +02:00

37 KiB

SPIKE — what does a genuinely universal ISO require? (2026-07-31)

Class: Spike. Output is findings, not shipped code. No production file was changed, no release ISO was built, nothing was published. Probe artefacts are quoted here and were removed at teardown.

Origin. The ISO build/publish task (2026-07-31) stopped before its Part 2 when its Part 0 found two hazards that make a public download a product decision rather than a build step. This spike establishes what a genuinely universal image would require.


1. Baselines

Read fresh at the start of this session, not carried from the previous one.

Baseline Value Source
ISO_VERSION 1.25.0 scripts/iso/build-felhom-iso.sh:44
SCRIPT_VERSION 1.22.0 scripts/felhom-host-install.sh:187
felhom-bootstrap.sh @ HEAD 21bf6a6bde0cb13e3809e2f5c136a49929dcc82eb8d40bbdf6f290a886ee8ab7 sha256sum scripts/iso/felhom-bootstrap.sh
PVE base ISO proxmox-ve_9.2-1.iso, 4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c sha256sum /mnt/5_hdd/felhom.eu/drill/proxmox-ve_9.2-1.iso — matches every ISO manifest's source-iso-sha256
felhom.eu HEAD 5825ceeabfc50227bee2ccf9cf35c412c05c0f2a, clean, == origin/main git status --porcelain empty
assistant tooling proxmox-installer-common v9.2.7 proxmox-auto-install-assistant --version

Fixture provenance. Nested VMs ran under plain QEMU/KVM on demo-hp (t740, Tier 0, the designated drill + build VM host per the 2026-07-25 standing ruling), with all scratch files under /mnt/nvme-1tb/spike-scratch/. Nothing was placed on demo-hp's local-lvm and no PVE storage was registered. drill-r50 (VM 300) and guest 9201 were not touched. The probe ISO was built on DooPlex from the baseline PVE ISO with prepare-iso --fetch-from partition and transferred once (sha256 337985c5c88b8d76c7a0a6e1678b48063f3b7d077222c91b9a1a38cfb6ca8c75, verified identical at both ends), so every scenario reused one image and varied only a 64 MB answer disk.

Real-hardware work was read-only. device-info was executed on demo-felhom and demo-hp via a temporary copy of the assistant binary in /tmp (removed at teardown). No install, no partitioning, no attach/detach.


2. Reconfirmation of the two established findings

Both re-verified at file:line rather than carried forward.

(a) Every ISO bakes a root password hash. build-felhom-iso.sh:270-283 mints ROOT_PLAIN unconditionally, hashes it with openssl passwd -6, and the renderer substitutes it into __ROOT_HASH__ (:295-296) from answer.toml.tmpl:24. There is no flag to suppress it.

Verified by extraction, not inference: osirrox pulled /answer.toml out of the shipped felhom-pve-9.2-1-v1.25.0-nested-vm-generic-mkimage.iso and it carries a live root-password-hashed field. The sibling .rootpw.txt plaintext was confirmed to hash to exactly that value. Any downloader can perform the same extraction with no privilege.

(b) generic.profile selects by name, not identity. profiles/generic.profile:16-18 sets disk-list = ["sda"]. Confirmed present verbatim in the extracted /answer.toml of the v1.25.0 build. generic.profile:12-14 concedes the limit in its own comment. The serial pin that closed this hazard on the N100 rehearsal (hp.profile:36) does not generalise to strangers' hardware.


3. Q1 — what the answer file can express about disk selection

3.1 The selection mechanisms (read from schema)

Extracted from the assistant binary's own serde field strings and confirmed against validate-answer behaviour. [disk-setup] is struct DiskSetup with 7 elements:

Key Purpose
filesystem ext4 / xfs / zfs / btrfs
disk-list explicit device names
filter map of udev-property globs
filter-match any | all
lvm / zfs / btrfs per-filesystem option sub-tables

The binary's validation strings pin the rules:

Need either 'disk-list' or 'filter' set
Cannot use both, 'disk-list' and 'filter'
make sure to define only one disk for ext4 and xfs
filter did not match any devices
no filter defined
invalid glob in disk selection

Glob syntax is ?, *, [a], [0-9], [!a] (device-match --help). [!…] negates a single character, not a match — there is no "does not contain" and no "property is absent" operator.

There is no exclusivity keyword. Nothing in the 7 elements expresses "abort unless exactly one candidate matches". The nearest thing is the only one disk for ext4 and xfs rule — a consequence of the filesystem choice, not a selection control (measured behaviour in §3.3).

3.2 What distinguishes an internal system disk from external media — measured

device-info -t disk, captured on both Tier 0 boxes.

demo-felhom — the hazard configuration (internal SATA SSD + the 1 TB external USB drive):

sda (internal) sdb (external USB)
ID_BUS ata ata
ID_PATH pci-0000:00:17.0-ata-2.0 pci-0000:00:14.0-usb-0:1:1.0-scsi-0:0:0:0
ID_MODEL AirDisk_512GB_SSD TOSHIBA_MQ04ABF100
ID_USB_DRIVER absent usb-storage
ID_USB_VENDOR absent ADATA
lsblk RM (removable) 0 0
lsblk HOTPLUG 0 1

Three findings, and the first two are the important ones:

  1. ID_BUS does not distinguish them. Both report ata. The external unit is a SATA drive in a USB caddy (an ADATA HD710 PRO enclosure around a Toshiba MQ04ABF100), so the bus property describes the drive, not the attachment. A filter written as "internal means ID_BUS=ata" matches the customer's backup drive.
  2. There is no removability property at all in device-info output — no RM, no ID_REMOVABLE. And lsblk reports RM=0 for the external drive anyway, so even reaching outside device-info does not supply the missing concept. Property filtering cannot express "not removable."
  3. What does distinguish them here is ID_PATH (-ata- vs -usb-) and the presence of the ID_USB_* key family — but only the former is expressible, because the filter language has no "key is absent" operator.

demo-hp (internal SATA system disk + a 1 TB NVMe data disk):

sda (SATA, the PVE system disk) nvme0n1 (NVMe, enrolled user data)
property count 45 28
ID_BUS ata absent
ID_TYPE disk absent
ID_PATH pci-0000:06:00.0-ata-1.0 pci-0000:04:00.0-nvme-1

Absence verified explicitly by key lookup, not by a grep that happened not to print.

  1. NVMe devices carry a different property set entirely — no ID_BUS, no ID_TYPE. Any filter built on ID_BUS silently excludes every NVMe-only machine.
  2. Decisively: no property expresses role. Every captured property describes hardware — bus, path, model, serial, size. None says "this is the disk the operating system belongs on." On demo-hp the system disk is the small SATA one and the big NVMe is user data; a heuristic like "largest disk" or "the NVMe one" targets the customer's data drive on exactly the configuration Felhom sells.

device-match confirmed the expressible cases on demo-felhom (read-only):

ID_BUS=ata          -> ["sda","sdb"]     both — the naive filter hits the backup drive
ID_PATH=*usb*       -> ["sdb"]           isolates the external
ID_PATH=pci-*-ata-* -> ["sda"]           isolates the internal, on this box

Both isolating filters are box-specific: the -ata- pattern excludes every NVMe system disk, and the *usb* pattern is an exclusion that the filter language cannot invert.

3.3 Multi-match behaviour — observed. It is the coin-flip.

This is the question the spike existed to answer, and it was measured, not inferred.

Method. A nested VM on demo-hp with two identical 16 GB virtio-SCSI disks carrying serials SPIKE0 and SPIKE1, plus a separate answer disk (serial ANSWERDISK, label proxmox-ais). Answer:

[disk-setup]
filesystem = "ext4"
filter-match = "all"
filter.ID_SERIAL_SHORT = "SPIKE*"      # matches SPIKE0 and SPIKE1 — two devices

The answer validated cleanly (The answer file was parsed successfully, no errors found!) — so validate-answer cannot catch this; it does not know what the filter will resolve to on the target.

The measurement is deliberately not a screen reading: both disk images were zeroed and hashed before the run and re-hashed after, so "which disk was written" is objective.

Disk sha256 (first 32) BEFORE AFTER Verdict
disk0.qcow2 (SPIKE0) de001102cbc8ff7aa8eb6c69d750cf22 382b55cad8027c60dfcbed254e723123 WIPED AND INSTALLED ONTO
disk1.qcow2 (SPIKE1) de001102cbc8ff7aa8eb6c69d750cf22 de001102cbc8ff7aa8eb6c69d750cf22 untouched

Observed mid-run, confirming it was a real install and not a partial write: disk0.qcow2 had grown to 5,441,388,544 bytes while disk1.qcow2 sat at 196,864 bytes (bare qcow2 metadata).

The installer did not abort. It silently selected one of the two matching disks and wiped it.

Two consequences:

  1. filter-match has no fail-safe on ambiguity. The filter did not match any devices guard — the spike-S5c behaviour the nested-canary profile pins, and the safety default the hp/n100 profiles rely on — protects only the zero-match case. The many-match case is unprotected and silent.
  2. The binary's make sure to define only one disk for ext4 and xfs string does not cover this. It rejects an explicit multi-entry disk-list; a filter that resolves to several devices sails past both validate-answer and the installer.

Why this matters more than the disk-list=["sda"] hazard. The previously known hazard is that sda may name the wrong disk. This one is worse in kind: a filter written specifically to be safe — "match the internal SSD" — degrades to an arbitrary pick the moment a second device matches, with no diagnostic. Given §3.2 (no removability property, ID_BUS identical on the hazard configuration), writing a filter that provably matches exactly one device on unseen hardware is not achievable.

Not established: whether the selection is deterministic by enumeration order or genuinely arbitrary. Only one ordering was tested. See §8.

3.4 Is the boot medium a candidate?

Partially measured. On demo-felhom the USB-attached drive is enumerated by device-info as an ordinary candidate disk with no marking distinguishing it from the internal one — a USB installer stick is the same class of block device, so nothing in the captured data suggests the enumeration would exclude it.

Not fully established. Whether the installer itself excludes the live medium it booted from is a separate question from whether device-info enumerates it, and this spike did not boot from USB on real hardware. See §8.


4. Q2 — can the root password be generated on the box instead of baked?

4.1 Is a password required? — measured

The binary carries both rules verbatim:

One of `global.root-password` or `global.root-password-hashed` must be set
`global.root-password` and `global.root-password-hashed` cannot be set at the same time

Confirmed empirically against validate-answer:

Answer Result
root password omitted entirely rejectedOne of global.root-password or global.root-password-hashed must be set
root-password-hashed = "*" accepted
root-password-hashed = "!" accepted
root-password-hashed = "" accepted
both plaintext and hashed set rejected

A root password field cannot be omitted — but the classic locked-account crypt placeholders * and ! validate. That is the opening: the field is mandatory, a usable credential is not.

That an empty string also validates is worth a fence of its own — "" is not a locked account, and whether it yields a passwordless root depends on how it reaches /etc/shadow.

Validation is not installation, so it was installed — and the first attempt to confirm that is worth recording, because it nearly produced a false finding.

S2, first attempt. A nested VM ran an unattended install whose only credential was root-password-hashed = "*". disk0.qcow2 went from de001102cbc8ff7aa8eb6c69d750cf22 to f4ae86b8317cc36af4628a6cd9c09da2 and grew to several GB, which reads like success. Booting the result showed otherwise: SeaBIOS printed Booting from Hard Disk... and hung. The install had been cut off mid-flight by the probe's fixed 300 s window; a written disk is evidence of writing, not of completion. A changed hash is a LastRun-class timestamp — it records that something was attempted.

S2b, with an actual completion signal. reboot-mode = "power-off" is a valid enum value ("poweroff" is rejected, so this is a real enum), and it makes the guest power itself off when the install finishes — i.e. the qemu process exiting is the completion signal, and a timeout is unambiguously a failure rather than an unknown.

Disk sha256 (first 32) BEFORE AFTER Completion signal
disk0.qcow2 de001102cbc8ff7aa8eb6c69d750cf22 dbc12e129ed84e834f6d54754cb0a2c8 guest powered itself off after 200 s

Established: an unattended install whose only credential is root-password-hashed = "*" runs to completion. The mandatory-field constraint does not force a usable baked credential — it forces a field, and a locked-account placeholder satisfies it. This is the mechanism a universal image needs.

Not established: that the resulting root account is actually locked. The install completing is not the same claim. Confirming it needs either /etc/shadow from the installed image or a refused login, and neither was obtained — see §4.3 and §8.

4.2 Can felhom-bootstrap rotate root at first boot?

Mechanically yes — it already runs as root at first boot, and felhom-host-install.sh:1831-1871 (step 4b, G1) already implements exactly this: generate 24 url-safe bytes, set via chpasswd on stdin, vault to the hub, scrub the variable. The code to rotate exists and is proven.

The problem is not capability, it is ordering — see §4.3.

4.3 The exposure window — structural finding, and it is not zero

felhom-bootstrap.service is ordered:

After=network-online.target pve-cluster.service pveproxy.service
Wants=network-online.target

So bootstrap starts after the network is up and after pveproxy is listening. Whatever it does to the root password happens strictly later than the box becoming reachable. The window is open by construction.

Worse, in pairing mode it does not close on its own. felhom-bootstrap.sh:9-22,52-54,363 registers the box as an unclaimed appliance and then polls indefinitely (POLL_INTERVAL=30) for the operator's bind, deliberately staying in activating because "waiting is not failing" (v1.21.0, R-33). Step 4b only runs after enroll, and enroll only happens after the bind. A box that is downloaded, installed, and never bound sits on its owner's LAN with the ISO-baked password indefinitely.

The measurement the task asked for was attempted twice and BOTH ATTEMPTS FAILED. No number is reported, because both numbers were artefacts of the fixture rather than facts about the system.

Attempt 1 — invalid, and it produced a plausible false zero. The probe booted the installed disk under QEMU with hostfwd port forwards and polled with a TCP connect. Both ports "accepted" at +0 s, which reads like "reachable instantly". It is not: QEMU's user-mode networking binds the forwarding socket on the host when QEMU starts and accepts the connection before it ever tries the guest. The same run then failed to read an SSH banner and reported Connection timed out during banner exchange — i.e. nothing was listening in the guest at all. A TCP connect against a hostfwd port proves nothing. Had the banner check not been in the same script, 0 s would have been recorded as a measured finding, and it would have been the exact opposite of the truth.

Attempt 2 — invalid for a different reason. The detector was rewritten to require a real SSH- version banner and a real HTTP response line. It then reported never for both, over 6 minutes. That is also not a fact about PVE: the S2b install VM was created with no network device at all, so the installed system's interface configuration cannot match the NIC that only exists in the boot VM. The fixture, not the system, produced the result.

What stands instead is the structural finding above, which does not depend on either probe: the unit's own After=network-online.target pveproxy.service places every rotation opportunity strictly after reachability, and the pairing loop makes the interval unbounded. That is a stronger claim than a single timing number would have been — it holds for every box rather than for one VM.

What it would take to measure it properly: install and boot with an identical NIC present in both phases, and probe at protocol level from the host. ~30 min. It is worth doing before a spec is written, because the number is what tells the operator whether the window is seconds or minutes when pairing does proceed promptly.

The mechanism that would close it: [first-boot].ordering accepts before-network as well as fully-up — verified against validate-answer (network-pre and a bogus value are both rejected, so this is a real enum, not a permissive parser). A hook running before-network executes before the box is reachable at all, which is what a zero-window rotation requires.

Felhom currently uses ordering = "fully-up" (answer.toml.tmpl:39), deliberately, so that pvesh and pct work (spike S3/S8a). Note the current stub (stub-first-boot.sh) only writes three files and runs systemctl enable + start --no-block — none of which obviously needs the network, and the unit it starts carries its own After=network-online.target. Whether the stub survives being moved to before-network is untested and is the single highest-value follow-up probe. [first-boot] accepts only one hook (struct FirstBootHookInfo with 4 elements), so this is a move, not an addition.

4.4 Console recovery if pairing fails

Not tested — stated plainly rather than assumed. The spike ran out of room before booting the S2b image into a GRUB single-user / init=/bin/sh shell.

This is not a detail. It is the input that decides §4.5: if the standard GRUB recovery path yields a root shell on a locked-root install, then discarding the password costs little, because a physically present person can always get back in. If it does not — or if PVE's installed GRUB is configured without an editable menu — then discarding turns a failed pairing into a reinstall, and the escrow argument is much stronger. The ruling in §4.5 should not be made before this is measured (§8, #4).

4.5 Escrow or discard — the tradeoff, for the operator to rule

Not a recommendation; the inputs.

For discarding (no per-box root password is ever recoverable):

  • The operator already holds agent-level access on every bound box, so console root is redundant for the normal case.
  • Under the D6 trust model the hub alone must not be sufficient. A vault of console root passwords for the whole fleet makes the hub exactly that, for every box at once — a strictly worse blast radius than the shared-ISO-password problem this is meant to fix.
  • It removes a class of credential that has to be rotated, audited, and leak-checked forever.

For escrowing (the current G1 behaviour, extended to pre-bind):

  • An unbound box has no agent access — precisely the case where console access is the only route in, and precisely when things go wrong.
  • G1 exists because the sshd path and the auto-heal both failing is a real, observed failure mode; the PVE web console at :8006 is a distinct failure domain from sshd.
  • Discarding turns "pairing failed" into a reinstall for a non-technical customer.

The middle option worth costing: rotate to a per-box random value at before-network and escrow nothing until the box binds, at which point step 4b's existing vaulting takes over. This closes the shared-credential window without adding a pre-bind fleet-wide vault — at the price of making an unbound, broken box unrecoverable except by reinstall. Whether that price is acceptable is the operator's call, and it is the one decision this spike asks for.

4.6 R-129 — SSH key provenance, resolved

The premise is confirmed: ssh -o BatchMode=yes demo-hp authenticates by key, while the documentation says no baked key exists. The two are not in conflict — the key is not baked.

Box /root/.ssh/authorized_keys/etc/pve/priv/authorized_keys Provenance
demo-hp SHA256:pgQh228R… nagyfenyvesi.viktor@gmail.com the operator's own DooPlex key, added post-install (file mtime 2026-07-27, install 2026-07-21). This is what authenticates CC.
SHA256:fnasIK1Y… root@felhom-host (RSA 4096) PVE's own self-generated root key
demo-felhom SHA256:a8zH+ecm… kisfenyo@windows baked by the ISO — exact fingerprint match to FELHOM_ROOT_SSH_KEY in the uncommitted n100-demo.profile
SHA256:BnLVXqTc… root@demo-felhom (RSA) PVE self-key
SHA256:pgQh228R… the operator's DooPlex key

Findings:

  1. demo-hp's key is not baked — it is the operator's own, added by hand. The documentation was right about the ISO and wrong about the box.
  2. demo-felhom's key is baked, via an uncommitted profile. So the "no baked key" claim was already false for one of the two demo boxes, and nothing recorded it.
  3. No committed profile bakes a key — all five have FELHOM_ROOT_SSH_KEY commented out — and the shipped v1.25.0 ISO contains zero occurrences of root-ssh-keys in its /answer.toml.
  4. But the mechanism is one uncommented line from shipping a shared key to every downloader (build-felhom-iso.sh:175-176answer.toml.tmpl:26). For a published image that is the same shared-credential class as the baked password, and it needs a build-time assertion, not a convention.
  5. kisfenyo@windows is the retired Windows workstation's key and is still authorized on demo-felhom. Stale credential; see R-rows.

5. Q3 — can the image offer both automated and interactive install?

Yes, and it requires no PVE-side work at all. Established by comparing three GRUB configs.

prepare-iso does not touch grub.cfg: the file extracted from the freshly prepared probe ISO is byte-identical to the one in the stock PVE ISO (diff clean). The stock config already carries the automated entry, gated on a file that prepare-iso creates:

if [ -f auto-installer-mode.toml ]; then
    set timeout-style=menu
    set timeout=10
    menuentry 'Install Proxmox VE (Automated)' { … proxmox-start-auto-installer }
fi
menuentry 'Install Proxmox VE (Graphical)'    { … }   # outside the conditional
menuentry 'Install Proxmox VE (Terminal UI)'  { … }   # outside the conditional
ISO menu entries
stock proxmox-ve_9.2-1.iso 15
prepared (probe, prepare-iso only) 15 — automated and graphical and TUI
Felhom v1.25.0 remastered 1 (Felhom telepítés)

So the answers are:

  1. The current remastering replaces the menu. iso-repack.sh rewrites grub.cfg from grub/grub.cfg.tmpl to exactly one entry — by design, and documented as such (build-felhom-iso.sh:16-23: the stock entries "are not hidden, they are not emitted"). This is also the answer to the previous session's open question: the mkimage/non-mkimage split is not this distinction — it is a UEFI loader swap, orthogonal to the menu, and both variants get the same single-entry menu.
  2. Preparation does not consume the interactive path. A multi-entry menu including the automated entry is the native shape of a prepared auto-install ISO. Felhom collapses it deliberately, for a reason that remains sound for a customer appliance (build-felhom-iso.sh:18-21: every stock entry is a route into the manual installer whose first question is which disk to wipe).
  3. Therefore a two-entry menu is purely a change to Felhom's own grub/grub.cfg.tmpl — keeping Felhom telepítés as default=0 and adding one guarded interactive entry. No PVE-side obstacle exists.

Not demonstrated by boot. The evidence here is configuration-level, which is weaker than a screenshot of the menu. See §8.


6. Q4 — what else is baked that a stranger should not receive

Answered by positive enumeration against the stock ISO, not by grepping for patterns I guessed — a pattern scan can only find what it was told to look for.

Method. Full recursive file listing of the shipped v1.25.0 ISO and the baseline PVE ISO (osirrox -find, 1576 vs 1573 paths), diffed both directions; content hashes compared for the OS payloads; all three first-boot payloads decoded and hashed against repo HEAD; the answer file's credential fields enumerated by name.

Added by the Felhom pipeline — exactly six paths:

Path Content Secret-bearing?
/answer.toml the rendered answer YES — root-password-hashed
/auto-installer-mode.toml mode = "iso" no
/proxmox-first-boot the rendered stub + 3 embedded payloads no (below)
/boot/grub/felhomtheme/ + background.png + theme.txt branding no

Removed: the three stock /boot/grub/pvetheme/ paths.

The OS payloads are untouchedpve-base.squashfs and pve-installer.squashfs are byte-identical to the stock ISO. Nothing is hidden in the installed system image.

The three first-boot payloads, decoded from the stub:

Payload Result
felhom-bootstrap.sh sha256 identical to repo HEAD
felhom-bootstrap.service sha256 identical to repo HEAD
pairing env only FELHOM_HUB_URL + FELHOM_INSTALL_URL — no customer id, no passphrase

Also scanned for (across all four content-bearing files): PEM private-key headers, ssh-rsa / ssh-ed25519 key material, Bearer tokens, api_key=, passphrase=, customer_id=, password=, and token= with ≥12-character values. The only hits were empty variable initialisations in felhom-bootstrap.sh:89 (FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE="") and a substring test at :480 — declarations, not values.

Conclusion for the recommended shape. The earlier recon's "three payloads, secret-free" finding holds for the payloads it examined and was scanning the wrong file for the credential question: /answer.toml is a fourth, separate artefact on the ISO root and is the only secret-bearing content in a pairing-mode build. In a universal image the entire question reduces to two fields in that one file — root-password-hashed and root-ssh-keys — both of which need a build-time assertion rather than a convention.


Stated as constraints on an eventual build spec. Not an implementation, and nothing here was built.

  1. Disk selection cannot be solved by a filter, and must not be attempted. §3.2 measured that no property expresses removability or role, that ID_BUS fails on the exact hazard configuration, and that NVMe carries a different property set. Any filter shipped to unknown hardware is a guess. → Constraint: a universal image must not choose a disk by property on hardware it has not seen.

  2. Ambiguity must fail closed, and the mechanism must be the one measured in §3.3 — not a filter the schema has no keyword for.

  3. The interactive installer must be reachable from the boot menu (§5). This is the constraint that makes the image universal in the strong sense: unattended where it is provably safe, and never a dead end where it is not — including on hardware nobody anticipated, without a code change. It costs one entry in grub/grub.cfg.tmpl.

  4. No usable credential may be baked. root-password-hashed = "*" is schema-valid (§4.1); the field cannot be omitted but a usable password can be denied. Bake a locked account.

  5. Rotation must precede reachability. ordering = "before-network" is the only ordering that closes the window structurally (§4.3). Anything after network-online.target — including everything the current bootstrap does — is open by construction, and in pairing mode open indefinitely.

  6. Two build-time assertions, because both failures are silent and checkable: the built image carries no usable root-password-hashed, and carries no root-ssh-keys at all (§4.6.4). A comment is not a guarantee; these are one-line greps over the rendered answer.

  7. The published artefact must be reproducible from committed inputs. nested-probe's profile no longer exists anywhere, so that ISO cannot be audited or rebuilt — a state a published image must never reach.


8. What is still unknown, and what it would take

Stated plainly rather than inferred. Several of these were reachable and were not reached; that is a budget outcome, not a judgement that they do not matter.

# Unknown What it would take
1 Whether the multi-match pick is deterministic by enumeration order or arbitrary. §3.3 tested one disk ordering and SPIKE0 was chosen. "Always the first-enumerated" and "arbitrary" have the same evidence so far. Re-run S1 with the two disks attached in reverse order, and a third run with differing sizes. ~15 min. Does not change the recommendation (either way it is unsafe), but it changes how the hazard is described to a tester.
2 Whether the installer excludes the live medium it booted from. §3.4 established only that USB block devices are enumerated as candidates. Boot the installer from a USB stick on a real Tier 0 box with a filter that would match the stick. Needs physical presence — HUMAN.
3 Whether the Felhom first-boot stub survives ordering = "before-network". This is the highest-value follow-up: it is the mechanism that would make the exposure window structurally zero (§4.3). The stub appears not to need the network, but "appears" is exactly the word this project has been burned by. Build one probe ISO with ordering = "before-network" and the real stub, boot a nested VM, confirm the bootstrap unit still starts and pairing still completes. ~30 min. Do this before any spec is written.
4 Whether GRUB single-user recovery works on a locked-root install (Q2.4). Not tested. This is load-bearing for the discard-vs-escrow ruling: if init=/bin/sh from the GRUB menu gives a root shell, discarding is far cheaper than if it does not. Boot the S2 disk image, edit the kernel line at the GRUB prompt, observe. Automatable via QMP sendkey. ~20 min.
5 The two-entry menu is not demonstrated by boot (§5). The evidence is configuration-level: prepare-iso provably does not touch grub.cfg, and the stock config provably contains both entries. A booted screenshot would be stronger. Boot the probe ISO in a nested VM and screendump the menu. ~10 min.
6 Whether root-password-hashed = "" produces a passwordless root or a locked one. It validates (§4.1); what it writes to /etc/shadow was not observed. One install + offline read. Low priority — nothing should ever set it — but it is a trap sitting in the schema.

9. R-rows opened

Numbering continues from the highest in use (R-138).

R Severity Finding
R-139 HIGH A disk filter that matches more than one device does not fail safe — the installer silently picks one and wipes it (§3.3, observed). The filter did not match any devices guard covers the zero-match case only. validate-answer cannot catch it. This invalidates "write a careful filter" as a strategy for unknown hardware, and it is the reason a universal ISO cannot select a disk unattended.
R-140 MEDIUM proxmox-auto-install-assistant device-match prints Error: … filter did not match any devices and exits 0. Joins validate-answer (spike S1) in the exit-codes-that-lie class. Any tooling built on device-match must parse output, never $?.
R-141 HIGH The answer schema makes a root credential mandatory (§4.1), so every ISO ships one. On a published image that is a fleet-wide shared credential, and in pairing mode the window does not close on its own: bootstrap is ordered after network-online.target, and the pairing poll waits indefinitely (§4.3). A box that is never bound keeps the download's password forever.
R-142 MEDIUM FELHOM_ROOT_SSH_KEY will bake a shared SSH key into every copy of a published image, and nothing prevents it. No committed profile sets it and the v1.25.0 build contains none — but an uncommitted profile already did exactly this on demo-felhom (§4.6). Same shared-credential class as R-141; needs a build-time assertion over the rendered answer, not a convention.
R-143 LOW A retired credential is still live: the kisfenyo@windows key (the decommissioned Windows workstation, §4.6) remains in demo-felhom's authorized_keys.
R-144 MEDIUM felhom-pve-9.2-1-v1.24.0-nested-probe-generic.iso cannot be reproduced or audited — no nested-probe profile exists anywhere in the repo or the build tree. A published artefact must never reach this state.
R-145 LOW The ISO variant naming convention is undocumented (carried from the 2026-07-31 Part 0). out/ holds 17 images whose labels mix hardware targets, lab purposes, a pairing-mode suffix and a loader suffix, with no written key. §3 and §5 of this document supply the meanings; they need a home in the ISO README.
R-146 LOW root-password-hashed = "" passes validation (§4.1). An empty hash is not a locked account. Worth a fence wherever answers are generated.
R-147 LOW The answer renderer substitutes __ROOT_HASH__/__FQDN__ inside the template's own comment lines (build-felhom-iso.sh:295-296 is line-oriented and unconditional), so the shipped /answer.toml repeats the real hash three times, twice in prose that was meant to be documentation. Cosmetic while the hash is on the ISO anyway; it stops being cosmetic if the credential ever becomes conditional.
R-128 (open, restated) build-felhom-iso.sh:44's claim that ISO_VERSION "aligns with felhom-host-install SCRIPT_VERSION" is a comment, not an assertion. SCRIPT_VERSION appears nowhere in scripts/iso/ except inside that comment's own text, so nothing evaluates it; the values have drifted (1.25.0 vs 1.22.0). Instance #7 of the "a comment asserting an invariant needs a test pinning it" class.

10. Teardown

All three layers, per PROMPT-TEMPLATE.md §13, each verified by a positive check rather than asserted.

Layer 1 — demo-hp (nested-VM host).

Check Result
QEMU processes 0 remaining
/mnt/nvme-1tb/spike-scratch/ (ISO, disk images, answer disk, probe scripts) removed — No such file or directory
/tmp/paia (temporary assistant binary) removed
loop devices 0
/mnt/nvme-1tb/ contents back to dump e2d-images felhom-data images lost+found — the pre-spike set
drill-r50 (VM 300) stopped — untouched, not destroyed (R-93 fixture intact)
guest 9201 running — untouched
/etc/pve/storage.cfg 4 storages, unchanged; no scratch storage was ever registered
local-lvm never written to

Layer 2 — demo-felhom (read-only probe host).

Check Result
/tmp/paia removed
guest 9201 running — untouched
external USB drive sdb 931.5G usb /mnt/hdd_1 — still attached and mounted exactly as found; never attached, detached, partitioned or written

Layer 3 — DooPlex (build host).

Check Result
scratchpad 3.2 GB → 28 K (probe ISO, extracted ISO trees, PPM/PNG screendumps, file lists removed)
throwaway root password file shred -u'd
felhom-iso/out/ 17 ISOs, nothing deleted (fence honoured)
repo working tree clean apart from this document
production files none modified — no .go, no build-felhom-iso.sh, no profile

Hub-side disposition — nothing to dispose. This spike created no customer, no host, no appliance registration: the nested VMs ran under plain QEMU with no network path to the hub, and the only hub interaction was two authenticated reads of /configuration and /configs. Verified positively rather than asserted — the customer list was fetched and searched for spike|probe|scratch-named entries (none) and carries the same single pre-existing customer. R-131 does not gain a row here.

Published artefacts: none. Nothing was uploaded to R2 or anywhere else. The R2 credential was never created, used, or handled in this session.