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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
+26
-2
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user