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
+17
View File
@@ -66,6 +66,23 @@ The `POST /api/v1/report` handler (v0.4.0+) automatically parses the optional `a
The hub assembles + stores + serves the **secret-free DR recipe** (`documentation/audits/SPIKE-dr-recipe-2026-06-16.md`) — the non-secret re-provision plan that complements escrow (keys) + PBS/restic (bytes). It arrives as two additive `dr_recipe` halves on the existing report paths: the **agent's** storage/guest/PBS half on `POST /api/v1/host-report` and the **controller's** customer/apps half on `POST /api/v1/report` (both backward-compatible, ignore-unknown). The hub stores them PLAINTEXT in a dedicated `dr_recipe` table keyed by `customer_id` (`SaveDRRecipeHostHalf` / `SaveDRRecipeAppHalf`, each preserving the other half), and `AssembleDRRecipe` stitches them into one operator-readable recipe (`{recipe_version, customer, guests, pbs, drives, pve_storage, apps}`; sub-sections pass through verbatim, version = max of the two halves). An operator views the panel on the customer page and downloads the assembled JSON at `GET /customers/{id}/dr-recipe.json`. **Plaintext-at-rest is correct here** — the recipe carries only identifiers/intents/sizes/coordinates, never a key/password/token (the boundary is enforced at the controller emitter). This is the clean inverse of the retired infra-backup.
### Offsite provisioning (SLICE 1, hub v0.37.0)
On operator enable (config form → **Offsite backup**), the hub provisions the customer's offsite tier against
the **Hetzner storage-box API at `https://api.hetzner.com/v1`** (NOT `api.hetzner.cloud` — the classic Cloud
API 404s for storage boxes; shapes measured in `documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md`).
Two models: **shared** (a sub-account on the pool box) or **dedicated** (its own box). Flow (`internal/offsite`):
idempotent lookup by label `felhom-customer=<id>` → create (password generated hub-side) → poll the action to
`success`**`SaveOneTimeSecret`** (the transient password, single-use) → merge the **non-secret descriptor**
(`{enabled,type,host,user,port:23,repo_path,quota_gb|box_type}`) into `ConfigJSON``ConfigVersion` bump →
the controller re-pulls. The controller consumes the one-time password at `POST /offsite/consume-password/{id}`
(customer-API-key auth, single-use) — then installs its key and the hub resets the box password (SLICE 2+).
**Fail-closed:** a provisioning error returns 502 and saves nothing. **Secrets:** the Hetzner token
(`HETZNER_TOKEN`, out-of-band) and every generated password are NEVER logged / committed / in `ConfigJSON`.
**PREREQUISITE for live use:** the token MUST be scoped to a **dedicated Hetzner project** (the current shared
project token can delete ep0 — spike §6). Absent token → the UI still renders; saving with offsite enabled
returns "not configured". SLICE 2 (controller apply-bridge), SLICE 3 (escrow auto-confirm), SLICE 4 (soft-quota) follow.
### Infrastructure Backup — RETIRED (Phase-1, 2026-06-16, hub v0.12.0)
The Infra Backup mechanism (`POST/GET /api/v1/infra-backup`, the operator panel, the