Files
felhom.eu/hub/internal/monitor/offsite_delivery.go
T
admin 91cabdde1b
gates / gates (push) Successful in 7s
hub v0.93.0: the retention keeps the key it was built to keep (R-198) + three honesty fixes (R-197, R-192, R-196)
R-198 — host_escrow_superseded shipped with `blob` (the K-escrow / PBS datastore key) and
identity_blob was added to host_escrow LATER, never here. The offsite restic REPOSITORY
password lives in identity_blob. So demoteCurrentEscrowTx -- whose own comment calls it "THE
ONE escrow row-copy routine" -- retained the whole-guest key and silently dropped the off-site
data key, which is the secret the retention was built to preserve. And because the copy happens
as the new blob overwrites the old, the destroying act was the ESCROW CEREMONY: the exact thing
a rebuilt box tells its customer to run, on a card promising in Hungarian that the old backups
stay recoverable. Both demo boxes crossed that line on 2026-08-04.

  - identity_blob added to the table (CREATE + additive ALTER) and carried in the shared copy
    routine, so BOTH callers are fixed at once: re-escrow and host-delete demotion.
  - ListSupersededEscrow reads it back; store.HostEscrow gains IdentityBlob.
  - CountCurrentEscrowWithIdentity is the census of who the fix protects.
  - Nothing is backfillable: pre-v0.93.0 retained rows have no blob and their sources are gone.
  - Tests assert the CONSEQUENCE (a retained row can still yield a repo password), which is why
    the pre-existing retention test stayed green for two months asserting the mechanism.

R-197 — SaveHostEscrow returns the hash it replaced; the escrow PUT raises
offsite_repo_key_changed (warning, operator-only, edge-triggered) when both hashes are known and
differ. No hash value travels. Severity chosen for the world v0.93.0 creates: with the identity
blob retained, a changed key is "this history now depends on an older recovery code", not a loss.

R-192 (half) — the stuck alert now reports the two shapes it actually covers, burned and
regressed, each stating its own measurement; the regressed text withdraws the Re-issue
recommendation. Every self-heal refusal leaves a notification_log row with its reason. The
guard's logic is unchanged; its 500-oldest-reports scoping stays OPEN and the window is named in
the alert text so the limitation travels with the number. offsite_delivery_stuck and
offsite_credential_restaged are added to operatorOnlyEvents -- neither was registered and neither
has a customerMessages entry, which is not a block.

R-196 — five comments (not the three the spec expected) claimed ReissueCredentials rotates the
restic repo password. It resets the PROVIDER password and cannot touch the repo password, which
is generated on the box. All five corrected; the staleness mark documented as precautionary. The
BEHAVIOUR stays open.

Not in this release: R-199, R-200, R-201 remain open -- the chain that hands the key back is
still unassembled. Part 5 hit its gate; the orphan card is untouched (R-202).
2026-08-04 12:56:58 +02:00

277 lines
15 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
}
emitted := c.maybeEmitStuck(cfg.CustomerID, status, age)
c.maybeHeal(cfg.CustomerID, status, emitted)
}
}
// 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"
)
func shapeOf(status offsite.DeliveryStatus) deliveryShape {
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) 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)
var msg string
switch shape {
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) {
if c.reissuer == nil {
return // no provisioner configured: the heal does not exist on this hub, so there is nothing to explain
}
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")
}
}