Installer step 4b: vault first, then set, then a PVE login (R-1145); host key off curl's argv (review F4); harness green and a gate (R-1146); hub 0.158.0 deploy evidence; independent review + verdicts
gates / gates (push) Successful in 7m23s

Live on demo-hp (--rotate-recovery-only): vault time moved, new value 200, old 401. Harness 52/0;
the R1145 cases fail against HEAD. Publishes only with the next installer tag.
This commit is contained in:
2026-10-18 12:50:49 +02:00
parent 4c319ddeb9
commit 88aecf55b6
17 changed files with 374 additions and 27 deletions
+83 -3
View File
@@ -325,12 +325,27 @@ 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.
# R-1146 (2026-10-18): the check is judged by WHERE a line is, not by a fixed window. The gate's R-300 hint grew the
# byo `if` beyond 6 lines above the refusal, and R-300 made the UNINSTALL turn off Felhom's OWN dnsmasq on purpose.
# Allowed: a disable inside run_uninstall (ours, R-300) or inside the gate's _hint="…" text (an instruction to the
# owner, never executed). Any other stop/mask/disable/kill of a resolver, anywhere, still FAILS.
gl8_f6_offenders=$(awk '
/^[a-zA-Z_]+\(\) *\{/ { fn = $1 }
/_hint="/ { inhint = 1 }
!/^[[:space:]]*#/ && /(systemctl (stop|mask|disable)|kill|pkill).*(dnsmasq|:53|resolv)/ {
if (fn != "run_uninstall()" && !inhint) print NR": "$0
}
inhint && /"[[:space:]]*$/ && !/_hint="[[:space:]]*$/ { inhint = 0 }
' "$SCRIPT")
gl8_f6_byo_before=$(awk '
/if \[\[ "\$MODE" == "byo" \]\]; then/ { last = NR }
/die "a resolver is already bound to :53 on this host — Felhom needs/ { print (last && NR - last <= 40) ? "yes" : "no"; exit }
' "$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
&& [[ "$gl8_f6_byo_before" == "yes" ]] && [[ -z "$gl8_f6_offenders" ]]; 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"
verdict FAIL "GL8-F6 byo :53 gate refuses+instructs, never mutates the owner's resolver" "byo-gate=${gl8_f6_byo_before:-none} offenders=${gl8_f6_offenders:-none}"
fi
# GL8-F1 / R-275: the agent config and EVERY copy of it go — the agent's own config dir is purged as a
@@ -427,6 +442,71 @@ else
verdict FAIL "R1141-k usage documents --rotate-recovery-only" "rc=$rc"
fi
# ── R-1145 (2026-10-18): step 4b is VAULT FIRST, THEN SET. The REAL function (cut out of the script) runs with fake
# openssl/curl/chpasswd/sleep on PATH and a fake shadow file; each fake appends one word to a calls file, so the
# ORDER is what is judged. The fake password must never appear in the step's output or any fake's argv.
r1145_dir="$WORK/r1145"; mkdir -p "$r1145_dir/bin"
R1145_PW="FAKEPW-r1145-AAAAAAAAAAAAAAAAAAAAAAAAAA"
cat > "$r1145_dir/bin/openssl" <<EOF
#!/bin/bash
echo "$R1145_PW"
EOF
cat > "$r1145_dir/bin/curl" <<'EOF'
#!/bin/bash
cat >/dev/null # the body (password) arrives on stdin and goes nowhere
echo "argv: $*" >> "$R1145_DIR/argv"
case "$*" in
*recovery-credential*) echo vault >> "$R1145_DIR/calls"; printf '%s' "$FAKE_VAULT" ;;
*access/ticket*) echo login >> "$R1145_DIR/calls"; printf '%s' "$FAKE_LOGIN" ;;
esac
EOF
cat > "$r1145_dir/bin/chpasswd" <<'EOF'
#!/bin/bash
cat >/dev/null
echo chpasswd >> "$R1145_DIR/calls"
[[ "$FAKE_CHPASSWD" == ok ]] || exit 1
echo "root:\$y\$changed:20000::::::" > "$R1145_SHADOW"
EOF
printf '#!/bin/bash\nexit 0\n' > "$r1145_dir/bin/sleep"
chmod +x "$r1145_dir/bin/"*
sed -n '/^_root_shadow_fingerprint() {/,/^}/p; /^step_break_glass() {/,/^}/p' "$SCRIPT" > "$r1145_dir/step.sh"
# r1145_run <vault code> <chpasswd ok|fail> <login code> — sets r_rc, r_out, r_calls, r_shadow_changed
r1145_run() {
: > "$r1145_dir/calls"; : > "$r1145_dir/argv"; echo 'root:$y$old:20000::::::' > "$r1145_dir/shadow"
set +e
r_out=$(PATH="$r1145_dir/bin:$PATH" R1145_DIR="$r1145_dir" R1145_SHADOW="$r1145_dir/shadow" \
FELHOM_SHADOW_FILE="$r1145_dir/shadow" FAKE_VAULT="$1" FAKE_CHPASSWD="$2" FAKE_LOGIN="$3" bash -c '
log_step() { echo "STEP $*"; }; log_info() { echo "INFO $*"; }; log_warn() { echo "WARN $*"; }
log_error() { echo "ERROR $*"; }; log_success() { echo "OK $*"; }; log_skip() { echo "SKIP $*"; }; log_dry() { echo "DRY $*"; }
die() { echo "DIE $*"; exit 1; }; _state_mark() { echo "MARK $*"; }; _state_has() { return 1; }
DRY_RUN=false; ROTATE_RECOVERY=true; HOST_ID=h-test; HOST_API_KEY=k-test; HUB_URL=http://127.0.0.1:9
source "'"$r1145_dir/step.sh"'"; step_break_glass' 2>&1)
r_rc=$?
set -e
r_calls=$(tr '\n' ' ' < "$r1145_dir/calls")
grep -q 'old' "$r1145_dir/shadow" && r_shadow_changed=no || r_shadow_changed=yes
}
r1145_check() { # <name> <want rc 0|1> <want calls> <want shadow changed> <want output substring>
local ok=true why=""
[[ "$r_rc" == "$2" || ( "$2" == 1 && "$r_rc" != 0 ) ]] || { ok=false; why+="rc=$r_rc "; }
[[ "$r_calls" == "$3" ]] || { ok=false; why+="calls='$r_calls' want '$3' "; }
[[ "$r_shadow_changed" == "$4" ]] || { ok=false; why+="shadow changed=$r_shadow_changed "; }
[[ "$r_out" == *"$5"* ]] || { ok=false; why+="missing '$5' "; }
if [[ "$r_out" == *"$R1145_PW"* ]] || grep -qF "$R1145_PW" "$r1145_dir/argv"; then ok=false; why+="the password leaked "; fi
if grep -qF "k-test" "$r1145_dir/argv" || [[ "$r_out" == *"k-test"* ]]; then ok=false; why+="the host key is in curl's argv or the output "; fi
if $ok; then verdict PASS "$1"; else verdict FAIL "$1" "$why"; fi
}
r1145_run 500 ok 200
r1145_check "R1145-a vault fails → the password is NOT touched" 1 "vault " no "the root@pam password was NOT changed"
r1145_run 401 ok 200
r1145_check "R1145-e a stale host key (vault 401) → the password is NOT touched (review F1)" 1 "vault " no "vault rejected (401)"
r1145_run 200 ok 200
r1145_check "R1145-b vault, then set, then the PVE login — in that order" 0 "vault chpasswd login " yes "the PVE login takes it"
r1145_run 200 fail 200
r1145_check "R1145-c set fails twice after the vault → the state is said, root unchanged" 1 "vault chpasswd chpasswd " no "STATE: the hub now holds a recovery password that root@pam does NOT have"
r1145_run 200 ok 401
r1145_check "R1145-d the PVE login refuses the new value → die, the state is said" 1 "vault chpasswd login " yes "the PVE web login answered HTTP 401"
echo ""
echo "--- F5 appliance guest auto-sizing (VALIDATION-n100) ---"
# The auto-size log line is emitted right after mode validation (BEFORE any PVE/hub contact), so it