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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user