diff --git a/documentation/runbooks/iso-release-gate.md b/documentation/runbooks/iso-release-gate.md index 44d1f05..a081320 100644 --- a/documentation/runbooks/iso-release-gate.md +++ b/documentation/runbooks/iso-release-gate.md @@ -100,13 +100,32 @@ install paths on unfamiliar hardware needs to read them first. *Why the spelling writes `timeout-style`, which GRUB does not recognise as a variable name at all (Spike 2 §5.2) — Felhom's underscore form is the one that works. -**Also assert the safety property still holds** — no live route to a manual disk-picker via a debug or -rescue entry: +**Also assert the safety property still holds:** ```bash -grep -v '^[[:space:]]*#' /tmp/g.cfg | grep -cE 'proxtui|proxdebug|nomodeset|Rescue Boot|memtest|fwsetup' +grep -v '^[[:space:]]*#' /tmp/g.cfg | grep -cE 'proxdebug|Rescue Boot|memtest|fwsetup' ``` -**PASS = `0`.** This is the gate `iso-repack.sh:160-164` already enforces and it is kept unchanged. +**PASS = `0`.** + +> **Amendment, 2026-07-31, before the first build — recorded rather than made quietly.** +> This criterion was first written as the six-token list `iso-repack.sh:160-164` enforces +> (`proxtui|proxdebug|nomodeset|Rescue Boot|memtest|fwsetup`), on the stated rationale *"no live route +> to a manual disk-picker"*. **That rationale is obsolete for a public image and was already obsolete +> when it was written.** The operator's ruling makes the manual installer *the product*: the person +> installing chooses their own disk in the stock installer, which shows the target and a `Bootdisk(s)` +> summary before erasing. A token list whose purpose is to keep users away from the manual installer +> cannot be applied to an image whose purpose is to offer it. +> +> Two tokens are therefore dropped **for the release image only**: +> - **`proxtui`** — it is the Terminal UI *installer*, one of the two entries the image deliberately +> ships; and +> - **`nomodeset`** — a graphics fallback for the same installer, needed on hardware whose GPU the +> default mode cannot drive. +> +> **Four are kept, and they are the ones that were ever the real hazard:** `proxdebug` (drops to a +> shell), `Rescue Boot` (boots an existing system rather than installing), `memtest` and `fwsetup` +> (not installers at all). The original six-token gate remains **unchanged** for the single-entry +> appliance mode, where its original rationale still holds exactly. ### G7 — The Felhom package is present, at a recorded version diff --git a/scripts/iso/build-felhom-iso.sh b/scripts/iso/build-felhom-iso.sh index 3377358..7011e2a 100755 --- a/scripts/iso/build-felhom-iso.sh +++ b/scripts/iso/build-felhom-iso.sh @@ -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 < "$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" </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." diff --git a/scripts/iso/grub/grub-release.cfg.tmpl b/scripts/iso/grub/grub-release.cfg.tmpl new file mode 100644 index 0000000..4b58cd0 --- /dev/null +++ b/scripts/iso/grub/grub-release.cfg.tmpl @@ -0,0 +1,80 @@ +# Felhom PUBLIC RELEASE ISO — GRUB menu. GENERATED by iso-repack.sh (FELHOM_MENU=release). +# +# TWO entries, both INTERACTIVE, and that is the whole design. +# +# WHY NOT THE AUTOMATED ENTRY: SPIKE-universal-iso-1 measured that no udev property distinguishes an +# internal system disk from a customer's external backup drive (ID_BUS is `ata` for BOTH on a SATA +# drive in a USB caddy; no removability property is exposed at all; NVMe carries a different property +# set), and that a filter matching two disks does NOT fail safe — the installer silently picks one and +# wipes it, while validate-answer accepts the answer. There is therefore no safe automated disk +# selection for hardware nobody has seen. The stock installer already shows the target disk and a +# `Bootdisk(s)` summary before erasing, so the human doing the choosing is the safety mechanism. +# +# WHY THE GRAPHICAL ENTRY IS DEFAULT: a tester who boots the stick and walks away must not land on a +# destructive unattended path. The default is the one that waits for a person. +# +# WHY timeout=15: SPIKE-universal-iso-2 lost an entire probe to the installed system's 1-SECOND menu. +# A human reading two options on unfamiliar hardware needs longer than a machine does. Note +# `timeout_style` (underscore) — the stock PVE config writes `timeout-style`, which GRUB does not +# recognise as a variable name at all, so the stock menu's own timeout directive is inert. +# +# The `linux` / `initrd` lines are lifted VERBATIM by iso-repack.sh from the stock ISO's own Graphical +# and Terminal-UI entries, so a PVE version bump that changes the kernel path tracks automatically +# instead of silently diverging from a copy frozen here. + +insmod gzio +insmod iso9660 +insmod png +insmod gfxmenu + +if [ x$feature_default_font_path = xy ] ; then + font=unicode +else + font=$prefix/unicode.pf2 +fi + +set gfxmode=1024x768,800x600,640x480 +set gfxpayload=1024x768 + +if loadfont $font; then + if test "${grub_platform}" = "efi"; then + insmod efi_gop + insmod efi_uga + fi + insmod video_bochs + insmod video_cirrus + insmod all_video + insmod gfxterm + set theme=/boot/grub/felhomtheme/theme.txt + export theme + terminal_input console + terminal_output gfxterm +fi + +insmod serial +insmod usbserial_common +insmod usbserial_ftdi +insmod usbserial_pl2303 +insmod usbserial_usbdebug +if serial --unit=0 --speed=115200; then + terminal_input --append serial + terminal_output --append serial +fi + +set timeout_style=menu +set timeout=15 +set default=0 + +menuentry 'Felhom telepítés' --class felhom --class os { + echo 'A Felhom telepítése indul — válassza ki a lemezt a telepítőben...' +@@LINUX_GFX@@ + echo 'Rendszerbetöltő betöltése...' +@@INITRD@@ +} + +menuentry 'Felhom telepítés (szöveges mód)' --class felhom --class os { + echo 'A Felhom telepítése indul szöveges módban...' +@@LINUX_TUI@@ + echo 'Rendszerbetöltő betöltése...' +@@INITRD@@ +} diff --git a/scripts/iso/iso-repack.sh b/scripts/iso/iso-repack.sh index 87d056a..198943c 100755 --- a/scripts/iso/iso-repack.sh +++ b/scripts/iso/iso-repack.sh @@ -15,6 +15,12 @@ # Env: # FELHOM_LOADER = shim | mkimage (default shim) — mkimage swaps BOOTX64.EFI (F1 firmware fix) # FELHOM_BRAND = 1 | 0 (default 1) — 0 leaves the stock PVE menu completely alone +# FELHOM_MENU = single | release (default single) — single: ONE automated entry (appliance ISO, +# requires a prepared auto-install ISO). release: TWO INTERACTIVE +# entries for the PUBLIC image, which carries no answer.toml. +# FELHOM_DEB = (optional) — a .deb to inject into /proxmox/packages/, which +# the PVE installer unpacks into the target on EVERY install path +# including the interactive one (Install.pm:1343-1372, :1378). # # Expects in /work (placed by build-felhom-iso.sh) when FELHOM_BRAND=1: # brand/grub.cfg.tmpl, brand/felhom-theme.txt, brand/generate-grub-background.sh, brand/card.png @@ -39,6 +45,10 @@ ORIG_CFG=/work/grub.cfg.orig LOADER="${FELHOM_LOADER:-shim}" BRAND="${FELHOM_BRAND:-1}" +MENU="${FELHOM_MENU:-single}" +DEB="${FELHOM_DEB:-}" +[[ "$MENU" == "single" || "$MENU" == "release" ]] \ + || { echo "iso-repack: FELHOM_MENU must be single|release (got '$MENU')" >&2; exit 2; } say() { echo "iso-repack: $*"; } @@ -95,15 +105,27 @@ cp "$GCFG" "$ORIG_CFG" if [[ "$BRAND" == "1" ]]; then say "branding GRUB (background + single-entry menu)" - # 2a. The prepared ISO MUST be an auto-install ISO. Our one menu entry boots the automated - # installer, which reads this file; without it the entry would drop into the manual installer - # — the exact outcome the single-entry menu exists to prevent. Fail loudly, never silently. - AIM="$(find "$EX" -maxdepth 2 -iname 'auto-installer-mode.toml' | head -1)" - [[ -n "$AIM" ]] || { - echo "iso-repack: auto-installer-mode.toml not found in the ISO — this is not a prepared" >&2 - echo " auto-install ISO, so the single Felhom entry would boot the MANUAL installer. Refusing." >&2 - exit 10 - } + # 2a. R-155 — the guard, NARROWED rather than removed. + # + # What it protected: in SINGLE-entry mode the menu shows exactly one item, "Felhom telepítés", + # and that item boots the AUTOMATED installer, which reads auto-installer-mode.toml. Without + # that file the very same label would drop the user into the manual disk-picker — a button + # promising an unattended install that silently does the opposite. That promise is real and + # the guard still enforces it, unchanged, for FELHOM_MENU=single. + # + # Why it must not apply to FELHOM_MENU=release: the public image deliberately offers the + # interactive installer (see grub-release.cfg.tmpl for the measurements behind that ruling), + # so it carries NO answer.toml and NO auto-installer-mode.toml by design — that absence is a + # release-gate criterion (G1), not a defect. Refusing it would be the guard firing on the + # shape it was written to describe rather than the shape it was written to prevent. + if [[ "$MENU" == "single" ]]; then + AIM="$(find "$EX" -maxdepth 2 -iname 'auto-installer-mode.toml' | head -1)" + [[ -n "$AIM" ]] || { + echo "iso-repack: auto-installer-mode.toml not found in the ISO — this is not a prepared" >&2 + echo " auto-install ISO, so the single Felhom entry would boot the MANUAL installer. Refusing." >&2 + exit 10 + } + fi # 2b. Lift the kernel + initrd lines VERBATIM from the stock 'Install Proxmox VE (Automated)' # entry, so a PVE bump that changes the kernel path or append line tracks automatically. @@ -118,14 +140,36 @@ if [[ "$BRAND" == "1" ]]; then inblk && /^[[:space:]]*}/ { inblk=0 } ' "$ORIG_CFG")" - [[ -n "$LINUX_LINE" ]] || { echo "iso-repack: could not lift the 'linux' line from the stock automated entry" >&2; exit 11; } + # release mode lifts the GRAPHICAL and TERMINAL-UI kernel lines instead of the automated one. + LINUX_GFX="$(awk ' + /menuentry .Install Proxmox VE \(Graphical\)./ { inblk=1; next } + inblk && /^[[:space:]]*linux[[:space:]]/ { print; exit } + inblk && /^[[:space:]]*}/ { inblk=0 } + ' "$ORIG_CFG")" + LINUX_TUI="$(awk ' + /menuentry .Install Proxmox VE \(Terminal UI\)./ { inblk=1; next } + inblk && /^[[:space:]]*linux[[:space:]]/ { print; exit } + inblk && /^[[:space:]]*}/ { inblk=0 } + ' "$ORIG_CFG")" + if [[ "$MENU" == "release" ]]; then + [[ -n "$LINUX_GFX" ]] || { echo "iso-repack: could not lift the Graphical 'linux' line" >&2; exit 11; } + [[ -n "$LINUX_TUI" ]] || { echo "iso-repack: could not lift the Terminal-UI 'linux' line" >&2; exit 11; } + # The release menu must NOT carry the unattended flag — that is the whole point of the ruling. + grep -q 'proxmox-start-auto-installer' <<<"$LINUX_GFX$LINUX_TUI" && { + echo "iso-repack: a release menu kernel line carries proxmox-start-auto-installer — refusing" >&2 + exit 12 + } + fi + [[ -n "$LINUX_LINE" || "$MENU" == "release" ]] || { echo "iso-repack: could not lift the 'linux' line from the stock automated entry" >&2; exit 11; } [[ -n "$INITRD_LINE" ]] || { echo "iso-repack: could not lift the 'initrd' line from the stock automated entry" >&2; exit 11; } # The append flag that MAKES it unattended. If PVE ever renames it, we must not ship an ISO that # boots a manual installer behind a button labelled "Felhom telepítés". - grep -q 'proxmox-start-auto-installer' <<<"$LINUX_LINE" || { - echo "iso-repack: the lifted kernel line has no 'proxmox-start-auto-installer' flag:" >&2 - echo " $LINUX_LINE" >&2; exit 12 - } + if [[ "$MENU" == "single" ]]; then + grep -q 'proxmox-start-auto-installer' <<<"$LINUX_LINE" || { + echo "iso-repack: the lifted kernel line has no 'proxmox-start-auto-installer' flag:" >&2 + echo " $LINUX_LINE" >&2; exit 12 + } + fi grep -q '/boot/initrd.img' <<<"$INITRD_LINE" || { echo "iso-repack: the lifted initrd line looks wrong: $INITRD_LINE" >&2; exit 12 } @@ -143,21 +187,39 @@ if [[ "$BRAND" == "1" ]]; then # 2d. Render the new grub.cfg. Use awk (not sed) so the lifted lines are inserted literally — # the append line is full of `/` and `=` that sed would need escaped. - awk -v lx="$LINUX_NORM" -v ird="$INITRD_NORM" ' - { gsub(/@@LINUX@@/, lx); gsub(/@@INITRD@@/, ird); print } - ' "$BRANDDIR/grub.cfg.tmpl" > "$GCFG" - grep -q '@@LINUX@@\|@@INITRD@@' "$GCFG" && { echo "iso-repack: grub.cfg still has unfilled markers" >&2; exit 13; } + if [[ "$MENU" == "release" ]]; then + GFX_NORM=" $(sed -E 's/^[[:space:]]+//' <<<"$LINUX_GFX")" + TUI_NORM=" $(sed -E 's/^[[:space:]]+//' <<<"$LINUX_TUI")" + awk -v gfx="$GFX_NORM" -v tui="$TUI_NORM" -v ird="$INITRD_NORM" ' + { gsub(/@@LINUX_GFX@@/, gfx); gsub(/@@LINUX_TUI@@/, tui); gsub(/@@INITRD@@/, ird); print } + ' "$BRANDDIR/grub-release.cfg.tmpl" > "$GCFG" + else + awk -v lx="$LINUX_NORM" -v ird="$INITRD_NORM" ' + { gsub(/@@LINUX@@/, lx); gsub(/@@INITRD@@/, ird); print } + ' "$BRANDDIR/grub.cfg.tmpl" > "$GCFG" + fi + grep -q '@@LINUX@@\|@@LINUX_GFX@@\|@@LINUX_TUI@@\|@@INITRD@@' "$GCFG" && { echo "iso-repack: grub.cfg still has unfilled markers" >&2; exit 13; } # 2e. GATES — the safety half is the whole point, so assert it on the rendered file rather than # trusting the template. Exactly one entry, zero submenus, no path back to a manual installer. N_ENTRY="$(grep -c '^[[:space:]]*menuentry ' "$GCFG" || true)" N_SUB="$(grep -c '^[[:space:]]*submenu ' "$GCFG" || true)" - [[ "$N_ENTRY" == "1" ]] || { echo "iso-repack: rendered grub.cfg has $N_ENTRY menuentries, want exactly 1" >&2; exit 14; } + WANT_ENTRIES=1; [[ "$MENU" == "release" ]] && WANT_ENTRIES=2 + [[ "$N_ENTRY" == "$WANT_ENTRIES" ]] || { echo "iso-repack: rendered grub.cfg has $N_ENTRY menuentries, want exactly $WANT_ENTRIES ($MENU mode)" >&2; exit 14; } [[ "$N_SUB" == "0" ]] || { echo "iso-repack: rendered grub.cfg has $N_SUB submenus, want 0" >&2; exit 14; } # Strip comments first: the template's header EXPLAINS which stock entries were dropped, and # naming them there must not trip the gate. What matters is that no live directive uses them. LIVE="$(grep -v '^[[:space:]]*#' "$GCFG")" - for banned in proxtui proxdebug nomodeset 'Rescue Boot' memtest fwsetup; do + # The banned set differs by mode, and the difference is the whole ruling (release-gate G6 amendment): + # single — six tokens. The one entry promises an unattended install, so ANY route to the manual + # installer breaks that promise. Unchanged. + # release — four tokens. The manual installer IS the product here, so `proxtui` (the Terminal-UI + # installer, one of the two entries we ship) and `nomodeset` (a graphics fallback for + # the same installer) are legitimate. What stays banned is what never installs anything: + # a debug shell, a rescue boot of an existing system, memtest and firmware settings. + BANNED=(proxtui proxdebug nomodeset 'Rescue Boot' memtest fwsetup) + [[ "$MENU" == "release" ]] && BANNED=(proxdebug 'Rescue Boot' memtest fwsetup) + for banned in "${BANNED[@]}"; do if grep -q "$banned" <<<"$LIVE"; then echo "iso-repack: rendered grub.cfg still has a live reference to '$banned'" >&2; exit 14 fi @@ -267,6 +329,30 @@ CFG say "swapped bootx64.efi in the ISO tree ($TREE_HITS) and inside $(basename "$EFIIMG")" fi +# --- 5b. INJECT the Felhom package into /proxmox/packages/ (release images). +# +# Install.pm:1343-1372 unpacks EVERY .deb in that directory into the target, with a fixed +# skip-list of known package-name patterns, then :1378 runs `dpkg --configure -a` which executes +# the postinsts. This happens on EVERY install path — measured on the interactive one in +# SPIKE-universal-iso-4 (package installed, postinst run, unit enabled, unit fired at 7.98 s +# uptime, with proxmox-first-boot absent on the same machine). It is the ONLY delivery mechanism +# that survives an interactive install; the answer file's [first-boot] hook does not. +if [[ -n "$DEB" ]]; then + [[ -f "$DEB" ]] || { echo "iso-repack: FELHOM_DEB not found: $DEB" >&2; exit 16; } + PKGDIR="$EX/proxmox/packages" + [[ -d "$PKGDIR" ]] || { echo "iso-repack: $PKGDIR missing — not a PVE ISO?" >&2; exit 16; } + cp "$DEB" "$PKGDIR/$(basename "$DEB")" + say "injected $(basename "$DEB") into /proxmox/packages/ ($(ls "$PKGDIR"/*.deb | wc -l) debs total)" + # The skip-list at Install.pm:1352-1362 matches known package-name prefixes. A Felhom package must + # not collide with one, or it would be silently skipped on some hardware and the whole delivery + # would fail invisibly — exactly the inert-payload class this project keeps hitting. + case "$(basename "$DEB")" in + grub-pc_*|grub-efi-*|proxmox-grub*|proxmox-secure-boot-support_*|proxmox-first-boot*|amd64-microcode_*|intel-microcode_*) + echo "iso-repack: package name collides with the installer's skip-list — it would be dropped" >&2 + exit 16 ;; + esac +fi + # --- 6. re-master from the (modified) tree, reproducing the source ISO's boot geometry from its OWN # as_mkisofs report so we track PVE minor versions. We drop ONLY the Apple APM/HFS+ boot map # (-hfsplus / -apm-block-size): re-emitting it trips xorriso 1.5.6's "Overlapping MBR partition @@ -314,10 +400,18 @@ if [[ "$BRAND" == "1" ]]; then xorriso -osirrox on -indev "$FINAL" -extract /boot/grub/grub.cfg "$VER/grub.cfg" >/dev/null 2>&1 xorriso -osirrox on -indev "$FINAL" -extract /boot/grub/felhomtheme "$VER/felhomtheme" >/dev/null 2>&1 N="$(grep -c '^[[:space:]]*menuentry ' "$VER/grub.cfg" 2>/dev/null || echo 0)" - [[ "$N" == "1" ]] || { echo "iso-repack: final.iso menu has $N entries, want 1" >&2; exit 15; } + [[ "$N" == "$WANT_ENTRIES" ]] || { echo "iso-repack: final.iso menu has $N entries, want $WANT_ENTRIES" >&2; exit 15; } [[ -s "$VER/felhomtheme/background.png" ]] || { echo "iso-repack: final.iso carries no theme background" >&2; exit 15; } grep -q "Felhom telepítés" "$VER/grub.cfg" || { echo "iso-repack: final.iso menu entry is not the Felhom one" >&2; exit 15; } rm -rf "$VER" - say "verified in final.iso: 1 entry ('Felhom telepítés') + theme background present" + say "verified in final.iso: $N entr(y/ies) + theme background present" +fi +# --- 8b. and prove the PACKAGE landed in final.iso too (seam-wiring: assert on what ships, not on the +# tree we built it from). -------------------------------------------------------------------- +if [[ -n "$DEB" ]]; then + B="$(basename "$DEB")" + xorriso -indev "$FINAL" -find /proxmox/packages -maxdepth 1 2>/dev/null | grep -q "$B" \ + || { echo "iso-repack: $B is NOT in final.iso/proxmox/packages — delivery would be inert" >&2; exit 17; } + say "verified in final.iso: $B present in /proxmox/packages/" fi say "done" diff --git a/scripts/iso/pkg/build-deb.sh b/scripts/iso/pkg/build-deb.sh new file mode 100755 index 0000000..be16087 --- /dev/null +++ b/scripts/iso/pkg/build-deb.sh @@ -0,0 +1,76 @@ +#!/bin/bash +#=============================================================================== +# build-deb.sh — build the felhom-bootstrap .deb that the PUBLIC ISO carries. +# +# WHY A PACKAGE AND NOT THE ANSWER FILE'S [first-boot] HOOK: +# SPIKE-universal-iso-3 measured, with a same-image control, that an INTERACTIVE install never places +# the first-boot hook on the system at all — the proxmox-first-boot PACKAGE is not even installed +# (Config.pm:118 defaults first_boot.enabled=0, Install.pm:746 returns early, :1360 skips the package, +# and proxinstall contains zero occurrences of "first-boot"). SPIKE-universal-iso-4 then measured that +# a .deb in the ISO's /proxmox/packages/ IS delivered on that same interactive path — installed, +# postinst run, unit enabled, unit fired at 7.98 s uptime — because Install.pm:1343-1372 unpacks every +# .deb on the medium and :1378 configures them. +# +# CONTENTS — exactly two files, and deliberately not three: +# /usr/local/sbin/felhom-bootstrap.sh 0755 (byte-identical to scripts/iso/felhom-bootstrap.sh) +# /lib/systemd/system/felhom-bootstrap.service 0644 +# The old stub also wrote /etc/felhom/bootstrap.env (0600). This package does NOT, because +# felhom-bootstrap.sh:91 reads it only `if [[ -r ... ]]` and its defaults at :95-96 are EXACTLY what +# the generic pairing env set (build-felhom-iso.sh:257-258). Shipping it would add a 0600 file to a +# public package to express values the script already defaults to. +# +# DEPENDENCIES: none, and that is a finding rather than an omission. The payload is a shell script and +# a unit file. The binaries the script calls (curl, ip, dhclient, python3, systemctl) run at FIRST +# BOOT, not at postinst time, and are all in a PVE base install — so there is nothing for +# `dpkg --configure -a` to order against, and SPIKE 4's open ordering question does not arise. +#=============================================================================== +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ISO_DIR="$(cd "$HERE/.." && pwd)" +VERSION="${1:?usage: build-deb.sh }" +OUTDIR="${2:?usage: build-deb.sh }" + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-deb.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +ROOT="$WORK/pkg" +install -d -m 0755 "$ROOT/DEBIAN" "$ROOT/usr/local/sbin" "$ROOT/lib/systemd/system" + +sed "s/^Package: /Version: $VERSION\nPackage: /" /dev/null >/dev/null 2>&1 || true +{ head -1 "$HERE/debian/control"; echo "Version: $VERSION"; tail -n +2 "$HERE/debian/control"; } \ + > "$ROOT/DEBIAN/control" +install -m 0755 "$HERE/debian/postinst" "$ROOT/DEBIAN/postinst" + +# The two payload files, copied VERBATIM from the same sources the old stub embedded, so the ISO's +# frozen script is provably the repo's (release gate G9). +install -m 0755 "$ISO_DIR/felhom-bootstrap.sh" "$ROOT/usr/local/sbin/felhom-bootstrap.sh" +install -m 0644 "$ISO_DIR/felhom-bootstrap.service" "$ROOT/lib/systemd/system/felhom-bootstrap.service" + +mkdir -p "$OUTDIR" +DEB="$OUTDIR/felhom-bootstrap_${VERSION}_all.deb" +dpkg-deb --build --root-owner-group "$ROOT" "$DEB" >/dev/null + +# Self-assertions: the package must satisfy the release gate's G8/G9 before it ever reaches an ISO. +# Strip comments first — the postinst's header NAMES the forbidden verbs in order to explain why they +# are banned, and naming them there must not trip the gate. Same reasoning, and same fix, as +# iso-repack.sh:157-159 applies to the GRUB banned-token gate. +POST_RAW="$(dpkg-deb --ctrl-tarfile "$DEB" | tar -xO ./postinst)" +POST_LIVE="$(grep -v '^[[:space:]]*#' <<<"$POST_RAW")" +if grep -qE 'systemctl (start|daemon-reload|restart)' <<<"$POST_LIVE"; then + echo "build-deb: postinst has a LIVE forbidden systemctl verb (G8)" >&2; exit 3 +fi +if grep -qE '^[[:space:]]*set -e' <<<"$POST_LIVE"; then + echo "build-deb: postinst uses 'set -e' (G8)" >&2; exit 3 +fi +if grep -qE '\b(curl|wget|apt-get|nc|ping)\b' <<<"$POST_LIVE"; then + echo "build-deb: postinst uses the network (G8)" >&2; exit 3 +fi +if [[ "$(tail -1 <<<"$POST_RAW")" != "exit 0" ]]; then + echo "build-deb: postinst does not end 'exit 0' (G8)" >&2; exit 3 +fi +A="$(dpkg-deb --fsys-tarfile "$DEB" | tar -xO ./usr/local/sbin/felhom-bootstrap.sh | sha256sum | cut -d' ' -f1)" +B="$(sha256sum "$ISO_DIR/felhom-bootstrap.sh" | cut -d' ' -f1)" +if [[ "$A" != "$B" ]]; then + echo "build-deb: packaged felhom-bootstrap.sh != repo HEAD (G9)" >&2; exit 3 +fi + +echo "$DEB" diff --git a/scripts/iso/pkg/debian/control b/scripts/iso/pkg/debian/control new file mode 100644 index 0000000..92f7a07 --- /dev/null +++ b/scripts/iso/pkg/debian/control @@ -0,0 +1,15 @@ +Package: felhom-bootstrap +Section: admin +Priority: optional +Architecture: all +Maintainer: Felhom +Description: Felhom host bootstrap — day-0 pairing on first boot + Installs the Felhom first-boot bootstrap and its systemd unit. On the first boot + of a freshly installed Proxmox VE host the unit registers the box at the Felhom + hub as an unclaimed appliance and waits to be bound; the operator binds it and + the hub delivers the credentials once. + . + This package carries NO customer identity and NO credential of any kind. It is + the delivery mechanism for a PUBLIC installation image: the PVE installer unpacks + every .deb on the medium into the target on every install path, including the + interactive one, which the [first-boot] answer-file hook does not reach. diff --git a/scripts/iso/pkg/debian/postinst b/scripts/iso/pkg/debian/postinst new file mode 100755 index 0000000..44474b0 --- /dev/null +++ b/scripts/iso/pkg/debian/postinst @@ -0,0 +1,45 @@ +#!/bin/sh +# felhom-bootstrap postinst. +# +# THIS SCRIPT MUST NOT BE ABLE TO FAIL. It runs under `dpkg --configure -a` INSIDE THE PVE INSTALLER +# CHROOT, in the middle of a customer's installation. A non-zero exit surfaces to that customer as an +# install error — far worse than the bootstrap simply not running. The stub not running costs one +# documented command; a broken install costs the machine. +# +# Four constraints, every one MEASURED in SPIKE-universal-iso-4-2026-07-31.md §3: +# 1. NO `set -e` — it converts any unexpected non-zero into the failure above. +# 2. NO systemctl start / daemon-reload — pid1 in the chroot is `unconfigured.sh` and NO systemd is +# running; those verbs are meaningless there. `enable` is the only one +# that works, and it was measured to work (it wrote the symlink). +# 3. NO network use — the network was up in the probe ONLY because the installer's DHCP +# happened to hold. A box installed with the cable out has none. +# 4. Real work at first boot — the unit does it, where systemd, network and a booted kernel exist. +# +# Every statement below is therefore individually guarded and the script ends `exit 0` unconditionally. + +UNIT=felhom-bootstrap.service +LOG=/var/log/felhom-bootstrap-postinst.log + +# A positive observable that this ran. SPIKE 2 R-150: a hook that never ran is indistinguishable from +# one that succeeded if you only look for errors. +{ + echo "felhom-bootstrap postinst: arg1=${1:-} date=$(date -u -Is 2>/dev/null || echo unknown)" +} >> "$LOG" 2>&1 || true +chmod 0644 "$LOG" 2>/dev/null || true + +if [ "${1:-}" = "configure" ]; then + # `enable` only. Guarded, and its outcome recorded either way. + if systemctl enable "$UNIT" >> "$LOG" 2>&1; then + echo "felhom-bootstrap postinst: enabled $UNIT via systemctl" >> "$LOG" 2>&1 || true + else + # Fallback: write the wants-symlink by hand. Also guarded — if this fails too, the unit is + # simply not enabled and the operator runs the documented one-liner. The install still succeeds. + mkdir -p /etc/systemd/system/multi-user.target.wants 2>/dev/null || true + ln -sf "/lib/systemd/system/$UNIT" \ + "/etc/systemd/system/multi-user.target.wants/$UNIT" 2>/dev/null || true + echo "felhom-bootstrap postinst: systemctl enable failed; wrote wants-symlink by hand" \ + >> "$LOG" 2>&1 || true + fi +fi + +exit 0