From 44ec06b50fa5c79d7be7156522c1638c96521030 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 9 Jul 2026 18:38:24 +0200 Subject: [PATCH] =?UTF-8?q?hub=20v0.37.0:=20offsite=20provisioning=20SLICE?= =?UTF-8?q?=201=20=E2=80=94=20Cloud-API=20client=20+=20provisioning=20core?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CONTEXT.md | 12 + hub/CHANGELOG.md | 35 +++ hub/README.md | 17 ++ hub/cmd/hub/main.go | 20 ++ hub/internal/api/handler.go | 3 + hub/internal/api/offsite.go | 31 ++ hub/internal/api/offsite_test.go | 52 ++++ hub/internal/hetznerapi/fake.go | 192 ++++++++++++ hub/internal/hetznerapi/hetznerapi.go | 319 ++++++++++++++++++++ hub/internal/hetznerapi/hetznerapi_test.go | 99 ++++++ hub/internal/offsite/offsite.go | 208 +++++++++++++ hub/internal/offsite/offsite_test.go | 152 ++++++++++ hub/internal/store/store.go | 52 ++++ hub/internal/web/configs.go | 46 +++ hub/internal/web/server.go | 8 +- hub/internal/web/templates/config_form.html | 34 +++ 16 files changed, 1279 insertions(+), 1 deletion(-) create mode 100644 hub/internal/api/offsite.go create mode 100644 hub/internal/api/offsite_test.go create mode 100644 hub/internal/hetznerapi/fake.go create mode 100644 hub/internal/hetznerapi/hetznerapi.go create mode 100644 hub/internal/hetznerapi/hetznerapi_test.go create mode 100644 hub/internal/offsite/offsite.go create mode 100644 hub/internal/offsite/offsite_test.go diff --git a/CONTEXT.md b/CONTEXT.md index c6626ed..66e06b5 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 — 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=`, 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 diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index a2ba1b2..d32b974 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -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=)** (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` + diff --git a/hub/README.md b/hub/README.md index 76a95c9..272e6cc 100644 --- a/hub/README.md +++ b/hub/README.md @@ -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=` → 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 diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 9c0b8a5..11f766a 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -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() diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index df88d55..0fab63d 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -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) diff --git a/hub/internal/api/offsite.go b/hub/internal/api/offsite.go new file mode 100644 index 0000000..d0a47c3 --- /dev/null +++ b/hub/internal/api/offsite.go @@ -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 +} diff --git a/hub/internal/api/offsite_test.go b/hub/internal/api/offsite_test.go new file mode 100644 index 0000000..70092ba --- /dev/null +++ b/hub/internal/api/offsite_test.go @@ -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) + } +} diff --git a/hub/internal/hetznerapi/fake.go b/hub/internal/hetznerapi/fake.go new file mode 100644 index 0000000..051f82c --- /dev/null +++ b/hub/internal/hetznerapi/fake.go @@ -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 +} diff --git a/hub/internal/hetznerapi/hetznerapi.go b/hub/internal/hetznerapi/hetznerapi.go new file mode 100644 index 0000000..c6237ec --- /dev/null +++ b/hub/internal/hetznerapi/hetznerapi.go @@ -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) +} diff --git a/hub/internal/hetznerapi/hetznerapi_test.go b/hub/internal/hetznerapi/hetznerapi_test.go new file mode 100644 index 0000000..131bb3f --- /dev/null +++ b/hub/internal/hetznerapi/hetznerapi_test.go @@ -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) + } +} diff --git a/hub/internal/offsite/offsite.go b/hub/internal/offsite/offsite.go new file mode 100644 index 0000000..d82c731 --- /dev/null +++ b/hub/internal/offsite/offsite.go @@ -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"` // .your-storagebox.de (dedicated) / -subN… (shared) + User string `json:"user,omitempty"` + Port int `json:"port,omitempty"` // 23 + RepoPath string `json:"repo_path,omitempty"` // /home/ + 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 +} diff --git a/hub/internal/offsite/offsite_test.go b/hub/internal/offsite/offsite_test.go new file mode 100644 index 0000000..68b693c --- /dev/null +++ b/hub/internal/offsite/offsite_test.go @@ -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)") + } +} diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 3e99f91..177bde0 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -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(` diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 4f6c810..6351628 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -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{}) diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index f2e3065..180d0f5 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -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" ) @@ -56,7 +57,8 @@ type Server struct { versionChecker *VersionChecker templateFetcher *TemplateFetcher assetsMgr *assets.Manager - gitea *gitea.Client // optional; enables the Day-0 artifact version dropdowns + 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) { diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index 2b56e49..40f94bc 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -103,6 +103,40 @@ +
+

Offsite backup

+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ {{with .Overrides}}{{with index . "offsite"}}{{if index . "host"}} +

Provisioned: {{index . "user"}}@{{index . "host"}}:{{index . "repo_path"}} — the transient password is delivered to the controller once (never shown here).

+ {{end}}{{end}}{{end}} +
+
Cancel