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:
2026-07-09 22:39:08 +02:00
parent ecf9185605
commit 17cc67f7cd
9 changed files with 321 additions and 3 deletions
+8
View File
@@ -20,6 +20,7 @@ type Fake struct {
CreatedSubaccounts int
CreatedBoxes int
ResetCalls int
BoxResetCalls int
DeletedSubaccounts int
DeletedBoxes int
@@ -175,6 +176,13 @@ func (f *Fake) ChangeType(_ context.Context, _ int64, _ string) (Action, error)
return f.newAction("change_type"), nil
}
func (f *Fake) ResetBoxPassword(_ context.Context, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.BoxResetCalls++
return f.newAction("reset_password"), nil
}
func (f *Fake) DeleteStorageBox(_ context.Context, boxID int64) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
+11
View File
@@ -96,6 +96,7 @@ type CloudAPI interface {
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)
ResetBoxPassword(ctx context.Context, boxID int64, password string) (Action, error)
DeleteStorageBox(ctx context.Context, boxID int64) (Action, error)
// WaitAction polls the action to "success" (bounded); errors on "error" or timeout.
@@ -270,6 +271,16 @@ func (c *Client) ChangeType(ctx context.Context, boxID int64, boxType string) (A
return out.Action, err
}
// ResetBoxPassword resets a dedicated box's (main-account) password. The password value is only ever in
// the request body — never logged by the caller (F4 re-issue).
func (c *Client) ResetBoxPassword(ctx context.Context, boxID int64, password string) (Action, error) {
var out struct {
Action Action `json:"action"`
}
err := c.do(ctx, http.MethodPost, fmt.Sprintf("/storage_boxes/%d/actions/reset_password", boxID), map[string]string{"password": password}, &out)
return out.Action, err
}
func (c *Client) DeleteStorageBox(ctx context.Context, boxID int64) (Action, error) {
var out struct {
Action Action `json:"action"`