From 0640aa06ea807191d7d3265b5e31a002a1578f57 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Wed, 8 Jul 2026 12:28:51 +0200 Subject: [PATCH] =?UTF-8?q?GL-6=20Gate-0=20ruling=20(G3):=20v1.11.2=20?= =?UTF-8?q?=E2=80=94=20anonymous=20artifact=20fetch=20supported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator ruling at the GL-6 drill's Gate 0: the Felhom artifacts are world-readable by design; an EMPTY git.username/git.token in the customer config now WARNS and fetches anonymously instead of dying at step 5/8 (the hub-vouched sha256 stays the integrity root; a configured credential is still used when present; curl auth args are conditional because -u with an empty token 401s even on public content). All 12 installer fetch targets validated 200 anonymously before shipping. Harness: +GL6-ANON shape case; GL4-C2 assertion updated for the v1.11.1 pinned constants (the benign overrides-notice is not a die). Drill record carries the deviation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- documentation/pilot/DRILL-GL6-2026-07-08.md | 74 +++++++++++++++++++++ scripts/felhom-host-install.sh | 46 ++++++++++--- scripts/hostinstall-mode-harness.sh | 21 +++++- 3 files changed, 129 insertions(+), 12 deletions(-) create mode 100644 documentation/pilot/DRILL-GL6-2026-07-08.md diff --git a/documentation/pilot/DRILL-GL6-2026-07-08.md b/documentation/pilot/DRILL-GL6-2026-07-08.md new file mode 100644 index 0000000..acdb70b --- /dev/null +++ b/documentation/pilot/DRILL-GL6-2026-07-08.md @@ -0,0 +1,74 @@ +# DRILL GL-6 — supervised go-live drill (demo box, two full cycles) — 2026-07-08 + +> Go-live item **G7** + the live-validation landing zone for GL-1…GL-5b deferrals. Destructive- +> supervised: guest 9201 destroyed + rebuilt twice; Viktor present, gating every phase. This record +> is filled per phase as evidence lands; timings + deviations verbatim. No secrets. + +**Inputs verified at start:** felhom.eu `bd97bc34` (host-install **v1.11.1**, operator pubkeys +pinned) · felhom-agent `7f073936` (v0.76.0) · hub Day-0 manifest vouches agent +`0.76.0`/`9828c5f7…f50b` + golden `0.103.0`/`8481e8a1…6026e` (fetched via the box-facing API). + +## Phase 0 — preflight + key validation (nothing destroyed) + +| Step | Result | +|---|---| +| 0a baselines | **PASS** — repo heads + `SCRIPT_VERSION="1.11.1"` + manifest all as stated above | +| 0b key validation leg 1 | **PASS** — all four fingerprints EQUAL (below) | +| 0c key validation leg 2 | [Viktor] — pending at Gate 0 | +| 0d abort-path backup + restore-test | **PASS** (local + PBS backups fresh; PBS restore-test parity ok, 5m35s) | +| 0e busy-drive prop | **STAGED** — `gl6-busyprop` unit cwd-pinned into `/mnt/felhom-drives/felhom-usb` (4h) | +| 0f mechanism re-read | **DONE** — Phase-3 delivery pinned (below) | + +### 0b — the four fingerprints, side by side + +``` +repo op (felhom-op-1): SHA256:7YqN4rXO08yixTeOO+UtQ8jHyIGycICuctQgRYVGnWw +box op (felhom-op-1): SHA256:7YqN4rXO08yixTeOO+UtQ8jHyIGycICuctQgRYVGnWw +repo rec (felhom-rec-1): SHA256:/ixgTesZqykAGJpFUUd4kLAiHFgKOkYFLNC3AQXWP+k +box rec (felhom-rec-1): SHA256:/ixgTesZqykAGJpFUUd4kLAiHFgKOkYFLNC3AQXWP+k +``` + +Box source: felhom-pve agent config (`systemctl cat` → `-config` path) `authz.signers[]`; +repo source: the v1.11.1 `OPERATOR_KEY_*_LINE` constants. Both match the runbook's stated values. + +### 0d — abort-path backups (the session's safety net) + +- **Local (agent pipeline):** `--selftest=backup -vmid 9201` → snapshot-mode, crash-consistent, + `local:backup/vzdump-lxc-9201-2026_07_08-11_43_42.tar.zst` (6.82 GB, 5m52s). Expected + `uncovered_volumes`: `/etc/felhom-bootstrap` + `/mnt/felhom-drives` (bind mounts — regenerated / + re-enrolled, per doctrine). +- **PBS (offsite tier, PVE-native vzdump per the S4-proven path):** `felhom-pbs:backup/ct/9201/2026-07-08T09:51:00Z` — 3m04s (dedup) + — NOTE: the live config deliberately pins `local_backup_target: local`; the PBS leg was driven + with `vzdump 9201 --storage felhom-pbs --mode snapshot` (tunnel healthy, storage active). +- **Restore-test vs the fresh PBS archive:** **PASS** — `mount_parity: ok`, inventory + `mp0=/var/lib/docker (200G)`, `mp1=/mnt/sys_drive (50G)`, mp8/mp9 throwaways; scratch 990000 + restored+booted+verified+torn-down in **5m35s** (first G12 PBS-tier runtime datum). + Observation (cosmetic): the record's `source_tier` says "local" — the tier label derives from the + CONFIG backup target, not the explicit `-archive`'s storage; display-only. + +### 0e — busy prop + data-intact witness + +Prop: transient unit `gl6-busyprop`, cwd inside `/mnt/felhom-drives/felhom-usb`. +Witness file (for the Phase-1/5 drive-data-intact checks): +`userdata/actualbudget/canary_hdd_actualbudget.txt` on `/dev/sdd1` +(UUID `da9e7089-cf8e-4617-adcb-a377743fae00`), sha256 prefix `7b00bc3550b10e3952e0e0965f5a2628`. + +### 0f — Phase-3 signed-update mechanism (confirmed at source, NOT improvised) + +1. [Viktor] sign: `felhom-opsign -op agent_update -host -key-id felhom-op-1 + -agent-version -sha256 -key [-ttl 30m]` → signed-op blob. +2. Deliver: `POST /api/v1/admin/hosts/{host_id}/jobs` `{job_id, blob_b64}` — **hub GLOBAL key + required** (Viktor), or the v0.70.0-precedent direct `signed_jobs` sqlite insert on the hub pod + (CC via kubectl on 180). The agent's heartbeat sees `has_signed_ops`, fetches, verifies against + the PINNED operational key + durable nonce store, downloads the binary from Gitea, re-verifies + the SIGNED sha (the only integrity root), then `felhom-selfupdate-guarded apply` A/B-swaps with + crash-rollback armed; dwell → `commit`. + +## Gate 0 — [Viktor] + +- Input 1 (byo caps): +- Input 2 (Phase-6 repopulation): +- 0c fingerprint confirmation vs password manager: +- Verdict: + +*(Phases 1–6 appended as they run.)* diff --git a/scripts/felhom-host-install.sh b/scripts/felhom-host-install.sh index a9b6e83..afddb18 100644 --- a/scripts/felhom-host-install.sh +++ b/scripts/felhom-host-install.sh @@ -37,6 +37,11 @@ # --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. # +# v1.11.2 (GL-6 Gate-0 operator ruling, G3): ANONYMOUS artifact fetch supported — empty +# git.username/git.token in the customer config now WARNS and fetches anonymously (the Felhom +# artifacts are world-readable by ruling; the hub-vouched sha256 stays the integrity root). +# A configured credential is still used when present. +# # Grounding: documentation/audits/SPIKE-day0-firstboot-handshake-2026-06-26.md # # Usage: @@ -144,7 +149,7 @@ set -euo pipefail -SCRIPT_VERSION="1.11.1" # keep in sync with the header line at the top of this file +SCRIPT_VERSION="1.11.2" # 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 @@ -349,24 +354,43 @@ resolve_artifacts() { # Resolve the Gitea fetch credential (git username + token) from the customer's controller.yaml — # the SAME secret config-retrieve already hands out (NO new credential). Sets GIT_USER / GIT_TOKEN. # Parses the git: block without a YAML lib (fresh PVE has no PyYAML). +# v1.11.2 (GL-6 Gate-0 operator ruling, G3): EMPTY credentials are ALLOWED — the fetches then run +# ANONYMOUSLY (the Felhom artifacts are deliberately world-readable; integrity comes from the +# hub-vouched sha256 + visudo/sh -n validation, never from transport auth). Loud warn, never die: +# a customer config without a git token must not block day-0 while the anonymous path works. resolve_git_creds() { local yaml yaml=$(curl -fsS "$HUB_URL/api/v1/config/$CUSTOMER_ID" -H "X-Retrieval-Password: $PASSPHRASE") \ - || die "controller.yaml fetch failed (for the git fetch token)" + || die "controller.yaml fetch failed (for the git fetch credential)" GIT_USER=$(awk '/^[^[:space:]#]/{ingit=($1=="git:")} ingit&&$1=="username:"{print $2}' <<<"$yaml" | head -1) GIT_TOKEN=$(awk '/^[^[:space:]#]/{ingit=($1=="git:")} ingit&&$1=="token:"{print $2}' <<<"$yaml" | head -1) # strip any surrounding quotes GIT_USER="${GIT_USER%\"}"; GIT_USER="${GIT_USER#\"}" GIT_TOKEN="${GIT_TOKEN%\"}"; GIT_TOKEN="${GIT_TOKEN#\"}" - [[ -n "$GIT_TOKEN" ]] || die "no git token in controller.yaml — cannot fetch artifacts from Gitea" + if [[ -z "$GIT_TOKEN" ]]; then + log_warn " no git credential in controller.yaml — fetching artifacts ANONYMOUSLY (they are world-readable; sha256 verification unchanged)" + fi } -# Fetch a Gitea generic-package URL to a dest with the git token, then VERIFY its sha256 against the -# expected (hub-vouched) value. Aborts on any mismatch — verify-before-use. $1=url $2=dest $3=expected_sha +# _git_auth_args populates the caller's curl arg array with basic-auth ONLY when a credential is +# present — empty creds = anonymous fetch (v1.11.2). `curl -u user:` with an empty token would NOT +# fall back to anonymous (a wrong credential 401s even on world-readable content), hence the array. +_git_auth_args() { + local -n _out=$1 + _out=() + if [[ -n "$GIT_TOKEN" ]]; then + _out=(-u "${GIT_USER}:${GIT_TOKEN}") + fi +} + +# Fetch a Gitea generic-package URL to a dest (authed when a credential exists, else anonymous), +# then VERIFY its sha256 against the expected (hub-vouched) value. Aborts on any mismatch — +# verify-before-use; the sha is the integrity root either way. $1=url $2=dest $3=expected_sha fetch_verify() { local url="$1" dest="$2" want="$3" [[ -n "$want" ]] || die "refusing to install an artifact with no expected sha256 (manifest incomplete): $url" - curl -fsS -u "${GIT_USER}:${GIT_TOKEN}" -o "$dest" "$url" || die "fetch failed: $url" + local -a _auth; _git_auth_args _auth + curl -fsS "${_auth[@]}" -o "$dest" "$url" || die "fetch failed: $url" local got; got=$(sha256sum "$dest" | awk '{print $1}') if [[ "$got" != "$want" ]]; then rm -f "$dest" @@ -375,12 +399,14 @@ fetch_verify() { log_success " verified sha256 ${got:0:16}… matches the hub manifest" } -# Fetch a raw config file (the canonical unit/sudoers) from the agent repo with the git token. These -# are non-executable text (not the integrity-checked binary); the sudoers is `visudo -cf`-validated -# before install, which catches corruption/tampering that would matter. $1=repo-path $2=dest +# Fetch a raw config file (the canonical unit/sudoers) from the agent repo (authed when a credential +# exists, else anonymous). These are non-executable text (not the integrity-checked binary); the +# sudoers is `visudo -cf`-validated before install, which catches corruption/tampering that would +# matter. $1=repo-path $2=dest fetch_raw() { local path="$1" dest="$2" - curl -fsS -u "${GIT_USER}:${GIT_TOKEN}" -o "$dest" \ + local -a _auth; _git_auth_args _auth + curl -fsS "${_auth[@]}" -o "$dest" \ "$GITEA_BASE/$GITEA_OWNER/$AGENT_REPO/raw/branch/main/$path" \ || die "raw fetch failed: $path" [[ -s "$dest" ]] || die "raw fetch empty: $path" diff --git a/scripts/hostinstall-mode-harness.sh b/scripts/hostinstall-mode-harness.sh index 0919fbf..187ebbd 100644 --- a/scripts/hostinstall-mode-harness.sh +++ b/scripts/hostinstall-mode-harness.sh @@ -215,10 +215,14 @@ expect_die "GL4-C3e key file: missing file refused" \ -- --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. +# (root/pveum/hub preflight, machine-dependent) and NEVER with a key-file ERROR. Since v1.11.1 the +# constants are pinned, so the benign "file overrides constants" NOTICE legitimately appears — +# assert on the actual die messages, not on any mention of the flag. 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 +if [[ $rc -ne 0 && "$out" != *"authorized_keys line"* && "$out" != *"unknown role"* \ + && "$out" != *"has no key lines"* && "$out" != *"key line has no comment"* \ + && "$out" != *"--operator-pubkey-file not found"* ]]; 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' ' ')" @@ -264,6 +268,19 @@ else verdict FAIL "GL4-D disclosure↔uninstall parity" "could not locate the uninstall section" fi +# GL6-ANON (v1.11.2, the Gate-0 operator ruling): empty git creds warn + fetch anonymously — the +# resolve must NOT die on an empty token, and every artifact fetch must build its auth args +# conditionally (a curl -u with an empty token would 401 even on world-readable content). +if grep -q 'fetching artifacts ANONYMOUSLY' "$SCRIPT" \ + && grep -B1 'fetching artifacts ANONYMOUSLY' "$SCRIPT" | grep -q 'log_warn' \ + && ! grep -q 'no git token in controller.yaml — cannot fetch' "$SCRIPT" \ + && [ "$(grep -c '_git_auth_args _auth' "$SCRIPT")" -ge 2 ] \ + && ! grep -E 'curl -fsS -u "\$\{GIT_USER\}' "$SCRIPT" >/dev/null; then + verdict PASS "GL6-ANON empty-cred anonymous-fetch fallback (warn-not-die + conditional auth)" +else + verdict FAIL "GL6-ANON empty-cred anonymous-fetch fallback (warn-not-die + conditional auth)" +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 \