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,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
|
||||
|
||||
Reference in New Issue
Block a user