diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index d6a9a76..18e3ea1 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -24,7 +24,8 @@ var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) // generator targets. Kept in sync with scripts/felhom-host-install.sh SCRIPT_VERSION — the generator // only ever emits flags this version parses. Display-only (the Option-1 command downloads the served // script, which is always current); bump when the generator's flag surface follows a new script. -const hostInstallVersion = "1.12.0" +// Drift is now gated: scripts/hostinstall_gates.py asserts this const == SCRIPT_VERSION (drill F-1). +const hostInstallVersion = "1.15.0" // validSemver matches a bare X.Y.Z controller version (the floor format). Empty is also accepted by // the floor handlers (clears the override). diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index d147ba2..4769c24 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -1,5 +1,40 @@ # Felhom scripts — Changelog +## felhom-host-install v1.15.0 — DR-tier-by-default: uniform DR plumbing on every install (2026-07-12) + +Sweeps the DRILL-day0-vm-2026-07-12 installer findings so the next Day-0 runs the drill's §5 +sequence with zero fix-and-continue stops. Operator decisions 2026-07-12: capability is BAKED +uniformly; activation is a hub flag; WG is base infrastructure. + +- **F-7** — ships `/usr/local/sbin/felhom-pbs-apply` (fetch `configs/felhom-pbs-apply` from the + agent repo, `bash -n`, install 0755 — the mkfs/selfupdate wrapper pattern). The FELHOM_PBSDR + sudoers alias already shipped; the 3 `pbsdr-*` capabilities are no longer born DEGRADED. + Uninstall removes it (disclosure↔uninstall parity kept, harness GL4-D green). +- **F-10** — installs the `age` package (escrow-ceremony identity-wrap dependency; a fresh-box + ceremony died on it live). +- **F-9 / decision 5** — the rendered agent.json now defaults `wg_tunnel.enabled: true` in BOTH + modes (WG is base infrastructure like cloudflared, also the future OOB transport; setdefault — + a deliberately-disabled preserved config is respected). The byo wg-off config assert is + RETIRED; byo consent = the typed disclosure ack (the WG tunnel + pbs-apply wrapper + age are + now on the disclosed list) + the tester-agreement disclosure line. +- **ACL** — the default `--acl-storages` set keeps `felhom-pbs` (drill-proven: narrowing it 403s + the PBS-DR apply-bridge at `/storage/felhom-pbs`); a missing felhom-pbs storage is now an + expected-pre-tier INFO, and no longer a byo refusal (the tier creates the storage later). +- **F-1** — single version source: `SCRIPT_VERSION` alone feeds `-h`, the run banners, and (via a + gate cross-check) the hub Setup-tab const `hostInstallVersion` (bumped 1.12.0→1.15.0 in the + same commit). The header no longer carries its own version literal. +- **F-2** — dry-run fetch lines print the REAL auth mode (`curl (anonymous …)` vs `curl -u `) + instead of the hardcoded `-u ` lie on the anonymous branch. +- **F-8** — step 4b + the final summary now say out loud that root@pam was rotated + vaulted and + where to retrieve it (pointer only, never the credential). +- **New gate: `scripts/hostinstall_gates.py`** — mechanical grep-assertions (version single-source + + hub-const sync, age, pbs-apply ship+uninstall, wg default-on + byo-assert retirement, + felhom-pbs in the default ACL). All red-proofed; the age assertion was hardened TWICE by its + red-proof (a prefix regex matched `agekit`, then a loose one matched the dry-run print line). +- F-3 (root-owned `guests/` parents) is fixed at its creator — the AGENT (v0.86.0); this pass + applied the drill's live-fix (chown the two parent dirs) to felhom-pve, which had the same + latent state. Peti's host: unreachable, check deferred. + ## felhom-host-install v1.14.0 — deployment_mode from --mode (appliance self-heal gate) (2026-07-12) - The agent-config writer now templates the top-level **`deployment_mode`** field from `--mode` diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 0e714eb..b5d259e 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -1,6 +1,7 @@ #!/bin/bash #=============================================================================== -# felhom-host-install.sh v1.14.0 +# felhom-host-install.sh — version: the SCRIPT_VERSION variable below (single source; +# `-h` and the run banner both read it — the v1.14.0/v1.13.0 header/banner drift was drill F-1). # Day-0 host-bootstrap for a Felhom Proxmox host (operator-deploy model). # # Run by the operator on a FRESHLY-PVE-INSTALLED box (after a manual PVE install @@ -19,6 +20,17 @@ # new credential. The checksum trust root is the HUB, not Gitea. This removes the # old prerequisite "install the agent binary + unit manually". # +# v1.15.0 (DR-tier-by-default, DRILL-day0-vm-2026-07-12 sweep): every install now deploys the FULL +# DR plumbing uniformly — capability is baked, activation is a hub flag. F-7: ships the +# `felhom-pbs-apply` wrapper (like mkfs/selfupdate — pbsdr capabilities no longer born DEGRADED). +# F-10: installs `age` (escrow-ceremony identity-wrap dependency). F-9: renders +# `wg_tunnel.enabled: true` (WG is BASE infrastructure like cloudflared, not a DR component — +# operator decision 2026-07-12; byo consent moves to the disclosure ack + tester agreement, the +# byo wg-off config assert is retired). The default --acl-storages set keeps `felhom-pbs` +# (drill-proven: narrowing it 403s the PBS-DR apply-bridge later); a missing felhom-pbs storage +# is expected pre-tier and no longer a byo refusal. F-1: single VERSION source (SCRIPT_VERSION). +# F-2: dry-run fetch lines show the real auth mode (anonymous vs -u). F-8: step 4b + the final +# summary now say OUT LOUD that root@pam was rotated + where to retrieve it. # v1.14.0 (CAMPAIGN-3 Part 6): --mode now also templates the agent's top-level `deployment_mode` # ("appliance" | "byo") — authoritative from --mode, written even over a --preserve-from config, so a # byo box can never inherit an appliance host-service self-heal. Gates the agent's node self-heal @@ -75,10 +87,12 @@ # touches root@pam (step 4b skipped), REQUIRES explicit # --cores AND --memory, refuses --enable-oob and # --rotate-recovery, dies on non-9.x PVE (unless --force) -# and on --acl-storages entries absent from this box, +# and on --acl-storages entries absent from this box +# (except felhom-pbs — the PBS-DR tier creates it later), # prints the full host-mutation list and requires typing # the host's short hostname, and asserts the agent config -# keeps lan_resolver/wg_tunnel/oob OFF. +# keeps lan_resolver/oob OFF (wg_tunnel is ON in BOTH +# modes since v1.15.0 — base infra, disclosed in the ack). # (dr the 10D DR stub — documented seam, not implemented.) # # Options: @@ -135,7 +149,7 @@ # Uninstall (local host teardown — no hub contact, no passphrase): # --uninstall cleanly revert an install: destroy the Felhom guest, remove the agent # (unit/sudoers/binary/state/config/user + runtime artifacts: shared-parent -# unit, mkfs wrapper, hook snippet, dnsmasq snippets), the pveum +# unit, mkfs + pbs-apply wrappers, hook snippet, dnsmasq snippets), the pveum # role/user/token/ACL, and the install state file. Refuses a non-Felhom guest (no # /etc/felhom-bootstrap mount) and skips host-level removal if OTHER Felhom # guests remain (both overridable with --force). Typed vmid confirmation @@ -168,7 +182,9 @@ set -euo pipefail -SCRIPT_VERSION="1.13.0" # keep in sync with the header line at the top of this file +SCRIPT_VERSION="1.15.0" # the SINGLE version source (F-1): -h, the run banners, and the hub + # Setup-tab copy (hub internal/web/configs.go hostInstallVersion — + # scripts/hostinstall_gates.py asserts the two stay equal) all follow it. # 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 @@ -291,9 +307,14 @@ ART_GOLDEN_SHA="" #------------------------------------------------------------------------------- # Helpers #------------------------------------------------------------------------------- -# Print the header through the last option line (the "# State (" line is the end marker — keeps the -# range from drifting as the header grows; v1.9.1's numeric '2,95p' had already drifted). -usage() { sed -n '2,/^# State (/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0; } +# Print the header through the last option line. Markers, not line numbers (v1.9.1's numeric +# '2,95p' had drifted): start at the "# Day-0 host-bootstrap" line, end before "# State (". +# The version line is printed from SCRIPT_VERSION — the ONLY version source (F-1). +usage() { + echo "felhom-host-install.sh v${SCRIPT_VERSION}" + sed -n '/^# Day-0 host-bootstrap/,/^# State (/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//' + exit 0 +} run() { # simple (no pipes/redirects) mutating command if $DRY_RUN; then log_dry "$*"; else "$@"; fi @@ -402,6 +423,12 @@ _git_auth_args() { fi } +# _dry_auth_hint — the dry-run print's auth stanza (drill F-2): the old hardcoded "curl -u " +# lied on the anonymous-fetch branch. Requires resolve_git_creds to have run (both call sites do). +_dry_auth_hint() { + if [[ -n "$GIT_TOKEN" ]]; then echo "curl -u "; else echo "curl (anonymous — no git credential)"; fi +} + # Fetch a Gitea generic-package URL to a dest (authed when a credential exists, else anonymous), # then VERIFY its sha256 against the expected (hub-vouched) value. Aborts on any mismatch — # verify-before-use; the sha is the integrity root either way. $1=url $2=dest $3=expected_sha @@ -599,7 +626,7 @@ _uninstall_statement() { if [[ "$scope" == "full" ]]; then 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 " - break-glass watchdog + OOB artifacts (where present); guest-hook snippet; dnsmasq snippets; the mkfs + pbs-apply wrappers" echo " - pveum: the Felhom roles/user/token/scoped ACL$( $pool_removed && printf '; the emptied %s pool' "$PVE_POOL")" echo " - the install state file" if $REMOVE_GOLDEN; then echo " - the golden vzdump (--remove-golden)"; fi @@ -856,8 +883,9 @@ run_uninstall() { fi if [[ -d /mnt/felhom-drives ]]; then run rmdir /mnt/felhom-drives 2>/dev/null || true; fi - # 4d. Guarded-mkfs wrapper, guest-hook snippet, lan-resolver dnsmasq snippets (drill R3-R5). + # 4d. Guarded-mkfs + pbs-apply wrappers, guest-hook snippet, lan-resolver dnsmasq snippets (drill R3-R5). if [[ -f /usr/local/sbin/felhom-mkfs-guarded ]]; then run rm -f /usr/local/sbin/felhom-mkfs-guarded; else log_skip " felhom-mkfs-guarded already absent"; fi + if [[ -f /usr/local/sbin/felhom-pbs-apply ]]; then run rm -f /usr/local/sbin/felhom-pbs-apply; else log_skip " felhom-pbs-apply already absent"; fi if [[ -f /var/lib/vz/snippets/felhom-guest-hook.sh ]]; then run rm -f /var/lib/vz/snippets/felhom-guest-hook.sh; fi local dconf _dnsmasq_touched=false for dconf in /etc/dnsmasq.d/felhom-*.conf; do @@ -923,7 +951,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+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." + log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config+baks/user + selfupdate-artifacts/shared-parent/mkfs-wrapper/pbs-apply-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." @@ -1239,9 +1267,14 @@ _byo_disclosure_ack() { + ${AGENT_UNIT} (+ felhom-agent-rollback.service + start-limit drop-in) + ${AGENT_SUDOERS} + state dir ${AGENT_STATE_DIR} + config ${AGENT_CONFIG} (0600) host: /usr/local/sbin/felhom-mkfs-guarded + /usr/local/sbin/felhom-selfupdate-guarded + + /usr/local/sbin/felhom-pbs-apply (PBS-DR apply wrapper — DR capability is baked + on every install; ACTIVATION stays a hub flag, off = zero effect on this host) + felhom-mgmt-watchdog service+timer+script + /etc/tmpfiles.d/felhom-privsep.conf + guest-hook snippet under /var/lib/vz/snippets/ (agent-installed at runtime) - + the 'sudo' package if absent + install state dir ${STATE_DIR} + + the 'sudo' and 'age' packages if absent + install state dir ${STATE_DIR} + wg: an OUTBOUND WireGuard tunnel to the Felhom hub (wg_tunnel.enabled=true — base + infrastructure like the cloudflared tunnel; hands-free peer registration; the + operator can disable it per-customer hub-side on request) EOF if $SKIP_PROVISION; then echo " guest: none (--skip-provision)" @@ -1250,7 +1283,7 @@ EOF echo " + the golden vzdump imported onto storage '${ARCHIVE_STORAGE}'" fi echo " update: operator-signed self-update authority: ${RESOLVED_OP_ID:-NONE (self-update stays dormant)}" - echo " NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), WireGuard tunnels, OOB sshd." + echo " NOT touched in byo mode: root@pam (no break-glass), host DNS (:53), OOB sshd." echo "" if $DRY_RUN; then log_dry "would prompt: Type this host's short hostname ($(hostname -s)) to acknowledge the byo install" @@ -1367,10 +1400,20 @@ step_preflight() { # --acl-storages existence (GL-2): the scoped ACL grants Datastore.* at each of these paths — a # grant on a storage this box doesn't have silently protects nothing (campaign-2 R2 class). byo - # refuses; appliance warns (the default set includes felhom-pbs, absent on most customer boxes). + # refuses; appliance warns. EXCEPTION (v1.15.0, drill retrofit-ACL lesson): `felhom-pbs` is + # EXPECTED absent on a fresh box — the PBS-DR tier apply creates it later, and the grant MUST + # be pre-positioned or the apply-bridge 403s (Datastore.Allocate on /storage/felhom-pbs — the + # drill hit exactly this after narrowing --acl-storages). Do NOT narrow the set to silence + # the info line. local _missing_storages=() _acls for _acls in "${PVE_STORAGES[@]}"; do - pvesm status --storage "$_acls" >/dev/null 2>&1 || _missing_storages+=("$_acls") + if ! pvesm status --storage "$_acls" >/dev/null 2>&1; then + if [[ "$_acls" == "felhom-pbs" ]]; then + log_info " acl storage 'felhom-pbs' not present yet — expected: the PBS-DR tier creates it; the grant is pre-positioned deliberately" + else + _missing_storages+=("$_acls") + fi + fi done if [[ ${#_missing_storages[@]} -gt 0 ]]; then if [[ "$MODE" == "byo" ]]; then @@ -1380,7 +1423,7 @@ step_preflight() { log_warn " acl storage '$_acls' not found (pvesm status) — its grant will point at nothing" done elif [[ "$MODE" == "byo" ]]; then - log_info " acl storages all present: ${PVE_STORAGES[*]}" + log_info " acl storages all present or pre-positioned: ${PVE_STORAGES[*]}" fi # Host DNS :53 gate (byo only) — GL-8/F6. In byo the agent's own lan-resolver stays OFF (a config @@ -1629,7 +1672,11 @@ step_break_glass() { -H "Authorization: Bearer $HOST_API_KEY" -H 'Content-Type: application/json' -d @- 2>/dev/null) newpw="" # scrub the plaintext from the shell var the moment it is vaulted case "$code" in - 200) log_success " root@pam password set + vaulted to the hub (retrieve via the operator /admin path; never logged here)" ;; + 200) log_success " root@pam password set + vaulted to the hub (retrieve via the operator /admin path; never logged here)" + # Drill F-8: say this OUT LOUD — the operator was live-surprised by a 401 at the PVE + # GUI. Pointer only, never the credential. + log_warn " NOTE: the root@pam password just CHANGED — the old one now fails at the PVE web GUI (:8006)." + log_warn " Retrieve the new one at hub → host page (vaulted recovery credential)." ;; 401|403) die "recovery-credential vault rejected ($code) — host key/authorization problem" ;; *) die "recovery-credential vault failed (HTTP $code)" ;; esac @@ -1661,7 +1708,7 @@ step_agent_install() { local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-agent/$ART_AGENT_VER/felhom-agent" log_info " fetching agent binary v$ART_AGENT_VER from Gitea …" if $DRY_RUN; then - log_dry "curl -u -o /tmp/felhom-agent.new $url ; verify sha256=$ART_AGENT_SHA ; install -m0755 -> $AGENT_BIN" + log_dry "$(_dry_auth_hint) -o /tmp/felhom-agent.new $url ; verify sha256=$ART_AGENT_SHA ; install -m0755 -> $AGENT_BIN" else local tmp; tmp=$(mktemp -t felhom-agent.XXXXXX) fetch_verify "$url" "$tmp" "$ART_AGENT_SHA" @@ -1689,6 +1736,21 @@ step_agent_install() { log_success " sudo installed ($(sudo --version 2>/dev/null | head -1))" fi fi + # `age` (drill F-10) — the escrow ceremony's identity-wrap dependency. NOT installed by PVE by + # default; a fresh-box ceremony died on it live (the demo host only had it from its spike-era + # install). DR-tier-by-default: baked on every install, both modes. Idempotent. + if ! command -v age >/dev/null 2>&1; then + if $DRY_RUN; then + log_dry "apt-get install -y age # escrow ceremony identity-wrap dependency (F-10)" + else + log_info " installing the 'age' package (escrow ceremony identity-wrap dependency) …" + DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1 \ + || { apt-get update -q >/dev/null 2>&1; DEBIAN_FRONTEND=noninteractive apt-get install -y -q age >/dev/null 2>&1; } \ + || die "failed to install the 'age' package (the escrow ceremony needs it)" + log_success " age installed ($(age --version 2>/dev/null | head -1))" + fi + fi + # Resolve visudo by absolute path too (non-login SSH PATH can miss /usr/sbin). local VISUDO; VISUDO=$(command -v visudo 2>/dev/null || echo /usr/sbin/visudo) @@ -1790,6 +1852,22 @@ step_agent_install() { log_success " installed /usr/local/sbin/felhom-selfupdate-guarded (0755, the guarded A/B binary-swap path)" fi + # PBS-DR apply wrapper (drill F-7) — the ONLY pbs-apply path the FELHOM_PBSDR sudoers alias + # permits. The sudoers has shipped the alias since the PBS-DR slice; the binary itself did not + # ship, so the 3 pbsdr-* capabilities were born DEGRADED ("binary not found") on every fresh + # box. DR-tier-by-default (2026-07-12): capability is baked on EVERY install (activation is a + # hub flag). Install BEFORE the sudoers, 0755 root:root, bash -n first — the mkfs pattern. + if $DRY_RUN; then + log_dry "fetch configs/felhom-pbs-apply ; bash -n ; install 0755 -> /usr/local/sbin/felhom-pbs-apply" + else + local patmp; patmp=$(mktemp -t felhom-pbsapply.XXXXXX) + fetch_raw "configs/felhom-pbs-apply" "$patmp" + bash -n "$patmp" || { rm -f "$patmp"; die "fetched felhom-pbs-apply failed bash -n — refusing to install"; } + install -m 0755 -o root -g root "$patmp" /usr/local/sbin/felhom-pbs-apply + rm -f "$patmp" + log_success " installed /usr/local/sbin/felhom-pbs-apply (0755, the guarded PBS-DR apply path)" + 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" @@ -1983,8 +2061,8 @@ step_agent_config() { fi if [[ "$MODE" == "byo" ]]; then log_dry "assert (byo) written config: lan_resolver.enable is false/absent" - log_dry "assert (byo) written config: wg_tunnel.enabled is false/absent" log_dry "assert (byo) written config: oob.enabled is false/absent" + log_dry "(byo NOTE: wg_tunnel.enabled=true is now the DEFAULT both modes — WG is base infrastructure; consent = the typed disclosure ack)" fi log_dry "systemctl restart felhom-agent && felhom-agent --config $AGENT_CONFIG --selftest" _state_mark agent_config; return 0 @@ -2036,6 +2114,13 @@ if os.environ.get('INSTALL_MODE') == 'byo': base.setdefault('lan_resolver', {"enable": False}) else: base.setdefault('lan_resolver', {"enable": True}) +# WG tunnel (drill F-9; operator decision 2026-07-12 #5): WG is BASE infrastructure like +# cloudflared — always-on for every install, both modes (it is also the future OOB-management +# transport; the PBS-DR tier merely RIDES it). Registration is hands-free (the drill proved it: +# keygen -> hub peer 10.77.0.x/32 -> handshake, no vouch). setdefault, not override: a +# --preserve-from config that deliberately disabled it is respected (the per-customer opt-out +# lives hub-side; byo consent = the disclosure ack + the tester agreement). +base.setdefault('wg_tunnel', {"enabled": True}) # Day-0 overrides (always authoritative) base['proxmox'] = { "endpoint":"https://127.0.0.1:8006", @@ -2079,20 +2164,22 @@ PY # a customer/BYO box until their production endpoints exist. Parse the JUST-WRITTEN file (same # interpreter pattern as the write above) and refuse to start the daemon on any true value — # a --preserve-from carrying lan_resolver.enable=true is exactly what this catches. + # NOTE (v1.15.0 / decision 5): wg_tunnel.enabled was REMOVED from the byo-forbidden set — WG is + # base infrastructure (always-on both modes, like cloudflared); byo consent moved to the typed + # disclosure ack (the WG tunnel is now on that list) + the tester-agreement disclosure line. if [[ "$MODE" == "byo" ]]; then - AGENT_CONFIG="$AGENT_CONFIG" python3 <<'PY' || die "byo config assert FAILED — see the keys above; a byo box must keep lan_resolver/wg_tunnel/oob off and deployment_mode=byo (fix the preserved config and re-run with --resume)" + AGENT_CONFIG="$AGENT_CONFIG" python3 <<'PY' || die "byo config assert FAILED — see the keys above; a byo box must keep lan_resolver/oob off and deployment_mode=byo (fix the preserved config and re-run with --resume)" import json, os, sys d = json.load(open(os.environ['AGENT_CONFIG'])) bad = [] if d.get('lan_resolver', {}).get('enable'): bad.append('lan_resolver.enable (takes over host DNS on :53)') -if d.get('wg_tunnel', {}).get('enabled'): bad.append('wg_tunnel.enabled (opens an outbound WG tunnel)') if d.get('oob', {}).get('enabled'): bad.append('oob.enabled (starts a second operator sshd)') if d.get('deployment_mode') == 'appliance': bad.append('deployment_mode=appliance (unlocks host-service self-heal on a host we do not own)') if bad: print('byo-forbidden config keys are TRUE: ' + '; '.join(bad), file=sys.stderr) sys.exit(1) PY - log_success " byo config asserts passed (lan_resolver.enable / wg_tunnel.enabled / oob.enabled off; deployment_mode=byo)" + log_success " byo config asserts passed (lan_resolver.enable / oob.enabled off; deployment_mode=byo; wg_tunnel intentionally ON — base infrastructure)" fi # health: read-only selftest (proxmox) must pass before provisioning @@ -2144,7 +2231,7 @@ step_golden() { local url="$GITEA_BASE/api/packages/$GITEA_OWNER/generic/felhom-golden/$ART_GOLDEN_VER/golden.tar.zst" if $DRY_RUN; then - log_dry "curl -u -o /vzdump-lxc-${GOLDEN_VMID}-.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID" + log_dry "$(_dry_auth_hint) -o /vzdump-lxc-${GOLDEN_VMID}-.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID" GOLDEN_VOLID="${ARCHIVE_STORAGE}:backup/vzdump-lxc-${GOLDEN_VMID}-.tar.zst" _state_mark golden; return 0 fi @@ -2267,6 +2354,10 @@ step_verify() { else log_warn "Day-0 provision completed WITH WARNINGS — review the checks above" fi + # Drill F-8: repeat the break-glass pointer in the FINAL summary (the 4b line scrolls away). + if _state_has break_glass; then + log_info "root@pam was rotated + vaulted at step 4b — retrieve at hub → host page (the old GUI password no longer works)." + fi } #------------------------------------------------------------------------------- diff --git a/scripts/hostinstall_gates.py b/scripts/hostinstall_gates.py new file mode 100644 index 0000000..c245f9c --- /dev/null +++ b/scripts/hostinstall_gates.py @@ -0,0 +1,117 @@ +# -*- coding: utf-8 -*- +"""DR-tier-by-default installer gates — mechanical grep-assertions against +felhom-host-install.sh's known failure mode: a Day-0 that silently regresses one of the +drill-swept findings (DRILL-day0-vm-2026-07-12 F-1/F-7/F-9/F-10 + the ACL-narrowing 403). +Run from the repo root: python scripts/hostinstall_gates.py + +Gates (all must pass; non-zero exit on any failure): + 1. version — exactly ONE version source: SCRIPT_VERSION exists, the header line carries + no version literal, and the hub Setup-tab const (hub internal/web/configs.go + hostInstallVersion) equals SCRIPT_VERSION (F-1 structural fix) + 2. age — the `age` package is installed by the agent-install step (F-10) + 3. pbs-apply — configs/felhom-pbs-apply is fetched + installed to + /usr/local/sbin/felhom-pbs-apply (F-7), and the uninstall removes it + 4. wg — the rendered agent.json defaults wg_tunnel enabled=true (F-9 / decision 5), + and the byo assert no longer forbids it + 5. acl — the default PVE_STORAGES set still contains felhom-pbs (narrowing it is the + drill's apply-bridge 403) +""" +import io, os, re, sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SCRIPT = os.path.join(ROOT, "scripts", "felhom-host-install.sh") +HUB_CONFIGS = os.path.join(ROOT, "hub", "internal", "web", "configs.go") + +fails = [] + + +def fail(msg): + fails.append(msg) + print("FAIL:", msg) + + +def ok(msg): + print(" ok:", msg) + + +with io.open(SCRIPT, "r", encoding="utf-8") as f: + src = f.read() +lines = src.splitlines() + +# ── 1. version single-source (F-1) ────────────────────────────────────────────── +m = re.search(r'^SCRIPT_VERSION="(\d+\.\d+\.\d+)"', src, re.M) +if not m: + fail("SCRIPT_VERSION=\"x.y.z\" not found — the single version source is gone") + script_ver = None +else: + script_ver = m.group(1) + ok("SCRIPT_VERSION=%s" % script_ver) + +# header (first 10 lines) must NOT carry its own version literal — that is the F-1 drift. +header = "\n".join(lines[:10]) +if re.search(r'felhom-host-install\.sh\s+v\d+\.\d+\.\d+', header): + fail("header line carries a hardcoded version — SCRIPT_VERSION is the only source (F-1)") +else: + ok("header has no version literal") + +# hub Setup-tab const must equal SCRIPT_VERSION (the copy the drill found at 1.12.0). +if os.path.exists(HUB_CONFIGS) and script_ver: + with io.open(HUB_CONFIGS, "r", encoding="utf-8") as f: + hub_src = f.read() + hm = re.search(r'hostInstallVersion\s*=\s*"(\d+\.\d+\.\d+)"', hub_src) + if not hm: + fail("hub hostInstallVersion const not found in internal/web/configs.go") + elif hm.group(1) != script_ver: + fail("hub Setup-tab hostInstallVersion=%s != SCRIPT_VERSION=%s (F-1: bump both together)" + % (hm.group(1), script_ver)) + else: + ok("hub Setup-tab hostInstallVersion matches (%s)" % hm.group(1)) +else: + if not os.path.exists(HUB_CONFIGS): + fail("hub/internal/web/configs.go not found — cannot cross-check the Setup-tab version") + +# ── 2. age package (F-10) ─────────────────────────────────────────────────────── +# must match the REAL install invocation, not the log_dry echo (red-proof-hardened twice: +# a prefix regex matched "agekit", then a loose one matched the dry-run print line). +if re.search(r'DEBIAN_FRONTEND=noninteractive apt-get install -y -q age\b', src): + ok("age is in the installed package set") +else: + fail("`age` install not found (F-10 — the fresh-box escrow ceremony dies without it)") + +# ── 3. pbs-apply wrapper shipped + removed (F-7) ──────────────────────────────── +if 'fetch_raw "configs/felhom-pbs-apply"' in src: + ok("felhom-pbs-apply is fetched from the agent repo") +else: + fail("configs/felhom-pbs-apply fetch not found (F-7 — pbsdr capabilities born DEGRADED)") +if re.search(r'install -m 0755 -o root -g root "\$patmp" /usr/local/sbin/felhom-pbs-apply', src): + ok("felhom-pbs-apply installed 0755 to /usr/local/sbin") +else: + fail("felhom-pbs-apply install line not found (F-7)") +if re.search(r'rm -f /usr/local/sbin/felhom-pbs-apply', src): + ok("uninstall removes felhom-pbs-apply") +else: + fail("uninstall does not remove /usr/local/sbin/felhom-pbs-apply") + +# ── 4. wg_tunnel default-on (F-9 / decision 5) ────────────────────────────────── +if re.search(r"base\.setdefault\('wg_tunnel',\s*\{\"enabled\":\s*True\}\)", src): + ok("rendered agent.json defaults wg_tunnel.enabled=true") +else: + fail("wg_tunnel enabled-by-default missing from the agent.json render (F-9)") +# the byo assert must NOT forbid wg_tunnel any more (decision 5: WG is base infrastructure). +byo_assert = re.search(r"byo-forbidden config keys.*?sys\.exit\(1\)", src, re.S) +if byo_assert and "wg_tunnel" in byo_assert.group(0): + fail("the byo config assert still forbids wg_tunnel.enabled (decision 5 retired that)") +else: + ok("byo assert no longer forbids wg_tunnel") + +# ── 5. default ACL keeps felhom-pbs (the drill 403) ───────────────────────────── +if re.search(r'^PVE_STORAGES=\([^)]*felhom-pbs[^)]*\)', src, re.M): + ok("PVE_STORAGES default contains felhom-pbs") +else: + fail("felhom-pbs missing from the default PVE_STORAGES — narrowing it 403s the PBS-DR apply-bridge") + +print() +if fails: + print("hostinstall gates: %d FAILURE(S)" % len(fails)) + sys.exit(1) +print("hostinstall gates: ALL PASS")