diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 7965742..9e3c989 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -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-, + # .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 to keep the pin stable, or --allow-new-leaf to regenerate intentionally (every guest must then be re-bootstrapped)." diff --git a/scripts/hostinstall-mode-harness.sh b/scripts/hostinstall-mode-harness.sh index e3b457b..f86b25a 100644 --- a/scripts/hostinstall-mode-harness.sh +++ b/scripts/hostinstall-mode-harness.sh @@ -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.