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:
2026-07-09 23:57:12 +02:00
parent cb26dc7e83
commit fad5573dd3
10 changed files with 543 additions and 0 deletions
+40
View File
@@ -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 {
+8
View File
@@ -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}}