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}}
+
{{slice .ReportedAgentSHA 0 12}}…
+ {{else}}
+ DRIFT — running {{slice .ReportedAgentSHA 0 12}}…, vouched {{slice .VouchedAgentSHA 0 12}}…