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:
+69
-22
@@ -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 }(),
|
||||
|
||||
Reference in New Issue
Block a user