Files
felhom.eu/scripts/iso/felhom-bootstrap.sh
T
admin eb1ae37095
gates / gates (push) Successful in 23s
ISO 1.29.0 source + an English download page (R-559 slice 4, source only)
THE IMAGE IS NOT BUILT AND NOT PUBLISHED BY THIS COMMIT. Publishing to
iso.felhom.eu is public and irreversible and its runbook requires a proof
install on BOTH menu entries plus the 16-criterion gate run against the exact
uploaded bytes. That is the operator's step. The download pages therefore still
name 1.28.0 - the image that is actually published - and a new site gate
refuses the two pages naming different files or hashes.

Three texts a person meets before any dashboard become bilingual: Hungarian
block first, byte for byte as before, then English, inside the same frame. The
pairing banner, the bound banner, /etc/issue (and the postinst's byte-coupled
copy), plus an English half on the GRUB entries.

The Hungarian is a GOLDEN, not a grep: test/golden/*.hu.txt were captured from
the script at 183727db9c before one English line existed, and the harness
asserts each banner's first N lines are exactly the golden. Red-proofed by one
changed byte, by an "a" planted in the English block, and by an over-wide line.

R-586, found on the way in: running the harness UNCHANGED at the base commit
failed two R-496 checks. The script paints with `>`, which truncates a FILE but
is a no-op on a console device; ISO 1.28.0's new bound banner (c033b3b) paints
straight after the pairing one and wiped it before the check read it. c033b3b
did not touch the harness, and nobody saw it because the harness is in no gate
and no CI run. Fixed with a FIFO; production code untouched. The harness being
ungated is still open.

The release gate's G16 required every Felhom string to be Hungarian and would
have STOPPED this publication. Operator ruling 1b of 2026-09-17 supersedes that
scope, so G16 is rewritten rather than waived: Hungarian FIRST, pinned by the
golden, each secret named once per language.

letoltes.html changes by four lines only. The English link is not in the nav -
the nav is a shared block site_gates.py pins across every page, and the gate
convicted the first attempt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 17:40:29 +02:00

613 lines
32 KiB
Bash

#!/bin/bash
#===============================================================================
# felhom-bootstrap.sh — invoked by felhom-bootstrap.service, retried until host-install succeeds.
#
# ONE unit, TWO modes, decided by the env:
# DIRECT (env has FELHOM_CUSTOMER_ID + FELHOM_RETRIEVAL_PASSPHRASE) — the slice-A path, unchanged:
# fetch felhom-host-install.sh from the PUBLIC channel -> run it unattended with the
# customer's retrieval passphrase -> on rc 0 write the done-flag + disable + shred the env.
# PAIRING (R-21 slice C — the GENERIC secret-free ISO, no customer-id/passphrase in the env):
# register this box as an UNCLAIMED appliance at the hub (uuid + MAC set + SSH host keys +
# hw), receive a one-per-registration APPLIANCE TOKEN (0600), then POLL for the operator's
# bind. ONE delivery hands over customer-id + retrieval passphrase; the bootstrap WRITES
# them into the env (0600) and FALLS THROUGH to the DIRECT path — so every later retry is a
# plain direct install (the delivery is one-shot; the box must not depend on re-fetching it).
#
# The PAIRING wait polls INSIDE this script (v1.21.0, R-33). It used to be systemd's
# Restart=on-failure/RestartSec=30 — one poll per invocation, exiting non-zero until the bind landed.
# That worked, but every 30s systemd printed `Failed to start Felhom host bootstrap` on the physical
# console the CUSTOMER is watching: 52 FAILED lines in ~11 minutes during the 2026-07-18 rehearsal,
# while nothing was wrong (the box was correctly waiting to be bound). Waiting is not failing, so it
# must not be reported as failure. The unit now stays in `activating` and the loop sleeps between
# polls; the unit's Restart= machinery is kept for the DIRECT path and for genuine crashes.
# REQUIRES TimeoutStartSec=infinity in the unit — a Type=oneshot ExecStart is otherwise killed at
# DefaultTimeoutStartSec (90s), which would silently reintroduce the restart spam.
#
# Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume safe
# — its producer steps re-run every pass. FIRST direct attempt is plain; any later attempt that finds
# the install state file adds --resume. State file: /var/lib/felhom-install/state.json.
#
# R-59/R-60 (v1.24.0) — the FIRST-BOOT NETWORK GATE runs before mode dispatch: if the hub is
# unreachable, the box refuses to wait silently. It diagnoses (physical-NIC table, installer
# 192.168.100.2-fallback signature), SWEEPS the NICs while no install attempt has begun (re-point
# vmbr0 -> bounded DHCP -> hub probe; mechanics + bounds measured in
# documentation/audits/SPIKE-firstboot-nic-sweep-2026-07-22.md), and otherwise paints a legible
# Hungarian screen on the console and retries every minute — the unit stays `activating` and never
# exits non-zero while waiting (the v1.21.0 lesson: waiting is not failing).
#
# 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 exit codes 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
TOKEN_FILE=/etc/felhom/appliance-token # PAIRING: the box's only pre-day-0 credential (0600, persists reboots)
PAIRING_CODE_FILE=/etc/felhom/appliance-pairing-code # R-27: non-secret pairing code shown on the console
# PAIRING wait cadence (v1.21.0, R-33). POLL_INTERVAL keeps the hub-side rate identical to the old
# RestartSec=30, so nothing downstream changes; the other two only govern how often we SPEAK.
POLL_INTERVAL=30 # seconds between polls
BANNER_EVERY=10 # re-print the console banner every N cycles (10 x 30s = 5 min)
HEARTBEAT_EVERY=20 # journal heartbeat every N cycles (20 x 30s = 10 min)
log() { echo "felhom-bootstrap: $*"; }
# print_pairing_banner (R-27, v0.66.0) — show the pairing code prominently on the physical console while
# the box waits to be bound, so the customer can read it into the self-bind page. Non-secret (the bind
# still requires the customer's retrieval passphrase). A hub older than v0.66.0 sends no code → no banner
# (the box stays operator-bind-only — graceful, no behavior change).
print_pairing_banner() {
local code; code=$(cat "$PAIRING_CODE_FILE" 2>/dev/null)
[[ -n "$code" ]] || return 0
set_console_font # R-63: ő/ű-capable font before painting (once)
{ printf '\n================================================\n'
printf ' Felhom — a doboz készen áll, és a párosításra vár.\n\n'
printf ' Párosító kód: %s\n\n' "$code"
printf ' Nyisd meg az e-mailben kapott linket, és add meg\n'
printf ' ezt a kódot és a Tulajdonosi jelmondatodat\n'
printf ' (az 5 szót a Felhom üzemeltetőjétől kaptad).\n\n'
printf ' Ez a képernyő magától frissül — nincs teendő a\n'
printf ' doboznál, és nyugodtan itt hagyhatod bekapcsolva.\n\n'
printf ' Pairing code: %s\n\n' "$code"
printf ' Open the link from your e-mail and enter this code\n'
printf ' together with your Owner passphrase (the 5 words you\n'
printf ' received from your Felhom operator).\n\n'
printf ' This screen refreshes itself. There is nothing to do\n'
printf ' at the box; you can leave it switched on.\n'
printf '================================================\n\n'
} > "$CONSOLE_DEV" 2>/dev/null || printf 'Párosító kód / Pairing code: %s\n' "$code"
}
# print_bound_banner (R-535, v1.28.0) — the pairing banner is the LAST thing the console shows, and it
# stays there. Measured 2026-09-16 on a fresh box: 25 minutes after a successful bind AND claim, with
# four apps deploying, the monitor still read „a doboz keszen all, es a parosisara var" with a pairing
# code, under a line promising the screen refreshes itself. A volunteer watching that has no way to
# tell the box is finished, and waiting does not help.
#
# WHAT THIS CAN AND CANNOT SAY. The bind delivery carries the customer id, the passphrase and the mode
# — it does NOT carry the domain (see the emit() block in the 200 branch), so this banner cannot name
# the dashboard URL without inventing one. It says the true thing it knows: the box is connected and
# the setup is running. The address is in the e-mail the customer already has.
print_bound_banner() {
set_console_font
{ printf '\n================================================\n'
printf ' Felhom — a doboz össze van kötve. ✔\n\n'
printf ' A beállítás magától folytatódik, ez néhány percig tart.\n'
printf ' A vezérlőpult címét az e-mailben kapott levél tartalmazza.\n\n'
printf ' Ezen a gépen nincs több teendőd.\n\n'
printf ' Felhom — the box is linked.\n\n'
printf ' Setup carries on by itself; this takes a few minutes.\n'
printf ' Your dashboard address is in the e-mail you received.\n\n'
printf ' There is nothing left to do on this machine.\n'
printf '================================================\n\n'
} > "$CONSOLE_DEV" 2>/dev/null || printf 'A doboz össze van kötve. / The box is linked.\n'
}
# install_felhom_issue (R-496, v1.27.0) — the text above the console login prompt is Felhom's, not
# Proxmox's. Measured 2026-09-14 (drill screen s29): the first thing a household read on a fresh box was
# `Welcome to the Proxmox Virtual Environment … connect to https://<ip>:8006/` — the operator admin UI,
# in English. pvebanner.service REWRITES /etc/issue on every boot (/usr/bin/pvebanner opens it '>'), so
# overwriting the file alone would last one boot: the unit is MASKED, then the file is written.
# Best-effort in every step — a banner must never block the boot or the pairing.
# v1.27.1: the package postinst now does the same two acts at INSTALL time (the 1.27.0 proof install showed
# pvebanner running at the first boot before this function), so this is the second line of defence. The text
# must stay BYTE-IDENTICAL to the postinst's (harness PI pins it) and must avoid ő/ű: the login screen is
# painted before the Latin-2 console font loads, and 1.27.0 rendered „képernyőn" as „képernyon".
ISSUE_FILE="${FELHOM_ISSUE_FILE:-/etc/issue}"
install_felhom_issue() {
if command -v systemctl >/dev/null 2>&1; then
if systemctl mask pvebanner.service >/dev/null 2>&1; then
log "console: pvebanner.service masked (it would rewrite $ISSUE_FILE with the Proxmox admin URL on every boot)"
else
log "console: could not mask pvebanner.service — the Proxmox banner may return on the next boot"
fi
fi
local tmp="${ISSUE_FILE}.felhom-tmp"
if { printf '\n'
printf ' Felhom otthoni szerver\n\n'
printf ' Ezen a gépen most nincs dolgod, és bejelentkezni sem kell.\n'
printf ' A beállításhoz kövesd a Felhomtól kapott útmutatót.\n\n'
printf ' Felhom home server\n\n'
printf ' There is nothing to do on this machine, and no need to log in.\n'
printf ' Follow the guide you received from Felhom.\n\n'
} > "$tmp" 2>/dev/null && mv -f "$tmp" "$ISSUE_FILE" 2>/dev/null; then
log "console: $ISSUE_FILE is the Felhom text (no admin URL)"
else
rm -f "$tmp" 2>/dev/null
log "console: could not write $ISSUE_FILE — continuing"
fi
command -v agetty >/dev/null 2>&1 && agetty --reload >/dev/null 2>&1
return 0
}
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 (may be absent in the generic ISO; a non-secret pairing env can still set FELHOM_HUB_URL) --
FELHOM_CUSTOMER_ID=""; FELHOM_MODE=""; FELHOM_RETRIEVAL_PASSPHRASE=""
FELHOM_HUB_URL=""; FELHOM_INSTALL_URL=""; FELHOM_EXTRA_ARGS=""
if [[ -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
# =====================================================================================================
# R-59/R-60 — the first-boot network gate (v1.24.0). Design inputs: SPIKE-firstboot-nic-sweep
# (F-P1..F-P8). The TRIGGER is always "hub unreachable" — never the 192.168.100.2 signature (a
# wrong-NIC box also exists as "leased at install, truth changed after": plausible static config,
# no fallback signature). The signature is diagnosis detail for the log + screen.
# =====================================================================================================
# Seams (the PATH-fake harness points these at fixtures; production = the defaults):
NET_SYS="${FELHOM_NET_SYS:-/sys/class/net}"
INTERFACES_FILE="${FELHOM_INTERFACES_FILE:-/etc/network/interfaces}"
CONSOLE_DEV="${FELHOM_CONSOLE_DEV:-/dev/console}"
GATE_RETRY_INTERVAL=60 # the screen promises "a doboz percenként újra próbálkozik"
SWEEP_DHCP_TIMEOUT=20 # F-P1/F-P4: a real lease lands in ~3s; a dead NIC never returns on its own
HUB_PROBE_TIMEOUT=10
GATE_ORIG_COPY=/run/felhom-interfaces.orig
# R-63 (v1.25.0): the kernel's default console font lacks the Hungarian double-acute ő/ű glyphs, so
# the R-59 network screen (élő / telepítő / ellenőrizze) and the pairing banner (képernyő / teendő)
# rendered them as blanks. Load a Latin-2 console font ONCE before the first paint — idempotent and
# strictly best-effort: a missing font or an ioctl failure (e.g. a serial console) must NEVER block
# the boot. Lat2 fonts ship in the trixie/PVE base (console-setup), so no copy rewording is needed.
FONT_SET=0
set_console_font() {
[[ "$FONT_SET" == 1 ]] && return 0
FONT_SET=1
command -v setfont >/dev/null 2>&1 || return 0
local fnt
for fnt in Lat2-Terminus16 Lat2-Fixed16 Lat2-Terminus14; do
if setfont "$fnt" -C "$CONSOLE_DEV" >/dev/null 2>&1 || setfont "$fnt" >/dev/null 2>&1; then
log "console font -> $fnt (Latin-2, ő/ű capable)"
return 0
fi
done
log "console font: no Latin-2 font loaded (setfont unavailable/failed) — accented chars may show as boxes"
}
hub_reachable() {
# F-P5: ANY HTTP status proves TLS+HTTP reachability (the hub answers / with a 302); only
# 000/empty means no contact.
local code
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time "$HUB_PROBE_TIMEOUT" "$HUB_URL/" 2>/dev/null)
[[ -n "$code" && "$code" != "000" ]]
}
physical_nics() {
# Physical NICs only — same rule as gather_identity_json, plus the explicit name excludes.
local d n
for d in "$NET_SYS"/*; do
[[ -e "$d" ]] || continue
n=$(basename "$d")
case "$n" in lo|vmbr*|veth*|tap*|fwln*|fwpr*) continue ;; esac
[[ -e "$d/device" ]] || continue
echo "$n"
done
}
nic_diag_table() {
# F-P2: unused NICs sit admin-DOWN and their carrier is unreadable while down — raise them
# first, settle once, then read. (The sweep itself needs none of this: ifreload raises the
# configured port on its own. This is for the human-facing table.)
local n raised=0
for n in $(physical_nics); do ip link set "$n" up 2>/dev/null && raised=1; done
[[ $raised -eq 1 ]] && sleep 2
local mac carrier speed cable
for n in $(physical_nics); do
mac=$(cat "$NET_SYS/$n/address" 2>/dev/null || echo '?')
carrier=$(cat "$NET_SYS/$n/carrier" 2>/dev/null || echo '')
speed=$(cat "$NET_SYS/$n/speed" 2>/dev/null || echo '')
case "$carrier" in 1) cable="van";; 0) cable="nincs";; *) cable="?";; esac
if [[ "$speed" =~ ^[0-9]+$ ]]; then speed="${speed} Mb/s"; else speed="?"; fi
printf ' %-12s %-18s kábel: %-6s %s\n' "$n" "$mac" "$cable" "$speed"
done
}
fallback_signature_present() {
# The installer's no-DHCP fallback baked as static (the demo-hp shape, R-59).
grep -Eq '^[[:space:]]*address[[:space:]]+192\.168\.100\.2(/|[[:space:]]|$)' "$INTERFACES_FILE" 2>/dev/null
}
current_bridge_port() {
awk '/^auto vmbr0$/{f=1} f && /bridge-ports/{print $2; exit}' "$INTERFACES_FILE" 2>/dev/null
}
render_candidate_interfaces() {
# Probe shape for candidate $1: ONLY bridge-ports changes, always derived from the PRISTINE
# copy (candidates never stack). The static stanza stays — dhclient simply adds the leased
# address next to it (proven in the spike), so a working static survives the probe untouched.
sed -E "s/^([[:space:]]*bridge-ports[[:space:]]+).*/\1$1/" "$GATE_ORIG_COPY"
}
persist_winner_interfaces() {
# F-P7: the winner is by definition a leasing NIC — persist bridge-ports + DHCP addressing.
# The static lines are dropped: in the fallback variant they are garbage (192.168.100.2), and
# in the truth-changed variant DHCP re-acquires the same router anyway.
awk -v nic="$1" '
/^iface vmbr0 inet/ { print "iface vmbr0 inet dhcp"; invmbr=1; next }
/^(iface|auto|source)/ { invmbr=0 }
invmbr && /^[[:space:]]+(address|gateway)[[:space:]]/ { next }
{ if (invmbr && $1=="bridge-ports") $0="\tbridge-ports " nic; print }
' "$GATE_ORIG_COPY"
}
sweep_nics() {
# R-60. The caller guarantees BOTH gates: hub unreachable AND no install state file — never
# re-shuffle NICs once an install attempt has begun. Structurally this whole path is first-boot
# only twice over: the state-file gate here, and the unit's
# ConditionPathExists=!/etc/felhom/.bootstrap-done confining the entire script to the
# pre-done lifetime.
cp -a "$INTERFACES_FILE" "$GATE_ORIG_COPY" || return 1
local cur n
cur=$(current_bridge_port)
# Candidate order: the current port first (a fresh lease on the same port heals a
# subnet/addressing change), then the rest. F-P3: carrier only orders, DHCP + hub-probe decide.
local ordered=()
[[ -n "$cur" ]] && ordered+=("$cur")
for n in $(physical_nics); do [[ "$n" == "$cur" ]] || ordered+=("$n"); done
[[ ${#ordered[@]} -gt 0 ]] || { rm -f "$GATE_ORIG_COPY"; return 1; }
for n in "${ordered[@]}"; do
log "network sweep: trying vmbr0 -> $n (bounded DHCP ${SWEEP_DHCP_TIMEOUT}s + hub probe)"
render_candidate_interfaces "$n" > "${INTERFACES_FILE}.felhom-tmp" \
&& mv "${INTERFACES_FILE}.felhom-tmp" "$INTERFACES_FILE"
ifreload -a 2>/dev/null
# Drill finding (2026-07-22, nested leg): the installer's fallback bakes a DEFAULT ROUTE via
# 192.168.100.1, and dhclient-script does not replace an existing default route — the probe
# then rides the dead gateway and fails even though the lease landed. A candidate must be
# judged on the lease's OWN addressing/routing, so clear vmbr0 first; the restore path (and
# any next candidate) re-applies the configured state via ifreload.
ip addr flush dev vmbr0 2>/dev/null
ip route flush dev vmbr0 2>/dev/null
timeout "$SWEEP_DHCP_TIMEOUT" dhclient -1 vmbr0 2>/dev/null
local drc=$?
if [[ $drc -eq 0 ]] && hub_reachable; then
local mac; mac=$(cat "$NET_SYS/$n/address" 2>/dev/null || echo '?')
# SUCCESS-ONLY persist (atomic tmp+mv; the original survives as interfaces.felhom-bak).
cp -a "$GATE_ORIG_COPY" "${INTERFACES_FILE}.felhom-bak"
persist_winner_interfaces "$n" > "${INTERFACES_FILE}.felhom-tmp" \
&& mv "${INTERFACES_FILE}.felhom-tmp" "$INTERFACES_FILE"
log "network self-heal: vmbr0 -> $n ($mac), hub reachable"
rm -f "$GATE_ORIG_COPY"
return 0
fi
pkill -x dhclient 2>/dev/null # F-P4: clear the failed/killed client before the next candidate
done
# Every candidate failed -> restore the pristine config BYTE-IDENTICALLY. A failed sweep must
# never leave a half-rewritten config behind (harness red-proofs this guard).
cp -a "$GATE_ORIG_COPY" "${INTERFACES_FILE}.felhom-tmp" && mv "${INTERFACES_FILE}.felhom-tmp" "$INTERFACES_FILE"
ifreload -a 2>/dev/null
rm -f "$GATE_ORIG_COPY"
return 1
}
paint_network_screen() {
# R-59: the legible refuse-loudly screen (print_pairing_banner pattern — /dev/console, stdout
# fallback). Calm adult Hungarian; spec-fixed copy.
local table="$1" fbnote="$2"
set_console_font # R-63: ő/ű-capable font before painting (once)
{ printf '\n================================================\n'
printf ' Felhom — Nincs hálózati kapcsolat: a doboz nem éri\n'
printf ' el a felhom.eu szolgáltatást.\n\n'
printf ' Hálózati csatolók:\n'
printf ' %-12s %-18s %-13s %s\n' 'név' 'MAC' 'kábel' 'sebesség'
printf '%s\n' "$table"
if [[ -n "$fbnote" ]]; then
printf '\n A telepítéskor egyik porton sem volt élő kapcsolat,\n'
printf ' ezért a doboz a telepítő tartalék-címén (192.168.100.2) áll.\n'
fi
printf '\n Csatlakoztassa a hálózati kábelt egy másik portba, vagy\n'
printf ' ellenőrizze a routert — a doboz percenként újra próbálkozik.\n'
printf '================================================\n\n'
} > "$CONSOLE_DEV" 2>/dev/null \
|| printf 'felhom: nincs hálózati kapcsolat — a doboz percenként újra próbálkozik.\n'
}
network_gate() {
# 1. Happy path FIRST, with ZERO new behavior: hub reachable -> return immediately. No ip, no
# dhclient, no ifreload, no interfaces read happens before this return (B'-style invariant,
# asserted by the harness).
if hub_reachable; then
return 0
fi
log "network gate: hub unreachable ($HUB_URL) — diagnosing (R-59)"
local table fbnote
while true; do
table=$(nic_diag_table)
fbnote=""
if fallback_signature_present; then
fbnote=yes
log "network gate: installer fallback signature (static 192.168.100.2 on vmbr0) — no NIC leased at install time"
fi
log "network gate: NIC status:"$'\n'"$table"
if [[ ! -e "$STATE_FILE" ]]; then
if sweep_nics; then
return 0
fi
log "network gate: sweep found no NIC that reaches the hub — console screen + retry every ${GATE_RETRY_INTERVAL}s"
else
# An install attempt exists: NEVER re-shuffle NICs under it — screen + retry only.
log "network gate: install already attempted ($STATE_FILE present) — no sweep, interfaces untouched; console screen + retry"
fi
paint_network_screen "$table" "$fbnote"
# Waiting is not failing (v1.21.0): the unit stays `activating`; we never exit non-zero
# here. A moved cable heals on the next cycle — via the plain probe (same port) or the
# sweep (different port).
sleep "$GATE_RETRY_INTERVAL"
if hub_reachable; then
log "network gate: hub reachable — proceeding"
return 0
fi
done
}
# =====================================================================================================
# DIRECT mode — fetch + run host-install with the customer passphrase (slice A, unchanged behaviour).
# =====================================================================================================
run_direct() {
for var in FELHOM_CUSTOMER_ID FELHOM_MODE FELHOM_RETRIEVAL_PASSPHRASE; do
if [[ -z "${!var:-}" ]]; then
log "ERROR: $var is unset/empty (direct mode) — refusing to guess"
exit 1
fi
done
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
( umask 077; printf '%s' "$FELHOM_RETRIEVAL_PASSPHRASE" > "$PASS_FILE" )
local 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
local extra
read -ra extra <<< "${FELHOM_EXTRA_ARGS:-}"
log "running host-install (customer=${FELHOM_CUSTOMER_ID} mode=${FELHOM_MODE} hub=${HUB_URL})"
bash "$SCRIPT_TMP" "${args[@]}" "${extra[@]}"
local rc=$?
cleanup_pass
if [[ $rc -eq 0 ]]; then
log "host-install SUCCESS — writing done-flag, disabling unit, scrubbing secrets"
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 (and the appliance token) are done.
shred -u "$ENV_FILE" 2>/dev/null || rm -f "$ENV_FILE"
[[ -e "$TOKEN_FILE" ]] && { shred -u "$TOKEN_FILE" 2>/dev/null || rm -f "$TOKEN_FILE"; }
exit 0
fi
log "host-install FAILED rc=${rc} — unit will retry in 30s"
exit "$rc"
}
# =====================================================================================================
# PAIRING mode — register the unclaimed appliance, then ONE poll per invocation until the bind delivers.
# =====================================================================================================
# gather_identity_json builds the registration payload. Keying is (SMBIOS UUID, MAC set) — the N100 DMI
# verdict is that serials are unusable ("Default string"), so only the uuid + physical MAC set are
# trusted; hw is a non-keyed summary. python3 ships with PVE and JSON-encodes robustly.
gather_identity_json() {
local uuid; uuid=$(tr -d '\n' < /sys/class/dmi/id/product_uuid 2>/dev/null)
local product; product=$(tr -d '\n' < /sys/class/dmi/id/product_name 2>/dev/null)
local mem_kb; mem_kb=$(awk '/MemTotal/{print $2}' /proc/meminfo 2>/dev/null)
local cpu; cpu=$(awk -F: '/model name/{print $2; exit}' /proc/cpuinfo 2>/dev/null | sed 's/^ *//')
local macs=()
local d n m
for d in /sys/class/net/*; do
n=$(basename "$d")
[[ "$n" == "lo" ]] && continue
[[ -e "$d/device" ]] || continue # physical NICs only (skip bridges/veth/wg)
m=$(cat "$d/address" 2>/dev/null)
[[ -n "$m" && "$m" != "00:00:00:00:00:00" ]] && macs+=("$m")
done
local keys=()
local f
for f in /etc/ssh/ssh_host_*_key.pub; do
[[ -f "$f" ]] && keys+=("$(cat "$f")")
done
UUID_G="$uuid" PRODUCT_G="$product" CPU_G="$cpu" MEM_G="$mem_kb" \
MACS_G="$(printf '%s\n' "${macs[@]}")" KEYS_G="$(printf '%s\n' "${keys[@]}")" \
python3 - <<'PY'
import json, os
def lines(v): return [x for x in (v or "").splitlines() if x.strip()]
print(json.dumps({
"uuid": os.environ.get("UUID_G",""),
"macs": lines(os.environ.get("MACS_G","")),
"ssh_host_pubkeys": lines(os.environ.get("KEYS_G","")),
"hw": {"product": os.environ.get("PRODUCT_G",""),
"cpu": os.environ.get("CPU_G",""),
"mem_kb": int(os.environ.get("MEM_G") or 0)},
}))
PY
}
register_appliance() {
# Register once and persist the token. Returns 0 on success (or if already registered), 1 on a
# transient failure the caller should simply retry — no network yet is the common case on a box
# that has only just booted, and it is not an error worth telling the customer about.
[[ -s "$TOKEN_FILE" ]] && return 0
local payload; payload=$(gather_identity_json)
if [[ -z "$payload" || "$payload" != *'"uuid"'* ]]; then
log "could not gather appliance identity yet — retrying"
return 1
fi
log "registering unclaimed appliance at the hub"
local resp; resp=$(curl -fsS --max-time 30 -X POST \
-H 'Content-Type: application/json' --data "$payload" \
"$HUB_URL/api/v1/appliance/register" 2>/dev/null)
if [[ $? -ne 0 || -z "$resp" ]]; then
log "registration did not go through (no network yet?) — retrying"
return 1
fi
local token; token=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("appliance_token",""))' 2>/dev/null)
if [[ -z "$token" ]]; then
log "registration returned no appliance token — retrying"
return 1
fi
( umask 077; printf '%s' "$token" > "$TOKEN_FILE" )
# R-27 (v0.66.0): persist the non-secret pairing code (absent on a pre-v0.66.0 hub — tolerated).
local pcode; pcode=$(printf '%s' "$resp" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("pairing_code",""))' 2>/dev/null)
if [[ -n "$pcode" ]]; then
printf '%s' "$pcode" > "$PAIRING_CODE_FILE"
fi
log "registered — appliance token stored (0600); waiting for the operator or a customer self-bind"
return 0
}
run_pairing() {
log "PAIRING mode (generic ISO, no baked customer/passphrase) — hub=$HUB_URL"
# The wait lives HERE, not in systemd's restart loop (v1.21.0, R-33). Waiting to be bound is the
# NORMAL state of a freshly installed box and must look like it — on the console and in the
# journal alike. Only a genuine crash should ever surface as a unit failure.
local cycle=0
while true; do
if ! register_appliance; then
sleep "$POLL_INTERVAL"; continue
fi
# Re-show the code periodically, not every cycle: the customer may walk up at any time, but a
# banner every 30s is its own kind of noise.
if (( cycle % BANNER_EVERY == 0 )); then
print_pairing_banner
fi
local token; token=$(cat "$TOKEN_FILE")
local body code
body=$(curl -sS --max-time 30 -o - -w '\n%{http_code}' \
-H "Authorization: Bearer $token" "$HUB_URL/api/v1/appliance/poll" 2>/dev/null)
code="${body##*$'\n'}"
body="${body%$'\n'*}"
case "$code" in
200)
log "bind DELIVERED — writing credentials to the env and switching to direct install"
print_bound_banner # R-535: replace the pairing code on the console with the truth
# Parse the one-shot delivery into shell-safe env assignments (never echo the passphrase).
local envtext
envtext=$(printf '%s' "$body" | python3 -c '
import json, sys, shlex
d = json.load(sys.stdin)
def emit(k, v): print("%s=%s" % (k, shlex.quote(v or "")))
emit("FELHOM_CUSTOMER_ID", d.get("customer_id"))
emit("FELHOM_RETRIEVAL_PASSPHRASE", d.get("retrieval_passphrase"))
emit("FELHOM_MODE", d.get("mode") or "appliance")
emit("FELHOM_EXTRA_ARGS", d.get("extra_args"))
')
if [[ -z "$envtext" || "$envtext" != *FELHOM_RETRIEVAL_PASSPHRASE=* ]]; then
log "ERROR: delivery parse failed — unit will retry"
exit 1
fi
# Persist as the direct-mode env (0600) so EVERY later retry is a plain direct install
# (the delivery was one-shot; a second poll returns 410).
install -d -m 0755 "$(dirname "$ENV_FILE")"
( umask 077
{ printf '%s\n' "$envtext"
printf 'FELHOM_HUB_URL=%q\n' "$HUB_URL"
printf 'FELHOM_INSTALL_URL=%q\n' "$INSTALL_URL"
} > "$ENV_FILE" )
chmod 0600 "$ENV_FILE"
# Re-source + fall through to the direct install in THIS same invocation.
# shellcheck disable=SC1090
source "$ENV_FILE"
run_direct
;; # run_direct exits
204)
# The expected state for as long as nobody has bound the box. Log it once, then only on
# a slow heartbeat — an operator reading the journal still sees liveness without the
# every-30s wall of text that made the real signal hard to find.
if (( cycle == 0 )); then
log "not bound yet — polling every ${POLL_INTERVAL}s until the operator or a customer self-bind lands (this is the normal waiting state, not an error)"
elif (( cycle % HEARTBEAT_EVERY == 0 )); then
log "still waiting to be bound ($(( cycle * POLL_INTERVAL / 60 ))m elapsed; polling continues)"
fi
;;
410)
# The delivery was consumed but we hold no env — a rare crash window. Exiting hands the
# box back to systemd, whose restart re-runs us from a clean slate.
log "ERROR: delivery already consumed but no local env — exiting so the unit restarts (rare crash-window)"
exit 1
;;
404)
if (( cycle % HEARTBEAT_EVERY == 0 )); then
log "appliance token not recognized (discarded, or the hub has no record) — still retrying"
fi
;;
*)
if (( cycle % HEARTBEAT_EVERY == 0 )); then
log "poll returned HTTP ${code:-none} — still retrying"
fi
;;
esac
sleep "$POLL_INTERVAL"
cycle=$(( cycle + 1 ))
done
}
# --- R-496: Felhom's text above the console login, before anything can wait on the network --------
install_felhom_issue
# --- R-59/R-60 first-boot network gate: never proceed silently into a hub-unreachable install ------
network_gate
# --- mode selection -------------------------------------------------------------------------------
if [[ -n "$FELHOM_CUSTOMER_ID" && -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]]; then
run_direct
else
run_pairing
fi