hub v0.37.0: offsite provisioning SLICE 1 — Cloud-API client + provisioning core

Hetzner storage-box provisioning against api.hetzner.com/v1 (NOT .cloud).
internal/hetznerapi (typed client + CloudAPI interface + Fake + WaitAction);
internal/offsite (Provisioner.ProvisionOffsite — idempotent by label, shared
sub-account/dedicated box, transient password, non-secret Descriptor,
fail-closed); one_time_secrets store (single-use Save/Consume); POST
/offsite/consume-password/{id} (customer-key auth, once); config-form Offsite
section → applyOffsite (502+no-save on error) → descriptor in ConfigJSON →
version bump. Token/passwords never logged/committed/in ConfigJSON. Tested vs a
faked Cloud API + fail-closed red-proof. NOT yet live-provisioned (needs the
dedicated-project scoped token; current token can delete ep0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-09 18:38:24 +02:00
parent 996d403248
commit 44ec06b50f
16 changed files with 1279 additions and 1 deletions
+52
View File
@@ -416,6 +416,24 @@ func (s *Store) migrate() error {
return err
}
// offsite provisioning (SLICE 1): the ONE-TIME transient storage-box/subaccount password. The hub
// generates it at provision time, delivers it to the controller EXACTLY ONCE (consume endpoint), then
// it is dead — the controller installs its own key + the hub resets the box password. It is transient
// custody, NOT the ConfigJSON (which is served every pull). One row per customer; consumed_at marks it
// spent. Never logged, never in any served config. (No hub-side at-rest cipher exists; the DB file is
// 0600 and the value is short-lived + single-use.)
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS one_time_secrets (
customer_id TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
consumed_at DATETIME
);
`)
if err != nil {
return err
}
return nil
}
@@ -832,6 +850,40 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
return &cfg, nil
}
// SaveOneTimeSecret stores (last-write-wins) the one-time transient offsite password for a customer,
// resetting the consumed flag (a fresh provision supersedes any prior unconsumed value). Never logged.
func (s *Store) SaveOneTimeSecret(customerID, value string) error {
_, err := s.db.Exec(`
INSERT INTO one_time_secrets (customer_id, value, created_at, consumed_at)
VALUES (?, ?, datetime('now'), NULL)
ON CONFLICT(customer_id) DO UPDATE SET value = excluded.value, created_at = datetime('now'), consumed_at = NULL`,
customerID, value)
return err
}
// ConsumeOneTimeSecret returns the customer's one-time offsite password 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) ConsumeOneTimeSecret(customerID 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 one_time_secrets WHERE customer_id = ? AND consumed_at IS NULL`, customerID).Scan(&value)
if err != nil {
return "", err // sql.ErrNoRows when absent OR already consumed
}
if _, err := tx.Exec(`UPDATE one_time_secrets SET consumed_at = datetime('now') WHERE customer_id = ?`, customerID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return value, nil
}
// ListCustomerConfigs returns all customer configurations ordered by ID.
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(`