diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 1b5cdc2..04a9df6 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -78,6 +78,12 @@ func main() { recoverOffsiteCheck := flag.Bool("recover-offsite-check", false, "R-200 diagnostic: read the customer recovery code from STDIN, recover the offsite repository password from the hub-held sealed escrow via the agent, and report whether it matches the one on disk — BY HASH. Compares, never installs; writes nothing. Exit 0 = match, 2 = clean mismatch, 1 = a step failed.") recoverOffsiteInstall := flag.Bool("recover-offsite-install", false, "R-200: read the customer recovery code from STDIN, recover the offsite repository password, print both hashes, and — with --confirm-install — PLACE it so the existing repository reopens. Without --confirm-install it is a dry run that writes nothing.") confirmInstall := flag.Bool("confirm-install", false, "Required alongside --recover-offsite-install to actually write the recovered repository password. Deliberately a second invocation so the hashes are seen before any write is possible.") + // §7.5 (R-241): the operator levers for a running abandonment countdown. The automatic 30-day + // ending is deliberately NOT built (R-245) — what is built is the path that actually happens, + // which is the customer getting in touch and support needing something to press. + abandonStatus := flag.Bool("abandon-status", false, "R-241: print the state of this box's off-site abandonment countdown (set-aside path, due date, days left) and exit. Read-only.") + abandonExtend := flag.Int("abandon-extend", 0, "R-241 (operator): extend a running abandonment countdown by N days from now, then exit. Refuses when no countdown is running.") + abandonStop := flag.Bool("abandon-stop", false, "R-241 (operator): stop a running abandonment countdown, then exit. The set-aside history is kept and nothing is deleted. Refuses when no countdown is running.") flag.Parse() if *showVersion { @@ -152,6 +158,52 @@ func main() { })) } + // §7.5 (R-241) — the operator's abandonment levers. Grouped in one block, before the server + // starts, exactly like the other CLI subcommands: each loads config + settings, acts, and exits. + if *abandonStatus || *abandonExtend > 0 || *abandonStop { + cfg, err := config.LoadPermissive(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "abandon: loading config: %v\n", err) + os.Exit(1) + } + lg := log.New(os.Stderr, "", 0) + sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", lg) + if err != nil { + fmt.Fprintf(os.Stderr, "abandon: loading settings: %v\n", err) + os.Exit(1) + } + mgr := backup.NewManager(cfg, sett, lg) + st := mgr.AbandonStatus() + switch { + case *abandonStop: + if serr := mgr.StopAbandon(); serr != nil { + fmt.Fprintf(os.Stderr, "abandon-stop: %v\n", serr) + os.Exit(2) + } + fmt.Printf("abandonment STOPPED — the set-aside history at %s is kept; nothing was deleted\n", st.RepoPath) + case *abandonExtend > 0: + due, eerr := mgr.ExtendAbandon(*abandonExtend) + if eerr != nil { + fmt.Fprintf(os.Stderr, "abandon-extend: %v\n", eerr) + os.Exit(2) + } + fmt.Printf("abandonment EXTENDED by %d day(s) — the set-aside history at %s is now deleted on %s\n", + *abandonExtend, st.RepoPath, due.Format("2006-01-02")) + default: + if !st.Active && !st.PurgeRequested { + fmt.Println("no abandonment countdown is running on this box") + os.Exit(0) + } + if st.PurgeRequested { + fmt.Printf("set-aside history DELETED at %s; awaiting the hub to drop the sealed package\n", st.RepoPath) + os.Exit(0) + } + fmt.Printf("abandonment countdown RUNNING\n set-aside history : %s\n chosen on : %s\n deleted on : %s\n days left : %d\n", + st.RepoPath, st.StartedAt.Format("2006-01-02"), st.DueAt.Format("2006-01-02"), st.DaysLeft) + } + os.Exit(0) + } + if *printResetCode { cfg, err := config.LoadPermissive(*configPath) if err != nil { diff --git a/controller/internal/backup/offbox_abandon.go b/controller/internal/backup/offbox_abandon.go index bd1ab2f..0a7c0d0 100644 --- a/controller/internal/backup/offbox_abandon.go +++ b/controller/internal/backup/offbox_abandon.go @@ -218,3 +218,56 @@ func (m *Manager) ClearAbandonPurgeIfConfirmed(supersededPresent bool) { } m.logger.Printf("[INFO] [offbox] abandonment COMPLETE — the set-aside history and the sealed package that protected it are both gone; nothing further to ask about") } + +// ── OPERATOR CONTROL (§7.5) ───────────────────────────────────────────────────────────────────── +// +// The automatic 30-day abandonment is deliberately NOT built (see R-245). What IS built is the path +// that actually happens: **the customer gets in touch.** Someone who cannot find their recovery code +// rings support, and support needs something to press — either "give them longer" or "stop it". +// +// Both live on the controller CLI rather than in the customer UI, deliberately: extending a deletion +// the customer asked for is an operator judgement, not a self-service button, and a customer who +// wants it stopped already has the self-service route — they recover with their code, which cancels +// it (Scenario G). + +// ExtendAbandon pushes the terminal step out by `days` from NOW. Returns the new due date. +// +// It refuses when no countdown is running: extending nothing would print a reassuring date for a +// deletion that was never scheduled, which is the kind of comfort this project keeps removing. +func (m *Manager) ExtendAbandon(days int) (time.Time, error) { + if days <= 0 { + return time.Time{}, fmt.Errorf("the extension must be a positive number of days") + } + st := m.AbandonStatus() + if !st.Active { + if st.PurgeRequested { + return time.Time{}, fmt.Errorf("too late: the set-aside history has already been deleted and only the sealed package is still being removed") + } + return time.Time{}, fmt.Errorf("no abandonment countdown is running on this box — nothing to extend") + } + due := m.abandonNow().UTC().AddDate(0, 0, days) + if err := m.settings.UpdateOffboxStatus(func(o *settings.OffboxTarget) { + o.AbandonAt = due.Format(time.RFC3339) + }); err != nil { + return time.Time{}, fmt.Errorf("record the extension: %w", err) + } + m.logger.Printf("[WARN] [offbox] abandonment EXTENDED by an operator: the set-aside history at %s is now deleted on %s (was %s)", + st.RepoPath, due.Format("2006-01-02"), st.DueAt.Format("2006-01-02")) + return due, nil +} + +// StopAbandon cancels the countdown outright — the operator's version of Scenario G, for the +// customer who telephoned instead of finding their code. The set-aside history is kept and nothing +// is deleted; it is `CancelAbandon` with an operator's reason and a refusal when nothing is running, +// so an operator never gets a silent no-op they might read as success. +func (m *Manager) StopAbandon() error { + st := m.AbandonStatus() + if !st.Active { + if st.PurgeRequested { + return fmt.Errorf("too late: the set-aside history has already been deleted") + } + return fmt.Errorf("no abandonment countdown is running on this box — nothing to stop") + } + m.CancelAbandon("stopped by an operator") + return nil +} diff --git a/controller/internal/backup/offbox_abandon_r241_test.go b/controller/internal/backup/offbox_abandon_r241_test.go index 759f2c1..d020907 100644 --- a/controller/internal/backup/offbox_abandon_r241_test.go +++ b/controller/internal/backup/offbox_abandon_r241_test.go @@ -245,3 +245,91 @@ func TestR241_UnclaimedAutoResetStartsNoCountdown(t *testing.T) { t.Fatal("the unclaimed auto-reset must not start a customer abandonment countdown") } } + +// ── §7.5 — THE OPERATOR LEVERS ────────────────────────────────────────────────────────────────── +// +// The automatic 30-day ending is deliberately NOT built (R-245). These are what IS built: the path +// that actually happens is the customer telephoning, and support needs something to press. +func TestR241_OperatorCanExtendARunningCountdown(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, rec := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + day10 := start.AddDate(0, 0, 10) + m.SetOffboxClock(func() time.Time { return day10 }) + + due, err := m.ExtendAbandon(30) + if err != nil { + t.Fatalf("extend: %v", err) + } + if want := day10.AddDate(0, 0, 30); !due.Equal(want) { + t.Errorf("new due = %v, want %v (from NOW, not from the old date)", due, want) + } + // The original date has passed and nothing is deleted, because the extension moved it. + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) + rec.cmds = nil + if deleted, serr := m.AbandonSweep(context.Background()); deleted || serr != nil { + t.Fatalf("an extended countdown must not fire on the old date: deleted=%v err=%v", deleted, serr) + } + if len(rec.cmds) != 0 { + t.Fatalf("nothing may be deleted after an extension, got %v", rec.cmds) + } +} + +func TestR241_OperatorCanStopARunningCountdown(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, rec := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + if err := m.StopAbandon(); err != nil { + t.Fatalf("stop: %v", err) + } + if m.AbandonStatus().Active { + t.Fatal("the countdown must be stopped") + } + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 90) }) + rec.cmds = nil + if deleted, err := m.AbandonSweep(context.Background()); deleted || err != nil { + t.Fatalf("a stopped countdown must never delete: deleted=%v err=%v", deleted, err) + } + if len(rec.cmds) != 0 { + t.Fatalf("a stopped countdown must issue no remote commands, got %v", rec.cmds) + } +} + +// Both levers REFUSE when nothing is running. A silent no-op is the thing an operator most easily +// mistakes for success — they would tell the customer it was handled. +func TestR241_OperatorLeversRefuseWhenNothingIsRunning(t *testing.T) { + m, _, _ := abandonFixture(t, time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)) + if _, err := m.ExtendAbandon(30); err == nil { + t.Error("extending a countdown that is not running must be an error, never a quiet success") + } + if err := m.StopAbandon(); err == nil { + t.Error("stopping a countdown that is not running must be an error, never a quiet success") + } + if _, err := m.ExtendAbandon(0); err == nil { + t.Error("a non-positive extension must be refused") + } +} + +// Once the store is deleted there is nothing left to extend or stop, and saying otherwise would be +// the worst kind of reassurance: an operator telling a customer their data is safe when it is gone. +func TestR241_OperatorLeversRefuseAfterTheDeletion(t *testing.T) { + start := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC) + m, _, _ := abandonFixture(t, start) + if err := m.ResetOrphanedRepo(context.Background()); err != nil { + t.Fatal(err) + } + m.SetOffboxClock(func() time.Time { return start.AddDate(0, 0, 15) }) + if _, err := m.AbandonSweep(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := m.ExtendAbandon(30); err == nil { + t.Error("extending after the deletion must be refused — there is nothing left to save") + } + if err := m.StopAbandon(); err == nil { + t.Error("stopping after the deletion must be refused — there is nothing left to save") + } +} diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 681fef0..398d185 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -137,6 +137,10 @@ type Settings struct { // nothing that can be forgotten to clear (§7.1 condition 2). RecoveryOfferEpoch int `json:"recovery_offer_epoch,omitempty"` RecoveryOfferActive bool `json:"recovery_offer_active,omitempty"` + // RecoveryOfferSince (RFC3339) stamps when the CURRENT epoch began — the anchor the undecided + // reminders escalate against (§2.3). Re-stamped on every entry, so a box that settles and is later + // rebuilt starts its reminder ladder again rather than inheriting an old one. + RecoveryOfferSince string `json:"recovery_offer_since,omitempty"` RecoveryNoticePostponedEpoch int `json:"recovery_notice_postponed_epoch,omitempty"` // RecoveryRemindOptOutEpoch — the customer ticked „ne emlékeztessen újra" in this epoch. // @@ -2185,6 +2189,7 @@ type RecoveryOfferView struct { Active bool PostponedEpoch int OptOutEpoch int + Since string // RFC3339 — when the current epoch began } // GetRecoveryOfferView returns the epoch state in one lock. @@ -2196,6 +2201,7 @@ func (s *Settings) GetRecoveryOfferView() RecoveryOfferView { Active: s.RecoveryOfferActive, PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch, + Since: s.RecoveryOfferSince, } } @@ -2207,13 +2213,14 @@ func (s *Settings) GetRecoveryOfferView() RecoveryOfferView { // are still in. Re-interrupting them on upgrade would be a regression dressed as a feature, so the // first epoch inherits that choice and the legacy flag is retired. A box that never dismissed keeps // PostponedEpoch 0 and is interrupted, which is correct. -func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, error) { +func (s *Settings) SyncRecoveryOfferEpoch(offered bool, now time.Time) (RecoveryOfferView, error) { s.mu.Lock() defer s.mu.Unlock() changed := false if offered && !s.RecoveryOfferActive { s.RecoveryOfferActive = true s.RecoveryOfferEpoch++ + s.RecoveryOfferSince = now.UTC().Format(time.RFC3339) if s.RecoveryOfferEpoch == 1 && s.RecoveryNoticePostponed { s.RecoveryNoticePostponedEpoch = 1 // inherit the pre-epoch dismissal, once s.RecoveryNoticePostponed = false @@ -2226,6 +2233,7 @@ func (s *Settings) SyncRecoveryOfferEpoch(offered bool) (RecoveryOfferView, erro view := RecoveryOfferView{ Epoch: s.RecoveryOfferEpoch, Active: s.RecoveryOfferActive, PostponedEpoch: s.RecoveryNoticePostponedEpoch, OptOutEpoch: s.RecoveryRemindOptOutEpoch, + Since: s.RecoveryOfferSince, } if !changed { return view, nil diff --git a/controller/internal/web/recovery_handlers.go b/controller/internal/web/recovery_handlers.go index 6fd1b23..56766a0 100644 --- a/controller/internal/web/recovery_handlers.go +++ b/controller/internal/web/recovery_handlers.go @@ -62,7 +62,7 @@ func (s *Server) recoveryOfferEpoch() settings.RecoveryOfferView { if s.settings == nil { return settings.RecoveryOfferView{} } - v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer()) + v, err := s.settings.SyncRecoveryOfferEpoch(s.recoveryOffer(), s.recoveryNow()) if err != nil { s.logger.Printf("[WARN] [web] recovery: could not persist the offer epoch: %v", err) } @@ -607,6 +607,17 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) if data["CSRFField"] == nil { data["CSRFField"] = s.csrfField(r) } + // §2.3 — THE UNDECIDED LADDER. A customer who never decides is reminded with escalating + // EMPHASIS as the situation ages: marks at 1, 3, 7 and 14 days since this epoch began. + // + // ⚠ THE READING IS STATED BECAUSE THE SPEC IS AMBIGUOUS. For an ABANDONING box, 5/3/1 are + // unambiguously days REMAINING before a deletion. An undecided box has no deadline — nothing is + // counting down to anything, because §7.5 deliberately does NOT auto-abandon — so 14/7/3/1 cannot + // be "remaining" and are taken as days ELAPSED, with the wording escalating rather than the bar + // appearing and disappearing. If the operator meant something else, this is the line to change. + dw := s.recoveryDaysWaiting() + data["RecoveryDaysWaiting"] = dw + data["RecoveryReminderTier"] = RecoveryReminderTier(dw) // While a countdown runs the bar counts it down instead of asking the same question — and the // reminder opt-out is deliberately NOT offered there: a deletion date is not something to silence. if s.backupMgr != nil { @@ -626,3 +637,36 @@ func (s *Server) addRecoveryBanner(data map[string]interface{}, r *http.Request) } } } + +// recoveryDaysWaiting returns whole days since the current offered epoch began (0 when unknown). +// It drives the escalating emphasis of the undecided reminder — see addRecoveryBanner. +func (s *Server) recoveryDaysWaiting() int { + if s.settings == nil { + return 0 + } + v := s.settings.GetRecoveryOfferView() + if v.Since == "" { + return 0 + } + since, err := time.Parse(time.RFC3339, v.Since) + if err != nil { + return 0 // an unparseable stamp means "we do not know", never "it has been ages" + } + d := int(s.recoveryNow().Sub(since) / (24 * time.Hour)) + if d < 0 { + return 0 + } + return d +} + +// RecoveryReminderTier maps days-waiting to the escalation marks in §2.3. It returns the HIGHEST +// mark reached, so the copy can firm up without the bar flickering in and out. +func RecoveryReminderTier(daysWaiting int) int { + tier := 0 + for _, mark := range []int{1, 3, 7, 14} { + if daysWaiting >= mark { + tier = mark + } + } + return tier +} diff --git a/controller/internal/web/templates/layout.html b/controller/internal/web/templates/layout.html index 1543ad9..fd37de3 100644 --- a/controller/internal/web/templates/layout.html +++ b/controller/internal/web/templates/layout.html @@ -141,6 +141,12 @@ {{if .RecoveryAbandonDays}} A korábbi távoli mentéseidet {{.RecoveryAbandonDays}} nap múlva ({{.RecoveryAbandonDate}}) véglegesen töröljük, a kérésed szerint. Addig még visszaszerezheted őket a helyreállítási kóddal. + {{else if ge .RecoveryReminderTier 14}} + Két hete várnak rád a korábbi távoli mentéseid, és még nem adtad meg a helyreállítási kódodat. Amíg nem teszed, ezekhez a mentésekhez nem férsz hozzá. + {{else if ge .RecoveryReminderTier 7}} + Már egy hete megvannak a korábbi távoli mentéseid, de a helyreállítási kódod nélkül nem tudjuk megnyitni őket. + {{else if ge .RecoveryReminderTier 3}} + A korábbi távoli mentéseid megvannak — a megnyitásukhoz a helyreállítási kódod szükséges. {{else}} A korábbi távoli mentéseid megvannak, de ehhez a géphez a helyreállítási kódod szükséges. {{end}}