From eb600872f213fc6c41c890df52ba061d391813e2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 10 Aug 2026 13:57:50 +0200 Subject: [PATCH] R-297: installer compares a local golden against the manifest before using it Step 7 short-circuited on any local golden archive with no version compare, no digest and no warning, so the manifest sha256 was consulted only on the fetch path. Local discovery is newest-by-filename: correct by recency, never by verification. A box could reinstall from a stale archive and come back below the version where the offsite recovery screen exists. Digest first, then the baked controller tag. An auto-discovered mismatch re-fetches the vouched golden; an operator-named mismatch refuses. An unreadable manifest refuses rather than passing. Not published: installer-v1.26.0 is deliberately not cut until a fresh install has been observed taking a stale local golden on drill-r50. Also files R-295..R-298. --- documentation/backlog/OPEN-ITEMS.md | 4 ++ scripts/CHANGELOG.md | 39 ++++++++++++++ scripts/felhom-host-install.sh | 81 +++++++++++++++++++++++++++-- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index 8ec8612..b5fc8fd 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -595,3 +595,7 @@ unrelated finding, which is why the sweep's proposals were renumbered to R-159 **R-159 and R-160 are SHIPPED** and are not ranked; they are filed to record the class, and R-159's class (an image `VOLUME` at an unmounted path) is still live — `immich-server` has one today. +| **R-295** | **One name per secret — CONTROLLER HALF SHIPPED.** The claim page called the SAME three-word dashboard code „Beállító kód" on the first-time branch and „Visszaállító kód" on the reset branch, while the TEN-word escrow code is „Helyreállítási kód". Two near-homographs for two different secrets; the collision cost a real code. „Visszaállító kód" is **retired** in the controller (`claim.html` label/subtitle/button, `claim.go` print-reset-code + lockout strings); the name is now constant and the SENTENCE changes. Naming only — pinned by `TestResetCode_StillAcceptedOnTheSetupPage`. **HUB HALF NOT DONE (Part 4a, dropped per the session's own drop order):** the hub's send button „Visszaállító kód küldése", the mail subject „Jelszó-visszaállítási kód", its body „Visszaállító kód:", and the mail sending the customer to an „Elfelejtett jelszó" page while a rebuilt box actually serves „A szerver beállítása" | **PARTIAL — controller shipped v0.211.0; hub half OPEN (S)** | R-294 | Apply the same ruling in `felhom.eu/hub`, and make the mail name the page the machine is actually showing | CC | +| **R-296** | **The orphan card's OTHER sentence makes the same promise, and the spec says it is fine.** `SPEC-orphan-card-copy-2026-08-10.md` §1 lists `backups_remote.html:98` as *"Accurate; keep"*. It is not: it ends *„A meglévő mentések nem sérültek — a hozzájuk tartozó helyreállítási kóddal később **visszaállíthatók lehetnek**."* — the identical unevaluable promise in a different conjugation, which the spec's own regression guard (`visszaállítható lehet`, singular) does not match. Line 101 was fixed; line 98 was left **deliberately unchanged**: the instruction was not to improvise Hungarian at the customer | **READY (XS) — NEW 2026-08-10** | R-294 | Needs a wording decision, then the same treatment as line 101 and an assertion covering BOTH conjugations | operator + CC | +| **R-297** | **An install took whatever golden was lying around.** Step 7 of the host installer short-circuited on any local archive with no version compare, no digest and no warning, so the manifest's sha256 — vouching from a different trust root than the code host — was consulted only on the fetch path. Discovery is newest-by-filename: correct by recency, never by verification. A box reinstalled from a stale archive can come back below the version where the off-site recovery screen exists, and below the update floor. Three goldens on `demo-hp` carry controller 0.153.0 and 0.210.0; nothing compared either to the vouched version. **FIXED in `felhom-host-install.sh` v1.26.0** (digest first, then the baked `/etc/felhom-controller-image` tag; auto-discovered mismatch re-fetches, an operator-named mismatch REFUSES; an unreadable manifest refuses). **NOT PUBLISHED and NOT YET OBSERVED FAILING** — the pre-fix stale install was never watched on `drill-r50`, so the `installer-v1.26.0` tag is deliberately not cut | **SHIPPED TO main, UNPUBLISHED (M)** | — | Observe a fresh install taking a stale local golden on `drill-r50` BEFORE publishing; then cut `installer-v1.26.0` and bump both `--ref`s in `webpage.yaml` | CC | +| **R-298** | **The `/storage` page's unregistered list is filtered by `role==='user-data'`, so a drive that is also the backup target can never be registered from it.** `storage.html:363` routes anything not `user-data` into the read-only protected group with NO actions. On the rebuilt `demo-hp` the NVMe is deliberately BOTH the user-data drive and the `felhom-backup` target (`/etc/pve/storage.cfg`: `dir: felhom-backup` → `/mnt/nvme-1tb`), so it renders locked. **This is the SECOND reason that page was empty** during the reinstall rehearsal, independent of R-280's candidate-source defect, and R-280's fix does not touch it — attaching is non-destructive, so the format-wizard protection is the wrong gate for a REGISTER action | **READY (S) — NEW 2026-08-10** | R-280 | Split the role gate: `user-data` keeps destructive actions; any mounted role may be REGISTERED | CC | diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index abcf92b..1ac0e55 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,3 +1,42 @@ +## felhom-host-install.sh v1.26.0 — an install takes the golden you approved (2026-08-10, R-297) + +**Step 7 short-circuited on ANY local golden archive: no version compare, no digest, no warning.** +`if [[ -n "$GOLDEN_VOLID" ]] && ! $FORCE_GITEA_GOLDEN; then log_skip "using local golden"; return 0` +— so the manifest's sha256, whose entire purpose is to vouch from a **different trust root than the +code host**, was consulted only on the fetch path. Local discovery is +`pvesm list … | sort | tail -1`: correct by **recency**, never by verification. + +**The sharp end:** a box with an old archive lying around reinstalls from it silently, and can come +back below the version where the off-site recovery screen exists — unable to run the ceremony its +own data depends on — and born below the update floor, whose auto-target is the floor and never the +newest. Measured on a real host: three goldens sat on `demo-hp`, carrying controller **0.153.0** and +**0.210.0**, and nothing compared either to the vouched version before using it. + +**The row narrows itself and the narrowing holds:** on `--resume`, step 7 fetches correctly, because +resume skips preflight and preflight is where local discovery sets the variable (`:2849`). The two +paths disagreed and resume was the safe one. + +`golden_local_matches_manifest()` now runs two comparisons, cheapest first: + +1. **digest** — local sha256 == manifest sha256 ⇒ this IS the vouched artifact, certain; +2. **version** — a locally BAKED golden is legitimately not byte-identical to the published file, so + a digest mismatch alone is not proof of staleness. The controller tag baked into the archive + (`/etc/felhom-controller-image`, what the bootstrap unit runs) is compared to the vouched version. + +Neither matching ⇒ the archive is not what the operator approved. **An auto-discovered archive is +then ignored and the vouched one fetched**, saying which check failed and why; **an archive the +operator NAMED with `--golden` is REFUSED, never silently substituted**, and the refusal names the +vouched version and the two routes (pass a matching archive, or `--force-gitea-golden`). + +**Fail-safe:** an unreadable manifest is a REFUSAL, not a pass. "We could not look" must not resolve +to "install whatever is lying around" — that is the finding restated. + +**NOT YET OBSERVED FAILING, and deliberately not published.** The task asked that a fresh install be +watched taking a stale local golden BEFORE the fix, on `drill-r50`. That observation was not made in +this session, so this change is committed to `main` — which publishes nothing (R-110) — and the +`installer-v1.26.0` tag is **deliberately not cut**. Publishing an unobserved change to the one +artifact that runs as root on a virgin box is the failure this project keeps writing rules about. + ## golden_currency_gate.py — a released controller must have a golden carrying it (2026-08-08, R-242) **R-242 was filed the previous day as a rule with no mechanism and RECURRED WITHIN A DAY.** Controller diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 0770978..ecb4a4d 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -184,7 +184,7 @@ set -euo pipefail -SCRIPT_VERSION="1.25.0" # the SINGLE version source (F-1): -h and the run banners follow it. +SCRIPT_VERSION="1.26.0" # the SINGLE version source (F-1): -h and the run banners follow it. # The hub used to carry a copy for its Setup tab; R-94 DELETED it # (2026-08-02) because the hub cannot know which version a box runs — # the Setup command fetches this script at run time. scripts/ @@ -228,6 +228,10 @@ HUB_URL="https://hub.felhom.eu" VMID="9201" VMID_EXPLICIT=false # set true when --vmid is given; gates the auto-pick-a-free-vmid behavior GOLDEN_VOLID="" +# R-297: true only when the operator named the archive with --golden, so a mismatch REFUSES +# (never silently substitutes) rather than re-fetching. +GOLDEN_VOLID_EXPLICIT=false +GOLDEN_CHECK_WHY="" GOLDEN_VMID="9100" ARCHIVE_STORAGE="local" NODE="" @@ -1230,7 +1234,7 @@ while [[ $# -gt 0 ]]; do --mode) MODE="$2"; shift 2 ;; --hub-url) HUB_URL="$2"; shift 2 ;; --vmid) VMID="$2"; VMID_EXPLICIT=true; shift 2 ;; - --golden) GOLDEN_VOLID="$2"; shift 2 ;; + --golden) GOLDEN_VOLID="$2"; GOLDEN_VOLID_EXPLICIT=true; shift 2 ;; --golden-vmid) GOLDEN_VMID="$2"; shift 2 ;; --archive-storage) ARCHIVE_STORAGE="$2"; shift 2 ;; --node) NODE="$2"; NODE_EXPLICIT=true; shift 2 ;; @@ -2642,12 +2646,81 @@ PY # Local auto-discovery is the default + fallback. When no local golden exists (or --force-gitea-golden), # fetch the golden from Gitea (git token), VERIFY its sha256 against the hub manifest, and import it # into the archive storage's dump dir under a valid vzdump name so the provision restore can use it. +# R-297 — A LOCAL GOLDEN IS NOT A VOUCHED GOLDEN UNTIL IT HAS BEEN COMPARED TO THE MANIFEST. +# +# Step 7 used to short-circuit on ANY local archive: no version compare, no digest, no warning. The +# manifest's sha256 — whose whole purpose is to vouch from a different trust root than the code host +# — was consulted only on the fetch path. Local discovery is `sort | tail -1`: correct by RECENCY, +# never by verification. A box with an old archive lying around therefore reinstalled from it +# silently, and could come back BELOW the version where the off-site recovery screen exists — unable +# to run the ceremony its own data depends on, and born below the update floor. +# +# Two comparisons, cheapest first: +# 1. DIGEST — if the local file's sha256 equals the manifest's, it IS the vouched artifact. Certain. +# 2. VERSION — a locally BAKED golden is not byte-identical to the published one, so a digest +# mismatch is not by itself proof of staleness. The controller version baked into the archive +# (/etc/felhom-controller-image, the tag the bootstrap unit runs) is then compared to the +# manifest's vouched version. +# Neither matching ⇒ the archive is not what the operator approved. +# +# Reading the marker streams the archive until the entry is found (--occurrence=1 stops there), which +# is why the digest is tried first: on a previously-FETCHED golden the cheap check settles it. +golden_local_matches_manifest() { + local volid="$1" path want_sha got_sha marker ver + path=$(pvesm path "$volid" 2>/dev/null) + if [[ -z "$path" || ! -f "$path" ]]; then + GOLDEN_CHECK_WHY="the archive could not be resolved to a file on disk" + return 1 + fi + want_sha="$ART_GOLDEN_SHA" + if [[ -z "$ART_GOLDEN_VER" || -z "$want_sha" ]]; then + # We could not look. That must never read as "it is fine" — the whole finding is a stale + # archive being taken on trust. + GOLDEN_CHECK_WHY="the hub manifest has no vouched golden version/sha256 to compare against" + return 1 + fi + got_sha=$(sha256sum "$path" 2>/dev/null | awk '{print $1}') + if [[ -n "$got_sha" && "$got_sha" == "$want_sha" ]]; then + log_info " local golden digest matches the manifest (${got_sha:0:16}…) — this IS the vouched artifact" + return 0 + fi + marker=$(tar --zstd -xOf "$path" --occurrence=1 ./etc/felhom-controller-image 2>/dev/null | tr -d '[:space:]') + ver="${marker##*:}" + if [[ -z "$ver" ]]; then + GOLDEN_CHECK_WHY="its digest does not match the manifest and it carries no readable controller version marker" + return 1 + fi + if [[ "$ver" == "$ART_GOLDEN_VER" ]]; then + log_info " local golden is controller $ver — the vouched version (digest differs: locally baked, not the published file)" + return 0 + fi + GOLDEN_CHECK_WHY="it is controller $ver, but the vouched golden is $ART_GOLDEN_VER" + return 1 +} + step_golden() { log_step "7/8 golden archive" if [[ -n "$GOLDEN_VOLID" ]] && ! $FORCE_GITEA_GOLDEN; then - log_skip " using local golden: $GOLDEN_VOLID" - _state_mark golden; return 0 + # The manifest is needed to compare; resolve it here rather than trusting the archive. + # resolve_artifacts dies with its own message if the manifest cannot be read. That is + # deliberate: without a vouched version there is nothing to compare an archive against, and + # "we could not look" must not resolve to "install whatever is lying around". + [[ -n "$ART_GOLDEN_VER" ]] || resolve_artifacts + GOLDEN_CHECK_WHY="" + if golden_local_matches_manifest "$GOLDEN_VOLID"; then + log_skip " using local golden: $GOLDEN_VOLID" + _state_mark golden; return 0 + fi + if $GOLDEN_VOLID_EXPLICIT; then + # The operator named this archive. Never silently substitute a different one. + die "refusing the golden you named ($GOLDEN_VOLID): ${GOLDEN_CHECK_WHY}. + The vouched golden is ${ART_GOLDEN_VER:-}. Either pass the archive that matches it, + or re-run with --force-gitea-golden to fetch the vouched one from Gitea." + fi + log_warn " ignoring the local golden $GOLDEN_VOLID — ${GOLDEN_CHECK_WHY}" + log_warn " fetching the vouched golden instead (this is what the manifest is for)" + GOLDEN_VOLID="" fi # Need the manifest + git creds (already resolved in step 5, but re-resolve on a fresh --resume run).