Files
felhom.eu/scripts/iso/felhom-bootstrap.sh
T
admin c033b3b617
gates / gates (push) Successful in 20s
ISO 1.28.0 source: the console stops showing the pairing code once bound (R-535)
Measured 2026-09-16: 25 minutes after a successful bind AND claim the console still
showed the pairing code under a line promising the screen refreshes itself.

print_bound_banner is printed the moment the bind delivery lands. It does NOT name
the dashboard URL: the one-shot delivery carries the customer id, passphrase and
mode, not the domain, so naming an address would mean inventing one. The residue —
the console still does not reflect the later CLAIM, because this unit has exited by
then — is recorded in the changelog rather than implied away.

Not published: the built image needs the release gate and the operator's yes.

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

600 lines
31 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'
printf '================================================\n\n'
} > "$CONSOLE_DEV" 2>/dev/null || printf 'Párosító kód: %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'
printf '================================================\n\n'
} > "$CONSOLE_DEV" 2>/dev/null || printf 'A doboz össze van kötve.\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'
} > "$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