# SPIKE — bare-metal Felhom ISO (Proxmox automated-installation mechanics), 2026-07-16

**Class:** spike (empirical mechanism validation before any production spec). **Output:** this findings
doc. **No production code shipped.** The only repo write is this file + one ROADMAP line (R-21 → spiked).

**Verdict in one line:** every mechanism the bare-metal path depends on — the
`proxmox-auto-install-assistant` toolchain, zero-touch install (BIOS *and* UEFI, incl. Secure Boot),
the first-boot hook (privileges / ordering / exactly-once), the post-installation webhook, disk-filter
safety, and from-url + cert-fingerprint pinning — **works and fails-closed where it must**. R-21 is
technically green to spec on nested virt; the only residual unknowns need one real bare-metal run
(vendor DMI serials, real firmware quirks). *(The spike brief refers to this feature as "R-22"; it is
entered in the ROADMAP as **R-21** — the same item.)*

## 0. Environment & baselines

| Item | Value |
|---|---|
| felhom.eu | `main` @ `d202b43` (clean, confirmed at start) |
| Host (nested-VM + live validation) | felhom-pve 192.168.0.162, PVE **9.2.2**, kernel 7.0.2-6-pve; 7.5 GiB RAM free at start (≥5 GiB gate ✓) |
| Assistant / ISO build host | DooPlex 192.168.0.180 (Docker 29.2.1) |
| Spike VM | **310** `spike-baremetal-iso`, 4 GB / 2 vCPU, scsi0 32 GB (target) + scsi1 8 GB (data/canary), virtio-scsi-single; SMBIOS uuid `14a82ff6-3ab5-440d-ac40-7f9968408618`, NIC MAC `bc:24:11:98:10:0e` |
| Never touched | qm 300 (drill), 9000, LXC 9201, Peti, hub — honored |
| Throwaway password | `$6$…` crypt hash baked into answer files (spike-only; `# THROWAWAY` in the archived answer.toml) |
| Evidence root | `180:~/spike-baremetal/` (ISOs, answers, `evidence/<leg>/`, listener/https logs) — kept |
| Spike VM final state | **stopped, kept** (per cleanup rule) pending acceptance |

## S1 — toolchain + ISO provenance — **PROVEN**

- **Assistant obtainable + pinnable per release:** `proxmox-auto-install-assistant 9.2.7`
  (`proxmox-installer-common 9.2.7`) installed in a disposable `debian:trixie` Docker container on
  DooPlex from the PVE **trixie** no-subscription repo. This is the intended `build-felhom-iso.sh`
  host and it works. Pairing rule: install the assistant from the `pve <codename> no-subscription`
  repo whose Debian codename matches the ISO generation (PVE 9.x = Debian 13 *trixie*); the assistant
  point release (9.2.7) tracks the repo and pairs with the ISO by **major.minor** (9.2), not by the
  ISO's point release (9.2-1).
- **ISO provenance:** `proxmox-ve_9.2-1.iso` sha256
  `4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c` — matches the official
  `download.proxmox.com/iso/SHA256SUMS` **and** the copy already on felhom-pve.
