Files
felhom.eu/hub/internal/monitor/offsite_delivery.go
T
admin f62a115891 R-204 item 4 (hub half): the hub answers a rebuilt box's request (hub v0.96.0)
New internal/offsiteheal, the sibling of pbsdrheal: it acts ONLY on the state the
box declares, sustained across two distinct reports, re-staging the stored
credential before ever minting a new one. A healthy box is a pure no-op; it never
blind-timer-reissues and never re-runs a provisioning step.

RESTAGE IS POSSIBLE because the stored value survives a consume — established from
the schema and ConsumeOneTimeSecret (which stamps consumed_at and nothing else),
not inherited from the PBS analogy, and pinned by a test that asserts the SAME
value comes back.

reportHasOffsite is TIGHTENED to require enabled:true. Its comment asserted that
presence == applied-on-the-box, and the declaration deliberately breaks that
premise; left alone it would have read a request for help as proof the tier was
applied. Provably a no-op for every report shape that existed before, because an
attached object has always carried enabled:true.

R-192's guard half is CLOSED BY REPLACEMENT: the delivery checker's counting
inference read the OLDEST 500 reports after a consume — all predating a rebuild,
which is why demo-hp sat stranded for 108 reports under a confident regressed-shape
verdict. A declaration outranks both inferred shapes, and the checker stands down
with a record so the two mechanisms cannot double-issue.

No escrow ceremony is ever run or requested: credential automatic, key
customer-present.
2026-08-05 10:48:18 +02:00

316 lines
18 KiB
Go

