eb1ae37095
gates / gates (push) Successful in 23s
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 at183727db9cbefore 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.c033b3bdid 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
613 lines
32 KiB
Bash
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
|