hub v0.46.0: observability pass — per-box log pulls, bundle custody, 72h TTL + secret gate

log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the
60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload;
REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests
controller_log_tail; heartbeat envelope gains log_tail_requested + ingests
log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host
detail Diagnostics section: request buttons (controller/agent), state rows with
honest latency hints, View/Download endpoint. Red-proofs: gate disabled and
clear-on-arrival removed both FAIL their tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 16:57:47 +02:00
parent 1ee5559772
commit e35b1ae0e6
13 changed files with 898 additions and 4 deletions
+55
View File
@@ -331,6 +331,29 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
}
}
// Controller self-log tail (v0.46.0) — the controller's OWN debug ring, shipped on the
// cycle after the ACK's controller_log_requested. SaveLogBundle runs the secret gate
// (a hit stores a BLOCKED flag row, nothing else) and clears the pending request
// (consume-once). Backward-compatible: old controllers never send this.
var selfTailPayload struct {
ControllerLogTail *struct {
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
} `json:"controller_log_tail"`
}
if err := json.Unmarshal(body, &selfTailPayload); err == nil && selfTailPayload.ControllerLogTail != nil {
lt := selfTailPayload.ControllerLogTail
blocked, berr := h.store.SaveLogBundle(payload.CustomerID, store.LogBundleComponentController, lt.CollectedAt, lt.Lines)
switch {
case berr != nil:
h.logger.Printf("[WARN] Failed to save controller log bundle for %s: %v", payload.CustomerID, berr)
case blocked:
h.logger.Printf("[WARN] controller log bundle for %s BLOCKED: possible secret in log content — nothing stored", payload.CustomerID)
default:
h.logger.Printf("[INFO] controller log bundle received for %s (%d lines)", payload.CustomerID, len(lt.Lines))
}
}
// DR recipe — persist the controller's secret-free customer/apps half (preserving any host half).
// Backward-compatible (old controllers won't have this field); a failure must not drop the report.
var drPayload struct {
@@ -376,6 +399,12 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
resp["log_tail_requests"] = apps
}
// v0.46.0 — pending CONTROLLER self-log pull (same additive ACK-flag pattern): the
// controller ships controller_log_tail on its NEXT report; omitted when nothing pending.
if pending, err := h.store.PendingLogBundleRequest(payload.CustomerID, store.LogBundleComponentController); err == nil && pending {
resp["controller_log_requested"] = true
}
// Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override
// else global default) and the latest available version. The controller compares its current
// version against the floor and auto-updates when below it (latest stays the customer's opt-in
@@ -447,6 +476,12 @@ type hostReportPayload struct {
// DR recipe — the agent's storage/guest/PBS half (secret-free). RawMessage = stored verbatim,
// ignore-unknown (forward-compat). Persisted to dr_recipe, assembled with the controller half.
DRRecipe json.RawMessage `json:"dr_recipe"`
// LogTail (v0.46.0) — the agent's on-demand debug-ring tail, present only on the
// heartbeat right after the envelope's log_tail_requested (agent ≥ 0.83.0).
LogTail *struct {
CollectedAt time.Time `json:"collected_at"`
Lines []string `json:"lines"`
} `json:"log_tail"`
}
// drRecipeVersionOnly extracts just recipe_version from a half's JSON (ignore-unknown). 0 if absent.
@@ -680,6 +715,21 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
h.logger.Printf("[INFO] host-report from %s (%d guests, %d storage targets, %d backups, %d restore-tests, %d pbs-snapshots, %d bytes)",
hostID, len(rep.Guests), len(rep.StorageTargets), len(rep.Backups), len(rep.RestoreTests), len(rep.PBSSnapshots), len(body))
// Agent log tail (v0.46.0) — the debug-ring bundle a prior envelope requested.
// SaveLogBundle runs the secret gate + clears the pending request (consume-once).
// A failure must NOT drop the heartbeat; just warn.
if rep.LogTail != nil {
blocked, berr := h.store.SaveLogBundle(hostID, store.LogBundleComponentAgent, rep.LogTail.CollectedAt, rep.LogTail.Lines)
switch {
case berr != nil:
h.logger.Printf("[WARN] Failed to save agent log bundle for %s: %v", hostID, berr)
case blocked:
h.logger.Printf("[WARN] agent log bundle for %s BLOCKED: possible secret in log content — nothing stored", hostID)
default:
h.logger.Printf("[INFO] agent log bundle received for %s (%d lines)", hostID, len(rep.LogTail.Lines))
}
}
// DR recipe — persist the agent's secret-free storage/guest/PBS half (preserving any app half).
// A failure here must NOT drop the heartbeat (the report already saved); just warn.
if len(rep.DRRecipe) > 0 && custID != "" {
@@ -717,6 +767,11 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
"desired_generation": desiredGen,
"has_signed_ops": hasSignedOps,
}
// v0.46.0 — pending agent log pull: the NEXT heartbeat carries log_tail (agent ≥
// 0.83.0; older agents ignore the flag and the request stays visibly pending).
if pending, err := h.store.PendingLogBundleRequest(hostID, store.LogBundleComponentAgent); err == nil && pending {
resp["log_tail_requested"] = true
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(resp)