Files
felhom.eu/hub/internal/web/offsite_window.go
T

210 lines
8.7 KiB
Go

package web
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// The one-shot clean-up window on the customer page (R-1299). Until hub v0.174.0 the grant was an address only a
// script could call (`POST /offsite/window-grant/<customer>`), so on 2026-10-26 the operator had to mint a one-purpose
// token to grant demo-hp one window (D55). The customer page's Backup card now shows the window state and a form: an
// optional limit, a confirm step that names the customer and the limit, then the same route under the same rule
// (`wr(grpOffsite)` — a session, or a token with offsite:write), with the page's CSRF token. Pinned by
// offsite_window_test.go.
// eventWindowGrant records every grant and every cancel: who, which customer, which limit. Hub-origin, operator-only
// (notify.operatorOnlyEvents), severity info — recorded, never mailed. The raised-cap grant keeps its own warning
// mail as well (offsitekeys.EventWindowLargeGrant).
const eventWindowGrant = "offsite_window_grant"
// windowCardView is the customer page's clean-up window block.
type windowCardView struct {
WeeklyOn bool // the fleet-wide weekly window switch
Pending bool // a one-shot grant waits for the box's next off-site run
PendingMax int // its raised limit, 0 = the normal cap
Last *store.OffsiteWindow // the latest window, nil = never
CapKnown bool // the snapshot count is known, so the normal cap is
NormalCap int // offsitekeys.MaxRemove(snapshots): half, at least 5
Bound int // the highest limit the form accepts (windowGrantBound)
// Confirm is the second step: the page was opened with ?window=confirm; ConfirmLimit is the limit to confirm ("" =
// the normal cap), already checked — an invalid one renders the refusal instead (LimitError).
Confirm bool
ConfirmLimit string
LimitError string
}
// windowGrantBound is the highest raised limit the hub accepts for this customer: never above the store's own
// snapshot count when the box has measured it (a window cannot remove more than exist, so a larger number is a typo),
// and never above offsitekeys.MaxRemoveGrantCeiling. known=false: only the ceiling applies.
func windowGrantBound(card backupCardView) (bound int, known bool) {
bound = offsitekeys.MaxRemoveGrantCeiling
if card.OffsiteReported && card.StatsKnown && card.Snapshots > 0 && card.Snapshots < bound {
return card.Snapshots, true
}
return bound, false
}
// checkWindowLimit parses an optional limit: "" = the normal cap (0). The error is the sentence the page shows.
func checkWindowLimit(raw string, card backupCardView) (int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 {
return 0, fmt.Errorf("the limit must be a whole number of 1 or more, or empty for the normal cap")
}
if bound, known := windowGrantBound(card); n > bound {
if known {
return 0, fmt.Errorf("the limit %d is above the store's %d snapshot(s) — a window cannot remove more than exist", n, bound)
}
return 0, fmt.Errorf("the limit %d is above the ceiling %d", n, bound)
}
return n, nil
}
func (s *Server) customerBackupCard(customerID string) backupCardView {
c, _ := s.store.GetCustomer(customerID)
if c == nil {
return backupCardView{}
}
return reportBackupCard(c.ReportJSON)
}
// buildWindowCard resolves the block for the page (the confirm step reads ?window=confirm&window_max=N).
func (s *Server) buildWindowCard(customerID string, card backupCardView, q url.Values) windowCardView {
v := windowCardView{WeeklyOn: s.store.OffsiteWindowsEnabled()}
v.Pending, v.PendingMax = s.store.PendingOffsiteWindowGrant(customerID)
v.Last, _ = s.store.LatestOffsiteWindow(customerID)
if card.StatsKnown {
v.CapKnown, v.NormalCap = true, offsitekeys.MaxRemove(card.Snapshots)
}
v.Bound, _ = windowGrantBound(card)
if q.Get("window") == "confirm" {
if _, err := checkWindowLimit(q.Get("window_max"), card); err != nil {
v.LimitError = err.Error()
} else {
v.Confirm, v.ConfirmLimit = true, strings.TrimSpace(q.Get("window_max"))
}
}
return v
}
// actorOf names who made a request: the token's name, or the operator's session.
func actorOf(r *http.Request) string {
if t := requestToken(r); t != nil {
return "token " + t.Name
}
return "operator session"
}
// handleOffsiteWindowGrant serves POST /offsite/window-grant/<customer> and POST /offsite/windows-enabled
// (decision 68; R-833; R-1299). A script gets JSON as before; the page's form (from=page) gets a redirect with a flash.
//
// max_remove optional raised limit for this ONE window (bounded: windowGrantBound)
// cancel=1 remove a waiting grant instead
// confirm=1 required from the page: the form's second step
func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request, path string) {
if r.Method != http.MethodPost || s.offsiteWindowGrant == nil {
http.Error(w, "unavailable", http.StatusServiceUnavailable)
return
}
if path == "/offsite/windows-enabled" {
if err := s.offsiteWindowSwitch(r.FormValue("on") == "1"); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{\"ok\":true}\n"))
return
}
cid := strings.TrimPrefix(path, "/offsite/window-grant/")
page := r.FormValue("from") == "page"
answer := func(code int, flash, msg string) {
if page {
http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#tab=backup", http.StatusSeeOther)
return
}
if code >= 400 {
http.Error(w, msg, code)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("{\"ok\":true}\n"))
}
if cfg, _ := s.store.GetCustomerConfig(cid); cfg == nil {
answer(http.StatusNotFound, "window_unknown_customer", "no customer "+cid)
return
}
who := actorOf(r)
if r.FormValue("cancel") == "1" {
cancelled, err := s.store.CancelOffsiteWindowGrant(cid)
if err != nil {
answer(http.StatusInternalServerError, "window_failed", err.Error())
return
}
if !cancelled {
answer(http.StatusConflict, "window_none_waiting", "no grant is waiting for "+cid)
return
}
s.logger.Printf("[INFO] offsite window: the waiting grant for %s was CANCELLED by %s", cid, who)
s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: the waiting one-time window was cancelled (by %s).", who), map[string]any{"cancelled": true, "by": who})
answer(http.StatusOK, "window_cancelled", "")
return
}
if page && r.FormValue("confirm") != "1" {
answer(http.StatusBadRequest, "window_needs_confirm", "confirm=1 required")
return
}
limit, err := checkWindowLimit(r.FormValue("max_remove"), s.customerBackupCard(cid))
if err != nil {
s.logger.Printf("[WARN] offsite window: grant for %s REFUSED (%v) — asked by %s", cid, err, who)
answer(http.StatusBadRequest, "window_limit_refused", err.Error())
return
}
if limit > 0 {
if s.offsiteWindowGrantMax == nil {
answer(http.StatusServiceUnavailable, "window_failed", "a raised limit is unavailable")
return
}
err = s.offsiteWindowGrantMax(cid, limit)
} else {
err = s.offsiteWindowGrant(cid)
}
if err != nil {
answer(http.StatusBadRequest, "window_failed", err.Error())
return
}
lim := "the normal cap"
if limit > 0 {
lim = fmt.Sprintf("a limit of %d", limit)
}
s.logger.Printf("[INFO] offsite window: ONE clean-up window granted for %s with %s, by %s", cid, lim, who)
s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: one window granted with %s (by %s); the box uses it on its next off-site run.", lim, who),
map[string]any{"max_remove": limit, "by": who})
answer(http.StatusOK, "window_granted", "")
}
// emitWindowEvent WRITES the event row (the record the brief asks for) and then hands it to the dispatcher. The
// dispatcher only routes mail — it writes no row, and an info event routes nowhere — so the row is this function's job,
// as it is offsitekeys.Service.event's. Hub 0.174.0 called only the dispatcher and recorded nothing (seen live on
// tester-1 2026-10-27; the test then asserted the call, not the row). Pinned by the TestR1299_* row checks.
func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) {
b, _ := json.Marshal(details)
if _, err := s.store.SaveEvent(customerID, eventWindowGrant, "info", msg, string(b), "hub"); err != nil {
s.logger.Printf("[WARN] offsite window: saving the %s event for %s: %v", eventWindowGrant, customerID, err)
}
if s.emit != nil {
s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub")
}
}