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:
+12
@@ -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
|
||||
|
||||
@@ -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` +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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(`
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user