# 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).