docs(v1.24.0): R-59/R-60/R-61 SHIPPED — CHANGELOG, README, ROADMAP (+R-62), runbook, capability map, drill evidence, REPORT

Virgin-ISO nested drill closed the train: dead-NIC install baked the
fallback (incl. the dead default gateway), the R-59 screen painted
(capture committed beside the spike doc), the cable move healed +
registered at the hub in 23s unaided, and the build's rootpw file
matched the installed box's shadow hash. R-59 SHIPPED with the recorded
deviation (first-boot gate; installer-initrd abort out of scope by
operator ack). R-60 SHIPPED (spike + drill cited; F-P9 route-flush fix
included). R-61 slice 1 SHIPPED. New R-62 row (hub delete-dialog
cosmetics, XS). Capability map: new PROVEN-LIVE row (nested != metal,
said so). Cleanup verified: felhom-pve interfaces byte-identical,
bridge/VMs/ISO removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
This commit is contained in:
2026-07-22 11:45:55 +02:00
parent 699325bd8a
commit a12c6f9730
8 changed files with 218 additions and 87 deletions
+90 -68
View File
@@ -1,80 +1,102 @@
# REPORT — hub v0.70.1: the ghost customer's Delete button must exist
# REPORT — scripts/ISO train v1.24.0: R-59 + R-60 + R-61 (2026-07-22)
**Date:** 2026-07-22 · **Repo:** `felhom.eu` (hub only) · **XS render-gate fix, DEPLOYED** ·
**STOP honoured: the ghost delete itself is NOT run — awaiting the operator's click.**
**Task:** the three HP-t740 findings — the box that can never call home (R-59 refuse-loudly gate),
the first-boot NIC sweep self-heal (R-60), and the knowable baked root password (R-61 slice 1) —
implemented in `scripts/iso/`, spike-proven, and driven end-to-end on a nested wrong-NIC install.
No hub/agent/controller/catalog changes; golden image and floors untouched.
## The defect (fourth inert seam — a template gate this time)
## Baselines
v0.70.0's ghost-delete was fully implemented server-side (`handleCustomerDelete`/`Preview` accept
`cfg == nil`) and fully unreachable: the Danger-zone card — and the `customerDeleteOpen/Submit`
script — sat inside the `{{if .HasConfig}}` block that also wraps the RESET card
(`customer_unified.html` old ~L736L945). A ghost rendered no Danger zone at all. Handler tests
POST directly and proved nothing about reachability. Confirmed live on `demo-vm-felhom` pre-fix.
- felhom.eu `main` at start: `1613bc7` (scripts CHANGELOG head = build-hub.sh v1.23.0); clean-tree
gate held before both ISO builds.
- Target: scripts train **v1.24.0** (`ISO_VERSION="1.24.0"` in `build-felhom-iso.sh`).
## The change
## Commits (this train, in order)
- **`configs.go` `handleCustomerUnified`**: new `Deletable` flag = the **exact negation** of the
preview's 404 predicate (`cfg == nil && no hosts && residue empty`). Hosts were already fetched
for the Host tab, so only the residue count is a new read — and it runs solely on the ghost
shape. Lookup error → log + `Deletable=false` (fail toward hiding a destructive control).
- **Template**: gate split — RESET card + its JS stay `{{if .HasConfig}}`; Danger zone + delete
dialog + delete JS gate on `{{if .Deletable}}`; Block/Unblock forms get an inner
`{{if .HasConfig}}`; ghost shape gets a one-sentence intro prefix (the dialog explains the rest).
## Tests (`customer_ghost_delete_render_test.go`) + red-proofs
Render tests against the real template set, real store on `t.TempDir()`, asserting the delete
form's `action` attribute and the `customerDeleteOpen(` call site (not free-text):
- **A — ghost with residue** (reports only, no config, 0 hosts): delete form + opener + script
present; RESET form ABSENT; Block/Unblock ABSENT. **PASS**
- **B — configured customer**: every affordance of today present (RESET, Block, delete machinery);
plus the blocked→Unblock branch (fixture note: `SaveCustomerConfig` never writes status —
blocking goes through `SetCustomerConfigStatus`, same as the /block handler). **PASS**
- **C — nothing left**: page-level **404**, mirroring the preview's semantics. There is **no
renderable `Deletable=false` state** with real store reads — `customer != nil` requires a report
row (`GetCustomer` selects from `reports`), and a report row makes `residue.Total() ≥ 1`. The
only false-branch shape short of that is a residue-lookup error (fail-safe, compile-trivial).
**Red-proof 1 (RUN):** Danger-zone gate reverted to `{{if .HasConfig}}` → test A **FAILED** on all
three delete-affordance assertions ("ghost page must render the delete form … dead UI"). Restored →
green.
**Red-proof 2 (RUN):** RESET card gated on `Deletable` (the lookalike) → the RESET-absent assertion
**FAILED** ("RESET card must NOT render for a ghost (found action=/configs/ghost/reset)").
Restored → green. `git diff` clean of scaffolding (0 REDPROOF markers).
Green gate: `go build ./... && go vet ./... && go test ./...`**green** (web suite 12.0 s).
## Ship (GitOps)
| Step | Evidence |
| Hash | What |
|---|---|
| Feature commit | `f4c2c41` (code + template + tests + CHANGELOG + ROADMAP + CONTEXT + CLAUDE.md) |
| Image | `felhom-hub:0.70.1` built + pushed, ID `f1741b46bfbb` |
| Manifest bump | **separate chore commit** `068bce6` (operator preference honoured) |
| Sync | ArgoCD hard-refresh + patch-sync → **Synced/Healthy**, rollout complete |
| Live verify | pod image `felhom-hub:0.70.1`, imageID digest `sha256:f1741b46bfbb…` (= the built image), clean startup log, `/login` 200. Footer is on password-gated pages — the visual check lands on the operator's next login (endpoint-level method stated per convention) |
| `9e29295` | R-61 slice 1: rootpw sibling file (0600) + manifest pointer; `test/rootpw-emission.sh`; OUT_ISO hoist; `FELHOM_ISO_KEEP_WORK` escape |
| `ad1d26a` | Harness catches up with the v1.21.0 in-script pairing wait (old scenarios would hang); assistant image + python3 |
| `c043ba9` | Phase-0 spike doc — sweep mechanics GO, F-P1..F-P8 (STOP-0 discharged before gate code) |
| `cf1862c` | R-59+R-60: `network_gate` + `sweep_nics` + Hungarian console screen; 4 harness scenarios; 3 red-proofs run |
| `699325b` | R-60 drill fix (F-P9): flush vmbr0 before the bounded dhclient — the baked fallback default route otherwise poisons the hub probe |
| (this commit) | v1.24.0 CHANGELOG, README, ROADMAP (R-59/60/61 SHIPPED + new R-62), onboarding runbook, capability map, spike-doc drill section + screen capture, this REPORT |
## AWAITING OPERATOR — the live leg is yours
## Harness results
Open `demo-vm-felhom` → Edit tab (Danger zone now renders with the ghost prefix) → **Delete
customer…** → three acks + typed id → confirm. Expected: customer gone from the Customers list,
journal legs `residue=ok customer_delete=ok` with `skipped_no_config` on the Hetzner/descriptor
legs, staleness emails stop. That click doubles as the v0.70.0 + v0.70.1 live validation; the
ROADMAP R-25b row flips its "PENDING" note once done.
- `test/bootstrap-modes.sh` (in `felhom-iso-assistant:trixie`): **40/40 green** — D (direct,
zero appliance calls, G1 zero-new-behavior invariant incl. zero sleeps + fixture untouched),
P (v1.21.0 single-invocation pairing loop), 410 crash-window, G2 sweep-success
(re-point + persist `inet dhcp` + `.felhom-bak` + proceed), G3 sweep-fail (screen content +
**byte-identical** interfaces + no bak), G4 state.json-present (sweep **never** invoked by
fake call-count, interfaces untouched).
- `test/rootpw-emission.sh`: **11/11 green** — dry-run emission, 0600, plaintext↔answer-hash
cross-check (`openssl passwd -6 -salt`), plaintext absent from build output, manifest-heredoc
pointer/no-leak guard.
- `shellcheck -S warning felhom-bootstrap.sh`: clean.
## Docs also in this train
## Red-proofs (all four run, failed exactly on their guarding assertion, restored)
- `hub/CHANGELOG.md` v0.70.1 (inert-seam framing, template/handler lines cited).
- `CLAUDE.md`: **the seam-wiring rule now covers template gates** — any conditional UI affordance
ships with a render test per branch.
- ROADMAP: R-25b annotated (v0.70.1 render fix, operator live leg PENDING); **R-17 → CLOSED**
(operator console action: `u629193-sub1` deleted, no sub-accounts remain on PBS-storage-1,
2026-07-22).
1. **Manifest plaintext leak** (R-61): `root-password-plain : ${ROOT_PLAIN}` injected into the
manifest heredoc → `manifest never references ROOT_PLAIN` FAILED → restored.
2. **Restore-on-failure dropped** (R-60): sweep's byte-identical restore removed → G3
`interfaces BYTE-IDENTICAL after failed sweep` FAILED → restored.
3. **State gate removed** (R-60): `[[ ! -e $STATE_FILE ]]``true` → G4 zero-ifreload/zero-
dhclient FAILED → restored.
4. **Hub-reachable early return removed** (R-59): all five G1 invariants FAILED → restored.
## Observations (recorded, not acted on)
## Phase-0 spike (STOP-0 honored)
- Pre-existing page-reachability quirk: a customer with host rows but neither config nor reports
404s on the unified page (configs.go `cfg == nil && customer == nil`) while the delete preview
would 200. No such state exists in practice (hosts imply enrollment implies reports); noted only.
- The Create Config button for ghosts is untouched (re-adopting a ghost stays a legitimate path).
`documentation/audits/SPIKE-firstboot-nic-sweep-2026-07-22.md` written and committed BEFORE the
gate code. Verdict GO; key inputs: success leg ≈ 4 s, dead candidate = exactly its DHCP bound;
unused NICs are admin-DOWN (raise before carrier read); ANY HTTP status = reachable (hub answers
302); trigger on hub-unreachable, never the fallback signature (the drill found the second
wrong-NIC variant: the installer picks the NIC that LEASES and bakes the lease as static).
## Nested end-to-end drill (evidence in the spike doc)
- **Round 1** caught a real defect the harness could not: the fallback bake includes a **default
route via 192.168.100.1**, and dhclient-script never replaces an existing default route → the
sweep's hub probe rode the dead gateway and the gate oscillated. Fixed (`699325b`, flush before
dhclient), validated by hot-patch (heal in 33 s), then the ISO was rebuilt.
- **Round 2, virgin final ISO, zero patching:** dead-NIC install → fallback baked → **R-59 screen
on the console** (capture committed: `SPIKE-firstboot-nic-sweep-2026-07-22-screen.png`) →
failed-sweep restore proven safe (flushed static re-applied by ifreload) → cable move →
**self-heal + hub registration in 23 s unaided** (persisted `inet dhcp` + winner port, original
in `interfaces.felhom-bak`, box at a proper DHCP address, pairing banner + code on console).
- **R-61 lockout closed, proven:** the build's `.rootpw.txt` password recomputed against the
installed box's `/etc/shadow` root hash → exact match (computed on the build host; the
plaintext never left it). Value NOT included here by design.
- Validation method note: no browser on DooPlex — console proof is QEMU screendump; hub-side
proof is the box's own registration/pairing-code delivery (endpoint-level).
## Artifacts
- Final drill ISO: `felhom-pve-9.2-1-v1.24.0-nested-probe-generic.iso`, sha256
`c25cdbbf35d2134b0bf278c4b069a3d5849c0960cf0a5e4500e52638489c8947`, built from clean tree at
`699325b` with an **uncommitted** key-bearing probe profile (nested-vm + ops SSH key — never
committed, per the armed-profile rule). Its `.rootpw.txt` exists beside it (0600) — verified,
value stored out-of-band only.
- Shipping rebuilds for real profiles (hp/n100/generic) happen at next need; the committed
pipeline builds them from `main` as usual.
## Cleanup verification
Nested VMs 9310/9311/9312 destroyed; throwaway `vmbr9` (+ its temporary host address) deleted;
transferred ISO + screendumps removed from felhom-pve; `brctl show` back to exactly `enp1s0` +
`veth9201i0` on vmbr0; **`/etc/network/interfaces` on felhom-pve diffed BYTE-IDENTICAL against
the pre-task copy** (sha `c7ace087…`). Residue by design: **three unclaimed-appliance
registrations at the production hub** (all three source machines destroyed) — operator may
discard them from the Unclaimed list.
## Observations / follow-ups
- **F-P8 (out of scope, noted):** the pairing loop's register-failure line prints every ~42 s on
a no-network box; v1.21.0 quieted the 204 wait but not this path. The gate makes it mostly moot
(it holds before register), but the line exists for a box that loses network AFTER install
start (state.json present → screen + retry, register never reached).
- The console font renders Hungarian long vowels (ő/ű) approximately on the text console —
same behavior as the existing pairing banner; acceptable.
- `qm` screendump (`qm monitor``screendump`) is a serviceable no-browser console-capture
method for nested drills — worth remembering for future runbooks.
- R-62 (hub delete-dialog cosmetics) filed on the ROADMAP as agreed — docs-only here.
@@ -32,6 +32,7 @@
| Appliance day-0 install: golden image → first boot → auto-confirm (zero clicks) → claimable box | installer, agent, hub, golden | **PROVEN-LIVE** (nested VM) | `DRILL-day0-vm-2026-07-12`, `DRILL-day0-take2-2026-07-12` | First firing on real customer hardware pending → R-1 |
| BYO install: `--mode byo`, mandatory caps, host-mutation disclosure, coexistence guards | installer v1.15+, agent | **PARTIAL** | `DRILL-GL6-2026-07-08` (demo box); GL-8 coexistence fixes | Peti clean-slate reinstall on proxmox2 is the first real BYO run of the current path → R-1 |
| Bare-metal Felhom ISO (blank hardware → zero-touch auto-install → first-boot `host-install`); selectable UEFI loader; **universal secret-free / operator-bind** mode | scripts v1.19.0 (`scripts/iso/`) + hub v0.62.0 + assistant container | **PROVEN-LIVE on TWO different boards** (N100 2026-07-18; HP t740 2026-07-21) | **`tests/VALIDATION-n100-rehearsal-2026-07-18.md` — the full chain on real metal in a single pass:** the generic reusable pairing ISO (v1.20.0, `--loader mkimage`, SB off) booted the cheap AMI board that F1 had blocked, installed unattended, and the box **self-registered as an unclaimed appliance at 16:17:14 — the same second it first booted** (`appliance_registrations` id=3), then bound → credential-delivered → day-0 SUCCESS 16:32:32 → floor-lifted to current. **F1 is closed on physical hardware.** Prior nested legs: slice A `SPIKE-baremetal-iso-2026-07-16` (build gate, disk-filter fail-safe, stub→host-install fetch); slice B RUNBOOK-B (shim boots+installs OVMF SB-enforcing + SeaBIOS; `--loader mkimage` boots+installs SB-off; mkimage SB-enforcing **FAILS** `Access Denied`; surgery byte-identical); **slice C (2026-07-17): the GENERIC secret-free ISO** — box self-registers as an unclaimed appliance (`POST /api/v1/appliance/register`, one-shot poll delivery, 404-no-oracle — all live-verified through the public ingress), operator binds on the Hosts page, hub delivers credentials once; bootstrap harness proves direct(zero-appliance-calls)/pairing/delivery; artifact proven secret-free (baked env = hub URL only) | **F1 loader caveat:** `--loader mkimage` fixes cheap AMI firmware that can't USB-boot the stock GRUB — UNSIGNED → **Secure Boot must be OFF**; default `shim` keeps SB. **Slice C bind is operator-password-gated** (CC stages, Viktor binds) → the live boot→register→bind→day-0 composition + physical N100 boot fold into the supervised rehearsal (R-1). Customer-facing **self-bind page = R-27 slice 1 SHIPPED (hub v0.66.0, 2026-07-17)** — see the dedicated self-bind row | **Second board, 2026-07-21 (demo-hp, HP t740 / Ryzen V1756B / AMI M42):** the whole chain ran on virgin hardware in one pass — armed install → self-registration as an unclaimed appliance → operator bind → day-0 → running guest 9201 + agent 0.92.1 as `demo-hp-bb76ea`. **The shim loader booted with Secure Boot ENABLED**, which retires the assumption that Felhom installs need SB off — that was an N100-firmware workaround. The exact-serial disk filter took the system SSD and left the box's 1TB NVMe untouched/unenrolled on hardware it had never seen. Two failures filed rather than smoothed over: **R-59** (no DHCP → the installer baked a static fallback instead of aborting) and **R-61** (baked root password unknowable → no console access).
| Box survives a wrong-NIC install: hub-unreachable first boot → legible Hungarian console screen (NIC table + remedy) + NIC sweep self-heal (bounded DHCP + hub probe per NIC, success-only persist), and the baked root password is operator-knowable (`<iso>.rootpw.txt`) | scripts v1.24.0 (`scripts/iso/felhom-bootstrap.sh` `network_gate`/`sweep_nics`, `build-felhom-iso.sh` rootpw emission) | **PROVEN-LIVE (nested drill — nested ≠ metal: metal proof rides the next real multi-NIC install)** | `audits/SPIKE-firstboot-nic-sweep-2026-07-22.md` — dead-NIC install from the virgin v1.24.0 ISO baked the 192.168.100.2 fallback (WITH a dead default gateway), the R-59 screen painted on the console (screendump captured), and after the cable move the box swept to the working NIC, re-leased and **self-registered at the hub unaided in under a minute**; the drill also caught + fixed the stale-fallback-route trap (flush before the bounded dhclient) and verified the emitted rootpw against the installed box's shadow hash | R-59 ships as a first-boot gate, not an install-time abort (recorded deviation — the fallback is the auto-installer's own, initrd hook out of scope); sweep is structurally first-boot-only (`state.json` gate + unit done-flag condition); a box past install-start gets the screen but its interfaces are never touched |
| Customer claim: one-time emailed code → customer sets own password (bcrypt, operator never sees it) | controller v0.122, hub v0.50 | **PROVEN-LIVE** (drill VM) | `DRILL-day0-vm-2026-07-12` §10/F-4 (gate ON via real edge; claimed, code consumed) | Never executed by a non-Viktor human → R-3. **Deliverability (R-4), gmail half DONE 2026-07-18:** the rehearsal's claim email was the first sent under the tightened DMARC `p=quarantine` and **landed in the gmail Inbox, not spam** (`tests/VALIDATION-n100-rehearsal-2026-07-18.md`). **freemail.hu remains Viktor's open half.** (Dropped mis-cited `CAMPAIGN-4` F-C — that is the escrow-claim 502, not password claim) |
| Customer binds their own appliance (self-service): operator-sent 7-day tokenized capability link → public two-factor `/bind/<token>` (console pairing code + retrieval passphrase) → hub stages the bind, no operator | hub v0.66.0 + ISO scripts v1.20.0 | **PROVEN-LIVE** (real customer-zero bind on metal, 2026-07-18) | **`tests/VALIDATION-n100-rehearsal-2026-07-18.md`:** operator minted + emailed the link 16:28:55 (7-day TTL, expiry 2026-07-25 recorded); **the customer bound their own box at 16:29:55 with `attempts=0`, `locked=0`**`appliance_bound` carries source **`customer_selfbind`**, and the credential was delivered **26 s later** with no operator action. Hub-side lifecycle in `hub-state.txt` (`selfbind_tokens` mint→email→consume). Prior unit evidence: hub v0.66.0 (`web/selfbind.go`, `store/selfbind.go`; Scenarios AF + F1/F2; 4 red-proofs verified red — THE TRAP `/bind/` exemption, no-oracle, lockout, single-active); GC verdict §3 (no appliance GC → TTL stands alone) | R-27 **slice 1**. No appliance list ever rendered; wrong code == wrong passphrase (one generic failure); 5-attempt lockout → call support; expiry falls back to operator-bind. **Live first-run DONE 2026-07-18** (rehearsal; the console banner rendered on the real ISO). **R-27b** (controller second-box dismissable prompt) deferred; **multi-box-per-link** = repeated operator sends |
| Escrow ceremony: customer-facing wizard, one-shot R claim, operator zero-knowledge | controller v0.127, agent v0.88/0.89 | **PROVEN-LIVE** (drill VM, endpoint-exact) | agent v0.88.0 REPORT (ceremony ~4s, one-shot claim 200→410, R absent from every payload); `SPIKE-controller-escrow-2026-07-13` | **Customer-facing browser wizard FIRST LIVE FIRING 2026-07-18** (`tests/VALIDATION-n100-rehearsal-2026-07-18.md`, S6): customer zero drove the wizard on the reborn box — ceremony started 16:56:29, recovery code claimed one-shot 16:56:39 (absent from logs by design), hub-verified and `EscrowState` auto-confirmed 16:56:41, **offsite runs enabled 12 s after the ceremony began**; the v0.138.0 „megerősítésre vár, legfeljebb 15 perc" awaiting card rendered and flipped on the ACK (operator screenshots: Viktor's set). Honest caveat: at a 12-second confirm the awaiting window is so short that catching *both* states on screen is luck, not procedure. Prior: endpoints driven on the drill VM. **agent v0.89.0:** `/escrow/preflight` `pbs_storage_id` row now live-reloads (reads current agent.json) — a pbsdr convergence that seeds the id flips it green with NO service restart. **hub v0.60.0 (data-first retention):** a re-escrow with a DIFFERENT sealed passphrase no longer destroys the old blob — the hub RETAINS it (`host_escrow_superseded`), so a previous passphrase stays recoverable with its recovery code (turns the reinstall-orphan incident from "history destroyed" into "history recoverable"). Guided-recovery flow = R-26. Red-proof `TestSaveHostEscrow_RetainsSuperseded`. **hub v0.60.1 — custody survives the host lifecycle:** host deletion (with the escrow ack) DEMOTES the current blob to retained custody (moved into `host_escrow_superseded`, never destroyed; existing superseded rows spared); the customer Danger-zone Delete is the one true purge point (cascades both escrow tables incl. already-deleted hosts). No operator path through host lifecycle can lose a blob. Red-proofs `TestDeleteHost_DemotesEscrowNeverDestroys` + `TestDeleteCustomer_PurgesEscrowCustody`. **agent v0.93.0 (2026-07-21) — recovery codes can no longer contain a hyphenated word.** The EFF large list holds exactly four entries containing the hyphen the words are joined with (`drop-down`, `felt-tip`, `t-shirt`, `yo-yo`); drawing one produced a code that reads as 11 words instead of 10 — ambiguous to transcribe in exactly the situation R exists for. They are now excluded **from GENERATION only**: the draw space goes 7776 → 7772 and a 10-word code 129.248 → 129.241 bits, still well clear of the 128-bit floor. **Every code already issued remains valid** — R is verified as a whole passphrase by the PBS scrypt KDF and is never re-split, so no customer needs to re-run a ceremony. This also retired the long-standing ~1/5 `TestGenerateRecoveryCode_EntropyAndFormat` flake, which was this defect and not a flaky test |
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -93,12 +93,41 @@ second address on vmbr0; default route unchanged.
The Part-3 gate makes this moot on the no-network path (the gate holds before register), but it
is worth knowing the line exists.
## Cleanup state after the probe
## Part-4 drill — the shipped v1.24.0 ISO, end to end (same day)
Nested VM 9310 **stopped** (destroyed in Part 4 after the drill re-uses the rig); `vmbr9` +
host-side `192.168.100.1/24` still mounted for Part 4; felhom-pve `/etc/network/interfaces`
untouched throughout (imperative `ip link` bridge only; pre-task copy sha
`c7ace087183cb72e031ad0cd1974b9239d539747528dd19523d993f8e9c4ec7a` — final diff happens at Part-4
cleanup). The probe box self-registered at the production hub as an unclaimed appliance while its
network was up (expected; the record can be discarded, or will simply be superseded by the Part-4
drill box's registration).
Two drill rounds on the same rig (both NICs on the dead `vmbr9` from `qm create`, so no lease
exists at ANY point of the install — the strict demo-hp shape):
**Round 1 (VM 9311, first v1.24.0 build) — reproduced the trap AND caught a real gate defect.**
The installer baked the fallback (`address 192.168.100.2/24` + **`gateway 192.168.100.1`** +
first NIC) and completed; the gate diagnosed, swept, painted the R-59 screen and retried — but
after the cable move the heal FAILED and the gate oscillated: the sweep's candidate leased fine,
yet the hub probe rode the **baked stale default route** (dhclient-script never replaces an
existing default route) and failed, so the sweep dutifully restored byte-identically every cycle.
**F-P9 (fix, shipped in `felhom-bootstrap.sh` before the final build): flush vmbr0's
addresses+routes after the re-point, before the bounded dhclient — a candidate is judged on the
lease's OWN addressing/routing.** Fix validated by hot-patching 9311 live: heal + hub
registration 33 s after unit restart (pairing banner with code on the console).
**Round 2 (VM 9312, virgin final ISO `c25cdbbf…`, zero patching) — the full claim:**
| Leg | Evidence |
|---|---|
| (a) dead-NIC install reproduces | installer env on 192.168.100.2 at 11:32; installed box: static fallback + dead gateway + `bridge-ports ens18` baked |
| R-59 screen renders | console screendump committed beside this doc (`SPIKE-firstboot-nic-sweep-2026-07-22-screen.png`): header, NIC table (név/MAC/kábel/sebesség), fallback-signature explanation naming 192.168.100.2, remedy line |
| failed-sweep restore is safe | after full sweep cycles (with F-P9 flushes) the runtime had `192.168.100.2/24` back on vmbr0 — `ifreload` re-applies the flushed pristine config; interfaces file byte-shape intact |
| (b) sweep to the moved cable, unaided | net1 → vmbr0 at 11:35:27; `network self-heal: vmbr0 -> ens19` + **hub registration at 11:35:50 — 23 s, no human action**; persisted `iface vmbr0 inet dhcp` + `bridge-ports ens19`, original in `interfaces.felhom-bak`; runtime 192.168.0.88 with a correct default route |
| (c) unclaimed appliance at the hub | `registered — appliance token stored (0600)`, pairing code delivered + banner on console; unit `activating` in the normal pairing wait |
| R-61 lockout closed | the build's `.rootpw.txt` password recomputed against the installed box's `/etc/shadow` root hash (`openssl passwd -6 -salt <shadow-salt>`) → **exact match**; comparison done on the build host, the plaintext never left it |
## Cleanup (verified at the end of the run)
Nested VMs 9310/9311/9312 destroyed (`--purge`); `vmbr9` deleted (its temporary
`192.168.100.1/24` went with it); the transferred ISO and screendump temp files removed from
felhom-pve; `brctl show` shows vmbr0 with exactly its original ports (`enp1s0`, `veth9201i0`);
**felhom-pve `/etc/network/interfaces` diffed byte-identical against the pre-task copy** (sha
`c7ace087183cb72e031ad0cd1974b9239d539747528dd19523d993f8e9c4ec7a`) — the file was never touched
(the throwaway bridge lived only as an imperative `ip link` object). Residue by design: **three
unclaimed-appliance registrations at the production hub** (the probe box, the hot-patched drill
box, the virgin drill box — all three machines destroyed); the operator can discard them from the
Unclaimed list at leisure.
+4 -3
View File
@@ -72,9 +72,10 @@
| R-56 | **[P3] Apps do not say how technical they are, so a beginner can be ambushed by a config-heavy one.** The catalog presents every app as equally approachable — one Telepítés button, the same Hungarian copy — but they are not. Glance needs a hand-written `glance.yml` before it does anything; some apps need a reverse-proxy or API concept to configure; others genuinely are install-and-use. A tester who picks the wrong first app concludes the PRODUCT is broken, not that they picked an advanced app. | S | **idea (filed 2026-07-21)** | Origin: TASK-E Part 3 — **filed, deliberately not implemented**. Shape: a `difficulty:` field in `.felhom.yml` (`kezdő` / `haladó` / `technikás`) surfaced as a catalog-card badge and repeated on the deploy screen. Cheap and incremental: one optional metadata field plus a badge, classifiable app-by-app with no migration — an app with no `difficulty:` simply shows no badge. **This is the constructive half of the glance ruling**: glance STAYS in the catalog (operator ruling 2026-07-21 — it is a legitimate app, not a broken one; its missing seeded `glance.yml` is a known pre-existing finding), and the honest fix is to LABEL it rather than hide it. Pairs with R-41: that gate proves an app CAN still deploy; this field tells a customer whether THEY should be the one deploying it. **Badge plumbing is ALREADY BUILT (controller v0.158.0)**`web.MetaBadge` + the `meta_badge` template partial + the `lifecycleBadge` funcmap entry were written generic for exactly this: a `difficultyBadge` funcmap function returning the same `*MetaBadge`, plus a `difficulty:` field on `stacks.Metadata`, is the whole remaining job. No new markup, no new CSS. Re-sized accordingly |
| R-57 | **An app can be withdrawn from the catalog without orphaning the customers already running it**`.felhom.yml` `lifecycle: available / hidden / abandoned`. | S | **SHIPPED 2026-07-21 — controller v0.158.0 (+ v0.158.1 fix), LIVE-PROVEN** | **Motivating case: plant-it.** Earlier the same day it was withdrawn by moving its directory to `retired/` — which un-offers the app but ALSO makes the controller's orphan detector see the template as GONE for anyone running it, flagging their working install `Elavult` and offering a Törlés button. Withdrawing an app must never take a working app away from a customer, so the directory move was replaced by metadata. **Operator requirements, verbatim (ruling 2026-07-21):** states `available` / `hidden` / `abandoned`; abandoned apps are NOT offered to new installs (no badge-but-installable middle state); deployed instances of hidden/abandoned apps keep full function; an abandoned app shows a permanent notice that *„Az alkalmazás fejlesztője felhagyott a fejlesztéssel. A telepített verzió továbbra is használható, de frissítések és biztonsági javítások már nem érkeznek hozzá."* **Design points that matter beyond this feature:** (a) the deploy gate is server-side and fail-CLOSED before any mutation — hiding a button is not a gate, and a stale link or direct POST must be refused; (b) an unknown lifecycle value fails OPEN (→ available + one WARN), deliberately opposite, because a typo or a state from a newer catalog must never pull a working app out of every customer's list — both read the same `EffectiveLifecycle`, so they cannot disagree; (c) lifecycle NEVER reaches orphan detection, red-proofed. **LIVE-PROVEN 2026-07-21 on 9201** through the real endpoints: `lifecycle: abandoned` arrived via the normal catalog sync; plant-it renders **0 times** on the Alkalmazások page while the control app renders 10; a direct `POST /api/stacks/plant-it/deploy` returns **HTTP 409 `{"ok":false,"error":"Ez az alkalmazás jelenleg nem telepíthető."}`**; the app page carries the notice and no Telepítés button. **v0.158.1 is a shipped-and-caught defect worth remembering:** the three predicates were declared with POINTER receivers, and html/template cannot call those on the non-addressable value the handler passes — every `/apps/<slug>` returned 500, for every app, while compiling cleanly with a fully green suite, because no test rendered `app_info`. A template method call is only checked when the template runs. Follow-on: R-56's difficulty badge reuses this plumbing |
| R-58 | **[P2] Assisted disk-picker install mode — the installer should let the operator CHOOSE the target disk instead of requiring the serial up front.** Today an install is either unattended (the answer file pins one `ID_SERIAL_SHORT`, which you can only know by first booting the machine) or match-nothing safety (aborts by design). That forces a two-boot dance for every new box: boot the safety ISO to read the serial, rebuild the ISO armed, boot again. | SM | **idea — operator ruling 2026-07-21** | **Operator's argument, verbatim:** *"the installer should list the available storage devices (excluding the installation media) and let us select one, and continue."* **Shape:** a THIRD ISO mode alongside the two that exist — unattended-serial and match-nothing-safety. It enumerates candidate disks with **size / model / serial**, excludes the installation media itself, takes a selection plus a confirm, and proceeds. **Unattended+serial REMAINS the appliance/factory mode** — it is the right shape when the machine is provisioned in bulk and nobody is standing there; the picker is for the case where somebody is. **Slice 1 (cheap, same code surface, do this first):** improve the abort screen. On filter-no-match the installer currently just fails safe and says nothing useful — it should print the candidate table (size/model/serial) plus the one-line hint naming which serial to put in the profile. That alone collapses the two-boot dance from "boot, guess, go read docs, rebuild" to "boot, copy the serial off the screen, rebuild", and it is the same enumeration code the full picker needs. **Why it matters beyond convenience:** it is the BYO / reinstall flow — a customer's existing hardware, or a rebuild of a box whose disk layout nobody recorded, is exactly where the serial is unknown and a wrong guess is destructive. The current fail-safe is correct but mute. Origin: TASK-G, arming the HP install ISO — the serial had to be read off the board by hand between two boots |
| R-59 | **[P1] A no-DHCP install must HARD-ABORT — instead it bakes the installer's fallback address as a STATIC config and completes, producing a box that can never call home.** | S | **idea (found live 2026-07-21, demo-hp)** | **This is the worst silent onboarding failure shape there is:** the install *succeeds*, the box looks finished, and it is permanently unreachable — no hub check-in, no pairing, no way in except a keyboard and monitor. Found on the HP t740's first install: the 4-port NIC got no DHCP lease at this site (see the t740 gotcha in `scripts/iso/README.md`), and rather than refusing, the installer wrote its **192.168.100.2 fallback as a static `vmbr0` address** into `/etc/network/interfaces` and carried on. **The philosophy is already established one layer over — the disk filter refuses loudly and touches nothing when it cannot identify its target** (spike S5c, proven twice on real boards). Networking deserves the identical treatment: no lease on any carrier-bearing NIC ⇒ **abort with a legible screen**, never invent an address. Slice: detect "DHCP produced no lease" in the answer/first-boot path and fail with the candidate NIC table (name / MAC / carrier / link speed) plus the one-line remedy, exactly as R-58 slice 1 does for disks — same refuse-loudly grammar, same screen shape. Pairs with **R-60**, which is the self-heal for the case where the cable simply moved |
| R-60 | **[P2] First-boot NIC sweep self-heal: if the hub is unreachable, try DHCP across every carrier-bearing NIC before settling.** | S | **idea (found live 2026-07-21, demo-hp)** | `felhom-bootstrap` currently accepts whatever addressing the installer left behind and, if the hub cannot be reached, simply stays broken. On demo-hp the fix was a human moving one cable from the 4-port card to the onboard port — **a sweep would have healed it unaided**: enumerate NICs with `carrier=1`, DHCP each in turn, and keep the first that reaches the hub. Cheap because the box has nothing to lose at first boot (no customer data, no running guests) and the failure it repairs is total. Deliberately scoped to FIRST BOOT and to the hub-unreachable condition only — a running box must never re-shuffle its own networking. Complements **R-59**: that one refuses to produce an unreachable box, this one repairs the case where the truth changed after the install (cable moved, switch port died, the installer guessed the wrong port) |
| R-61 | **[P1] The baked root password must be knowable by the operator — the recurring console lockout.** | S | **idea (found live 2026-07-21; recurring)** | The ISO mints a **fresh throwaway crypt hash per build** and the plaintext is discarded, so nobody — including the person holding the machine — can log into the console of a box they just installed. Today that meant reaching demo-hp only through the G1 break-glass credential vaulted in the hub, which is the right mechanism for a *lost* password and the wrong one for a *never-known* password: it requires a working hub, a working network, and operator tooling, at exactly the moment the likely reason you need the console is that one of those is broken. **Slice 1 (do this):** the ISO build emits the baked root password into the build REPORT and the operator cheat-sheet alongside the sha256 — it is already a per-build value, so surfacing it costs nothing and closes the lockout. **Follow-up (appliance-grade):** keep it per-build random and treat the build output as the record of truth. **A fixed well-known password is explicitly REJECTED (operator ruling 2026-07-21)** — a pre-pairing box sits on a stranger's LAN with a predictable root credential, which is a far worse exposure than the lockout it would fix. Relates to G1 break-glass (the vault stays; this is about the window before/without it) |
| R-59 | **[P1] A no-DHCP install must HARD-ABORT — instead it bakes the installer's fallback address as a STATIC config and completes, producing a box that can never call home.** | S | **SHIPPED v1.24.0 (2026-07-22) — as a FIRST-BOOT refuse-loudly gate, with a RECORDED DEVIATION: the install-time abort is out of scope (the 192.168.100.2 fallback is baked inside the Proxmox auto-installer itself, unreachable without an installer-initrd hook; operator-acked, not silently dropped). The screen + gate proven on the nested drill (SPIKE-firstboot-nic-sweep-2026-07-22.md); nested ≠ metal — metal proof rides the next real install** | **This is the worst silent onboarding failure shape there is:** the install *succeeds*, the box looks finished, and it is permanently unreachable — no hub check-in, no pairing, no way in except a keyboard and monitor. Found on the HP t740's first install: the 4-port NIC got no DHCP lease at this site (see the t740 gotcha in `scripts/iso/README.md`), and rather than refusing, the installer wrote its **192.168.100.2 fallback as a static `vmbr0` address** into `/etc/network/interfaces` and carried on. **The philosophy is already established one layer over — the disk filter refuses loudly and touches nothing when it cannot identify its target** (spike S5c, proven twice on real boards). Networking deserves the identical treatment: no lease on any carrier-bearing NIC ⇒ **abort with a legible screen**, never invent an address. Slice: detect "DHCP produced no lease" in the answer/first-boot path and fail with the candidate NIC table (name / MAC / carrier / link speed) plus the one-line remedy, exactly as R-58 slice 1 does for disks — same refuse-loudly grammar, same screen shape. Pairs with **R-60**, which is the self-heal for the case where the cable simply moved |
| R-60 | **[P2] First-boot NIC sweep self-heal: if the hub is unreachable, try DHCP across every carrier-bearing NIC before settling.** | S | **SHIPPED v1.24.0 (2026-07-22) — spike + nested drill proven (SPIKE-firstboot-nic-sweep-2026-07-22.md): cable move → sweep → heal + hub registration unaided in <1 min; sweep is structurally first-boot-only (state.json gate + the unit's done-flag condition); drill also surfaced and fixed the baked-fallback-default-route trap (flush before the bounded dhclient)** | `felhom-bootstrap` currently accepts whatever addressing the installer left behind and, if the hub cannot be reached, simply stays broken. On demo-hp the fix was a human moving one cable from the 4-port card to the onboard port — **a sweep would have healed it unaided**: enumerate NICs with `carrier=1`, DHCP each in turn, and keep the first that reaches the hub. Cheap because the box has nothing to lose at first boot (no customer data, no running guests) and the failure it repairs is total. Deliberately scoped to FIRST BOOT and to the hub-unreachable condition only — a running box must never re-shuffle its own networking. Complements **R-59**: that one refuses to produce an unreachable box, this one repairs the case where the truth changed after the install (cable moved, switch port died, the installer guessed the wrong port) |
| R-61 | **[P1] The baked root password must be knowable by the operator — the recurring console lockout.** | S | **slice 1 SHIPPED v1.24.0 (2026-07-22): the build emits the plaintext into a 0600 sibling `<iso>.rootpw.txt` (single record of truth — never logged/manifested/committed); drill-verified against the installed box's shadow hash. Follow-up (appliance-grade record-keeping) stays open** | The ISO mints a **fresh throwaway crypt hash per build** and the plaintext is discarded, so nobody — including the person holding the machine — can log into the console of a box they just installed. Today that meant reaching demo-hp only through the G1 break-glass credential vaulted in the hub, which is the right mechanism for a *lost* password and the wrong one for a *never-known* password: it requires a working hub, a working network, and operator tooling, at exactly the moment the likely reason you need the console is that one of those is broken. **Slice 1 (do this):** the ISO build emits the baked root password into the build REPORT and the operator cheat-sheet alongside the sha256 — it is already a per-build value, so surfacing it costs nothing and closes the lockout. **Follow-up (appliance-grade):** keep it per-build random and treat the build output as the record of truth. **A fixed well-known password is explicitly REJECTED (operator ruling 2026-07-21)** — a pre-pairing box sits on a stranger's LAN with a predictable root credential, which is a far worse exposure than the lockout it would fix. Relates to G1 break-glass (the vault stays; this is about the window before/without it) |
| R-62 | **[P3] Hub delete dialog: show the customer-id the operator must type, and reword the three acks for the ghost shape.** | XS | **idea (operator, 2026-07-22)** | Cosmetic, hub-only, docs-only in the v1.24.0 train. The delete confirmation asks the operator to type the customer-id, but the id appears NOWHERE on the Edit page the dialog opens from — the operator has to fish it out of the URL or another tab. Also: for a GHOST customer (host already gone) the three acknowledgement checkboxes describe teardown steps that cannot happen; **wording only** — the server MUST keep requiring all three (the render-gate lesson of v0.70.1 stands: reachability and requirements are separate concerns). |
| R-53 | **`app_export.html` substituted the CSRF token where the customer domain belongs** - the open-in-browser link was wrong for every app with a subdomain, and a session CSRF token landed in a URL. | XS | **SHIPPED (controller v0.150.0, 2026-07-20)** | One template token (`{{$.CSRFToken}}` -> `{{$.Domain}}`) plus the `Domain` key in `exportPageHandler`'s data map - that handler does not go through `baseData`, which is where every other page gets it, so the template had no domain to read. Render tests assert the joined `<sub>.<domain>` and that the token appears nowhere in that line; red-proofed against the pre-fix template. Origin: `audits/AUDIT-vacation-remote-ops-2026-07-20.md` (F7) |
## P3 — post-alpha
@@ -36,6 +36,11 @@
memtest pass, SMART clean, BIOS queue for the single visit in B2.
3. **Install media:** the **reusable generic pairing ISO** (secret-free, slice C). Only rebuild it
when `scripts/` ships a new version — never per-customer. Flash via dd/Rufus-DD.
**Console credential for bench work (R-61, since v1.24.0):** every build emits the baked root
password into the 0600 sibling **`<iso>.rootpw.txt`** next to the ISO in the build output — that
file is how you log into the console of any box installed from that build (bench checks, dead-
network diagnosis). Operator-only; never commit or paste it anywhere; the G1 break-glass vault
remains the mechanism for a *lost* password on an enrolled box.
Loader per firmware — record the board model + loader choice in the customer record; full
fleet inventory in `operations/nodes.md`:
@@ -72,9 +77,14 @@
> HP t740 the cable was on the 4-port expansion card, which gets **no lease** — and instead of
> aborting, the installer baked its `192.168.100.2` fallback as a **static** config and
> completed: a box that looked installed and could never call home
> (`operations/nodes.md` — the NIC map and the trap; filed as **R-59** hard-abort and **R-60**
> first-boot NIC sweep, both pending). Until those ship, on any multi-NIC board confirm at the
> installer's network step that the interface holding the lease is the one with the cable.
> (`operations/nodes.md` — the NIC map and the trap). **Since ISO v1.24.0 the box handles this
> itself (R-59+R-60):** if the hub is unreachable on first boot it sweeps every NIC (bounded
> DHCP + hub probe) and keeps the first that works — a cable in the wrong port just costs the
> sweep a minute — and when nothing works it paints a legible Hungarian screen with the NIC
> table (név/MAC/kábel/sebesség) and retries every minute. Nested-drill proven
> (`audits/SPIKE-firstboot-nic-sweep-2026-07-22.md`); on a pre-v1.24.0 stick the old manual
> check still applies: confirm at the installer's network step that the leasing interface is
> the cabled one.
3. **Bind** to the customer — **the rehearsal used the SELF-BIND path, and it is now the default for
this runbook.** The link is already in the customer's inbox (auto-minted at creation, A1); the
customer opens it and completes the public two-factor `/bind/<token>` page (console pairing code
+52
View File
@@ -1,5 +1,57 @@
# Felhom scripts — Changelog
## ISO train v1.24.0 — the box that can never call home learns to say so, heal itself, and let you in (R-59 + R-60 + R-61) (2026-07-22)
All three findings are from the HP t740 install (2026-07-21). Proven end-to-end on a nested
wrong-NIC install (spike + virgin-ISO drill: `documentation/audits/SPIKE-firstboot-nic-sweep-2026-07-22.md`).
- **R-59 — the silent unreachable box gets a legible screen.** A no-DHCP install bakes the
installer's **192.168.100.2 fallback as a static `vmbr0` config** (with a dead
`gateway 192.168.100.1`) and completes; the box then waited silently forever. The first-boot
bootstrap now runs a **network gate before mode dispatch**
(`scripts/iso/felhom-bootstrap.sh:251` `network_gate`, called at `:510`): hub unreachable →
physical-NIC diagnosis (name/MAC/carrier/speed; NICs are raised first — unused ports sit
admin-DOWN with unreadable carrier), the fallback signature named when present, and a calm
Hungarian console screen (`paint_network_screen`, `:230` — the `print_pairing_banner` pattern)
with the NIC table and the one-line remedy; then retry every 60 s with the unit held in
`activating` (waiting is not failing — the v1.21.0 lesson, unit `TimeoutStartSec=infinity`).
The trigger is always **hub-unreachable, never the fallback signature**: the drill proved a
second wrong-NIC variant (the installer picks the NIC that LEASES and bakes the lease as
static — a later cable move leaves a plausible-looking dead config with no signature).
*Recorded deviation:* R-59 ships as this first-boot refuse-loudly gate, not an install-time
abort — the fallback is baked inside the Proxmox auto-installer itself, unreachable without an
installer-initrd hook (out of scope by operator ack, noted on the ROADMAP row).
- **R-60 — first-boot NIC sweep self-heal.** While the hub is unreachable AND no install attempt
has begun (`state.json` gate; the unit's `ConditionPathExists=!done-flag` confines the whole
script to pre-install anyway), the gate sweeps (`sweep_nics`,
`scripts/iso/felhom-bootstrap.sh:178`): per candidate NIC, re-point `vmbr0`'s `bridge-ports`
(atomic tmp+mv, always rendered from the pristine copy) → `ifreload` → **flush vmbr0's
addresses/routes** → bounded `dhclient` (20 s) → hub probe (any HTTP status counts — the hub
answers `/` with 302). The flush is load-bearing: the baked fallback default route survives a
successful lease otherwise (dhclient-script never replaces an existing default route) and the
probe rides the dead gateway — found live in the drill. First success persists
`bridge-ports <winner>` + `iface vmbr0 inet dhcp` (original kept as
`interfaces.felhom-bak`) and proceeds; total failure restores the config **byte-identically**.
Drill: cable moved → healed + registered at the hub in well under a minute, unaided.
- **R-61 slice 1 — the baked root password becomes knowable.** `build-felhom-iso.sh` writes the
minted throwaway plaintext to a **0600 sibling `<iso>.rootpw.txt`** (password + ISO name +
build date; `scripts/iso/build-felhom-iso.sh:254`) — the single record of truth. Never
stdout/logs; the manifest carries only a pointer (`:383`). A fixed well-known password stays
REJECTED (operator ruling 2026-07-21). Drill: the emitted password's hash matches the
installed box's `/etc/shadow` — the console lockout is closed.
- **Harness** (`scripts/iso/test/bootstrap-modes.sh`): caught up with the v1.21.0 in-script wait
(the old pairing scenarios would hang), plus four gate scenarios — hub-reachable = ZERO new
behavior (no ip/ifreload/dhclient/sleep call, fixture untouched), sweep-success (persist +
bak + proceed), sweep-fail (screen + byte-identical config), state-file-present (sweep NEVER
invoked). New `test/rootpw-emission.sh` for R-61 (emission, 0600, plaintext↔answer-hash
cross-check, manifest guard). **Four red-proofs run and restored:** manifest-plaintext leak,
restore-on-failure dropped, state gate removed, hub-reachable early-return removed — each
failed exactly its guarding assertion.
- Assistant image gains `python3` (the bootstrap's JSON parsing needs it; PVE ships it on real
boxes — the harness runs the bootstrap in the image). `build-felhom-iso.sh` gains the
`FELHOM_ISO_KEEP_WORK=1` debug/test escape and resolves `OUT_ISO` before the mint (the rootpw
sibling is named after the ISO).
## build-hub.sh v1.23.0 — the hub build script enters the repo (2026-07-21)
It never was in one. `/mnt/5_hdd/felhom.eu/build/felhom-hub/build.sh` lived only in a DooPlex build
+21 -5
View File
@@ -49,7 +49,13 @@ scripts/iso/build-felhom-iso.sh \
> `/mnt/5_hdd/felhom.eu/felhom-iso/out`** (override with `$FELHOM_ISO_OUT` on other hosts), so on 180 you
> can omit `--out` entirely and output still lands on the HDD, never the SSD.
Output: `felhom-pve-<pvever>-v<isover>-<profile>.iso` + `.sha256` + `.manifest.txt`.
Output: `felhom-pve-<pvever>-v<isover>-<profile>.iso` + `.sha256` + `.manifest.txt` +
**`.rootpw.txt` (0600, R-61 slice 1)** — the minted throwaway root password in plaintext (one line:
password + ISO name + build date), the operator's console credential for every box installed from
that build. It is the SINGLE record of truth: never logged, never in the manifest (which carries
only a pointer — manifests get pasted into committed REPORTs), **never committed or pasted
anywhere**. A fixed well-known password stays rejected (operator ruling 2026-07-21): a pre-pairing
box sits on a stranger's LAN.
The build **gates the answer on validate-answer's OUTPUT TEXT, never `$?`**`validate-answer`
returns exit 0 even on a broken file (spike S1 trap; also `prepare-iso` was observed to exit 0 on a
@@ -170,10 +176,20 @@ wired NICs and the install picks wrong:
| `wlo1` | `iwlwifi` | wifi | unused |
Plug the cable into the **onboard** port. If the install already happened on the wrong port, the
symptom is nasty: the installer does not abort, it bakes its **192.168.100.2 fallback as a STATIC
`vmbr0` address** and completes, so the box looks installed and can never reach the hub (R-59).
Repair on the console: point `bridge-ports` at `enp2s0f0` in `/etc/network/interfaces`, set the
correct address (or `dhcp`), `ifreload -a`. R-60 is the self-heal that would make this unnecessary.
symptom used to be nasty: the installer does not abort, it bakes its **192.168.100.2 fallback as a
STATIC `vmbr0` config** (address AND a dead `gateway 192.168.100.1`) and completes, so the box
looks installed and can never reach the hub (R-59). **Since v1.24.0 the trap has a gate and a
self-heal (R-59+R-60):** on first boot, hub-unreachable → the bootstrap sweeps every physical NIC
(re-point `bridge-ports` → bounded DHCP → hub probe) and keeps the first that reaches the hub
(persisted as `iface vmbr0 inet dhcp`, original in `interfaces.felhom-bak`); if nothing works it
paints a legible Hungarian console screen (NIC table: név/MAC/kábel/sebesség + remedy) and retries
every minute — so *moving the cable to any port is enough, no console repair needed*. The sweep
runs only before the install attempt begins (`state.json` gate); a box already past that point
gets the screen but its interfaces are never touched. Manual repair (pre-v1.24.0 boxes): point
`bridge-ports` at `enp2s0f0` in `/etc/network/interfaces`, set the correct address (or `dhcp`),
`ifreload -a` — and mind the baked stale `gateway` line. Proven end-to-end on the nested drill
(`documentation/audits/SPIKE-firstboot-nic-sweep-2026-07-22.md`); metal proof rides the next real
install.
**Confirm the serial is the system disk and not a data drive.** On this board the SanDisk X600 128GB
(`sda`) is the system disk; the 1TB NVMe is the future data drive and must stay OUTSIDE the filter —