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.
This commit is contained in:
2026-08-10 13:57:50 +02:00
parent 11a5c3bd92
commit eb600872f2
3 changed files with 120 additions and 4 deletions
+39
View File
@@ -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
+77 -4
View File
@@ -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:-<unknown>}. 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).