SPIKE 2 complete: locked root closes the PVE web UI; before-network gives a measured zero window

Findings only — no script, profile or build file changed; no ISO built, nothing published.
documentation/audits/SPIKE-universal-iso-2-2026-07-31.md

Both Tier 0 boxes went offline mid-session (provider cable fault; four routes tried, no Tier 2
fallback used) and returned. All three scenarios then ran to completion on real PVE, each signalled
by reboot-mode='power-off' rather than a disk hash.

- A LOCKED ROOT CLOSES THE PVE WEB INTERFACE. Measured at the exact endpoint the UI uses
  (POST /api2/json/access/ticket, root@pam) WITH A WORKING CONTROL: known-password install returns
  HTTP 200 + ticket; locked install returns 401 for every password and none can exist.
  passwd -S root = L, shadow = literal-asterisk, PVE uses the stock PAM stack.
- GRUB recovery mode is also closed ('the root account is locked') — but init=/bin/bash still gives
  an unauthenticated root@(none):/#. A locked box is recoverable, operator-only, at the console.
  The installed GRUB has NO password, so locking root is not a physical-security measure. R-152.
- before-network MEASURED (A/B, same image): the hook RUNS (marker, uptime 6.58s) with entropy 256,
  writable /etc, all binaries and openssl_rand_len=32, while ip_global is EMPTY and
  listen_22_8006 = 0. fully-up is the converse: sshd+pveproxy active, 3 listening. Zero window.
- R-148: answer.toml.tmpl:27 justifies fully-up with a pvesh/pct dependency the stub does not have
  (grep rc=1) — it blocked the ordering now measured as the fix.
- R-149 three ordering values; R-150 Condition-guarded hooks skip silently; R-151 demo-felhom built
  from an uncommitted profile.

