hub v0.62.0 + scripts v1.19.0 — R-21 slice C: the universal secret-free ISO
A generic ISO carries NO customer secret. The box registers itself at the hub as an unclaimed appliance; the operator binds it to a customer; the hub delivers the customer-id + retrieval passphrase ONCE; day-0 completes via the slice-A path. Hub (v0.62.0): - store/appliance.go: appliance_registrations keyed by (uuid, mac_set) — MAC set is the tiebreaker (duplicate SMBIOS UUIDs); token stored as sha256 only. Idempotent register (sticky-discard), atomic one-shot delivery, bind/discard. - api/appliance.go: POST /appliance/register (the one unauth endpoint, per-IP rate-limited, 256-bit token); GET /appliance/poll (404 no-oracle / 204 unbound / 200 deliver-once / 410 delivered). Passphrase read live, never logged. - web/appliances.go: Hosts-page "Unclaimed appliances" section + BIND (customer picker, host count display-only) + DISCARD; SSH host-key fingerprints; events. - Red-proofs: one-shot delivery + register idempotency (both proven red); 404-no-oracle, sticky-discard, bind staging, render. Green + confirm gate. Scripts (v1.19.0): - felhom-bootstrap.sh: ONE unit, TWO modes. Direct (env has customer/passphrase) = slice-A path, byte-identical, only branched around. Pairing (generic) = register + poll (RestartSec=30 is the poll timer); on delivery write the env 0600 and fall through to direct. Secrets + token shredded on success. - build-felhom-iso.sh --pairing: generic secret-free ISO, -generic filename, manifest mode=pairing. profiles/generic.profile (new). - test/bootstrap-modes.sh: Scenario D (direct = zero appliance calls) + pairing register/poll + delivery handoff — all green in a debian container.
This commit is contained in:
+212
-65
@@ -2,23 +2,29 @@
|
||||
#===============================================================================
|
||||
# felhom-bootstrap.sh — invoked by felhom-bootstrap.service, retried until host-install succeeds.
|
||||
#
|
||||
# One attempt: read /etc/felhom/bootstrap.env -> fetch felhom-host-install.sh from the PUBLIC
|
||||
# distribution channel (hub install-command Option-1 URL) -> run it unattended with the customer's
|
||||
# retrieval passphrase -> on rc 0 write the done-flag + disable the unit; else exit non-zero so the
|
||||
# unit retries. Journal-only logging; the passphrase is never echoed and lives only in a 0600 tmpfs
|
||||
# file for the duration of one host-install invocation.
|
||||
# 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).
|
||||
#
|
||||
# Retry-vs-resume (source-verified, encoded ONCE): felhom-host-install.sh v1.11.3 makes --resume
|
||||
# safe — its producer steps (token/enroll/grows) re-run every pass, so a resumed install repopulates
|
||||
# hub.host_id/proxmox.token and never writes a crash-loop config. A plain re-invoke over an existing
|
||||
# install state, by contrast, would re-hit the populated-host leaf guard / existing-vmid refusal.
|
||||
# Therefore: FIRST attempt is plain; any later attempt that finds the install state file adds
|
||||
# --resume. (--mode is required in both forms.) State file: /var/lib/felhom-install/state.json.
|
||||
# The poll loop IS systemd's Restart=on-failure/RestartSec=30: each invocation does register-if-needed
|
||||
# + exactly ONE poll, exiting non-zero (retry in 30s) until the bind delivers. This keeps every
|
||||
# invocation short (no long-running-oneshot timeout) and reuses the existing retry machinery.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# NOT production-generic: this is the R-21 bare-metal first-boot bootstrap. It does NOT modify
|
||||
# felhom-host-install.sh; it only invokes it.
|
||||
#===============================================================================
|
||||
# Deliberately NOT `set -e`: we must capture host-install's exit code and exit on our own terms.
|
||||
# Deliberately NOT `set -e`: we must capture exit codes and exit on our own terms.
|
||||
set -uo pipefail
|
||||
|
||||
ENV_FILE=/etc/felhom/bootstrap.env
|
||||
@@ -26,6 +32,7 @@ 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)
|
||||
|
||||
log() { echo "felhom-bootstrap: $*"; }
|
||||
|
||||
@@ -38,61 +45,201 @@ if [[ -e "$DONE_FLAG" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- env ------------------------------------------------------------------------------------------
|
||||
if [[ ! -r "$ENV_FILE" ]]; then
|
||||
log "ERROR: $ENV_FILE missing or unreadable — cannot bootstrap (no guessed defaults)"
|
||||
exit 1
|
||||
# --- 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
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
|
||||
for var in FELHOM_CUSTOMER_ID FELHOM_MODE FELHOM_RETRIEVAL_PASSPHRASE; do
|
||||
if [[ -z "${!var:-}" ]]; then
|
||||
log "ERROR: $var is unset/empty in $ENV_FILE — refusing to guess"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
HUB_URL="${FELHOM_HUB_URL:-https://hub.felhom.eu}"
|
||||
INSTALL_URL="${FELHOM_INSTALL_URL:-https://felhom.eu/scripts/felhom-host-install.sh}"
|
||||
EXTRA_ARGS="${FELHOM_EXTRA_ARGS:-}"
|
||||
|
||||
# --- fetch host-install (public channel) ----------------------------------------------------------
|
||||
log "fetching host-install: $INSTALL_URL"
|
||||
if ! curl -fsSL --max-time 60 "$INSTALL_URL" -o "$SCRIPT_TMP"; then
|
||||
log "ERROR: host-install fetch failed (no network yet?) — unit will retry"
|
||||
exit 1
|
||||
# =====================================================================================================
|
||||
# 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
|
||||
}
|
||||
|
||||
run_pairing() {
|
||||
log "PAIRING mode (generic ISO, no baked customer/passphrase) — hub=$HUB_URL"
|
||||
|
||||
# 1. register once (persist the token). A present token means we already registered — go poll.
|
||||
if [[ ! -s "$TOKEN_FILE" ]]; then
|
||||
local payload; payload=$(gather_identity_json)
|
||||
if [[ -z "$payload" || "$payload" != *'"uuid"'* ]]; then
|
||||
log "ERROR: could not gather appliance identity — unit will retry"
|
||||
exit 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 "ERROR: registration failed (no network yet?) — unit will retry"
|
||||
exit 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 "ERROR: registration returned no appliance token — unit will retry"
|
||||
exit 1
|
||||
fi
|
||||
( umask 077; printf '%s' "$token" > "$TOKEN_FILE" )
|
||||
log "registered — appliance token stored (0600); waiting for the operator to bind this box"
|
||||
fi
|
||||
|
||||
# 2. ONE poll. RestartSec=30 is the poll interval.
|
||||
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"
|
||||
# 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)
|
||||
log "not bound yet — will poll again in 30s"
|
||||
exit 1
|
||||
;;
|
||||
410)
|
||||
log "ERROR: delivery already consumed but no local env — unit will retry (rare crash-window)"
|
||||
exit 1
|
||||
;;
|
||||
404)
|
||||
log "appliance token not recognized (discarded, or the hub has no record) — will retry in 30s"
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
log "poll returned HTTP ${code:-none} — will retry in 30s"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- mode selection -------------------------------------------------------------------------------
|
||||
if [[ -n "$FELHOM_CUSTOMER_ID" && -n "$FELHOM_RETRIEVAL_PASSPHRASE" ]]; then
|
||||
run_direct
|
||||
else
|
||||
run_pairing
|
||||
fi
|
||||
if [[ ! -s "$SCRIPT_TMP" ]]; then
|
||||
log "ERROR: fetched host-install is empty — unit will retry"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- retrieval passphrase -> 0600 tmpfs file ------------------------------------------------------
|
||||
( umask 077; printf '%s' "$FELHOM_RETRIEVAL_PASSPHRASE" > "$PASS_FILE" )
|
||||
|
||||
# --- retry-vs-resume ruling -----------------------------------------------------------------------
|
||||
args=(--customer-id "$FELHOM_CUSTOMER_ID" --mode "$FELHOM_MODE" --hub-url "$HUB_URL" --passphrase-file "$PASS_FILE")
|
||||
if [[ -f "$STATE_FILE" ]]; then
|
||||
log "prior install state present ($STATE_FILE) -> adding --resume (host-install v1.11.3: producers re-run, safe)"
|
||||
args+=(--resume)
|
||||
fi
|
||||
# EXTRA_ARGS are profile-only flags (never secrets); intentional word-split.
|
||||
read -ra extra <<< "$EXTRA_ARGS"
|
||||
|
||||
log "running host-install (customer=${FELHOM_CUSTOMER_ID} mode=${FELHOM_MODE} hub=${HUB_URL})"
|
||||
bash "$SCRIPT_TMP" "${args[@]}" "${extra[@]}"
|
||||
rc=$?
|
||||
cleanup_pass
|
||||
|
||||
if [[ $rc -eq 0 ]]; then
|
||||
log "host-install SUCCESS — writing done-flag, disabling unit, scrubbing env"
|
||||
install -d -m 0755 "$(dirname "$DONE_FLAG")"
|
||||
: > "$DONE_FLAG"; chmod 0644 "$DONE_FLAG"
|
||||
systemctl disable felhom-bootstrap.service 2>/dev/null || true
|
||||
# Reduce secret-at-rest: the box is enrolled; the passphrase is no longer needed.
|
||||
shred -u "$ENV_FILE" 2>/dev/null || rm -f "$ENV_FILE"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "host-install FAILED rc=${rc} — unit will retry in 30s"
|
||||
exit "$rc"
|
||||
|
||||
Reference in New Issue
Block a user