From 996d403248209b872e00eff7e3cbed19121592bc Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 9 Jul 2026 17:46:43 +0200 Subject: [PATCH] =?UTF-8?q?docs(audit):=20Hetzner=20API=20provisioning=20s?= =?UTF-8?q?pike=20=E2=80=94=20both=20offsite=20models=20FIT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KEY: Storage Box API is api.hetzner.com/v1 (NOT api.hetzner.cloud/v1). Proven live on spike-* (torn down): sub-account create/reset/readonly/delete + billable bx11 box create/change_type/delete. Create takes a password (no ssh-key param) → transient-pw→ssh-copy-id -s -f→restic reach→reset_subaccount_password (key-only steady state). RepoPath /home/; quota levers (dedicated=box type, shared= readonly+soft-quota); idempotency via label_selector. SECURITY: token is project-wide unscopeable — can touch ep0 + SSH keys → move customer boxes to a dedicated Hetzner project. No prod code; spike torn down clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CONTEXT.md | 12 +++ ...IKE-hetzner-api-provisioning-2026-07-09.md | 101 ++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md diff --git a/CONTEXT.md b/CONTEXT.md index e189065..c6626ed 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -3,6 +3,18 @@ > Created with the REUSE.md rollout (2026-07-03). Authoritative history: `hub/CHANGELOG.md` (hub), > `website/CHANGELOG.md`, `scripts/CHANGELOG.md`; end-of-task detail in `REPORT.md`. +- **2026-07-09 — Hetzner API provisioning spike (offsite tier).** Both offsite models are API-provisionable + (FIT). **#1 correction: the Storage Box API is `https://api.hetzner.com/v1`, NOT `api.hetzner.cloud/v1`** + (Cloud API 404s for storage boxes); same Bearer token. Proven live on `spike-*` (torn down): sub-account + create/reset/readonly/delete + a billable bx11 box create/rescale(`change_type`)/delete. Create takes a + **password (no ssh-key param)** → transient-password→`ssh-copy-id -s -f`→restic-reach→`reset_subaccount_password` + (transient pw invalidated, key survives) = key-only steady state. RepoPath `/home/` (chroot). Quota: + dedicated=box type/`change_type` (hard); shared=no native quota → Felhom soft-quota + `update_access_settings{readonly}` + freeze. Idempotency = **label_selector** (box names not unique). Rate 3600/h. **SECURITY: the token is + project-wide unscopeable Read+Write — it can also touch ep0 (the prod offsite server) + 3 SSH keys → + move customer boxes to a DEDICATED Hetzner project before the hub holds a write token.** Record: + `documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md`. NEXT: the hub-provisioning impl. + - **2026-07-09 — fork-4 custody VALIDATED end-to-end live (supervised).** The full custody cycle proven on the demo (agent v0.77.0 + controller v0.105.0): enable → controller pushes the repo password to the agent → **real escrow ceremony** (`--selftest=escrow-create`, K + restic-pw under a fresh R, uploaded to the hub, diff --git a/documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md b/documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md new file mode 100644 index 0000000..145ef2d --- /dev/null +++ b/documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md @@ -0,0 +1,101 @@ +# SPIKE — Hetzner API provisioning (sub-account + dedicated box) for the offsite tier + +**Date:** 2026-07-09 · **Class:** spike + risky (write token; one billable box create) · **Executor:** +Claude Code live from DooPlex, Viktor on the 🛑 billable + token steps. **No code, no version bump.** +Captures the exact API shapes the hub-provisioning impl will code against, for BOTH offsite models. + +--- + +## 1. VERDICT + +**Both offsite models are API-provisionable — FIT.** An **API-created** account (shared sub-account or +dedicated box) becomes a **working restic target reached key-only**, via the transient-password → install-key +→ reset bootstrap. **The #1 correction:** the Storage Box API lives at **`https://api.hetzner.com/v1`**, NOT +`api.hetzner.cloud/v1` (the classic Cloud API 404s for every storage-box route). Same Bearer token; different +host. One **security caveat** (blast radius, §6) must be addressed before the hub wields this token. + +--- + +## 2. The exact API surface (measured live) + +**Base:** `https://api.hetzner.com/v1` · **Auth:** `Authorization: Bearer ` · **Rate limit:** +`RateLimit-Limit: 3600`/h (per-token; `RateLimit-Remaining`/`-Reset` headers). Async writes return an +**action object** `{id, command, status:"running"|"success"|"error", progress, error}` — poll to `success`. + +### Shared sub-account (non-billable; on an existing box) +| Op | Method + path | Request | Result | +|----|---------------|---------|--------| +| List | `GET /storage_boxes/{box}/subaccounts` | `?label_selector=k%3Dv` supported | `{subaccounts:[…], meta.pagination}` | +| **Create** | `POST /storage_boxes/{box}/subaccounts` | `{home_directory, password, access_settings:{ssh_enabled,reachable_externally,samba_enabled,webdav_enabled,readonly}, labels, description}` | **201** `{action(create_subaccount), subaccount:{id, storage_box}}`; action ~**0 s** | +| Get | `GET /storage_boxes/{box}/subaccounts/{id}` | — | `{id, storage_box, name, username (uXXXXXX-subN), server (…-subN.your-storagebox.de), home_directory, access_settings, description, labels, created}` | +| **Reset pw** | `POST /storage_boxes/{box}/subaccounts/{id}/actions/reset_subaccount_password` | `{password}` | 201 action `reset_subaccount_password` | +| **Access/quota** | `POST /storage_boxes/{box}/subaccounts/{id}/actions/update_access_settings` | `{ssh_enabled,reachable_externally,samba_enabled,webdav_enabled,readonly}` | 201; `readonly:true` freezes writes | +| Delete | `DELETE /storage_boxes/{box}/subaccounts/{id}` | — | 201 action `delete_subaccount` (async) | + +### Dedicated box (billable) +| Op | Method + path | Request | Result | +|----|---------------|---------|--------| +| **Create** | `POST /storage_boxes` | `{name, storage_box_type:"bx11", location:"fsn1", password, access_settings, labels}` — **no `ssh_keys` param** (create takes only a password) | **201** `{action(create), storage_box:{id, status:"initializing"}}`; action **~26 s** → `active`, username `uXXXXXX`, server `uXXXXXX.your-storagebox.de` | +| **Rescale** | `POST /storage_boxes/{id}/actions/change_type` | `{storage_box_type:"bx21"}` | 201 action `change_type` (Hetzner allows downscale only if usage fits) | +| Delete | `DELETE /storage_boxes/{id}` | — | 201 action `delete` (**succeeded immediately even mid-resize** → billing stops) | +| Types | `GET /storage_box_types` | — | `bx11`(id 1333,1 TB), `bx21`(1334,5 TB), `bx31`(1335,10 TB), `bx41`… each `subaccounts_limit:100` | + +**Errors:** invalid input → **422** `{error:{code:"invalid_input", message, details:{fields:[{name,messages}]}}}`; +not-found → **404** `{error:{code:"not_found"}}`. **Password policy:** must contain upper + lower + digit + a +special char (a 422 on the field otherwise). **Box `name` is NOT unique** (a dup-name create only errored on +the missing password) → idempotency must be **label-based**, not name-based. + +## 3. Confirmed (the bootstrap + conventions) +- **Create takes a password; there is no ssh-key param** for boxes or sub-accounts → the + transient-password→install-key path is the only way in. **Proven live:** generate password → API-create → + `ssh-copy-id -p 23 -s -f` (the `-s -f` flags are mandatory on Storage Boxes) → passwordless key auth → + `restic init`+backup+restore round-trip **integrity OK** (chown-immune on an API-created account). +- **`reset_subaccount_password` invalidates the transient password** while the installed key keeps working — + proven (old password → "Permission denied", key → `pwd:/home`). This is the "key-only steady state" the hub + should reach: create → deliver password once → controller installs key → hub resets/discards the password. +- **RepoPath convention:** the account is chrooted to its `home_directory`, presented as `/home` → RepoPath + **`/home/`** for both models (sub-account and dedicated box `/home` are both writable). Confirmed by + the existing `u629193-sub1` (`home_directory:"felhom-demo/"`) and both spike accounts. + +## 4. Quota levers +- **Dedicated:** the **box type** (`change_type`/rescale, Hetzner-enforced hard quota) — bx11 1 TB → bx21 5 TB → … +- **Shared:** **no native per-sub-account quota.** The only API lever is `readonly:true` (a hard freeze, + proven). So a shared-model customer needs a **Felhom soft-quota** (track usage → flip `readonly` when over). + +## 5. Impl blueprint (for the hub-provisioning spec) +- **Cloud-API client:** base `https://api.hetzner.com/v1`, Bearer token, poll actions to `success`; structs + mirror §2's field names. Idempotency + teardown via **label_selector** (e.g. `felhom-customer=`). +- **Customer config:** `offsite_enabled` (bool), `offsite_type` (`shared`|`dedicated`), `capacity` + (dedicated → box type; shared → soft-quota GB), `shared_box_id` (for the shared model). +- **Provision flow (per model):** API-create (password generated hub-side; label with the customer id) → + poll action → **deliver the password to the controller once** (one-time-consumable) → controller installs + its key → hub **`reset_subaccount_password`** (discard) → set the non-secret target descriptor + (Host=`.your-storagebox.de` or `-subN…`, User, Port 23, RepoPath `/home/`, the pinned + host-key line) in the served config → the controller's fork-4 enable→stage→escrow→**auto-confirm** runs. +- **Quota:** dedicated → `change_type`; shared → soft-quota monitor → `update_access_settings{readonly}`. +- **Teardown/offboarding:** `DELETE` the sub-account/box by label. + +## 6. Security recommendation (load-bearing) +Hetzner API tokens are **project-scoped and NOT further scopeable** (no per-resource-type tokens). The +`storage-felhom.eu` token (project `15217960`) therefore has **Read+Write over everything in that project** — +measured live: **1 cloud server (this is ep0, the production offsite WG endpoint `167.235.97.81`), 2 primary +IPs, 3 SSH keys**. A storage-box-provisioning token can today **delete ep0.** **Recommendation:** move +customer Storage Boxes into a **dedicated Hetzner project** whose only contents are storage boxes, so the +provisioning token's project scope IS the blast-radius boundary. The hub should hold that scoped token, not +one that can touch ep0/servers. + +## 7. Teardown state (clean) +Every `spike-*` deleted: the dedicated box `spike-dedicated` (id 611689, `u629463`) DELETED (billing stopped +after a few minutes ≈ cents; delete succeeded even mid-resize); the sub-account `spike-sub` (id 268917, +`u629193-sub2`) DELETED (async, confirmed 0 by label). Only the pre-existing real resources remain: box +`611421` (`PBS-storage-1`) + sub-account `u629193-sub1` (the standing demo offsite) — **untouched**. All +throwaway keys/passwords/request-bodies `shred`ded on DooPlex; the token was only ever read from Viktor's +0600 file (never logged/committed). *(Left in place: `sshpass` installed on DooPlex — a standard tool.)* + +## 8. NOT done / next +- The **hub-provisioning impl** (customer config + automated label-tagged create + one-time password → controller + apply + quota + auto-confirm escrow) — this spike hands it the exact shapes. +- **Decide** (impl spec, not here): dedicated-box-per-customer vs pooled sub-accounts on a shared box, per plan + tier — bx11 allows 100 sub-accounts; shared needs the Felhom soft-quota; dedicated gives Hetzner-hard quota. +- The **dedicated-project migration** (§6) before the hub gets a write token. +- Host-key pinning at provision time (ssh-keyscan + cross-check; one host key across the box's sub-accounts).