hub v0.41.0: OffsiteChecker (fill 90/95 + staleness >48h) + operator freeze lever (SLICE 4)
Sibling checker over the controller report's offsite object: quota-fill warn/crit + the silently-stuck staleness detector (escrowed-only, red-proofed; nil-safe on pre-v0.109 reports; same-second tie-guard). SetOffsiteFrozen flips ONLY readonly on the exactly-1 labelled sub-account (SSH preserved); Freeze/Unfreeze buttons — manual only, never automatic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -581,6 +581,46 @@ func (s *Server) handleOffsiteReissue(w http.ResponseWriter, r *http.Request, cu
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash=offsite_reissued", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// handleOffsiteFreeze (SLICE 4) freezes/unfreezes the customer's shared sub-account (readonly) — an
|
||||
// OPERATOR lever, never automatic (freezing also blocks prune, the customer's only way down from
|
||||
// over-quota). Shared model only; the exactly-1 label guard lives in the provisioner. Action logged,
|
||||
// no secrets involved.
|
||||
func (s *Server) handleOffsiteFreeze(w http.ResponseWriter, r *http.Request, customerID string, frozen bool) {
|
||||
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 != "shared" {
|
||||
http.Error(w, "Freeze applies to a provisioned SHARED offsite tier only", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := s.offsite.SetOffsiteFrozen(ctx, customerID, frozen); err != nil {
|
||||
s.logger.Printf("[ERROR] offsite freeze(%v) for %s: %v", frozen, customerID, err)
|
||||
http.Error(w, "Offsite freeze/unfreeze failed: "+err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] offsite frozen=%v (readonly) for %s (operator action)", frozen, customerID)
|
||||
flash := "offsite_frozen"
|
||||
if !frozen {
|
||||
flash = "offsite_unfrozen"
|
||||
}
|
||||
http.Redirect(w, r, "/customers/"+customerID+"?flash="+flash, 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 {
|
||||
|
||||
@@ -351,6 +351,14 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/offsite-freeze"):
|
||||
customerID := strings.TrimPrefix(path, "/configs/")
|
||||
customerID = strings.TrimSuffix(customerID, "/offsite-freeze")
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleOffsiteFreeze(w, r, customerID, r.FormValue("unfreeze") != "1")
|
||||
} 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")
|
||||
|
||||
@@ -141,6 +141,18 @@
|
||||
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>
|
||||
{{if eq (index . "type") "shared"}}
|
||||
<!-- SLICE 4: operator freeze lever (readonly on the sub-account) — MANUAL only, never
|
||||
automatic: freezing also blocks prune, the customer's only way down from over-quota. -->
|
||||
<button type="submit" class="btn btn-outline" style="margin-top:.5rem"
|
||||
formaction="/configs/{{$.Config.CustomerID}}/offsite-freeze" formmethod="POST"
|
||||
onclick="return confirm('Freeze the offsite storage (read-only)?\n\nNew backups AND prune will fail until unfrozen — use for runaway usage only.')">
|
||||
Freeze offsite (read-only)</button>
|
||||
<button type="submit" class="btn btn-outline" name="unfreeze" value="1" style="margin-top:.5rem"
|
||||
formaction="/configs/{{$.Config.CustomerID}}/offsite-freeze" formmethod="POST"
|
||||
onclick="return confirm('Unfreeze the offsite storage (read-write again)?')">
|
||||
Unfreeze offsite</button>
|
||||
{{end}}
|
||||
{{end}}{{end}}{{end}}
|
||||
</details>
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@
|
||||
{{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 "offsite_frozen"}}Offsite storage FROZEN (read-only) — new backups and prune will fail until unfrozen.
|
||||
{{else if eq .Flash "offsite_unfrozen"}}Offsite storage unfrozen — read-write restored.
|
||||
{{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