From 915642aaaa68b2571e7246b8c0d7c429dad73614 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 5 Jul 2026 15:36:13 +0200 Subject: [PATCH] =?UTF-8?q?docs(agent):=20D1=20=E2=80=94=20README=20self-u?= =?UTF-8?q?pdate=20section,=20REUSE,=20CHANGELOG=20v0.70.0,=20CONTEXT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ CONTEXT.md | 14 ++++++++++++++ README.md | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ REUSE.md | 1 + 4 files changed, 93 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c040736..0a21fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## v0.70.0 — agent self-update (operator-signed, A/B slots, crash-loop auto-rollback) (2026-07-05) + +Closes the update asymmetry: the root-adjacent agent was updated by manual SSH binary-replace while +the lower-stakes controller already auto-updates. Design provenance: +`felhom.eu/documentation/audits/SPIKE-agent-selfupdate-2026-07-05.md` (SF-findings binding). Core +principle (as with the controller swap): the thing that performs rollback is never the thing being +updated — here systemd + an ~80-line root wrapper. + +- **Trust model:** an update is an operator-signed `agent_update` op (new `reconcile.ClassAgentUpdate`, + always Destructive) through the existing signed-jobs pipeline; the signed params pin version + sha256, + so the sha is the ONLY integrity root (hub = dumb transport, Gitea = dumb storage — neither can + substitute a binary). `felhom-opsign -op agent_update -agent-version -sha256 `. +- **Host artifacts** (`configs/`): `felhom-selfupdate-guarded` (apply/commit/rollback — root re-verify, + path confinement, same-fs assert, `.prev`, atomic mv, pending marker, detached restart [SF-6]; + rollback pending-guarded [SF-1]); `felhom-agent-rollback.service` (OnFailure oneshot); + `felhom-agent-limits.conf` ([Unit]-only drop-in [SF-3] with the spike's tuned 120s/4 [SF-2] + + OnFailure=); `FELHOM_SELFUPDATE` sudoers alias. +- **Go** (`internal/selfupdate/`): `Executor` (download → verify vs signed sha → `sudo -n` wrapper + apply; job completed after verify+download, before apply); `Manager` (startup dwell → `commit`; + version-mismatch → no-commit + WARN + marker-left; report seam `SelfUpdatePending()`). Wired as a + 3rd executor-chain element + a `MaybeCommit` goroutine after core init. `SelfUpdateConfig` + (url_template/creds/dwell, Token redacted). Additive report fields `selfupdate_pending(+version)` + (omitempty → cross-repo golden contract byte-stable, no hub change). 3 non-critical capability probes. +- **felhom.eu:** `felhom-host-install.sh` installs the wrapper + rollback unit + drop-in on day-0 + (self-update from birth); agent README "Self-update" section. +- Tests: executor (happy / sha-mismatch + companion / bad-params / wrapper-fail), gate ride-along + (agent_update rides the LOCKED gate: pinned-key executes, non-pinned + retarget rejected), commit + (dwell-commit / version-mismatch / no-pending / shutdown), opsign, classify. Wrapper covered by + shellcheck + the live crash-rollback drill. Full `go test ./...` green. + ## v0.69.0 — S5: host-loss DR — recovered WG-key install + directive→restore-PLAN (safe halves) (2026-07-04) The two safe, non-destructive mechanical links for host-loss DR (the destructive in-place restore is diff --git a/CONTEXT.md b/CONTEXT.md index 455f986..295e841 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,6 +5,20 @@ ## Current +- **v0.70.0** (2026-07-05) — **agent self-update (operator-signed A/B slots + crash-loop + auto-rollback)** — TASK D1, provenance `SPIKE-agent-selfupdate-2026-07-05`. An operator-signed + `agent_update` op (version+sha256, sha is the only integrity root) rides the signed-jobs gate; + `internal/selfupdate.Executor` downloads+verifies+hands to `felhom-selfupdate-guarded apply` (root + re-verify → A/B atomic flip → pending marker → detached restart); the new binary commits after a + 60s dwell; a crash-looping binary is auto-reverted by `OnFailure=felhom-agent-rollback.service` + (first-crash trigger [SF-1]) with the tuned `[Unit]` start-limit (120s/4) as backstop. Host + artifacts + sudoers `FELHOM_SELFUPDATE` + `felhom-host-install.sh` day-0 install + report field + `selfupdate_pending`. Green tests + companions; **live validation pending (build/publish v0.70.0, + manual install the artifacts on felhom-pve, then the happy-path + crash-rollback drills)**. Rollback + `felhom-agent.bak-0.69.0`. OPEN (v1 scope-outs): no hub-floor auto-update, no failed-update + auto-retry, no pending-timeout auto-rollback (a runs-but-never-commits binary is caught by + `host_staleness` + the pending report flag). Detail: REPORT.md. + - **v0.69.0** (2026-07-04, live on felhom-pve) — **S5: host-loss DR — safe halves shipped**. **Part 1** `wgtunnel.InstallRecoveredKey` — writes an escrow-recovered WG privkey (create-only, refuse-overwrite) so the tunnel re-establishes with the SAME identity/pubkey (same /32), no keygen; diff --git a/README.md b/README.md index 5967512..8555cd7 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,54 @@ The agent is **external** to the controller container, so it survives the contro mid-swap (which the controller cannot do to itself). `GuestBinder.GuestExec` is the single `pct exec` seam. Exercise directly with `--selftest=controller-swap -vmid -image `. +## Agent self-update (operator-signed, A/B slots, crash-loop auto-rollback — v0.70.0, TASK D1) + +The agent updates ITSELF the same way it swaps the controller: **the thing that performs rollback is +never the thing being updated.** For the agent that means systemd + an ~80-line root shell wrapper +(`configs/felhom-selfupdate-guarded`) that changes almost never; the Go binary is what flips. + +**Trust model.** An update is an **operator-signed `agent_update` op** delivered through the existing +signed-jobs pipeline (same LOCKED authz gate as `storage_wipe`/`decommission`). The signed params pin +the exact **version + sha256**, so the pinned sha is the *only* integrity root — **neither a +compromised hub (dumb transport) nor a compromised Gitea (dumb storage) can substitute a binary.** +The operator signs offline with `felhom-opsign -op agent_update -agent-version -sha256 `. + +**The flow** (`internal/selfupdate/` = the Go half; the wrapper = the root half): + +1. The control loop sees a pending signed op → the gate verifies it (pinned-key SSHSIG → namespace → + allow-list → crypto → host → time → **durable nonce-burn**) → the `agent_update` executor runs. +2. Executor downloads the binary for the signed version from the config'd artifact host + (`selfupdate.url_template`, `{version}` interpolated) to `/var/lib/felhom-agent/selfupdate/`, + verifies its sha256 against the **signed** value (mismatch → refuse, remove, agent untouched), + and hands it to `sudo -n felhom-selfupdate-guarded apply `. The job is completed on + the hub **after verify+download, before apply** (the nonce is already burned — a queued job would + only re-fetch and no-op on the spent nonce; a failed/rolled-back update is visible via the report). +3. The wrapper (as root) **re-verifies** the sha, confines the staged path to the staging dir, asserts + same-filesystem (the atomic-rename guarantee), snapshots the current binary to `.prev`, atomically + `mv`s the new binary into place, writes a `pending.json` marker, `reset-failed`s, and schedules a + **detached** restart (`systemd-run --on-active=2s … systemctl restart felhom-agent`, so the caller + survives to log the handoff). +4. The **new** binary boots; after it has run cleanly for a dwell (`selfupdate.dwell_seconds`, default + 60) *and* core init is done, `internal/selfupdate.Manager` calls the wrapper's `commit` (clears the + marker; `.prev` retained as a manual net). A pending marker naming a *different* version than the + running binary is **not** committed — loud WARN, marker left so the report shows why (a human + decides). +5. **Crash-loop auto-rollback (the safety property).** If the new binary crashes, systemd's + `OnFailure=felhom-agent-rollback.service` (the `felhom-agent-limits.conf` drop-in) runs the + wrapper's `rollback`: pending marker present → restore `.prev` byte-identical → clear marker → + restart → the old binary is back **within seconds of the first crash**. On systemd 257 `OnFailure=` + fires on *every* crash, so rollback triggers at the first one; the marker-guard makes every later + fire (and any crash with no update in flight) a harmless no-op. The tuned start-limit + (`[Unit] StartLimitIntervalSec=120 + StartLimitBurst=4`) is the terminal **backstop** (e.g. an + environmental crash loop of the known-good binary → terminal `failed` ≈20s → the hub's + `host_staleness` dead-man's-switch alerts the operator). + +**Design provenance:** every systemd behaviour above is empirically validated in +`felhom.eu/documentation/audits/SPIKE-agent-selfupdate-2026-07-05.md` (the SF-findings). The host +report carries `selfupdate_pending` (+ version) so a runs-but-never-commits binary is visible even +though it never crashes. v1 scope: no hub-floor auto-update, no auto-retry of a failed update, no +pending-timeout auto-rollback (a stuck-but-alive binary is caught by `host_staleness`). + ### TLS trust The host serves a self-signed cert. Verification is **not** blanket-disabled. Pick one in diff --git a/REUSE.md b/REUSE.md index 7f4595e..7778c2b 100644 --- a/REUSE.md +++ b/REUSE.md @@ -27,6 +27,7 @@ | `SudoHostOps.ListCandidateDisks` | internal/storage/candidates.go | `ListCandidateDisks(ctx) ([]CandidateDisk, error)` | enroll-candidate discovery | Fail-safe: omits anything not provably unclaimed | | `antiRetargetResolveExpect` (+ `antiRetargetResolve`, `antiRetargetResolveBlank`) | internal/localapi/wipe_reresolve.go | `(durableID, expectDataBearing, resolve, derive, inspect) (device, err)` | pre-mkfs anti-retarget: resolve durable id → re-derive+match → re-inspect | AGENT-001 + audit D3; refuses path-only bindings; wired via `Server.reresolveWipe`/`reresolveBlank` (test-injectable) | | `signedjobs.WipeExecutor.Execute` | internal/signedjobs/wipe.go | `Execute(ctx, op, params) error` | operator-signed data-bearing wipe | Durable-id bound; nonce burned by gate BEFORE execute; refuses no-longer-data-bearing targets | +| `selfupdate.Executor` / `selfupdate.Manager` | internal/selfupdate/{executor,commit}.go | `NewExecutor(Config)` / `NewManager(ManagerConfig)` | operator-signed agent self-update (D1): download+verify-vs-signed-sha → wrapper `apply`; startup dwell → `commit` | sha is the ONLY integrity root; wrapper (`felhom-selfupdate-guarded`) re-verifies as root + does the A/B flip; NEVER rolls back (systemd + wrapper do). `WrapperRunner` seam. Report seam `SelfUpdatePending()` | | `Gate.AuthorizeStorageWipe` | internal/reconcile/gate.go | `AuthorizeStorageWipe(StorageWipeAuthz, *SignedOp) Decision` | tiered wipe authz | user-data ⇒ customer confirm bound to agent's DeviceDurableID; system/backup ⇒ operator signature only, `Confirmed` IGNORED by role | | `Gate.Authorize` | internal/reconcile/gate.go | `Authorize(Intent, *SignedOp) Decision` | every destructive intent | role-scoping (`roleAuthorizes`) + op-to-action binding; benign passes unsigned; audits every decision | | `storage.DeviceDurableID` / `ResolveDurableDevice` | internal/storage/durable_device.go | `DeviceDurableID(device) (string, error)` | WIPE-binding ids (`byid:`/`byuuid:`) | Single seam for /disks list AND gate (F20-BUG2); `ResolveDurableDevice` refuses bare paths |