installer v1.15.0: DR-tier-by-default sweep (F-7 pbs-apply, F-10 age, F-9 wg default-on, F-1 single version + hub const, F-2 dry-run auth, F-8 rotation pointer, ACL felhom-pbs kept) + hostinstall_gates.py
Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 <git>`)
|
||||
instead of the hardcoded `-u <git>` 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`
|
||||
|
||||
+114
-23
@@ -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 <git>"
|
||||
# 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 <git>"; 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 <git> -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 <git> -o <dump>/vzdump-lxc-${GOLDEN_VMID}-<ts>.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID"
|
||||
log_dry "$(_dry_auth_hint) -o <dump>/vzdump-lxc-${GOLDEN_VMID}-<ts>.tar.zst $url ; verify sha256=$ART_GOLDEN_SHA ; set GOLDEN_VOLID"
|
||||
GOLDEN_VOLID="${ARCHIVE_STORAGE}:backup/vzdump-lxc-${GOLDEN_VMID}-<dry-run>.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
|
||||
}
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user