iso v1.26.0: the PUBLIC release image — no answer file, interactive install, day-0 by .deb

Design inputs: SPIKE-universal-iso-{1,2,3,4}-2026-07-31.md. Every choice below is a measurement.

NEW: scripts/iso/pkg/ — the felhom-bootstrap .deb, built from committed source.
  Two files only (script + unit), NOT three: felhom-bootstrap.sh:91 reads /etc/felhom/bootstrap.env
  only 'if [[ -r ]]', and its defaults at :95-96 are EXACTLY what the pairing env set
  (build-felhom-iso.sh:257-258) — so shipping it would add a 0600 file to a public package to express
  values the script already defaults to. NO dependencies: the binaries it calls run at FIRST BOOT,
  not at postinst time, so SPIKE 4's open 'dpkg --configure -a' ordering question does not arise.
  The postinst is structurally incapable of failing (no 'set -e', every statement guarded, ends
  'exit 0'); build-deb.sh self-asserts G8/G9 and REFUSES to emit a package that violates them.

iso-repack.sh — two changes, both narrowing rather than deleting:
  - R-155 guard: now applies to FELHOM_MENU=single ONLY. It protected the single-entry mode's promise
    (one button labelled 'install' must not drop into a disk-picker); a release image carries no
    auto-installer-mode.toml BY DESIGN (gate G1), so refusing it would be the guard firing on the
    shape it describes rather than the one it prevents.
  - the menu collapse now has a release mode: two INTERACTIVE entries, Graphical default, timeout 15.
    Entry-count and banned-token gates are per-mode; the six-token list is UNCHANGED for single mode.
  - .deb injection into /proxmox/packages/, with a skip-list collision check (a colliding name would
    be dropped silently — the inert-payload class) and a post-remaster assertion that it landed in
    final.iso, not merely in the extract tree.

build-felhom-iso.sh — --release: no profile, no root hash, no answer.toml, no prepare-iso at all.
  Skipping prepare-iso is what removes the Automated entry by construction, since the stock grub.cfg
  emits it only inside 'if [ -f auto-installer-mode.toml ]'.

R-128 RULING — FIXED, by correcting the claim rather than inventing an assertion for it. The comment
  said ISO_VERSION 'aligns with SCRIPT_VERSION'; nothing evaluated it and the two had drifted. The
  coupling does not exist: the ISO is frozen, felhom-host-install.sh is fetched at run time from main
  (R-94/R-110), so an assertion would invent a constraint. Comment corrected, ISO_VERSION -> 1.26.0.

Release gate G6 AMENDED before the build, with its reasoning recorded in the runbook: the six-token
  ban existed to keep users away from the manual installer, which the ruling makes the product.
  'proxtui' (the TUI installer we ship) and 'nomodeset' (its graphics fallback) are dropped for
  release images; proxdebug/Rescue Boot/memtest/fwsetup stay banned in both modes.
