From 95ed6314cc7d0ee8a715f9d9bfd56f48f069d6f8 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 14 Nov 2026 17:34:13 +0100 Subject: [PATCH] controller (unreleased): a step of the host's clock is not a new boot (R-1395) The boot pass decides through guestBootVerdict and prefers the agent's new guest_boot_key (kernel boot_id), which a clock step cannot move. An agent that sends no key gets the old rule. The move to the key recreates nothing. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- CHANGELOG.md | 17 ++++ controller/internal/agentapi/client.go | 4 + .../internal/web/guest_boot_verdict_test.go | 95 +++++++++++++++++++ controller/internal/web/intermediary.go | 60 ++++++++++-- 4 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 controller/internal/web/guest_boot_verdict_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index b95c9898..2c00f47a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,20 @@ +## Unreleased (2026-11-14 night) — built and NOT released: a clock step is not a new boot (felhom.eu R-1395) + +**MinAgent: 0.158.1** (unchanged — the new field is optional; without it the controller behaves as before). + +- **A step of the host's clock no longer makes the controller recreate every drive-backed app (R-1395).** Found on + the N100 on 2026-11-14: its time service came back, stepped the clock by +32.7 s, and radarr, sonarr and qbittorrent + were recreated. **The boot token is the AGENT's, not the controller's:** `guest_boot_id` in the agent's `/disks` + answer is `-`, and the kernel computes the boot time from the wall clock. The only + reader in this repository is `web.processGuestBootChange` (it compares the token with `settings.LastGuestBootID`). + It now decides through `guestBootVerdict`, which prefers the agent's new `guest_boot_key` + (`-`; an agent after 0.174.0 — built there, not released): the key equal to the + record is the same boot whatever the old token says; the first report with a key moves the record to the key + WITHOUT a recreate when the old token says „same boot”; an agent that sends no key gets the old rule, byte for + byte. Tests `TestGuestBootVerdict_AClockStepIsNotANewBoot` (14 cases), `TestGuestBootVerdict_IsWhatTheBootPassReads`; + 8 mutations seen red (felhom.eu `documentation/audits/night-build-2026-11-14/C/redproof-controller-boot-key.txt`). + **Not proven on a box.** With an older agent a clock step still recreates the apps once. + ## v0.352.1 (2026-11-14) — Go 1.27.4: the security fix GO-2026-6859 (felhom.eu R-1391) — RELEASED (image `sha256:1a6e8945…`, from `8747744b`, CI run id 2637; rc.1 on scratch guest 9202); per-box floors 09:18 UTC, global floor 2026-11-14 09:21 UTC (MinAgent 0.158.1); golden 0.352.1 baked and vouched the same day ### 2026-11-14 diff --git a/controller/internal/agentapi/client.go b/controller/internal/agentapi/client.go index 09b45f12..9d3f62fd 100644 --- a/controller/internal/agentapi/client.go +++ b/controller/internal/agentapi/client.go @@ -392,6 +392,10 @@ type DisksResponse struct { // GuestBootID changes on every guest boot (host or guest reboot) but is stable across a // controller-only restart — the deterministic signal the controller recreates drive-backed apps on. GuestBootID string `json:"guest_boot_id,omitempty"` + // GuestBootKey (R-1395; an agent after 0.174.0) is the same signal built from the kernel's boot_id instead of + // the boot TIME: a step of the host's clock moves the boot time (and so GuestBootID) and does not move this. + // "" from an older agent — the controller then reads GuestBootID as before. See web.guestBootVerdict. + GuestBootKey string `json:"guest_boot_key,omitempty"` } // FormatResult mirrors POST /disks/format (the success/refusal payload). diff --git a/controller/internal/web/guest_boot_verdict_test.go b/controller/internal/web/guest_boot_verdict_test.go new file mode 100644 index 00000000..b1541b6f --- /dev/null +++ b/controller/internal/web/guest_boot_verdict_test.go @@ -0,0 +1,95 @@ +package web + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" +) + +// R-1395: on 2026-11-14 the N100's time service came back and stepped the host's clock by +32.7 s. The agent's boot +// token carries the host's boot TIME, which the kernel computes from the wall clock — so the token moved and the +// controller recreated radarr, sonarr and qbittorrent. The consequence asserted: with the clock-proof key, a moved +// boot time is NOT a new boot; a real reboot still is; and the move from the old record to the key recreates nothing. +// COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/C/redproof-controller-boot-key.txt). +func TestGuestBootVerdict_AClockStepIsNotANewBoot(t *testing.T) { + const ( + key = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-4242" + keyNext = "c0ffee00-1111-2222-3333-444455556666-3907" // after a host reboot + keyGuest = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-991234" + old = "1794112518-4242" // btime before the step + oldStep = "1794112550-4242" // the same boot, btime moved by the +32 s step + ) + for _, c := range []struct { + name string + last, oldTok, key_ string + changed bool + persist string + }{ + {"THE CASE: the clock stepped, the key did not move", key, oldStep, key, false, ""}, + {"same boot, nothing moved", key, old, key, false, ""}, + {"a host reboot: the key changes", key, "1794200000-3907", keyNext, true, keyNext}, + {"a guest reboot: the key's second half changes", key, "1794112518-991234", keyGuest, true, keyGuest}, + {"the first report with a key, same boot by the old token: the record moves, no recreate", old, old, key, false, key}, + {"the first report with a key, and the old token differs: a new boot", old, "1794200000-3907", keyNext, true, keyNext}, + {"a box that never recorded a boot: the first pass runs, as before", "", old, key, true, key}, + {"a key and no old token at all, record differs: a new boot", old, "", key, true, key}, + // an older agent sends no key: the rule of before, byte for byte + {"older agent, same boot", old, old, "", false, ""}, + {"older agent, new boot", old, "1794200000-3907", "", true, "1794200000-3907"}, + {"older agent, guest not running (no token)", old, "", "", false, ""}, + {"older agent, never recorded", "", old, "", true, old}, + {"older agent, the clock stepped: still a (false) new boot — only the key cures it", old, oldStep, "", true, oldStep}, + {"an agent downgrade: the record is a key, the agent sends only the old token", key, old, "", true, old}, + } { + changed, persist := guestBootVerdict(c.last, c.oldTok, c.key_) + if changed != c.changed || persist != c.persist { + t.Errorf("%s: guestBootVerdict(%q, %q, %q) = (%v, %q), want (%v, %q)", c.name, c.last, c.oldTok, c.key_, changed, persist, c.changed, c.persist) + } + } +} + +// The verdict is a pure function; a pure function nobody calls is the inert-seam defect. processGuestBootChange must +// decide through it, with the agent's key, and must not compare the old token by itself any more. +func TestGuestBootVerdict_IsWhatTheBootPassReads(t *testing.T) { + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "intermediary.go", nil, 0) + if err != nil { + t.Fatal(err) + } + var calls, rawCompares, persists int + for _, d := range f.Decls { + fn, ok := d.(*ast.FuncDecl) + if !ok || fn.Name.Name != "processGuestBootChange" { + continue + } + ast.Inspect(fn, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CallExpr: + if id, ok := node.Fun.(*ast.Ident); ok && id.Name == "guestBootVerdict" && len(node.Args) == 3 { + if a, ok := node.Args[2].(*ast.SelectorExpr); ok && a.Sel.Name == "GuestBootKey" { + calls++ + } + } + if sel, ok := node.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "SetLastGuestBootID" && len(node.Args) == 1 { + if a, ok := node.Args[0].(*ast.SelectorExpr); ok && strings.HasPrefix(a.Sel.Name, "GuestBoot") { + rawCompares++ // persisting the agent's raw token, not the verdict's + } else { + persists++ + } + } + case *ast.BinaryExpr: + for _, side := range []ast.Expr{node.X, node.Y} { + if a, ok := side.(*ast.SelectorExpr); ok && a.Sel.Name == "GuestBootID" && (node.Op == token.EQL || node.Op == token.NEQ) { + rawCompares++ + } + } + } + return true + }) + } + if calls != 1 || rawCompares != 0 || persists != 3 { + t.Errorf("processGuestBootChange: guestBootVerdict(…, resp.GuestBootKey) calls=%d (want 1), raw uses of the old token=%d (want 0), persists of the verdict's token=%d (want 3)", calls, rawCompares, persists) + } +} diff --git a/controller/internal/web/intermediary.go b/controller/internal/web/intermediary.go index 6f3b198d..1c5bf19a 100644 --- a/controller/internal/web/intermediary.go +++ b/controller/internal/web/intermediary.go @@ -395,6 +395,36 @@ func (s *Server) driveGateLoop() { } } +// guestBootVerdict decides „is this a new boot of the guest?” from the persisted record and the agent's two tokens +// (R-1395). `oldTok` is guest_boot_id = "-"; the kernel computes the boot time from +// the wall clock, so a STEP of the host's clock moved it and the controller recreated every drive-backed app (the +// N100, 2026-11-14: +32.7 s, three apps down for seconds). `key` is guest_boot_key = "-", which a clock step cannot move; "" from an agent that does not send it. +// +// - no key (older agent): the old rule — a non-empty token that differs from the record is a new boot. +// - key equals the record: same boot, whatever the old token says. THIS is the fix. +// - the record still holds the OLD token and it equals the old token now: same boot — and the record moves to the +// key (changed=false, persist=key), so the move itself recreates nothing. +// - anything else: a new boot; the record becomes the key. +// +// persist is the value to write: after the recreate pass when changed, at once when only the record moves. +// Pinned by TestGuestBootVerdict_*. +func guestBootVerdict(last, oldTok, key string) (changed bool, persist string) { + if key == "" { + if oldTok == "" || oldTok == last { + return false, "" + } + return true, oldTok + } + if key == last { + return false, "" + } + if last != "" && oldTok != "" && oldTok == last { + return false, key + } + return true, key +} + // processGuestBootChange converges a GUEST REBOOT DETERMINISTICALLY. On a guest reboot docker auto-starts // the app containers (restart:unless-stopped) BEFORE the agent re-propagates the drive under the parent // (~18s later) — so the create-time bind of an app's volume fails (`mkdir …/userdata: permission denied` @@ -428,9 +458,21 @@ func (s *Server) processGuestBootChange() { if derr != nil { return } - if resp.GuestBootID == "" || resp.GuestBootID == s.settings.GetLastGuestBootID() { - return // no boot-id, or unchanged (controller-only restart) → no recreate + last := s.settings.GetLastGuestBootID() + changed, persist := guestBootVerdict(last, resp.GuestBootID, resp.GuestBootKey) + if !changed { + if persist != "" && persist != last { + // R-1395: the first report with the clock-proof key, and the old token says „same boot” — take the key + // as the record from now on. No app is touched. + if serr := s.settings.SetLastGuestBootID(persist); serr != nil { + s.logger.Printf("[WARN] [gate] persist boot key: %v", serr) + } else { + s.logger.Printf("[INFO] [gate] boot record moved to the kernel's boot key (same boot; no app recreated)") + } + } + return // no token, or unchanged (controller-only restart) → no recreate } + bootID := persist // the token of THIS boot, logged and persisted below // Distinct stable drive paths that deployed drive-backed apps depend on (HDD_PATH is the drive root). needed := map[string]bool{} @@ -445,7 +487,7 @@ func (s *Server) processGuestBootChange() { } } if len(needed) == 0 { - if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil { + if serr := s.settings.SetLastGuestBootID(bootID); serr != nil { s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr) } return @@ -456,7 +498,7 @@ func (s *Server) processGuestBootChange() { } // READINESS GATE (the fix): wait for the REAL live in-guest bind, not the once-sampled agent view. - s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", resp.GuestBootID, bootBindWait, paths) + s.logger.Printf("[INFO] [gate] boot %s: waiting (≤%s) for live drive bind(s) %v before recreating drive-backed apps", bootID, bootBindWait, paths) presentStable := pollLiveBinds(paths, driveBindLive, time.Sleep, time.Now, bootBindWait, bootBindPoll) var bootStacks []bootStack @@ -475,7 +517,7 @@ func (s *Server) processGuestBootChange() { }) } recreate := func(bs bootStack) { - s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", resp.GuestBootID, bs.name, bs.state, bs.hdd) + s.logger.Printf("[INFO] [gate] boot %s: live bind confirmed — recreating drive-backed app %s (state=%s) onto %s", bootID, bs.name, bs.state, bs.hdd) // R-1251: a cut restore whose copy waited for THIS drive is poured back first — the recreate used to start // the app on the cut restore's data, and a later start poured the old copy over what the household wrote. if !s.resumePutBack(bs.name) || s.appHeld(bs.name) { @@ -487,19 +529,19 @@ func (s *Server) processGuestBootChange() { } } syncFB := func() { - s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", resp.GuestBootID) + s.logger.Printf("[INFO] [gate] boot %s: re-syncing FileBrowser mounts against the live binds", bootID) go s.SyncFileBrowserMounts() } _, skipped, leftStopped := recreateDriveBackedApps(bootStacks, presentStable, recreate, syncFB) if skipped > 0 { - s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", resp.GuestBootID, skipped, bootBindWait) + s.logger.Printf("[WARN] [gate] boot %s: %d drive-backed app(s) had no live bind within %s — leaving to the drive gate", bootID, skipped, bootBindWait) } if leftStopped > 0 { // INFO, not WARN: this is the gate working as intended (R-55). Make the honoured path // observable — a silent correct path is how an inert seam hides. - s.logger.Printf("[INFO] [gate] boot %s: %d drive-backed app(s) left stopped on purpose (recorded Stop, or a legacy app.yaml at zero containers)", resp.GuestBootID, leftStopped) + s.logger.Printf("[INFO] [gate] boot %s: %d drive-backed app(s) left stopped on purpose (recorded Stop, or a legacy app.yaml at zero containers)", bootID, leftStopped) } - if serr := s.settings.SetLastGuestBootID(resp.GuestBootID); serr != nil { + if serr := s.settings.SetLastGuestBootID(bootID); serr != nil { s.logger.Printf("[WARN] [gate] persist boot-id: %v", serr) } }