17cc67f7cd
F4: ReissueCredentials — explicit operator recovery for consumed-password dead-ends; resets the labelled resource's password (exactly-1 guard, red-proofed), stores a fresh one-time secret, bumps ConfigVersion. New hetznerapi.ResetBoxPassword for the dedicated path. F2: host-key scan retry-with-backoff (~60s ladder, red-proofed) — first save survives fresh-subaccount DNS lag. F5: config form disables submits + shows an in-flight notice (the re-click bait that caused live F1). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
321 lines
13 KiB
Go
321 lines
13 KiB
Go
// 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"
|
|
"time"
|
|
|
|
"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)
|
|
// HostFingerprint is the box's SSH host-key fingerprint (SHA256:…), captured at provision so the
|
|
// controller VERIFIES the box identity instead of blind-TOFU (SLICE 2). Non-secret.
|
|
HostFingerprint string `json:"host_fingerprint,omitempty"`
|
|
}
|
|
|
|
// HostKeyScanner returns a box's SSH host-key fingerprint (SHA256:…). Seam'd so tests inject a fake.
|
|
type HostKeyScanner interface {
|
|
Fingerprint(ctx context.Context, host string, port int) (string, error)
|
|
}
|
|
|
|
// 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
|
|
Scanner HostKeyScanner // captures the box host-key fingerprint (fail-closed if nil/scan-fails)
|
|
PoolBoxID int64 // the shared-pool storage-box id (e.g. 611421)
|
|
Location string // dedicated-box location, e.g. "fsn1"
|
|
Logger *log.Logger
|
|
// ScanBackoff is the retry schedule for the host-key scan (F2: a fresh sub-account's DNS name lags
|
|
// creation by seconds-to-a-minute, so the first scan typically fails with "no such host"). nil → the
|
|
// default ~60s ladder. Tests inject zeros. The total must fit inside applyOffsite's 3-min detached ctx.
|
|
ScanBackoff []time.Duration
|
|
}
|
|
|
|
// defaultScanBackoff: 5 retries, ~60s total — sized to the observed DNS propagation lag.
|
|
var defaultScanBackoff = []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second, 16 * time.Second, 30 * time.Second}
|
|
|
|
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
|
|
}
|
|
var d *Descriptor
|
|
var err error
|
|
switch in.Type {
|
|
case "shared":
|
|
d, err = p.provisionShared(ctx, customerID, in)
|
|
case "dedicated":
|
|
d, err = p.provisionDedicated(ctx, customerID, in)
|
|
default:
|
|
return nil, fmt.Errorf("offsite: unknown type %q (want shared|dedicated)", in.Type)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Capture the box host-key fingerprint so the controller verifies (no blind TOFU). Fail-closed: don't
|
|
// serve a descriptor the controller can't verify. Applies to both fresh and idempotent paths.
|
|
if p.Scanner == nil {
|
|
return nil, fmt.Errorf("offsite: no host-key scanner configured (cannot capture the pin)")
|
|
}
|
|
port := d.Port
|
|
if port == 0 {
|
|
port = sftpPort
|
|
}
|
|
fp, err := p.scanWithRetry(ctx, d.Host, port)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("offsite: host-key scan %s: %w", d.Host, err)
|
|
}
|
|
d.HostFingerprint = fp
|
|
return d, nil
|
|
}
|
|
|
|
// scanWithRetry retries the host-key scan on failure (F2: fresh-resource DNS lag). Fail-closed past the
|
|
// budget; ctx cancellation aborts between attempts.
|
|
func (p *Provisioner) scanWithRetry(ctx context.Context, host string, port int) (string, error) {
|
|
backoff := p.ScanBackoff
|
|
if backoff == nil {
|
|
backoff = defaultScanBackoff
|
|
}
|
|
fp, err := p.Scanner.Fingerprint(ctx, host, port)
|
|
for i := 0; err != nil && i < len(backoff); i++ {
|
|
p.logf("[offsite] host-key scan %s failed (attempt %d/%d, retrying in %s): %v", host, i+1, len(backoff)+1, backoff[i], err)
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-time.After(backoff[i]):
|
|
}
|
|
fp, err = p.Scanner.Fingerprint(ctx, host, port)
|
|
}
|
|
return fp, err
|
|
}
|
|
|
|
// ReissueCredentials resets the customer's offsite credential and stores a FRESH one-time password — the
|
|
// EXPLICIT operator recovery for a consumed-password dead-end (a fresh guest at DR, or a
|
|
// consumed-but-failed install). It is NOT implicit rotation: ProvisionOffsite never calls this. Scoped
|
|
// hard: the reset targets ONLY the resource labelled `felhom-customer=<id>`, and refuses unless the label
|
|
// lookup finds exactly one. The password value is never logged (the action is).
|
|
func (p *Provisioner) ReissueCredentials(ctx context.Context, customerID, typ string) error {
|
|
pw, err := genPassword()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch typ {
|
|
case "shared":
|
|
if p.PoolBoxID == 0 {
|
|
return fmt.Errorf("offsite: no shared pool box configured")
|
|
}
|
|
subs, err := p.API.ListSubaccounts(ctx, p.PoolBoxID, customerSelector(customerID))
|
|
if err != nil {
|
|
return fmt.Errorf("offsite: reissue lookup: %w", err)
|
|
}
|
|
if len(subs) != 1 {
|
|
return fmt.Errorf("offsite: reissue needs exactly 1 sub-account labelled for %s, found %d — refusing", customerID, len(subs))
|
|
}
|
|
act, err := p.API.ResetSubaccountPassword(ctx, p.PoolBoxID, subs[0].ID, pw)
|
|
if err != nil {
|
|
return fmt.Errorf("offsite: reset sub-account password: %w", err)
|
|
}
|
|
if err := p.API.WaitAction(ctx, act); err != nil {
|
|
return fmt.Errorf("offsite: reset action: %w", err)
|
|
}
|
|
p.logf("[offsite] re-issued shared credentials for %s (subaccount %d)", customerID, subs[0].ID)
|
|
case "dedicated":
|
|
boxes, err := p.API.ListStorageBoxes(ctx, customerSelector(customerID))
|
|
if err != nil {
|
|
return fmt.Errorf("offsite: reissue lookup: %w", err)
|
|
}
|
|
if len(boxes) != 1 {
|
|
return fmt.Errorf("offsite: reissue needs exactly 1 box labelled for %s, found %d — refusing", customerID, len(boxes))
|
|
}
|
|
act, err := p.API.ResetBoxPassword(ctx, boxes[0].ID, pw)
|
|
if err != nil {
|
|
return fmt.Errorf("offsite: reset box password: %w", err)
|
|
}
|
|
if err := p.API.WaitAction(ctx, act); err != nil {
|
|
return fmt.Errorf("offsite: reset action: %w", err)
|
|
}
|
|
p.logf("[offsite] re-issued dedicated credentials for %s (box %d)", customerID, boxes[0].ID)
|
|
default:
|
|
return fmt.Errorf("offsite: reissue: unknown type %q (want shared|dedicated)", typ)
|
|
}
|
|
if err := p.Store.SaveOneTimeSecret(customerID, pw); err != nil {
|
|
return fmt.Errorf("offsite: store re-issued one-time password: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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
|
|
}
|