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