From a5b4d29af4921fc5e305922e262c26dc1eb1118e Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 5 Oct 2026 21:42:15 +0200 Subject: [PATCH] R-349 (hub half): host page shows agent-binary drift from the reported agent_sha256 When the box reports the vouched agent version, its running binary's sha256 is compared with the vouched AgentSHA256: same bytes -> "matches vouched", different -> amber DRIFT. An empty hash (older agent) or another version is not comparable and shows nothing. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/internal/web/hosts.go | 38 +++++++++++ hub/internal/web/r349_agent_binary_test.go | 63 +++++++++++++++++++ .../web/templates/host_detail_body.html | 11 ++++ 3 files changed, 112 insertions(+) create mode 100644 hub/internal/web/r349_agent_binary_test.go diff --git a/hub/internal/web/hosts.go b/hub/internal/web/hosts.go index 0d1a78bb..48c37e9b 100644 --- a/hub/internal/web/hosts.go +++ b/hub/internal/web/hosts.go @@ -678,6 +678,40 @@ func parseReportedWrapperSHA(reportJSON string) string { return strings.ToLower(strings.TrimSpace(doc.Host.WrapperSHA256)) } +// agentBinaryDrift (R-349) compares the sha256 of the agent binary the box is RUNNING (top-level +// agent_sha256 in its host report, agent >= the R-349 release) with the vouched AgentSHA256 — but ONLY +// when the reported agent_version IS the vouched version: a box on another version is a version +// difference, which every version check already shows, not a byte difference. A hand-built proof +// binary carries the vouched version string with different bytes, and self-update cannot notice it; +// this is where it becomes visible. +// +// Returns ("", reported) when not comparable: either hash unknown (empty = unknown, NEVER drift — an +// older agent sends none), or the versions differ. Otherwise "ok" / "mismatch". +// Pinned by TestR349_AgentBinaryDrift and TestR349_HostPageShowsAgentBinaryDrift. +func agentBinaryDrift(reportJSON string, m store.ArtifactManifest) (drift, reported string) { + version, reported := parseReportedAgentBinary(reportJSON) + if version == "" || m.AgentVersion == "" || strings.TrimPrefix(version, "v") != strings.TrimPrefix(m.AgentVersion, "v") { + return "", reported + } + return compareWrapperSHA(reported, strings.ToLower(strings.TrimSpace(m.AgentSHA256))), reported +} + +// parseReportedAgentBinary pulls the top-level agent_version and agent_sha256 out of a host report +// ("" for each when absent or unparseable). +func parseReportedAgentBinary(reportJSON string) (version, sha string) { + if strings.TrimSpace(reportJSON) == "" { + return "", "" + } + var doc struct { + AgentVersion string `json:"agent_version"` + AgentSHA256 string `json:"agent_sha256"` + } + if json.Unmarshal([]byte(reportJSON), &doc) != nil { + return "", "" + } + return strings.TrimSpace(doc.AgentVersion), strings.ToLower(strings.TrimSpace(doc.AgentSHA256)) +} + func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]interface{} { status := s.hostStatus(host.LastReportAt) @@ -692,6 +726,7 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in reportJSON, _ := s.store.GetLatestHostReportJSON(host.CustomerID) vitals := parseHostVitals(reportJSON) wrapperDrift, reportedWrapperSHA := s.wrapperDrift(reportJSON) + agentDrift, reportedAgentSHA := agentBinaryDrift(reportJSON, s.store.GetArtifactManifest()) storageTargets := parseHostStorageTargets(reportJSON) sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name }) // v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name. @@ -732,6 +767,9 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in } return map[string]interface{}{ + "AgentBinaryDrift": agentDrift, + "ReportedAgentSHA": reportedAgentSHA, + "VouchedAgentSHA": strings.ToLower(s.store.GetArtifactManifest().AgentSHA256), "WrapperDrift": wrapperDrift, "ReportedWrapperSHA": reportedWrapperSHA, "VouchedWrapperSHA": s.store.GetArtifactManifest().WrapperSHA256, diff --git a/hub/internal/web/r349_agent_binary_test.go b/hub/internal/web/r349_agent_binary_test.go new file mode 100644 index 00000000..e68da567 --- /dev/null +++ b/hub/internal/web/r349_agent_binary_test.go @@ -0,0 +1,63 @@ +package web + +import ( + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-349 (hub half): the agent reports the sha256 of the binary it RUNS (top-level agent_sha256). The +// hub compares it with the vouched AgentSHA256 only when the reported version IS the vouched version; +// an empty hash is UNKNOWN and never drift. +const r349Vouched = "a56a92a7aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const r349Hand = "256e0829bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +func TestR349_AgentBinaryDrift(t *testing.T) { + m := store.ArtifactManifest{AgentVersion: "0.130.0", AgentSHA256: strings.ToUpper(r349Vouched)} + cases := []struct{ name, report, want string }{ + {"same version, same bytes", `{"agent_version":"0.130.0","agent_sha256":"` + r349Vouched + `"}`, "ok"}, + {"same version, different bytes (the R-349 case)", `{"agent_version":"0.130.0","agent_sha256":"` + r349Hand + `"}`, "mismatch"}, + {"v-prefixed version still compares", `{"agent_version":"v0.130.0","agent_sha256":"` + r349Hand + `"}`, "mismatch"}, + {"older agent: no hash = unknown", `{"agent_version":"0.130.0"}`, ""}, + {"empty hash = unknown", `{"agent_version":"0.130.0","agent_sha256":""}`, ""}, + {"other version = not comparable", `{"agent_version":"0.129.0","agent_sha256":"` + r349Hand + `"}`, ""}, + {"nested host.agent_sha256 is not the field", `{"agent_version":"0.130.0","host":{"agent_sha256":"` + r349Hand + `"}}`, ""}, + {"no report", ``, ""}, + {"malformed", `{nope`, ""}, + } + for _, tc := range cases { + if got, _ := agentBinaryDrift(tc.report, m); got != tc.want { + t.Errorf("%s: drift = %q, want %q", tc.name, got, tc.want) + } + } + if got, _ := agentBinaryDrift(`{"agent_version":"0.130.0","agent_sha256":"`+r349Hand+`"}`, store.ArtifactManifest{AgentVersion: "0.130.0"}); got != "" { + t.Errorf("un-vouched hash: drift = %q, want unknown", got) + } +} + +// The page, per branch of the template gate: drift (amber, both hashes), ok, and the unknown case +// (no line at all). +func TestR349_HostPageShowsAgentBinaryDrift(t *testing.T) { + s, st, _ := newRevealServer(t) + cookie, _ := newRevealSession(t, s) + if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.130.0", AgentSHA256: r349Vouched}); err != nil { + t.Fatal(err) + } + seedNetHost(t, st, "h-drift", "0.130.0", `{"agent_version":"0.130.0","agent_sha256":"`+r349Hand+`"}`, "") + seedNetHost(t, st, "h-ok", "0.130.0", `{"agent_version":"0.130.0","agent_sha256":"`+r349Vouched+`"}`, "") + seedNetHost(t, st, "h-old", "0.130.0", `{"agent_version":"0.130.0"}`, "") + + b := getHostPage(t, s, cookie, "h-drift") + if !strings.Contains(b, `id="agent-binary-drift"`) || !strings.Contains(b, r349Hand[:12]) || !strings.Contains(b, r349Vouched[:12]) { + t.Fatal("same version, different bytes: the host page shows no agent-binary DRIFT with both hashes") + } + b = getHostPage(t, s, cookie, "h-ok") + if !strings.Contains(b, `id="agent-binary"`) || strings.Contains(b, `id="agent-binary-drift"`) { + t.Fatal("matching bytes: want the 'matches vouched' line and no drift") + } + b = getHostPage(t, s, cookie, "h-old") + if strings.Contains(b, `id="agent-binary"`) { + t.Fatal("an agent that reports no hash must show no agent-binary line (unknown, never drift)") + } +} diff --git a/hub/internal/web/templates/host_detail_body.html b/hub/internal/web/templates/host_detail_body.html index eb99b92c..84bade72 100644 --- a/hub/internal/web/templates/host_detail_body.html +++ b/hub/internal/web/templates/host_detail_body.html @@ -22,6 +22,17 @@ Agent Version {{if .AgentVersion}}{{.AgentVersion}}{{else}}—{{end}} + {{if .AgentBinaryDrift}} +
+ Agent binary + {{if eq .AgentBinaryDrift "ok"}} + matches vouched {{slice .ReportedAgentSHA 0 12}}… + {{else}} + DRIFT — running {{slice .ReportedAgentSHA 0 12}}…, vouched {{slice .VouchedAgentSHA 0 12}}…
+ Same version as the vouched agent, different bytes (a hand build?). Self-update sees the version as installed and will not correct it (R-349) — deliver the published artifact.
+ {{end}} +
+ {{end}} {{if .WrapperDrift}}
PBS wrapper