Three probes failed and are recorded as failed: a container probe that ran as uid 0, a GRUB probe
that missed the 1-second menu timeout, and a kernel-line edit one line off (caught by a pre-typing
verification screendump). The interim 'Layer 1 teardown INCOMPLETE' is corrected — the fixture had
never landed, because the staging mkdir was in the SSH call that timed out.
This commit is contained in:
2026-07-31 13:57:21 +02:00
parent 5bdd8372f8
commit 19c932a693
3 changed files with 424 additions and 388 deletions
+63 -69
View File
@@ -1,95 +1,89 @@
# REPORT — SPIKE 2: closing the last mechanisms, and the universal ISO's shape (2026-07-31)
> Written as `REPORT-universal-iso-spike.md`, not `REPORT.md`: the shared file belongs to today's hub
> v0.85.0 session and the standing rule is that the second session in a shared clone never touches it.
> This file supersedes its own Spike 1 contents.
> v0.85.0 session and the second session in a shared clone never touches it.
**Class: Spike.** Findings only — no production file changed, no ISO built, nothing published.
Evidence: `documentation/audits/SPIKE-universal-iso-2-2026-07-31.md`. Continues
`SPIKE-universal-iso-2026-07-31.md`.
Evidence: `documentation/audits/SPIKE-universal-iso-2-2026-07-31.md`.
## The headline: the venue went away, and three answers are blocked
**All four questions are answered**, and every answer that matters is measured on a real PVE install.
The session lost both Tier 0 boxes mid-run (provider cable fault; four routes tried, no Tier 2
fallback used) and resumed when connectivity returned.
At 12:28 CEST both Tier 0 boxes went offline **simultaneously** and did not return. Four routes were
tried (tailnet direct, `ssh demo-hp`, `demo-hp-lan` via ProxyJump, `ssh felhom-pve`); our tailscale
pod is healthy and `tailscale status` shows both peers `offline` with a rising `last seen`. Both boxes
are at the same remote site — that site's power or uplink, not fixable from here.
## The two results that decide the product questions
**No fallback venue was used.** DooPlex is Tier 2, the 2026-07-25 ruling moved drill VMs off it, and
this task did not authorise it — an absent fence is not permission. So **Q1, Q2 and Q3 each keep a
part that is BLOCKED, not answered**, and they are labelled that way throughout. The fixture is built
and staged with Spike 1's two measurement defects already corrected (a NIC present at install time; a
`reboot-mode = "power-off"` completion signal instead of a disk hash).
**A locked root closes the PVE web interface.** Measured against the exact endpoint the UI logs in
through — `POST /api2/json/access/ticket`, realm `root@pam`**with a working control**: the
known-password install returns **HTTP 200 and a ticket**; the locked install returns 401 for every
password offered, and no password exists that could work. The control is what makes this a finding
rather than a broken probe. `passwd -S root` reports `L`, the shadow field is `literal-asterisk`, and
PVE uses the stock PAM stack.
## What was established anyway
**GRUB's recovery mode is closed too**`Cannot open access to console, the root account is locked.`
**but `init=/bin/bash` still works**, giving an unauthenticated `root@(none):/#`. So a locked-root
box is *not* unrecoverable, as the structural reading had feared; it is recoverable **operator-only, at
the physical console**. The installed GRUB has no password, which also means locking root is not a
physical-security measure and must never be described as one (**R-152**).
**The best result of the session, and it is structural rather than measured — which makes it
stronger.** `ordering = "before-network"` maps to `proxmox-first-boot-network-pre.service`
(`proxmox-first-boot` 9.2.5, read out of the ISO's own package), which is `Before=network-pre.target`
with `Type=oneshot`. It therefore runs **to completion before any network interface is configured**.
A credential rotation placed there has a **zero-length exposure window by construction, not by being
fast** — and that holds on every machine, unlike the timing number Spike 1 failed twice to obtain.
## The mechanism that fixes the credential window
**`before-network` measured, A/B, same image, only `ordering` differing.** The hook **runs** (positive
marker, uptime 6.58 s) with entropy 256, a writable `/etc`, every binary it needs and a demonstrated
`openssl_rand_len=32` — while `ip_global` is **empty** and `listen_22_8006` is **0**. The `fully-up`
column is the converse proof: sshd and pveproxy already active, 3 sockets listening. **Zero credential
window, measured**, and the shipped unit (`Before=network-pre.target`, `Type=oneshot`) generalises it
to every machine.
**And the reason it was never used is a comment that is wrong.** `answer.toml.tmpl:27` justifies
`fully-up` as needed "so pvesh/pct work" but `stub-first-boot.sh` makes no `pvesh`/`pct`/`pveum`/`qm`
call at all (grep `rc=1`). That usage lives in `felhom-bootstrap.sh`, which runs under its own
`After=network-online.target … pveproxy.service` unit. A comment asserting a **dependency** that does
not exist, which has constrained the design ever since. **R-148.**
`fully-up` as needed "so pvesh/pct work", but `stub-first-boot.sh` makes no `pvesh`/`pct`/`pveum`/`qm`
call at all (grep `rc=1`); that usage lives in `felhom-bootstrap.sh`, under its own properly-ordered
unit. **R-148** — a comment asserting a *dependency* that does not exist, which blocked the fix.
**A locked root closes `sulogin`** — measured in an isolated container with a working control (correct
password → `SU_OK`, wrong → `Authentication failure`; then `*` and `!` both → `L` and
`Cannot open access to console, the root account is locked.`). This reshapes the escrow question: if
nobody holds the password, `sulogin` refuses either way, so **"discard" and "lock" are the same
outcome for recovery**, and the decision is binary. Caveat stated plainly: container, not PVE.
## Probes that failed, recorded as failed
**The ordering enum has three values, not two**`before-network`, `network-online`, `fully-up`.
Spike 1 recorded two; the middle one has never been evaluated (**R-149**).
Three, none massaged: a container probe that ran as uid 0 so `su` never invoked `pam_unix` (rc=0 even
with a wrong password); a GRUB probe that missed the **1-second** menu timeout and typed into the login
prompt; and a kernel-line edit that landed one line off — caught by a pre-typing verification
screendump before it was booted. All were rerun.
**A first-boot hook that never runs is indistinguishable from one that succeeded** — all four
`proxmox-first-boot-*` units are `Condition*`-guarded and a failed condition is a *skip*, not a
failure. Any rotation-in-first-boot design must verify by a positive marker (**R-150**).
Also corrected: the interim report's "Layer 1 teardown INCOMPLETE" was over-cautious. When demo-hp
returned, `/mnt/nvme-1tb/spike2/` did not exist and disk usage was unchanged — the staging `mkdir` had
been inside the SSH call that timed out as the link degraded, so **the fixture never landed**.
**Q4 answered fully from source.** The single-entry menu is enforced by **four** gates in
`iso-repack.sh` (`:153-155`, `:156`, `:160-164`, and a post-remaster re-extraction at `:314-319`), so
restoring an interactive entry is a gate change rather than a template edit — but the banned-token
gate would pass unchanged for a stock *Graphical* entry, so the safety property survives. `default`
and `timeout` are already settable in `grub/grub.cfg.tmpl`.
## The operator's decisions
## What failed, and is recorded as failed
**Escrow versus discard — no longer blocked, and reshaped twice.** "Discard" and "lock" are the same
outcome (nobody holds the password, so both `sulogin` and the web UI refuse), and **neither makes a box
unrecoverable** because `init=/bin/bash` always works. So escrow buys only this: the operator keeps the
web-UI and `sulogin` routes without physical presence. Against that stands D6 — a hub holding console
root for the whole fleet is what the hub alone must not be. **I recommend discard/lock for the public
image**, escrow remaining defensible for operator-built boxes. Your call; it is no longer waiting on a
measurement.
The first mechanism probe produced **zero valid observations**: it ran everything as uid 0, so `su`
never invoked `pam_unix` (rc=0 even with a wrong password), `sulogin` died on `tcgetattr` with no tty,
and a `sed` mangled the control hash. Rerun with a non-root caller, a real pty, and `chpasswd`.
**The default boot entry — framed, unruled.** The disk evidence (Spike 1) and the recovery evidence
(this session) now point the same way: a tester whose unattended install succeeds but whose pairing
fails has no web UI and no recovery shell. The cost is the unattended experience. Two profiles from one
build is cheap, so operator-built and public need not match.
A **teardown error** is also recorded rather than quietly fixed: shredding the control password
destroyed the plaintext scenario C existed to use, so `C.toml` was deleted rather than left looking
usable. It must be regenerated before the PVE-web-interface measurement is attempted.
## Still open
## The two decisions, and why neither should be ruled yet
**Escrow versus discard** — now binary (see above). **Blocked on one measurement:** whether a locked
root also closes the PVE web interface. If it does, discarding removes the last remote route as well
as the local one and the balance shifts sharply toward escrow. **Do not rule before that is measured.**
**The default boot entry for a public image** — the disk evidence points at interactive-default; the
cost is the unattended experience. Partially blocked on the same measurement. The
operator-built-versus-public split is cheap and available regardless of the ruling.
One gap in Q3: **Felhom's real stub at `before-network`** is argued structurally, not observed — the
scenarios used the spike's own diagnostic as the hook. That is the one thing to measure before a spec
is written (~40 min). Also: the SSH-as-root leg used a wrong password in the control too, so it
discriminates nothing and is recorded as a gap rather than a result.
## R-rows
**R-148** (`fully-up` justified by a non-existent dependency) · **R-149** (three ordering values, not
two) · **R-150** (Condition-guarded first-boot hook skips silently) · **R-151** (demo-felhom was
installed from an **uncommitted** profile — a Tier 0 reference box is not reproducible from `main`).
**R-148** (`fully-up` justified by a non-existent dependency) · **R-149** (three ordering values) ·
**R-150** (Condition-guarded hooks skip silently) · **R-151** (demo-felhom built from an uncommitted
profile) · **R-152** (a locked root is not a physical-security measure).
## Teardown
**Layer 1 (demo-hp) is INCOMPLETE and named as such** — the box went offline while
`/mnt/nvme-1tb/spike2/` held the staged fixture. Nothing was running on it (last check: 0 QEMU
processes, no install started this session), nothing on `local-lvm`, no PVE storage registered,
`drill-r50` and 9201 never touched. The outstanding command is recorded in §10 of the findings doc
and **is not claimed as done** — R-131 exists because recorded commands never get run.
Layer 2 (demo-felhom): nothing placed this session. Layer 3 (DooPlex): scratchpad 4.4 GB → 48 K,
control password `shred -u`'d and verified absent, mechanism container `--rm`, no global Docker
cleanup, `felhom-iso/out/` untouched at 17 ISOs, no production file modified. **Hub-side: nothing
created** — verified by fetching the customer list and searching it, not asserted. Nothing published.
All three layers verified positively. demo-hp is back to its exact pre-spike state: `spike2/` and
`/tmp/paia` removed, 0 QEMU processes, 0 loop devices, **disk usage 6.6 G — identical to pre-spike**,
4 storages unchanged, nothing on `local-lvm`, `drill-r50` stopped and 9201 running, both untouched.
demo-felhom: probe binary removed, external USB drive observed only, still mounted as found. DooPlex:
scratchpad 1.7 GB → 60 K, control password `shred -u`'d and verified absent, `felhom-iso/out/`
untouched at 17 ISOs, no production file modified. **Hub-side: nothing created** — the VMs had no route
to it. Nothing published.
@@ -4,201 +4,290 @@
>
> Continues `SPIKE-universal-iso-2026-07-31.md`; its results are premises here and are not re-derived.
>
> **Read §0 first: the venue went away mid-session.** Both Tier 0 hosts dropped off the tailnet at
> ~12:28 CEST and did not return. Q1, Q2 and Q3 each have a part that needs a nested VM, and those
> parts are **BLOCKED, not answered**. What could be established without them — from the shipped PVE
> source, the answer schema, and an isolated mechanism probe — is below and is labelled by how it was
> obtained.
> **All four questions are answered, and every answer that mattered is measured on a real PVE install.**
> The session lost its venue mid-run (§0) and resumed when connectivity was restored.
---
## 0. Venue loss — what was tried
## 0. Venue loss and recovery — what was tried
At 12:28 CEST, mid-run, both Tier 0 boxes went offline **simultaneously**:
```
100.76.96.79 demo-hp active; relay "fra"; offline, last seen 5m ago, tx 1843821564 rx 63739848
100.70.170.35 felhom-pve active; relay "waw"; offline, last seen 5m ago, tx 1560 rx 0
```
Attempts, in order (per the standing rule that a "no access" claim names what it tried):
At 12:28 CEST both Tier 0 boxes went offline **simultaneously**. Routes attempted, in order:
| # | Route | Result |
|---|---|---|
| 1 | `ping 100.76.96.79` (tailnet direct) | 100 % packet loss |
| 2 | `ssh demo-hp` (tailnet, `ConnectTimeout=10`) | `connect to host 100.76.96.79 port 22: Connection timed out` |
| 3 | `ssh demo-hp-lan` (LAN `192.168.0.87`, ProxyJump `felhom-pve`) | `Connection timed out during banner exchange` |
| 4 | `ssh felhom-pve` (the jump host itself) | `connect to host 100.70.170.35 port 22: Connection timed out` |
| 5 | DooPlex tailscale pod health | pod `tailscale-c84df7f84-tw2wz` **Running 6d10h**, healthy; log shows `open-conn-track: timeout … online=yes` then peers dropping |
| 6 | `tailscale status` from the pod | **both peers `offline`**, `last seen 5m` and rising |
| 1 | `ping 100.76.96.79` (tailnet direct) | 100 % loss |
| 2 | `ssh demo-hp` | `Connection timed out` |
| 3 | `ssh demo-hp-lan` (LAN, ProxyJump `felhom-pve`) | `Connection timed out during banner exchange` |
| 4 | `ssh felhom-pve` (the jump host) | `Connection timed out` |
| 5 | DooPlex tailscale pod | `Running 6d10h`, healthy |
| 6 | `tailscale status` from the pod | **both peers `offline`**, `last seen` rising |
**Diagnosis: the far end, not ours.** Our tailscale pod is healthy and every other peer route behaves;
both demo boxes are at the same remote site (REMOTE until ~08-02) and went offline together, which is
that site's power or uplink. Not fixable from here.
Far end, not ours. **No fallback venue was used** — DooPlex is Tier 2, the 2026-07-25 ruling moved
drill VMs off it, and this task did not authorise it. An absent fence is not permission.
**No fallback venue was used.** DooPlex is Tier 2 and the 2026-07-25 standing ruling moved drill and
build VMs off it; `target-selection.md` says a Tier 2 machine is used only when a task says so
explicitly, and this one does not. **An absent fence is not permission**, so the probes simply did not
run. A watch is armed for the boxes' return.
Connectivity was restored (operator: a provider cable fault nearby) and the boxes returned with
**uptime 7 days** — a pure network outage, no reboot. All probes then ran.
The probe fixture is built and staged and will run unchanged when they come back: probe ISO
(`prepare-iso --fetch-from partition`, serial-capture first-boot diagnostic), three validated answer
files (locked/`fully-up`, locked/`before-network`, known-password control), an install runner with
`reboot-mode = "power-off"` as the completion signal, and a boot runner with protocol-level detectors
and **a NIC present at install time** — the two fixture defects that invalidated Spike 1's
measurements are already corrected.
**One thing the outage revealed.** `/mnt/nvme-1tb/spike2/` did not exist when demo-hp came back, and
disk usage was unchanged at 6.6 GB: the staging `mkdir` had been inside the SSH call that timed out as
the link degraded, so **the fixture never landed and Layer 1 had nothing to clean**. The interim
report's "Layer 1 teardown INCOMPLETE" was therefore over-cautious rather than wrong — corrected here,
and the fixture was rebuilt from scratch.
---
## 1. Baselines
Read fresh this session.
| Baseline | Value |
|---|---|
| `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` |
| PVE base | `proxmox-ve_9.2-1.iso`, `4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c` |
| `felhom.eu` HEAD | **`ea00976`**, clean, `== origin/main` (the expected Spike 1 commit) |
| `felhom.eu` HEAD | **`ea00976`**, clean, `== origin/main` |
| `proxmox-first-boot` pkg | `9.2.5` (from the ISO's `/proxmox/packages/`) |
| installed guest kernel | `7.0.2-6-pve`, GRUB `2.12-9+pmx2` |
**Fixture provenance.** Probe ISO built on DooPlex from the baseline PVE ISO. Nested VMs were to run
under plain QEMU/KVM on demo-hp under `/mnt/nvme-1tb/spike2/` — never `local-lvm`, no PVE storage
registered. They did not run (§0). The mechanism probe in §2.2 ran in a throwaway `debian:trixie`
Docker container on DooPlex, which is not an install and touches no box.
**Fixture provenance.** Probe ISO built on DooPlex from the baseline PVE ISO
(`prepare-iso --fetch-from partition --partition-label proxmox-ais --on-first-boot diag.sh`),
sha256 `dd97dba5518c70baf4300b119e1042eabf1012d5a046082acf31e60983efaeb9`, **verified byte-identical at
both ends** after transfer. Nested VMs ran under plain QEMU/KVM on **demo-hp** (Tier 0, the designated
drill host) under `/mnt/nvme-1tb/spike2/` — never `local-lvm`, no PVE storage registered, `drill-r50`
and guest 9201 untouched.
**Evidence classes used throughout:** **[MEASURED-PVE]** against the real PVE tooling or a PVE install ·
**[MEASURED-MECH]** measured, but in an isolated container rather than on PVE · **[STRUCTURAL]** read
from shipped source or unit files · **[BLOCKED]** needs the nested VM.
**Both of Spike 1's fixture defects are corrected in this one:** a NIC is present **at install time**
(its absence made the installed system's interface config unmatchable at boot), and completion is
signalled by `reboot-mode = "power-off"` — the guest powering itself off — never by a changed disk hash.
Three scenarios, all installed to completion:
| Scenario | root credential | `[first-boot].ordering` | Install |
|---|---|---|---|
| **A** | `root-password-hashed = "*"` (locked) | `fully-up` | completed, powered off after **191 s** |
| **B** | `root-password-hashed = "*"` (locked) | `before-network` | completed, powered off after **190 s** |
| **C** | a **known** password (control) | `fully-up` | completed, powered off after **191 s** |
**Evidence classes:** **[MEASURED-PVE]** on a real PVE install · **[MEASURED-MECH]** measured in an
isolated container · **[STRUCTURAL]** read from shipped source or unit files.
---
## 2. Q1 — is a locked root actually locked, and what else does it lock out?
### 2.1 The account's state on a real PVE install — **[BLOCKED]**
### 2.1 The account's real state — **[MEASURED-PVE]**
Needs the nested VM. The fixture is staged. Spike 1 established only that
`root-password-hashed = "*"` **installs to completion**; the resulting account state was explicitly
left open there and remains open.
From the first-boot diagnostic on both locked installs (A and B), emitted to `ttyS0`:
### 2.2 What can authenticate — **[MEASURED-MECH]**, and the first attempt was invalid
```
passwd_S_root : root L 2026-07-31 0 99999 7 -1
shadow_shape : literal-asterisk
sshd_PermitRoot : yes
pam_common_auth : 1
pve_pam_realm : 0
```
**The first probe produced nothing and is recorded as failed.** It ran everything as uid 0, so `su`
never invoked `pam_unix` at all (`rc=0` even with a deliberately wrong password); `sulogin` died on
`tcgetattr failed: Invalid argument` with no TTY; and a `sed` mangled the control hash so the control
case had no password set. Four cases, zero valid observations. Rerun with a non-root caller, a real
pty via `script`, and `chpasswd` instead of `sed`.
**The account is genuinely locked**`passwd -S` reports `L`, and the `*` reached `/etc/shadow`
verbatim. Note `sshd_PermitRoot: yes`: PVE ships `PermitRootLogin yes`, so the lock is doing the work,
not sshd's configuration.
**Corrected probe** (`debian:trixie`, `util-linux 2.41`, `pam_unix`) — with a working control:
`pve_pam_realm: 0` — there is no PVE-specific file in `/etc/pam.d/` matching `proxmox|pve`, and
`pam_unix` is present in `common-auth`. **PVE uses the stock PAM stack**, which is why the §2.2
container measurement transfers to it rather than merely resembling it.
| Shadow field | `passwd -S root` | `su root` as non-root user | `sulogin` (real pty) |
### 2.2 What can authenticate — **[MEASURED-MECH]**, first attempt invalid
**The first probe produced nothing and is recorded as failed:** it ran everything as uid 0 so `su`
never invoked `pam_unix` (`rc=0` even with a deliberately wrong password), `sulogin` died on
`tcgetattr failed` with no TTY, and a `sed` mangled the control hash. Four cases, zero observations.
Corrected (`debian:trixie`, `util-linux 2.41`, non-root caller, real pty via `script`, `chpasswd`):
| Shadow field | `passwd -S root` | `su root` as a non-root user | `sulogin` (real pty) |
|---|---|---|---|
| real hash (control) | `P` | **correct pw → `SU_OK`**; wrong pw → `Authentication failure` | — |
| `*` | **`L`** | `su: Authentication failure` (any password) | **`Cannot open access to console, the root account is locked.`** |
| `!` | **`L`** | `su: Authentication failure` (any password) | **`Cannot open access to console, the root account is locked.`** |
| real hash (**control**) | `P` | **correct pw → `SU_OK`**; wrong pw → `Authentication failure` | — |
| `*` | `L` | `Authentication failure` (any password) | `Cannot open access to console, the root account is locked.` |
| `!` | `L` | `Authentication failure` (any password) | `Cannot open access to console, the root account is locked.` |
The control is what makes this readable: the same harness authenticates successfully with the right
password and fails with the wrong one, so the two locked rows are the module refusing, not the harness
misfiring.
The control makes it readable: the same harness succeeds with the right password and fails with the
wrong one, so the locked rows are the module refusing rather than the harness misfiring. **`*` and `!`
are equivalent.**
**So, at mechanism level: `*` and `!` are equivalent, both report `L`, and no password authenticates.**
**On PVE, root SSH was refused on every scenario** — but that row is **uninformative and is not
evidence**: the probe supplied a deliberately wrong password in the control scenario too, so it cannot
distinguish "locked" from "wrong password". Recorded as a gap, not a result.
**Caveat, and it is not small.** This is Debian's `pam_unix` and `util-linux`'s `sulogin` — the same
components PVE ships — but it is **not PVE**. PVE could ship PAM configuration, a `sulogin` override,
or a `SYSTEMD_SULOGIN_FORCE` setting that changes the outcome. Confirming on a real PVE install is
part of the blocked work.
### 2.3 Does the Proxmox web interface still work? — **[MEASURED-PVE]. No. It is closed.**
### 2.3 Does the Proxmox web interface still work? — **[BLOCKED]**, and this is the product question
The product-level answer, measured against the **exact endpoint the web UI logs in through**
(`POST /api2/json/access/ticket`, realm `root@pam`), with a working control:
**No measurement was obtained, so no answer is given.** Stating the reasoning without the measurement
would be exactly the failure mode this spike exists to avoid.
| Scenario | Password offered | Result |
|---|---|---|
| **C (control, known password)** | **correct** | **HTTP 200, TICKET ISSUED — login would SUCCEED** |
| C (control) | wrong | HTTP 401, no ticket |
| C (control) | empty | HTTP 401, no ticket |
| **A (locked root)** | wrong | HTTP 401, no ticket |
| **A (locked root)** | empty | HTTP 401, no ticket |
What is established: PVE's `root@pam` realm authenticates the Linux root account through PAM, and
§2.2 measured that `pam_unix` refuses a locked account for a non-root caller. If PVE's proxy
authenticates the same way — **which was not verified** — then a locked root means **nobody can log
into the PVE web interface at all**, neither customer nor operator, and that is a product decision
rather than a detail.
The control is what makes this a finding rather than a broken probe: **the same endpoint on the same
image demonstrably issues a ticket when the password is right.** On the locked install no password can
produce one, because no password exists.
The staged control scenario exists precisely to settle this: install C carries a **known** password, so
the PVE API ticket endpoint can be exercised positively (correct password → ticket) before install A
(locked) is tested against the same endpoint. Without that control a failure is indistinguishable
from a broken probe — Spike 1's lesson.
**Therefore a locked root closes the PVE web interface completely to the customer and to the
operator alike.** This is a product decision, not a detail: it removes the browser-based route into
the box entirely, on top of the console route (§3.2).
### 2.4 Does anything else in PVE break? — **[BLOCKED]**
Reachability, incidentally measured on all three: **SSH banner at +20 s, pveproxy TLS at +2629 s**
after power-on.
Not investigated. Cron, `pveproxy`, backup jobs and anything else assuming a usable root credential
remain unexamined.
### 2.4 Does anything else in PVE break? — **not investigated**
Cron, backup jobs and other consumers of a root credential were not examined. `pveproxy` itself runs
and serves TLS (it answered `/api2/json/version`), so the daemon is unaffected — only authentication
through it is.
---
## 3. Q2 — is there a way back into a box whose setup failed?
### 3.1 Does the installed GRUB expose a recovery entry? — **[BLOCKED]**
### 3.1 Does the installed GRUB expose a recovery entry? — **[MEASURED-PVE]. Yes.**
Needs a boot. Note the installed system's GRUB is **PVE's own**, written by the installer — not the
ISO's Felhom menu — so §5's single-entry collapse does not apply to it. What it offers is unverified.
### 3.2 `sulogin` with a locked root — **[MEASURED-MECH]. It refuses.**
The single observation the escrow-versus-discard ruling was said to rest on:
The installed system's GRUB is PVE's own, unaffected by Felhom's ISO menu collapse (§4). Screendumped
from the locked install:
```
*Proxmox VE GNU/Linux
Advanced options for Proxmox VE GNU/Linux
Memory test (memtest86+x64.bin)
Memory test (memtest86+x64.bin, serial console)
```
and inside *Advanced options*:
```
*Proxmox VE GNU/Linux, with Linux 7.0.2-6-pve
Proxmox VE GNU/Linux, with Linux 7.0.2-6-pve (recovery mode)
```
**A recovery entry exists, the menu is editable (`e`), a GRUB command line is available (`c`), and
there is no GRUB password.** Its timeout is **1 second** — short enough that a first probe missed it
entirely (§3.5).
### 3.2 `sulogin` with a locked root — **[MEASURED-PVE]. It refuses.**
Booting *(recovery mode)* on the locked install reaches rescue mode and then stops:
```
Started rescue.service - Rescue Shell.
Reached target rescue.target - Rescue Mode.
You are in rescue mode. After logging in, type "journalctl -xb" …
Cannot open access to console, the root account is locked.
See sulogin(8) man page for more details.
Press Enter to continue.
```
Both for `*` and for `!`, with a real pty. **The standard Debian/systemd emergency and single-user
path does not grant a shell on a locked-root system.** `systemd` invokes `sulogin` for
`emergency.target` and `rescue.target`, so both are closed by the same refusal.
**The advertised recovery path is closed on a locked-root box** — verbatim the container result,
now confirmed on real PVE. `systemd` invokes `sulogin` for both `rescue.target` and
`emergency.target`, so both are closed by the same refusal.
Subject to the §2.2 caveat: measured in a container, not on PVE.
### 3.3 What is left — **[MEASURED-PVE]. `init=/bin/bash` works.**
### 3.3 What is left if `sulogin` refuses — **[STRUCTURAL]**, not measured
Appending `init=/bin/bash` to the kernel line in the GRUB editor and booting produced:
| Path | Realistic for whom |
|---|---|
| `init=/bin/bash` on the kernel line (GRUB `e`) | **Operator only.** It bypasses `sulogin` because PID 1 becomes the shell — no authentication is involved. Requires editing a boot entry at the console. Not a non-technical customer action. |
| `systemd.unit=emergency.target` | **Closed** — it lands in `sulogin`, which refuses (§3.2). |
| Live USB / rescue media | Operator only; requires physical media and knowing to do it. |
| PVE web console (`:8006`) | **Unknown, possibly closed** — §2.3. |
| SSH by key | Only if a key was baked, which for a public image is the thing R-142 says must not happen. |
```
Found volume group "pve" using metadata type lvm2
3 logical volume(s) in volume group "pve" now active
/dev/mapper/pve-root: clean, 60332/561936 files, 1141450/2243584 blocks
bash: cannot set terminal process group (-1): Inappropriate ioctl for device
bash: no job control in this shell
root@(none):/#
```
**The honest summary:** on a locked-root box, every remaining route is operator-grade and physical.
For a customer whose pairing failed, there is no self-service path back — which is precisely the
objection raised when locked-root was first proposed, and §3.2 is the first evidence that the
objection is well founded rather than speculative.
**An unauthenticated root shell on a locked-root system.** No password is involved because PID 1 *is*
the shell — `sulogin` is never reached.
### 3.4 Does locked differ from rotated? — **[PARTIALLY ESTABLISHED]**
| Path | Works? | Realistic for whom |
|---|---|---|
| PVE web console (`:8006`) | **No** (§2.3) | — |
| GRUB *(recovery mode)*`sulogin` | **No** (§3.2) | — |
| GRUB editor → `init=/bin/bash` | **Yes** (measured) | **Operator only.** Requires catching a 1-second menu, editing a kernel line, and knowing the incantation. Not a non-technical customer action. |
| Live USB / rescue media | not tested | Operator only |
| SSH by key | n/a | Only if a key were baked — which R-142 says must not happen in a public image |
At mechanism level, yes, and materially:
**So a locked-root box is recoverable, but only by someone competent at the physical console.** That is
a materially weaker objection than "no path back at all", which is what the structural reading
suggested before it was measured.
- **Locked (`*`/`!`)** — `sulogin` refuses; console recovery is closed to everyone; the credential
does not exist so it cannot leak, be escrowed, or be rotated.
- **Rotated to a per-box random value** — `sulogin` prompts and *accepts the correct password*
(the control row in §2.2 shows `pam_unix` authenticating a real hash). So console recovery is open
**to whoever holds that password** — which is the operator if it is escrowed, and nobody if it is
discarded.
**A security corollary that is worth stating plainly: the installed GRUB has no password, so locking
root buys nothing against physical access.** Anyone at the keyboard can take a root shell in under a
minute. Locked root is a defence against *remote* credential reuse — the shared-ISO-password problem —
and not a physical-security measure. **R-152.**
**This makes the tradeoff concrete: "discard" and "lock" are the same outcome for recovery.** A
rotated-then-discarded password is operationally identical to a locked account, because nobody holds
it. The real choice is therefore **binary — escrow, or accept that console recovery is operator-only
and physical.** Framed for the ruling in §7.
### 3.4 Does locked differ from rotated? — **[MEASURED]. Yes, at exactly one point.**
- **Locked (`*`/`!`)** — `sulogin` refuses (§3.2); the web UI refuses (§2.3); `init=/bin/bash` works.
- **Rotated to a per-box value** — `pam_unix` accepts the correct password (the §2.2 control proves
this) and the web UI issues a ticket for it (the §2.3 control proves this), so **both** the console
and the browser open **to whoever holds it**; `init=/bin/bash` works as well.
**And this is what settles the escrow question.** A rotated password that is *discarded* is
operationally identical to a locked account: nobody holds it, so `sulogin` and the web UI refuse
either way. The only thing escrow buys is that the **operator** keeps the web-UI and `sulogin` routes.
It does not buy recoverability as such, because `init=/bin/bash` is available in every case.
### 3.5 A probe that failed, recorded as failed
The first GRUB attempt waited 3 seconds before acting; the menu's timeout is **1 second**, so the
system had already booted and the keystrokes went into the login prompt — the arrow-key escape
sequences (`^[[B`) are visible there in the screendump. It tested nothing. A later attempt typed
`init=/bin/bash` onto the `echo` line one below `linux`, which the **pre-typing verification
screendump caught** before it was booted. Both were rerun; the numbers above are from the corrected
runs.
---
## 4. Q3 — does the Felhom stub survive `before-network`?
### 4.1 Does it execute? — **[BLOCKED]**
### 4.1 Does it execute? — **[MEASURED-PVE]. Yes.**
Needs the VM. The staged diagnostic emits `@@SPIKE2_FIRSTBOOT_RAN@@ marker=BEGIN/END` to `ttyS0`
(captured by `qemu -serial file:`) precisely so the answer is a **positive observable** rather than the
absence of an error — and §4.4 below is why that matters more than usual here.
The diagnostic emits `@@SPIKE2_HOOK_RAN@@ BEGIN`/`END` to `ttyS0`. On scenario B
(`ordering = "before-network"`) **both markers are present** (count `2`). This is a positive
observable the hook produced, which §4.4 explains is the only acceptable form of proof here.
### 4.2 Does it have what it needs that early? — **[STRUCTURAL]**, partly answered
### 4.2 Does it have what it needs that early? — **[MEASURED-PVE]. Yes, everything.**
From the shipped unit (`proxmox-first-boot` 9.2.5,
`/usr/lib/systemd/system/proxmox-first-boot-network-pre.service`):
### 4.3 Does it complete before anything is network-reachable? — **[MEASURED-PVE + STRUCTURAL]. Yes.**
The A/B comparison, same image, same diagnostic, only `ordering` differs:
| Diagnostic | **A — `fully-up`** | **B — `before-network`** |
|---|---|---|
| hook markers | 2 (ran) | **2 (ran)** |
| `uptime_s` at hook | 18.32 | **6.58** |
| `entropy_avail` | 256 | **256** |
| `etc_writable` | YES | **YES** |
| `openssl`/`chpasswd`/`systemctl`/`passwd`/`curl`/`ip` | all present | **all present** |
| `openssl_rand_len` | 32 | **32** |
| `ip_global` | `vmbr0 10.0.2.15/24` | **(empty)** |
| `network.target` | active | **inactive** |
| `network-online.target` | active | **inactive** |
| `pveproxy` | active | **inactive** |
| `sshd` | active | **inactive** |
| **`listen_22_8006`** | **3** | **0** |
**At `before-network` the hook runs with entropy, a writable `/etc`, every binary it would need, and a
demonstrated ability to generate a 32-character random string — while the box has no IP address and
nothing is listening on `:22` or `:8006`.** A credential rotation placed there has a **zero-length
exposure window, measured.**
Equally, the `fully-up` column is the measured proof of the converse: **today's ordering runs the hook
when sshd and pveproxy are already listening.** The window Spike 1 argued for structurally is now
observed.
The structural half agrees and generalises it. The shipped unit
(`proxmox-first-boot` 9.2.5, `/usr/lib/systemd/system/proxmox-first-boot-network-pre.service`):
```
After=systemd-remount-fs.service
@@ -209,57 +298,37 @@ ConditionPathIsReadWrite=/var/lib
Type=oneshot
```
`After=systemd-remount-fs.service` plus `ConditionPathIsReadWrite=/var/lib` mean **a writable
filesystem is structurally guaranteed** — or the unit does not run at all (§4.4). Entropy availability
and binary presence at that point are **not** established structurally and are part of the staged
diagnostic (`entropy_avail`, `openssl rand` length, `command -v` for `openssl`/`chpasswd`/`systemctl`).
### 4.3 Does it complete before anything is network-reachable? — **[STRUCTURAL]. Yes, by construction.**
This is the result the task asked to establish structurally rather than by timing, and the unit file
settles it:
- **`Before=network-pre.target`** — `network-pre.target` is systemd's ordering barrier that precedes
*all* network configuration; network managers order themselves after it.
- **`Type=oneshot`** — systemd treats the unit as complete only when the process exits, so
`Before=` is a hard barrier rather than a hint.
Together: **the hook runs to completion before any interface is configured**, therefore before `sshd`
can bind a routable address and before `pveproxy` serves. A rotation performed there has a
**zero-length exposure window by construction, not by being fast.** This holds on every machine and
does not vary with hardware — which is why it is stronger than the timing number Spike 1 failed twice
to obtain.
`Before=network-pre.target` + `Type=oneshot` makes it a hard barrier ahead of all network
configuration — so this holds on every machine, not just this VM. Both installs then booted to full
reachability (SSH +20 s, pveproxy +2629 s), so the early hook does not break the boot.
**A third ordering value exists that Spike 1 did not record.** `validate-answer` accepts
**`before-network`, `network-online`, and `fully-up`** — and rejects `network-pre`, `multi-user` and a
bogus value, so this is a real enum and not a permissive parser. The shipped package contains a
matching unit for each. Spike 1 reported two. `network-online` is the middle option — after the
network, before `pveproxy` — and is worth considering if `before-network` proves too early for
something.
**`before-network`, `network-online`, `fully-up`**; it rejects `network-pre`, `multi-user` and a bogus
value, so this is a real enum. The shipped package contains a matching unit for each.
`network-online` is the unevaluated middle option. **R-149.**
### 4.4 Does the existing pairing flow still work? — **[STRUCTURAL]** yes; **[BLOCKED]** empirically
### 4.4 Does the existing pairing flow still work? — **[STRUCTURAL]**, and it is the one gap left
Two structural findings, and the second one corrects a premise this design has been carrying.
**Not measured.** The scenarios installed the spike's own diagnostic as the first-boot hook, not
Felhom's `stub-first-boot.sh`, so stub-plus-pairing coexistence is argued, not observed.
**The stub does not need the network, and does not need `pveproxy`.** `stub-first-boot.sh` writes three
files, then `systemctl daemon-reload`, `enable`, and `start --no-block felhom-bootstrap.service`. A
grep for `pvesh`, `pct`, `pveum` and `qm` across the stub returns **no match** (`rc=1`). The unit it
starts carries its own `After=network-online.target pve-cluster.service pveproxy.service`, so
`--no-block` merely queues the job and systemd holds it until those are satisfied. Structurally, stub
and pairing coexist: the stub runs early, the bootstrap runs when its own dependencies are met.
The structural case is strong and has a correction in it:
**So `answer.toml.tmpl:27` justifies the `fully-up` choice with a dependency the stub does not have.**
The comment reads *"fully-up = the stub runs after pveproxy so pvesh/pct work"* — but the `pvesh`/`pct`
usage is in `felhom-bootstrap.sh`, which never runs under the stub's ordering. This is the
CLAUDE.md "a comment asserting an invariant" class in a new shape: a comment asserting a **dependency**
that does not exist, which has constrained the design ever since. **R-148.**
**The stub does not need `fully-up`.** `stub-first-boot.sh` writes three files, then
`systemctl daemon-reload`, `enable`, and `start --no-block felhom-bootstrap.service`. A grep for
`pvesh`, `pct`, `pveum` and `qm` across it returns **no match** (`rc=1`). The unit it starts carries its
own `After=network-online.target pve-cluster.service pveproxy.service`, so `--no-block` queues the job
and systemd holds it until those are satisfied.
**The trap that makes §4.1's positive observable load-bearing.** All four `proxmox-first-boot-*` units
are guarded by `ConditionPathExists=` and `ConditionPathIsReadWrite=`. In systemd a **failed condition
is a skip, not a failure** — the unit reports `inactive`/`condition failed`, the boot succeeds, and
nothing is logged as wrong. A first-boot hook that never ran and one that ran perfectly are
indistinguishable from the absence of errors. **R-150.** This is why §4.1 must be confirmed by a
marker the hook emitted, never by "no errors appeared".
**So `answer.toml.tmpl:27` justifies the current ordering with a dependency the stub does not have**
it reads *"fully-up = the stub runs after pveproxy so pvesh/pct work"*, but that usage lives in
`felhom-bootstrap.sh`, which never runs under the stub's ordering. A comment asserting a **dependency**
that does not exist, and it has constrained the design ever since. **R-148.**
**Why §4.1's positive marker is load-bearing.** All four `proxmox-first-boot-*` units are guarded by
`ConditionPathExists=` / `ConditionPathIsReadWrite=`, and in systemd **a failed condition is a skip,
not a failure** — no error, successful boot. A hook that never ran and one that ran perfectly are
indistinguishable from the absence of errors. **R-150.**
---
@@ -267,119 +336,99 @@ marker the hook emitted, never by "no errors appeared".
### 5.1 Where the collapse happens — **[STRUCTURAL]**, and it is not a simple removal
`iso-repack.sh` replaces the stock `grub.cfg` wholesale: it renders `grub/grub.cfg.tmpl` over it
(`iso-repack.sh:144-148`), having first lifted the `linux`/`initrd` lines verbatim from the stock
*Install Proxmox VE (Automated)* entry (`:108-131`) so a PVE bump tracks automatically.
`iso-repack.sh` replaces the stock `grub.cfg` wholesale, rendering `grub/grub.cfg.tmpl` over it
(`:144-148`) after lifting the `linux`/`initrd` lines verbatim from the stock *Install Proxmox VE
(Automated)* entry (`:108-131`) so a PVE bump tracks automatically.
**Four independent gates then enforce the single entry**, so restoring the stock menu is a change to
the gates, not just to the template:
**Four independent gates enforce the single entry:**
| Gate | Location | Effect |
|---|---|---|
| exactly one `menuentry` | `iso-repack.sh:153-155` | `exit 14` otherwise |
| zero `submenu` | `iso-repack.sh:156` | `exit 14` otherwise |
| no live reference to `proxtui`, `proxdebug`, `nomodeset`, `Rescue Boot`, `memtest`, `fwsetup` | `iso-repack.sh:160-164` | `exit 14` otherwise |
| **re-extracted from `final.iso`**: exactly one entry, theme present, entry named `Felhom telepítés` | `iso-repack.sh:314-319` | `exit 15` otherwise |
| exactly one `menuentry` | `:153-155` | `exit 14` |
| zero `submenu` | `:156` | `exit 14` |
| no live `proxtui`, `proxdebug`, `nomodeset`, `Rescue Boot`, `memtest`, `fwsetup` | `:160-164` | `exit 14` |
| **re-extracted from `final.iso`**: one entry, theme present, entry named `Felhom telepítés` | `:314-319` | `exit 15` |
The fourth is the strongest of them — it proves the menu in the shipped image rather than in the build
tree, which is the seam-wiring lesson applied correctly.
The fourth proves the menu in the shipped image rather than the build tree — the seam-wiring lesson
applied correctly.
**What adding one interactive entry would actually cost:** the two count gates (rows 1 and 4) move from
`== 1` to `== 2`, or better to a named allowlist. **The banned-token gate would still pass unchanged**
for a stock *Install Proxmox VE (Graphical)* entry — it uses none of `proxtui`, `proxdebug`,
`nomodeset`, `Rescue Boot`, `memtest`, `fwsetup`. So the safety property the gates exist to protect
(no route to a debug/rescue/manual-disk-picker path) survives a second *graphical installer* entry
intact. That is a narrower change than it first appears.
**What a second entry would cost:** the two count gates move from `== 1` to `== 2`, or to a named
allowlist. **The banned-token gate passes unchanged** for a stock *Install Proxmox VE (Graphical)*
entry — it uses none of those six tokens. The safety property the gates exist for (no route to a
debug/rescue/manual-disk-picker path) survives a second graphical-installer entry intact.
### 5.2 Are default and timeout settable? — **[STRUCTURAL]. Yes, already.**
### 5.2 Are default and timeout settable? — **[STRUCTURAL]. Already are.**
`grub/grub.cfg.tmpl` sets them explicitly:
`grub/grub.cfg.tmpl` sets `set timeout_style=menu`, `set timeout=5`, `set default=0`. The template also
records a real defect it fixed: the stock PVE config writes `timeout-style` (hyphen), **which GRUB does
not recognise as a variable name**, so Felhom's underscore spelling is the one that works.
```
set timeout_style=menu
set timeout=5
set default=0
```
### 5.3 The design question — **framed with evidence, deliberately not ruled**
Both are plain template values. The template also records a real defect it fixed: the stock PVE config
writes `timeout-style` (hyphen), **which GRUB does not recognise as a variable name**, so Felhom's
`timeout_style` (underscore) is the one that actually works. A two-entry menu would therefore have a
functioning timeout and a settable default — which is exactly what the §5.3 question needs.
**For interactive-as-default.** Spike 1 proved the automated path cannot be made safe on unseen
hardware: no property distinguishes a system disk from a customer's backup drive, and a filter matching
two disks silently wipes one. A tester who boots and walks away on multi-disk hardware is walking away
from an unattended destructive operation whose target cannot be predicted.
### 5.3 The design question — **framed, deliberately not ruled**
**This session strengthens that case independently.** If the public image also ships a locked root,
then a box whose install succeeds but whose pairing fails has **no web UI** (§2.3) and **no working
recovery-mode shell** (§3.2) — only an operator-grade `init=/bin/bash` (§3.3). An unattended default
maximises the number of testers who reach that state without having read anything.
Which entry should be `default=0` in a **public** image?
**What the measurements say, and they only point one way:** Spike 1 established that the automated
path cannot be made safe on unseen hardware — no property distinguishes a system disk from a
customer's backup drive, and a filter matching two disks silently wipes one. A tester who boots the
image and walks away is, on multi-disk hardware, walking away from an unattended destructive operation
whose target cannot be predicted.
**What that costs:** unattended install stops being the default for people who do not read
instructions — and unattended install is a selling point. Most first boxes are single-disk, where the
**Against.** Unattended install is a selling point and most first boxes are single-disk, where the
automated path is fine.
**The measurement that would change the balance** is §2.3 and §3: if a locked root also closes the PVE
web interface and the console, then a tester whose unattended install completes but whose pairing
fails has a machine nobody can reach — which argues for interactive-default independently of the disk
question. Those measurements are blocked, so the balance cannot be struck yet.
**Operator-built versus public need not match, and two profiles from one build is cheap.** An
operator-built image targets a machine whose disks are known, so automated-default is safe there. The
public image is the one facing unseen hardware. Recommending the split is not the same as ruling the
default, and the split is available regardless of how the ruling goes.
**Operator-built and public need not match, and two profiles from one build is cheap.** An
operator-built image targets known disks, so automated-default is safe there; the public image is the
one facing unseen hardware. That split is available however the default is ruled.
---
## 6. Q5 — the resulting shape, as constraints
Constraints on a build spec, each with a release-gate criterion. **Not an implementation, and nothing
was built.** Constraints marked ⚠ depend on blocked measurements and are provisional.
Constraints on a build spec, each with a release-gate criterion. **Not an implementation.**
| # | Constraint | Release gate |
|---|---|---|
| 1 | **The image must not select a disk by property on unseen hardware.** Spike 1 §3.2/§3.3. | The rendered answer contains no `filter.*` key, **or** the image is operator-built for a named machine. Grep the rendered `answer.toml`. |
| 2 | **The interactive installer must be reachable from the boot menu.** §5.1 shows the cost is two count gates. | `final.iso`'s `grub.cfg` contains ≥1 interactive entry and still contains no live `proxtui`/`proxdebug`/`nomodeset`/`Rescue Boot`/`memtest`/`fwsetup` i.e. keep gate 3, relax gates 1 and 4 to an allowlist. |
| 3 | **No usable baked credential.** `root-password-hashed = "*"` is schema-valid and installs (Spike 1). | The rendered answer's `root-password-hashed` is exactly `*`, and **no `.rootpw.txt` is emitted**. Both are one-line assertions in the build. |
| 4 | **If a per-box password is set, it is set at `before-network`.** §4.3 — the only ordering with a zero window by construction. | The rendered answer's `[first-boot].ordering` is `before-network`; and the hook's positive marker is observed in a boot test, never inferred from absent errors (§4.4, R-150). |
| 5 | **No `root-ssh-keys`, ever, in a public image.** Spike 1 R-142. | `grep -c root-ssh-keys` on the rendered answer is `0`. |
| 6 | **The profile must be committed.** Spike 1 found demo-felhom's baked key comes from an *uncommitted* profile, so a reference box is not reproducible from the repo. | The `--profile` path resolves inside the repo working tree **and** `git status --porcelain` is empty for it. **R-151.** |
| 7 | **The artefact must be reproducible.** Spike 1 R-144 (`nested-probe`'s profile no longer exists). | Manifest records the profile's committed path and the repo commit; both resolve. |
| 8 | **A recovery path must be documented and, if operator-only, said so plainly.** §3.3. | The release notes name the recovery route for a failed pairing. If that route is `init=/bin/bash`, the notes say it is operator-only. |
| 1 | **No disk selection by property on unseen hardware** (Spike 1 §3.2/§3.3). | Rendered answer contains no `filter.*` key, **or** the image is operator-built for a named machine. |
| 2 | **The interactive installer must be reachable from the boot menu** (§5.1). | `final.iso`'s `grub.cfg` has ≥1 interactive entry **and** still no live `proxtui`/`proxdebug`/`nomodeset`/`Rescue Boot`/`memtest`/`fwsetup` — keep gate 3, relax gates 1 and 4 to an allowlist. |
| 3 | **No usable baked credential** `root-password-hashed = "*"` installs cleanly (§1, three scenarios). | Rendered `root-password-hashed` is exactly `*`, **and no `.rootpw.txt` is emitted**. Two one-line assertions. |
| 4 | **Any per-box password is set at `before-network`** — the only ordering with a measured zero window (§4.3). | Rendered `[first-boot].ordering` is `before-network`, **and** the hook's positive marker is observed in a boot test (§4.4, R-150). |
| 5 | **No `root-ssh-keys`, ever, in a public image** (R-142). | `grep -c root-ssh-keys` on the rendered answer is `0`. |
| 6 | **The profile must be committed** demo-felhom's baked key comes from an *uncommitted* profile, so a reference box is not reproducible from `main`. | `--profile` resolves inside the repo tree **and** `git status --porcelain` is empty for it. **R-151.** |
| 7 | **The artefact must be reproducible** (R-144: `nested-probe`'s profile is gone). | Manifest records the committed profile path + repo commit; both resolve. |
| 8 | **The recovery path must be documented, and named operator-only if it is.** §2.3 + §3.2 + §3.3: with a locked root the only way in is `init=/bin/bash` at the console. | Release notes state it, in those terms. |
**What the image must not contain:** a usable root password hash, any `root-ssh-keys`, any customer
id, claim code, retrieval passphrase or hub token, and any `filter.*` disk selector in the public
variant. Spike 1 §6 established by enumeration that the only secret-bearing file is `/answer.toml`, so
gates 3 and 5 over that one rendered file cover the credential surface.
**What the image must not contain:** a usable root password hash, any `root-ssh-keys`, any customer id,
claim code, retrieval passphrase or hub token, and any `filter.*` selector in the public variant.
Spike 1 §6 established by enumeration that `/answer.toml` is the only secret-bearing file, so gates 3
and 5 over that one rendered file cover the credential surface.
---
## 7. The operator's decisions
Two, each with the measurement that informs it and its current state.
**Decision 1 — escrow or discard a per-box root password.** §3.4 reshapes this twice over:
**Decision 1 — escrow or discard a per-box root password.**
- **Discard and lock are the same thing** for recovery — if nobody holds the password, both `sulogin`
and the web UI refuse.
- **Neither makes a box unrecoverable.** `init=/bin/bash` works in every case (§3.3), so an operator at
the console can always get in. The earlier fear that locking root leaves "no path back at all" is
**measured to be false**.
§3.4 changed the shape of this question. **"Discard" and "lock" are operationally identical** — if
nobody holds the password, `sulogin` refuses either way, and console recovery is closed to everyone.
So the choice is binary:
So what escrow actually buys is narrower than it looked: **the operator keeps the web-UI route (§2.3)
and the `sulogin` route (§3.2) without needing physical presence.** Against that stands D6 — a hub
holding console root for the whole fleet is exactly what the hub alone must not be.
- **Escrow** — the operator can always reach a box's console; the cost is a hub-held credential for
every box, which is the thing the D6 trust model says the hub alone must not be.
- **Discard / lock** — no credential exists anywhere; the cost is that a failed pairing leaves a
machine recoverable only by an operator at the keyboard (`init=/bin/bash`) or a reinstall.
**Recommendation, offered because the measurements now support one:** discard/lock for the **public**
image, and accept operator-only physical recovery, because the credential that would be escrowed is
the same one whose fleet-wide sharing started this whole line of work. Escrow remains defensible for
**operator-built** boxes, where the operator installed the machine anyway. The decision is still the
operator's; it is no longer blocked on a measurement.
**Blocking measurement: §2.3** — whether a locked root also closes the PVE web interface. If it does,
"discard" removes the last remote route as well as the local one, and the balance shifts sharply
toward escrow. **Do not rule until §2.3 is measured.**
**Decision 2 — the default boot entry for a public image.**
Framed in §5.3. The disk evidence points at interactive-default; the product cost is the unattended
experience. **Partially blocked on the same §2.3 measurement**, because an unreachable-after-failure
box strengthens the interactive case independently. The operator-built-versus-public split (§5.3) is
available regardless and is cheap.
**Decision 2 — the default boot entry for a public image.** Framed in §5.3, unruled. The disk evidence
and the recovery evidence now point the same way (interactive), and the cost is the unattended
experience. The operator-built-versus-public split is cheap and available either way.
---
@@ -387,70 +436,65 @@ available regardless and is cheap.
| # | Unknown | What it takes |
|---|---|---|
| 1 | **Everything in §2.1, §2.3, §2.4, §3.1, §4.1, §4.2 (entropy/binaries).** | The staged fixture, unchanged, once a Tier 0 box returns. ~90 min for all three scenarios. |
| 2 | **Whether PVE alters the §2.2/§3.2 mechanism results** — PAM config, a `sulogin` override, `SYSTEMD_SULOGIN_FORCE`. | Same fixture; check `/etc/pam.d/`, the `sulogin` invocation, and systemd's emergency unit on the installed image. |
| 3 | **Whether the multi-match pick is deterministic by enumeration order** (Spike 1 §8 #1, still open). | Re-run Spike 1's S1 with reversed disk order. ~15 min. |
| 4 | **Whether the installer excludes its own boot medium** (Spike 1 §8 #2, still open). | USB boot on real hardware — HUMAN. |
| 5 | **What `network-online` ordering buys** (§4.3) — it is a real third option nobody has evaluated. | One extra scenario alongside the staged two. |
| 6 | **Whether a second GRUB entry actually boots the stock installer** after the gates are relaxed. | Build a probe ISO with a two-entry template and boot it. Not attempted — it would have meant changing repo files, which §4 forbids. |
| 1 | **Felhom's real stub at `before-network`** (§4.4) — coexistence with pairing is structural, not observed. The single remaining gap in Q3. | Build a probe ISO with `stub-first-boot.sh` as the hook and `ordering = "before-network"`, boot, confirm `felhom-bootstrap.service` still starts and pairing completes. ~40 min. **Do this before the spec.** |
| 2 | **Whether root SSH is refused *because* of the lock** (§2.2) — the probe used a wrong password in the control too, so it discriminates nothing. | Re-run the SSH leg with the control's correct password. ~10 min. |
| 3 | **What else in PVE assumes a usable root credential** (§2.4) — cron, backup jobs. | Inspect a locked install over a longer uptime. |
| 4 | **What `network-online` ordering buys** (§4.3) — a real third option, never evaluated. | One extra scenario. |
| 5 | **Whether the multi-match pick is deterministic by enumeration order** (Spike 1, still open). | Re-run Spike 1's S1 with reversed disk order. ~15 min. |
| 6 | **Whether the installer excludes its own boot medium** (Spike 1, still open). | USB boot on real hardware — HUMAN. |
| 7 | **Whether a relaxed-gate two-entry menu actually boots the stock installer** (§5.1). | Not attempted — it would mean changing repo files, which §4 forbids. |
---
## 9. R-rows opened
Numbering continues from R-147 (Spike 1).
Continuing from R-147 (Spike 1).
| R | Severity | Finding |
|---|---|---|
| **R-148** | MEDIUM | **`answer.toml.tmpl:27` justifies `ordering = "fully-up"` with a dependency the stub does not have.** The comment says the stub must run after `pveproxy` "so pvesh/pct work", but `stub-first-boot.sh` contains no `pvesh`/`pct`/`pveum`/`qm` call (grep `rc=1`); that usage lives in `felhom-bootstrap.sh`, which runs under its own `After=network-online.target … pveproxy.service` unit. A comment asserting a **dependency** rather than an invariant — and it has constrained the design, because `before-network` is the ordering that would make the credential window zero (§4.3). |
| **R-149** | LOW | **The `[first-boot].ordering` enum has three values, not two.** `before-network`, `network-online`, `fully-up` all validate; `network-pre` and `multi-user` are rejected. Spike 1 recorded two and the middle option has never been evaluated. |
| **R-150** | MEDIUM | **A first-boot hook that never runs is indistinguishable from one that succeeded.** All four `proxmox-first-boot-*.service` units are guarded by `ConditionPathExists=` / `ConditionPathIsReadWrite=`, and in systemd a failed condition is a **skip, not a failure** — no error, successful boot. Any design that puts credential rotation in a first-boot hook must verify it by a positive marker the hook emitted. Directly the "an absent log line is not evidence" class. |
| **R-151** | MEDIUM | **A reference box is not reproducible from the repository.** `demo-felhom` was installed from an ISO built with the **uncommitted** `n100-demo.profile`, which is where its baked SSH key comes from (Spike 1 §4.6). One of the two Tier 0 boxes therefore cannot be rebuilt from `main`. Related to R-144 (`nested-probe`'s profile is gone entirely); this row is the *reference box*, which matters more. Gate 6 in §6 exists to stop a public image inheriting the shape. |
| **R-148** | MEDIUM | **`answer.toml.tmpl:27` justifies `ordering = "fully-up"` with a dependency the stub does not have.** `stub-first-boot.sh` contains no `pvesh`/`pct`/`pveum`/`qm` call (grep `rc=1`); that usage is in `felhom-bootstrap.sh`, under its own `After=network-online.target … pveproxy.service` unit. A comment asserting a **dependency** rather than an invariant — and it blocked the ordering that §4.3 now measures as giving a zero credential window. |
| **R-149** | LOW | **`[first-boot].ordering` has three values, not two** — `before-network`, `network-online`, `fully-up` validate; `network-pre` and `multi-user` are rejected. The middle option has never been evaluated. |
| **R-150** | MEDIUM | **A first-boot hook that never runs is indistinguishable from one that succeeded.** All four `proxmox-first-boot-*.service` units are `Condition*`-guarded, and a failed condition is a systemd **skip, not a failure** — no error, successful boot. Any credential-rotation-in-first-boot design must be verified by a positive marker the hook emitted. |
| **R-151** | MEDIUM | **A reference box is not reproducible from the repository.** `demo-felhom` was installed from an ISO built with the **uncommitted** `n100-demo.profile`, which is where its baked SSH key comes from (Spike 1 §4.6). Gate 6 in §6 exists to stop a public image inheriting that shape. |
| **R-152** | LOW | **A locked root is not a physical-security measure, and should never be described as one.** The installed PVE GRUB has **no password** (§3.1), so `init=/bin/bash` yields an unauthenticated root shell in under a minute (§3.3, measured). Locking root defends against *remote* reuse of a shared ISO credential and nothing else. If a physical-access threat model is ever wanted, it needs a GRUB password, which is a separate decision with its own lockout risk. |
---
## 10. Teardown
All three layers, verified positively.
All three layers, each verified positively.
**Layer 1 — demo-hp.** **INCOMPLETE, and named as such.** The box went offline (§0) while
`/mnt/nvme-1tb/spike2/` held the probe ISO (~1.7 GB) and the staged answer files and runners. Nothing
was running on it: the last successful command before the outage confirmed `0` QEMU processes, and no
install had been started this session. Nothing was registered as a PVE storage, nothing was written to
`local-lvm`, and `drill-r50` / guest 9201 were never touched this session.
**Layer 1 — demo-hp.**
**Outstanding cleanup, to run when the box returns** — recorded here because R-131 exists precisely
because recorded commands never get run:
| Check | Result |
|---|---|
| QEMU processes | `0` |
| `/mnt/nvme-1tb/spike2/` | removed — `No such file or directory` |
| `/tmp/paia` | removed |
| loop devices / `/mnt/s2ans` | `0` / removed |
| `/mnt/nvme-1tb/` contents | `dump e2d-images felhom-data images lost+found` — the pre-spike set |
| disk usage | **6.6 G — identical to pre-spike** |
| `drill-r50` (VM 300) | `stopped`, untouched (R-93 fixture intact) |
| guest 9201 | `running`, untouched |
| `/etc/pve/storage.cfg` | **4 storages, unchanged**; no scratch storage ever registered |
| `local-lvm` | never written |
```
rm -rf /mnt/nvme-1tb/spike2 ; rm -f /tmp/paia ; pkill -9 qemu-system-x86
```
This is deliberately *not* claimed as done. Spike 1's `/mnt/nvme-1tb/spike-scratch` and both `/tmp/paia`
copies were removed and verified at that session's teardown, so the outstanding item is this session's
`spike2/` directory alone.
**Layer 2 — demo-felhom.** Nothing placed this session — it was never reached before the outage.
Spike 1's `/tmp/paia` was removed and verified then. No action outstanding.
**Layer 2 — demo-felhom.** `/tmp/paia` removed; guest 9201 `running`; the external USB drive
`931.5G usb /mnt/hdd_1` still attached and mounted exactly as found — never attached, detached,
partitioned or written. Only `device-info` reads were ever performed there.
**Layer 3 — DooPlex.**
| Check | Result |
|---|---|
| scratchpad | **4.4 GB → 48 K** (probe ISO, squashfs extracts, `.deb` extract, schema test dirs removed) |
| control-password file (`s2/.ctlpw`) | `shred -u`'d — never printed, never committed; verified absent |
| mechanism-probe container | `docker run --rm` — no image or container retained; **no global Docker cleanup run** (fence) |
| scratchpad | **1.7 GB → 60 K** (probe ISO, screendumps, squashfs/`.deb` extracts, schema test dirs) |
| control password (`s2/.ctlpw`) and `scen/C.toml` | `shred -u`'d / removed; verified absent. Never printed, never committed |
| mechanism-probe container | `docker run --rm`; **no global Docker cleanup run** (fence) |
| `felhom-iso/out/` | untouched, **17 ISOs** |
| production files | none modified — no `build-felhom-iso.sh`, no repack, no profile, no `felhom-bootstrap.sh` |
| production files | none modified — no `build-felhom-iso.sh`, no `iso-repack.sh`, no profile, no `felhom-bootstrap.sh` |
**One teardown error, recorded rather than quietly fixed.** Shredding the control password
(`s2/.ctlpw`) destroyed the plaintext that scenario **C** existed to use — a `$6$` hash whose plaintext
is gone cannot demonstrate "correct password → PVE ticket", which was C's entire purpose as the
control for §2.3. `s2/scen/C.toml` was therefore **deleted** rather than left in place looking usable.
**Scenario C must be regenerated** (one `openssl passwd -6` against a fresh random plaintext, kept
0600) before the §2.3 measurement is attempted. Scenarios A and B are unaffected and remain staged.
**Hub-side disposition — nothing to dispose.** No customer, host or appliance was created: no install
ran, and the only hub contact this session was none at all. Verified rather than asserted below.
**Hub-side disposition — nothing to dispose.** No customer, host or appliance was created: the nested
VMs ran under plain QEMU with user-mode networking and no route to the hub, and no install ever
contacted it. **R-131 gains no row.**
**Published artefacts: none.** Nothing went to R2.
+27 -29
View File
@@ -1,38 +1,36 @@
## SPIKE 2 (no code change) — before-network gives a zero window by construction; locked root closes sulogin (2026-07-31)
## SPIKE 2 (no code change) — locked root closes the PVE web UI; before-network gives a measured zero window (2026-07-31)
**No script, profile or build file was modified.** Findings:
`documentation/audits/SPIKE-universal-iso-2-2026-07-31.md`. Opens R-148..R-151.
`documentation/audits/SPIKE-universal-iso-2-2026-07-31.md`. Opens R-148..R-152.
**Both Tier 0 boxes went offline mid-session** (remote site, 12:28 CEST; four routes tried, our
tailscale pod healthy). Q1/Q2/Q3 each retain a part that needs a nested VM and those parts are
**BLOCKED, not answered**. The fixture is staged with Spike 1's two defects already corrected.
Both Tier 0 boxes went offline mid-session (provider cable fault; four routes tried) and returned;
all three scenarios then ran to completion on real PVE, each signalled by `reboot-mode="power-off"`.
Established without them:
- **STRUCTURAL — `ordering = "before-network"` maps to `proxmox-first-boot-network-pre.service`
(`proxmox-first-boot` 9.2.5), which is `Before=network-pre.target` + `Type=oneshot`.** It therefore
runs to completion before any interface is configured — a credential rotation there has a
**zero-length window by construction**, not by being fast. Stronger than the timing number Spike 1
failed twice to obtain.
- **STRUCTURAL — the stub does not need `fully-up`.** `stub-first-boot.sh` contains no
`pvesh`/`pct`/`pveum`/`qm` call (grep rc=1); that usage is in `felhom-bootstrap.sh`, which runs under
its own `After=network-online.target ... pveproxy.service` unit. So `answer.toml.tmpl:27` justifies
the current ordering with a dependency that does not exist. **R-148.**
- **The ordering enum has THREE values** — `before-network`, `network-online`, `fully-up`. Spike 1
recorded two; the middle one has never been evaluated. **R-149.**
- **MECHANISM (container, not PVE) — a locked root closes `sulogin`:** `Cannot open access to console,
the root account is locked.` for both `*` and `!`, with a working control (correct password ->
SU_OK, wrong -> Authentication failure). So "discard" and "lock" are the SAME outcome for recovery,
which makes the escrow decision binary.
- **All four `proxmox-first-boot-*` units are `Condition*`-guarded, and a failed condition is a SKIP,
not a failure** — a hook that never ran looks exactly like one that succeeded. **R-150.**
- Q4: the single-entry menu is enforced by **four** gates in `iso-repack.sh` (`:153-155`, `:156`,
`:160-164`, `:314-319`), so restoring an interactive entry is a gate change, not a template edit —
but the banned-token gate would pass unchanged for a stock Graphical entry. `default`/`timeout` are
- **A locked root CLOSES the PVE web interface.** Measured against the exact endpoint the UI logs in
through (`POST /api2/json/access/ticket`, `root@pam`) **with a working control**: the known-password
install returns **HTTP 200 + ticket**; the locked install returns 401 for every password, and no
password exists. `passwd -S root` = `L`, shadow = `literal-asterisk`. PVE uses the stock PAM stack
(`pve_pam_realm: 0`).
- **GRUB recovery mode is ALSO closed**: `Cannot open access to console, the root account is locked.`
— the container mechanism result confirmed verbatim on real PVE.
- **But `init=/bin/bash` still works** — an unauthenticated `root@(none):/#`. So a locked-root box is
recoverable, operator-only, at the physical console. The installed GRUB has **no password**, so
locking root is NOT a physical-security measure. **R-152.**
- **`before-network` measured, A/B, same image:** the hook runs (positive marker, uptime 6.58s) with
entropy 256, writable /etc, all binaries and `openssl_rand_len=32`, while `ip_global` is EMPTY and
`listen_22_8006` is **0**. The `fully-up` column is the converse proof: sshd + pveproxy already
active, 3 listening. Zero credential window, measured.
- **R-148:** `answer.toml.tmpl:27` justifies `fully-up` with a `pvesh`/`pct` dependency the stub does
not have (grep rc=1) — it blocked the ordering that is now measured as the fix.
- **R-149** three ordering values, not two · **R-150** Condition-guarded hooks skip silently ·
**R-151** demo-felhom built from an uncommitted profile.
- Q4 from source: four gates in `iso-repack.sh` enforce the single-entry menu; `default`/`timeout`
already settable in `grub/grub.cfg.tmpl`.
The first mechanism probe was invalid (ran as uid 0, so `su` never invoked pam_unix; `sulogin` had no
tty) and is recorded as failed, not massaged.
Three probes failed and are recorded as failed, not massaged: a container probe that ran as uid 0
(so `su` never invoked pam_unix), a GRUB probe that missed the **1-second** menu timeout and typed into
the login prompt, and a kernel-line edit that landed one line off — caught by a pre-typing
verification screendump.
## SPIKE (no code change) — a universal ISO needs a different disk strategy and a locked root (2026-07-31)