# 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 <token>` · **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/<repo>`** 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=<id>`).
- **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=`<user>.your-storagebox.de` or `<user>-subN…`, User, Port 23, RepoPath `/home/<repo>`, 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).
