hub v0.68.0 — auth_failed self-heal, consumed_at honesty gauge, wrapper drift (R-39 + R-50b(a))

Completes the hub half of R-39's fleet fix on top of the generation core (c484aa2).

pbsdrheal gains an auth_failed TRIGGER — a new trigger in the existing machine, not a
new machine. A box whose credential PBS rejects escalates to a fresh mint, never a
re-stage (which would re-feed the secret PBS just rejected), through the EXISTING damper:
a 401 flap must not become a secret-minting chain. With the generation stamp this closes
the loop end to end — agent proves the 401, hub re-keys, generation advances, descriptor
hash moves, agent re-consumes.

consumed_at honesty gauge: a staged secret still unconsumed past a 15-minute grace while
the box reports `applied` is surfaced with its own event. That is the exact 2026-07-18
fingerprint and a disagreement no single tier can see alone. Deliberately a SURFACE, not
a heal — auto-re-issuing on it would mint a second secret on top of an unconsumed one,
which is the mint/consume race R-39(a) already recorded. One event per distinct report,
and an honestly-stuck box does not double-report (its unconsumed secret is the symptom
being healed, not a contradiction).

R-50b(a): ArtifactManifest.WrapperSHA256 + operator field + host-page drift surface. The
PBS wrapper is root-owned 0755 and the pinned sudoers vector, yet installed unversioned
from raw/branch/main and absent from every manifest. Agents >=0.91.0 report the installed
hash; a mismatch is surfaced. An unknown on EITHER side reads as quiet, never as drift —
lighting every host amber on rollout day is how a warning becomes background noise. The
delivery channel itself stays R-50b(b)/(c).

Compatibility unchanged: safe for 0.90.0 agents (unknown JSON key dropped); the re-arm
and auth-honesty guarantees need agent >=0.91.0, so MinAgent moves only after the fleet
has self-updated.

