scripts/iso R-59+R-60: the first-boot network gate — refuse loudly, sweep the NICs, never silently unreachable (v1.24.0 train, part 2/3)

network_gate() runs before mode dispatch. Hub reachable -> return with
ZERO new behavior (asserted). Unreachable -> diagnose (physical-NIC
table with raise-first carrier read; installer 192.168.100.2-fallback
signature named when present), then — ONLY while no install state file
exists, doubly confined by the unit's ConditionPathExists=!done-flag —
sweep: per candidate re-point vmbr0's bridge-ports (atomic tmp+mv,
always derived from the pristine copy) -> ifreload -> bounded dhclient
(20s) -> hub probe (any HTTP status = reachable; the hub answers 302).
First success persists winner + DHCP addressing (original kept as
interfaces.felhom-bak) and proceeds; total failure restores the config
BYTE-IDENTICALLY, paints a calm Hungarian console screen (spec copy,
print_pairing_banner pattern) and retries every 60s with the unit held
in activating — waiting is not failing (v1.21.0). Trigger is always
hub-unreachable, never the fallback signature (spike F-P6: the
truth-changed-after-install variant has no signature). Design inputs:
SPIKE-firstboot-nic-sweep-2026-07-22 F-P1..F-P8.

Harness: +4 scenarios (G1 zero-behavior in D incl. zero-sleeps +
fixture-untouched, G2 sweep-success/persist/proceed, G3 sweep-fail
byte-identical + screen content, G4 state-file-present = never sweep),
gate seams (FELHOM_NET_SYS/INTERFACES_FILE/CONSOLE_DEV), ip/ifreload/
dhclient fakes, hub-probe oracle incl. follow:<nic> mode. Red-proofs
run and restored: (A) restore-on-failure dropped -> G3 byte-identical
FAILs; (B) state gate removed -> G4 zero-call FAILs; (C) early return
removed -> all five G1 invariants FAIL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
This commit is contained in:
2026-07-22 10:59:33 +02:00
parent c043ba9abe
commit cf1862c8ab
2 changed files with 342 additions and 4 deletions
+196
View File
@@ -27,6 +27,14 @@
# — 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.
#===============================================================================
@@ -86,6 +94,191 @@ 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
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
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"
{ 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).
# =====================================================================================================
@@ -306,6 +499,9 @@ emit("FELHOM_EXTRA_ARGS", d.get("extra_args"))
done
}
# --- 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