feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)

- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
  blesses replacing the root-adjacent binary). classify test + companion
  (TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
  params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
  + WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
  wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
  agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
  commit + loud WARN + marker left for report visibility; shutdown-before-dwell
  leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
  seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
  stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
  the report seam + MaybeCommit goroutine after core init.

Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.

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-05 15:32:15 +02:00
parent b7cbded429
commit 8033a522cd
16 changed files with 923 additions and 4 deletions
+38 -1
View File
@@ -40,6 +40,7 @@ import (
"gitea.dooplex.hu/admin/felhom-agent/internal/provision"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/reconcile"
"gitea.dooplex.hu/admin/felhom-agent/internal/selfupdate"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
"gitea.dooplex.hu/admin/felhom-agent/internal/wgtunnel"
@@ -616,7 +617,36 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
decommIntent = intentStore
}
decommExec := signedjobs.NewDecommissionExecutor(decommIntent, logger)
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec}, cfg.Hub.HostID, logger)
// Agent self-update (TASK D1): the agent_update executor downloads the operator-signed binary,
// verifies it against the SIGNED sha256, and hands it to the root guarded wrapper (A/B flip +
// detached restart). Wired as a third chain element. The commit-manager (below) commits a good
// update after a clean dwell; systemd + the wrapper auto-roll-back a crash-looping one. sudoRunner
// shells the wrapper verbs via `sudo -n` (same mode the rest of the privileged surface uses).
suCfg := cfg.SelfUpdate.WithDefaults()
suMode := proxmox.RunnerMode(cfg.Privileged.Mode)
if suMode == "" {
suMode = proxmox.RunnerSudo
}
suRunner := &proxmox.ExecRunner{Mode: suMode, SudoPath: cfg.Privileged.SudoPath}
updateExec := selfupdate.NewExecutor(selfupdate.Config{
URLTemplate: suCfg.URLTemplate,
Username: suCfg.Username,
Token: suCfg.Token,
StateDir: suCfg.StateDir,
Runner: suRunner,
Logger: logger,
})
selfUpdateMgr := selfupdate.NewManager(selfupdate.ManagerConfig{
StateDir: suCfg.StateDir,
RunningVersion: version,
Dwell: time.Duration(suCfg.DwellSeconds) * time.Second,
Runner: suRunner,
Logger: logger,
})
collector.SetSelfUpdateReporter(selfUpdateMgr) // heartbeat pending-status field
jobsRunner := signedjobs.NewRunner(client, gate, signedjobs.ExecutorChain{wipeExec, decommExec, updateExec}, cfg.Hub.HostID, logger)
loop.SetEnvelopeObserver(hub.MultiObserver(desiredSyncer, jobsRunner))
localSrv := buildLocalAPIServer(cfg, px, backupStore, observer, driveKnown, hostOps, gate, collector, intentRec, guestBindStore, formatJobStore, logger, &localTokens)
@@ -732,6 +762,13 @@ func runDaemon(cfg config.Config, logger *slog.Logger) int {
go func() { errc <- wgLoop.Run(ctx) }()
}
// TASK D1 Scenario D: if this process is a JUST-FLIPPED self-update (a pending marker names THIS
// version), commit it after a clean dwell — but only now that core init is done (config parsed,
// hub loop + storage watchdog + local API all started above). Runs in its own goroutine so the
// dwell never blocks the daemon; it is NOT one of the errc siblings (it returns after commit and
// must not end the daemon). A crash before the commit → systemd + the wrapper roll back to .prev.
go selfUpdateMgr.MaybeCommit(ctx)
err = <-errc
stop() // tear down the siblings on the first exit
for i := 0; i < 4+localServers+lanServers+wgServers; i++ { // wait for the other goroutines
+27 -1
View File
@@ -43,13 +43,15 @@ func main() {
func run() error {
var (
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission")
op = flag.String("op", "", "op class to sign, e.g. storage_wipe | guest_destroy | decommission | agent_update")
host = flag.String("host", "", "target host_id (anti-retarget — the op runs ONLY on this host)")
guest = flag.String("guest", "", "target guest_id (\"\" = host-scoped op)")
keyID = flag.String("key-id", "", "key id of the signing key (must match a pinned agent signer)")
paramsRaw = flag.String("params", "", "op params as JSON (overrides -durable-id/-fstype)")
durableID = flag.String("durable-id", "", "storage_wipe: the DURABLE device id (byid:…|byuuid:…); decommission: the drive's STORAGE durable-id (e.g. uuid:<fs-uuid>)")
fstype = flag.String("fstype", "ext4", "for storage_wipe: the filesystem to mkfs after wipe")
agentVer = flag.String("agent-version", "", "for agent_update: the target agent version (e.g. 0.70.1)")
sha256Hex = flag.String("sha256", "", "for agent_update: the pinned lowercase-hex sha256 of the target binary")
keyFile = flag.String("key", "", "operator signing key (ssh private key / sk- key handle) for ssh-keygen -Y sign")
ttl = flag.Duration("ttl", 30*time.Minute, "validity window from now (issued_at..expires_at)")
nonce = flag.String("nonce", "", "explicit nonce (default: a fresh 128-bit random nonce)")
@@ -82,6 +84,17 @@ func run() error {
}
pj, _ := json.Marshal(map[string]string{"durable_id": *durableID})
params = string(pj)
case "agent_update":
// The agent downloads the binary for -agent-version and verifies it against -sha256.
// The sha is the ONLY integrity root, so both are mandatory and the sha is strict-validated.
if *agentVer == "" || *sha256Hex == "" {
return fmt.Errorf("agent_update needs -agent-version and -sha256 (the pinned binary hash)")
}
if !isHex64(*sha256Hex) {
return fmt.Errorf("agent_update -sha256 must be 64 lowercase hex chars (got %d)", len(*sha256Hex))
}
pj, _ := json.Marshal(map[string]string{"version": *agentVer, "sha256": *sha256Hex})
params = string(pj)
default:
params = "{}"
}
@@ -141,6 +154,19 @@ func run() error {
return nil
}
// isHex64 reports whether s is exactly 64 lowercase hex chars (a sha256 hex digest).
func isHex64(s string) bool {
if len(s) != 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// signWithSSHKeygen signs `message` with `ssh-keygen -Y sign -n <namespace>`, the hardware-ready
// path (sk-/YubiKey keys work unchanged). It writes the message to a temp file, runs ssh-keygen,
// and reads the armored SSHSIG it produces. The namespace is the agent's FIXED domain separator.
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
// TASK D1 Group D — agent_update opsign param validation. isHex64 is the sha gate the CLI applies
// before it will build an agent_update envelope (the agent re-validates too, but a bad sha should
// never even be signed).
func TestIsHex64(t *testing.T) {
good := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" // 64 'a'
if !isHex64(good) {
t.Errorf("isHex64(%q) = false, want true", good)
}
for name, bad := range map[string]string{
"too short": "abcdef",
"too long": good + "a",
"uppercase hex": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"non-hex char": "g" + good[1:],
"empty": "",
} {
if isHex64(bad) {
t.Errorf("%s: isHex64(%q) = true, want false", name, bad)
}
}
}