From 64f704d0f71646f9c6c71224406c2f53634b79bf Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:10:43 +0200 Subject: [PATCH] R-349: the host report carries the sha256 of the RUNNING agent binary A hand-built proof binary and the published artifact share a version string but not their bytes, so no version check could see the divergence. The agent now reports agent_sha256 (hash of /proc/self/exe, once per process; empty = unknown) beside agent_version, the same mechanism as host.wrapper_sha256. The hub half (compare against the vouched agent_sha256, surface drift) is owed in the hub repo. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- internal/hub/collect.go | 28 +++++++++++- internal/hub/collect_agentsha_test.go | 64 +++++++++++++++++++++++++++ internal/hub/report.go | 10 +++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 internal/hub/collect_agentsha_test.go diff --git a/internal/hub/collect.go b/internal/hub/collect.go index 01c290a..0fed642 100644 --- a/internal/hub/collect.go +++ b/internal/hub/collect.go @@ -10,6 +10,7 @@ import ( "log/slog" "os" "strings" + "sync" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/capability" @@ -115,6 +116,7 @@ type Collector struct { backupTarget func() ConfiguredBackupTarget // R-109: primary backup tier id (nil → recipe records unknown) hostID string agentVersion string + selfSHA func() string // R-349: sha256 of the running binary; default runningBinarySHA256 logger *slog.Logger now func() time.Time } @@ -135,6 +137,7 @@ func NewCollector(px proxmoxReader, cf CloudflaredProber, storage StorageObserve temp: SysfsTempReader{}, // slice 9: real sysfs reader by default; tests inject a fake hostID: hostID, agentVersion: agentVersion, + selfSHA: runningBinarySHA256, logger: logger, now: func() time.Time { return time.Now().UTC() }, } @@ -337,6 +340,7 @@ func (c *Collector) Collect(ctx context.Context) (*HostReport, error) { HostID: c.hostID, ReportedAt: c.now().Format(time.RFC3339), AgentVersion: c.agentVersion, + AgentSHA256: c.agentSHA256(), Host: host, Guests: c.collectGuests(ctx), // storage_targets populated this slice (slice 5) via the observer; the rest stay @@ -431,8 +435,28 @@ const pbsWrapperPath = "/usr/local/sbin/felhom-pbs-apply" // unreadable file yields "", which the hub reads as UNKNOWN rather than as drift — a host that // legitimately has no DR wrapper must not light up amber. The file is 0755, so no privilege is // needed to read it. -func pbsWrapperSHA256() string { - f, err := os.Open(pbsWrapperPath) +func pbsWrapperSHA256() string { return fileSHA256(pbsWrapperPath) } + +// selfExePath is the running binary as the kernel holds it. /proc/self/exe, not the installed path: +// after an A/B flip the file at /usr/local/bin/felhom-agent may already be the NEXT binary while this +// process still runs the old one, and the report must describe what runs (R-349). Test seam. +var selfExePath = "/proc/self/exe" + +// runningBinarySHA256 hashes the running binary ONCE per process — the bytes cannot change under a +// running process, and re-hashing ~20 MB every report cycle buys nothing. A failed read is cached as +// "" (UNKNOWN); it never fails the report. +var runningBinarySHA256 = sync.OnceValue(func() string { return fileSHA256(selfExePath) }) + +func (c *Collector) agentSHA256() string { + if c.selfSHA == nil { + return "" + } + return c.selfSHA() +} + +// fileSHA256 is the hex sha256 of a file's bytes, or "" when it cannot be read. +func fileSHA256(path string) string { + f, err := os.Open(path) if err != nil { return "" } diff --git a/internal/hub/collect_agentsha_test.go b/internal/hub/collect_agentsha_test.go new file mode 100644 index 0000000..8f1d7dc --- /dev/null +++ b/internal/hub/collect_agentsha_test.go @@ -0,0 +1,64 @@ +package hub + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// R-349: the report carries the sha256 of the binary that is RUNNING, so the hub can tell a +// hand-built proof binary from the vouched artifact of the same version string. The consequence +// asserted: the wire field equals the hash of this very test binary's bytes (read independently via +// os.Executable, a different channel from /proc/self/exe), and it is on the wire as agent_sha256. +func TestCollect_AgentSHA256IsTheRunningBinary(t *testing.T) { + exe, err := os.Executable() + if err != nil { + t.Skipf("os.Executable: %v", err) + } + raw, err := os.ReadFile(exe) + if err != nil { + t.Fatalf("read own binary: %v", err) + } + sum := sha256.Sum256(raw) + want := hex.EncodeToString(sum[:]) + + px := &fakePx{node: "n", ns: newTestNodeStatus()} + c := NewCollector(px, fakeProber{status: "running"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger()) + r, err := c.Collect(context.Background()) + if err != nil { + t.Fatalf("Collect: %v", err) + } + if r.AgentSHA256 != want { + t.Fatalf("agent_sha256 = %q, want the running binary's %q", r.AgentSHA256, want) + } + b, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), `"agent_sha256":"`+want+`"`) { + t.Fatalf("agent_sha256 not on the wire: %s", b) + } +} + +// An unreadable binary is UNKNOWN (empty, omitted) — never a made-up hash, never a failed report. +func TestFileSHA256_UnreadableIsEmpty(t *testing.T) { + if got := fileSHA256(filepath.Join(t.TempDir(), "absent")); got != "" { + t.Fatalf("absent file hashed to %q, want empty", got) + } + px := &fakePx{node: "n", ns: newTestNodeStatus()} + c := NewCollector(px, fakeProber{status: "running"}, nil, nil, nil, nil, "h", "0.3.0", quietLogger()) + c.selfSHA = func() string { return "" } + r, err := c.Collect(context.Background()) + if err != nil { + t.Fatalf("Collect must not fail on an unreadable binary: %v", err) + } + b, _ := json.Marshal(r) + if strings.Contains(string(b), "agent_sha256") { + t.Fatalf("empty agent_sha256 must be omitted: %s", b) + } +} diff --git a/internal/hub/report.go b/internal/hub/report.go index ead6be1..bc3bebc 100644 --- a/internal/hub/report.go +++ b/internal/hub/report.go @@ -18,6 +18,16 @@ type HostReport struct { HostID string `json:"host_id"` // echoes config.Hub.HostID ReportedAt string `json:"reported_at"` // RFC3339, agent clock AgentVersion string `json:"agent_version"` + // AgentSHA256 is the sha256 of the binary this process is RUNNING (read through /proc/self/exe, + // once per process), R-349. The version string cannot tell a hand-built proof binary from the + // published, vouched artifact of the same version — same source, different bytes (`-trimpath + // -buildvcs=false` in release-agent.sh) — so self-update sees "already installed" and never + // corrects it. Reporting the bytes lets the hub compare against the vouched agent_sha256, the + // same mechanism host.wrapper_sha256 is for the PBS wrapper (R-50b(a)). + // + // Empty = unreadable, which the hub must treat as UNKNOWN, never as drift. Pinned by + // TestCollect_AgentSHA256IsTheRunningBinary. + AgentSHA256 string `json:"agent_sha256,omitempty"` Host HostMetrics `json:"host"` Guests []Guest `json:"guests"`