diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index d2928c6f..e6e7ce8e 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,3 +1,22 @@ +## Unreleased (next: v0.174.0) — R-1299: the one-time clean-up window gets a button on the customer page + +- **R-1299:** the customer page's Backup card, in its off-site part, shows a „Clean-up window" block: the weekly + switch, the normal cap per window (half the snapshots, at least 5 — `offsitekeys.MaxRemove`), a waiting one-time + grant and its limit, and the last window (`#id`, time, result, count before → after, cap). A form takes an optional + limit; a confirm step (a GET that stores nothing) names the customer and the limit; the confirm posts to the SAME + route as before, `POST /offsite/window-grant/` (rule unchanged: `wr(grpOffsite)` — a session or an + offsite:write token), with the page's CSRF token, `from=page` and `confirm=1`, and answers with a redirect and a + flash. A waiting grant shows a „Cancel" button instead (`cancel=1`, `store.CancelOffsiteWindowGrant`). +- **New bound:** a raised limit above the store's own snapshot count (when the box has measured it) is refused — a + window cannot remove more than exist, so a larger number is a typo; the 500 ceiling stays (`windowGrantBound`). The + script path gets the same bound (400). +- Every grant and cancel: a log line (customer, limit, who — the token's name or „operator session") and an + operator-only info event `offsite_window_grant` (recorded, never mailed; `notify.operatorOnlyEvents`). The raised-cap + grant still sends its warning `offsite_window_large_grant` as well. An unknown customer is refused on both paths. +- `internal/web/offsite_window.go` (the handler moved out of `server.go`), `store.PendingOffsiteWindowGrant` (reads + without consuming), `store.LatestOffsiteWindow`. Tests `TestR1299_*` (five), red-proved by five mutations (the block + removed, the limit ignored, no bound, no confirm check, no cancel). + ## v0.173.0 (2026-10-27) — D53: a held app reaches the operator (`08` §10); R-1218: a keyed recovery package is never replaced by a key-less one — DEPLOYED on the operator's yes (image sha256:458bfb03…, from `29134c52`, CI 2418) - **R-972: Go 1.27.3** (`go.mod` + the builder image `golang:1.27.3-*` pinned by digest; released 2026-10-19 with a diff --git a/hub/internal/notify/dispatcher.go b/hub/internal/notify/dispatcher.go index 6ec7cdb8..e46da93e 100644 --- a/hub/internal/notify/dispatcher.go +++ b/hub/internal/notify/dispatcher.go @@ -802,6 +802,9 @@ var operatorOnlyEvents = map[string]bool{ "offsite_prune_guard_refused": true, // R-833 (v0.129.0): the operator raised ONE window's removal cap — an operator act, logged. "offsite_window_large_grant": true, + // R-1299 (v0.174.0): every grant and cancel of the one-shot window, with who and which limit — an operator act, + // recorded (info), never mailed. Hub-origin only, like the large grant. + "offsite_window_grant": true, // R-895 slice 2 (v0.158.0): the hub's own snapshot listing — files gone outside a clean-up window, files no box run // explains. Custody facts about a repository the household cannot inspect; listed in the SAME commit that mints // them. Hub-origin only: deliberately NOT in api.allowedEventTypes, so no box can POST one. diff --git a/hub/internal/store/offsite_keys.go b/hub/internal/store/offsite_keys.go index 3f00ae06..9d87ca0d 100644 --- a/hub/internal/store/offsite_keys.go +++ b/hub/internal/store/offsite_keys.go @@ -125,6 +125,20 @@ func (s *Store) GetOffsiteWindow(id int64) (*OffsiteWindow, error) { return &w, nil } +// LatestOffsiteWindow returns the customer's most recent window row, or (nil, nil) when there is none (R-1299: the +// customer page shows its result next to the button). +func (s *Store) LatestOffsiteWindow(customerID string) (*OffsiteWindow, error) { + var id int64 + err := s.db.QueryRow(`SELECT id FROM offsite_windows WHERE customer_id = ? ORDER BY id DESC LIMIT 1`, customerID).Scan(&id) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return s.GetOffsiteWindow(id) +} + // LastOffsiteWindowOpened returns when the customer's most recent window was opened (zero = never). func (s *Store) LastOffsiteWindowOpened(customerID string) time.Time { var v sql.NullString @@ -203,6 +217,32 @@ func (s *Store) TakeOffsiteWindowGrant(customerID string) (granted bool, maxRemo return true, maxRemove } +// PendingOffsiteWindowGrant reads a one-shot grant WITHOUT consuming it (R-1299: the page says a grant is waiting). +// maxRemove is the raised cap, 0 = the default cap. +func (s *Store) PendingOffsiteWindowGrant(customerID string) (pending bool, maxRemove int) { + v := s.getSetting("offsite_window_grant:" + customerID) + switch { + case v == "1": + return true, 0 + case strings.HasPrefix(v, "max:"): + n, err := strconv.Atoi(strings.TrimPrefix(v, "max:")) + if err != nil || n <= 0 { + n = 0 + } + return true, n + } + return false, 0 +} + +// CancelOffsiteWindowGrant removes a waiting one-shot grant (R-1299: the operator's undo before the box uses it). +// cancelled is false when none was waiting. +func (s *Store) CancelOffsiteWindowGrant(customerID string) (cancelled bool, err error) { + if pending, _ := s.PendingOffsiteWindowGrant(customerID); !pending { + return false, nil + } + return true, s.setSetting("offsite_window_grant:"+customerID, "") +} + // ForceOffsiteWindowDueForTest back-dates a window's closes_by. TEST-ONLY. func (s *Store) ForceOffsiteWindowDueForTest(id int64) error { _, err := s.db.Exec(`UPDATE offsite_windows SET closes_by = datetime('now', '-1 minute') WHERE id = ?`, id) diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index 5f735b8b..909b4f3b 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -305,7 +305,9 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c // BackupCard (R-331) is resolved in Go, not in the template: the card must distinguish // "no data reported" from "measured empty" from "never measured", and a {{if}} chain over // Report's float64s cannot. See backup_card.go. - BackupCard backupCardView + BackupCard backupCardView + // WindowCard (R-1299) is the one-shot clean-up window block inside the Backup card's off-site part. + WindowCard windowCardView OverallStatus string HostCause string // v0.53.0 roll-up: "" or "host down|stale|pending: " @@ -486,6 +488,7 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Customer: customer, Report: report, BackupCard: backupCard, + WindowCard: s.buildWindowCard(customerID, backupCard, r.URL.Query()), OverallStatus: overallStatus, HostCause: hostCause, diff --git a/hub/internal/web/offsite_window.go b/hub/internal/web/offsite_window.go new file mode 100644 index 00000000..380ef399 --- /dev/null +++ b/hub/internal/web/offsite_window.go @@ -0,0 +1,203 @@ +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/`), 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/ 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+"#offsite-window", 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", "") +} + +func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) { + if s.emit == nil { + return + } + b, _ := json.Marshal(details) + s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub") +} diff --git a/hub/internal/web/offsite_window_test.go b/hub/internal/web/offsite_window_test.go new file mode 100644 index 00000000..d61faff0 --- /dev/null +++ b/hub/internal/web/offsite_window_test.go @@ -0,0 +1,214 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-hub/internal/store" +) + +// R-1299: the one-shot clean-up window on the customer page. The page renders the block per gate branch; a grant +// through the form (session + CSRF + the confirm step) stores ONE grant with its limit; the box's next window uses it +// up; a limit above the store's snapshot count is refused; a POST without CSRF never reaches the store. + +const windowReport = `{"customer_id":"acme","customer_name":"Acme","offsite":{"enabled":true,"state":"active", + "last_status":"ok","last_success":"2026-10-27T03:17:00Z","snapshot_count":40,"stats_known":true}}` + +type windowEvent struct{ customer, typ, sev, msg, details string } + +// windowRig: a password-protected server (so RequireAuth and the CSRF gate run) with one customer whose box reported +// 40 off-site snapshots, the grant functions wired as main.go wires them (the store's), and the events captured. +func windowRig(t *testing.T, report string) (*Server, *store.Store, http.Handler, *[]windowEvent) { + t.Helper() + s, st := serverWithPassword(t, "op-pass") + if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "acme", CustomerName: "Acme", Domain: "acme.hu", + RetrievalPassword: "pw", APIKey: "k", Status: "active"}); err != nil { + t.Fatal(err) + } + if report != "" { + if err := st.SaveReport("acme", []byte(report)); err != nil { + t.Fatal(err) + } + } + s.SetOffsiteWindowAdmin(st.GrantOffsiteWindowOnce, st.SetOffsiteWindowsEnabled) + s.SetOffsiteWindowLargeGrant(st.GrantOffsiteWindowOnceMax) + var evs []windowEvent + s.SetEventEmitter(func(c, typ, sev, msg, det, _ string) { evs = append(evs, windowEvent{c, typ, sev, msg, det}) }) + s.sessionsMu.Lock() + s.sessions["sess1"] = &hubSession{expiresAt: time.Now().Add(time.Hour), csrfToken: "tok1"} + s.sessionsMu.Unlock() + return s, st, s.Handler(), &evs +} + +func windowPost(h http.Handler, form url.Values, csrf bool) *httptest.ResponseRecorder { + if csrf { + form.Set("_csrf", "tok1") + } + r := httptest.NewRequest(http.MethodPost, "/offsite/window-grant/acme", strings.NewReader(form.Encode())) + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func windowPage(t *testing.T, h http.Handler, query string) string { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/customers/acme"+query, nil) + r.AddCookie(&http.Cookie{Name: SessionCookieName, Value: "sess1"}) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("GET /customers/acme%s: %d", query, w.Code) + } + return w.Body.String() +} + +// The page, per branch of its gates (the seam-wiring rule: a handler test alone proves nothing about reachability). +// RED-PROOF: remove the block from customer_unified.html → the first case fails; drop `{{if .Pending}}` → the third. +func TestR1299_ThePageShowsTheWindowBlockPerBranch(t *testing.T) { + _, st, h, _ := windowRig(t, windowReport) + + page := windowPage(t, h, "") + for _, want := range []string{`id="offsite-window"`, `id="window-form"`, `name="window" value="confirm"`, "Normal cap per window", + "20 (half the snapshots", "at most 40", "none waiting", "never"} { + if !strings.Contains(page, want) { + t.Errorf("the page lacks %q", want) + } + } + + page = windowPage(t, h, "?window=confirm&window_max=33") + for _, want := range []string{`id="window-confirm"`, "a limit of 33", `name="confirm" value="1"`, `name="_csrf" value="tok1"`, `name="from" value="page"`} { + if !strings.Contains(page, want) { + t.Errorf("the confirm step lacks %q", want) + } + } + if !strings.Contains(windowPage(t, h, "?window=confirm&window_max=41"), "above the store's 40 snapshot(s)") { + t.Error("a limit above the snapshot count must render the refusal at the confirm step, not the confirm") + } + + if err := st.GrantOffsiteWindowOnceMax("acme", 30); err != nil { + t.Fatal(err) + } + page = windowPage(t, h, "") + if !strings.Contains(page, "limit 30") || !strings.Contains(page, `name="cancel" value="1"`) || strings.Contains(page, `id="window-form"`) { + t.Error("a waiting grant must show its limit and the cancel button, and no second form") + } + + _, _, h2, _ := windowRig(t, `{"customer_id":"acme","offsite":{"enabled":false}}`) + if strings.Contains(windowPage(t, h2, ""), `id="offsite-window"`) { + t.Error("the block must not render when off-site is not enabled") + } +} + +// The form's grant: ONE grant with the limit, a redirect with its flash, an operator event naming who and the limit; +// the box's window uses it up, and the next window finds none. +// RED-PROOF: make the handler ignore max_remove → the stored grant has no limit → fails. +func TestR1299_AGrantThroughTheFormStoresOneGrantWithItsLimit(t *testing.T) { + _, st, h, evs := windowRig(t, windowReport) + w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"33"}}, true) + if w.Code != http.StatusSeeOther || !strings.Contains(w.Header().Get("Location"), "flash=window_granted") { + t.Fatalf("grant: %d Location=%q", w.Code, w.Header().Get("Location")) + } + if pending, max := st.PendingOffsiteWindowGrant("acme"); !pending || max != 33 { + t.Fatalf("stored grant: pending=%v max=%d, want true 33", pending, max) + } + if len(*evs) != 1 || (*evs)[0].typ != eventWindowGrant || (*evs)[0].sev != "info" || + !strings.Contains((*evs)[0].details, `"max_remove":33`) || !strings.Contains((*evs)[0].details, `"by":"operator session"`) { + t.Fatalf("events: %+v — want one info %s naming the limit and who", *evs, eventWindowGrant) + } + if granted, max := st.TakeOffsiteWindowGrant("acme"); !granted || max != 33 { + t.Fatalf("the window took granted=%v max=%d", granted, max) + } + if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted { + t.Fatal("a second window reused the one-shot grant") + } + if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending { + t.Fatal("the page would still show a used grant as waiting") + } +} + +// Refusals that store nothing: a limit above the snapshot count, a non-number, no confirm step, no CSRF token. +// RED-PROOF: drop the bound in checkWindowLimit → the 41 case stores a grant → fails; drop the confirm check → the +// no-confirm case stores one. +func TestR1299_RefusalsStoreNothing(t *testing.T) { + cases := []struct { + name string + form url.Values + csrf bool + want func(*httptest.ResponseRecorder) bool + }{ + {"above the snapshot count", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"41"}}, true, + func(w *httptest.ResponseRecorder) bool { + return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused") + }}, + {"not a number", url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"2e2"}}, true, + func(w *httptest.ResponseRecorder) bool { + return strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused") + }}, + {"script above the snapshot count", url.Values{"max_remove": {"500"}}, true, + func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusBadRequest }}, + {"no confirm step", url.Values{"from": {"page"}, "max_remove": {"10"}}, true, + func(w *httptest.ResponseRecorder) bool { + return strings.Contains(w.Header().Get("Location"), "flash=window_needs_confirm") + }}, + {"no CSRF token", url.Values{"from": {"page"}, "confirm": {"1"}}, false, + func(w *httptest.ResponseRecorder) bool { return w.Code == http.StatusForbidden }}, + } + for _, c := range cases { + _, st, h, evs := windowRig(t, windowReport) + w := windowPost(h, c.form, c.csrf) + if !c.want(w) { + t.Errorf("%s: %d Location=%q", c.name, w.Code, w.Header().Get("Location")) + } + if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending { + t.Errorf("%s: a grant was stored", c.name) + } + if len(*evs) != 0 { + t.Errorf("%s: %d event(s) for a refused request", c.name, len(*evs)) + } + } +} + +// Without a measured snapshot count only the ceiling bounds the limit (D55's 260 must stay possible). +func TestR1299_WithoutACountOnlyTheCeilingBounds(t *testing.T) { + _, st, h, _ := windowRig(t, "") + if w := windowPost(h, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"260"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_granted") { + t.Fatalf("260 with no count: Location=%q", w.Header().Get("Location")) + } + if _, max := st.PendingOffsiteWindowGrant("acme"); max != 260 { + t.Fatalf("stored max %d, want 260", max) + } + _, st2, h2, _ := windowRig(t, "") + if w := windowPost(h2, url.Values{"from": {"page"}, "confirm": {"1"}, "max_remove": {"501"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_limit_refused") { + t.Fatalf("501: Location=%q", w.Header().Get("Location")) + } + if pending, _ := st2.PendingOffsiteWindowGrant("acme"); pending { + t.Fatal("501 stored a grant") + } +} + +// The cancel removes a waiting grant and records who; with none waiting it changes nothing. +func TestR1299_CancelRemovesAWaitingGrant(t *testing.T) { + _, st, h, evs := windowRig(t, windowReport) + if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_none_waiting") { + t.Fatalf("cancel with none waiting: Location=%q", w.Header().Get("Location")) + } + _ = st.GrantOffsiteWindowOnce("acme") + if w := windowPost(h, url.Values{"from": {"page"}, "cancel": {"1"}}, true); !strings.Contains(w.Header().Get("Location"), "flash=window_cancelled") { + t.Fatalf("cancel: Location=%q", w.Header().Get("Location")) + } + if pending, _ := st.PendingOffsiteWindowGrant("acme"); pending { + t.Fatal("the grant is still waiting after the cancel") + } + if granted, _ := st.TakeOffsiteWindowGrant("acme"); granted { + t.Fatal("the box's window would still use the cancelled grant") + } + if len(*evs) != 1 || !strings.Contains((*evs)[0].details, `"cancelled":true`) { + t.Fatalf("events: %+v — want one cancel event", *evs) + } +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index c994fa83..8efaa0a0 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -12,7 +12,6 @@ import ( "log" "math" "net/http" - "strconv" "strings" "sync" "time" @@ -777,36 +776,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]int{"changed": n}) case strings.HasPrefix(path, "/offsite/window-grant/") || path == "/offsite/windows-enabled": - // Operator (decision 68): a one-shot grant lets the customer's NEXT window request through; - // the switch turns the WEEKLY window on/off fleet-wide (off = the interim: nothing prunes). - if r.Method != http.MethodPost || s.offsiteWindowGrant == nil { - http.Error(w, "unavailable", http.StatusServiceUnavailable) - return - } - var err error - if path == "/offsite/windows-enabled" { - err = s.offsiteWindowSwitch(r.FormValue("on") == "1") - } else if mr := r.FormValue("max_remove"); mr != "" { - // R-833: one window with a raised removal cap (operator only — this server is behind the - // operator's login; the box API has no route to it). - n, perr := strconv.Atoi(mr) - if perr != nil || s.offsiteWindowGrantMax == nil { - http.Error(w, "max_remove must be a number", http.StatusBadRequest) - return - } - if err = s.offsiteWindowGrantMax(strings.TrimPrefix(path, "/offsite/window-grant/"), n); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - } else { - err = s.offsiteWindowGrant(strings.TrimPrefix(path, "/offsite/window-grant/")) - } - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte("{\"ok\":true}\n")) + // Operator (decision 68): a one-shot grant lets the customer's NEXT window request through, with an optional + // raised cap (R-833); the switch turns the WEEKLY window on/off fleet-wide. The customer page's form (R-1299) + // posts here too — offsite_window.go. + s.handleOffsiteWindowGrant(w, r, path) case strings.HasPrefix(path, "/os/"): // Operator (hub v0.130.0, `11` §8 step 2): per-box ring and switch, approve now, the fleet lines. s.handleOSAdmin(w, r, path) diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 977b2fab..5629e381 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -67,6 +67,13 @@ {{else if eq .Flash "selfbind-no-email"}}Self-bind link NOT sent — this customer has no registered email address. Set one first, or bind the appliance manually from the Hosts page. {{else if eq .Flash "selfbind-send-failed"}}Self-bind link send FAILED — the link was invalidated (not left live). Check the hub log (email delivery / send error). {{else if eq .Flash "reset_done"}}Customer RESET complete — every operational trace was destroyed (offsite repo, PBS namespace, DR recipe, claim state, retained escrow custody). Identity and basic config survive; the audit event stream records it. + {{else if eq .Flash "window_granted"}}One clean-up window granted — the box uses it on its next off-site run (see „Clean-up window” in the Backup card). + {{else if eq .Flash "window_cancelled"}}The waiting clean-up window was cancelled — nothing will be removed for it. + {{else if eq .Flash "window_none_waiting"}}No clean-up window was waiting — nothing changed. + {{else if eq .Flash "window_needs_confirm"}}Not granted — the clean-up window needs the confirm step. Nothing was saved. + {{else if eq .Flash "window_limit_refused"}}Not granted — the limit is not a whole number of 1 or more, or it is above the store's snapshot count. Nothing was saved. + {{else if eq .Flash "window_unknown_customer"}}Not granted — no such customer. + {{else if eq .Flash "window_failed"}}The clean-up window could not be saved — see the hub log. Nothing was granted. {{end}} {{end}} @@ -337,6 +344,61 @@ claim the repository is empty and that is not what is known. They fill in after the next off-site run reads the repository.

{{end}} + + + {{with $.WindowCard}} +

Clean-up window

+
+
+ Weekly windows + {{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}} +
+
+ Normal cap per window + {{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}— (half the snapshots; count not measured){{end}} +
+
+ One-time window + {{if .Pending}}waiting — {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}} +
+
+ Last window + {{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC — {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} → {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}} +
+
+ {{if .Pending}} +
+ + + + +
+ {{else if .Confirm}} +
+ Open one clean-up window for {{$.CustomerID}} with + {{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}? + The box uses it on its next off-site run; it removes only what its keep rule selects, and the + fake-snapshot guard still runs. One use, then it is gone. +
+ + + + + + Back +
+
+ {{else}} + {{if .LimitError}}

Not granted: {{.LimitError}}.

{{end}} +
+ + + + +
+ {{end}} + {{end}} {{end}} {{end}}