This commit is contained in:
2026-07-31 16:39:47 +02:00
parent e787391c0a
commit 01a8155c5a
7 changed files with 439 additions and 38 deletions
+84 -12
View File
@@ -41,7 +41,15 @@
#===============================================================================
set -euo pipefail
ISO_VERSION="1.25.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION).
# R-128 RULING (2026-07-31): the previous comment here claimed this "aligns with felhom-host-install
# SCRIPT_VERSION". That was a COMMENT, not an assertion — `SCRIPT_VERSION` appeared nowhere in
# scripts/iso/ except inside that sentence, so nothing ever evaluated it, and the two had drifted
# (1.25.0 vs 1.22.0). The fix is NOT to turn it into a real assertion, because the coupling it claimed
# does not exist: the ISO is a frozen artifact, while felhom-host-install.sh is fetched at RUN TIME
# from the website's git-sync of `main` (R-94/R-110), so whatever version an ISO carries, the script a
# box runs is always current. Coupling them would invent a constraint. The claim is corrected instead.
ISO_VERSION="1.26.0" # the ISO's own version. INDEPENDENT of felhom-host-install.sh's SCRIPT_VERSION,
# which is fetched at run time from main and is not frozen into the image.
IMAGE="${FELHOM_ISO_ASSISTANT_IMAGE:-felhom-iso-assistant:trixie}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -82,12 +90,15 @@ assert_golden_ge_floor() {
PVE_ISO=""; ISO_SHA256=""; PROFILE=""; BOOTSTRAP_ENV=""; OUT_DIR="${FELHOM_ISO_OUT:-/mnt/5_hdd/felhom.eu/felhom-iso/out}"; PVE_VERSION=""; DRY_RUN=false
LOADER_CLI="" # --loader override; empty = fall back to the profile, then the shim default.
PAIRING=false # --pairing: build the GENERIC secret-free ISO (slice C); no --bootstrap-env.
RELEASE=false # --release: the PUBLIC image. No answer.toml at all, an INTERACTIVE two-entry menu,
# and day-0 delivered by a .deb rather than the answer file's [first-boot] hook.
BRAND=true # R-38: Felhom boot screen + single-entry menu. --no-brand is a debugging escape only.
BRAND_IMAGE="" # --brand-image override; empty = the website's og-image_2.png (the single source).
usage() {
cat <<EOF
Usage (direct): build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --bootstrap-env FILE [options]
Usage (generic): build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --pairing [options]
Usage (release): build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --release [options]
Required:
--pve-iso PATH pre-downloaded official PVE ISO (not fetched here)
@@ -123,6 +134,7 @@ while [[ $# -gt 0 ]]; do
--profile) PROFILE="$2"; shift 2 ;;
--bootstrap-env) BOOTSTRAP_ENV="$2"; shift 2 ;;
--pairing) PAIRING=true; shift ;;
--release) RELEASE=true; shift ;;
--loader) LOADER_CLI="$2"; shift 2 ;;
--no-brand) BRAND=false; shift ;;
--brand-image) BRAND_IMAGE="$2"; shift 2 ;;
@@ -138,9 +150,18 @@ done
[[ -n "$PVE_ISO" ]] || die "--pve-iso is required"
[[ -n "$ISO_SHA256" ]] || die "--iso-sha256 is required"
[[ -n "$PROFILE" ]] || die "--profile is required"
[[ -f "$PVE_ISO" ]] || die "--pve-iso not found: $PVE_ISO"
[[ -f "$PROFILE" ]] || die "--profile not found: $PROFILE"
if $RELEASE; then
# A release image has no answer file, so it has no fqdn and no [disk-setup] to take from a profile.
# Accepting one would imply a disk selection the image deliberately does not make.
[[ -z "$PROFILE" ]] || die "--release takes no --profile (a release image bakes no disk selection)"
[[ -z "$BOOTSTRAP_ENV" ]] || die "--release and --bootstrap-env are mutually exclusive"
$PAIRING && die "--release and --pairing are mutually exclusive"
$BRAND || die "--release requires branding (the two-entry menu IS the release menu)"
else
[[ -n "$PROFILE" ]] || die "--profile is required"
[[ -f "$PROFILE" ]] || die "--profile not found: $PROFILE"
fi
# Mode: exactly one of --bootstrap-env (direct, secret-bearing) or --pairing (generic, secret-free).
if $PAIRING; then
[[ -z "$BOOTSTRAP_ENV" ]] || die "--pairing and --bootstrap-env are mutually exclusive"
@@ -165,6 +186,7 @@ fi
PROFILE_NAME="$(basename "$PROFILE")"; PROFILE_NAME="${PROFILE_NAME%.profile}"
# --- load + validate profile ----------------------------------------------------------------------
if ! $RELEASE; then
log_step "loading profile: $PROFILE"
FELHOM_FQDN=""; FELHOM_DISK_SETUP=""; FELHOM_ROOT_SSH_KEY=""; FELHOM_LOADER=""; FELHOM_HUB_URL=""; FELHOM_INSTALL_URL=""
# shellcheck disable=SC1090
@@ -174,6 +196,10 @@ source "$PROFILE"
# Optional emergency/validation SSH key baked into the installed root account.
ROOT_SSH_LINE=""
[[ -n "$FELHOM_ROOT_SSH_KEY" ]] && ROOT_SSH_LINE="root-ssh-keys = [\"${FELHOM_ROOT_SSH_KEY}\"]"
else
log_info "release mode — no profile, no fqdn, no [disk-setup], no root key"
FELHOM_FQDN="(none — interactive install)"
fi
# --- resolve the loader mode: --loader wins over the profile's FELHOM_LOADER over the shim default ---
LOADER="${LOADER_CLI:-${FELHOM_LOADER:-shim}}"
@@ -205,7 +231,14 @@ else
fi
# --- mode: DIRECT validates the secret-bearing env; PAIRING is secret-free (env generated below) -----
if $PAIRING; then
if $RELEASE; then
SECRET_BEARING="no"
echo -e "${YELLOW}==================================================================================${NC}"
log_info "RELEASE MODE — the PUBLIC image. No answer.toml, no auto-installer-mode.toml, no root"
log_info "password, no SSH key, no disk profile. Two INTERACTIVE menu entries; the person"
log_info "installing chooses the disk. Day-0 rides felhom-bootstrap.deb in /proxmox/packages/."
echo -e "${YELLOW}==================================================================================${NC}"
elif $PAIRING; then
SECRET_BEARING="no"
PAIR_HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
PAIR_INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
@@ -265,9 +298,20 @@ fi
# Secure-Boot-off prep. -----------------------------------------------------------------------
LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}"
MODE_SUFFIX=""; $PAIRING && MODE_SUFFIX="-generic" # the secret-free universal ISO is unmistakable
OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${MODE_SUFFIX}${LOADER_SUFFIX}.iso"
if $RELEASE; then
# Named so it cannot be mistaken for one of the lab artifacts in out/ (canary, probe, nested-vm,
# demo, hp, n100). "felhom-installer" + the version is what a tester quotes in a bug report.
OUT_ISO="$OUT_DIR/felhom-installer-${ISO_VERSION}-pve${PVE_VERSION}${LOADER_SUFFIX}.iso"
else
OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}${MODE_SUFFIX}${LOADER_SUFFIX}.iso"
fi
# --- mint fresh THROWAWAY root hash ---------------------------------------------------------------
if $RELEASE; then
log_info "release mode — NOT minting a root password (release gate G2: none is baked, and no"
log_info " .rootpw.txt is emitted). The installer prompts the person doing the install."
ROOT_HASH=""
else
log_step "minting fresh throwaway root password hash"
ROOT_PLAIN="felhom-throwaway-$(head -c12 /dev/urandom | base64 | tr -dc 'A-Za-z0-9')"
ROOT_HASH="$(openssl passwd -6 "$ROOT_PLAIN")"
@@ -281,8 +325,10 @@ ROOT_HASH="$(openssl passwd -6 "$ROOT_PLAIN")"
unset ROOT_PLAIN
[[ -n "$ROOT_HASH" ]] || die "failed to mint root hash"
log_info "throwaway root hash written to the answer file; plaintext in $(basename "$OUT_ISO").rootpw.txt (0600, operator-only — value not logged)"
fi
# --- render answer.toml (pure bash param-expansion; no metachar hazards) ---------------------------
if ! $RELEASE; then
log_step "rendering answer.toml"
ANSWER="$WORK/answer.toml"
: > "$ANSWER"
@@ -329,6 +375,17 @@ awk -v sh="$sh_b64" -v unit="$unit_b64" -v env="$env_b64" '
' "$HERE/stub-first-boot.sh" > "$STUB"
chmod 0755 "$STUB"
grep -q '@@BOOTSTRAP_.*_B64@@' "$STUB" && die "stub still has unfilled markers — injection failed"
fi # end: non-release answer/stub rendering
# --- RELEASE: build the felhom-bootstrap .deb from COMMITTED source (release gate G7/G9/G10) --------
RELEASE_DEB=""
if $RELEASE; then
log_step "building the felhom-bootstrap package"
[[ -x "$HERE/pkg/build-deb.sh" ]] || die "pkg/build-deb.sh not found next to build-felhom-iso.sh"
RELEASE_DEB="$("$HERE/pkg/build-deb.sh" "$ISO_VERSION" "$WORK/pkg")" \
|| die "package build FAILED (its own G8/G9 self-assertions gate it)"
log_success "package: $(basename "$RELEASE_DEB") ($(stat -c%s "$RELEASE_DEB") bytes)"
fi
# --- prepare-iso ----------------------------------------------------------------------------------
# (OUT_ISO is resolved above the mint — the rootpw sibling file is named after it.)
@@ -342,11 +399,19 @@ if $DRY_RUN; then
log_info "DRY-RUN: no ISO produced"
exit 0
fi
docker run --rm -v "$ISO_DIR":/iso:ro -v "$WORK":/work "$IMAGE" \
proxmox-auto-install-assistant prepare-iso "/iso/$ISO_BASE" \
--fetch-from iso --answer-file /work/answer.toml \
--on-first-boot /work/stub-first-boot.sh \
--tmp /work/tmp --output /work/out.iso
if $RELEASE; then
# NO prepare-iso. That step is what creates /answer.toml and /auto-installer-mode.toml, and their
# absence is release-gate G1 — it also removes the Automated menu entry by construction, since the
# stock grub.cfg emits it only inside `if [ -f auto-installer-mode.toml ]`.
log_info "release mode — skipping prepare-iso entirely (no answer file is created)"
cp "$PVE_ISO" "$WORK/out.iso"
else
docker run --rm -v "$ISO_DIR":/iso:ro -v "$WORK":/work "$IMAGE" \
proxmox-auto-install-assistant prepare-iso "/iso/$ISO_BASE" \
--fetch-from iso --answer-file /work/answer.toml \
--on-first-boot /work/stub-first-boot.sh \
--tmp /work/tmp --output /work/out.iso
fi
[[ -f "$WORK/out.iso" ]] || die "prepare-iso produced no output"
# --- REPACK: R-38 GRUB branding + single-entry menu, and (slice B) the mkimage loader surgery. Both
@@ -389,6 +454,7 @@ BRAND_NOTE="FELHOM (single entry 'Felhom telepítés', default, 5s; stock instal
$BRAND || BRAND_NOTE="STOCK PVE menu (--no-brand; DEBUG BUILD — manual installer reachable, do not ship)"
MODE_NOTE="direct (env-baked customer-id + retrieval passphrase; secret-bearing)"
$PAIRING && MODE_NOTE="pairing (GENERIC secret-free universal ISO — box self-registers, operator binds, hub delivers once)"
$RELEASE && MODE_NOTE="release (PUBLIC image — NO answer.toml, NO baked credential, interactive disk selection)"
cat > "$OUT_ISO.manifest.txt" <<EOF
Felhom bare-metal ISO build manifest (R-21 slice A+B+C)
built : $(date -Is)
@@ -406,7 +472,10 @@ boot-menu : ${BRAND_NOTE}
$( $BRAND && cat "$WORK/brand-report.txt" 2>/dev/null || true )
host-install-url : $(grep -oE 'FELHOM_INSTALL_URL=[^ ]*' "$BOOTSTRAP_ENV" 2>/dev/null || echo 'https://felhom.eu/scripts/felhom-host-install.sh (default)')
secret-bearing : ${SECRET_BEARING}$( $PAIRING && echo ' (GENERIC ISO — carries NO customer secret)' || echo ' (embeds the customer retrieval passphrase — supervised/single-use, delete after the run)')
root-password : see $(basename "$OUT_ISO").rootpw.txt (operator-only; NEVER commit or paste into REPORTs)
root-password : $( $RELEASE && echo 'NONE — not baked. The installer prompts the person installing (release gate G2).' || echo "see $(basename "$OUT_ISO").rootpw.txt (operator-only; NEVER commit or paste into REPORTs)" )
answer-file : $( $RELEASE && echo 'NONE — no answer.toml, no auto-installer-mode.toml (release gate G1)' || echo 'baked (/answer.toml)' )
felhom-package : $( [[ -n "$RELEASE_DEB" ]] && echo "$(basename "$RELEASE_DEB") sha256=$(sha256sum "$RELEASE_DEB" | cut -d\ -f1)" || echo 'n/a (day-0 rides the answer file first-boot hook)' )
repo-commit : $(cd "$HERE" && git rev-parse HEAD 2>/dev/null || echo unknown)
output : $(basename "$OUT_ISO")
output-sha256 : ${OUT_SHA}
output-size-bytes : ${OUT_SIZE}
@@ -417,7 +486,10 @@ log_info "sha256 : $OUT_SHA"
log_info "size : $OUT_SIZE bytes"
log_info "manifest : $OUT_ISO.manifest.txt"
log_info "root-pw : $OUT_ISO.rootpw.txt (0600, operator-only — the console credential for this build; never commit/paste)"
if $PAIRING; then
if $RELEASE; then
log_success "PUBLIC RELEASE image — no answer file, no baked credential, interactive disk selection."
log_info "Run the release gate before publishing: documentation/runbooks/iso-release-gate.md"
elif $PAIRING; then
log_success "GENERIC secret-free ISO — carries NO customer secret. Bind the box on the hub after it registers."
else
log_warn "SECRET-BEARING ISO (embeds the retrieval passphrase). Supervised/single-use; never distribute; delete after the run."