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
+12
View File
@@ -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 — hub offsite provisioning SLICE 1 (hub v0.37.0).** The hub can now provision the offsite tier
on operator enable: `internal/hetznerapi` (typed client, base **api.hetzner.com/v1**, `CloudAPI` interface +
exported `Fake`, `WaitAction`), `internal/offsite` (`Provisioner.ProvisionOffsite` — idempotent by label
`felhom-customer=<id>`, shared sub-account / dedicated box, generates the transient password, builds the
NON-SECRET descriptor, **fail-closed**), `one_time_secrets` store (single-use `Save/ConsumeOneTimeSecret`),
`POST /offsite/consume-password/{id}` (customer-API-key auth, once), config-form Offsite section →
`applyOffsite` (502 + no-save on error) → descriptor in ConfigJSON → ConfigVersion bump. Token from
`HETZNER_TOKEN` (out-of-band); password NEVER in ConfigJSON/logs. Unit-tested vs a faked Cloud API (+ a
fail-closed red-proof). **NOT yet live-provisioned** — awaiting the **dedicated-project scoped token** (the
current token can delete ep0). NEXT: SLICE 2 (controller apply-bridge consuming the descriptor + one-time
password), SLICE 3 (escrow auto-confirm), SLICE 4 (soft-quota). Deployed via GitOps.
- **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
+35
View File
@@ -1,5 +1,40 @@
# Felhom Hub — Changelog
## v0.37.0 — offsite provisioning SLICE 1: Hetzner Cloud-API client + provisioning core (2026-07-09)
Slice 1 of the offsite-provisioning epic. On operator enable, the hub provisions a Hetzner storage-box
sub-account (shared) or dedicated box, generates the transient password, stores it one-time-consumable, and
serves the non-secret target descriptor to the controller via `ConfigJSON`. Coded against the API shapes
measured live in `documentation/audits/SPIKE-hetzner-api-provisioning-2026-07-09.md` (996d403). The
controller apply-bridge (SLICE 2), escrow auto-confirm (SLICE 3), and soft-quota enforcement (SLICE 4) are
separate slices.
- **`internal/hetznerapi`:** a typed client for the storage-box surface at **`https://api.hetzner.com/v1`**
(NOT `api.hetzner.cloud` — the classic Cloud API 404s for storage boxes). Sub-account + box
create/reset/access/change_type/delete/list-by-label + `WaitAction` (poll to `success`, bounded). A
`CloudAPI` interface + an exported `Fake` so provisioning is unit-tested with **no live Hetzner calls**.
Bearer token from an injected func (out-of-band secret; never logged).
- **`internal/offsite`:** `Provisioner.ProvisionOffsite` — **idempotent by `label_selector`
(felhom-customer=<id>)** (names aren't unique); shared → sub-account on the pool box, dedicated → box;
generates a 4-class transient password → `WaitAction``Store.SaveOneTimeSecret` → builds the NON-SECRET
`Descriptor{enabled,type,host,user,port:23,repo_path:/home/felhom-repo, quota_gb|box_type}`. **Fail-closed:**
any API/action error returns without a provisioned resource, one-time password, or descriptor.
`MergeDescriptor` merges it under the `offsite` key of `ConfigJSON` (never a secret).
- **`internal/store`:** `one_time_secrets` table + `SaveOneTimeSecret` / `ConsumeOneTimeSecret` (single-use,
return-and-mark in one tx). The transient password NEVER rides `ConfigJSON`.
- **`internal/api`:** `POST /offsite/consume-password/{id}` — serves the one-time password to the
authenticated customer (same API-key auth as config-pull) EXACTLY once, then 404s. Never logged.
- **`internal/web`:** the config form gains an **Offsite backup** section (enable / type / soft-quota / box
type); save → `applyOffsite` provisions (fail-closed: a provisioning error returns 502 and does NOT save)
and merges the descriptor → `ConfigVersion` bump → controller re-pulls. Optional dep
(`SetOffsiteProvisioner`), wired in `cmd/hub/main.go` from `HETZNER_TOKEN`/`HETZNER_POOL_BOX_ID`/`HETZNER_LOCATION`.
- Tests (faked Cloud API, no live calls): shared/dedicated provision + descriptor + one-time-password-stored
+ **password-absent-from-ConfigJSON**; idempotent re-save (no 2nd resource); fail-closed **+ companion
red-proof** (swallow the create error → offsite marked enabled despite failure → test fails); one-time
consume-once; `WaitAction` success/error/timeout; the consume endpoint (auth + single-use).
- **NOT yet live-provisioned** — awaiting the **dedicated-project scoped token** (the current token can delete
ep0 — SPIKE §6); a live create is a supervised validation. Unit tests are this slice's proof.
## v0.36.0 — customer page: passphrase hardening + interactive install-command generator (TASK GL-7) (2026-07-09)
Two coupled, security-first changes to the operator-facing customer page (`customer_unified.html` +
+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
+20
View File
@@ -9,13 +9,16 @@ import (
"os"
"os/signal"
"path/filepath"
"strconv"
"syscall"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/mailrelay"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/notify"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
@@ -264,6 +267,23 @@ func main() {
logger.Printf("[INFO] Gitea artifact browser enabled (Day-0 version dropdowns)")
}
// Offsite provisioning (SLICE 1): enabled when a Hetzner storage-box token is provided out-of-band.
// PREREQUISITE: this MUST be a token scoped to a DEDICATED Hetzner project (the shared project token can
// delete ep0 — SPIKE §6). Base is api.hetzner.com (NOT api.hetzner.cloud). Absent token → offsite UI
// still renders, but saving with offsite enabled returns "not configured".
if tok := os.Getenv("HETZNER_TOKEN"); tok != "" {
poolBoxID, _ := strconv.ParseInt(os.Getenv("HETZNER_POOL_BOX_ID"), 10, 64)
location := os.Getenv("HETZNER_LOCATION")
if location == "" {
location = "fsn1"
}
client := hetznerapi.NewClient(func() string { return os.Getenv("HETZNER_TOKEN") })
webServer.SetOffsiteProvisioner(&offsite.Provisioner{
API: client, Store: dataStore, PoolBoxID: poolBoxID, Location: location, Logger: logger,
})
logger.Printf("[INFO] Offsite provisioning enabled (pool_box=%d, location=%s)", poolBoxID, location)
}
// Build HTTP mux
mux := http.NewServeMux()
+3
View File
@@ -244,6 +244,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/config/"):
customerID := strings.TrimPrefix(path, "/config/")
h.handleConfigRetrieve(w, r, customerID)
case r.Method == http.MethodPost && strings.HasPrefix(path, "/offsite/consume-password/"):
customerID := strings.TrimPrefix(path, "/offsite/consume-password/")
h.handleOffsiteConsumePassword(w, r, customerID)
case r.Method == http.MethodGet && strings.HasPrefix(path, "/artifacts/"):
customerID := strings.TrimPrefix(path, "/artifacts/")
h.handleArtifactManifest(w, r, customerID)
+31
View File
@@ -0,0 +1,31 @@
package api
import (
"database/sql"
"encoding/json"
"net/http"
)
// handleOffsiteConsumePassword serves the one-time transient offsite password to the controller EXACTLY
// ONCE (SLICE 1; SLICE 2 controller consumes it, installs its key, then the hub resets the box password).
// Auth = the customer's API key (same credential as config-pull); the token's customer must match the
// path. The value is returned once then marked consumed — a second call 404s. NEVER logged.
func (h *Handler) handleOffsiteConsumePassword(w http.ResponseWriter, r *http.Request, customerID string) {
authCustomerID, isGlobal, ok := h.checkAuthCustomer(r)
if !ok || (!isGlobal && authCustomerID != customerID) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
pw, err := h.store.ConsumeOneTimeSecret(customerID)
if err == sql.ErrNoRows {
http.Error(w, "no unconsumed offsite password", http.StatusNotFound)
return
}
if err != nil {
h.logger.Printf("[ERROR] offsite consume-password %s: %v", customerID, err) // no secret
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"password": pw}) // one-time; never logged
}
+52
View File
@@ -0,0 +1,52 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// The one-time offsite password is served once to the authenticated customer, then 404s; a wrong/absent or
// cross-customer key is rejected.
func TestOffsite_ConsumePassword(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pp"})
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c2", APIKey: "ckey2", RetrievalPassword: "pp2"})
st.SaveOneTimeSecret("c1", "the-transient-pw")
do := func(token string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/api/v1/offsite/consume-password/c1", nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
return rr
}
if rr := do(""); rr.Code != http.StatusUnauthorized {
t.Fatalf("no auth → %d, want 401", rr.Code)
}
if rr := do("wrongkey"); rr.Code != http.StatusUnauthorized {
t.Fatalf("wrong key → %d, want 401", rr.Code)
}
if rr := do("ckey2"); rr.Code != http.StatusUnauthorized {
t.Fatalf("cross-customer key → %d, want 401", rr.Code)
}
// first (authorized) consume → 200 + the password
rr := do("ckey")
if rr.Code != http.StatusOK {
t.Fatalf("consume → %d, want 200", rr.Code)
}
var body map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil || body["password"] != "the-transient-pw" {
t.Fatalf("body = %q (%v)", rr.Body.String(), err)
}
// second consume → 404 (single use)
if rr2 := do("ckey"); rr2.Code != http.StatusNotFound {
t.Fatalf("second consume → %d, want 404", rr2.Code)
}
}
+192
View File
@@ -0,0 +1,192 @@
package hetznerapi
import (
"context"
"fmt"
"sync"
)
// Fake is an in-memory CloudAPI for tests (no live Hetzner calls). It records created resources by label
// (so idempotency lookups work) and lets a test force a failure. Actions it returns are already-terminal
// (success unless FailCreate/FailAction is set), so WaitAction resolves without a network poll.
type Fake struct {
mu sync.Mutex
Subaccounts map[int64]Subaccount // by id
Boxes map[int64]StorageBox // by id
nextID int64
// Records of calls (for assertions).
CreatedSubaccounts int
CreatedBoxes int
ResetCalls int
DeletedSubaccounts int
DeletedBoxes int
// Failure injection.
FailCreate error // if set, CreateSubaccount/CreateStorageBox return this error
FailAction bool // if set, created actions come back status "error" (WaitAction fails)
}
// NewFake returns an empty Fake.
func NewFake() *Fake {
return &Fake{Subaccounts: map[int64]Subaccount{}, Boxes: map[int64]StorageBox{}, nextID: 1000}
}
func (f *Fake) newAction(cmd string) Action {
if f.FailAction {
a := Action{ID: f.nextID, Command: cmd, Status: "error"}
a.Error = &struct {
Code string `json:"code"`
Message string `json:"message"`
}{Code: "action_failed", Message: "injected action failure"}
f.nextID++
return a
}
a := Action{ID: f.nextID, Command: cmd, Status: "success", Progress: 100}
f.nextID++
return a
}
func labelMatch(labels map[string]string, selector string) bool {
if selector == "" {
return true
}
// minimal k=v matcher (the hub only uses single-key equality selectors)
for i := 0; i < len(selector); i++ {
if selector[i] == '=' {
k, v := selector[:i], selector[i+1:]
return labels[k] == v
}
}
return false
}
func (f *Fake) ListSubaccounts(_ context.Context, boxID int64, sel string) ([]Subaccount, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Subaccount
for _, s := range f.Subaccounts {
if s.StorageBox == boxID && labelMatch(s.Labels, sel) {
out = append(out, s)
}
}
return out, nil
}
func (f *Fake) GetSubaccount(_ context.Context, _ int64, subID int64) (Subaccount, error) {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.Subaccounts[subID]
if !ok {
return Subaccount{}, fmt.Errorf("hetznerapi(fake): subaccount %d not found", subID)
}
return s, nil
}
func (f *Fake) CreateSubaccount(_ context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailCreate != nil {
return 0, Action{}, f.FailCreate
}
f.CreatedSubaccounts++
id := f.nextID
f.nextID++
f.Subaccounts[id] = Subaccount{
ID: id, StorageBox: boxID,
Username: fmt.Sprintf("u629193-sub%d", id),
Server: fmt.Sprintf("u629193-sub%d.your-storagebox.de", id),
HomeDirectory: req.HomeDirectory,
AccessSettings: req.AccessSettings,
Labels: req.Labels,
}
return id, f.newAction("create_subaccount"), nil
}
func (f *Fake) ResetSubaccountPassword(_ context.Context, _, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ResetCalls++
return f.newAction("reset_subaccount_password"), nil
}
func (f *Fake) UpdateSubaccountAccess(_ context.Context, _, subID int64, as AccessSettings) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if s, ok := f.Subaccounts[subID]; ok {
s.AccessSettings = as
f.Subaccounts[subID] = s
}
return f.newAction("update_access_settings"), nil
}
func (f *Fake) DeleteSubaccount(_ context.Context, _, subID int64) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.Subaccounts, subID)
f.DeletedSubaccounts++
return f.newAction("delete_subaccount"), nil
}
func (f *Fake) ListStorageBoxes(_ context.Context, sel string) ([]StorageBox, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []StorageBox
for _, b := range f.Boxes {
if labelMatch(b.Labels, sel) {
out = append(out, b)
}
}
return out, nil
}
func (f *Fake) GetStorageBox(_ context.Context, boxID int64) (StorageBox, error) {
f.mu.Lock()
defer f.mu.Unlock()
b, ok := f.Boxes[boxID]
if !ok {
return StorageBox{}, fmt.Errorf("hetznerapi(fake): box %d not found", boxID)
}
return b, nil
}
func (f *Fake) CreateStorageBox(_ context.Context, req CreateBoxRequest) (int64, Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailCreate != nil {
return 0, Action{}, f.FailCreate
}
f.CreatedBoxes++
id := f.nextID
f.nextID++
f.Boxes[id] = StorageBox{
ID: id, Name: req.Name, Status: "active",
Username: fmt.Sprintf("u6294%d", id),
Server: fmt.Sprintf("u6294%d.your-storagebox.de", id),
Labels: req.Labels,
}
return id, f.newAction("create_storage_box"), nil
}
func (f *Fake) ChangeType(_ context.Context, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.newAction("change_type"), nil
}
func (f *Fake) DeleteStorageBox(_ context.Context, boxID int64) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.Boxes, boxID)
f.DeletedBoxes++
return f.newAction("delete_storage_box"), nil
}
// WaitAction resolves based on the (already-terminal) fake action status — no polling.
func (f *Fake) WaitAction(_ context.Context, a Action) error {
if a.Status == "error" {
return actionErr(a)
}
return nil
}
+319
View File
@@ -0,0 +1,319 @@
// Package hetznerapi is a small typed client for the Hetzner unified API storage-box surface, used by the
// hub to provision the offsite tier. SPIKE 996d403 (authoritative, measured live): the base URL is
// https://api.hetzner.com/v1 — NOT api.hetzner.cloud (the classic Cloud API 404s for every storage-box
// route). Async writes return an action object; poll it to status "success".
//
// Provisioning depends on the CloudAPI interface (not *Client) so tests inject a fake — NO live Hetzner
// calls in CI. The bearer token is supplied by an injected func (read from an out-of-band secret, never a
// committed file); it is never logged.
package hetznerapi
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// DefaultBaseURL is the storage-box API base (spike §2).
const DefaultBaseURL = "https://api.hetzner.com/v1"
// Action is the async-write result the API returns; poll to status "success".
type Action struct {
ID int64 `json:"id"`
Command string `json:"command"`
Status string `json:"status"` // "running" | "success" | "error"
Progress int `json:"progress"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
// AccessSettings mirrors the storage-box/subaccount access_settings object.
type AccessSettings struct {
SSHEnabled bool `json:"ssh_enabled"`
ReachableExternally bool `json:"reachable_externally"`
SambaEnabled bool `json:"samba_enabled"`
WebDAVEnabled bool `json:"webdav_enabled"`
Readonly bool `json:"readonly"`
}
// Subaccount mirrors a storage-box subaccount (the non-secret fields; the API never returns a password).
type Subaccount struct {
ID int64 `json:"id"`
StorageBox int64 `json:"storage_box"`
Username string `json:"username"` // uXXXXXX-subN
Server string `json:"server"` // …-subN.your-storagebox.de
HomeDirectory string `json:"home_directory"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels"`
}
// StorageBox mirrors a dedicated storage box (non-secret fields).
type StorageBox struct {
ID int64 `json:"id"`
Username string `json:"username"` // uXXXXXX (empty until active)
Name string `json:"name"`
Status string `json:"status"` // initializing | active | …
Server string `json:"server"` // uXXXXXX.your-storagebox.de
Labels map[string]string `json:"labels"`
}
// CreateSubaccountRequest — POST /storage_boxes/{box}/subaccounts. Password satisfies the 4-class policy.
type CreateSubaccountRequest struct {
HomeDirectory string `json:"home_directory"`
Password string `json:"password"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels,omitempty"`
Description string `json:"description,omitempty"`
}
// CreateBoxRequest — POST /storage_boxes.
type CreateBoxRequest struct {
Name string `json:"name"`
StorageBoxType string `json:"storage_box_type"` // e.g. "bx11"
Location string `json:"location"` // e.g. "fsn1"
Password string `json:"password"`
AccessSettings AccessSettings `json:"access_settings"`
Labels map[string]string `json:"labels,omitempty"`
}
// CloudAPI is the storage-box provisioning surface the hub depends on. A fake implements it in tests.
type CloudAPI interface {
ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error)
GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error)
CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (createdID int64, action Action, err error)
ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error)
UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error)
DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error)
ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error)
GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error)
CreateStorageBox(ctx context.Context, req CreateBoxRequest) (createdID int64, action Action, err error)
ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error)
DeleteStorageBox(ctx context.Context, boxID int64) (Action, error)
// WaitAction polls the action to "success" (bounded); errors on "error" or timeout.
WaitAction(ctx context.Context, action Action) error
}
// Client implements CloudAPI against the live API.
type Client struct {
BaseURL string
Token func() string // injected; read from an out-of-band secret; never logged
HC *http.Client
PollEvery time.Duration
PollMax time.Duration
}
// NewClient builds a Client with sane defaults. token must return the bearer (from env/mounted secret).
func NewClient(token func() string) *Client {
return &Client{
BaseURL: DefaultBaseURL,
Token: token,
HC: &http.Client{Timeout: 30 * time.Second},
PollEvery: 3 * time.Second,
PollMax: 3 * time.Minute,
}
}
// apiError is the API's error envelope {error:{code,message,details}}.
type apiError struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
func (e *apiError) String() string { return fmt.Sprintf("%s: %s", e.Error.Code, e.Error.Message) }
// do performs a request and decodes the JSON body into out. On a non-2xx it returns a typed error carrying
// the API's error code/message. The token is set on the header only — never logged.
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rdr)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token())
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HC.Do(req)
if err != nil {
return fmt.Errorf("hetznerapi: %s %s: %w", method, path, err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var ae apiError
if json.Unmarshal(raw, &ae) == nil && ae.Error.Code != "" {
return fmt.Errorf("hetznerapi: %s %s: HTTP %d: %s", method, path, resp.StatusCode, ae.String())
}
return fmt.Errorf("hetznerapi: %s %s: HTTP %d", method, path, resp.StatusCode)
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("hetznerapi: %s %s: decode: %w", method, path, err)
}
}
return nil
}
func labelQuery(labelSelector string) string {
if labelSelector == "" {
return ""
}
return "?label_selector=" + url.QueryEscape(labelSelector)
}
// --- subaccounts ---
func (c *Client) ListSubaccounts(ctx context.Context, boxID int64, labelSelector string) ([]Subaccount, error) {
var out struct {
Subaccounts []Subaccount `json:"subaccounts"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts%s", boxID, labelQuery(labelSelector)), nil, &out)
return out.Subaccounts, err
}
func (c *Client) GetSubaccount(ctx context.Context, boxID, subID int64) (Subaccount, error) {
var out struct {
Subaccount Subaccount `json:"subaccount"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
return out.Subaccount, err
}
func (c *Client) CreateSubaccount(ctx context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
var out struct {
Action Action `json:"action"`
Subaccount struct {
ID int64 `json:"id"`
} `json:"subaccount"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts", boxID), req, &out)
return out.Subaccount.ID, out.Action, err
}
func (c *Client) ResetSubaccountPassword(ctx context.Context, boxID, subID int64, password string) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/reset_subaccount_password", boxID, subID), map[string]string{"password": password}, &out)
return out.Action, err
}
func (c *Client) UpdateSubaccountAccess(ctx context.Context, boxID, subID int64, as AccessSettings) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d/actions/update_access_settings", boxID, subID), as, &out)
return out.Action, err
}
func (c *Client) DeleteSubaccount(ctx context.Context, boxID, subID int64) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d/subaccounts/%d", boxID, subID), nil, &out)
return out.Action, err
}
// --- storage boxes ---
func (c *Client) ListStorageBoxes(ctx context.Context, labelSelector string) ([]StorageBox, error) {
var out struct {
StorageBoxes []StorageBox `json:"storage_boxes"`
}
err := c.do(ctx, http.MethodGet, "/storage_boxes"+labelQuery(labelSelector), nil, &out)
return out.StorageBoxes, err
}
func (c *Client) GetStorageBox(ctx context.Context, boxID int64) (StorageBox, error) {
var out struct {
StorageBox StorageBox `json:"storage_box"`
}
err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
return out.StorageBox, err
}
func (c *Client) CreateStorageBox(ctx context.Context, req CreateBoxRequest) (int64, Action, error) {
var out struct {
Action Action `json:"action"`
StorageBox struct {
ID int64 `json:"id"`
} `json:"storage_box"`
}
err := c.do(ctx, http.MethodPost, "/storage_boxes", req, &out)
return out.StorageBox.ID, out.Action, err
}
func (c *Client) ChangeType(ctx context.Context, boxID int64, boxType string) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/actions/change_type", boxID), map[string]string{"storage_box_type": boxType}, &out)
return out.Action, err
}
func (c *Client) DeleteStorageBox(ctx context.Context, boxID int64) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodDelete, fmt.Sprintf("/storage_boxes/%d", boxID), nil, &out)
return out.Action, err
}
// WaitAction polls the action to "success" (bounded by PollMax). An already-terminal action returns
// immediately. It NEVER assumes create == ready.
func (c *Client) WaitAction(ctx context.Context, action Action) error {
if s := action.Status; s == "success" {
return nil
} else if s == "error" {
return actionErr(action)
}
deadline := time.Now().Add(c.PollMax)
for {
var out struct {
Action Action `json:"action"`
}
if err := c.do(ctx, http.MethodGet, fmt.Sprintf("/storage_boxes/actions/%d", action.ID), nil, &out); err != nil {
return err
}
switch out.Action.Status {
case "success":
return nil
case "error":
return actionErr(out.Action)
}
if time.Now().After(deadline) {
return fmt.Errorf("hetznerapi: action %d (%s) did not reach success within %s", action.ID, action.Command, c.PollMax)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(c.PollEvery):
}
}
}
func actionErr(a Action) error {
if a.Error != nil {
return fmt.Errorf("hetznerapi: action %d (%s) failed: %s: %s", a.ID, a.Command, a.Error.Code, a.Error.Message)
}
return fmt.Errorf("hetznerapi: action %d (%s) failed", a.ID, a.Command)
}
@@ -0,0 +1,99 @@
package hetznerapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// compile-time: both the real client and the fake satisfy CloudAPI.
var _ CloudAPI = (*Client)(nil)
var _ CloudAPI = (*Fake)(nil)
func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := NewClient(func() string { return "TESTTOKEN" })
c.BaseURL = srv.URL
c.PollEvery = 5 * time.Millisecond
c.PollMax = 2 * time.Second
return c, srv
}
// The create request carries the bearer + JSON, and the {action,subaccount} envelope decodes.
func TestClient_CreateSubaccount(t *testing.T) {
var gotAuth, gotBody string
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
buf := make([]byte, r.ContentLength)
r.Body.Read(buf)
gotBody = string(buf)
w.WriteHeader(201)
w.Write([]byte(`{"action":{"id":42,"command":"create_subaccount","status":"success"},"subaccount":{"id":268917,"storage_box":611421}}`))
}))
id, act, err := c.CreateSubaccount(context.Background(), 611421, CreateSubaccountRequest{HomeDirectory: "spike-sub", Password: "Xx1%", AccessSettings: AccessSettings{SSHEnabled: true}})
if err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer TESTTOKEN" {
t.Errorf("bearer not set, got %q", gotAuth)
}
if !strings.Contains(gotBody, `"home_directory":"spike-sub"`) {
t.Errorf("request body missing home_directory: %s", gotBody)
}
if id != 268917 || act.ID != 42 || act.Status != "success" {
t.Fatalf("got id=%d action=%+v", id, act)
}
}
// A non-2xx surfaces the API's error code/message (the 422 password shape).
func TestClient_ErrorEnvelope(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"error":{"code":"invalid_input","message":"invalid input in field password"}}`))
}))
_, _, err := c.CreateSubaccount(context.Background(), 1, CreateSubaccountRequest{})
if err == nil || !strings.Contains(err.Error(), "invalid_input") {
t.Fatalf("want invalid_input error, got %v", err)
}
}
// WaitAction: an already-success action returns immediately; running→success polls; error fails.
func TestClient_WaitAction(t *testing.T) {
if err := (&Client{}).WaitAction(context.Background(), Action{Status: "success"}); err != nil {
t.Fatalf("terminal success must return nil, got %v", err)
}
if err := (&Client{}).WaitAction(context.Background(), Action{ID: 9, Command: "x", Status: "error"}); err == nil {
t.Fatal("terminal error must return an error")
}
calls := 0
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
st := "running"
if calls >= 3 {
st = "success"
}
w.Write([]byte(`{"action":{"id":7,"command":"create","status":"` + st + `"}}`))
}))
if err := c.WaitAction(context.Background(), Action{ID: 7, Command: "create", Status: "running"}); err != nil {
t.Fatalf("running→success must resolve, got %v", err)
}
if calls < 3 {
t.Fatalf("expected polling, only %d calls", calls)
}
}
func TestClient_WaitActionTimeout(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"action":{"id":7,"status":"running"}}`))
}))
c.PollMax = 30 * time.Millisecond
if err := c.WaitAction(context.Background(), Action{ID: 7, Status: "running"}); err == nil || !strings.Contains(err.Error(), "did not reach success") {
t.Fatalf("want timeout error, got %v", err)
}
}
+208
View File
@@ -0,0 +1,208 @@
// Package offsite orchestrates per-customer offsite-tier provisioning against the Hetzner storage-box API
// (SLICE 1): idempotent create of a shared sub-account or a dedicated box, generation of the transient
// one-time password, and the NON-SECRET target descriptor that rides ConfigJSON to the controller. The
// controller-side apply-bridge (SLICE 2) and escrow auto-confirm (SLICE 3) are out of scope here.
//
// Fail-closed: any API/action error returns without a provisioned resource being recorded — the caller
// must NOT mark offsite enabled/served on error. Idempotent: every create is guarded by a label lookup
// first (box/sub-account names are not unique — SPIKE §2).
package offsite
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"log"
"math/big"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// sftpPort is the storage-box SSH/SFTP port (SPIKE: 23).
const sftpPort = 23
// Descriptor is the NON-SECRET offsite target that rides ConfigJSON to the controller. It NEVER carries
// the password or the SSH key.
type Descriptor struct {
Enabled bool `json:"enabled"`
Type string `json:"type,omitempty"` // "shared" | "dedicated"
Host string `json:"host,omitempty"` // <user>.your-storagebox.de (dedicated) / <user>-subN… (shared)
User string `json:"user,omitempty"`
Port int `json:"port,omitempty"` // 23
RepoPath string `json:"repo_path,omitempty"` // /home/<repo>
QuotaGB int `json:"quota_gb,omitempty"` // shared soft-quota (Felhom-enforced; no native lever)
BoxType string `json:"box_type,omitempty"` // dedicated (Hetzner-hard quota via the type)
}
// Input is the operator's offsite choice.
type Input struct {
Enabled bool
Type string // "shared" | "dedicated"
QuotaGB int // shared
BoxType string // dedicated, e.g. "bx11"
}
// Provisioner provisions offsite resources. It depends on the CloudAPI interface (tests inject a fake).
type Provisioner struct {
API hetznerapi.CloudAPI
Store *store.Store
PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421)
Location string // dedicated-box location, e.g. "fsn1"
Logger *log.Logger
}
func (p *Provisioner) logf(f string, a ...any) {
if p.Logger != nil {
p.Logger.Printf(f, a...)
}
}
// customerLabel is the idempotency/teardown key.
func customerLabel(customerID string) map[string]string { return map[string]string{"felhom-customer": customerID} }
func customerSelector(customerID string) string { return "felhom-customer=" + customerID }
// repoPath is the controller-facing RepoPath — each account is chrooted, /home is writable (SPIKE).
const repoPath = "/home/felhom-repo"
// ProvisionOffsite ensures the customer's offsite resource exists and returns the non-secret descriptor.
// On a fresh create it generates + stores the one-time password (Store.SaveOneTimeSecret). On an existing
// resource (found by label) it is a no-op create → returns the descriptor without a new password. The
// caller merges the descriptor into ConfigJSON and saves. Disable → returns {Enabled:false} (NO deprovision).
func (p *Provisioner) ProvisionOffsite(ctx context.Context, customerID string, in Input) (*Descriptor, error) {
if !in.Enabled {
return &Descriptor{Enabled: false}, nil
}
switch in.Type {
case "shared":
return p.provisionShared(ctx, customerID, in)
case "dedicated":
return p.provisionDedicated(ctx, customerID, in)
default:
return nil, fmt.Errorf("offsite: unknown type %q (want shared|dedicated)", in.Type)
}
}
func (p *Provisioner) provisionShared(ctx context.Context, customerID string, in Input) (*Descriptor, error) {
if p.PoolBoxID == 0 {
return nil, fmt.Errorf("offsite: no shared pool box configured")
}
// Idempotency: an existing labelled sub-account is reused (no second create).
existing, err := p.API.ListSubaccounts(ctx, p.PoolBoxID, customerSelector(customerID))
if err != nil {
return nil, fmt.Errorf("offsite: list subaccounts: %w", err)
}
if len(existing) > 0 {
s := existing[0]
p.logf("[offsite] shared already provisioned for %s (subaccount %d)", customerID, s.ID)
return &Descriptor{Enabled: true, Type: "shared", Host: s.Server, User: s.Username, Port: sftpPort, RepoPath: repoPath, QuotaGB: in.QuotaGB}, nil
}
// Fresh create: generate the transient password, create, wait, fetch the full object.
pw, err := genPassword()
if err != nil {
return nil, err
}
id, action, err := p.API.CreateSubaccount(ctx, p.PoolBoxID, hetznerapi.CreateSubaccountRequest{
HomeDirectory: "felhom-" + customerID,
Password: pw,
AccessSettings: hetznerapi.AccessSettings{SSHEnabled: true, ReachableExternally: true},
Labels: customerLabel(customerID),
Description: "felhom offsite " + customerID,
})
if err != nil {
return nil, fmt.Errorf("offsite: create subaccount: %w", err)
}
if err := p.API.WaitAction(ctx, action); err != nil {
return nil, fmt.Errorf("offsite: subaccount create action: %w", err)
}
sub, err := p.API.GetSubaccount(ctx, p.PoolBoxID, id)
if err != nil {
return nil, fmt.Errorf("offsite: fetch subaccount: %w", err)
}
if err := p.Store.SaveOneTimeSecret(customerID, pw); err != nil {
return nil, fmt.Errorf("offsite: store one-time password: %w", err)
}
p.logf("[offsite] shared provisioned for %s (subaccount %d, user %s)", customerID, sub.ID, sub.Username)
return &Descriptor{Enabled: true, Type: "shared", Host: sub.Server, User: sub.Username, Port: sftpPort, RepoPath: repoPath, QuotaGB: in.QuotaGB}, nil
}
func (p *Provisioner) provisionDedicated(ctx context.Context, customerID string, in Input) (*Descriptor, error) {
boxType := in.BoxType
if boxType == "" {
boxType = "bx11"
}
existing, err := p.API.ListStorageBoxes(ctx, customerSelector(customerID))
if err != nil {
return nil, fmt.Errorf("offsite: list boxes: %w", err)
}
if len(existing) > 0 {
b := existing[0]
p.logf("[offsite] dedicated already provisioned for %s (box %d)", customerID, b.ID)
return &Descriptor{Enabled: true, Type: "dedicated", Host: b.Server, User: b.Username, Port: sftpPort, RepoPath: repoPath, BoxType: boxType}, nil
}
pw, err := genPassword()
if err != nil {
return nil, err
}
id, action, err := p.API.CreateStorageBox(ctx, hetznerapi.CreateBoxRequest{
Name: "felhom-" + customerID,
StorageBoxType: boxType,
Location: p.Location,
Password: pw,
AccessSettings: hetznerapi.AccessSettings{SSHEnabled: true, ReachableExternally: true},
Labels: customerLabel(customerID),
})
if err != nil {
return nil, fmt.Errorf("offsite: create box: %w", err)
}
if err := p.API.WaitAction(ctx, action); err != nil {
return nil, fmt.Errorf("offsite: box create action: %w", err)
}
box, err := p.API.GetStorageBox(ctx, id)
if err != nil {
return nil, fmt.Errorf("offsite: fetch box: %w", err)
}
if err := p.Store.SaveOneTimeSecret(customerID, pw); err != nil {
return nil, fmt.Errorf("offsite: store one-time password: %w", err)
}
p.logf("[offsite] dedicated provisioned for %s (box %d, user %s)", customerID, box.ID, box.Username)
return &Descriptor{Enabled: true, Type: "dedicated", Host: box.Server, User: box.Username, Port: sftpPort, RepoPath: repoPath, BoxType: boxType}, nil
}
// MergeDescriptor merges the offsite descriptor under the "offsite" key of a ConfigJSON object, preserving
// all other keys. Returns the new ConfigJSON string. NEVER carries a secret (Descriptor is non-secret).
func MergeDescriptor(configJSON string, d *Descriptor) (string, error) {
obj := map[string]json.RawMessage{}
if strings.TrimSpace(configJSON) != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &obj); err != nil {
return "", fmt.Errorf("offsite: parse config_json: %w", err)
}
}
db, err := json.Marshal(d)
if err != nil {
return "", err
}
obj["offsite"] = db
out, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(out), nil
}
// genPassword returns a transient password satisfying the Hetzner 4-class policy (upper+lower+digit+special).
func genPassword() (string, error) {
const alnum = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, 24)
for i := range b {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alnum))))
if err != nil {
return "", err
}
b[i] = alnum[n.Int64()]
}
// Guarantee all four classes (transient + single-use + reset after install).
return string(b) + "Aa9%", nil
}
+152
View File
@@ -0,0 +1,152 @@
package offsite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
func newTestProvisioner(t *testing.T) (*Provisioner, *hetznerapi.Fake, *store.Store) {
t.Helper()
st, err := store.New(filepath.Join(t.TempDir(), "off.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
fake := hetznerapi.NewFake()
return &Provisioner{API: fake, Store: st, PoolBoxID: 611421, Location: "fsn1", Logger: log.New(io.Discard, "", 0)}, fake, st
}
// Scenario A — enable shared → sub-account provisioned, descriptor built, one-time password stored (NOT in
// ConfigJSON), password absent from the merged config.
func TestProvision_Shared(t *testing.T) {
p, fake, st := newTestProvisioner(t)
d, err := p.ProvisionOffsite(context.Background(), "cust-a", Input{Enabled: true, Type: "shared", QuotaGB: 50})
if err != nil {
t.Fatalf("provision: %v", err)
}
if fake.CreatedSubaccounts != 1 {
t.Fatalf("want 1 subaccount created, got %d", fake.CreatedSubaccounts)
}
if d.Type != "shared" || d.Port != 23 || d.RepoPath != "/home/felhom-repo" || d.QuotaGB != 50 || d.User == "" || d.Host == "" {
t.Fatalf("descriptor wrong: %+v", d)
}
// one-time password stored + is NOT the descriptor / config
pw, err := st.ConsumeOneTimeSecret("cust-a")
if err != nil || pw == "" {
t.Fatalf("one-time password not stored: %v", err)
}
merged, err := MergeDescriptor(`{"git":{"token":"x"}}`, d)
if err != nil {
t.Fatal(err)
}
if strings.Contains(merged, pw) {
t.Fatal("the transient password LEAKED into ConfigJSON")
}
if !strings.Contains(merged, `"offsite"`) || !strings.Contains(merged, `"git"`) {
t.Fatalf("merge lost keys: %s", merged)
}
// descriptor struct has no password field at all
db, _ := json.Marshal(d)
if strings.Contains(strings.ToLower(string(db)), "password") {
t.Fatalf("descriptor carries a password field: %s", db)
}
}
// Scenario B — enable dedicated → box provisioned.
func TestProvision_Dedicated(t *testing.T) {
p, fake, st := newTestProvisioner(t)
d, err := p.ProvisionOffsite(context.Background(), "cust-b", Input{Enabled: true, Type: "dedicated", BoxType: "bx11"})
if err != nil {
t.Fatalf("provision: %v", err)
}
if fake.CreatedBoxes != 1 {
t.Fatalf("want 1 box created, got %d", fake.CreatedBoxes)
}
if d.Type != "dedicated" || d.BoxType != "bx11" || d.User == "" || d.Host == "" || d.RepoPath != "/home/felhom-repo" {
t.Fatalf("descriptor wrong: %+v", d)
}
if pw, err := st.ConsumeOneTimeSecret("cust-b"); err != nil || pw == "" {
t.Fatalf("one-time password not stored: %v", err)
}
}
// Scenario C — idempotent re-save does NOT create a second resource.
func TestProvision_Idempotent(t *testing.T) {
p, fake, _ := newTestProvisioner(t)
if _, err := p.ProvisionOffsite(context.Background(), "cust-c", Input{Enabled: true, Type: "shared", QuotaGB: 20}); err != nil {
t.Fatal(err)
}
d2, err := p.ProvisionOffsite(context.Background(), "cust-c", Input{Enabled: true, Type: "shared", QuotaGB: 20})
if err != nil {
t.Fatal(err)
}
if fake.CreatedSubaccounts != 1 {
t.Fatalf("re-provision created a SECOND resource (%d) — not idempotent", fake.CreatedSubaccounts)
}
if d2.User == "" || d2.Type != "shared" {
t.Fatalf("idempotent descriptor wrong: %+v", d2)
}
}
// Scenario D — a provisioning error surfaces; nothing is recorded (fail-closed). Companion: the caller must
// not mark offsite provisioned — modelled here by asserting no descriptor + no stored password on error.
func TestProvision_FailClosed(t *testing.T) {
p, fake, st := newTestProvisioner(t)
fake.FailCreate = errors.New("hetzner 500")
d, err := p.ProvisionOffsite(context.Background(), "cust-d", Input{Enabled: true, Type: "shared", QuotaGB: 10})
if err == nil {
t.Fatal("a create failure must return an error (fail-closed)")
}
if d != nil {
t.Fatalf("no descriptor may be returned on error, got %+v", d)
}
if _, cerr := st.ConsumeOneTimeSecret("cust-d"); cerr != sql.ErrNoRows {
t.Fatal("no one-time password may be stored on a failed provision")
}
// action-failure path (create ok, action errors) is also fail-closed
fake.FailCreate = nil
fake.FailAction = true
if _, err := p.ProvisionOffsite(context.Background(), "cust-d2", Input{Enabled: true, Type: "dedicated", BoxType: "bx11"}); err == nil {
t.Fatal("a failed create-action must return an error")
}
}
// Scenario E — the one-time password is consumable exactly once.
func TestOneTimeSecret_ConsumedOnce(t *testing.T) {
_, _, st := newTestProvisioner(t)
if err := st.SaveOneTimeSecret("cust-e", "secretpw"); err != nil {
t.Fatal(err)
}
got, err := st.ConsumeOneTimeSecret("cust-e")
if err != nil || got != "secretpw" {
t.Fatalf("first consume: got %q err %v", got, err)
}
if _, err := st.ConsumeOneTimeSecret("cust-e"); err != sql.ErrNoRows {
t.Fatalf("second consume must be ErrNoRows, got %v", err)
}
if _, err := st.ConsumeOneTimeSecret("never-provisioned"); err != sql.ErrNoRows {
t.Fatalf("absent consume must be ErrNoRows, got %v", err)
}
}
// Disable → {Enabled:false}, no deprovision (no API delete).
func TestProvision_DisableNoDeprovision(t *testing.T) {
p, fake, _ := newTestProvisioner(t)
d, err := p.ProvisionOffsite(context.Background(), "cust-f", Input{Enabled: false})
if err != nil || d == nil || d.Enabled {
t.Fatalf("disable must return {enabled:false}, got %+v err %v", d, err)
}
if fake.DeletedSubaccounts != 0 || fake.DeletedBoxes != 0 {
t.Fatal("disable must NOT deprovision (data-loss guard)")
}
}
+52
View File
@@ -416,6 +416,24 @@ func (s *Store) migrate() error {
return err
}
// offsite provisioning (SLICE 1): the ONE-TIME transient storage-box/subaccount password. The hub
// generates it at provision time, delivers it to the controller EXACTLY ONCE (consume endpoint), then
// it is dead — the controller installs its own key + the hub resets the box password. It is transient
// custody, NOT the ConfigJSON (which is served every pull). One row per customer; consumed_at marks it
// spent. Never logged, never in any served config. (No hub-side at-rest cipher exists; the DB file is
// 0600 and the value is short-lived + single-use.)
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS one_time_secrets (
customer_id TEXT PRIMARY KEY,
value TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
consumed_at DATETIME
);
`)
if err != nil {
return err
}
return nil
}
@@ -832,6 +850,40 @@ func (s *Store) GetCustomerConfig(customerID string) (*CustomerConfig, error) {
return &cfg, nil
}
// SaveOneTimeSecret stores (last-write-wins) the one-time transient offsite password for a customer,
// resetting the consumed flag (a fresh provision supersedes any prior unconsumed value). Never logged.
func (s *Store) SaveOneTimeSecret(customerID, value string) error {
_, err := s.db.Exec(`
INSERT INTO one_time_secrets (customer_id, value, created_at, consumed_at)
VALUES (?, ?, datetime('now'), NULL)
ON CONFLICT(customer_id) DO UPDATE SET value = excluded.value, created_at = datetime('now'), consumed_at = NULL`,
customerID, value)
return err
}
// ConsumeOneTimeSecret returns the customer's one-time offsite password and marks it consumed in the SAME
// transaction (single use). A second call — or a call when none is stored — returns ("", sql.ErrNoRows).
// The value is never logged.
func (s *Store) ConsumeOneTimeSecret(customerID string) (string, error) {
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
var value string
err = tx.QueryRow(`SELECT value FROM one_time_secrets WHERE customer_id = ? AND consumed_at IS NULL`, customerID).Scan(&value)
if err != nil {
return "", err // sql.ErrNoRows when absent OR already consumed
}
if _, err := tx.Exec(`UPDATE one_time_secrets SET consumed_at = datetime('now') WHERE customer_id = ?`, customerID); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return value, nil
}
// ListCustomerConfigs returns all customer configurations ordered by ID.
func (s *Store) ListCustomerConfigs() ([]CustomerConfig, error) {
rows, err := s.db.Query(`
+46
View File
@@ -8,11 +8,13 @@ import (
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"time"
cfClient "gitea.dooplex.hu/admin/felhom-hub/internal/cloudflare"
"gitea.dooplex.hu/admin/felhom-hub/internal/configgen"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
@@ -456,6 +458,13 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) {
ConfigJSON: configJSON,
}
// Offsite provisioning (fail-closed): a provisioning error must NOT save a half-enabled config.
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
return
}
if err := s.store.SaveCustomerConfig(cfg); err != nil {
s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -512,6 +521,12 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
cfg.Email = strings.TrimSpace(r.FormValue("email"))
cfg.ConfigJSON = buildConfigJSON(r)
if err := s.applyOffsite(r.Context(), r, cfg); err != nil {
s.logger.Printf("[ERROR] offsite provision for %s: %v", customerID, err)
http.Error(w, "Offsite provisioning failed: "+err.Error(), http.StatusBadGateway)
return
}
if err := s.store.SaveCustomerConfig(cfg); err != nil {
s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
@@ -791,6 +806,37 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew
}
// buildConfigJSON builds the config_json from optional form fields.
// applyOffsite provisions the offsite tier (if enabled in the form) and merges the NON-SECRET descriptor
// into cfg.ConfigJSON. Fail-closed: on any provisioning error it returns the error and leaves cfg.ConfigJSON
// unchanged — the caller must NOT save. When offsite is unchecked, the offsite key is naturally absent from
// the freshly-built ConfigJSON (disabled by omission; the Hetzner resource is NOT deprovisioned this slice).
func (s *Server) applyOffsite(ctx context.Context, r *http.Request, cfg *store.CustomerConfig) error {
if v := r.FormValue("offsite_enabled"); v != "on" && v != "true" {
return nil // not enabled → disabled by omission
}
if s.offsite == nil {
return fmt.Errorf("offsite provisioning is not configured on this hub (no Hetzner token)")
}
in := offsite.Input{
Enabled: true,
Type: strings.TrimSpace(r.FormValue("offsite_type")),
BoxType: strings.TrimSpace(r.FormValue("offsite_box_type")),
}
if q := strings.TrimSpace(r.FormValue("offsite_quota_gb")); q != "" {
in.QuotaGB, _ = strconv.Atoi(q)
}
d, err := s.offsite.ProvisionOffsite(ctx, cfg.CustomerID, in)
if err != nil {
return err
}
merged, err := offsite.MergeDescriptor(cfg.ConfigJSON, d)
if err != nil {
return err
}
cfg.ConfigJSON = merged
return nil
}
func buildConfigJSON(r *http.Request) string {
overrides := make(map[string]interface{})
+6
View File
@@ -18,6 +18,7 @@ import (
"gitea.dooplex.hu/admin/felhom-hub/internal/assets"
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
@@ -57,6 +58,7 @@ type Server struct {
templateFetcher *TemplateFetcher
assetsMgr *assets.Manager
gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns
offsite *offsite.Provisioner // optional; enables Hetzner offsite provisioning (SLICE 1)
sessions map[string]*hubSession
sessionsMu sync.RWMutex
@@ -142,6 +144,10 @@ func (s *Server) SetAssetManager(am *assets.Manager) {
s.assetsMgr = am
}
// SetOffsiteProvisioner enables Hetzner offsite provisioning (optional). Without it, saving a config with
// offsite enabled returns an error (offsite not configured on this hub).
func (s *Server) SetOffsiteProvisioner(p *offsite.Provisioner) { s.offsite = p }
// SetGiteaClient enables the Day-0 artifact version dropdowns (optional). Without it the artifact form
// degrades to manual text entry.
func (s *Server) SetGiteaClient(c *gitea.Client) {
@@ -103,6 +103,40 @@
</div>
</details>
<details class="card" {{if index .Overrides "offsite"}}open{{end}}>
<summary><h2 style="display:inline">Offsite backup</h2></summary>
<div class="form-grid" style="margin-top: 1rem;">
<div class="form-group">
<label><input type="checkbox" name="offsite_enabled"
{{with .Overrides}}{{with index . "offsite"}}{{if index . "enabled"}}checked{{end}}{{end}}{{end}}>
Enable offsite (provisions a Hetzner Storage Box on save)</label>
</div>
<div class="form-group">
<label for="offsite_type">Type</label>
<select id="offsite_type" name="offsite_type">
<option value="shared" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "type") "shared"}}selected{{end}}{{end}}{{end}}>Shared (sub-account on the pool box)</option>
<option value="dedicated" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "type") "dedicated"}}selected{{end}}{{end}}{{end}}>Dedicated (own box)</option>
</select>
</div>
<div class="form-group">
<label for="offsite_quota_gb">Soft-quota GB (shared)</label>
<input type="number" id="offsite_quota_gb" name="offsite_quota_gb"
value="{{with .Overrides}}{{with index . "offsite"}}{{with index . "quota_gb"}}{{.}}{{end}}{{end}}{{end}}" placeholder="e.g. 50">
</div>
<div class="form-group">
<label for="offsite_box_type">Box type (dedicated)</label>
<select id="offsite_box_type" name="offsite_box_type">
<option value="bx11" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx11"}}selected{{end}}{{end}}{{end}}>bx11 (1 TB)</option>
<option value="bx21" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx21"}}selected{{end}}{{end}}{{end}}>bx21 (5 TB)</option>
<option value="bx31" {{with .Overrides}}{{with index . "offsite"}}{{if eq (index . "box_type") "bx31"}}selected{{end}}{{end}}{{end}}>bx31 (10 TB)</option>
</select>
</div>
</div>
{{with .Overrides}}{{with index . "offsite"}}{{if index . "host"}}
<p class="form-hint" style="margin-top:.5rem">Provisioned: {{index . "user"}}@{{index . "host"}}:{{index . "repo_path"}} — the transient password is delivered to the controller once (never shown here).</p>
{{end}}{{end}}{{end}}
</details>
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
<button type="submit" class="btn">{{if .IsNew}}Create Configuration{{else}}Save Changes{{end}}</button>
<a href="{{if .IsNew}}/configs{{else}}/customers/{{.Config.CustomerID}}{{end}}" class="btn btn-outline">Cancel</a>