feat(iso): R-21 slice A — bare-metal Felhom ISO pipeline + first-boot bootstrap

scripts/iso/: a DooPlex pipeline (build-felhom-iso.sh + Dockerfile.assistant) that
turns the official PVE ISO into a Felhom auto-install ISO whose first-boot stub
installs a retry-forever felhom-bootstrap unit which unattended-fetches
felhom-host-install.sh from the public felhom.eu/scripts channel and runs it until the
host is enrolled + a guest provisioned. host-install is UNMODIFIED (invoked only).

- build gates the answer on validate-answer OUTPUT text, never $? (spike S1 exit-0 trap)
- stub is from-iso, fully-up, exactly-once; retry unit owns all network work (S8a)
- retry-vs-resume encoded once: plain first, --resume when install state exists (v1.11.3)
- secret-bearing (embeds the retrieval passphrase): supervised/single-use; env shredded on success

Validated on VM 310: build gate + red-proof, disk-filter fail-safe, chain + retry,
resume-decision, exactly-once, no-net retry+recovery. Terminal host-install rc-0 success
operator-gated (drill customer needs the password-gated create-UI). scripts v1.16.0;
ROADMAP R-21 -> in-progress. Detail in REPORT.md.
This commit is contained in:
2026-07-16 13:47:08 +02:00
parent 914d51aa5f
commit 1fa3250aa3
13 changed files with 743 additions and 109 deletions
+25
View File
@@ -1,5 +1,30 @@
# Felhom scripts — Changelog
## felhom bare-metal ISO pipeline v1.16.0 — NEW (R-21 slice A, 2026-07-16)
New operator tooling under `scripts/iso/`: a reproducible DooPlex pipeline that turns the official
PVE ISO into a Felhom auto-install ISO whose first-boot chain unattended-fetches-and-runs
`felhom-host-install.sh` until the host is enrolled + a guest provisioned. `felhom-host-install.sh`
is **unchanged** (invoked, not modified). Tagged **v1.16.0** to align the ISO with the current
host-install release (they must stay coherent; the ISO's baked invocation targets that flag surface).
- `Dockerfile.assistant` (assistant paired to the ISO's PVE generation by Debian codename + shellcheck),
`build-felhom-iso.sh` (render answer from `answer.toml.tmpl` + a profile, mint a fresh THROWAWAY root
hash per build, **gate validate-answer on OUTPUT TEXT not `$?`** — spike S1 exit-0-on-failure trap,
`prepare-iso --fetch-from iso --on-first-boot`, emit ISO + sha256 + manifest).
- In-ISO chain: `stub-first-boot.sh` (dumb, exactly-once, from-iso/fully-up) installs
`felhom-bootstrap.sh` + `felhom-bootstrap.service` (retry-forever `Type=oneshot`+`Restart=on-failure`)
+ a 0600 `bootstrap.env`. Retry-vs-resume encoded once (plain first; `--resume` when
`/var/lib/felhom-install/state.json` exists — safe per host-install v1.11.3).
- **Secret-bearing:** the unattended install needs the customer retrieval passphrase, so the ISO embeds
it (the distribution URL itself carries no secret). Supervised/single-use; the bootstrap scrubs the
env on success. Slice C removes the secret from the *distributed* ISO via claim-pairing.
- Validated on nested VM 310: build gate + red-proof; disk-filter fail-safe through the pipeline;
stub→unit→real public-channel fetch→invocation→retry; resume decision; exactly-once; no-net retry +
recovery. Terminal host-install success (hub-enrolled + guest-provisioned) is operator-gated (drill
customer needs the password-gated create-UI; live-DB insert unsafe). Full report: root `REPORT.md`,
`documentation/audits/SPIKE-baremetal-iso-2026-07-16.md`.
## felhom-host-install v1.16.0 — FELHOM_ESCROW rides the canonical sudoers (2026-07-13)
Companion to agent v0.88.0 + controller v0.127.0 (the controller-driven escrow ceremony wizard).
+24
View File
@@ -0,0 +1,24 @@
# scripts/iso/Dockerfile.assistant — the build host for the Felhom bare-metal ISO pipeline (R-21).
#
# Debian trixie == PVE 9.x codename; the proxmox-auto-install-assistant is pinned to the ISO
# generation by installing it from the PVE no-subscription repo of the ISO's Debian codename
# (spike SPIKE-baremetal-iso-2026-07-16.md §15.6: pair by major.minor). Rebuild this image when the
# target PVE generation changes (e.g. PVE 10 -> Debian forky) and record both versions in the build
# manifest.
#
# Build: docker build -f scripts/iso/Dockerfile.assistant -t felhom-iso-assistant:trixie scripts/iso
FROM debian:trixie
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates wget gnupg xorriso shellcheck \
&& rm -rf /var/lib/apt/lists/*
# PVE 9.x (trixie) no-subscription repo — pairs the assistant to the 9.2 ISO generation.
RUN wget -q https://enterprise.proxmox.com/debian/proxmox-release-trixie.gpg \
-O /etc/apt/trusted.gpg.d/proxmox-release-trixie.gpg
RUN echo "deb http://download.proxmox.com/debian/pve trixie pve-no-subscription" \
> /etc/apt/sources.list.d/pve.list
RUN apt-get update && apt-get install -y --no-install-recommends \
proxmox-auto-install-assistant \
&& rm -rf /var/lib/apt/lists/*
+126
View File
@@ -0,0 +1,126 @@
# Felhom bare-metal ISO pipeline (R-21 slice A)
Turns the official Proxmox VE ISO into a **Felhom auto-install ISO** whose first-boot chain,
unattended, fetches and runs `felhom-host-install.sh` until the host is enrolled and a guest is
provisioned. Boot the ISO on blank hardware → walk away → managed Felhom host.
Grounding: every install/first-boot/webhook mechanism used here is proven in
`documentation/audits/SPIKE-baremetal-iso-2026-07-16.md` (S1S8). This pipeline **composes** them;
it does **not** modify `felhom-host-install.sh` (which it only invokes over the public distribution
channel).
> **Scope:** slice A = the pipeline + the in-ISO bootstrap, validated on nested VM 310. The physical
> N100 run is **RUNBOOK B** (separate, supervised). The customer-facing claim/pairing that removes the
> secret from the ISO is **slice C** (not built here).
## Files
| File | Role |
|---|---|
| `Dockerfile.assistant` | build host: Debian trixie + `proxmox-auto-install-assistant` (paired to the ISO's PVE generation by Debian codename) + `xorriso` + `shellcheck` |
| `build-felhom-iso.sh` | the pipeline: render answer, mint throwaway root hash, **validate-answer output-parse gate**, render stub, `prepare-iso`, emit ISO + sha256 + manifest |
| `answer.toml.tmpl` | Proxmox answer template (`__FQDN__` / `__ROOT_HASH__` / `__ROOT_SSH_KEYS__` / `__DISK_SETUP__` placeholders; `[first-boot]` from-iso/fully-up) |
| `profiles/*.profile` | disk-selection + fqdn fragments (sourceable, no secret) |
| `stub-first-boot.sh` | the ONE first-boot executable (skeleton; build injects the bootstrap script/unit/env as base64) |
| `felhom-bootstrap.sh` | per-attempt: fetch host-install from the public channel → run it with the retrieval passphrase → on rc 0 write done-flag + disable |
| `felhom-bootstrap.service` | retry-forever unit (`Type=oneshot`, `Restart=on-failure`, `RestartSec=30`, `StartLimitIntervalSec=0`) |
## Build
```bash
# 1. build the assistant image once (rebuild when the target PVE generation changes)
docker build -f scripts/iso/Dockerfile.assistant -t felhom-iso-assistant:trixie scripts/iso
# 2. build an ISO (on the build host; PVE ISO pre-downloaded + its official sha256)
scripts/iso/build-felhom-iso.sh \
--pve-iso /path/proxmox-ve_9.2-1.iso \
--iso-sha256 4e88fe416df9b527624a175f24c9aa07c714d3332afb1ee3dbf3879573ef2c6c \
--profile scripts/iso/profiles/nested-vm.profile \
--bootstrap-env /secure/bootstrap.env \
--out ~/felhom-iso/out
```
Output: `felhom-pve-<pvever>-v<isover>-<profile>.iso` + `.sha256` + `.manifest.txt`.
The build **gates the answer on validate-answer's OUTPUT TEXT, never `$?`**`validate-answer`
returns exit 0 even on a broken file (spike S1 trap; also `prepare-iso` was observed to exit 0 on a
bad answer). A broken answer aborts the build with the validator's message and produces **no ISO**.
Note `validate-answer` is *syntactic only*: disk existence is a runtime property (a non-existent disk
passes the build and fails-safe at install — spike S5c/S8b).
## Profile format
A sourceable shell fragment:
```sh
FELHOM_FQDN="felhom-host.local"
FELHOM_DISK_SETUP='[disk-setup]
filesystem = "ext4"
disk-list = ["sda"]'
# optional emergency/validation key baked into root's authorized_keys (blank -> not baked):
# FELHOM_ROOT_SSH_KEY="ssh-ed25519 AAAA... ops@felhom"
```
`FELHOM_DISK_SETUP` is any valid Proxmox `[disk-setup]` block — a `disk-list` OR a udev `filter.*`
glob. **A filter matching nothing, or a non-existent disk, fails-safe**: the installer aborts (exit
1, no disk touched) rather than installing on the wrong disk (spike S5c). Never commit a real key in
`FELHOM_ROOT_SSH_KEY` unless it is meant to ship in every ISO built from that profile.
## bootstrap-env (SECRET-BEARING)
The `--bootstrap-env` file becomes the in-ISO `/etc/felhom/bootstrap.env`:
```sh
FELHOM_CUSTOMER_ID=<hub customer id> # required
FELHOM_MODE=appliance # required (appliance|byo)
FELHOM_RETRIEVAL_PASSPHRASE=<customer passphrase> # required — SECRET
# optional:
# FELHOM_HUB_URL=https://hub.felhom.eu
# FELHOM_INSTALL_URL=https://felhom.eu/scripts/felhom-host-install.sh
# FELHOM_EXTRA_ARGS="--cores 4 --memory 8192" # profile-only flags, never secrets
```
**Why the ISO is secret-bearing (§4.4 finding):** the hub install-command's *distribution channel* is
the public `https://felhom.eu/scripts/felhom-host-install.sh` — no token, no secret in the command.
But `felhom-host-install.sh` requires the customer **retrieval passphrase** (normally a no-echo
prompt) to fetch the config and enroll the host. An *unattended* install must supply it via
`--passphrase-file`, so the ISO embeds it. **Rules for a secret-bearing ISO:**
- supervised / single-use only; **never distributed**; **delete after the run**.
- issue the passphrase with the shortest usable lifetime; rotate/retire after the install.
- the bootstrap `shred`s `bootstrap.env` on the box once host-install succeeds (reduces secret-at-rest).
- **slice C** removes this: the claim/pairing flow delivers the passphrase (or config) to the box
*after* the customer claims it, so the *distributed* ISO carries no secret.
## The first-boot chain
`stub-first-boot.sh` (exactly-once, `[first-boot]` from-iso/fully-up) is **dumb**: it lays down
`/usr/local/sbin/felhom-bootstrap.sh`, the systemd unit, and `/etc/felhom/bootstrap.env` (0600), then
enables + starts the unit. All fallible/network work lives in `felhom-bootstrap.service`, which
retries forever until `felhom-host-install.sh` exits 0, then writes `/etc/felhom/.bootstrap-done`,
disables itself, and scrubs the env. Retry-vs-resume: the first attempt is plain; any later attempt
that finds `/var/lib/felhom-install/state.json` adds `--resume` (safe per host-install v1.11.3 — its
producer steps re-run each pass).
## N100 profile (for RUNBOOK B)
The nested profile uses `disk-list=["sda"]`. **A real single-disk mini-PC should use a unique-serial
udev filter, not `disk-list`:**
1. Boot once (or use a live env), harvest the target disk's udev id:
`udevadm info --query=property --name=/dev/sda | grep -E 'ID_SERIAL_SHORT|ID_MODEL|ID_WWN'`.
2. Write a profile: `filter.ID_SERIAL_SHORT = "<harvested>"` + `filter-match = "all"`. A
match-nothing filter fails-safe (won't wipe the wrong disk).
3. **Prior-LVM prerequisite:** the auto-installer does **not** force-clear a pre-existing LVM PV
signature — a disk with a previous Linux/Proxmox install aborts at "cannot init physical volume"
(spike S2b). Wipe first: `blkdiscard -f /dev/<disk>` (or `wipefs -a` + zero the PV region).
4. UEFI + Secure Boot: no caveat — installs and boots under SB enforcing, no MOK/keypress (spike S2b).
## Validation status (nested VM 310, 2026-07-16)
PASS: build gate + red-proof (C); disk-filter fail-safe through the pipeline (D); stub→unit→**real
public-channel fetch**→correct host-install invocation→retry; 2.3 resume decision; exactly-once stub;
no-network retry + auto-recovery (B). **Operator-gated (not live-proven here):** host-install rc-0
terminal success → hub-enrolled + guest-provisioned + done-flag + unit-disable — needs a properly
created drill customer (the hub create-UI is password-gated and a live-DB insert is unsafe). Also
out of scope: the physical N100 run (RUNBOOK B).
+34
View File
@@ -0,0 +1,34 @@
# scripts/iso/answer.toml.tmpl — Proxmox auto-install answer template for the Felhom ISO pipeline.
#
# build-felhom-iso.sh renders this into a concrete answer.toml by substituting:
# __FQDN__ <- profile's FELHOM_FQDN
# __ROOT_HASH__ <- a FRESH THROWAWAY crypt hash minted per build (never a real credential)
# __DISK_SETUP__ <- profile's FELHOM_DISK_SETUP block (disk-list OR a udev filter)
# The rendered answer is then run through validate-answer with the OUTPUT-PARSE gate (validate-answer
# returns exit 0 even on failure — spike S1 trap; the build parses the message text, never $?).
#
# This committed template carries ONLY the __ROOT_HASH__ placeholder, never a real hash.
[global]
keyboard = "en-us"
country = "hu"
fqdn = "__FQDN__"
mailto = "noreply@felhom.eu"
timezone = "Europe/Budapest"
# THROWAWAY-PLACEHOLDER — build-felhom-iso.sh mints a fresh per-build crypt hash here.
root-password-hashed = "__ROOT_HASH__"
# Optional emergency/validation root SSH key (profile FELHOM_ROOT_SSH_KEY); blank -> line removed.
__ROOT_SSH_KEYS__
[network]
source = "from-dhcp"
# Enable the baked first-boot stub (prepare-iso --on-first-boot). from-iso = no network / no
# cert-rotation risk (R-21 ruling); fully-up = the stub runs after pveproxy so pvesh/pct work and
# the retry unit it installs can drive host-install (spike S3/S8a).
[first-boot]
source = "from-iso"
ordering = "fully-up"
# --- disk selection (from the build profile) ---
__DISK_SETUP__
+218
View File
@@ -0,0 +1,218 @@
#!/bin/bash
#===============================================================================
# build-felhom-iso.sh — R-21 slice A: turn the official PVE ISO into a Felhom auto-install ISO.
#
# 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.
#
# 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 to the felhom-iso-assistant container.
#===============================================================================
set -euo pipefail
ISO_VERSION="1.16.0" # Felhom release the ISO is tagged to (aligns with felhom-host-install SCRIPT_VERSION).
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; }
PVE_ISO=""; ISO_SHA256=""; PROFILE=""; BOOTSTRAP_ENV=""; OUT_DIR="${HOME}/felhom-iso/out"; PVE_VERSION=""; DRY_RUN=false
usage() {
cat <<EOF
Usage: build-felhom-iso.sh --pve-iso PATH --iso-sha256 SHA --profile FILE --bootstrap-env FILE [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
--bootstrap-env FILE the in-ISO /etc/felhom/bootstrap.env (SECRET-BEARING: retrieval passphrase).
Must define FELHOM_CUSTOMER_ID, FELHOM_MODE, FELHOM_RETRIEVAL_PASSPHRASE.
Options:
--out DIR output directory (default: \$HOME/felhom-iso/out)
--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 ;;
--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
[[ -n "$PVE_ISO" ]] || die "--pve-iso is required"
[[ -n "$ISO_SHA256" ]] || die "--iso-sha256 is required"
[[ -n "$PROFILE" ]] || die "--profile is required"
[[ -n "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env is required"
[[ -f "$PVE_ISO" ]] || die "--pve-iso not found: $PVE_ISO"
[[ -f "$PROFILE" ]] || die "--profile not found: $PROFILE"
[[ -f "$BOOTSTRAP_ENV" ]] || die "--bootstrap-env not found: $BOOTSTRAP_ENV"
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 ----------------------------------------------------------------------
log_step "loading profile: $PROFILE"
FELHOM_FQDN=""; FELHOM_DISK_SETUP=""; FELHOM_ROOT_SSH_KEY=""
# 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}\"]"
# --- validate bootstrap-env (secret-bearing detection) --------------------------------------------
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"
# --- workspace ------------------------------------------------------------------------------------
WORK="$(mktemp -d "${TMPDIR:-/tmp}/felhom-iso.XXXXXX")"
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT
mkdir -p "$OUT_DIR" "$WORK/tmp"
ISO_DIR="$(cd "$(dirname "$PVE_ISO")" && pwd)"; ISO_BASE="$(basename "$PVE_ISO")"
# --- mint fresh THROWAWAY root hash ---------------------------------------------------------------
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")"
unset ROOT_PLAIN
[[ -n "$ROOT_HASH" ]] || die "failed to mint root hash"
log_info "throwaway root hash written to the answer file (value stored out-of-band, not logged)"
# --- render answer.toml (pure bash param-expansion; no metachar hazards) ---------------------------
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"
# --- prepare-iso ----------------------------------------------------------------------------------
OUT_ISO="$OUT_DIR/felhom-pve-${PVE_VERSION}-v${ISO_VERSION}-${PROFILE_NAME}.iso"
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"
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
[[ -f "$WORK/out.iso" ]] || die "prepare-iso produced no output"
cp "$WORK/out.iso" "$OUT_ISO"
# --- 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"
cat > "$OUT_ISO.manifest.txt" <<EOF
Felhom bare-metal ISO build manifest (R-21 slice A)
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}
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} (embeds the customer retrieval passphrase — supervised/single-use, delete after the run)
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_warn "SECRET-BEARING ISO (embeds the retrieval passphrase). Supervised/single-use; never distribute; delete after the run."
+25
View File
@@ -0,0 +1,25 @@
[Unit]
Description=Felhom host bootstrap (fetch + run felhom-host-install.sh unattended, retry until success)
Documentation=https://felhom.eu/documentation/backlog/ROADMAP.md
# Network-dependent work lives HERE (not in the first-boot stub) per spike S8a: the stub is
# exactly-once and network-independent; this unit retries forever until host-install exits 0.
After=network-online.target pve-cluster.service pveproxy.service
Wants=network-online.target
# Second exactly-once lock (the script also writes/checks this flag): once host-install succeeds,
# the flag exists and this unit becomes a no-op even if re-enabled.
ConditionPathExists=!/etc/felhom/.bootstrap-done
# Retry forever: no start-rate limit.
StartLimitIntervalSec=0
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/felhom-bootstrap.sh
# Retry every 30s on any non-zero exit (fetch failed, host-install failed mid-run, no network yet).
Restart=on-failure
RestartSec=30
# Journal-only logging (no secret ever printed; keys-not-values).
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+98
View File
@@ -0,0 +1,98 @@
#!/bin/bash
#===============================================================================
# felhom-bootstrap.sh — invoked by felhom-bootstrap.service, retried until host-install succeeds.
#
# One attempt: read /etc/felhom/bootstrap.env -> fetch felhom-host-install.sh from the PUBLIC
# distribution channel (hub install-command Option-1 URL) -> run it unattended with the customer's
# retrieval passphrase -> on rc 0 write the done-flag + disable the unit; else exit non-zero so the
# unit retries. Journal-only logging; the passphrase is never echoed and lives only in a 0600 tmpfs
# file for the duration of one host-install invocation.
#
# Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume
# safe — its producer steps (token/enroll/grows) re-run every pass, so a resumed install repopulates
# hub.host_id/proxmox.token and never writes a crash-loop config. A plain re-invoke over an existing
# install state, by contrast, would re-hit the populated-host leaf guard / existing-vmid refusal.
# Therefore: FIRST attempt is plain; any later attempt that finds the install state file adds
# --resume. (--mode is required in both forms.) State file: /var/lib/felhom-install/state.json.
#
# NOT production-generic: this is the R-21 bare-metal first-boot bootstrap. It does NOT modify
# felhom-host-install.sh; it only invokes it.
#===============================================================================
# Deliberately NOT `set -e`: we must capture host-install's exit code and exit on our own terms.
set -uo pipefail
ENV_FILE=/etc/felhom/bootstrap.env
DONE_FLAG=/etc/felhom/.bootstrap-done
STATE_FILE=/var/lib/felhom-install/state.json
PASS_FILE=/run/felhom-bootstrap-pass
SCRIPT_TMP=/run/felhom-host-install.sh
log() { echo "felhom-bootstrap: $*"; }
cleanup_pass() { [[ -e "$PASS_FILE" ]] && { shred -u "$PASS_FILE" 2>/dev/null || rm -f "$PASS_FILE"; }; return 0; }
trap cleanup_pass EXIT
# Belt-and-suspenders: the unit already has ConditionPathExists=!done, but guard here too.
if [[ -e "$DONE_FLAG" ]]; then
log "done-flag present ($DONE_FLAG) — nothing to do"
exit 0
fi
# --- env ------------------------------------------------------------------------------------------
if [[ ! -r "$ENV_FILE" ]]; then
log "ERROR: $ENV_FILE missing or unreadable — cannot bootstrap (no guessed defaults)"
exit 1
fi
# shellcheck disable=SC1090
source "$ENV_FILE"
for var in FELHOM_CUSTOMER_ID FELHOM_MODE FELHOM_RETRIEVAL_PASSPHRASE; do
if [[ -z "${!var:-}" ]]; then
log "ERROR: $var is unset/empty in $ENV_FILE — refusing to guess"
exit 1
fi
done
HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
EXTRA_ARGS="${FELHOM_EXTRA_ARGS:-}"
# --- fetch host-install (public channel) ----------------------------------------------------------
log "fetching host-install: $INSTALL_URL"
if ! curl -fsSL --max-time 60 "$INSTALL_URL" -o "$SCRIPT_TMP"; then
log "ERROR: host-install fetch failed (no network yet?) — unit will retry"
exit 1
fi
if [[ ! -s "$SCRIPT_TMP" ]]; then
log "ERROR: fetched host-install is empty — unit will retry"
exit 1
fi
# --- retrieval passphrase -> 0600 tmpfs file ------------------------------------------------------
( umask 077; printf '%s' "$FELHOM_RETRIEVAL_PASSPHRASE" > "$PASS_FILE" )
# --- retry-vs-resume ruling -----------------------------------------------------------------------
args=(--customer-id "$FELHOM_CUSTOMER_ID" --mode "$FELHOM_MODE" --hub-url "$HUB_URL" --passphrase-file "$PASS_FILE")
if [[ -f "$STATE_FILE" ]]; then
log "prior install state present ($STATE_FILE) -> adding --resume (host-install v1.11.3: producers re-run, safe)"
args+=(--resume)
fi
# EXTRA_ARGS are profile-only flags (never secrets); intentional word-split.
read -ra extra <<< "$EXTRA_ARGS"
log "running host-install (customer=${FELHOM_CUSTOMER_ID} mode=${FELHOM_MODE} hub=${HUB_URL})"
bash "$SCRIPT_TMP" "${args[@]}" "${extra[@]}"
rc=$?
cleanup_pass
if [[ $rc -eq 0 ]]; then
log "host-install SUCCESS — writing done-flag, disabling unit, scrubbing env"
install -d -m 0755 "$(dirname "$DONE_FLAG")"
: > "$DONE_FLAG"; chmod 0644 "$DONE_FLAG"
systemctl disable felhom-bootstrap.service 2>/dev/null || true
# Reduce secret-at-rest: the box is enrolled; the passphrase is no longer needed.
shred -u "$ENV_FILE" 2>/dev/null || rm -f "$ENV_FILE"
exit 0
fi
log "host-install FAILED rc=${rc} — unit will retry in 30s"
exit "$rc"
@@ -0,0 +1,12 @@
# Felhom ISO build profile — nested-canary (Scenario D: disk-filter fail-safe regression).
#
# A udev filter that matches NOTHING. Proves the pipeline preserves the spike-proven S5c semantics:
# the installer aborts (exit 1, "filter did not match any device") and touches NO disk — verified by
# a host-side canary sha256 that stays byte-identical. NOT for real installs.
FELHOM_FQDN="felhom-host.local"
FELHOM_DISK_SETUP='[disk-setup]
filesystem = "ext4"
filter-match = "all"
filter.ID_SERIAL_SHORT = "no-such-disk-zzz"'
+12
View File
@@ -0,0 +1,12 @@
# Felhom ISO build profile — nested-vm (VM 310 validation; N100-agnostic).
#
# A profile is a sourceable shell fragment providing the answer's fqdn + [disk-setup] block.
# It carries NO secret. Scenario-A validation uses disk-list=["sda"] (the nested VM's single
# target disk). For the physical N100 (RUNBOOK B) use a unique-serial udev filter harvested from
# the pre-wipe udev dump instead — see README.md "N100 profile".
FELHOM_FQDN="felhom-host.local"
FELHOM_DISK_SETUP='[disk-setup]
filesystem = "ext4"
disk-list = ["sda"]'
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
#===============================================================================
# stub-first-boot.sh — the ONE executable the Proxmox auto-installer runs on first boot
# (--on-first-boot, [first-boot] source=from-iso, ordering=fully-up). This file is a SKELETON:
# build-felhom-iso.sh fills the three base64 markers below (bootstrap script, unit, env) and passes
# the RENDERED result to prepare-iso. The rendered stub is secret-bearing (the env carries the
# retrieval passphrase); the committed skeleton is not.
#
# DUMB BY DESIGN: the first-boot hook is exactly-once regardless of success (spike S3 x S8a), so this
# stub does NO network and NO fallible logic — it only lays down the retry unit + env and starts it.
# Everything that can fail lives in felhom-bootstrap.service, which retries forever.
#===============================================================================
set -euo pipefail
LOG=/var/log/felhom-first-boot.log
exec >>"$LOG" 2>&1
echo "=== felhom stub-first-boot $(date -Is) uid=$(id -u) ==="
install -d -m 0755 /etc/felhom /usr/local/sbin
# --- bootstrap script (no secret; world-readable ok) ----------------------------------------------
base64 -d > /usr/local/sbin/felhom-bootstrap.sh <<'__B64_BOOTSTRAP_SH__'
@@BOOTSTRAP_SH_B64@@
__B64_BOOTSTRAP_SH__
chmod 0755 /usr/local/sbin/felhom-bootstrap.sh
# --- systemd retry unit ---------------------------------------------------------------------------
base64 -d > /etc/systemd/system/felhom-bootstrap.service <<'__B64_BOOTSTRAP_UNIT__'
@@BOOTSTRAP_UNIT_B64@@
__B64_BOOTSTRAP_UNIT__
chmod 0644 /etc/systemd/system/felhom-bootstrap.service
# --- bootstrap env (SECRET-BEARING: retrieval passphrase) -> 0600 ---------------------------------
umask 077
base64 -d > /etc/felhom/bootstrap.env <<'__B64_BOOTSTRAP_ENV__'
@@BOOTSTRAP_ENV_B64@@
__B64_BOOTSTRAP_ENV__
chmod 0600 /etc/felhom/bootstrap.env
umask 022
systemctl daemon-reload
systemctl enable felhom-bootstrap.service
systemctl start --no-block felhom-bootstrap.service
echo "=== felhom stub-first-boot done — felhom-bootstrap enabled + started ==="
exit 0