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:
2026-07-08 08:14:07 +02:00
parent a63cc7151b
commit f7cc6a720e
7 changed files with 484 additions and 52 deletions
+15
View File
@@ -3,6 +3,21 @@
> Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub),
> `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`.
- **2026-07-08 — TASK GL-4 SHIPPED: uninstaller gap-closure + operator-key day-0 fold
(host-install v1.11.0)** — go-live G6 + the G1 key-pin follow-up; **awaiting GL-6** for the real
(non-dry) teardown + armed-pin install. Uninstall now removes the self-update artifacts (4b4:
guarded wrapper, .prev/.new.* slots, rollback unit, limits drop-in — derived from
configs/felhom-selfupdate-guarded), unmounts enrolled drives under /mnt/felhom-drives (plain
umount ONLY, busy = warn+guidance, root-bind guarded), and ends 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` (validated at argv, comment=key_id required) → authz.signers written
at step 6 per the agent SignerKey schema; **no-keys-resolved KEEPS preserved signers** (never
un-pin a manually-pinned box); verify reports armed/dormant (dormant = WARN). Harness 28/28 on
felhom-pve incl. the NEW GL4 H-U full-uninstall DRY transcript vs live 9201; red-proofs RP-1..3
green. **GO-LIVE-PACKAGE.md was ABSENT AGAIN** (spec said the operator attaches it; not present
in the repo or workspace) — G6/G1 status recorded here, doc still pending its operator commit.
Key CEREMONY (real keypairs + felhom-pve pin) = operator step.
- **2026-07-07 — RUNBOOK GL-1 EXECUTED: agent 0.74.0 + golden 0.103.0 PUBLISHED** — go-live G1
(partial): the published-artifact chain is current. Agent = the LIVE felhom-pve bytes (sha
`1ec3f588…76af05`, provenance preserved, publish-agent.sh round-trip verified); golden baked
+64 -44
View File
@@ -2,59 +2,79 @@
> **Overwrite** this file with a summary of the most recent task only (uniform with the other repos; not cumulative). The cumulative hub history lives in [hub/CHANGELOG.md](hub/CHANGELOG.md); the scripts history lives in [scripts/CHANGELOG.md](scripts/CHANGELOG.md).
## RUNBOOK GL-1publish agent 0.74.0 + bake/publish golden 0.103.0 (2026-07-07)
## TASK GL-4uninstaller gap-closure + operator-key day-0 fold — host-install v1.11.0 (2026-07-08)
Full execution record with per-gate evidence:
`documentation/pilot/RUNBOOK-GL1-publish-2026-07-07.md`. Docs-only commit; no code changed, no
production host mutated (felhom-pve read-only; all root work inside the disposable drill VM).
**Baseline confirmed:** felhom.eu @ `a63cc715`, `SCRIPT_VERSION="1.10.0"`**`1.11.0`**;
felhom-agent reference @ `4c408467` (read-only — authz schema from `internal/config/config.go`
SignerKey, self-update paths from `configs/felhom-selfupdate-guarded`, drive root from
`internal/storage/{claim,netmount}.go`, bind store `/var/lib/felhom-agent/guest-binds.json` from
`cmd/felhom-agent/main.go:514`). §12 STOP honored: zero live install/uninstall runs anywhere.
### The four operator values (hub Day-0 manifest — Configs → Day-0 artifacts)
**Part 0 — GO-LIVE-PACKAGE.md was ABSENT AGAIN.** The spec said the operator attaches it; it is
not in the repo, not anywhere under `E:\git`. Per the spec's own fallback: G6/G1 status recorded
in CONTEXT.md, no Commit 1. Third task in a row without the doc — flagging it loudly.
```
AGENT_VERSION=0.74.0
AGENT_SHA256=1ec3f58842edce1e9e32d022eaef15b7ff599c658c7a36bfe7833c708076af05
GOLDEN_VERSION=0.103.0
GOLDEN_SHA256=8481e8a14e2aa0abe3831cc40e5aea4c32f2017e7561c15dca8a674df6a6026e
```
### Files modified
### Gate outcomes (all PASS)
- `scripts/felhom-host-install.sh` → v1.11.0 (Parts 13; feature detail in scripts/CHANGELOG.md)
- `scripts/hostinstall-mode-harness.sh` — +13 static cases + PVE-tier GL4 H-U (extended, not forked)
- `documentation/runbooks/day0-install.md` — §C.5b key-pin section; Part E teardown additions
- `scripts/CHANGELOG.md`, `REUSE.md` (parity-pattern row), `CONTEXT.md`, this file
- **0a0d**: baselines exact (`ced60ddf` / `59eb3bea` / `02d63ed0`; agent 0.63.0 + golden 0.98.3
the last published, targets 404); drill VM + `virgin` snapshot intact; controller image
0.103.0 resolvable; headroom ample.
- **1a/1b**: felhom-pve's RUNNING binary (`readlink /proc/…/exe`, no pending A/B marker) is
0.74.0; those exact bytes (hash-stable across both copy hops) published via
`publish-agent.sh` from `main`@`ced60ddf` — PUT 201, round-trip GET sha match.
- **2a/2b**: clean-room bake on the virgin-restored drill VM (build-golden.sh v2.0.0):
overlay2 + both split-volumes as separate mounts, rootfs+mp0+mp1 ALL included in the vzdump
(no exclusions, no FATAL), archive 583MB, publish HTTP 201; transcript token-leak grep = 0.
Deviations: debian-13 template pveam-downloaded inside (virgin never has it, checksum
verified); bake launched via `systemd-run` (bare nohup didn't survive ssh session close).
- **3a/3b**: both artifacts fetched **ANONYMOUSLY** and hashed to exactly the published sha
(golden 611 696 321 bytes, streamed+hashed+deleted); off-LAN HEAD 200 from the CC machine too.
### Per-scenario results
### Gate 3c finding (feeds G3) — read-credential sufficiency
| Scenario | Result | How |
|---|---|---|
| A — full uninstall dry covers everything | **PASS (live dry)** | GL4 H-U on felhom-pve vs the real guest 9201: selfupdate-artifact removals + kept-vs-wiped statement present, no `umount -l/-f`, no destructive op on any `/mnt/felhom-drives/` path (no drives currently mounted there → per-mount umount lines correctly conditional) |
| B — guest-only | **static** | branch adds `_guest_drive_note` (bind-store best-effort, generic fallback) + guest-scoped statement; not transcript-runnable on felhom-pve (no second Felhom guest) — GL-6 |
| C1 dormant WARN | grep-shape PASS (GL4-C1) + verify code path | |
| C2 keys written | **runtime PASS** (GL4-C2: valid file passes resolution, dies later at preflight, never at key parse) + write-shape in GL4-C4 | |
| C3 malformed file | **runtime PASS ×5** (unknown role / non-key line / missing key_id comment / empty file / missing file — each dies at argv naming the line) | |
| C4 preserve rule | grep-shape PASS (`if signers:` guard + pin-rotation notice) + red-proof RP-2 | |
| C5 file overrides constants | grep-shape PASS (notice + constant reset) — not runtime-testable while the shipped constants are empty (deliberate); the notice fires only when both sources are set | |
| D parity | **PASS** (curated token list over the uninstall section; every disclosure artifact covered by a removal or an explicit KEPT line) | |
The specified reviewer read-only token (`8417…4140`) was not locatable by CC anywhere in reach —
the gates ran with **no credential at all and passed**: the generic packages are anonymously
world-readable. Therefore rotating the customer `git.token` to read-only **cannot break** the
artifact fetch path (G3 unblocked from this side). Nuance: `resolve_git_creds` in host-install
**dies on an empty token** even though Gitea would serve the fetch anonymously — the requirement
is script-side; customer configs still need a (read-scoped) token until a small installer
follow-up makes it optional. This also reframes GL-2's finding that demo-felhom's git credentials
are EMPTY: the fetch would work, the installer's precondition is what fails.
**Red-proofs (run→fail→revert on scratch copies; repo file never mutated):**
RP-1 dropped the 4b4 block → GL4-D FAILED (24/28). RP-2 made the signers write unconditional →
GL4-C4 FAILED. RP-3 dropped the unknown-role die → GL4-C3a FAILED. All reverted (scratch deleted).
### Drill-VM end state
**Gates:** `bash -n` clean; shellcheck 0.10.0 `--severity=warning` clean on both scripts (the two
pre-existing SC2015 infos on untouched v1.9.1 lines remain triaged); harness **25/25 static
locally, 28/28 on felhom-pve** (H-A/H-B still cred-gated by demo-felhom's empty git credentials —
unchanged since GL-2). GL-2's Scenario-A contract re-verified: all v1.10.0 cases still pass.
Guest 9100 destroyed (`--purge`), in-VM cred file shredded, VM powered off, qcow2 **restored to
`virgin`** (verified) — environment exactly as found. Evidence kept: `~/drill/bake-0.103.0.log`
on the build server.
### Implementation notes / judgment calls
1. **4b4 inventory** (from the guarded script, not spec memory): wrapper +
`felhom-agent.prev` + `felhom-agent.new.*` + `felhom-agent-rollback.service` + the
`felhom-agent-limits.conf` drop-in (+dir). `pending.json` + the staging dir live under
`$AGENT_STATE_DIR` — already removed in step 4 (noted in the block comment).
2. **Root-bind guard**: with a busy child mount, v1.10.0's `run umount /mnt/felhom-drives` would
have DIED mid-teardown (set -e); the root umount is now skipped with a warn when children stayed
busy — the statement lists them as "retry".
3. The statement's conditional lines: PBS (any `pbs`-type storage present), recovery credential
(`_state_has break_glass`, snapshotted BEFORE the state file is deleted); hub record + escrow
always printed (escrow phrased "if one exists" — there is no cheap local detector).
4. Key-file validation happens at argv time (before the passphrase prompt) so all C3 cases run on
any machine; options-prefixed authorized_keys lines (e.g. `command="…"`) are rejected as
"bad key type" — the pin format is deliberately plain `<type> <b64> <key_id>`.
5. H-U initially FAILED on felhom-pve because the naive assertion flagged the legitimate
`rm -f /usr/local/sbin/felhom-mkfs-guarded` line as "contains mkfs" — fixed to Scenario A's
real invariant (destructive ops on `/mnt/felhom-drives/` paths only).
### NOT live-validated — awaiting supervised GL-6
- The real (non-dry) full uninstall: drive umounts incl. a genuinely busy mount, the statement on
a real teardown, residue re-diff at v1.11.0.
- Guest-only mode on a multi-guest host (Scenario B transcript).
- An armed key-pin install end-to-end (needs the operator's real keys — the pin CEREMONY, incl.
pinning felhom-pve, is the operator's; the constants ship empty).
- C7-class verify-drift firing.
### Follow-ups
1. **OPERATOR — hub Day-0 manifest bump** to the four values above (until then fresh installs
land agent 0.63.0 + golden 0.98.3).
2. **Operator-key pin** (out of GL-1 scope): day-0 ships no operator pubkey → self-update dormant
on fresh boxes; pin the real key + a small host-install follow-up (candidate: fold into GL-4).
3. **G3 rotation** unblocked per Gate 3c; also fixes demo-felhom's empty git credentials.
4. **GL-6** consumes the bumped manifest end-to-end (deliberately not run here).
- **OPERATOR:** the key ceremony (offline keypairs → fill `OPERATOR_KEY_*` or keep a pubkey file);
the GL-1 manifest bump is still pending too (agent 0.74.0 / golden 0.103.0).
- **OPERATOR:** actually attach/commit GO-LIVE-PACKAGE.md (absent for the third task running).
- Observation: `install-v191.sh` (v1.9.1) is what `~/drill` still carries; GL-6 should fetch the
served v1.11.0 from felhom.eu (git-sync auto-deploys this push in ~12 min).
+1
View File
@@ -99,6 +99,7 @@
| Gate script | scripts/site_gates.py | Byte-level mechanical gates (BOM, emoji codepoint ranges, nav/footer diff, analytics, banned tokens, cache-bust); run `python scripts/site_gates.py` after ANY website change; non-zero exit on failure. |
| Fetch-validate-install (shell) | scripts/felhom-host-install.sh `step_agent_install` (~L1108) | `fetch_raw` to mktemp → syntax-check (`bash -n`) → `install -m0755 -o root -g root` → only then activate; guarded-mkfs wrapper installed BEFORE the sudoers that references it (ordering is the safety property). All mutations through `run()` (dry-run aware). |
| Install-profile gate (shell) | scripts/felhom-host-install.sh `--mode appliance\|byo` (GL-2, v1.10.0) | Mandatory-flag profile (no default), refusals at argv time BEFORE any prompt/step, risky step gated at its CALL SITE (one auditable place — never a branch inside the step), mode persisted to state.json + resume-mismatch refusal, `FELHOM_INSTALL_STATE_DIR` override for harness isolation. Harness: scripts/hostinstall-mode-harness.sh (static refusal matrix + grep-invariants + PVE dry-transcript tier; red-proofs run against a mutated scratch copy). |
| Disclosure↔uninstall parity (shell) | scripts/felhom-host-install.sh `_uninstall_statement` + harness GL4-D (v1.11.0) | Every host artifact the byo disclosure names must be removed OR explicitly listed KEPT by `run_uninstall`; the harness greps the parity (token list). New install-time artifact ⇒ add its removal + disclosure line + parity token in the SAME commit. Drive data rule: plain `umount` only, never `-l`/`-f`, never any format op under /mnt/felhom-drives. |
| Website deploy (manifest) | manifests/webpage.yaml | git-sync sidecar (sparse-checkout `/website/` + `/scripts/`, `--link=current`) + init container waits for first sync; nginx serves `current/website`; push to main = deployed, no image build. |
| Secret handling (manifest) | manifests/hub.yaml (env, ~L142) | Secrets via `secretKeyRef` to OUT-OF-BAND secrets created per documentation/runbooks/secrets.md — never inline stringData (see §3). ERRATA (2026-07-03): only `resend-api` is truly out-of-band today; `gitea-creds` is COMMITTED in manifests/felhom.secret.yaml AND live-consumed by hub.yaml — rotation + de-git is a pending operator task (spike SPIKE-a1 appendix). |
| Hub deploy (GitOps) | manifests/hub.yaml `image:` (~L129) | Pinned explicit tag, bumped in git, deliberate ArgoCD sync (auto-sync OFF). Code push alone deploys nothing. |
+26
View File
@@ -249,6 +249,24 @@ What byo does differently (everything else matches C.4's eight steps):
- Step 4b/8 (break-glass) is skipped; the verify step asserts pool membership + the scoped ACL
grants landed (both modes do this from v1.10.0).
### C.5b Operator key pin — self-update authority (v1.11.0)
The agent's self-update only acts on **operator-signed** update ops; the verifying public keys are
pinned in the agent config (`authz.signers`). A fresh install with **no key pinned runs self-update
DORMANT** (the safe default — the verify step prints a WARN, not an error). To arm it at install
time, either:
- fill the `OPERATOR_KEY_*` constants near the top of the script (one commit, after the offline
key-generation ceremony), or
- pass `--operator-pubkey-file <path>` — one key per line, `operational <authorized_keys line>` or
`recovery <authorized_keys line>`; the authorized_keys **comment field is the key_id and is
required**. The file overrides the constants.
Rules: PUBLIC keys only (private keys never touch the box or this script); a reinstall with no
keys resolved **keeps** an already-pinned config's signers (`--preserve-from`) — pinned boxes are
never silently un-pinned; providing keys over a preserved config replaces them (pin rotation,
logged). byo installs show the operational key_id in the disclosure block.
### C.6 Post-hoc mode note for pre-v1.10.0 installs
Boxes installed by ≤ v1.9.1 have no recorded mode; their state file simply predates it. `--resume`
@@ -349,6 +367,14 @@ state dir/config/service user), the shared-parent unit + wrapper + `/mnt/felhom-
guarded-mkfs wrapper, the guest-hook snippet, the lan-resolver dnsmasq snippets, the pveum
roles/ACL/token/user, the pool (if empty), the install state file.
v1.11.0 additions: the teardown also removes the **self-update artifacts** (guarded wrapper, A/B
slots, rollback unit, limits drop-in), **unmounts enrolled drives** under `/mnt/felhom-drives/`
(plain umount only — a busy drive gets a warning and guidance, never a forced unmount; the data is
never touched and the drives are physically removable afterwards), and both modes end with an
explicit **KEPT-vs-WIPED statement** — read it before pulling drives or closing the customer out
(it lists what lives on: drive data, PBS backups, the hub record, the escrow blob, the vaulted
recovery credential).
**Expected remnants** (documented, not residue):
- The **hub host record** (+ its report/guest history) — the hub currently has **no host-delete**;
+39
View File
@@ -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
+220 -7
View File
@@ -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
+119 -1
View File
@@ -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