hub(R-1299): the one-time clean-up window gets a button on the customer page
gates / gates (push) Successful in 1m51s

The Backup card's off-site part shows the window state (weekly switch, the normal cap, a waiting grant, the last
window) and a form: optional limit -> a confirm step that names the customer and the limit -> the SAME route and
rule as the script address (POST /offsite/window-grant/<customer>, wr(grpOffsite), the page's CSRF). A waiting grant
can be cancelled. A raised limit above the store's measured snapshot count is refused (the 500 ceiling stays). Every
grant and cancel: a log line and an operator-only info event offsite_window_grant (who, customer, limit).
Tests TestR1299_* (five), red-proved by five mutations.
This commit is contained in:
admin committed 2026-10-27 17:23:37 +01:00
1 parent 7610f69e7c
commit d85198b835
8 files changed
+549 -32

No files matched your search

+19
View File
@@ -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/<customer>` (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
+3
View File
@@ -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.
+40
View File
@@ -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)
+4 -1
View File
@@ -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: <id>"
@@ -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,
+203
View File
@@ -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/<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+"#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")
}
+214
View File
@@ -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&#39;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)
}
}
+4 -31
View File
@@ -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)
@@ -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&#39;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}}
</div>
{{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.</p>
{{end}}
<!-- R-1299: the one-shot clean-up window. Same route and rule as the script address
(POST /offsite/window-grant/<customer>, offsite:write); the confirm step is a GET that stores nothing. -->
{{with $.WindowCard}}
<h3 id="offsite-window">Clean-up window</h3>
<div class="info-grid">
<div class="info-item">
<span class="label">Weekly windows</span>
<span class="value">{{if .WeeklyOn}}On{{else}}Off (fleet-wide){{end}}</span>
</div>
<div class="info-item">
<span class="label">Normal cap per window</span>
<span class="value">{{if .CapKnown}}{{.NormalCap}} (half the snapshots, at least 5){{else}}&mdash; (half the snapshots; count not measured){{end}}</span>
</div>
<div class="info-item">
<span class="label">One-time window</span>
<span class="value">{{if .Pending}}<strong>waiting</strong> &mdash; {{if .PendingMax}}limit {{.PendingMax}}{{else}}the normal cap{{end}}{{else}}none waiting{{end}}</span>
</div>
<div class="info-item">
<span class="label">Last window</span>
<span class="value">{{with .Last}}#{{.ID}} {{.OpenedAt.UTC.Format "2006-01-02 15:04"}} UTC &mdash; {{if .ClosedAt.IsZero}}open{{else}}{{.BoxResult}}, {{.CountBefore}} &rarr; {{.CountAfter}}{{end}}{{if .MaxRemove}} (cap {{.MaxRemove}}){{end}}{{else}}never{{end}}</span>
</div>
</div>
{{if .Pending}}
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="cancel" value="1">
<button type="submit" class="btn btn-outline btn-sm">Cancel the waiting window</button>
</form>
{{else if .Confirm}}
<div class="flash flash-warn" id="window-confirm">
Open <strong>one</strong> clean-up window for <strong>{{$.CustomerID}}</strong> with
<strong>{{if .ConfirmLimit}}a limit of {{.ConfirmLimit}}{{else}}the normal cap{{end}}</strong>?
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.
<form method="POST" action="/offsite/window-grant/{{$.CustomerID}}" class="inline-confirm">
<input type="hidden" name="_csrf" value="{{$.CSRFToken}}">
<input type="hidden" name="from" value="page">
<input type="hidden" name="confirm" value="1">
<input type="hidden" name="max_remove" value="{{.ConfirmLimit}}">
<button type="submit" class="btn btn-danger btn-sm">Open the window</button>
<a href="/customers/{{$.CustomerID}}#offsite-window" class="btn btn-outline btn-sm">Back</a>
</form>
</div>
{{else}}
{{if .LimitError}}<p class="flash flash-error">Not granted: {{.LimitError}}.</p>{{end}}
<form method="GET" action="/customers/{{$.CustomerID}}" class="inline-confirm" id="window-form">
<input type="hidden" name="window" value="confirm">
<label for="window_max">Limit for this window (empty = the normal cap; at most {{.Bound}})</label>
<input type="number" id="window_max" name="window_max" min="1" max="{{.Bound}}">
<button type="submit" class="btn btn-outline btn-sm">Open one clean-up window&hellip;</button>
</form>
{{end}}
{{end}}
{{end}}
{{end}}
</section>