107f74ea3c
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.
337 lines
16 KiB
Go
337 lines
16 KiB
Go
// Package pbsdrheal is the hub-side PBS-DR self-heal reconciler (TASK 2026-07-15, from
|
||
// SPIKE-pbsdr-selfheal-2026-07-15 / e8f8c44).
|
||
//
|
||
// Root cause the spike proved (not reasoned): a customer box re-installed / restored / rolled back
|
||
// onto its STABLE host_id loses its agent-side converged marker; the hub still holds the durable
|
||
// pbs_dr descriptor (enabled) and the durable *consumed* one-time secret, and the WG peer still
|
||
// exists (same pubkey → changed==false → the provision cascade cannot re-fire). The agent gets the
|
||
// descriptor, verifies over the tunnel, but ConsumePBSToken returns "no secret" — it sits in
|
||
// pbs_dr.state="waiting_secret" forever. PBS-DR never converges, so escrow can't run and offsite
|
||
// never arms. The MISSING PIECE IS A CONSUMABLE SECRET, NOT THE DESCRIPTOR (spike SQ-2b′:
|
||
// re-staging the stored secret converged the box in one ~30 s agent tick, using the existing ep0
|
||
// token, zero churn).
|
||
//
|
||
// This reconciler, for each host whose descriptor is enabled+provisioned and whose LATEST report is
|
||
// a stuck state sustained across a debounce, RE-STAGES the stored secret (store.RestageHostPBSSecret
|
||
// — no ep0 call, no generation bump) and escalates to the existing Re-issue only when there is no
|
||
// stored secret to re-stage or the agent burned one (consumed_failed). It NEVER re-runs the whole
|
||
// provision atom (which refuses ErrTokenExists) and NEVER blind-timer-reissues (hash/gen thrash).
|
||
// A converged/healthy/disabled host is a pure no-op (idempotency — Scenario C).
|
||
package pbsdrheal
|
||
|
||
import (
|
||
"context"
|
||
"log"
|
||
"time"
|
||
|
||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||
)
|
||
|
||
// The reported agent pbs_dr.state strings (felhom-agent internal/pbsdr/manager.go). Only the two
|
||
// STUCK states below are acted on; everything else (applied, adopted, disabled, verify_failed, "")
|
||
// is a no-op. verify_failed is DELIBERATELY not healed: it is a descriptor/PBS-reachability problem
|
||
// (the secret is untouched by verify-before-consume), self-heals when the tunnel recovers, and must
|
||
// stay LOUD for the operator if it does not — re-staging a secret would not help it.
|
||
const (
|
||
stateWaitingSecret = "waiting_secret"
|
||
stateConsumedFailed = "consumed_failed"
|
||
// stateAuthFailed (R-39, agent >= 0.91.0) — the box HAS a credential, the descriptor is applied,
|
||
// and PBS rejects it with 401. Before 0.91.0 this state could not exist: the agent's verify loop
|
||
// read the credential file directly as non-root, always failed with "permission denied", and
|
||
// skipped — so an applied-and-dead tier was invisible to both tiers. It is healed like
|
||
// consumed_failed (escalate to a fresh mint), never by a re-stage: re-staging re-feeds the SAME
|
||
// secret PBS just rejected.
|
||
stateAuthFailed = "auth_failed"
|
||
)
|
||
|
||
// Audit event types (store.SaveEvent; hub-internal, not gated by allowedEventTypes). Distinct per
|
||
// remediation so the operator sees exactly what was auto-done (Scenario E wants a distinct signal).
|
||
const (
|
||
eventRestaged = "pbsdr_selfheal_restaged" // re-armed the stored secret (routine)
|
||
eventReissued = "pbsdr_selfheal_reissued" // no stored secret → minted a fresh one
|
||
eventConsumedFailed = "pbsdr_selfheal_consumed_failed" // burned secret → minted a fresh one (a real problem was remediated)
|
||
eventAuthFailed = "pbsdr_selfheal_auth_failed" // PBS rejected the box's credential (401) → minted a fresh one
|
||
eventUnconsumed = "pbsdr_unconsumed_secret" // staged secret never consumed under an `applied` box (surfaced, NOT auto-healed)
|
||
)
|
||
|
||
// unconsumedGrace bounds how long a staged-but-unconsumed secret is NORMAL before it is a lie.
|
||
//
|
||
// A fresh mint (or a deliberate re-stage) is consumed on the agent's next tick — well inside one
|
||
// ~15-minute report cycle. Beyond this window, an unconsumed secret sitting under a box that reports
|
||
// `applied` is the exact fingerprint of the 2026-07-18 N100 failure: hub minted, agent
|
||
// short-circuited, both tiers green, box serving a revoked credential.
|
||
const unconsumedGrace = 15 * time.Minute
|
||
|
||
// Actions is the mutation seam — fakes in tests count calls without SSH/ep0. Restage flips a stored
|
||
// secret's consumed flag (returns restaged=false when NO row exists → the caller escalates). Reissue
|
||
// mints a fresh ep0 token + stores a fresh consume-once secret + bumps the descriptor.
|
||
type Actions interface {
|
||
Restage(hostID string) (restaged bool, err error)
|
||
Reissue(ctx context.Context, customerID string) error
|
||
}
|
||
|
||
// Reissuer is satisfied by *web.Server (its ReissuePBSDR). Kept here so main.go can wire the server
|
||
// as the escalation path without an import cycle.
|
||
type Reissuer interface {
|
||
ReissuePBSDR(ctx context.Context, customerID string) error
|
||
}
|
||
|
||
// storeActions is the production Actions: Restage → the store primitive; Reissue → the web server.
|
||
type storeActions struct {
|
||
st *store.Store
|
||
reissuer Reissuer
|
||
}
|
||
|
||
func (a storeActions) Restage(hostID string) (bool, error) { return a.st.RestageHostPBSSecret(hostID) }
|
||
func (a storeActions) Reissue(ctx context.Context, customerID string) error {
|
||
return a.reissuer.ReissuePBSDR(ctx, customerID)
|
||
}
|
||
|
||
// NewActions builds the production mutation seam.
|
||
func NewActions(st *store.Store, reissuer Reissuer) Actions {
|
||
return storeActions{st: st, reissuer: reissuer}
|
||
}
|
||
|
||
// debounceState tracks, per host, the last DISTINCT report observed and how many consecutive
|
||
// distinct reports it has held the current stuck state — so a fresh box that briefly shows
|
||
// waiting_secret between provision and its first consume (resolved within one 60 s agent tick, well
|
||
// inside one ~15 min report cycle) is NOT healed on a single report (Scenario D).
|
||
type debounceState struct {
|
||
reportID int64
|
||
state string
|
||
streak int
|
||
}
|
||
|
||
// Reconciler re-arms stuck PBS-DR hosts. DECLARATIVE + IDEMPOTENT: a tick over a converged fleet
|
||
// writes nothing (Scenario C). It reads the hub DB (the source of truth) — never the box.
|
||
type Reconciler struct {
|
||
store *store.Store
|
||
act Actions
|
||
interval time.Duration
|
||
debounceReports int // distinct stuck reports required before healing (default 2)
|
||
onlyHost string // "" = whole fleet; non-empty restricts the work set to one host (supervised rollout)
|
||
trigger chan struct{}
|
||
logger *log.Logger
|
||
|
||
deb map[string]debounceState
|
||
// unconsumedSeen remembers the last report id already surfaced per host (Scenario F), so a
|
||
// sustained disagreement produces one event per fresh report rather than one per tick.
|
||
unconsumedSeen map[string]int64
|
||
}
|
||
|
||
// NewReconciler builds the reconciler. interval defaults to 5m, debounceReports to 2.
|
||
func NewReconciler(st *store.Store, act Actions, logger *log.Logger) *Reconciler {
|
||
if logger == nil {
|
||
logger = log.Default()
|
||
}
|
||
return &Reconciler{
|
||
store: st,
|
||
act: act,
|
||
interval: 5 * time.Minute,
|
||
debounceReports: 2,
|
||
trigger: make(chan struct{}, 1),
|
||
logger: logger,
|
||
deb: map[string]debounceState{},
|
||
unconsumedSeen: map[string]int64{},
|
||
}
|
||
}
|
||
|
||
// RestrictToHost scopes the reconciler's work set to a single host_id (empty = whole fleet). Used
|
||
// for a supervised first rollout (PBSDRHEAL_ONLY_HOST): validate the converged-host no-op live on the
|
||
// drill guest before widening to the fleet. Set before Run.
|
||
func (r *Reconciler) RestrictToHost(hostID string) { r.onlyHost = hostID }
|
||
|
||
// Trigger requests an immediate reconcile (tests + the mutation handlers). Non-blocking.
|
||
func (r *Reconciler) Trigger() {
|
||
select {
|
||
case r.trigger <- struct{}{}:
|
||
default:
|
||
}
|
||
}
|
||
|
||
// Run loops until ctx is done, reconciling on each tick or Trigger. Never exits on an error.
|
||
func (r *Reconciler) Run(ctx context.Context) {
|
||
ticker := time.NewTicker(r.interval)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-r.trigger:
|
||
case <-ticker.C:
|
||
}
|
||
r.reconcileOnce(ctx)
|
||
}
|
||
}
|
||
|
||
// reconcileOnce reads the whole fleet's DR-heal state and acts on the stuck, debounce-confirmed,
|
||
// enabled+provisioned hosts. Errors are logged and retried next tick.
|
||
func (r *Reconciler) reconcileOnce(ctx context.Context) {
|
||
rows, err := r.store.PBSDRHealStates()
|
||
if err != nil {
|
||
r.logger.Printf("[ERROR] pbsdrheal: read heal states: %v (retry next tick)", err)
|
||
return
|
||
}
|
||
seen := make(map[string]bool, len(rows))
|
||
for _, row := range rows {
|
||
if r.onlyHost != "" && row.HostID != r.onlyHost {
|
||
continue // supervised rollout scope: only this host is in the work set
|
||
}
|
||
seen[row.HostID] = true
|
||
// Work set: descriptor enabled AND provisioned (namespace set). A DR-OFF / disabled / bare-
|
||
// enabled-but-never-provisioned host is out of scope (Scenario F) — forget any debounce.
|
||
if !row.DescriptorEnabled || !row.DescriptorProvisioned {
|
||
delete(r.deb, row.HostID)
|
||
continue
|
||
}
|
||
// R-39 consumed_at HONESTY (Scenario F). Deliberately a SURFACE, not a heal: the remediation
|
||
// for a genuinely stuck box is the auth_failed / waiting_secret / consumed_failed machinery
|
||
// above, driven by what the BOX reports. This check catches the disagreement itself — the hub
|
||
// staged a credential the box never took, while the box claims to be applied — which is a
|
||
// state no single tier can detect alone. Auto-re-issuing on it would mint a second secret on
|
||
// top of an unconsumed one: exactly the mint/consume race R-39(a) already recorded.
|
||
r.checkUnconsumed(row)
|
||
|
||
switch row.ReportedState {
|
||
case stateWaitingSecret:
|
||
if r.confirm(row) {
|
||
r.healWaitingSecret(ctx, row)
|
||
}
|
||
case stateConsumedFailed:
|
||
if r.confirm(row) {
|
||
r.healConsumedFailed(ctx, row)
|
||
}
|
||
case stateAuthFailed:
|
||
if r.confirm(row) {
|
||
r.healAuthFailed(ctx, row)
|
||
}
|
||
default:
|
||
// applied | adopted | disabled | verify_failed | "" (no report) | anything else → no-op.
|
||
delete(r.deb, row.HostID)
|
||
}
|
||
}
|
||
// Drop debounce state for hosts that vanished from the fleet.
|
||
for h := range r.deb {
|
||
if !seen[h] {
|
||
delete(r.deb, h)
|
||
}
|
||
}
|
||
}
|
||
|
||
// checkUnconsumed surfaces the applied-but-never-consumed disagreement (Scenario F). One event per
|
||
// distinct report, so a sustained state does not spam the audit log every tick.
|
||
func (r *Reconciler) checkUnconsumed(row store.PBSDRHealRow) {
|
||
if row.SecretUnconsumedFor <= unconsumedGrace {
|
||
return // no staged secret, already consumed, or still inside the normal pickup window
|
||
}
|
||
// Only meaningful when the box claims to be converged. A box that honestly reports
|
||
// waiting_secret / consumed_failed / auth_failed is already being healed above and its unconsumed
|
||
// secret is the SYMPTOM being fixed, not a contradiction.
|
||
if row.ReportedState != "applied" && row.ReportedState != "adopted" {
|
||
return
|
||
}
|
||
if st, ok := r.unconsumedSeen[row.HostID]; ok && st == row.ReportID {
|
||
return // already surfaced for this exact report
|
||
}
|
||
if r.unconsumedSeen == nil {
|
||
r.unconsumedSeen = map[string]int64{}
|
||
}
|
||
r.unconsumedSeen[row.HostID] = row.ReportID
|
||
r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports pbs_dr=%s while a staged one-time secret has been UNCONSUMED for %s — the box is not using the credential the hub issued (R-39 fingerprint)",
|
||
row.HostID, row.CustomerID, row.ReportedState, row.SecretUnconsumedFor.Round(time.Minute))
|
||
r.event(row.CustomerID, eventUnconsumed, "warning",
|
||
"PBS-DR honesty check: the box reports the DR tier as applied, but a one-time credential issued by the hub has never been consumed. The tier may be authenticating with a superseded credential.")
|
||
}
|
||
|
||
// confirm advances the per-host debounce and reports whether the stuck state has held across
|
||
// >= debounceReports DISTINCT reports. A re-observed same report (same reportID) never advances the
|
||
// streak — the debounce counts fresh evidence, not reconciler ticks.
|
||
func (r *Reconciler) confirm(row store.PBSDRHealRow) bool {
|
||
st := r.deb[row.HostID]
|
||
if row.ReportID != st.reportID {
|
||
if row.ReportedState == st.state {
|
||
st.streak++
|
||
} else {
|
||
st.streak = 1
|
||
}
|
||
st.state = row.ReportedState
|
||
st.reportID = row.ReportID
|
||
r.deb[row.HostID] = st
|
||
}
|
||
return st.streak >= r.debounceReports
|
||
}
|
||
|
||
// healWaitingSecret re-stages the stored secret; if none is stored, escalates to Re-issue.
|
||
func (r *Reconciler) healWaitingSecret(ctx context.Context, row store.PBSDRHealRow) {
|
||
restaged, err := r.act.Restage(row.HostID)
|
||
if err != nil {
|
||
r.logger.Printf("[ERROR] pbsdrheal: re-stage %s: %v (retry next tick)", row.HostID, err)
|
||
return
|
||
}
|
||
if restaged {
|
||
r.logger.Printf("[INFO] pbsdrheal: re-staged the stored one-time secret for host %s (customer %s) stuck in waiting_secret — the agent re-consumes on its next tick (no ep0 token minted, no generation bump)", row.HostID, row.CustomerID)
|
||
r.event(row.CustomerID, eventRestaged, "info",
|
||
"PBS-DR self-heal: re-staged the stored one-time credential for a box stuck awaiting a secret (re-install/rollback recovery). No endpoint token was minted.")
|
||
r.resetAfterHeal(row)
|
||
return
|
||
}
|
||
// No stored secret to re-stage → escalate to a fresh mint.
|
||
r.logger.Printf("[INFO] pbsdrheal: host %s (customer %s) is waiting_secret with NO stored secret — escalating to Re-issue", row.HostID, row.CustomerID)
|
||
if r.reissue(ctx, row) {
|
||
r.event(row.CustomerID, eventReissued, "warning",
|
||
"PBS-DR self-heal: re-issued endpoint credentials for a box awaiting a secret that the hub no longer had stored.")
|
||
r.resetAfterHeal(row)
|
||
}
|
||
}
|
||
|
||
// healConsumedFailed escalates a burned-secret box to a fresh mint — a re-stage of the SAME secret
|
||
// would only re-feed the credential the agent already burned into a failed apply (Scenario E).
|
||
func (r *Reconciler) healConsumedFailed(ctx context.Context, row store.PBSDRHealRow) {
|
||
r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports consumed_failed (burned secret) — escalating to Re-issue", row.HostID, row.CustomerID)
|
||
if r.reissue(ctx, row) {
|
||
r.event(row.CustomerID, eventConsumedFailed, "warning",
|
||
"PBS-DR self-heal: a box reported consumed_failed (it burned a one-time credential into a failed apply); re-issued fresh endpoint credentials so the agent can converge.")
|
||
r.resetAfterHeal(row)
|
||
}
|
||
}
|
||
|
||
// healAuthFailed escalates a box whose credential PBS rejects (401) to a fresh mint. This is the leg
|
||
// that closes the R-39 loop end to end: the agent now PROVES the credential is dead instead of
|
||
// silently skipping, the hub re-keys, the fresh mint advances the secret generation, the descriptor
|
||
// hash moves, and the agent finally re-consumes (Scenario A). Before this, an applied-and-401 tier
|
||
// stayed green forever and would have surfaced first at a real restore.
|
||
//
|
||
// A re-stage is deliberately NOT attempted: the stored secret IS the one PBS just rejected, so
|
||
// re-arming it would burn a tick and change nothing. Damping is the SHARED confirm() — a 401 flap
|
||
// must not turn into a secret-minting chain.
|
||
func (r *Reconciler) healAuthFailed(ctx context.Context, row store.PBSDRHealRow) {
|
||
r.logger.Printf("[WARN] pbsdrheal: host %s (customer %s) reports auth_failed (PBS rejects its credential) — escalating to Re-issue", row.HostID, row.CustomerID)
|
||
if r.reissue(ctx, row) {
|
||
r.event(row.CustomerID, eventAuthFailed, "warning",
|
||
"PBS-DR self-heal: a box reported auth_failed (the DR endpoint rejected its stored credential); re-issued fresh endpoint credentials so the agent can re-consume and converge.")
|
||
r.resetAfterHeal(row)
|
||
}
|
||
}
|
||
|
||
// reissue runs the escalation; returns true on success (the caller then records the audit event).
|
||
func (r *Reconciler) reissue(ctx context.Context, row store.PBSDRHealRow) bool {
|
||
if err := r.act.Reissue(ctx, row.CustomerID); err != nil {
|
||
r.logger.Printf("[ERROR] pbsdrheal: re-issue for customer %s (host %s): %v (retry next tick)", row.CustomerID, row.HostID, err)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// resetAfterHeal clears the streak (keeping the report id) so the same stuck report does not re-heal
|
||
// on the next tick — a FRESH report must re-confirm the host is still stuck before acting again.
|
||
func (r *Reconciler) resetAfterHeal(row store.PBSDRHealRow) {
|
||
r.deb[row.HostID] = debounceState{reportID: row.ReportID, state: row.ReportedState, streak: 0}
|
||
}
|
||
|
||
// event records an audit row; a failure to write it must never break the heal loop.
|
||
func (r *Reconciler) event(customerID, eventType, severity, message string) {
|
||
if _, err := r.store.SaveEvent(customerID, eventType, severity, message, "", "hub"); err != nil {
|
||
r.logger.Printf("[WARN] pbsdrheal: audit event %s for %s not stored: %v", eventType, customerID, err)
|
||
}
|
||
}
|