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:
@@ -537,6 +537,50 @@ func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, cust
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=updated", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleOffsiteReissue (F4) resets the customer's offsite credential and stores a fresh one-time password —
|
||||
// the explicit operator recovery for a consumed-password dead-end (fresh-guest DR, consumed-but-failed
|
||||
// install). Scoped to the resource labelled for THIS customer (the provisioner refuses unless exactly one).
|
||||
// The config is re-saved unchanged so ConfigVersion bumps → the stuck guest's next refresh re-runs the
|
||||
// bridge, which consumes the fresh password. The password value is never logged or rendered.
|
||||
func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if s.offsite == nil {
|
||||
http.Error(w, "Offsite provisioning is not configured on this hub", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
cfg, err := s.store.GetCustomerConfig(customerID)
|
||||
if err != nil || cfg == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var overrides struct {
|
||||
Offsite struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Type string `json:"type"`
|
||||
} `json:"offsite"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(cfg.ConfigJSON), &overrides)
|
||||
if !overrides.Offsite.Enabled || overrides.Offsite.Type == "" {
|
||||
http.Error(w, "No provisioned offsite tier for this customer", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Same detached-ctx discipline as applyOffsite (F1): once the reset starts, reset→store must complete.
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.offsite.ReissueCredentials(ctx, customerID, overrides.Offsite.Type); err != nil {
|
||||
s.logger.Printf("[ERROR] offsite reissue for %s: %v", customerID, err)
|
||||
http.Error(w, "Offsite credential re-issue failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
// Re-save unchanged → ConfigVersion bump → the customer's controller re-pulls + re-runs the bridge.
|
||||
if err := s.store.SaveCustomerConfig(cfg); err != nil {
|
||||
s.logger.Printf("[ERROR] offsite reissue for %s: config bump failed: %v", customerID, err)
|
||||
http.Error(w, "Credential re-issued but the config bump failed — save the config once to trigger the pickup", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite credentials re-issued for %s (fresh one-time password stored; ConfigVersion bumped)", customerID)
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleConfigDelete deletes a customer config.
|
||||
func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
if err := s.store.DeleteCustomerConfig(customerID); err != nil {
|
||||
|
||||
@@ -343,6 +343,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
s.handleConfigEditForm(w, r, customerID)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-reissue"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/offsite-reissue")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleOffsiteReissue(w, r, customerID)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/preview"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/preview")
|
||||
|
||||
@@ -134,14 +134,39 @@
|
||||
</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>
|
||||
<!-- F4: explicit operator recovery for a consumed-password dead-end (fresh-guest DR).
|
||||
Rides the parent form (nested forms are invalid HTML) via formaction; the _csrf field
|
||||
submits with it. Resets the box credential + stages a fresh one-time password. -->
|
||||
<button type="submit" class="btn btn-outline" style="margin-top:.5rem"
|
||||
formaction="/configs/{{$.Config.CustomerID}}/offsite-reissue" formmethod="POST"
|
||||
onclick="return confirm('Re-issue the offsite credentials?\n\nThe box password is reset and a fresh one-time password is staged for the controller. Guests with a working installed key are unaffected (key-auth-first); a stuck fresh guest picks the new password up on its next config refresh.')">
|
||||
Re-issue offsite credentials</button>
|
||||
{{end}}{{end}}{{end}}
|
||||
</details>
|
||||
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 1rem;">
|
||||
<div style="margin-top: 1.5rem; display: flex; gap: 1rem; align-items: center;">
|
||||
<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>
|
||||
<span id="cfg-inflight" class="form-hint" style="display:none">Saving… offsite provisioning can take up to a minute — please do not click again.</span>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
// F5: the offsite save takes ~25–60s (create + action-wait + host-key scan). Without feedback
|
||||
// operators re-click, which used to strand the provision (live finding F1). Disable all submit
|
||||
// buttons + show the in-flight notice once a submit is underway (deferred a tick so the
|
||||
// clicked button's formaction still applies).
|
||||
(function () {
|
||||
var form = document.querySelector('form.config-form');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function () {
|
||||
document.getElementById('cfg-inflight').style.display = 'inline';
|
||||
var btns = form.querySelectorAll('button[type=submit]');
|
||||
setTimeout(function () {
|
||||
for (var i = 0; i < btns.length; i++) btns[i].disabled = true;
|
||||
}, 0);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<p>Felhom Hub {{hubVersion}} — Configuration Management</p>
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
{{if eq .Flash "created"}}Configuration created successfully.
|
||||
{{else if eq .Flash "updated"}}Configuration updated.
|
||||
{{else if eq .Flash "password_regenerated"}}Retrieval password regenerated.
|
||||
{{else if eq .Flash "offsite_reissued"}}Offsite credentials re-issued — a fresh one-time password is staged; the controller picks it up on its next config refresh.
|
||||
{{else if eq .Flash "blocked"}}Customer blocked — hidden from Dashboard.
|
||||
{{else if eq .Flash "unblocked"}}Customer unblocked — visible on Dashboard again.
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user