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/<repo>; 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
8.4 KiB
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 -fflags 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_passwordinvalidates 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/homeare both writable). Confirmed by the existingu629193-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 → flipreadonlywhen over).
5. Impl blueprint (for the hub-provisioning spec)
- Cloud-API client: base
https://api.hetzner.com/v1, Bearer token, poll actions tosuccess; 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.deor<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:
DELETEthe 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 shredded 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).