hub v0.39.0: offsite hardening — F4 credential re-issue, F2 scan retry, F5 save UX
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
This commit is contained in:
@@ -16,6 +16,7 @@ import (
|
||||
"log"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
@@ -61,8 +62,15 @@ type Provisioner struct {
|
||||
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...)
|
||||
@@ -106,7 +114,7 @@ func (p *Provisioner) ProvisionOffsite(ctx context.Context, customerID string, i
|
||||
if port == 0 {
|
||||
port = sftpPort
|
||||
}
|
||||
fp, err := p.Scanner.Fingerprint(ctx, d.Host, port)
|
||||
fp, err := p.scanWithRetry(ctx, d.Host, port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("offsite: host-key scan %s: %w", d.Host, err)
|
||||
}
|
||||
@@ -114,6 +122,81 @@ func (p *Provisioner) ProvisionOffsite(ctx context.Context, customerID string, i
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user