Files
felhom.eu/documentation/audits/SPIKE-pbsdr-selfheal-2026-07-15.md
T
admin e8f8c441fa docs: SPIKE PBS-DR self-heal — root cause = consumed secret, not missing descriptor
Refutes the surviving theory: on the drill host the pbs_dr descriptor is present
and enabled, the WG peer exists, verify passes — the block is a one-time secret
consumed 07-12 that no path re-mints after the agent loses its converged marker
(re-install/rollback; same WG pubkey -> changed==false -> cascade can't re-fire).
Live-proven: staging any consumable secret converges in one tick (existing token,
zero ep0 churn); re-asserting a converged descriptor is a clean idempotent no-op.
Safe re-trigger = re-serve a secret gated on agent waiting_secret/consumed_failed,
never blind-timer Reissue. Design-inputs table handed to the self-heal TASK spec.
Drill left CONVERGED (P-DAY0-DEEP PBS-DR leg now GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEPuEwyyGDJdcsXLFsTWJn
2026-07-15 17:53:42 +02:00

20 KiB
Raw Blame History

SPIKE — PBS-DR day-0 self-heal: root cause + re-trigger validation — 2026-07-15

Class: spike (findings only — no production code, config, or schema edits; the only non-scratch write is this doc + its commit to felhom.eu main). Gates the PBS-DR self-heal TASK spec, which is BLOCKED until these verdicts land. All hub-DB mutations were throwaway probes scoped to the drill host; every one is logged + reverted/self-reverted below (§Mutations). Evidence sink: 180:~/spikes/pbsdr-selfheal-2026-07-15/.

Verdict up front

  1. ROOT CAUSE (SQ-1) — the prompt's surviving theory is REFUTED by live evidence. The descriptor is NOT absent. On the drill host the pbs_dr descriptor is present and enabled in desired_json, the WG peer exists, and the fingerprint verifies over the tunnel. The block is that the one-time PBS secret was consumed on 2026-07-12 and no code path ever re-mints or re-stages it for a host that has since lost its agent-side converged marker (fresh day-0 / re-install / snapshot rollback). The agent sits in pbs_dr.state="waiting_secret" forever, re-applying the descriptor every 60 s tick but unable to consume a burned secret. The reused-peer angle is real but its consequence is not "descriptor never written" — it is "the WG-registration cascade cannot re-fire (same pubkey → changed==false), so nothing re-mints a consumable secret."

  2. THE MISSING PIECE IS A CONSUMABLE SECRET, NOT THE DESCRIPTOR (SQ-2, live-proven). Re-asserting the descriptor alone (generation bump) converges nothing (negative control). Staging any consumable secret converges the box in one tick: re-exposing the existing, still-valid ep0 token secret (consumed_at → NULL, zero ep0 churn) made the agent verify → consume → create the felhom-pbs storage entry → mark converged within ~30 s. The operator "Re-issue PBS credentials" action IS the manual recovery; its DB effect is a superset of that proven re-stage (fresh secret + descriptor bump).

  3. A PERIODIC RE-TRIGGER IS SAFE — IF it re-serves a secret and never blind-timer-reissues (SQ-3). Re-asserting a converged descriptor is a clean idempotent no-op (live-proven: marker untouched, no re-consume, no generation thrash — the descriptorHash marker gates it). Re-running the provision atom does NOT churn ep0 (Provision short-circuits with ErrTokenExists, mints nothing) — but it also does NOT fix a stuck host (it refuses with "use Re-issue"). Reissue rotates the ep0 token every call — safe against orphans (delete+recreate) but a blind-timer Reissue = descriptor-hash thrash + constant re-consume. Safe design: gate on the agent's reported waiting_secret/consumed_failed state, re-stage the stored secret first (no churn, no gen bump), escalate to Reissue only if that fails.

  4. ⚠️ ARCHITECTURE IMPACT. There is no automatic recovery for the single most common production event — a customer box re-installed / restored / rolled back onto its stable host_id. The hub keeps the durable descriptor + a durable consumed secret; the WG hook can't re-fire; applyPBSDR's "already provisioned → no-op" branch means even a config re-save won't re-mint. The PBS-DR tier silently stays unconverged (and therefore escrow + offsite never arm) until an operator notices and clicks Re-issue. This is the P-DAY0-DEEP block, generalized.


0. Baselines (live-verified at session start)

Item Value How verified
felhom.eu main head aff272c4be40da33855c0c45f7e66f407f33c99c (CAMPAIGN-6D P-DAY0 doc) git log -1 local == pulled on 180
Hub live image / version felhom-hub:0.55.0 (hub-6fddb45766-k9f7f, Running 9 h) kubectl get deploy hub -o jsonpath; startup log felhom-hub 0.55.0 starting
Agent live version (drill) felhom-agent 0.88.0 felhom-agent --version on drill VM 192.168.0.152
Controller golden 0.136.0 (not exercised here) baseline per prompt
Drill host (hub host_id) demo-vm-felhom-2f4b00 / customer demo-vm-felhom, gen 3 at start hosts table
Drill guest (Proxmox) VM 300 drill-day0 on demo-pve (192.168.0.152, MAC bc:24:11:85:98:7b) qm status 300; ARP on felhom-pve
Rollback point snapshot post_day0_golden136 (2026-07-15 15:32:02) — "golden 0.136.0 + agent 0.88.0 … escrow pending (no DR chain)" qm listsnapshot 300

Read seam (both directions): a throwaway pure-Go tool hubdbpoke (built on 180 inside the hub module so modernc.org/sqlite resolves; CGO_ENABLED=0), kubectl cp'd into the hub pod, run against /data/hub.dbq = read query, e = exec. Removed from the pod at session end; source never committed. Secret values were never selected (only length(value) + timestamps).


The two theories the reviewer already refuted (restated with file:line, both CONFIRMED here)

  • (a) "the agent lacks self-heal" — FALSE. pbsdr/loop.go:62-77 re-runs Manager.Apply on every 60 s tick and on each desired-state nudge. pbsdr/manager.go:265-272 (verify-pin-before-consume): a probe failure leaves the one-time secret untouched and returns, so a slow/flapping tunnel self-heals as long as a consumable secret is in hand. Live-proven: the drill agent oscillated through verify_failed (tunnel i/o timeout) and recovered without operator help — see §SQ-1 note.

  • (b) "the hub provision atom probes the customer tunnel" — FALSE. web/pbsdr.go:184-198: the atom's preconditions are all hub-DB-local (tenantsync!=nil, GetWGPeerForHost, GetWGEndpoint). It never dials the box. The i/o timeout in the field is therefore the agent's PBS fingerprint probe (dial 10.77.0.1:8007), not a hub failure — confirmed verbatim in the drill agent journal (§SQ-1).


SQ-1 — delivery-path root cause

Question: is the descriptor's only write path the first-WG cascade — and on the drill host, is the descriptor absent (cascade never fired) or present-but-something-else?

Probes (hubdbpoke q against live /data/hub.db, scoped host_id='demo-vm-felhom-2f4b00'):

# Query Verbatim result
a wg_peers WHERE host_id=… 1 row: pubkey zxy+y029eYri+g8HhP4qGwep/Nmv8VR7l3iyf39ovmo=, ip 10.77.0.3, created 2026-07-12 19:50:52, updated 2026-07-15 13:14:43
b desired_json {"pbs_dr":{"enabled":true,"storage_id":"felhom-pbs","pbs_tunnel_ip":"10.77.0.1","datastore":"felhom-offsite","namespace":"demo-vm-felhom","token_id":"felhom@pbs!demo-vm-felhom","fingerprint":"c6:07:…:fd"}}
c host_pbs_secrets (len only) 1 row: val_len=36, created 2026-07-12 19:50:53, consumed_at=2026-07-12 20:05:53
d agent pbsdr status (latest host-report) "pbs_dr":{"state":"waiting_secret","storage_id":"felhom-pbs","namespace":"demo-vm-felhom","message":"verified; no unconsumed token secret staged on the hub"}
e customer_configs.dr_tier 1 (DR tier ON)

Agent journal (drill VM, local CEST = UTC+2), verbatim:

17:07:26 WARN pbsdr: PBS fingerprint verify failed BEFORE consume (nothing consumed; retrying)
         err="pbs: fingerprint probe dial 10.77.0.1:8007: i/o timeout"
15:14:42 INFO pbsdr: bridge enabled (hub-driven; no-op until a pbs_dr descriptor arrives)

At session time the tunnel was up (wg show: handshake 10 s ago; 10.77.0.1:8007 OPEN 3/3). No marker.json and no felhom-pbs entry in /etc/pve/storage.cfg existed → the agent had never gotten past consume on this box's current life.

Forensic tie-off (reused peer): the agent's own WG pubkey on the drill VM is zxy+y029eYri+g8HhP4qGwep/Nmv8VR7l3iyf39ovmo=identical to the stored wg_peers row. The key survives the snapshot/re-install, so on re-registration RegisterWGPeerForHost returns changed==false → the if changed { … wgRegisteredHook(…) } block (api/wg.go:288-305) never fires → PBSDRAutoProvision (web/pbsdr.go:274-300) never runs → no new secret.

Verdict: the descriptor is PRESENT and enabled (delivered correctly at first enrollment on 07-12, and durable in desired_json ever since). The block is that the one-time secret was consumed on 07-12 and is never re-minted/re-staged after the box lost its converged marker (the post_day0_golden136 snapshot is a fresh day-0 with "no DR chain", branched off pre-day0-clean). The agent is permanently waiting_secret. The transient verify_failed/i/o-timeout is a separate, self-healing environmental flap of the nested drill VM's WG tunnel — not the root cause.


SQ-2 — prove the gap directly + validate the re-trigger

The literal SQ-2a premise ("host_pbs_secrets holds an UNCONSUMED secret") is false — it is consumed. Two probes isolate the exact missing piece.

SQ-2a (negative control — descriptor re-assert only). MUT-1: desired_generation 3→4 (re-serve the identical descriptor; secret untouched). Result: the agent kept reporting waiting_secret; the descriptor had already been re-applied on every 60 s tick since rollback with no convergence. A generation bump / descriptor re-delivery changes nothing. → descriptor delivery is not the gap.

SQ-2b (stage a consumable secret — zero ep0 churn). Tunnel confirmed up first. MUT-2: host_pbs_secrets.consumed_at → NULL (re-expose the existing still-valid ep0 token secret; no tenantsync call, no new token). Within ~30 s the drill agent journal showed, verbatim:

17:44:51 INFO pbsdr: one-time token secret consumed (single-use; value withheld from logs) storage_id=felhom-pbs secret_len=36
17:44:53 INFO pbsdr: converged state=applied storage_id=felhom-pbs

marker.json = {"hash":"f1059b54…","state":"applied","applied_at":"2026-07-15T15:44:53Z"}; felhom-pbs now PRESENT in /etc/pve/storage.cfg; hub consumed_at self-re-set to 15:44:51 (single-use tx). The agent self-heals fully the instant a consumable secret exists — the descriptor was never the missing thing.

Re-issue (SQ-2b, the literal operator action): validated from code rather than fired live — the web handler POST /configs/{id}/pbsdr-reissue (server.go:456-463handlePBSDRReissue, web/pbsdr.go:305-355) sits behind the password-gated operator session + CSRF (not drivable headless), and a live run would rotate an ep0 token against the STOP condition. Its DB effect is a superset of the proven re-stage: tenantsync.Reissue (fresh token) → SaveHostPBSSecret (new secret, consumed_at reset) → descriptor rewrite with the new token_id/fingerprint + bump (web/pbsdr.go:335-346). Since a fresh consumable secret is exactly what SQ-2b proved sufficient, Re-issue converges a reused-peer guest. Use it as the escalation when the stored secret is stale.

Verdict: descriptor-only delivery converges NO; staging a consumable secret converges YES (one tick, existing token, no churn); Re-issue converges YES (superset, code-proven). The fix is to re-serve a consumable secret — re-stage the stored one (least-churn) or Re-issue (escalation) — not to re-assert the descriptor.


SQ-3 — is a periodic re-trigger safe?

SQ-3a — re-serve for an ALREADY-CONVERGED host (live-proven). MUT-3: desired_generation 4→5 on the now-converged host; waited ~75 s (≥1 tick). Result: marker.json unchanged (applied_at still 15:44:53Z), no new consumed/converged journal line, hub consumed_at unchanged (15:44:51). The descriptorHash idempotency short-circuit (manager.go:233-238: mk.Hash==h && cf==nil → set status + return) makes re-serving the same descriptor a clean no-op — no re-consume, no re-adopt, no generation thrash on the agent side.

SQ-3b — the one-shot-secret interaction + ep0 churn (code-derived; STOP-condition forbids spraying tokens).

  • store/pbsdr.go:10-17 SaveHostPBSSecret = last-write-wins, resets consumed_at. It is only reached after a successful Provision/Reissue.
  • tenantsync/client.go:93-95,151-155 Provision returns typed ErrTokenExists for an existing token — it mints nothing and never reaches SaveHostPBSSecret. So re-running the provision atom does NOT churn ep0 and does NOT stomp — but (per web/pbsdr.go:206-222) for a provisioned-and-not-acked-deleted host it returns a loud "use Re-issue" error and does not fix the stuck host. Re-running the atom is therefore the wrong re-trigger for this root cause.
  • tenantsync/client.go:97-100 Reissue = delete+recreate the token (one rotation per call; no orphan accumulation). A blind-timer Reissue would rotate the token every tick → the descriptor hash (token_id/fingerprint) changes every tick → the agent re-consumes + re-reconciles every tick = generation + apply thrash. This is the real hazard to avoid.
  • Least-churn primary: re-stage the stored secret (consumed_at → NULL). Zero ep0 interaction, no hash change; proven in SQ-2b. Valid whenever the stored token is still good on ep0 (the common re-install/rollback case — the token was never deleted).

SQ-3c — the consumed-failed dead-end (code-derived from manager.go).

  • Same-hash re-serve stays put: with consumed-failed.json present and cf.Hash==h, the idempotent short-circuit is skipped (manager.go:235), and when ConsumePBSToken returns ErrNoPBSSecret the cf.Hash==h branch (:275-281) re-asserts the LOUD consumed_failed state — never a silent burned-secret retry. Matches the package law comment (:17-19).
  • Re-issue breaks out because it does two things at once: stages a fresh secret and changes the descriptor (new token_id/fingerprint → new hash h'). With h'≠cf.Hash, the dead-end gate no longer matches → the agent re-attempts, consume now succeeds, finishConverged runs and os.Remove(consumedFailedPath()) clears the dead-end (manager.go:348-362).
  • Note (design input): re-staging the stored secret also recovers consumed_failed when the stored token is still valid — because ConsumePBSToken then succeeds (not ErrNoPBSSecret), so the dead-end branch is never entered and the apply proceeds. Only a genuinely stale/rotated ep0 token forces the Reissue escalation.

Verdict: a safe re-trigger = re-assert-secret, gated on the agent's reported stuck state, never blind-timer-reissue. Re-staging the stored secret needs no generation bump (SQ-2b converged on the agent's own ticker); a generation bump must be reserved for an actual descriptor content change (Reissue / storage-id edit) — reuse the existing readPBSDR+compare no-op discipline (web/pbsdr.go:148-164).


SQ-4 — where the re-trigger lives + the condition

Recommended design (spike-proven, one paragraph): add a hub periodic reconciler (mirroring wgsync/reconciler.go) that, for each host with dr_tier=1 and an enabled pbs_dr descriptor, reads the latest host-report pbs_dr.state; when that state is waiting_secret or consumed_failed sustained across ≥1 report cycle (debounce, so a transient verify_failed tunnel flap is not acted on), it re-stages the stored one-time secret (consumed_at → NULL, no generation bump) and records an event. If the host is still stuck after N recon_cycles (stored token stale → consumed_failed persists), it escalates once to Reissue (fresh token + descriptor bump). The capability/host-report path is the only trigger that can observe "DR-ON but agent reports pbsdr inactive" — the WG hook is structurally unable to help (same pubkey → changed==false), and config-save is operator-manual. The one spike-proven reason this is correct: convergence requires only a consumable secret, and staging the existing stored secret is a zero-ep0-churn, no-hash-change, no-gen-bump operation that the agent picks up on its own 60 s tick (SQ-2b) and that no-ops cleanly once converged (SQ-3a) — so the reconciler self-limits to exactly the stuck hosts and stops the moment they converge.

Generation-bump discipline (exact condition): bump only on a real descriptor content change (Reissue's new token_id/fingerprint, or a storage_id edit) — never for a secret re-stage. This is the existing applyPBSDR precedent: readPBSDR + field compare, no-op-if-equal (web/pbsdr.go:148-164); the agent re-fetches on generation change, so a spurious bump every tick is an agent-refetch loop.

Design inputs for the PBS-DR self-heal TASK

Dimension Spike-proven answer
Root cause Durable hub descriptor + durable consumed one-time secret + stable host_id, after the agent loses its converged marker (re-install / rollback / disk loss). No path re-mints/re-stages the secret; WG cascade can't re-fire (changed==false); applyPBSDR "already provisioned" no-ops. Agent stuck in waiting_secret.
Safe re-trigger shape Re-serve a consumable secret, gated on the agent's reported waiting_secret/consumed_failed (debounced ≥1 cycle). Primary: re-stage stored secret (consumed_at→NULL, no ep0 call, no gen bump). Escalation (stored token stale): Reissue once. Never re-run the provision atom (refuses, doesn't fix) and never blind-timer Reissue (hash/gen thrash).
Trigger location Hub periodic reconciler reading host-report pbs_dr.state (like wgsync/reconciler.go). Not the WG hook (structurally can't re-fire); not config-save (manual).
Gen-bump condition Only on a real descriptor content change (Reissue/storage-id edit) via readPBSDR+compare no-op discipline (web/pbsdr.go:148-164). A secret re-stage bumps nothing.
Orphan-token cleanup None needed for the primary path (re-stage = no ep0 interaction) or repeat Provision (ErrTokenExists, mints nothing). Reissue is delete+recreate (no accumulation). The only orphan risk is a blind-timer Reissue design — explicitly excluded above.

Mutations made + reverts (all scoped host_id='demo-vm-felhom-2f4b00'; SELECT-verified 1 row before each)

ID Mutation Revert
MUT-1 UPDATE hosts SET desired_generation+1 (3→4) — SQ-2a none needed (monotonic counter; agent compares, absolute value irrelevant)
MUT-2 UPDATE host_pbs_secrets SET consumed_at=NULL WHERE … AND consumed_at IS NOT NULL — SQ-2b re-stage of the existing token secret (no ep0 churn) self-reverted: agent re-consumed at 15:44:51Z, consumed_at re-set by ConsumeHostPBSSecret
MUT-3 UPDATE hosts SET desired_generation+1 (4→5) — SQ-3a none needed (monotonic)

Net DB delta vs spike start: host_pbs_secrets.consumed_at 2026-07-12T20:05:53Z2026-07-15T15:44:51Z (secret re-consumed by real convergence); hosts.desired_generation 3 → 5. No orphaned/unconsumed secrets, no ep0 token churn (existing token reused throughout). No non-drill host touched (every statement WHERE host_id='demo-vm-felhom-2f4b00'; the demo host demo-felhom-01 was never in a WHERE clause). Throwaway hubdbpoke binary removed from the hub pod.

Guest end-state

LEFT CONVERGED (not rolled back). PBS-DR on the drill guest is now genuinely state=applied (felhom-pbs storage entry created, marker f1059b54…) — a real side-benefit: the P-DAY0-DEEP PBS-DR leg is now GREEN on the drill (escrow ceremony + offsite arming remain separate follow-ups). Rollback to post_day0_golden136 is available if a pristine "no DR chain" fixture is wanted again.

STOP conditions honored

Every WHERE clause proven to hit only the drill host (SELECT-before-UPDATE, count(*)=1). No code fix attempted (that is the spec). No ep0 token churn — the primary proof deliberately reused the existing token; Reissue was code-validated, not fired. No non-drill/production host touched.