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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 21:42:15 +02:00
parent 68df84bcaa
commit a5b4d29af4
3 changed files with 112 additions and 0 deletions
+38
View File
@@ -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,
@@ -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)")
}
}
@@ -22,6 +22,17 @@
<span class="label">Agent Version</span>
<span class="value">{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</span>
</div>
{{if .AgentBinaryDrift}}
<div class="info-item" id="agent-binary">
<span class="label">Agent binary</span>
{{if eq .AgentBinaryDrift "ok"}}
<span class="value">matches vouched <code>{{slice .ReportedAgentSHA 0 12}}…</code></span>
{{else}}
<span class="value" style="color: var(--yellow)" id="agent-binary-drift">DRIFT — running <code>{{slice .ReportedAgentSHA 0 12}}…</code>, vouched <code>{{slice .VouchedAgentSHA 0 12}}…</code><br>
<span style="font-size:.85em">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.</span></span>
{{end}}
</div>
{{end}}
{{if .WrapperDrift}}
<div class="info-item">
<span class="label">PBS wrapper</span>