Tests: auth_failed escalate/debounce/recovery-forgets-streak; honesty gauge incl. grace
window, the restage edge (consumed_at deliberately NULLed), consumed-never-alarms, and
honest-stuck-no-double-report; wrapper drift incl. both unknown directions. Red-proof run
at the assertion level: removing the auth_failed arm fails the escalation tests with
reissues=0.
This commit is contained in:
2026-07-21 10:01:35 +02:00
parent c484aa204e
commit 107f74ea3c
10 changed files with 561 additions and 38 deletions
+16 -1
View File
@@ -1019,6 +1019,12 @@ func normalizeSHA256(raw string) (string, bool) {
// that version's sha256 from Gitea itself (never trusting a client-supplied checksum), so there is no
// hand-copied sha to get wrong. When no Gitea client is configured (no registry creds) it falls back
// to the submitted sha256 (legacy manual path). Empty version clears that artifact.
// sha256HexRe validates an operator-typed sha256 (R-50b(a) wrapper hash): exactly 64 lowercase hex
// characters. The agent/golden hashes are resolved from the package registry instead, so this is the
// only manifest field a human types by hand — and a truncated paste must be refused, not stored as a
// hash that can never match.
var sha256HexRe = regexp.MustCompile(`^[0-9a-f]{64}$`)
func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -1037,18 +1043,27 @@ func (s *Server) handleSetArtifacts(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return
}
// R-50b(a): the PBS-DR wrapper hash is operator-typed, not resolved from the package registry —
// unlike the agent binary and the golden, this artifact is not published there at all. It is
// installed from raw/branch/main, which is exactly the drift this field makes visible.
wrapperSHA := strings.ToLower(strings.TrimSpace(r.FormValue("wrapper_sha256")))
if wrapperSHA != "" && !sha256HexRe.MatchString(wrapperSHA) {
http.Redirect(w, r, "/configuration?flash=artifact_sha_invalid", http.StatusSeeOther)
return
}
if err := s.store.SetArtifactManifest(store.ArtifactManifest{
AgentVersion: agentVer,
AgentSHA256: agentSHA,
GoldenVersion: goldenVer,
GoldenSHA256: goldenSHA,
MinAgent: minAgent,
WrapperSHA256: wrapperSHA,
}); err != nil {
s.logger.Printf("[ERROR] Failed to set artifact manifest: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q", agentVer, goldenVer, minAgent)
s.logger.Printf("[INFO] Artifact manifest set: agent=%s golden=%s min_agent=%q wrapper_sha=%t", agentVer, goldenVer, minAgent, wrapperSHA != "")
// Agent-plane immediate-sync (Direction-2a, v0.59.0): a MinAgent-floor / vouched-agent change is
// a fleet-wide agent-plane intent shift. Fire-and-forget nudge every box so it re-reports at
// once (the self-update train's signed op / floor re-evaluation lands in seconds, not ≤15 min).
+69 -22
View File
@@ -356,6 +356,49 @@ func (s *Server) handleHostsList(w http.ResponseWriter, r *http.Request) {
// hostDetailData assembles the view-model map the shared host_detail_body sub-template
// renders — used by BOTH the standalone /hosts/{id} page and the customer page's Host tab
// (v0.47.0). Booleans/counts only for DR/escrow; never api_key or blob contents.
// wrapperDrift compares the PBS-DR wrapper hash a host REPORTS against the one the operator vouched
// in the artifact manifest (R-50b(a), v0.68.0).
//
// The wrapper is a root-owned 0755 file installed from `raw/branch/main` — unversioned, unpinned and
// absent from every manifest until now, so two hosts installed a week apart could carry different
// privileged code while reporting the same agent version. This does not fix the delivery channel
// (R-50b(b)/(c)); it makes drift VISIBLE, which is what was missing.
//
// Returns ("", "") when either side is unknown: a hub that has not vouched a hash, or an agent below
// 0.91.0 that does not report one, is NOT drift — treating "unknown" as "mismatch" would light every
// host amber on the day this ships and teach the operator to ignore it.
func (s *Server) wrapperDrift(reportJSON string) (drift string, reported string) {
reported = parseReportedWrapperSHA(reportJSON)
return compareWrapperSHA(reported, s.store.GetArtifactManifest().WrapperSHA256), reported
}
// compareWrapperSHA is the pure comparison: "" (quiet) when either side is unknown, else ok/mismatch.
func compareWrapperSHA(reported, vouched string) string {
if reported == "" || vouched == "" {
return ""
}
if !strings.EqualFold(reported, vouched) {
return "mismatch"
}
return "ok"
}
// parseReportedWrapperSHA pulls host.wrapper_sha256 out of a host report ("" when absent).
func parseReportedWrapperSHA(reportJSON string) string {
if strings.TrimSpace(reportJSON) == "" {
return ""
}
var doc struct {
Host struct {
WrapperSHA256 string `json:"wrapper_sha256"`
} `json:"host"`
}
if json.Unmarshal([]byte(reportJSON), &doc) != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(doc.Host.WrapperSHA256))
}
func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]interface{} {
status := s.hostStatus(host.LastReportAt)
@@ -369,6 +412,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)
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.
@@ -395,28 +439,31 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
escrow, _ := s.store.GetHostEscrow(host.HostID)
return map[string]interface{}{
"HostID": host.HostID,
"CustomerID": host.CustomerID,
"CustomerName": s.customerName(host.CustomerID),
"AgentVersion": host.AgentVersion,
"CreatedAt": host.CreatedAt,
"Status": status,
"StatusLabel": hostStatusLabel(status),
"StatusClass": hostStatusClass(status),
"LastReportAt": host.LastReportAt,
"HasReport": host.LastReportAt != nil,
"RecoveryMode": host.InRecoveryMode(time.Now()),
"RecoveryUntil": host.RecoveryModeUntil,
"DesiredGeneration": host.DesiredGeneration,
"Vitals": vitals,
"Guests": guests,
"GuestRunning": guestRunning,
"GuestTotal": len(guests),
"StorageTargets": storageTargets,
"Capabilities": capabilities,
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
"DRPresent": drBundle != nil,
"EscrowPresent": escrow != nil,
"WrapperDrift": wrapperDrift,
"ReportedWrapperSHA": reportedWrapperSHA,
"VouchedWrapperSHA": s.store.GetArtifactManifest().WrapperSHA256,
"HostID": host.HostID,
"CustomerID": host.CustomerID,
"CustomerName": s.customerName(host.CustomerID),
"AgentVersion": host.AgentVersion,
"CreatedAt": host.CreatedAt,
"Status": status,
"StatusLabel": hostStatusLabel(status),
"StatusClass": hostStatusClass(status),
"LastReportAt": host.LastReportAt,
"HasReport": host.LastReportAt != nil,
"RecoveryMode": host.InRecoveryMode(time.Now()),
"RecoveryUntil": host.RecoveryModeUntil,
"DesiredGeneration": host.DesiredGeneration,
"Vitals": vitals,
"Guests": guests,
"GuestRunning": guestRunning,
"GuestTotal": len(guests),
"StorageTargets": storageTargets,
"Capabilities": capabilities,
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
"DRPresent": drBundle != nil,
"EscrowPresent": escrow != nil,
// v0.60.0 Part B: retained superseded escrow blobs (data-first — old passphrases stay
// R-recoverable). Operator-only surface.
"SupersededEscrowCount": func() int { n, _ := s.store.CountSupersededEscrow(host.HostID); return n }(),
@@ -180,6 +180,11 @@
<label style="font-size: 0.9em; color: #cbd5e1;">Min agent</label>
<input type="text" name="min_agent" value="{{.Artifacts.MinAgent}}" placeholder="e.g. 0.81.0 (blank = uncoupled)" style="padding: 0.3em 0.5em;">
<span style="font-size: 0.8em; color: #94a6bf;">The golden's controller CHANGELOG <code>MinAgent:</code>. The hub HOLDS the floor for any box whose agent is below this — blank = uncoupled release, no gating.</span>
</div>
<div style="margin-bottom: 0.75em;">
<label style="display:inline-block; min-width: 12em;">PBS wrapper sha256</label>
<input type="text" name="wrapper_sha256" value="{{.Artifacts.WrapperSHA256}}" placeholder="64 hex chars (blank = not vouched)" style="padding: 0.3em 0.5em; width: 34em;">
<span style="font-size: 0.8em; color: #94a6bf;">sha256 of <code>configs/felhom-pbs-apply</code> (R-50b). Unlike the agent and golden, this root-owned wrapper is installed from <code>raw/branch/main</code> — unversioned and unpinned. Recording it here does not fix the channel; it makes host drift <em>visible</em>: agents report the installed file's hash and a mismatch is surfaced on the host.</span>
<span></span><span></span>
<button class="btn btn-sm" type="submit" style="justify-self: start;">Save artifact manifest</button>
</form>
@@ -22,6 +22,17 @@
<span class="label">Agent Version</span>
<span class="value">{{if .AgentVersion}}<code>{{.AgentVersion}}</code>{{else}}—{{end}}</span>
</div>
{{if .WrapperDrift}}
<div class="info-item">
<span class="label">PBS wrapper</span>
{{if eq .WrapperDrift "ok"}}
<span class="value">matches vouched <code>{{slice .ReportedWrapperSHA 0 12}}…</code></span>
{{else}}
<span class="value" style="color: var(--yellow)">DRIFT — installed <code>{{slice .ReportedWrapperSHA 0 12}}…</code>, vouched <code>{{slice .VouchedWrapperSHA 0 12}}…</code><br>
<span style="font-size:.85em">/usr/local/sbin/felhom-pbs-apply differs from the manifest. It is delivered unversioned from <code>main</code> (R-50b), so this host may be running privileged code from a different commit.</span></span>
{{end}}
</div>
{{end}}
<div class="info-item">
<span class="label">Enrolled</span>
<span class="value">{{timeAgo .CreatedAt}}</span>
+54
View File
@@ -0,0 +1,54 @@
package web
import "testing"
// R-50b(a) — wrapper drift must be VISIBLE, and "unknown" must never read as "mismatch".
//
// The wrapper is root-owned, 0755, and installed from raw/branch/main: unversioned, unpinned, and
// absent from every manifest until v0.68.0. This is the surface that makes drift answerable.
func TestParseReportedWrapperSHA(t *testing.T) {
cases := []struct {
name, report, want string
}{
{"present", `{"host":{"wrapper_sha256":"AABBCC"}}`, "aabbcc"},
{"lowercased and trimmed", `{"host":{"wrapper_sha256":" AaBb "}}`, "aabb"},
{"absent key", `{"host":{"agent_version":"0.91.0"}}`, ""},
{"no host stanza", `{"guests":[]}`, ""},
{"empty report", ``, ""},
{"malformed json", `{nope`, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseReportedWrapperSHA(tc.report); got != tc.want {
t.Errorf("parseReportedWrapperSHA(%q) = %q, want %q", tc.report, got, tc.want)
}
})
}
}
// The comparison itself. The load-bearing case is the pair of UNKNOWNS: an un-vouched hub, or an
// agent below 0.91.0 that reports nothing, must be quiet — lighting every host amber on rollout day
// is how a warning gets trained into background noise.
func TestWrapperDriftComparison(t *testing.T) {
const vouched = "1111111111111111111111111111111111111111111111111111111111111111"
const other = "2222222222222222222222222222222222222222222222222222222222222222"
cases := []struct {
name, reported, vouched, want string
}{
{"match", vouched, vouched, "ok"},
{"match is case-insensitive", "AAAA", "aaaa", "ok"},
{"mismatch", other, vouched, "mismatch"},
{"agent reports nothing (pre-0.91.0) → quiet", "", vouched, ""},
{"hub has vouched nothing → quiet", vouched, "", ""},
{"neither side known → quiet", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := compareWrapperSHA(tc.reported, tc.vouched)
if got != tc.want {
t.Errorf("compareWrapperSHA(%q, %q) = %q, want %q", tc.reported, tc.vouched, got, tc.want)
}
})
}
}