package localapi import ( "context" "encoding/json" "io" "log/slog" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) type initPIDRunner struct{ pid string } func (r initPIDRunner) Run(_ context.Context, name string, _ ...string) ([]byte, []byte, error) { if name == "lxc-info" { return []byte(r.pid + "\n"), nil, nil } return nil, nil, nil } func (r initPIDRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return r.Run(ctx, name, args...) } // R-1395: on 2026-11-14 the N100's time service came back and stepped the host's clock by +32.7 s. The kernel // computes the boot TIME from the wall clock, so guest_boot_id moved and the controller recreated every drive-backed // app. The consequence asserted: the boot KEY does not move when the boot time moves; it does move on a host reboot // and on a guest reboot; and it is "" (so the controller falls back to the old token) when the boot_id is unreadable. // COMPANION RED-PROOFS (observed, felhom.eu audits/night-build-2026-11-14/C/redproof-agent-boot-key.txt). func TestGuestBootKey_AClockStepDoesNotMoveIt(t *testing.T) { oldB, oldK, oldS := hostBtimeFn, hostBootIDFn, procStarttimeFn t.Cleanup(func() { hostBtimeFn, hostBootIDFn, procStarttimeFn = oldB, oldK, oldS }) btime, bootID, start := "1794112518", "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11", "4242" hostBtimeFn = func() string { return btime } hostBootIDFn = func() string { return bootID } procStarttimeFn = func(pid string) string { if pid != "31337" { t.Errorf("start time read for pid %q, want the guest's init", pid) } return start } b := NewGuestBinder(initPIDRunner{"31337"}, slog.New(slog.NewTextHandler(io.Discard, nil))) ctx := context.Background() id0, key0 := b.GuestBootID(ctx, 9201), b.GuestBootKey(ctx, 9201) if id0 != "1794112518-4242" || key0 != "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11-4242" { t.Fatalf("tokens = %q / %q", id0, key0) } btime = "1794112550" // THE STEP: the wall clock jumped +32 s, so the kernel's btime did too id1, key1 := b.GuestBootID(ctx, 9201), b.GuestBootKey(ctx, 9201) if id1 == id0 { t.Fatalf("control: the old token did not move with the boot time (%q) — the test does not stage the defect", id1) } if key1 != key0 { t.Fatalf("the boot key moved with the clock: %q → %q — a clock step reads as a new boot", key0, key1) } if strings.Contains(key1, "1794") { t.Errorf("the boot key carries the boot time: %q", key1) } bootID = "c0ffee00-1111-2222-3333-444455556666" // a host reboot if k := b.GuestBootKey(ctx, 9201); k == key0 { t.Errorf("a host reboot (new boot_id) did not change the key: %q", k) } bootID, start = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11", "991234" // a guest reboot if k := b.GuestBootKey(ctx, 9201); k == key0 || !strings.HasSuffix(k, "-991234") { t.Errorf("a guest reboot (new init start) did not change the key: %q", k) } for _, bad := range []string{"", "not-a-boot-id", "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11\nx"} { bootID = bad if k := b.GuestBootKey(ctx, 9201); k != "" { t.Errorf("boot_id %q gave the key %q, want \"\" (the controller then reads the old token)", bad, k) } if id := b.GuestBootID(ctx, 9201); id == "" { t.Errorf("the old token must still be sent when the boot_id is unreadable") } } bootID, start = "5b1f6c1e-8f0a-4c56-9d1c-0a8c3b7d2e11", "" // the guest does not run if k := b.GuestBootKey(ctx, 9201); k != "" { t.Errorf("a guest that does not run gave the key %q", k) } } // The key is no use if /disks does not carry it: both tokens are on the wire, the old one unchanged for controllers // that do not know the key. func TestDisks_CarriesBothBootTokens(t *testing.T) { srv := targetRowServer(t, "", []hub.StorageTarget{}) srv.guestAttach = &fakeGuestAttacher{} body := do(t, srv.Handler(), "GET", "/disks", "A", "").Body.Bytes() var w struct { Data struct { ID string `json:"guest_boot_id"` Key string `json:"guest_boot_key"` } `json:"data"` ID string `json:"guest_boot_id"` Key string `json:"guest_boot_key"` } if err := json.Unmarshal(body, &w); err != nil { t.Fatalf("decode: %v (%s)", err, body) } if w.Data.ID+w.ID != "boot-1" || w.Data.Key+w.Key != "key-1" { t.Fatalf("/disks boot tokens: id=%q key=%q, want boot-1 and key-1 (%s)", w.Data.ID+w.ID, w.Data.Key+w.Key, body) } } // The real binder is what main.go hands the server: it must have the method (the interface makes this a build // error if not — stated here so the reason is on record). var _ GuestAttacher = (*GuestBinder)(nil)