host-install: --preserve-state-from + populated-host leaf guard (prevention B.2)

--preserve-state-from DIR carries the prior local-api.{crt,key}+local-tokens.log into the agent
state dir (validates the leaf parses) so the pinned fp stays stable across a reinstall — no
re-bootstrap. Populated-host guard: refuse to proceed leaf-less when the host already has guests
unless --preserve-state-from or --allow-new-leaf is given (converts the 2026-06-28 silent footgun
into a hard stop). bash -n clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pg8ANF97SEeKYSN5Jxw3qJ
This commit is contained in:
2026-06-29 21:46:45 +02:00
parent 12f038618e
commit a69e06354e
+49
View File
@@ -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 <old state dir> 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"