GL-4: uninstaller gap-closure + operator-key day-0 fold (host-install v1.11.0)
Uninstall: NEW 4b4 removes the self-update artifacts (guarded wrapper, .prev/.new.* A/B slots, rollback unit, limits drop-in — derived from configs/felhom-selfupdate-guarded); enrolled drives under /mnt/felhom-drives unmounted plain-umount-only (busy = warn+guidance, never -l/-f; root bind guarded); both modes end with a KEPT-vs-WIPED statement (drives/PBS/hub record/escrow/vaulted recovery credential live on); guest-only mode prints the vmid's bind-store drives. Key-pin: OPERATOR_KEY_* constants (empty until the operator ceremony) + --operator-pubkey-file (argv-validated; comment=key_id required; file overrides constants) -> authz.signers per the agent SignerKey schema; no-keys-resolved KEEPS preserved signers; verify reports armed/dormant WARN. Harness: +13 static cases + GL4-D parity + GL4-INV + PVE-tier GL4 H-U (full-uninstall dry transcript vs live 9201). 28/28 on felhom-pve; red-proofs RP-1..3 run->fail->revert. GO-LIVE-PACKAGE.md ABSENT AGAIN -> status in CONTEXT.md. Live teardown/armed-pin = GL-6. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -1,5 +1,44 @@
|
||||
# Felhom scripts — Changelog
|
||||
|
||||
## felhom-host-install v1.11.0 — uninstaller gap-closure + operator-key day-0 fold (TASK GL-4, go-live G6 + G1-follow-up) (2026-07-08)
|
||||
|
||||
- **Uninstall gap-closure (G6):**
|
||||
- NEW 4b4 block removes the self-update artifacts the install lays down but v1.10.0 never
|
||||
removed: `/usr/local/sbin/felhom-selfupdate-guarded`, the A/B slot files next to the live
|
||||
binary (`felhom-agent.prev` + orphaned `.new.*` temps), `felhom-agent-rollback.service`, and
|
||||
the `felhom-agent.service.d/felhom-agent-limits.conf` drop-in (+dir). Paths derived from the
|
||||
authoritative `felhom-agent/configs/felhom-selfupdate-guarded`; `pending.json` was already
|
||||
covered by the state-dir removal. Tolerate-absent, 4b2 shape.
|
||||
- Enrolled/network drives mounted under `/mnt/felhom-drives/<name>` are now unmounted (deepest
|
||||
first) before the root self-bind — **plain `umount` ONLY, never `-l`/`-f`**: a busy mount gets
|
||||
a warning + "eject via the dashboard or stop the apps and retry" and the root bind is then left
|
||||
alone (previously a child mount made the root umount die mid-teardown). The data is NEVER
|
||||
touched — no wipe/format path exists anywhere near `/mnt/felhom-drives`.
|
||||
- Both modes now END with an explicit **KEPT-vs-WIPED statement**: WIPED mirrors what the mode
|
||||
actually ran; KEPT names the drives + their data (physically removable; busy ones listed as
|
||||
retry), PBS backups + customer namespace (conditional on a pbs storage), the hub host/customer
|
||||
record, the escrow blob, and — when step 4b had vaulted one — the root@pam recovery credential.
|
||||
- Guest-only mode prints the drives the agent's bind store records for that vmid (best-effort
|
||||
from `/var/lib/felhom-agent/guest-binds.json`, generic note otherwise) + "eject BEFORE
|
||||
uninstall" guidance; no umounts there (remaining guests may use the drives).
|
||||
- **Operator-key day-0 fold (the GL-1/G1 key-pin follow-up):** `OPERATOR_KEY_{OPERATIONAL,RECOVERY}_{ID,LINE}`
|
||||
constants (EMPTY until the operator's offline pin ceremony) + `--operator-pubkey-file PATH`
|
||||
(lines `operational|recovery <authorized_keys line>`; the comment field IS the key_id, required;
|
||||
malformed/empty file dies at argv time naming the line; file OVERRIDES the constants with a
|
||||
notice). Resolved keys are written to `authz.signers` in the agent config (exact
|
||||
`config.go` SignerKey schema; `nonce_store_path` left to the agent default). **Preserve rule:**
|
||||
script/file keys are authoritative ONLY when non-empty — a reinstall with no keys KEEPS a
|
||||
preserved config's signers (never silently clobber a manually-pinned box; replacing preserved
|
||||
signers logs a pin-rotation notice). byo disclosure names the operational key_id (or "NONE —
|
||||
dormant"); verify reports `authz signers: N` (armed) or a dormant WARN (dormant = safe default).
|
||||
- Harness: +13 static cases (GL4-C1..C5 incl. 5 live key-file refusals + grep shapes, GL4-D
|
||||
disclosure↔uninstall parity, GL4-INV no-forced-unmount/no-format invariant) + PVE-tier **GL4 H-U**
|
||||
(full uninstall DRY transcript against the live guest, state-override protected). 28/28 PASS on
|
||||
felhom-pve; red-proofs RP-1 (4b4 dropped → parity FAILs), RP-2 (preserve guard dropped → C4
|
||||
FAILs), RP-3 (role-die dropped → C3a FAILs) all run→fail→revert on scratch copies.
|
||||
- NOT live-validated (GL-6): the real (non-dry) uninstall with mounted/busy drives, the statement
|
||||
on a real teardown, and an armed key-pin install end-to-end.
|
||||
|
||||
## felhom-host-install v1.10.0 — --mode appliance|byo install profile (TASK GL-2, go-live G2/G4/G5) (2026-07-07)
|
||||
|
||||
- **`--mode appliance|byo` is now REQUIRED** for a fresh install / `--resume` (no default — the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
#===============================================================================
|
||||
# felhom-host-install.sh v1.10.0
|
||||
# felhom-host-install.sh v1.11.0
|
||||
# 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
|
||||
@@ -28,6 +28,15 @@
|
||||
# stay off), pool+ACL verify asserts (BOTH modes — campaign-2 R2 lesson), and --preflight-only.
|
||||
# Test harness: scripts/hostinstall-mode-harness.sh (static tier runs anywhere; PVE tier dry-only).
|
||||
#
|
||||
# v1.11.0 (GL-4, go-live G6 + the G1 key-pin fold): (A) uninstall gap-closure — the teardown now
|
||||
# also removes the self-update artifacts (guarded wrapper, .prev/.new.* A/B slots, rollback unit,
|
||||
# start-limit drop-in), unmounts every enrolled drive under /mnt/felhom-drives (plain umount only,
|
||||
# NEVER -l/-f; busy = warn + guidance, data always stays on the drive), and ends with an explicit
|
||||
# KEPT-vs-WIPED statement (PBS backups, hub record, escrow, vaulted recovery credential live on).
|
||||
# (B) operator-key day-0 fold — OPERATOR_KEY_* constants (empty until the pin ceremony) and
|
||||
# --operator-pubkey-file write authz.signers into the agent config at step 6; empty keys keep a
|
||||
# preserved config's signers (never clobber a manually-pinned box); verify reports armed/dormant.
|
||||
#
|
||||
# Grounding: documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md
|
||||
#
|
||||
# Usage:
|
||||
@@ -76,6 +85,12 @@
|
||||
#
|
||||
# --passphrase-file PATH read the retrieval passphrase from a 0600 file
|
||||
# (default: secure no-echo prompt)
|
||||
# --operator-pubkey-file PATH pin the operator signing PUBLIC keys at day-0 (GL-4). One key per
|
||||
# line: "operational <authorized_keys line>" or "recovery <authorized_keys
|
||||
# line>" (# comments/blank ok; the authorized_keys comment field is the
|
||||
# key_id and is required). Overrides the script's OPERATOR_KEY_* constants.
|
||||
# Written to authz.signers in the agent config; no keys resolved = a
|
||||
# preserved config's signers are KEPT, else self-update stays dormant.
|
||||
# --preserve-from PATH merge non-Day-0 sections (privileged/storage/backup/
|
||||
# local_api/authz/lan_resolver) from an existing config
|
||||
# --preserve-state-from PATH carry the prior agent leaf+key+token-store (local-api.crt/key,
|
||||
@@ -129,7 +144,17 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_VERSION="1.10.0" # keep in sync with the header line at the top of this file
|
||||
SCRIPT_VERSION="1.11.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
|
||||
# in one commit (or pass --operator-pubkey-file at install time, which overrides them). Empty =
|
||||
# no authz.signers written = agent self-update stays DORMANT (the safe default; the verify step
|
||||
# warns). PUBLIC keys only — this script never generates, reads, or references private key material.
|
||||
OPERATOR_KEY_OPERATIONAL_ID="" # key_id = the authorized_keys comment, e.g. "felhom-op-1"
|
||||
OPERATOR_KEY_OPERATIONAL_LINE="" # full authorized_keys line: "ssh-ed25519 AAAA… felhom-op-1"
|
||||
OPERATOR_KEY_RECOVERY_ID="" # cold key; authorizes only key-rotation/break-glass
|
||||
OPERATOR_KEY_RECOVERY_LINE=""
|
||||
|
||||
#-------------------------------------------------------------------------------
|
||||
# Logging (mirrors felhom-controller/scripts/docker-setup.sh)
|
||||
@@ -166,6 +191,7 @@ SYSDATA_GROW=""
|
||||
CPU_CORES="" # --cores: optional appliance CPU-core cap (empty/unset = golden default)
|
||||
MEM_MIB="" # --memory: optional appliance RAM cap in MiB (empty/unset = golden default)
|
||||
PASSPHRASE_FILE=""
|
||||
OPERATOR_PUBKEY_FILE="" # --operator-pubkey-file: "operational|recovery <authorized_keys line>" per line; overrides the OPERATOR_KEY_* constants (GL-4)
|
||||
PRESERVE_FROM=""
|
||||
PRESERVE_STATE_FROM="" # dir holding a prior local-api.{crt,key} + local-tokens.log to carry over (keeps the pin stable across a reinstall)
|
||||
ALLOW_NEW_LEAF=false # opt-in to intentionally regenerate the agent leaf on a populated host (else the guard refuses)
|
||||
@@ -495,12 +521,79 @@ remove_old_broad_acl() {
|
||||
if _role_exists "$PVE_ROLE"; then run pveum role delete "$PVE_ROLE"; else log_skip " old broad role $PVE_ROLE already absent"; fi
|
||||
}
|
||||
|
||||
# _guest_drive_note VMID — GL-4 (guest-only mode): best-effort list of the drives the agent's bind
|
||||
# store records for THIS vmid (/var/lib/felhom-agent/guest-binds.json, vmid -> durable-ids); store
|
||||
# unreadable/absent → the generic note. Read-only, never dies.
|
||||
_guest_drive_note() {
|
||||
local vmid="$1" binds=""
|
||||
binds=$(python3 -c "import json
|
||||
try:
|
||||
d=json.load(open('$AGENT_STATE_DIR/guest-binds.json'))
|
||||
print(', '.join(d.get('$vmid',[])))
|
||||
except Exception:
|
||||
pass" 2>/dev/null || true)
|
||||
if [[ -n "$binds" ]]; then
|
||||
log_info " drives recorded as bound to guest $vmid: $binds"
|
||||
log_info " they stay mounted (other guests may share the host) — eject via the dashboard BEFORE uninstalling if this guest owned them."
|
||||
else
|
||||
log_info " enrolled drives (if any) stay mounted; eject a drive via the dashboard BEFORE uninstall if it belonged to this guest."
|
||||
fi
|
||||
}
|
||||
|
||||
# _uninstall_statement full|guest-only — GL-4: the explicit end-of-teardown KEPT-vs-WIPED statement.
|
||||
# WIPED mirrors what THIS mode actually ran; KEPT names everything that deliberately lives on.
|
||||
# Statement-only: this script NEVER deletes PBS backups, hub records, escrow blobs, or drive data.
|
||||
# Reads run_uninstall's locals (vmid, pool_removed, _busy_mounts, _had_break_glass) via bash's
|
||||
# dynamic scoping — call it from run_uninstall only.
|
||||
_uninstall_statement() {
|
||||
local scope="$1"
|
||||
echo ""
|
||||
log_step "kept vs wiped — read before pulling drives or closing the customer out"
|
||||
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 " - 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")"
|
||||
echo " - the install state file"
|
||||
if $REMOVE_GOLDEN; then echo " - the golden vzdump (--remove-golden)"; fi
|
||||
else
|
||||
echo " - NOTHING host-level (other Felhom guests remain: agent, token/ACL, pool, state all stay)"
|
||||
fi
|
||||
echo " KEPT (lives on deliberately — remove/rotate these out-of-band if the customer is leaving):"
|
||||
if [[ "$scope" == "full" ]]; then
|
||||
echo " - the enrolled drives + ALL data under /mnt/felhom-drives — unmounted only, NEVER wiped;"
|
||||
if [[ ${#_busy_mounts[@]} -gt 0 ]]; then
|
||||
echo " physically removable now, EXCEPT still mounted (busy — stop the apps and retry): ${_busy_mounts[*]}"
|
||||
else
|
||||
echo " the drives are physically removable now."
|
||||
fi
|
||||
else
|
||||
echo " - the enrolled drives + ALL data under /mnt/felhom-drives — left MOUNTED (remaining guests may use them)"
|
||||
fi
|
||||
if pvesm status 2>/dev/null | awk '$2=="pbs"{found=1} END{exit !found}'; then
|
||||
echo " - the PBS backups + this customer's namespace on the PBS side — delete there if wanted"
|
||||
fi
|
||||
echo " - the hub host/customer record + report history (operator UI / DB)"
|
||||
echo " - the escrow blob in the hub, if one exists (operator UI)"
|
||||
if $_had_break_glass; then
|
||||
echo " - the hub-vaulted root@pam recovery credential — the box KEEPS the password step 4b set; rotate it if the box leaves Felhom management"
|
||||
fi
|
||||
echo ""
|
||||
}
|
||||
|
||||
# run_uninstall — the full guarded teardown. Every mutation goes through run() so --dry-run prints it
|
||||
# and executes nothing. Ordering is the reverse of install: guest -> agent -> pveum(ACL,token,user,
|
||||
# role) -> golden(opt-in) -> state file. See the TASK spec §7/§8.
|
||||
run_uninstall() {
|
||||
log_step "UNINSTALL — local host teardown"
|
||||
|
||||
# GL-4: snapshot state facts BEFORE any removal (the closing statement needs them; the state
|
||||
# file itself is deleted in step 7).
|
||||
local _had_break_glass=false _busy_mounts=()
|
||||
_state_has break_glass && _had_break_glass=true
|
||||
|
||||
# 1. Resolve the target vmid: --vmid, else the recorded provisioned_vmid, else die.
|
||||
local state_vmid vmid pool_removed=false
|
||||
state_vmid=$(_state_get provisioned_vmid)
|
||||
@@ -575,6 +668,9 @@ run_uninstall() {
|
||||
echo ""
|
||||
log_warn "Other Felhom guests remain (${others_csv}); leaving the agent + PVE token + state in place."
|
||||
log_warn "Re-run --uninstall --force to remove host-level components anyway (this orphans ${others_csv})."
|
||||
# GL-4 (Scenario B): NO umounts in guest-only mode — drives may serve the remaining guests.
|
||||
_guest_drive_note "$vmid"
|
||||
_uninstall_statement guest-only
|
||||
log_success "UNINSTALL (guest-only) complete — removed guest $vmid; host-level components preserved."
|
||||
log_info " NOTE: the host record still exists in the hub — remove it there if desired."
|
||||
$DRY_RUN && log_warn " DRY-RUN: nothing above was actually executed."
|
||||
@@ -651,6 +747,27 @@ run_uninstall() {
|
||||
if [[ -d /etc/felhom-sshd ]]; then run rm -rf /etc/felhom-sshd; fi
|
||||
if id felhom-op >/dev/null 2>&1; then run userdel -r felhom-op 2>/dev/null || run userdel felhom-op; fi
|
||||
|
||||
# 4b4. Self-update artifacts (TASK D1; GL-4 gap-closure). Paths derived from the AUTHORITATIVE
|
||||
# list in felhom-agent configs/felhom-selfupdate-guarded: the wrapper itself, the A/B slot
|
||||
# files it creates next to the live binary (.prev snapshot + orphaned .new.* temps), plus
|
||||
# the rollback unit + start-limit drop-in step 5 installs alongside it. pending.json lives
|
||||
# under $AGENT_STATE_DIR (already removed in 4). Tolerate-absent throughout.
|
||||
if systemctl list-unit-files felhom-agent-rollback.service >/dev/null 2>&1; then
|
||||
systemctl is-active --quiet felhom-agent-rollback 2>/dev/null && run systemctl stop felhom-agent-rollback
|
||||
systemctl is-enabled --quiet felhom-agent-rollback 2>/dev/null && run systemctl disable felhom-agent-rollback
|
||||
fi
|
||||
run systemctl reset-failed felhom-agent-rollback.service 2>/dev/null || true
|
||||
local sua
|
||||
for sua in /usr/local/sbin/felhom-selfupdate-guarded /etc/systemd/system/felhom-agent-rollback.service \
|
||||
"${AGENT_BIN}.prev"; do
|
||||
if [[ -e "$sua" ]]; then run rm -f "$sua"; fi
|
||||
done
|
||||
for sua in "${AGENT_BIN}".new.*; do [[ -e "$sua" ]] && run rm -f "$sua"; done
|
||||
if [[ -d "${AGENT_UNIT}.d" ]]; then
|
||||
if [[ -f "${AGENT_UNIT}.d/felhom-agent-limits.conf" ]]; then run rm -f "${AGENT_UNIT}.d/felhom-agent-limits.conf"; fi
|
||||
run rmdir "${AGENT_UNIT}.d" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 4c. Shared-parent unit + wrapper + /mnt/felhom-drives (agent-installed at runtime; drill R2).
|
||||
# Stop/disable, remove unit + script, unbind + remove the (empty) parent dir. Tolerate-absent.
|
||||
if systemctl list-unit-files felhom-shared-parent.service 2>/dev/null | grep -q felhom-shared-parent; then
|
||||
@@ -662,7 +779,31 @@ run_uninstall() {
|
||||
if [[ -f /etc/systemd/system/felhom-shared-parent.service ]]; then run rm -f /etc/systemd/system/felhom-shared-parent.service; else log_skip " felhom-shared-parent.service already absent"; fi
|
||||
if [[ -f /usr/local/sbin/felhom-shared-parent.sh ]]; then run rm -f /usr/local/sbin/felhom-shared-parent.sh; fi
|
||||
run systemctl daemon-reload
|
||||
if mountpoint -q /mnt/felhom-drives 2>/dev/null; then run umount /mnt/felhom-drives; fi
|
||||
# GL-4: unmount every enrolled/network drive mounted UNDER /mnt/felhom-drives (deepest first)
|
||||
# BEFORE the root self-bind. Plain umount ONLY — NEVER -l/-f: a lazy/forced unmount on a busy
|
||||
# data mount risks the customer's data; a busy mount gets a warning + guidance instead. The
|
||||
# DATA STAYS ON THE DRIVE — nothing here (or anywhere in this script) wipes or formats it.
|
||||
local dmnt
|
||||
while IFS= read -r dmnt; do
|
||||
[[ -n "$dmnt" ]] || continue
|
||||
if $DRY_RUN; then
|
||||
log_dry "umount $dmnt # data stays on the drive"
|
||||
elif umount "$dmnt" 2>/dev/null; then
|
||||
log_success " unmounted $dmnt (data stays on the drive)"
|
||||
else
|
||||
log_warn " $dmnt is busy — NOT forcing (never umount -l/-f). Eject the drive via the dashboard, or stop the apps using it and re-run."
|
||||
_busy_mounts+=("$dmnt")
|
||||
continue
|
||||
fi
|
||||
run rmdir "$dmnt" 2>/dev/null || true
|
||||
done < <(findmnt -rn -o TARGET 2>/dev/null | grep '^/mnt/felhom-drives/' | sort -r || true)
|
||||
if mountpoint -q /mnt/felhom-drives 2>/dev/null; then
|
||||
if [[ ${#_busy_mounts[@]} -gt 0 ]]; then
|
||||
log_warn " /mnt/felhom-drives root bind left mounted (busy drive mounts above must go first)"
|
||||
else
|
||||
run umount /mnt/felhom-drives
|
||||
fi
|
||||
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).
|
||||
@@ -730,9 +871,9 @@ run_uninstall() {
|
||||
if [[ -f "$STATE_FILE" ]]; then run rm -f "$STATE_FILE"; else log_skip " $STATE_FILE already absent"; fi
|
||||
run rmdir "$STATE_DIR" 2>/dev/null || true
|
||||
|
||||
# 8. Summary.
|
||||
echo ""
|
||||
log_success "UNINSTALL complete — removed: guest $vmid, the felhom-agent (unit/sudoers/binary/state/config/user + 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."
|
||||
# 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."
|
||||
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."
|
||||
@@ -841,6 +982,7 @@ while [[ $# -gt 0 ]]; do
|
||||
--cores) CPU_CORES="$2"; shift 2 ;;
|
||||
--memory) MEM_MIB="$2"; shift 2 ;;
|
||||
--passphrase-file) PASSPHRASE_FILE="$2"; shift 2 ;;
|
||||
--operator-pubkey-file) OPERATOR_PUBKEY_FILE="$2"; shift 2 ;;
|
||||
--preserve-from) PRESERVE_FROM="$2"; shift 2 ;;
|
||||
--preserve-state-from) PRESERVE_STATE_FROM="$2"; shift 2 ;;
|
||||
--allow-new-leaf) ALLOW_NEW_LEAF=true; shift ;;
|
||||
@@ -954,6 +1096,46 @@ if [[ "$MODE" == "byo" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# GL-4: resolve the operator signing keys — script constants by default; --operator-pubkey-file
|
||||
# OVERRIDES them (C5). Validated HERE (before the passphrase prompt, before any step) so a bad key
|
||||
# file dies fast and the harness can exercise it on a non-PVE machine. File format: one key per
|
||||
# line, "operational <authorized_keys line>" or "recovery <authorized_keys line>"; '#' comments and
|
||||
# blank lines allowed. The key_id is the authorized_keys COMMENT field — required (the agent's
|
||||
# signed-op verifier addresses keys by key_id).
|
||||
RESOLVED_OP_ID="$OPERATOR_KEY_OPERATIONAL_ID"; RESOLVED_OP_LINE="$OPERATOR_KEY_OPERATIONAL_LINE"
|
||||
RESOLVED_REC_ID="$OPERATOR_KEY_RECOVERY_ID"; RESOLVED_REC_LINE="$OPERATOR_KEY_RECOVERY_LINE"
|
||||
if [[ -n "$OPERATOR_PUBKEY_FILE" ]]; then
|
||||
[[ -f "$OPERATOR_PUBKEY_FILE" ]] || die "--operator-pubkey-file not found: $OPERATOR_PUBKEY_FILE"
|
||||
if [[ -n "$RESOLVED_OP_LINE$RESOLVED_REC_LINE" ]]; then
|
||||
log_info "--operator-pubkey-file overrides the script's built-in operator key constants"
|
||||
fi
|
||||
RESOLVED_OP_ID=""; RESOLVED_OP_LINE=""; RESOLVED_REC_ID=""; RESOLVED_REC_LINE=""
|
||||
_kseen=false
|
||||
while IFS= read -r _kline || [[ -n "$_kline" ]]; do
|
||||
_kline="${_kline%$'\r'}"
|
||||
[[ -z "$_kline" || "$_kline" == \#* ]] && continue
|
||||
_krole="${_kline%% *}"; _krest="${_kline#* }"
|
||||
case "$_krole" in
|
||||
operational|recovery) ;;
|
||||
*) die "--operator-pubkey-file: unknown role '$_krole' (want operational|recovery) in line: $_kline" ;;
|
||||
esac
|
||||
read -r _ktype _kb64 _kid _ <<<"$_krest"
|
||||
[[ "$_ktype" == ssh-* || "$_ktype" == sk-ssh-* || "$_ktype" == ecdsa-* ]] \
|
||||
|| die "--operator-pubkey-file: not an authorized_keys line (bad key type '${_ktype:-<empty>}') in line: $_kline"
|
||||
[[ -n "$_kb64" && "$_kb64" =~ ^[A-Za-z0-9+/=]+$ ]] \
|
||||
|| die "--operator-pubkey-file: not an authorized_keys line (missing/invalid key material) in line: $_kline"
|
||||
[[ -n "$_kid" ]] \
|
||||
|| die "--operator-pubkey-file: key line has no comment field — the comment IS the key_id; add one (e.g. felhom-op-1): $_kline"
|
||||
if [[ "$_krole" == operational ]]; then
|
||||
RESOLVED_OP_ID="$_kid"; RESOLVED_OP_LINE="$_krest"
|
||||
else
|
||||
RESOLVED_REC_ID="$_kid"; RESOLVED_REC_LINE="$_krest"
|
||||
fi
|
||||
_kseen=true
|
||||
done < "$OPERATOR_PUBKEY_FILE"
|
||||
$_kseen || die "--operator-pubkey-file has no key lines (empty/comments only): $OPERATOR_PUBKEY_FILE"
|
||||
fi
|
||||
|
||||
# Resume mode-mismatch (C4): an install keeps the mode it started with — mode-flipping a half-done
|
||||
# install would skip already-completed steps under the WRONG profile's guarantees.
|
||||
if $RESUME; then
|
||||
@@ -1017,6 +1199,7 @@ EOF
|
||||
echo " guest: the provisioned Felhom LXC (vmid ${VMID}, capped ${CPU_CORES} cores / ${MEM_MIB} MiB) + its volumes"
|
||||
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 ""
|
||||
if $DRY_RUN; then
|
||||
@@ -1716,6 +1899,11 @@ step_agent_config() {
|
||||
|
||||
if $DRY_RUN; then
|
||||
log_dry "write $AGENT_CONFIG (0600): proxmox{endpoint,node=$NODE,token=<secret>,tls.fingerprint=$fp} hub{url=$HUB_URL,host_id=$HOST_ID,api_key=<secret>} local_api{$BRIDGE_ADDR}"
|
||||
if [[ -n "$RESOLVED_OP_LINE$RESOLVED_REC_LINE" ]]; then
|
||||
log_dry "write authz.signers: operational=${RESOLVED_OP_ID:-<none>} recovery=${RESOLVED_REC_ID:-<none>} (operator-signed self-update authority)"
|
||||
else
|
||||
log_dry "authz.signers: none resolved — self-update stays dormant (preserved signers, if any, are kept)"
|
||||
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"
|
||||
@@ -1730,8 +1918,10 @@ step_agent_config() {
|
||||
# Secrets passed via env (NOT argv) to avoid ps exposure.
|
||||
PVE_TOKEN="$PVE_TOKEN" HOST_API_KEY="$HOST_API_KEY" \
|
||||
NODE="$NODE" FP="$fp" HUB_URL="$HUB_URL" HOST_ID="$HOST_ID" BRIDGE_ADDR="$BRIDGE_ADDR" \
|
||||
OP_KEY_ID="$RESOLVED_OP_ID" OP_KEY_LINE="$RESOLVED_OP_LINE" \
|
||||
REC_KEY_ID="$RESOLVED_REC_ID" REC_KEY_LINE="$RESOLVED_REC_LINE" \
|
||||
PRESERVE_FROM="$PRESERVE_FROM" INSTALL_MODE="$MODE" OUT="$AGENT_CONFIG" python3 <<'PY'
|
||||
import json, os
|
||||
import json, os, sys
|
||||
out = os.environ['OUT']
|
||||
base = {}
|
||||
pf = os.environ.get('PRESERVE_FROM','')
|
||||
@@ -1778,6 +1968,21 @@ base['hub'] = {
|
||||
"poll_seconds": base.get('hub',{}).get('poll_seconds',900),
|
||||
"timeout_seconds": base.get('hub',{}).get('timeout_seconds',30),
|
||||
}
|
||||
# GL-4: operator-signed self-update authority (authz.signers, schema = agent config.go SignerKey).
|
||||
# Script/file-provided keys are authoritative ONLY when non-empty — a reinstall with NO keys keeps
|
||||
# a manually-pinned box's preserved signers (never silently clobber; C4). nonce_store_path is never
|
||||
# invented here: the agent defaults it, and a preserved value rides along untouched in base['authz'].
|
||||
signers = []
|
||||
if os.environ.get('OP_KEY_LINE',''):
|
||||
signers.append({"key_id": os.environ['OP_KEY_ID'], "role": "operational", "public_key": os.environ['OP_KEY_LINE']})
|
||||
if os.environ.get('REC_KEY_LINE',''):
|
||||
signers.append({"key_id": os.environ['REC_KEY_ID'], "role": "recovery", "public_key": os.environ['REC_KEY_LINE']})
|
||||
if signers:
|
||||
authz = base.setdefault('authz', {})
|
||||
prev = authz.get('signers') or []
|
||||
if prev:
|
||||
print("[config] replacing %d preserved authz signer(s) with the script/file-provided operator key(s) (pin rotation)" % len(prev), file=sys.stderr)
|
||||
authz['signers'] = signers
|
||||
fd = os.open(out, os.O_WRONLY|os.O_CREAT|os.O_TRUNC, 0o600)
|
||||
with os.fdopen(fd,'w') as f:
|
||||
json.dump(base, f, indent=2); f.write('\n')
|
||||
@@ -1949,6 +2154,14 @@ step_verify() {
|
||||
log_error " acl: $_arole@$_apath MISSING for the user and/or the token (re-apply with --rescope-acl)"; ok=false
|
||||
fi
|
||||
done
|
||||
# GL-4: operator-key pin state. Dormant is the SAFE default, not an error — WARN only.
|
||||
local _signers
|
||||
_signers=$(python3 -c "import json;print(len(json.load(open('$AGENT_CONFIG')).get('authz',{}).get('signers') or []))" 2>/dev/null || echo 0)
|
||||
if [[ "${_signers:-0}" -ge 1 ]]; then
|
||||
log_success " authz signers: $_signers (operator-signed self-update armed)"
|
||||
else
|
||||
log_warn " no operator key pinned — agent self-update stays dormant (pin via --operator-pubkey-file or the script's OPERATOR_KEY_* constants; the key ceremony is an operator step)"
|
||||
fi
|
||||
# controller container healthy in-guest — bounded wait (the post-provision reboot + docker start
|
||||
# take a while, especially on modest hardware; drill R6 re-verify)
|
||||
local cstat="" _waited=0
|
||||
|
||||
@@ -189,6 +189,90 @@ else
|
||||
verdict FAIL "INV-3 usage documents --mode appliance|byo" "rc=$rc"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- GL-4 static tier (key-pin + uninstall parity) ---"
|
||||
|
||||
# GL4-C3: --operator-pubkey-file refusals (each dies at argv-validation, before any host access).
|
||||
GOODKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeFakeFakeFakeFakeFakeFakeFakeFakeFakeFake felhom-op-1"
|
||||
printf 'signer %s\n' "$GOODKEY" > "$WORK/keys-badrole"
|
||||
expect_die "GL4-C3a key file: unknown role refused" \
|
||||
"unknown role 'signer' (want operational|recovery)" \
|
||||
-- --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-badrole"
|
||||
printf 'operational not-a-key-at-all\n' > "$WORK/keys-badline"
|
||||
expect_die "GL4-C3b key file: non-authorized_keys line refused" \
|
||||
"not an authorized_keys line" \
|
||||
-- --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-badline"
|
||||
printf 'operational ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeFakeFake\n' > "$WORK/keys-nocomment"
|
||||
expect_die "GL4-C3c key file: missing key_id comment refused" \
|
||||
"no comment field" \
|
||||
-- --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-nocomment"
|
||||
printf '# only a comment\n\n' > "$WORK/keys-empty"
|
||||
expect_die "GL4-C3d key file: empty file refused" \
|
||||
"has no key lines" \
|
||||
-- --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-empty"
|
||||
expect_die "GL4-C3e key file: missing file refused" \
|
||||
"--operator-pubkey-file not found" \
|
||||
-- --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-nonexistent"
|
||||
|
||||
# GL4-C2 (positive shape, runtime): a VALID key file passes resolution — the script must die LATER
|
||||
# (root/pveum/hub preflight, machine-dependent) and NEVER with a key-file error.
|
||||
printf 'operational %s\nrecovery ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFakeFakeRecovery felhom-rec-1\n' "$GOODKEY" > "$WORK/keys-good"
|
||||
run_script --customer-id t --mode appliance --operator-pubkey-file "$WORK/keys-good" --hub-url https://127.0.0.1:9
|
||||
if [[ $rc -ne 0 && "$out" != *"--operator-pubkey-file"* && "$out" != *"authorized_keys line"* && "$out" != *"unknown role"* ]]; then
|
||||
verdict PASS "GL4-C2 valid key file accepted (dies later, not at key parse)"
|
||||
else
|
||||
verdict FAIL "GL4-C2 valid key file accepted" "rc=$rc; $(echo "$out" | tail -2 | tr '\n' ' ')"
|
||||
fi
|
||||
|
||||
# GL4-C4/C5 (grep shapes): the write-guard + override mechanics exist in the script text.
|
||||
if grep -q '^if signers:$' "$SCRIPT" && grep -q "replacing %d preserved authz signer" "$SCRIPT"; then
|
||||
verdict PASS "GL4-C4 signers-only-when-nonempty guard + preserve/replace notice present"
|
||||
else
|
||||
verdict FAIL "GL4-C4 signers-only-when-nonempty guard + preserve/replace notice present"
|
||||
fi
|
||||
if grep -q 'overrides the script.s built-in operator key constants' "$SCRIPT" \
|
||||
&& grep -q 'RESOLVED_OP_ID=""; RESOLVED_OP_LINE=""; RESOLVED_REC_ID=""; RESOLVED_REC_LINE=""' "$SCRIPT"; then
|
||||
verdict PASS "GL4-C5 file-overrides-constants mechanics present (notice + reset)"
|
||||
else
|
||||
verdict FAIL "GL4-C5 file-overrides-constants mechanics present"
|
||||
fi
|
||||
if grep -q 'no operator key pinned' "$SCRIPT" && grep -B1 'no operator key pinned' "$SCRIPT" | grep -q 'log_warn'; then
|
||||
verdict PASS "GL4-C1 verify dormant path is a WARN (not an error)"
|
||||
else
|
||||
verdict FAIL "GL4-C1 verify dormant path is a WARN (not an error)"
|
||||
fi
|
||||
|
||||
# GL4-D: disclosure↔uninstall parity — every host artifact the byo disclosure names must be covered
|
||||
# (removed or explicitly KEPT) in the uninstall section (_guest_drive_note.._end of run_uninstall).
|
||||
ustart=$(grep -n '^_guest_drive_note()' "$SCRIPT" | cut -d: -f1)
|
||||
uend=$(grep -n '^# run_adopt_pool' "$SCRIPT" | cut -d: -f1)
|
||||
if [[ -n "$ustart" && -n "$uend" && "$ustart" -lt "$uend" ]]; then
|
||||
usect=$(sed -n "${ustart},${uend}p" "$SCRIPT")
|
||||
d_missing=""
|
||||
for tok in 'felhom-selfupdate-guarded' 'felhom-agent-rollback.service' 'felhom-agent-limits.conf' \
|
||||
'.prev' 'felhom-mgmt-watchdog' 'felhom-privsep.conf' 'felhom-mkfs-guarded' \
|
||||
'felhom-guest-hook' '/mnt/felhom-drives' 'AGENT_SUDOERS' 'AGENT_STATE_DIR' \
|
||||
'remove_scoped_acl' 'pveum user token remove' 'pveum pool delete' 'STATE_FILE'; do
|
||||
echo "$usect" | grep -qF "$tok" || d_missing+="$tok "
|
||||
done
|
||||
if [[ -z "$d_missing" ]]; then
|
||||
verdict PASS "GL4-D disclosure↔uninstall parity (all artifact tokens covered)"
|
||||
else
|
||||
verdict FAIL "GL4-D disclosure↔uninstall parity" "uncovered: $d_missing"
|
||||
fi
|
||||
else
|
||||
verdict FAIL "GL4-D disclosure↔uninstall parity" "could not locate the uninstall section"
|
||||
fi
|
||||
|
||||
# GL4-INV: no forced/lazy unmount and no format op on the drives root — REAL invocations only
|
||||
# (comment lines and log_* guidance strings legitimately SAY "never umount -l/-f").
|
||||
if ! grep -vE '^[[:space:]]*#|log_(warn|info|dry|error|success|skip)' "$SCRIPT" | grep -E 'umount +-(l|f)' >/dev/null \
|
||||
&& ! grep -vE '^[[:space:]]*#' "$SCRIPT" | grep -E '(mkfs|wipefs) [^|]*/mnt/felhom-drives' >/dev/null; then
|
||||
verdict PASS "GL4-INV no umount -l/-f, no mkfs/wipefs invocation on /mnt/felhom-drives"
|
||||
else
|
||||
verdict FAIL "GL4-INV no umount -l/-f, no mkfs/wipefs invocation on /mnt/felhom-drives"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "--- PVE tier ---"
|
||||
if ! command -v pveum >/dev/null 2>&1 || [[ "$(id -u)" != 0 ]]; then
|
||||
@@ -201,6 +285,40 @@ else
|
||||
-- --customer-id t --mode byo --cores 4 --memory 8192 \
|
||||
--acl-storages "local definitely-not-a-storage" --dry-run
|
||||
|
||||
# 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.
|
||||
hu_vmid=$(for id in $( { pct list 2>/dev/null; qm list 2>/dev/null; } | awk "{print \$1}" | grep -E "^[0-9]+$" ); do
|
||||
pct config "$id" 2>/dev/null | grep -q "mp=/etc/felhom-bootstrap" && { echo "$id"; break; }
|
||||
done)
|
||||
if [[ -z "$hu_vmid" ]]; then
|
||||
verdict SKIP "GL4 H-U uninstall dry transcript" "no Felhom guest on this host to target"
|
||||
else
|
||||
run_script --uninstall --vmid "$hu_vmid" --dry-run
|
||||
hu_ok=true; hu_why=""
|
||||
[[ $rc -eq 0 ]] || { hu_ok=false; hu_why+="rc=$rc "; }
|
||||
for want in "felhom-selfupdate-guarded" "kept vs wiped" "NEVER wiped"; do
|
||||
[[ "$out" == *"$want"* ]] || { hu_ok=false; hu_why+="missing '$want' "; }
|
||||
done
|
||||
# MUST NOT: forced/lazy unmounts, or ANY destructive op (mkfs/wipefs/rm) on a drive-data
|
||||
# path. Removing the felhom-mkfs-guarded WRAPPER from /usr/local/sbin is legitimate.
|
||||
for bad in "umount -l" "umount -f"; do
|
||||
[[ "$out" != *"$bad"* ]] || { hu_ok=false; hu_why+="contains '$bad' "; }
|
||||
done
|
||||
if echo "$out" | grep -E '(mkfs|wipefs|rm |rm -rf).*/mnt/felhom-drives/' >/dev/null; then
|
||||
hu_ok=false; hu_why+="destructive op on a /mnt/felhom-drives/ path "
|
||||
fi
|
||||
# drive umount lines only when child mounts exist — assert conditionally
|
||||
if findmnt -rn -o TARGET 2>/dev/null | grep -q '^/mnt/felhom-drives/'; then
|
||||
[[ "$out" == *"data stays on the drive"* ]] || { hu_ok=false; hu_why+="missing per-drive umount lines "; }
|
||||
fi
|
||||
if $hu_ok; then
|
||||
verdict PASS "GL4 H-U uninstall dry transcript (guest $hu_vmid; statement + selfupdate removal, no forced ops)"
|
||||
else
|
||||
verdict FAIL "GL4 H-U uninstall dry transcript" "$hu_why"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n "${FELHOM_TEST_CUSTOMER:-}" && -n "${FELHOM_TEST_PASSFILE:-}" && -r "${FELHOM_TEST_PASSFILE:-}" ]]; then
|
||||
common=(--customer-id "$FELHOM_TEST_CUSTOMER" --passphrase-file "$FELHOM_TEST_PASSFILE" \
|
||||
--vmid 990100 --cores 2 --memory 4096 --dry-run)
|
||||
@@ -218,7 +336,7 @@ else
|
||||
for bad in "4b/8" "chpasswd" "recovery-credential"; do
|
||||
[[ "$out" != *"$bad"* ]] || { hb_ok=false; hb_why+="contains '$bad' "; }
|
||||
done
|
||||
for want in "acknowledge the byo install" "acl storages all present" "-cores 2 -memory 4096"; do
|
||||
for want in "acknowledge the byo install" "acl storages all present" "-cores 2 -memory 4096" "self-update authority"; do
|
||||
[[ "$out" == *"$want"* ]] || { hb_ok=false; hb_why+="missing '$want' "; }
|
||||
done
|
||||
if $hb_ok; then
|
||||
|
||||
Reference in New Issue
Block a user