package monitor
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// R-70 + R-71(c): the offsite delivery-state checker. Reads the shared detector
// (offsite.DeliveryStateFor) for every offsite-enabled customer and drives two consumers:
//
// - the LOUD EVENT: `offsite_delivery_stuck` (warning → operator email per existing dispatcher
// rules) when the burned-credential shape persists past stuckAfter;
// - the SELF-HEAL (R-71c): invoke the EXISTING Re-issue path — never a second delivery
// mechanism — when the shape is unambiguous, then surface `offsite_credential_restaged`
// (warning) so the operator ALWAYS knows it fired.
//
// Cooldowns are durable: both events rate-limit off store.LastEventAt (the events table), so a hub
// restart cannot flood or silently re-heal. A repeating pattern surfaces as repeating events on a
// 24 h cadence, never as a silent retry loop.
//
// THE R-39(a) GUARD (mandatory, enforced HERE because the store deliberately clobbers):
// SaveOneTimeSecret is last-write-wins by design — Re-issue depends on supersede. Restaging on top
// of an UNCONSUMED secret would clobber a password a box may be about to consume (the operator may
// have clicked Re-issue between this checker's derive and its act). So the heal re-reads the
// secret row IMMEDIATELY before acting and refuses unless it is still a CONSUMED row.
const (
eventDeliveryStuck = "offsite_delivery_stuck" // hub-internal (not in allowedEventTypes, like pbsdr_*)
eventCredentialRestaged = "offsite_credential_restaged" // hub-internal
// stuckAfter: consumed_awaiting_apply is normal for seconds (F10 repair: consume→applied in
// 4 s). An hour of it means the apply will never come without intervention.
stuckAfter = time.Hour
// stuckCooldown / healCooldown: per-customer, durable via the events table.
stuckCooldown = 24 * time.Hour
healCooldown = 24 * time.Hour
// healMinReports: at least this many consecutive offbox-less reports after consumed_at before
// the self-heal may fire — the box must be alive and reporting, just not applied.
healMinReports = 4
)
// OffsiteReissuer is the narrow reissue surface the self-heal needs — satisfied by
// *web.Server.ReissueOffsiteForCustomer (the pbsdrheal.Reissuer precedent; avoids an import
// cycle and guarantees the heal IS the designed Re-issue path, not a sibling mechanism).
type OffsiteReissuer interface {
ReissueOffsiteForCustomer(ctx context.Context, customerID string) error
}
// OffsiteDeliveryChecker runs on the shared monitor ticker.
type OffsiteDeliveryChecker struct {
store *store.Store
reissuer OffsiteReissuer // nil → self-heal disabled (no provisioner configured); detector+event still run
onEvent EventNotifyFunc
logger *log.Logger
now func() time.Time
}
// NewOffsiteDeliveryChecker constructs the checker. reissuer may be nil (heal disabled).
func NewOffsiteDeliveryChecker(s *store.Store, reissuer OffsiteReissuer, onEvent EventNotifyFunc, logger *log.Logger) *OffsiteDeliveryChecker {
return &OffsiteDeliveryChecker{store: s, reissuer: reissuer, onEvent: onEvent, logger: logger, now: time.Now}
}
// Check derives the delivery state for every offsite-enabled active customer and applies the
// event + self-heal rules. Never returns an error — a checker failure must not take the ticker
// down (log-and-continue, like every sibling checker).
func (c *OffsiteDeliveryChecker) Check() {
configs, err := c.store.ListCustomerConfigs()
if err != nil {
c.logger.Printf("[WARN] offsite-delivery: list configs: %v", err)
return
}
for _, cfg := range configs {
if cfg.Status != "active" {
continue // blocked/inactive customers are not delivery-monitored (and never healed)
}
d, err := offsite.ReadDescriptor(cfg.ConfigJSON)
if err != nil || d == nil || !d.Enabled {
continue // unparseable config never drives a heal; the config UI owns that failure
}
status, err := offsite.DeliveryStateFor(c.store, cfg.CustomerID)
if err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: derive: %v", cfg.CustomerID, err)
continue
}
if status.State != offsite.DeliveryConsumedAwaitingApply {
continue // applied / staged / no_secret: card-rendered states, no event or heal (yet)
}
age := c.now().Sub(status.Since)
if age < stuckAfter {
continue // normal convergence window
}
// R-204 item 4 / R-192's guard half: does the BOX declare that it needs a credential? A
// declaration is stronger evidence than anything this checker can infer, and it is owned by
// internal/offsiteheal — see maybeHeal. A read error is treated as "no declaration", which is
// the conservative direction: this checker keeps its pre-v0.96.0 behaviour rather than
// silently standing down.
declared := false
if _, _, state, derr := c.store.LatestReportOffsiteDeclaration(cfg.CustomerID); derr != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: read declaration: %v (treating as undeclared)", cfg.CustomerID, derr)
} else {
declared = state == declaredNeedsCredential
}
emitted := c.maybeEmitStuck(cfg.CustomerID, status, age, declared)
c.maybeHeal(cfg.CustomerID, status, emitted, declared)
}
}
// deliveryShape names the two situations the ONE stuck state actually covers. They need different
// text and different advice, and conflating them is R-192's defect (a).
type deliveryShape string
const (
// shapeBurned — NO report since the consume carried an offbox target: the apply never persisted.
// Re-issue is the indicated action.
shapeBurned deliveryShape = "burned"
// shapeRegressed — reports since the consume DID carry an offbox target and the latest does not:
// the credential worked and the target was later lost (a guest rebuild does exactly this, R-193).
// Re-issue is NOT indicated; it treats a symptom whose cause is elsewhere.
shapeRegressed deliveryShape = "regressed"
// shapeDeclared — the BOX itself says it needs a credential (controller >= v0.199.0). This
// OUTRANKS both shapes above and is R-192's guard half closed by REPLACEMENT rather than repair:
// the counting guard inferred the situation from how many of the OLDEST 500 reports after the
// consume carried an offbox target — all of which predate a rebuild, which is why demo-hp sat
// stranded for 108 reports while the checker confidently reported the regressed shape. A
// declaration needs no window, no count and no inference. The remediation belongs to
// internal/offsiteheal; this checker stands down and says so.
shapeDeclared deliveryShape = "declared"
)
// declaredNeedsCredential mirrors backup.OffsiteStateNeedsCredential / offsiteheal.StateNeedsCredential.
// Duplicated as a literal rather than imported to avoid a monitor→offsiteheal dependency; the three
// are pinned together by TestDeclaredStateStringMatchesTheReconciler.
const declaredNeedsCredential = "needs_credential"
func shapeOf(status offsite.DeliveryStatus, declared bool) deliveryShape {
if declared {
return shapeDeclared
}
if status.OffsiteReportsSinceConsume == 0 {
return shapeBurned
}
return shapeRegressed
}
// maybeEmitStuck emits offsite_delivery_stuck (warning) once per stuckCooldown per customer. Returns
// whether it emitted, so the heal's refusal record rides the same cadence rather than inventing one.
//
// R-192 defect (a), fixed here: the message used to interpolate ReportsSinceConsume (the TOTAL) into
// a hardcoded phrase "report(s) since carry no offbox target", and never consulted
// OffsiteReportsSinceConsume — the field that says the opposite. On demo-hp it stated, daily, that
// 500 reports carried no offbox target when all 500 of them did, and prescribed Re-issue for a
// failure mode that had not occurred. The message now STATES WHAT WAS MEASURED and lets the operator
// read it; the recommendation follows the shape rather than being hardcoded.
//
// THE WINDOW IS NAMED ON PURPOSE. CountReportsOffsiteSince reads `ORDER BY id LIMIT 500` — the OLDEST
// 500 reports after the consume, not the newest — so on a long-lived customer these counts describe
// the beginning of the window and not the present. That is a real scoping defect (R-192's other half)
// and it stays OPEN because its correct shape depends on the recovery chain that is not yet
// assembled (R-199/R-200/R-201). Naming the window in the text is how it stays visible instead of
// being laundered into a confident sentence — an instrument that can silently mis-scope its results
// must say so where it reports them.
func (c *OffsiteDeliveryChecker) maybeEmitStuck(customerID string, status offsite.DeliveryStatus, age time.Duration, declared bool) bool {
last, err := c.store.LastEventAt(customerID, eventDeliveryStuck)
if err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: cooldown read: %v", customerID, err)
return false
}
if !last.IsZero() && c.now().Sub(last) < stuckCooldown {
return false
}
shape := shapeOf(status, declared)
var msg string
switch shape {
case shapeDeclared:
msg = fmt.Sprintf("Offsite delivery stuck (DECLARED by the box): the one-time password was consumed %s ago and the box now reports `offsite.state=%s` — it has been rebuilt, holds no repository password, and the hub is holding a sealed recovery package for it. No inference was needed: the box said so. The offsite self-heal reconciler owns this remediation (it re-arms the stored credential before minting a new one); no operator action is indicated unless this repeats.",
age.Round(time.Minute), declaredNeedsCredential)
case shapeBurned:
msg = fmt.Sprintf("Offsite delivery stuck (BURNED-credential shape): the one-time password was consumed %s ago; of the first %d report(s) after that consume, NONE carried an offbox target, and the latest report carries none either. The credential never reached a persisted apply. Re-issue delivers a fresh one.",
age.Round(time.Minute), status.ReportsSinceConsume)
default:
msg = fmt.Sprintf("Offsite delivery stuck (REGRESSED-apply shape): the one-time password was consumed %s ago; of the first %d report(s) after that consume, %d DID carry an offbox target — and the latest report carries none. The credential was applied and worked; the target was lost afterwards. Re-issue is NOT the indicated action: find what removed the offbox target (a guest rebuild does, R-193). Automatic restage is deliberately withheld for this shape. NOTE: the counts cover at most the first 500 reports after the consume, so on a long-lived box they describe the start of the window, not now (R-192, open).",
age.Round(time.Minute), status.ReportsSinceConsume, status.OffsiteReportsSinceConsume)
}
details, _ := json.Marshal(map[string]any{
"state": string(status.State),
"shape": string(shape),
"consumed_at": status.Since.UTC().Format(time.RFC3339),
"reports_since_consume": status.ReportsSinceConsume,
"offsite_reports_since_consume": status.OffsiteReportsSinceConsume,
"count_window": "oldest 500 reports after consumed_at (R-192, open)",
})
c.emit(customerID, eventDeliveryStuck, "warning", msg, string(details))
return true
}
// maybeHeal fires the R-71c self-heal when the burned-credential shape is unambiguous:
// consumed ≥ stuckAfter ago, ≥ healMinReports consecutive reports since with ZERO offbox evidence,
// one heal per healCooldown — and the R-39(a) guard holds at act time.
//
// R-192 defect (b), fixed here: every refusal above the "not configured" line now leaves a RECORD.
// The regressed-shape branch used to be a bare `return`, so the operator received a daily e-mail with
// the wrong story, no heal, and nothing anywhere saying why the heal declined — "we chose not to act"
// and "the heal never ran" looked identical. `offsite_credential_restaged` has never fired for any
// customer, and until now that fact was indistinguishable from the checker being dead.
//
// The record is a notification_log row (the dispatcher's suppressed-operator-e-mail precedent, R-182:
// a decision not to act is written down on the channel it would have used). It rides `recordRefusal`
// — true only when the stuck event was emitted this pass — so it appears once per stuckCooldown
// beside the e-mail it explains, rather than once per monitor tick. The GUARD ITSELF IS UNCHANGED:
// the set of situations in which the heal fires is byte-for-byte what it was; only the silence is
// gone. The two conditions are split into separate branches solely so each refusal can name its own
// reason.
func (c *OffsiteDeliveryChecker) maybeHeal(customerID string, status offsite.DeliveryStatus, recordRefusal bool, declared bool) {
if c.reissuer == nil {
return // no provisioner configured: the heal does not exist on this hub, so there is nothing to explain
}
// R-204 item 4: a DECLARING box belongs to internal/offsiteheal, which re-arms the stored
// credential before minting a new one. Two mechanisms healing the same customer would double-issue
// — and this one can only mint, so it would also skip the cheap path. Stand down, with a record:
// "we chose not to act" and "the heal never ran" must not look identical (R-192 defect (b)).
if declared {
c.recordHealRefusal(customerID, recordRefusal,
"the box DECLARES offsite.state=needs_credential; internal/offsiteheal owns this remediation (it re-stages the stored credential before minting). A second mechanism minting here would double-issue.")
return
}
if status.OffsiteReportsSinceConsume != 0 {
c.recordHealRefusal(customerID, recordRefusal, fmt.Sprintf(
"regressed-apply shape: %d of the first %d report(s) after the consume DID carry an offbox target, so a burned credential is ruled out — a restage would treat a symptom whose cause is elsewhere. Operator's call (R-193).",
status.OffsiteReportsSinceConsume, status.ReportsSinceConsume))
return
}
if status.ReportsSinceConsume < healMinReports {
c.recordHealRefusal(customerID, recordRefusal, fmt.Sprintf(
"only %d report(s) since the consume (need %d): the box has not reported enough for the burned shape to be unambiguous.",
status.ReportsSinceConsume, healMinReports))
return
}
last, err := c.store.LastEventAt(customerID, eventCredentialRestaged)
if err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: heal rate-limit read: %v", customerID, err)
return
}
if !last.IsZero() && c.now().Sub(last) < healCooldown {
return // one restage per customer per 24 h — repeats surface as repeated events only
}
// THE R-39(a) GUARD — re-read the secret row immediately before acting. The derive above is a
// snapshot; an operator Re-issue may have staged a FRESH UNCONSUMED secret since (TOCTOU).
// SaveOneTimeSecret clobbers by design, so acting now would burn that fresh password.
info, err := c.store.GetOneTimeSecretInfo(customerID)
if err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: guard read: %v", customerID, err)
return
}
if info == nil || info.ConsumedAt.IsZero() {
c.recordHealRefusal(customerID, recordRefusal, fmt.Sprintf(
"R-39(a) guard: the secret row is now %s — restaging over an unconsumed secret would clobber a password the box may be about to consume.", secretShape(info)))
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := c.reissuer.ReissueOffsiteForCustomer(ctx, customerID); err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: self-heal reissue failed: %v", customerID, err)
return
}
c.logger.Printf("[INFO] offsite-delivery: %s: self-heal restage fired (consumed_at %s, %d offbox-less reports since)",
customerID, status.Since.UTC().Format(time.RFC3339), status.ReportsSinceConsume)
details, _ := json.Marshal(map[string]any{
"burned_consumed_at": status.Since.UTC().Format(time.RFC3339),
"reports_since_consume": status.ReportsSinceConsume,
})
c.emit(customerID, eventCredentialRestaged, "warning",
"Offsite credential re-staged automatically: the previous one-time password was consumed but never applied (burned mid-delivery). The box picks the fresh password up on its next config refresh.",
string(details))
}
// recordHealRefusal makes a decision NOT to self-heal visible. Always logs; additionally writes a
// notification_log row on the "operator" channel with status "refused" when `record` is set (the
// stuck event was emitted this pass), so the refusal sits next to the e-mail that prompted the
// question. A LogNotification failure is logged, never swallowed, and never blocks the refusal — the
// refusal is the primary effect.
func (c *OffsiteDeliveryChecker) recordHealRefusal(customerID string, record bool, reason string) {
c.logger.Printf("[INFO] offsite-delivery: %s: self-heal REFUSED — %s", customerID, reason)
if !record {
return
}
if err := c.store.LogNotification(customerID, eventCredentialRestaged, "warning",
"Automatic offsite credential restage was NOT performed.", "refused", reason, "operator"); err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: could not record the heal refusal: %v", customerID, err)
}
}
func secretShape(info *store.OneTimeSecretInfo) string {
if info == nil {
return "absent"
}
return "unconsumed (staged " + info.CreatedAt.UTC().Format(time.RFC3339) + ")"
}
// emit saves the event (audit trail first) and then notifies — the OffsiteChecker convention:
// SaveEvent failure logs and SKIPS the notification (an email without its audit row lies).
func (c *OffsiteDeliveryChecker) emit(customerID, eventType, severity, message, details string) {
if _, err := c.store.SaveEvent(customerID, eventType, severity, message, details, "hub"); err != nil {
c.logger.Printf("[WARN] offsite-delivery: %s: save %s: %v", customerID, eventType, err)
return
}
c.logger.Printf("[INFO] offsite-delivery: %s: %s (%s)", customerID, eventType, severity)
if c.onEvent != nil {
c.onEvent(customerID, eventType, severity, message, details, "hub")
}
}