Files
felhom-agent/internal/localapi/guest_boot_key_test.go
T
admin d5ce5c364a agent (unreleased): a boot token that a clock step cannot move (R-1395)
/disks answers guest_boot_key (the kernel's boot_id and the guest init's start) beside the unchanged guest_boot_id.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-11-14 17:43:33 +01:00

110 lines
4.5 KiB
Go

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)