diff --git a/CONTEXT.md b/CONTEXT.md index 05a6fb6..a60b2b2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 diff --git a/REPORT.md b/REPORT.md index 31d8da8..ba2c9c2 100644 --- a/REPORT.md +++ b/REPORT.md @@ -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-1 — publish agent 0.74.0 + bake/publish golden 0.103.0 (2026-07-07) +## TASK GL-4 — uninstaller 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 1–3; 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 -- **0a–0d**: 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 ` `. +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 ~1–2 min). diff --git a/REUSE.md b/REUSE.md index 50ce5a6..aae84ec 100644 --- a/REUSE.md +++ b/REUSE.md @@ -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. | diff --git a/documentation/runbooks/day0-install.md b/documentation/runbooks/day0-install.md index 92538d7..236203e 100644 --- a/documentation/runbooks/day0-install.md +++ b/documentation/runbooks/day0-install.md @@ -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 ` — one key per line, `operational ` or + `recovery `; 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**; diff --git a/scripts/CHANGELOG.md b/scripts/CHANGELOG.md index e429d22..f454ebd 100644 --- a/scripts/CHANGELOG.md +++ b/scripts/CHANGELOG.md @@ -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/` 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 `; 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 diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index 3150522..5530047 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -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 " or "recovery " (# 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 " 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 " or "recovery "; '#' 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:-}') 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=,tls.fingerprint=$fp} hub{url=$HUB_URL,host_id=$HOST_ID,api_key=} local_api{$BRIDGE_ADDR}" + if [[ -n "$RESOLVED_OP_LINE$RESOLVED_REC_LINE" ]]; then + log_dry "write authz.signers: operational=${RESOLVED_OP_ID:-} recovery=${RESOLVED_REC_ID:-} (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 diff --git a/scripts/hostinstall-mode-harness.sh b/scripts/hostinstall-mode-harness.sh index ce48cef..0919fbf 100644 --- a/scripts/hostinstall-mode-harness.sh +++ b/scripts/hostinstall-mode-harness.sh @@ -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