diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 554c76d..f538d98 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -47,6 +47,13 @@ # (default: secure no-echo prompt) # --preserve-from PATH merge non-Day-0 sections (privileged/storage/backup/ # local_api/authz/lan_resolver) from an existing config +# --preserve-state-from PATH carry the prior agent leaf+key+token-store (local-api.crt/key, +# local-tokens.log) over so the pinned fingerprint STAYS STABLE across a +# reinstall (no controller re-bootstrap). Use an aside copy of the old +# /var/lib/felhom-agent. +# --allow-new-leaf opt in to REGENERATE the agent leaf on a host that already has guests +# (the populated-host guard otherwise refuses; every guest must then be +# re-bootstrapped — only use intentionally). # --force allow provisioning over an EXISTING vmid (destructive) # --skip-provision install + configure + verify the agent, but do NOT # provision a guest (re-install/upgrade an agent on a host @@ -99,6 +106,8 @@ DATAVOL_GROW="" SYSDATA_GROW="" PASSPHRASE_FILE="" PRESERVE_FROM="" +PRESERVE_STATE_FROM="" # dir holding a prior local-api.{crt,key} + local-tokens.log to carry over (keeps the pin stable across a reinstall) +ALLOW_NEW_LEAF=false # opt-in to intentionally regenerate the agent leaf on a populated host (else the guard refuses) FORCE=false FORCE_GITEA_GOLDEN=false SKIP_PROVISION=false @@ -242,6 +251,8 @@ while [[ $# -gt 0 ]]; do --sysdata-grow) SYSDATA_GROW="$2"; shift 2 ;; --passphrase-file) PASSPHRASE_FILE="$2"; shift 2 ;; --preserve-from) PRESERVE_FROM="$2"; shift 2 ;; + --preserve-state-from) PRESERVE_STATE_FROM="$2"; shift 2 ;; + --allow-new-leaf) ALLOW_NEW_LEAF=true; shift ;; --force) FORCE=true; shift ;; --force-gitea-golden) FORCE_GITEA_GOLDEN=true; shift ;; --skip-provision) SKIP_PROVISION=true; shift ;; @@ -571,6 +582,44 @@ step_agent_install() { run chown -R "${AGENT_USER}:${AGENT_USER}" "$AGENT_STATE_DIR" run chmod 0750 "$AGENT_STATE_DIR" + # ── Agent local-API leaf lifecycle (B.2) ────────────────────────────────────────────────────── + # The leaf's SHA-256 is pinned into EVERY guest's bootstrap. A reinstall that REGENERATES the leaf + # invalidates every controller's pin (the 2026-06-28 root→non-root incident → controller↔agent dead + # for days). Two protections: + # (a) --preserve-state-from DIR: carry the prior leaf+key+token-store over → the fp stays STABLE, + # no re-bootstrap needed. (Distinct from --preserve-from, which merges config sections only.) + # (b) populated-host guard: REFUSE to proceed leaf-less on a host that already has guests, unless + # --preserve-state-from or an explicit --allow-new-leaf is given. Converts the silent footgun + # into a hard stop. + local _have_leaf=false + [[ -f "$AGENT_STATE_DIR/local-api.crt" && -f "$AGENT_STATE_DIR/local-api.key" ]] && _have_leaf=true + + if [[ -n "$PRESERVE_STATE_FROM" ]]; then + [[ -f "$PRESERVE_STATE_FROM/local-api.crt" && -f "$PRESERVE_STATE_FROM/local-api.key" ]] \ + || die "--preserve-state-from $PRESERVE_STATE_FROM: local-api.crt/key not found there" + openssl x509 -in "$PRESERVE_STATE_FROM/local-api.crt" -noout >/dev/null 2>&1 \ + || die "--preserve-state-from: $PRESERVE_STATE_FROM/local-api.crt does not parse as an X.509 cert — refusing" + if $DRY_RUN; then + log_dry "preserve agent state: copy local-api.{crt,key}+local-tokens.log from $PRESERVE_STATE_FROM -> $AGENT_STATE_DIR (chown $AGENT_USER; 644/600/600)" + else + install -o "$AGENT_USER" -g "$AGENT_USER" -m 0644 "$PRESERVE_STATE_FROM/local-api.crt" "$AGENT_STATE_DIR/local-api.crt" + install -o "$AGENT_USER" -g "$AGENT_USER" -m 0600 "$PRESERVE_STATE_FROM/local-api.key" "$AGENT_STATE_DIR/local-api.key" + [[ -f "$PRESERVE_STATE_FROM/local-tokens.log" ]] && \ + install -o "$AGENT_USER" -g "$AGENT_USER" -m 0600 "$PRESERVE_STATE_FROM/local-tokens.log" "$AGENT_STATE_DIR/local-tokens.log" + log_success " preserved agent leaf+token store from $PRESERVE_STATE_FROM (pin stays stable — no re-bootstrap)" + fi + _have_leaf=true + fi + + if ! $_have_leaf && ! $ALLOW_NEW_LEAF; then + if pct list 2>/dev/null | tail -n +2 | grep -q .; then + die "this host already has guests but $AGENT_STATE_DIR has no agent leaf to preserve. + Re-running here will REGENERATE the leaf and invalidate every controller's pin (the 2026-06-28 incident). + Pass --preserve-state-from to keep the pin stable, or --allow-new-leaf to regenerate + intentionally (every guest must then be re-bootstrapped)." + fi + fi + # Sudoers — fetch the canonical file, validate with visudo -cf BEFORE installing (0440 root:root). if $DRY_RUN; then log_dry "fetch configs/felhom-agent.sudoers ; visudo -cf ; install 0440 -> $AGENT_SUDOERS"