- **Build cost (design input #6):** container image build 37.9 s / 243 MB; `prepare-iso` ~**11.5 s**
  per prepared ISO (source local); prepared ISO 1,707,278,336 B (~1.1 MB over the 1,706,178,560 B
  source).
- **`validate-answer` catches a broken file** — fed one with both `root-password` and
  `root-password-hashed`: `` `global.root-password` and `global.root-password-hashed` cannot be set at
  the same time / Error: Found issues in the answer file.`` **TRAP (load-bearing for the pipeline):**
  it prints that to **stderr but returns exit code 0** — for *both* valid and invalid files. A
  `build-felhom-iso.sh` gate **must parse the output text**, never `$?`. It is also **syntactic only**:
  `disk-list=["sdz"]` (a non-existent disk) passed validation and only failed at install time (see S8b).
- Evidence: `evidence/S1-S8-key-results.txt`, `~/spike-baremetal/build/build.log`, `iso/SHA256SUMS`.

## S2 — baseline zero-touch install (SeaBIOS) — **PROVEN**

- **Given/When/Then:** prepared ISO (`--fetch-from iso`, keyless `answer-base.toml`) on VM 310
  (SeaBIOS, single 32 GB disk) → boot → automated install with **no keypress**, reboot into working PVE.
- Boot menu **auto-selected "Install Proxmox VE (Automated)"** with a 5 s countdown
  (`evidence/S2/boot-8s.png`); unattended package extraction (`evidence/S2/mid-install.png`); **no
  interactive prompt at any point**. Install wall-clock **≈ 3 min** (start 09:45:33 → booting installed
  PVE by 09:48:48).
- Installed system reachable over IPv6 link-local: `22` OPEN (`SSH-2.0-OpenSSH_10.0p2 Debian-7+deb13u4`),
  `8006` OPEN. TLS cert **CN=spike310.felhom.local** (configured fqdn applied); SAN carries the
  **DHCP-assigned IPv4 192.168.0.115**. Boot-order `scsi0;ide2` fall-through: the post-install reboot
  landed on the installed disk (no reinstall loop).
- Evidence: `evidence/S2/verdict.txt`, `boot-8s.png`, `mid-install.png`.

## S2b — UEFI + Secure Boot — **PROVEN** (no caveat needed)

- **UEFI (OVMF/q35, Secure Boot OFF, `pre-enrolled-keys=0`):** automated install identical, zero
  keypress (`evidence/S2b/boot-uefi-installer.png`). Keyed SSH login worked; `/sys/firmware/efi`
  present → **genuine UEFI boot**; `mokutil`: *SecureBoot disabled / Setup Mode*.
  **Password mechanism proven non-hollow:** the installed `/etc/shadow` root hash equals the answer's
  `root-password-hashed` **verbatim** → the password would work as configured.
- **Secure Boot ON (`pre-enrolled-keys=1`, MS 2023 certs, enforcing):** the PVE ISO's MS-signed shim
  chain booted the automated installer (`evidence/S2b/sbon-18s.png`), the install completed, **and the
  installed PVE booted under Secure Boot enforcing** — `mokutil: SecureBoot enabled`, `BOOTED_VIA=UEFI`,
  lockdown=integrity, **zero keypress, no MOK-enrollment prompt**. → **R-21 needs no Secure-Boot
  caveat**: customer mini-PCs with SB on by default install and boot fine.
- **Bonus finding:** the PVE installer writes a **dual BIOS+UEFI-bootable disk** — the S2 SeaBIOS
  install also booted under OVMF.
- **Re-imaging gotcha (finding):** the first UEFI reinstall over a disk that still held the prior
  install's **LVM PV signature** (only first/last-10 MiB wiped) **aborted**: *"unable to initialize
  physical volume /dev/sda3 / low level installer returned early"*. The auto-installer's
  "cleanup root-disks" step does **not** force-clear a pre-existing LVM PV. A full `blkdiscard` then
  installed cleanly. → a customer disk with a prior LVM/Proxmox/Linux install may need an explicit
  wipe before the automated install.

## S3 — first-boot hook: privileges, ordering, exactly-once — **PROVEN**

- **`--on-first-boot stub.sh`, `[first-boot] source="from-iso" ordering="fully-up"`.** Unit is
  **`proxmox-first-boot-multi-user.service`** ("Fully Booted"),
  `After=… pveproxy.service pmgproxy.service proxmox-backup-proxy.service`.
- **Runs as root (uid 0).** Under **fully-up**, `pvesh get /nodes` returned a **complete** node object
  (status online, ssl_fingerprint present, uptime) `pvesh_rc=0`; `pct list` `pct_rc=0`; outbound
  `curl` hit the DooPlex listener — `GET /firstboot?host=spike310&uid=0 from 192.168.0.115` (network
  truly up). This is the ordering the real wrapper needs (it drives pveum/pct/pvesh).
- **Exactly-once mechanism:** unit has `ConditionPathExists=/var/lib/proxmox-first-boot/pending-first-boot-setup`
  and `ExecStartPost=/usr/bin/rm … pending-first-boot-setup`. Empirically: after a reboot the hook log
  is **byte-identical** (16 lines, 1 marker, sha256 unchanged), the unit is `inactive(dead)` with
  *"ConditionPathExists … was not met"* (`ConditionResult=no`), and the listener shows **exactly 1**
  firstboot hit.
- **Comparison leg — `ordering="network-online"`** (`proxmox-first-boot-network-online.service`):
  pvesh returned **degraded** data — `'/etc/pve/nodes/spike310/pve-ssl.pem' does not exist!`,
  `status:"unknown"`, `ssl_fingerprint:null` (pmxcfs/pveproxy not yet ready). Note `pvesh_rc=0` in
  **both** orderings — the exit code lies; only **fully-up** yields a coherent PVE stack.
- Evidence: `evidence/S3/firstboot-network-online.log`, listener log, `evidence/S1-S8-key-results.txt`.

## S4 — post-installation-webhook payload — **PROVEN** (full payload archived)

- `[post-installation-webhook] url="http://…:8088/webhook"` (plain HTTP; no cert-fingerprint required).
  Fired **near end of install, before reboot** (~2 m 42 s in). `POST … application/json`, `ureq/3.0.11`,
  **5052 B**, schema **v1.2**. Full body: `evidence/S4/webhook-payload.json`.
- **Identity fields the hub's unclaimed-appliance record can key on:**
  - `dmi.system.uuid` = `14a82ff6-…` — SMBIOS/product UUID (stable hardware id).
  - `network-interfaces[].mac` = `bc:24:11:98:10:0e` (+ `is-management:true`, DHCP `address`).
  - `machine-id` (regenerates per install — not a hardware id), `fqdn`, `boot-info.mode` ("bios"),
    `product.version` 9.2.2, `debian-version` 13.5, `cpu-info.model` "Intel(R) N100".
  - `disks[].udev-properties.ID_SERIAL / ID_SERIAL_SHORT / ID_PATH` — disk serials.
  - **`ssh-public-host-keys`** (ecdsa/ed25519/rsa) — lets the hub pin the box's SSH host keys for
    later TOFU-free access.
- **CAVEATS (design input #2):**
  - On **nested virt** `dmi.baseboard` is `{}`, `dmi.chassis.serial` and `dmi.system.serial` are **empty**.
    On real bare metal these carry vendor board/chassis/system serials — **unprovable here** (OPEN item).
  - **Webhook `disks[].size` is unreliable:** the 8 GB second disk was reported as 32 GB (it echoed the
    boot disk's size); the guest's own `lsblk` shows 8 GB. Key on disk **serials**, not `disks[].size`.

## S5 — disk-filter safety (LOAD-BEARING) — **PROVEN**

Two disks: sda=32 GB target, sdb=8 GB ext4 canary (`LABEL=FELHOM_CANARY`,
`UUID=1111…5555`, `canary.bin` sha256 `0d22f95b…`, `canary.txt` `42139528…`; `evidence/S5/canary-before.sha`).
Canary verified by **mounting the LV on the host and hashing**, not from installer output.

| Sub-run | Selection | Result |
|---|---|---|
| (a) | `disk-list=["sda"]` | installed on sda; sdb canary `sha256 -c: OK` (byte-identical), UUID unchanged. **PROVEN** |
| (b) | `filter.ID_SERIAL_SHORT="drive-scsi0"` (matches only sda) | installed on sda (lsblk: sda=pve LVM, sdb=ext4 FELHOM_CANARY); canary `OK`. **PROVEN** |
| (c) | `filter.ID_SERIAL_SHORT="no-such-disk-zzz"` (matches nothing) | **`ERROR: Installation failed: filter did not match any device` → `Auto-installation failed (exit-code 1)` → aborts to installer root shell.** No interactive fallback, **no disk touched**, canary intact. `evidence/S5/matchnothing-console.png` |

## S6 — first-boot from-url + cert-fingerprint pinning — **PROVEN (fails closed)**

- Stub served over **HTTPS with a self-signed cert** from DooPlex; `[first-boot] source="from-url"
  url="https://…:9443/stub.sh" cert-fingerprint=<SHA256> ordering="fully-up"`. The pin is checked at
  **install time** (in the installer environment), not first boot.
- **Correct fingerprint:** installer fetched the stub — HTTPS log
  `192.168.0.115 "GET /stub.sh HTTP/1.1" 200` — and it ran at first boot (uid 0, log present, unit active).
- **RED-PROOF — wrong fingerprint (`F6…`→`A6…`):** *"INFO: Fetching first-boot hook from https://…/stub.sh
  .. ERROR: Autoinstaller setup error: io: unexpected error: **Fingerprint did not match!** →
  Auto-installation failed (exit-code 1)"* — the **entire install aborts**; the stub was **never
  fetched** (HTTPS log empty) and never ran. `evidence/S6/redproof-console.png`.
- **Verdict: pinning FAILS CLOSED** — fit for the wrapper download story (from-url is safe; from-iso is
  not required for security).

## S7 — HTTP answer-fetch identity (stretch) — **PROVEN**

- `--fetch-from http --url http://…:8088/answer`. The installer **POSTs** for its answer:
  `application/json`, `ureq/3.0.11`, `accept: application/json, application/toml;q=0.5`, schema **v1.0**,
  **388 B**. Body carries: `dmi.system.uuid` `14a82ff6-…`, `network_interfaces[].mac`
  `BC:24:11:98:10:0E`, `product`/`iso` info, and (empty on virt) DMI serials — **but no disk info yet**
  (disks aren't enumerated at answer-fetch time). → option B (hub serves a per-customer answer) is
  viable, keyed on **MAC + SMBIOS UUID**. Full request in the listener log after the `S7-HTTPFETCH` marker.

## S8 — failure modes — **PROVEN**

- **(a) No DHCP / no link at first boot** (host-side `qm reboot` with NIC `link_down=1`; hook status
  read via the pending-flag over IPv6 link-local — no reboot needed):
  - **fully-up:** **hook RUNS** — flag consumed, new marker, pvesh works locally, outbound `curl` fails
    ("Could not connect"). Boot reaches login. → robust to "customer's router is off".
  - **network-online:** **hook does NOT run** — flag persists, unit `inactive(dead)` (gated on
    `network-online.target`, unsatisfied without a DHCP-configured link); boot still reaches login
    (**no infinite hang**). Hook stays armed for a later boot once network returns.
  - *(Method note: a first attempt set `link_down=1` before an ssh `reboot`, which then couldn't reach
    the guest — the reboot never fired. Redone host-side. The two "*-nonet-console.png" captures are
    from that stale boot and are superseded by the flag-check result in `evidence/S1-S8-key-results.txt`.)*
- **(b) Invalid answer — `disk-list=["sdz"]` (non-existent disk):** validate-answer **passed** it
  (syntactic only); at install: *"ERROR: Installation failed: disk in 'disk-selection' not found →
  Auto-installation failed (exit-code 1)"*, aborts to shell, no disk touched, **no interactive
  fallback**. `evidence/S8/invalid-disk-console.png`.

## 13. Evidence

All under `180:~/spike-baremetal/evidence/`: per-leg console PNGs (S2/S2b/S5/S6/S8), the full webhook
JSON (`S4/webhook-payload.json`), the request-logging listener (`listener.log`) and HTTPS access log
(`https.log`), canary hashes (`S5/canary-before.sha`), and the consolidated
`S1-S8-key-results.txt`. Console captures were taken with QEMU-monitor `screendump` → PPM → PNG
(felhom-pve has no image tools; converted on DooPlex). Prepared ISOs + source ISO retained under
`180:~/spike-baremetal/iso/`.

## 15. Design inputs for R-21 (bare-metal ISO) — *(the spike brief calls this feature "R-22")*

1. **First-boot ordering → `fully-up`.** Proven: under fully-up, pvesh returns a *complete* node object
   and pct works (S3); under network-online pvesh is degraded (`pve-ssl.pem` missing) and the hook does
   not even run without a DHCP link (S8a). The wrapper (drives pveum/pct/pvesh) must use fully-up. The
   exactly-once guard is `ConditionPathExists=…/pending-first-boot-setup` + `ExecStartPost=rm` — re-arm
   by re-touching that flag if a redo is ever needed.
2. **Webhook keys for the unclaimed-appliance record:** `dmi.system.uuid` (SMBIOS/product UUID) +
   management-NIC `mac` are the stable pair; also archive `ssh-public-host-keys` (host-key pinning),
   `fqdn`, `machine-id` (per-install, not hardware), `cpu-info.model`, disk `ID_SERIAL`. **Do not key on
   `disks[].size`** (unreliable — S4). Real board/chassis/system serials are empty on virt → confirm on
   bare metal before relying on them.
3. **Disk-selection policy for alpha:** both `disk-list=["sda"]` and a udev `filter.*` glob install only
   the intended disk and leave other disks **byte-identical** (S5 a/b, proven by host-side hashing). A
   **match-nothing** filter and a **non-existent disk name** both **fail-safe**: abort with exit-code 1,
   touch no disk, no interactive fallback (S5c, S8b). Recommendation: **single-disk mini-PC → a
   size/model `filter.*` glob** (portable across identical hardware, self-documenting), with the
   match-nothing abort as the built-in wrong-hardware guard. Add a pre-install **LVM/partition wipe**
   step (or document it) — the installer does not force-clear a prior LVM PV (S2b re-imaging gotcha).
4. **UEFI / Secure Boot:** no caveat required. Automated install and the installed PVE both work under
   UEFI with Secure Boot **enforcing** (pre-enrolled MS keys), zero keypress, no MOK prompt (S2b). A
   single install image is BIOS+UEFI-bootable.
5. **Pinning verdict:** `from-url` + `cert-fingerprint` is **fit** — a wrong pin aborts the whole
   install (fails closed, S6). The wrapper/stub may be fetched from a URL with a pinned fingerprint;
   `from-iso` (baking) is not required for security. Note the cert-fingerprint format is the
   colon-separated uppercase SHA256 (`openssl x509 -fingerprint -sha256`).
6. **Assistant↔ISO pairing + build cost → `build-felhom-iso.sh`:** install the assistant from the PVE
   `no-subscription` repo of the ISO's Debian codename (PVE 9.x = trixie); pair by major.minor. Per
   prepared ISO ≈ 11.5 s + ~1.7 GB artifact; the container build is ~40 s one-time. The pipeline runs
   fine on DooPlex (the build host). **Gate on parsed `validate-answer` output, never `$?`** (exit 0 on
   failure). Remember `validate-answer` is syntactic only — disk existence is a runtime check.
7. **Failure-mode table (customer-facing "what if it goes wrong"):**

   | Scenario | Behavior | Customer/operator signal |
   |---|---|---|
   | Wrong hardware — filter matches nothing | install aborts, exit 1, no disk touched | box never finishes install; stays on ISO/shell |
   | Non-existent disk in answer | install aborts, exit 1, no disk touched | same |
   | Prior LVM/OS on disk | install aborts ("cannot init PV") | needs a wipe step (see #3) |
   | Wrong cert-fingerprint (from-url) | whole install aborts, exit 1 | box never finishes install |
   | No internet at first boot (fully-up) | hook runs; net-dependent steps fail-and-retry | box installed, wrapper fired, retries when net returns |
   | No internet at first boot (network-online) | hook does not run; boot OK | avoid this ordering |

8. **OPEN — needs one real bare-metal run (not provable on nested virt):**
   - Real vendor **DMI serials** (baseboard/chassis/system) in the webhook + answer-fetch payloads
     (empty on QEMU) — decide whether to key on them.
   - Real firmware quirks: vendor UEFI boot-entry handling, whether the disk-first→CD fall-through works
     on a real board (worked on SeaBIOS and OVMF here), and Secure Boot with a *vendor's* (non-MS-clean)
     key set.
   - Real-NIC naming (this VM enumerated `ens18`) and multi-NIC `is-management` selection.
   - Real DHCP behavior / captive networks; USB-stick boot media (we booted a virtual CD).

9. **Observations (recorded, not acted on):**
   - `validate-answer` exit-0-on-failure is arguably a Proxmox bug; regardless, the pipeline must parse output.
   - The probe stub's `curl_rc` reflected the trailing pipe stage, not curl — rely on curl's error text.
   - Webhook `disks[].size` mis-report (see #2) is a Proxmox data-quality issue worth reporting upstream.
   - `machine-id` regenerates per install → not a durable hardware identity for re-imaged boxes.

## Cleanup state

Spike VM 310 **stopped, kept** (config intact, s8b ISO still attached). ISOs + evidence retained under
`180:~/spike-baremetal/`. DooPlex helper listeners (8088 answer/webhook, 9443 HTTPS, 8099 file-server)
were used only during the spike and may be stopped. Nothing else on either host was modified.
