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:
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user