GL-8: BYO coexistence hardening (host-install v1.12.0) — F3/F6/F1
F3: the populated-host leaf guard now fires only when a FELHOM guest exists (felhom_guests, not `pct list`) — a fresh byo install on a host running only the OWNER's guests proceeds without --allow-new-leaf; the pin protection for real Felhom guests is unchanged (guard still fires on a Felhom guest). F6: byo preflight REFUSES (never mutates) when a foreign resolver is bound to :53, with remediation — Felhom needs the guest reachable by name and will not stop/mask the owner's DNS (the dnsmasq analog of the break-glass rule). Viktor's ruling: refuse+instruct only, no stop/mask path. ss failure degrades to a warn. Appliance mode untouched (the agent's lan_resolver owns :53). F1: uninstall now purges the agent config's .bak* siblings (one GL-6 residue held a live hub api_key), not just agent.json; WIPED statement updated. Harness +GL8-F3/F6/F1 static + GL8-F1b behavioural + GL8-F6 live (byo preflight refuses on felhom-pve's leftover dnsmasq — the real F6 scenario). Red-proofs RP-F3/F6/F1 each fail their invariant. 36/36 on felhom-pve; shellcheck clean; GL-2 Scenario-A + GL-4 Scenario-D regression green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -48,6 +48,14 @@
|
||||
# missing hub.host_id/proxmox.token (daemon crash-loop) or passes an empty -rootfs-grow. golden's
|
||||
# GOLDEN_VOLID is re-derived from the local archive on resume.
|
||||
#
|
||||
# v1.12.0 (GL-8, BYO coexistence hardening — GL-6 findings): F3 — the populated-host leaf guard
|
||||
# now fires only when a FELHOM guest exists (felhom_guests), so a fresh byo install on a host that
|
||||
# only runs the OWNER's guests proceeds without --allow-new-leaf (the pin protection for real Felhom
|
||||
# guests is unchanged). F6 — byo preflight REFUSES (never mutates) when a foreign resolver is bound
|
||||
# to :53, with remediation (Felhom needs the guest reachable by name; it won't stop the owner's DNS).
|
||||
# F1 — uninstall now purges the agent config's .bak* siblings (one held a live hub api_key), not just
|
||||
# agent.json.
|
||||
#
|
||||
# Grounding: documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md
|
||||
#
|
||||
# Usage:
|
||||
@@ -155,7 +163,7 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_VERSION="1.11.3" # keep in sync with the header line at the top of this file
|
||||
SCRIPT_VERSION="1.12.0" # keep in sync with the header line at the top of this file
|
||||
|
||||
# Operator signing keys pinned at day-0 (GL-4; doc 04 §3 two-key model). EMPTY by default — the pin
|
||||
# CEREMONY is an operator step: generate the real keypairs OFFLINE, then fill these four constants
|
||||
@@ -584,7 +592,7 @@ _uninstall_statement() {
|
||||
echo " WIPED (this run):"
|
||||
echo " - guest $vmid (container + its OS/Docker/user-data volumes)"
|
||||
if [[ "$scope" == "full" ]]; then
|
||||
echo " - the felhom-agent: binary, unit, sudoers, config, state dir, service user"
|
||||
echo " - the felhom-agent: binary, unit, sudoers, config (+ its .bak backups), state dir, service user"
|
||||
echo " - self-update artifacts: guarded wrapper, A/B slots (.prev/.new.*), rollback unit, start-limit drop-in"
|
||||
echo " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets"
|
||||
echo " - pveum: the Felhom roles/user/token/scoped ACL$( $pool_removed && printf '; the emptied %s pool' "$PVE_POOL")"
|
||||
@@ -737,8 +745,13 @@ run_uninstall() {
|
||||
if [[ -d "$AGENT_STATE_DIR" ]]; then run rm -rf "$AGENT_STATE_DIR"; else log_skip " $AGENT_STATE_DIR already absent"; fi
|
||||
if id "$AGENT_USER" >/dev/null 2>&1; then run userdel "$AGENT_USER"; else log_skip " service user $AGENT_USER already absent"; fi
|
||||
|
||||
# 4b. Agent config (pve token + per-host hub api_key — secrets must not survive; drill R1).
|
||||
# 4b. Agent config + its backups (pve token + per-host hub api_key — secrets must not survive;
|
||||
# drill R1 / GL-6 F1). The config write leaves `${agent_cfg}.bak*` siblings (e.g. .bak-<ver>,
|
||||
# .bak-ceremony-*, .bak-pre064) — one GL-6 residue still held a LIVE hub api_key. Remove the
|
||||
# config AND every `.bak*` sibling, then the (now-empty) dir. Paths logged, contents never.
|
||||
if [[ -f "$agent_cfg" ]]; then run rm -f "$agent_cfg"; else log_skip " $agent_cfg already absent"; fi
|
||||
local _cfgbak
|
||||
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && run rm -f "$_cfgbak"; done
|
||||
run rmdir "$(dirname "$agent_cfg")" 2>/dev/null || true
|
||||
|
||||
# 4b2. Management-plane break-glass (TASK G1): timer+oneshot+script+tmpfiles. Stop/disable the
|
||||
@@ -905,7 +918,7 @@ run_uninstall() {
|
||||
|
||||
# 8. Summary + the GL-4 kept-vs-wiped statement.
|
||||
_uninstall_statement full
|
||||
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
|
||||
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config+baks/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/hook-snippet/dnsmasq-snippets), the pveum role/user/token/ACL,$( $pool_removed && printf ' the %s pool,' "$PVE_POOL") and $STATE_FILE."
|
||||
if $REMOVE_GOLDEN; then log_info " golden vzdump: removed."; else log_info " golden vzdump: left in place (--remove-golden to remove)."; fi
|
||||
log_info " NOTE: the 'sudo' and 'dnsmasq' packages were left installed (system packages); the host record still exists in the hub — remove it there if desired."
|
||||
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
|
||||
@@ -1365,19 +1378,25 @@ step_preflight() {
|
||||
log_info " acl storages all present: ${PVE_STORAGES[*]}"
|
||||
fi
|
||||
|
||||
# Host DNS info line (byo only, informational ONLY): the installer never configures DNS and the
|
||||
# byo config asserts keep the agent's lan-resolver off — this line just tells the operator what
|
||||
# already listens on :53 (the owner may run a resolver Felhom must not disturb).
|
||||
# Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config
|
||||
# assert enforces it), so ANY process bound to :53 is the OWNER's — and Felhom needs the guest
|
||||
# reachable by name on the LAN. Felhom must NOT stop/mask/kill a service on a host it does not own
|
||||
# (the dnsmasq analog of the break-glass rule), so this REFUSES with the remediation instead of
|
||||
# mutating anything. `ss` failure/absence degrades to a warn (never block on a missing tool).
|
||||
# Appliance mode is untouched — there the agent's lan_resolver owns :53 by design.
|
||||
if [[ "$MODE" == "byo" ]]; then
|
||||
local _dns53
|
||||
if _dns53=$(ss -H -ltnup 'sport = :53' 2>/dev/null); then
|
||||
if [[ -n "$_dns53" ]]; then
|
||||
log_info " host DNS (:53): $(echo "$_dns53" | tr -s '[:space:]' ' ' | cut -c1-160)"
|
||||
else
|
||||
log_info " host DNS (:53): nothing bound"
|
||||
log_error " a resolver is already bound to :53 on this host:"
|
||||
echo "$_dns53" | tr -s '[:space:]' ' ' | cut -c1-200 | sed 's/^/ /' >&2
|
||||
die "a resolver is already bound to :53 on this host — Felhom needs the guest reachable by name on your LAN.
|
||||
Stop or reconfigure that resolver, OR point your LAN DNS at the guest's address, then re-run.
|
||||
(Felhom does NOT touch DNS services on a host it does not own — this is a refusal, not a change.)"
|
||||
fi
|
||||
log_info " host DNS (:53): free"
|
||||
else
|
||||
log_warn " host DNS (:53): could not query (ss failed/absent) — informational check skipped"
|
||||
log_warn " host DNS (:53): could not query (ss failed/absent) — skipping the :53 gate"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -1713,9 +1732,14 @@ step_agent_install() {
|
||||
_have_leaf=true
|
||||
fi
|
||||
|
||||
# GL-8/F3: the guard fires only when a FELHOM guest is present — regenerating the leaf orphans
|
||||
# THAT guest's pinned fingerprint (the 2026-06-28 incident). A BYO host's own non-Felhom guests
|
||||
# have no Felhom pin to break, so `felhom_guests` (the /etc/felhom-bootstrap detector) — NOT
|
||||
# `pct list` (any guest) — is the correct populated-host test: a fresh byo install on a host that
|
||||
# only runs the owner's guests now proceeds without `--allow-new-leaf`.
|
||||
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.
|
||||
if [[ -n "$(felhom_guests)" ]]; then
|
||||
die "this host already has a Felhom guest 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)."
|
||||
|
||||
@@ -301,10 +301,56 @@ else
|
||||
verdict FAIL "GL4-INV no umount -l/-f, no mkfs/wipefs invocation on /mnt/felhom-drives"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- GL-8 static tier (BYO coexistence hardening) ---"
|
||||
|
||||
# GL8-F3: the populated-host leaf guard tests for a FELHOM guest (felhom_guests), NOT any guest
|
||||
# (pct list). Runtime behaviour is impractical to isolate (the guard is inline in step 5, gated on
|
||||
# the real state dir's leaf) — validated grep-level + red-proof here; the true behavioural proof is
|
||||
# GL-7's real byo install on a populated non-Felhom host.
|
||||
if grep -A2 'this host already has a Felhom guest' "$SCRIPT" >/dev/null 2>&1 \
|
||||
&& grep -B3 'this host already has a Felhom guest' "$SCRIPT" | grep -q '\[\[ -n "\$(felhom_guests)" \]\]' \
|
||||
&& ! grep -B3 'has no agent leaf to preserve' "$SCRIPT" | grep -q 'pct list .* | .*tail -n +2 | grep -q'; then
|
||||
verdict PASS "GL8-F3 leaf guard uses felhom_guests (not pct list — fires on Felhom guests only)"
|
||||
else
|
||||
verdict FAIL "GL8-F3 leaf guard uses felhom_guests (not pct list — fires on Felhom guests only)"
|
||||
fi
|
||||
|
||||
# GL8-F6: the byo :53 gate REFUSES (dies) with the remediation and mutates nothing — no
|
||||
# stop/mask/kill of a :53 service anywhere in the script.
|
||||
if grep -q 'a resolver is already bound to :53 on this host — Felhom needs the guest reachable' "$SCRIPT" \
|
||||
&& grep -B6 'a resolver is already bound to :53 on this host — Felhom needs' "$SCRIPT" | grep -q 'MODE" == "byo"' \
|
||||
&& ! grep -vE '^[[:space:]]*#' "$SCRIPT" | grep -E '(systemctl (stop|mask|disable)|kill|pkill).*(dnsmasq|:53|resolv)' >/dev/null; then
|
||||
verdict PASS "GL8-F6 byo :53 gate refuses+instructs, never mutates the owner's resolver"
|
||||
else
|
||||
verdict FAIL "GL8-F6 byo :53 gate refuses+instructs, never mutates the owner's resolver"
|
||||
fi
|
||||
|
||||
# GL8-F1(static): uninstall removes the agent config's .bak* siblings (not just agent.json).
|
||||
if grep -q '"${agent_cfg}".bak\*' "$SCRIPT"; then
|
||||
verdict PASS "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
|
||||
else
|
||||
verdict FAIL "GL8-F1 uninstall removes \${agent_cfg}.bak* (secret-bearing backups)"
|
||||
fi
|
||||
|
||||
# GL8-F1(behavioural): the exact glob-removal pattern the script uses, exercised in a temp dir —
|
||||
# both agent.json AND its .bak* siblings must go (a plain `rm -f agent.json` would leave the .bak).
|
||||
f1dir="$WORK/etc-felhom-agent"; mkdir -p "$f1dir"
|
||||
: > "$f1dir/agent.json"; : > "$f1dir/agent.json.bak-0.75.0"; : > "$f1dir/agent.json.bak-ceremony-2026-07-08"; : > "$f1dir/agent.json.bak-pre064"
|
||||
agent_cfg="$f1dir/agent.json"
|
||||
rm -f "$agent_cfg"
|
||||
for _cfgbak in "${agent_cfg}".bak*; do [[ -e "$_cfgbak" ]] && rm -f "$_cfgbak"; done
|
||||
rmdir "$f1dir" 2>/dev/null || true
|
||||
if [[ ! -e "$f1dir" ]]; then
|
||||
verdict PASS "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir"
|
||||
else
|
||||
verdict FAIL "GL8-F1b glob removal clears agent.json + every .bak* + the empty dir" "residue: $(ls -A "$f1dir" 2>/dev/null | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- PVE tier ---"
|
||||
if ! command -v pveum >/dev/null 2>&1 || [[ "$(id -u)" != 0 ]]; then
|
||||
verdict SKIP "PVE tier (C5 + Scenario A/B dry transcripts)" "needs a PVE host as root — run there"
|
||||
verdict SKIP "PVE tier (C5 + Scenario A/B dry transcripts + GL8-F6 live)" "needs a PVE host as root — run there"
|
||||
else
|
||||
# C5: byo with a bogus --acl-storages entry dies in preflight NAMING it (read-only: dies before
|
||||
# any hub contact/passphrase and before any mutation; state override active).
|
||||
@@ -313,6 +359,29 @@ else
|
||||
-- --customer-id t --mode byo --cores 4 --memory 8192 \
|
||||
--acl-storages "local definitely-not-a-storage" --dry-run
|
||||
|
||||
# GL8-F6 live: the byo :53 gate is behaviour-checked against whatever THIS host has on :53
|
||||
# (read-only preflight die; state override active). If a resolver is bound (e.g. felhom-pve's
|
||||
# leftover dnsmasq), byo preflight MUST refuse with the remediation; if :53 is free, it must pass
|
||||
# the gate and die LATER for another reason (never at :53). A PATH-shimmed `ss` proves both arms.
|
||||
mkdir -p "$WORK/f6shim"
|
||||
printf '#!/bin/sh\nexit 0\n' > "$WORK/f6shim/ss"; chmod 0755 "$WORK/f6shim/ss" # empty :53 output
|
||||
if ss -H -ltnup 'sport = :53' 2>/dev/null | grep -q .; then
|
||||
expect_die "GL8-F6 live: foreign :53 refused, byo" \
|
||||
"a resolver is already bound to :53 on this host — Felhom needs the guest reachable" \
|
||||
-- --customer-id t --mode byo --cores 4 --memory 8192 --acl-storages "local" --dry-run
|
||||
else
|
||||
verdict SKIP "GL8-F6 live: foreign :53 refused, byo" "nothing bound to :53 on this host — cannot exercise the refuse arm"
|
||||
fi
|
||||
# free-:53 arm (ss shimmed to report nothing): byo preflight passes the :53 gate → dies LATER,
|
||||
# never with the :53 message.
|
||||
out=$(FELHOM_INSTALL_STATE_DIR="$STATE_OVERRIDE_ENV" PATH="$WORK/f6shim:$PATH" \
|
||||
bash "$SCRIPT" --customer-id t --mode byo --cores 4 --memory 8192 --acl-storages "local" --dry-run 2>&1 || true)
|
||||
if [[ "$out" != *"already bound to :53"* ]]; then
|
||||
verdict PASS "GL8-F6 live: free :53 passes the gate (ss-shim empty)"
|
||||
else
|
||||
verdict FAIL "GL8-F6 live: free :53 passes the gate (ss-shim empty)" "died at :53 despite an empty ss"
|
||||
fi
|
||||
|
||||
# GL4 H-U: FULL uninstall dry transcript (Scenario A). Read-only: every mutation is dry-printed,
|
||||
# the typed confirm takes its dry branch, and the state override keeps the live state.json out.
|
||||
# Requires a Felhom guest to target — resolved from felhom_guests-style detection below.
|
||||
|
||||
Reference in New Issue
Block a user