01a8155c5a
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.
497 lines
31 KiB
Bash
Executable File
497 lines
31 KiB
Bash
Executable File
#!/bin/bash
|
|
#===============================================================================
|
|
# build-felhom-iso.sh — R-21 slice A+B+C: turn the official PVE ISO into a Felhom auto-install ISO.
|
|
#
|
|
# SLICE C — --pairing builds the GENERIC, SECRET-FREE universal ISO: no customer-id / passphrase is
|
|
# baked in. The box registers itself at the hub as an UNCLAIMED APPLIANCE, the operator binds it to a
|
|
# customer, and the hub delivers the credentials ONCE — then the box completes day-0 exactly like a
|
|
# direct-mode box. Direct mode (--bootstrap-env, secret-bearing, operator-prepped) is unchanged.
|
|
#
|
|
# Renders answer.toml (from answer.toml.tmpl + a profile), mints a fresh THROWAWAY root hash,
|
|
# gates the answer through validate-answer by PARSING ITS OUTPUT (never $? — validate-answer returns
|
|
# exit 0 even on failure, spike S1 trap), renders the first-boot stub (injecting the bootstrap
|
|
# script/unit/env), and runs prepare-iso --fetch-from iso --on-first-boot. Emits the ISO + sha256 +
|
|
# a build manifest.
|
|
#
|
|
# R-38 GRUB SLICE (v1.22.0) — every ISO is now REPACKED after prepare-iso to carry the Felhom boot
|
|
# screen and, more importantly, a menu with exactly ONE entry ("Felhom telepítés", default, 5s). The
|
|
# stock PVE menu offers Graphical/Terminal-UI/serial installers plus an Advanced Options submenu with
|
|
# debug/nomodeset/rescue entries — every one of them a route into the MANUAL installer, whose first
|
|
# question is which disk to wipe. A customer, or their helpful nephew, must not be able to reach it
|
|
# from a boot menu; those entries are not hidden, they are not emitted. The kernel/append line is
|
|
# lifted verbatim from the ISO's own automated entry, so boot behavior is unchanged. See iso-repack.sh.
|
|
# (--no-brand exists for debugging the stock menu; it is not a shipping mode.)
|
|
#
|
|
# SLICE B — --loader shim|mkimage (default shim). shim = the stock output (MS-signed shim→GRUB chain,
|
|
# keeps Secure Boot working on compliant firmware, spike S2b). mkimage = replace the ISO's UEFI boot
|
|
# path with a monolithic grub-mkimage-built BOOTX64.EFI built from the ISO's OWN GRUB modules — the
|
|
# workaround PROVEN LIVE during the N100 run (VALIDATION-n100-baremetal F1: cheap AMI AN3PLUS-class
|
|
# firmware can't relocate the ISO's signed GRUB from USB, `relocation 0x0`). The mkimage loader is
|
|
# UNSIGNED → the target board MUST have Secure Boot OFF (documented in the n100 profile's prep). The
|
|
# loader surgery runs AFTER prepare-iso (the assistant's answer/first-boot payload is provably
|
|
# untouched except the loader path).
|
|
#
|
|
# SECRET-BEARING: if the bootstrap-env carries a retrieval passphrase (it must, for an unattended
|
|
# install — see README "secret-bearing"), the produced ISO embeds it. Supervised/single-use only;
|
|
# never distributed; delete after the run. The build log says so loudly.
|
|
#
|
|
# Runs on DooPlex; delegates validate-answer + prepare-iso + the mkimage surgery to the
|
|
# felhom-iso-assistant container (which carries proxmox-auto-install-assistant, xorriso, grub-mkimage,
|
|
# and mtools).
|
|
#===============================================================================
|
|
set -euo pipefail
|
|
|
|
# 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)"
|
|
|
|
# --- logging (host-install idiom) -----------------------------------------------------------------
|
|
if [[ -t 1 ]]; then RED=$'\033[0;31m'; GREEN=$'\033[0;32m'; YELLOW=$'\033[1;33m'; BLUE=$'\033[0;34m'; CYAN=$'\033[0;36m'; NC=$'\033[0m'
|
|
else RED=""; GREEN=""; YELLOW=""; BLUE=""; CYAN=""; NC=""; fi
|
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
|
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
|
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|
log_dry() { echo -e "${CYAN}[DRY-RUN]${NC} $1"; }
|
|
die() { log_error "$1"; exit 1; }
|
|
|
|
# ver_ge A B → true iff version A >= version B (dotted numeric, sort -V).
|
|
ver_ge() { [[ "$1" == "$2" ]] && return 0; [[ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -1)" == "$1" ]]; }
|
|
|
|
# R-71 STANDING GATE (v1.25.0): every ISO build asserts the golden it will install is >= the managed
|
|
# controller floor. If the floor exceeds golden, a freshly-installed box boots BELOW the floor and the
|
|
# day-0 managed update fires — racing the offsite apply-bridge exactly as it did on demo-hp
|
|
# (DIAG-f10-demo-hp-offsite-2026-07-23 / R-71). The build must fail loudly rather than ship that gap.
|
|
# Inputs: FELHOM_ASSERT_GOLDEN + FELHOM_ASSERT_FLOOR (the hub's current artifact_golden_version and
|
|
# min_controller_version — resolve them operator-side and pass in; the publish-train runbook covers
|
|
# how). Both printed. If either is unset the gate is UNENFORCED and says so LOUDLY (never a silent pass).
|
|
assert_golden_ge_floor() {
|
|
local golden="${FELHOM_ASSERT_GOLDEN:-}" floor="${FELHOM_ASSERT_FLOOR:-}"
|
|
if [[ -z "$golden" || -z "$floor" ]]; then
|
|
log_warn "R-71 golden>=floor gate UNENFORCED — pass FELHOM_ASSERT_GOLDEN + FELHOM_ASSERT_FLOOR to enforce (golden='${golden:-unset}' floor='${floor:-unset}')"
|
|
return 0
|
|
fi
|
|
if ver_ge "$golden" "$floor"; then
|
|
log_success "R-71 gate OK: golden $golden >= managed floor $floor"
|
|
else
|
|
die "R-71 BUILD GATE FAILED: golden $golden < managed floor $floor — a fresh box would boot below the floor and the day-0 update would race the offsite apply-bridge. Republish golden >= $floor (and vouch it) before building this ISO."
|
|
fi
|
|
}
|
|
|
|
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)
|
|
--iso-sha256 SHA expected sha256 of --pve-iso (verified before build; abort on mismatch)
|
|
--profile FILE build profile (fqdn + [disk-setup]); see profiles/ and README
|
|
|
|
Mode (exactly one):
|
|
--bootstrap-env FILE DIRECT mode: the in-ISO /etc/felhom/bootstrap.env (SECRET-BEARING: retrieval
|
|
passphrase). Must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE.
|
|
--pairing PAIRING mode (slice C): the GENERIC, SECRET-FREE universal ISO. The box registers
|
|
itself as an unclaimed appliance at the hub; the operator binds it; the hub
|
|
delivers the customer-id + passphrase ONCE. No customer secret is baked in. The
|
|
hub URL comes from the profile (FELHOM_HUB_URL) or the default.
|
|
Options:
|
|
--loader shim|mkimage UEFI boot loader (default: shim, or the profile's FELHOM_LOADER; --loader wins).
|
|
shim = stock MS-signed chain (Secure Boot OK on compliant firmware).
|
|
mkimage = monolithic grub-mkimage loader for cheap AMI boards that can't boot
|
|
the ISO's GRUB from USB (F1). UNSIGNED -> the target board needs Secure Boot OFF.
|
|
--no-brand DEBUG ONLY: keep the stock PVE boot menu (all installer entries reachable).
|
|
Shipping ISOs are always branded: Felhom boot screen + exactly ONE entry.
|
|
--brand-image FILE override the boot-screen card (default: website/assets/og-image_2.png)
|
|
--out DIR output directory (default: the DooPlex build root
|
|
/mnt/5_hdd/felhom.eu/felhom-iso/out; override via \$FELHOM_ISO_OUT for other hosts)
|
|
--pve-version VER override PVE version tag (default: parsed from the ISO filename)
|
|
--dry-run print the steps without producing an ISO
|
|
-h, --help this help
|
|
EOF
|
|
}
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--pve-iso) PVE_ISO="$2"; shift 2 ;;
|
|
--iso-sha256) ISO_SHA256="$2"; shift 2 ;;
|
|
--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 ;;
|
|
--out) OUT_DIR="$2"; shift 2 ;;
|
|
--pve-version) PVE_VERSION="$2"; shift 2 ;;
|
|
--dry-run) DRY_RUN=true; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "unknown argument: $1 (see --help)" ;;
|
|
esac
|
|
done
|
|
[[ -z "$LOADER_CLI" || "$LOADER_CLI" == "shim" || "$LOADER_CLI" == "mkimage" ]] \
|
|
|| die "--loader must be 'shim' or 'mkimage' (got '$LOADER_CLI')"
|
|
|
|
[[ -n "$PVE_ISO" ]] || die "--pve-iso is required"
|
|
[[ -n "$ISO_SHA256" ]] || die "--iso-sha256 is required"
|
|
[[ -f "$PVE_ISO" ]] || die "--pve-iso not found: $PVE_ISO"
|
|
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"
|
|
else
|
|
[[ -n "$BOOTSTRAP_ENV" ]] || die "one of --bootstrap-env (direct) or --pairing (generic) is required"
|
|
[[ -f "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env not found: $BOOTSTRAP_ENV"
|
|
fi
|
|
|
|
command -v docker >/dev/null || die "docker not found (needed for the assistant container)"
|
|
docker image inspect "$IMAGE" >/dev/null 2>&1 || die "assistant image '$IMAGE' not found — build it: docker build -f $HERE/Dockerfile.assistant -t $IMAGE $HERE"
|
|
|
|
# --- verify source ISO ----------------------------------------------------------------------------
|
|
log_step "verifying source ISO sha256"
|
|
actual_sha=$(sha256sum "$PVE_ISO" | awk '{print $1}')
|
|
[[ "$actual_sha" == "$ISO_SHA256" ]] || die "ISO sha256 MISMATCH: expected $ISO_SHA256, got $actual_sha"
|
|
log_success "source ISO sha256 OK ($actual_sha)"
|
|
|
|
if [[ -z "$PVE_VERSION" ]]; then
|
|
PVE_VERSION=$(basename "$PVE_ISO" | sed -E 's/^proxmox-ve_(.+)\.iso$/\1/')
|
|
[[ "$PVE_VERSION" != "$(basename "$PVE_ISO")" ]] || die "cannot parse PVE version from '$(basename "$PVE_ISO")' — pass --pve-version"
|
|
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
|
|
source "$PROFILE"
|
|
[[ -n "$FELHOM_FQDN" ]] || die "profile missing FELHOM_FQDN"
|
|
[[ -n "$FELHOM_DISK_SETUP" ]] || die "profile missing FELHOM_DISK_SETUP"
|
|
# 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}}"
|
|
[[ "$LOADER" == "shim" || "$LOADER" == "mkimage" ]] \
|
|
|| die "profile FELHOM_LOADER must be 'shim' or 'mkimage' (got '$FELHOM_LOADER')"
|
|
if [[ "$LOADER" == "mkimage" ]]; then
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
log_warn "LOADER MODE = mkimage — the UEFI boot path is a monolithic grub-mkimage loader (F1 fix)."
|
|
log_warn "This loader is UNSIGNED: the target board MUST have Secure Boot OFF. shim/SB is bypassed."
|
|
log_warn "The ISO filename gains '-mkimage'; never confuse it with a shim (SB-capable) build."
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
else
|
|
log_info "loader mode = shim (stock MS-signed chain; Secure Boot works on compliant firmware)"
|
|
fi
|
|
|
|
# --- resolve the boot-screen card (R-38) ------------------------------------------------------------
|
|
# Single source of truth: the website asset. NOT copied into scripts/iso/ — a second copy of a brand
|
|
# asset is a second thing to forget to update.
|
|
if $BRAND; then
|
|
[[ -n "$BRAND_IMAGE" ]] || BRAND_IMAGE="$HERE/../../website/assets/og-image_2.png"
|
|
[[ -f "$BRAND_IMAGE" ]] || die "brand image not found: $BRAND_IMAGE (pass --brand-image, or run from the repo checkout)"
|
|
log_info "boot-screen card = $BRAND_IMAGE (menu: exactly ONE entry, 'Felhom telepítés')"
|
|
else
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
log_warn "--no-brand — the STOCK PVE boot menu ships: Graphical/Terminal-UI/serial installers and"
|
|
log_warn "the Advanced Options submenu are all reachable, i.e. a manual 'which disk do I wipe'"
|
|
log_warn "installer is one keypress away. DEBUGGING ONLY — never hand this ISO to a customer."
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
fi
|
|
|
|
# --- mode: DIRECT validates the secret-bearing env; PAIRING is secret-free (env generated below) -----
|
|
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}"
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
log_info "PAIRING MODE — building the GENERIC, SECRET-FREE universal ISO (slice C)."
|
|
log_info "The box registers as an unclaimed appliance; the operator binds it; the hub delivers the"
|
|
log_info "customer-id + passphrase ONCE. Baked env carries only the hub URL ($PAIR_HUB_URL) — no secret."
|
|
echo -e "${YELLOW}==================================================================================${NC}"
|
|
else
|
|
log_step "checking bootstrap-env (secret-bearing detection)"
|
|
( set +e
|
|
FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE=""
|
|
# shellcheck disable=SC1090
|
|
source "$BOOTSTRAP_ENV"
|
|
[[ -n "$FELHOM_CUSTOMER_ID" ]] || { echo "MISSING FELHOM_CUSTOMER_ID"; exit 3; }
|
|
[[ -n "$FELHOM_MODE" ]] || { echo "MISSING FELHOM_MODE"; exit 3; }
|
|
[[ -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]] || { echo "MISSING FELHOM_RETRIEVAL_PASSPHRASE"; exit 3; }
|
|
) || die "bootstrap-env invalid ($BOOTSTRAP_ENV) — must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE"
|
|
SECRET_BEARING="yes" # a valid bootstrap-env always carries the retrieval passphrase
|
|
log_warn "this ISO will be SECRET-BEARING (embeds the customer retrieval passphrase) — supervised/single-use only"
|
|
fi
|
|
|
|
# R-71 build gate — before any workspace/mint/build work commits (fails fast + loud).
|
|
assert_golden_ge_floor
|
|
|
|
# --- workspace ------------------------------------------------------------------------------------
|
|
WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")"
|
|
# chmod first: the mkimage surgery's osirrox extract leaves read-only file modes that rm can't clear.
|
|
# FELHOM_ISO_KEEP_WORK=1 is a DEBUG/TEST escape: keep $WORK for post-mortem (the rootpw-emission
|
|
# harness cross-checks the emitted plaintext against the rendered answer's hash). Never a build mode.
|
|
cleanup() {
|
|
if [[ "${FELHOM_ISO_KEEP_WORK:-0}" == "1" ]]; then
|
|
log_warn "FELHOM_ISO_KEEP_WORK=1 — workspace KEPT at $WORK (debug/test only; delete it yourself)"
|
|
return 0
|
|
fi
|
|
chmod -R u+w "$WORK" 2>/dev/null || true; rm -rf "$WORK"
|
|
}
|
|
trap cleanup EXIT
|
|
mkdir -p "$OUT_DIR" "$WORK/tmp"
|
|
ISO_DIR="$(cd "$(dirname "$PVE_ISO")" && pwd)"; ISO_BASE="$(basename "$PVE_ISO")"
|
|
|
|
# PAIRING: generate the SECRET-FREE env the stub bakes — only the hub URL, no customer/passphrase.
|
|
# (The bootstrap detects the absent customer-id/passphrase and enters pairing mode.)
|
|
if $PAIRING; then
|
|
BOOTSTRAP_ENV="$WORK/pairing.env"
|
|
cat > "$BOOTSTRAP_ENV" <<EOF
|
|
# GENERIC secret-free pairing env (R-21 slice C). NO customer-id, NO passphrase — the box registers
|
|
# as an unclaimed appliance and the hub delivers the credentials once, after the operator binds it.
|
|
FELHOM_HUB_URL=$PAIR_HUB_URL
|
|
FELHOM_INSTALL_URL=$PAIR_INSTALL_URL
|
|
EOF
|
|
log_info "generated secret-free pairing env (hub=$PAIR_HUB_URL)"
|
|
fi
|
|
|
|
# --- resolve the output ISO name (needed by the mint below: the rootpw sibling file is named after
|
|
# the ISO). Rule 4: the loader mode is loud in the filename — a '-mkimage' ISO implies
|
|
# Secure-Boot-off prep. -----------------------------------------------------------------------
|
|
LOADER_SUFFIX=""; [[ "$LOADER" != "shim" ]] && LOADER_SUFFIX="-${LOADER}"
|
|
MODE_SUFFIX=""; $PAIRING && MODE_SUFFIX="-generic" # the secret-free universal ISO is unmistakable
|
|
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")"
|
|
# R-61 slice 1: the baked root password becomes KNOWABLE. The plaintext is written to a 0600 sibling
|
|
# file next to the ISO — the SINGLE record of truth — and nowhere else: never stdout/stderr/logs,
|
|
# never the manifest (manifests get pasted into committed REPORTs; a separate file cannot ride along
|
|
# by accident). A fixed well-known password stays REJECTED (operator ruling 2026-07-21): a
|
|
# pre-pairing box sits on a stranger's LAN. Note: an aborted build can leave a rootpw file for an
|
|
# ISO that was never produced; the next build of the same name overwrites it.
|
|
( umask 077; printf '%s %s built %s\n' "$ROOT_PLAIN" "$(basename "$OUT_ISO")" "$(date -Is)" > "$OUT_ISO.rootpw.txt" )
|
|
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"
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if [[ "$line" == "__DISK_SETUP__" ]]; then
|
|
printf '%s\n' "$FELHOM_DISK_SETUP" >> "$ANSWER"
|
|
elif [[ "$line" == "__ROOT_SSH_KEYS__" ]]; then
|
|
[[ -n "$ROOT_SSH_LINE" ]] && printf '%s\n' "$ROOT_SSH_LINE" >> "$ANSWER" # blank -> omit line
|
|
else
|
|
line="${line//__FQDN__/$FELHOM_FQDN}"
|
|
line="${line//__ROOT_HASH__/$ROOT_HASH}"
|
|
printf '%s\n' "$line" >> "$ANSWER"
|
|
fi
|
|
done < "$HERE/answer.toml.tmpl"
|
|
|
|
# --- validate-answer OUTPUT-PARSE gate (never $? — spike S1) --------------------------------------
|
|
gate_validate_answer() {
|
|
local out
|
|
out=$(docker run --rm -v "$WORK":/work "$IMAGE" \
|
|
proxmox-auto-install-assistant validate-answer /work/answer.toml 2>&1) || true
|
|
echo "----- validate-answer output -----"; echo "$out"; echo "----------------------------------"
|
|
# LOAD-BEARING: validate-answer exits 0 even on failure; decide on the MESSAGE TEXT, not $?.
|
|
if echo "$out" | grep -q "parsed successfully" && ! echo "$out" | grep -qi "Found issues"; then
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
log_step "validating rendered answer (output-parse gate)"
|
|
if $DRY_RUN; then
|
|
log_dry "docker run … validate-answer /work/answer.toml (output-parse gate)"
|
|
else
|
|
gate_validate_answer || die "answer validation FAILED — NO ISO produced (fix the answer/profile)"
|
|
log_success "answer validated"
|
|
fi
|
|
|
|
# --- render the first-boot stub (inject bootstrap script/unit/env as base64) ----------------------
|
|
log_step "rendering first-boot stub"
|
|
STUB="$WORK/stub-first-boot.sh"
|
|
sh_b64="$(base64 -w0 < "$HERE/felhom-bootstrap.sh")"
|
|
unit_b64="$(base64 -w0 < "$HERE/felhom-bootstrap.service")"
|
|
env_b64="$(base64 -w0 < "$BOOTSTRAP_ENV")"
|
|
awk -v sh="$sh_b64" -v unit="$unit_b64" -v env="$env_b64" '
|
|
{ gsub(/@@BOOTSTRAP_SH_B64@@/, sh); gsub(/@@BOOTSTRAP_UNIT_B64@@/, unit); gsub(/@@BOOTSTRAP_ENV_B64@@/, env); print }
|
|
' "$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.)
|
|
GRUB_VERSION="" # populated by the mkimage surgery (the grub-mkimage build used)
|
|
log_step "building ISO: $(basename "$OUT_ISO")"
|
|
if $DRY_RUN; then
|
|
log_dry "docker run … prepare-iso /iso/$ISO_BASE --fetch-from iso --answer-file /work/answer.toml --on-first-boot /work/stub-first-boot.sh --output /work/out.iso"
|
|
$BRAND && log_dry "docker run … (repack/brand) generate 1024x768 boot card from $(basename "$BRAND_IMAGE") → install felhomtheme → rewrite grub.cfg to ONE entry ('Felhom telepítés', 5s), kernel line lifted from the stock automated entry"
|
|
[[ "$LOADER" == "mkimage" ]] && log_dry "docker run … (repack/mkimage) grub-mkimage from the ISO's own modules → swap BOOTX64.EFI in the EFI tree + efi.img"
|
|
( $BRAND || [[ "$LOADER" == "mkimage" ]] ) && log_dry "docker run … (repack) xorriso re-master → /work/final.iso"
|
|
log_info "DRY-RUN: no ISO produced"
|
|
exit 0
|
|
fi
|
|
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
|
|
# need the same extract -> modify -> re-master cycle, so iso-repack.sh does them in ONE pass. The
|
|
# assistant's answer/first-boot payload is untouched; only the GRUB menu/theme and (mkimage) the
|
|
# EFI boot path change. The mkimage recipe is the N100 run's proven workaround (VALIDATION F1) —
|
|
# do NOT re-derive it. See iso-repack.sh for the full rationale. -----------------------------------
|
|
if $BRAND || [[ "$LOADER" == "mkimage" ]]; then
|
|
REPACK_WHAT=""
|
|
$BRAND && REPACK_WHAT="branding"
|
|
[[ "$LOADER" == "mkimage" ]] && REPACK_WHAT="${REPACK_WHAT:+$REPACK_WHAT + }mkimage loader"
|
|
log_step "repacking ISO ($REPACK_WHAT)"
|
|
[[ -f "$HERE/iso-repack.sh" ]] || die "iso-repack.sh not found next to build-felhom-iso.sh"
|
|
cp "$HERE/iso-repack.sh" "$WORK/iso-repack.sh"
|
|
if $BRAND; then
|
|
mkdir -p "$WORK/brand"
|
|
cp "$HERE/grub/grub.cfg.tmpl" "$HERE/grub/felhom-theme.txt" \
|
|
"$HERE/grub/generate-grub-background.sh" "$WORK/brand/"
|
|
cp "$BRAND_IMAGE" "$WORK/brand/card.png"
|
|
fi
|
|
docker run --rm -v "$WORK":/work \
|
|
-e FELHOM_LOADER="$LOADER" -e FELHOM_BRAND="$($BRAND && echo 1 || echo 0)" \
|
|
"$IMAGE" bash /work/iso-repack.sh 2>&1 | sed 's/^/ [repack] /'
|
|
[[ -f "$WORK/final.iso" ]] || die "repack produced no output (see [repack] log above)"
|
|
GRUB_VERSION="$(cat "$WORK/grub-version.txt" 2>/dev/null || echo unknown)"
|
|
cp "$WORK/final.iso" "$OUT_ISO"
|
|
log_success "repack applied ($REPACK_WHAT)"
|
|
else
|
|
cp "$WORK/out.iso" "$OUT_ISO"
|
|
fi
|
|
|
|
# --- sha256 + manifest ----------------------------------------------------------------------------
|
|
OUT_SHA="$(sha256sum "$OUT_ISO" | awk '{print $1}')"
|
|
OUT_SIZE="$(stat -c '%s' "$OUT_ISO")"
|
|
ASSISTANT_VER="$(docker run --rm "$IMAGE" proxmox-auto-install-assistant --version 2>&1 | head -1)"
|
|
echo "$OUT_SHA $(basename "$OUT_ISO")" > "$OUT_ISO.sha256"
|
|
LOADER_NOTE="shim (stock MS-signed chain; Secure Boot OK on compliant firmware)"
|
|
[[ "$LOADER" == "mkimage" ]] && LOADER_NOTE="mkimage (monolithic grub-mkimage UEFI loader, F1 fix — UNSIGNED; target board MUST have Secure Boot OFF)"
|
|
BRAND_NOTE="FELHOM (single entry 'Felhom telepítés', default, 5s; stock installer entries not emitted)"
|
|
$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)
|
|
iso-version-tag : v${ISO_VERSION}
|
|
pve-version : ${PVE_VERSION}
|
|
source-iso : ${ISO_BASE}
|
|
source-iso-sha256 : ${ISO_SHA256}
|
|
assistant-version : ${ASSISTANT_VER}
|
|
profile : ${PROFILE_NAME}
|
|
fqdn : ${FELHOM_FQDN}
|
|
mode : ${MODE_NOTE}
|
|
loader : ${LOADER_NOTE}
|
|
grub-mkimage : ${GRUB_VERSION:-n/a (shim mode; loader unchanged)}
|
|
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 : $( $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}
|
|
EOF
|
|
|
|
log_success "ISO built: $OUT_ISO"
|
|
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 $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."
|
|
fi
|