hub v0.44.0: PBS DR tier SLICE 1 — felhom-tenantsync surface (script+client) + hub provisioning flow (consume-once host secret, pbs_dr desired-state descriptor, fail-closed + idempotent, re-issue)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-10 20:49:48 +02:00
parent 00afadc1fe
commit ce6a56691e
19 changed files with 1743 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
package store
// PBS DR tier (SLICE 1): the HOST-scoped one-time PBS token secret — the host/agent twin of the
// customer-scoped one_time_secrets pair (SaveOneTimeSecret/ConsumeOneTimeSecret). The hub stores
// the tenantsync-returned token secret here; the agent consumes it EXACTLY ONCE with its per-host
// key. Transient custody: never logged, never in desired-state or any served config.
// SaveHostPBSSecret stores (last-write-wins) the one-time PBS token secret for a host, resetting
// the consumed flag (a re-issue supersedes any prior unconsumed value). Never logged.
func (s *Store) SaveHostPBSSecret(hostID, value string) error {
_, err := s.db.Exec(`
INSERT INTO host_pbs_secrets (host_id, value, created_at, consumed_at)
VALUES (?, ?, datetime('now'), NULL)
ON CONFLICT(host_id) DO UPDATE SET value = excluded.value, created_at = datetime('now'), consumed_at = NULL`,
hostID, value)
return err
}
// ConsumeHostPBSSecret returns the host's one-time PBS token secret and marks it consumed in the
// SAME transaction (single use). A second call — or a call when none is stored — returns
// ("", sql.ErrNoRows). The value is never logged.
func (s *Store) ConsumeHostPBSSecret(hostID string) (string, error) {
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var value string
err = tx.QueryRow(`SELECT value FROM host_pbs_secrets WHERE host_id = ? AND consumed_at IS NULL`, hostID).Scan(&value)
if err != nil {
return "", err // sql.ErrNoRows when absent OR already consumed
}
if _, err := tx.Exec(`UPDATE host_pbs_secrets SET consumed_at = datetime('now') WHERE host_id = ?`, hostID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return value, nil
}
+43
View File
@@ -0,0 +1,43 @@
package store
import (
"database/sql"
"testing"
)
// The host-scoped consume-once contract (PBS DR SLICE 1): exactly one read per stored value,
// a re-save resets the consumed flag (re-issue supersedes), absence is sql.ErrNoRows.
func TestHostPBSSecret_ConsumeOnce(t *testing.T) {
s := newTestStore(t)
if _, err := s.ConsumeHostPBSSecret("h1"); err != sql.ErrNoRows {
t.Fatalf("consume with nothing stored = %v, want sql.ErrNoRows", err)
}
if err := s.SaveHostPBSSecret("h1", "secret-1"); err != nil {
t.Fatalf("save: %v", err)
}
got, err := s.ConsumeHostPBSSecret("h1")
if err != nil || got != "secret-1" {
t.Fatalf("first consume = (%q, %v), want (secret-1, nil)", got, err)
}
// Single use: the second consume MUST be ErrNoRows (red-proof: dropping the consumed_at
// UPDATE in ConsumeHostPBSSecret makes this assert fail with the value returned again).
if got, err := s.ConsumeHostPBSSecret("h1"); err != sql.ErrNoRows {
t.Fatalf("second consume = (%q, %v), want sql.ErrNoRows — consume-once broken", got, err)
}
// Re-issue path: a fresh save resets consumption and serves the NEW value once.
if err := s.SaveHostPBSSecret("h1", "secret-2"); err != nil {
t.Fatalf("re-save: %v", err)
}
got, err = s.ConsumeHostPBSSecret("h1")
if err != nil || got != "secret-2" {
t.Fatalf("post-reissue consume = (%q, %v), want (secret-2, nil)", got, err)
}
// Host isolation: h2 never sees h1's rows.
if _, err := s.ConsumeHostPBSSecret("h2"); err != sql.ErrNoRows {
t.Fatalf("foreign host consume = %v, want sql.ErrNoRows", err)
}
}
+17
View File
@@ -440,6 +440,23 @@ func (s *Store) migrate() error {
return err
}
// PBS DR tier (SLICE 1, v0.44.0): the one-time PBS token secret, HOST-scoped — the sibling of
// one_time_secrets (customer/controller custody) for host/agent custody. The hub receives the
// secret over the tenantsync channel, stores it here, and the AGENT consumes it exactly once
// (POST /api/v1/hosts/{id}/pbs/consume-token, per-host key). consumed_at marks it spent; a
// re-issue supersedes any unconsumed value. Never logged, never in desired-state/ConfigJSON.
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS host_pbs_secrets (
host_id TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
consumed_at DATETIME
);
`)
if err != nil {
return err
}
// v0.43.0 — remote app-log diagnostics. Additive columns on app_log_issues:
// context = JSON array of ±5 redacted lines around the FIRST occurrence (first capture
// wins — stable repro context, no churn); context_customer = whose box it came from