diff --git a/controller/README.md b/controller/README.md index 99f5f9e..7d1c74e 100644 --- a/controller/README.md +++ b/controller/README.md @@ -161,6 +161,11 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated restic `rclone:` over ssh port 23 (`settings.OffboxTarget.Transport = "rclone-pinned"`); the household's own SFTP NAS is unchanged. Retention runs only inside a hub-opened weekly window, behind the fake-snapshot guard (`backup/offbox_window.go`; v0.290.0: a young snapshot superseded the same day is skipped, not refused); the orphan move-aside is the hub's; a due abandonment is handed to the hub, which deletes the set-aside copy after a 7-day wait unless cancelled (decision 74). + **The household can remove its own target (R-729/R-545):** „Távoli mentési cél törlése" on /backups/remote + (`/backup/offbox/clear`, reveal-then-confirm, `backup/offbox_clear.go`) forgets the target and deletes `ssh_key` + + `known_hosts`; nothing on the target is touched. `repo_password` is deleted only when nothing depends on it (no hub + sealed package, not escrowed, no successful run, no snapshots). Refused for the Felhom tier (`rclone-pinned`), while a + backup operation holds the single-flight, and while an abandonment countdown runs. **Apps go off-site by themselves (v0.283.0, decision 50):** a fresh install on a box whose customer has off-site switches the app's off-site copy ON (`settings.DefaultOffboxOnForNewApp`, deploy-done hook); an earlier choice is kept. Older apps: one press on both backup pages (`/backup/offbox/enable-all`, „Nem most" dismisses). The size diff --git a/controller/internal/backup/offbox_clear.go b/controller/internal/backup/offbox_clear.go new file mode 100644 index 0000000..3fa8482 --- /dev/null +++ b/controller/internal/backup/offbox_clear.go @@ -0,0 +1,101 @@ +package backup + +import ( + "errors" + "fmt" + "os" +) + +// CLEARING AN OFF-SITE TARGET — R-729 / R-545 (the household's „Távoli mentési cél törlése" press). +// +// Before this, a target once saved could be edited or switched off but never removed: the page +// refuses an empty address, and the only other route (`/backup/offbox/reset`) means "start a new +// remote backup, set the old history aside", and only for an ORPHANED store. A household that tried +// its own NAS and gave up kept the host, user, path, SSH key and known-host line for ever. +// +// WHAT THE PRESS DOES: forgets the target (settings) and deletes the transport secrets (`ssh_key`, +// `known_hosts`) from `data/offbox/`. It NEVER touches the repository on the target — not one remote +// command runs (R-729: "never the repository"). +// +// WHAT IT DOES WITH THE REPOSITORY PASSWORD — the one secret that is not transport: +// it is KEPT whenever anything could depend on it, and deleted only when nothing can: +// - the hub holds a sealed recovery package for this box (the R-241 rule: a box without a password +// and with a held package refuses to mint, so deleting the key here would strand the next target +// in the „awaiting recovery key" holding state — and the history the package protects would lose +// its on-box key); +// - the escrow was confirmed (`escrowed`) — the hub's package seals exactly this key; +// - the target ever completed a run or holds snapshots — history exists on the target that only +// this key opens. +// Keeping it is harmless: a later target reuses it (WriteOffboxSecrets never re-mints over an +// existing file), and the escrow then matches by hash with nothing to redo. +// +// WHAT IT REFUSES (each leaves every file and setting untouched): +// - the hub-provisioned Felhom tier (Transport rclone-pinned) — that target is Felhom's to remove, +// not the page's; the hub would re-apply it anyway; +// - while a backup/restore/check holds the single-flight — a run reading the key file mid-delete; +// - while an abandonment countdown is running or awaiting the hub (R-241 / decision 74) — the +// countdown's state lives on the target record, and clearing it would strand the scheduled +// deletion of the set-aside history with nobody left to run or cancel it. +// +// Pinned by internal/backup/offbox_clear_r729_test.go. + +// Refusal sentinels — the handler maps each to its own household sentence. +var ( + ErrOffboxClearNoTarget = errors.New("offbox clear: no off-site target is configured") + ErrOffboxClearHubTier = errors.New("offbox clear: the Felhom-provided off-site tier cannot be removed from the box") + ErrOffboxClearBusy = errors.New("offbox clear: a backup operation is running") + ErrOffboxClearAbandonOn = errors.New("offbox clear: a set-aside history deletion is scheduled") +) + +// OffboxClearResult says what the press did with the one secret it may keep. +type OffboxClearResult struct { + // KeptRepoPassword is true when the repository password stayed on disk (see the header for why). + KeptRepoPassword bool +} + +// offboxRepoPasswordNeeded reports whether anything could still depend on the on-box repository +// password. Fail-safe direction: every input that MIGHT mean "something depends on it" keeps it. +func (m *Manager) offboxRepoPasswordNeeded(escrowState, lastSuccess string, snapshots int) bool { + return m.sealedPackageHeld() || escrowState == "escrowed" || lastSuccess != "" || snapshots > 0 +} + +// ClearOffboxTarget forgets the household's off-site target. See the file header. +func (m *Manager) ClearOffboxTarget() (OffboxClearResult, error) { + var res OffboxClearResult + t := m.settings.GetOffboxTarget() + if t == nil { + return res, ErrOffboxClearNoTarget + } + if t.Pinned() { + return res, ErrOffboxClearHubTier + } + if ab := m.AbandonStatus(); ab.Active || ab.PurgeRequested || ab.HubPending { + return res, ErrOffboxClearAbandonOn + } + // Hold the single-flight for the whole clear, so no run can start between the check and the delete. + if err := m.acquireRunning(); err != nil { + return res, ErrOffboxClearBusy + } + defer m.releaseRunning() + + res.KeptRepoPassword = m.offboxRepoPasswordNeeded(t.EscrowState, t.LastSuccess, t.SnapshotCount) + + // Files FIRST, settings LAST: a failure part-way leaves a target that is still listed (and no + // longer runnable — OffboxConfigured needs the key file), so the press can simply be repeated. + // The reverse order would leave secrets on disk with no target left to clear them through. + files := []string{m.offboxKeyPath(), m.offboxKnownHosts()} + if !res.KeptRepoPassword { + files = append(files, m.offboxPwPath()) + } + for _, f := range files { + if err := os.Remove(f); err != nil && !os.IsNotExist(err) { + return res, fmt.Errorf("offbox clear: remove %s: %w", f, err) + } + } + if err := m.settings.SetOffboxTarget(nil); err != nil { + return res, fmt.Errorf("offbox clear: save settings: %w", err) + } + m.logger.Printf("[INFO] [offbox] off-site target %s@%s:%s CLEARED by the household (transport secrets deleted; repository password kept=%v; nothing on the target was touched)", + t.User, t.Host, t.RepoPath, res.KeptRepoPassword) + return res, nil +} diff --git a/controller/internal/backup/offbox_clear_r729_test.go b/controller/internal/backup/offbox_clear_r729_test.go new file mode 100644 index 0000000..a6ed3e5 --- /dev/null +++ b/controller/internal/backup/offbox_clear_r729_test.go @@ -0,0 +1,160 @@ +package backup + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-729 / R-545 — the household can remove its own off-site target; the repository is never touched, +// and the repository password is kept whenever anything could depend on it. See offbox_clear.go. + +func clearFixture(t *testing.T, hubHoldsPackage bool, tgt *settings.OffboxTarget) (*Manager, *settings.Settings, string) { + t.Helper() + m, sett, pwPath := mintGuardManager(t, false) // mint first, THEN set the package fact + if err := sett.SetOffboxTarget(tgt); err != nil { + t.Fatal(err) + } + if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil { + t.Fatal(err) + } + if err := sett.SetHubEscrowIdentityPresent(hubHoldsPackage); err != nil { + t.Fatal(err) + } + // Any remote command would go through the runner — none may run. + m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) { + t.Errorf("R-729: clearing a target ran restic %v — the repository must never be touched", args) + return nil, errors.New("forbidden") + }) + m.SetOffboxSSH(func(ctx context.Context, host, user string, port int, keyPath, knownHosts, remoteCmd string) ([]byte, error) { + t.Errorf("R-729: clearing a target ran a remote command %q — the repository must never be touched", remoteCmd) + return nil, errors.New("forbidden") + }) + return m, sett, filepath.Dir(pwPath) +} + +func ownNAS() *settings.OffboxTarget { + return &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo", EscrowState: "pending"} +} + +// The headline: a never-used own-NAS target is forgotten and every secret it minted is gone. +func TestR729_Clear_ForgetsTargetAndDeletesSecrets(t *testing.T) { + m, sett, dir := clearFixture(t, false, ownNAS()) + res, err := m.ClearOffboxTarget() + if err != nil { + t.Fatalf("clear: %v", err) + } + if sett.GetOffboxTarget() != nil { + t.Fatal("R-729: the target is still in settings after the clear") + } + for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} { + if exists(filepath.Join(dir, f)) { + t.Errorf("R-545: %s is still on disk after clearing a target nothing depends on", f) + } + } + if res.KeptRepoPassword { + t.Error("KeptRepoPassword=true for a target with no escrow, no history and no hub package") + } + // Idempotent refusal: nothing left to clear. + if _, err := m.ClearOffboxTarget(); !errors.Is(err, ErrOffboxClearNoTarget) { + t.Errorf("second clear: want ErrOffboxClearNoTarget, got %v", err) + } +} + +// The password is the one secret that may protect history — kept whenever anything depends on it. +func TestR729_Clear_KeepsRepoPasswordWhenSomethingDependsOnIt(t *testing.T) { + cases := []struct { + name string + pkg bool + mutate func(*settings.OffboxTarget) + }{ + {"hub holds a sealed package (R-241)", true, func(*settings.OffboxTarget) {}}, + {"escrow confirmed", false, func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" }}, + {"a run succeeded once", false, func(o *settings.OffboxTarget) { o.LastSuccess = "2026-10-01T02:00:00Z" }}, + {"snapshots exist", false, func(o *settings.OffboxTarget) { o.SnapshotCount = 3 }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + tgt := ownNAS() + c.mutate(tgt) + m, sett, dir := clearFixture(t, c.pkg, tgt) + res, err := m.ClearOffboxTarget() + if err != nil { + t.Fatalf("clear: %v", err) + } + if !exists(filepath.Join(dir, "repo_password")) { + t.Fatal("R-241: the repository password was deleted although something depends on it") + } + if !res.KeptRepoPassword { + t.Error("KeptRepoPassword=false although the key was kept") + } + if exists(filepath.Join(dir, "ssh_key")) || exists(filepath.Join(dir, "known_hosts")) { + t.Error("the transport secrets must still be deleted") + } + if sett.GetOffboxTarget() != nil { + t.Error("the target must still be forgotten") + } + }) + } +} + +// Refusals leave every file and the setting exactly as they were. +func TestR729_Clear_Refusals(t *testing.T) { + check := func(t *testing.T, m *Manager, sett *settings.Settings, dir string, want error) { + t.Helper() + if _, err := m.ClearOffboxTarget(); !errors.Is(err, want) { + t.Fatalf("want %v, got %v", want, err) + } + if sett.GetOffboxTarget() == nil { + t.Error("a refused clear removed the target") + } + for _, f := range []string{"ssh_key", "known_hosts", "repo_password"} { + if !exists(filepath.Join(dir, f)) { + t.Errorf("a refused clear deleted %s", f) + } + } + } + t.Run("hub tier", func(t *testing.T) { + tgt := ownNAS() + tgt.Transport = settings.TransportRclonePinned + m, sett, dir := clearFixture(t, false, tgt) + check(t, m, sett, dir, ErrOffboxClearHubTier) + }) + t.Run("run in flight", func(t *testing.T) { + m, sett, dir := clearFixture(t, false, ownNAS()) + if err := m.acquireRunning(); err != nil { + t.Fatal(err) + } + defer m.releaseRunning() + check(t, m, sett, dir, ErrOffboxClearBusy) + }) + t.Run("abandonment countdown", func(t *testing.T) { + tgt := ownNAS() + tgt.AbandonStartedAt = time.Now().UTC().Format(time.RFC3339) + tgt.AbandonAt = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339) + tgt.AbandonRepoPath = "/srv/repo.orphaned-1" + m, sett, dir := clearFixture(t, false, tgt) + check(t, m, sett, dir, ErrOffboxClearAbandonOn) + }) + t.Run("hub-held deletion", func(t *testing.T) { + tgt := ownNAS() + tgt.AbandonHubDueAt = time.Now().Add(3 * 24 * time.Hour).UTC().Format(time.RFC3339) + m, sett, dir := clearFixture(t, false, tgt) + check(t, m, sett, dir, ErrOffboxClearAbandonOn) + }) +} + +// The single-flight is released after a clear — a later run is not wedged. +func TestR729_Clear_ReleasesSingleFlight(t *testing.T) { + m, _, _ := clearFixture(t, false, ownNAS()) + if _, err := m.ClearOffboxTarget(); err != nil { + t.Fatal(err) + } + if m.IsRunning() { + t.Fatal("the clear left the single-flight held") + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 69cb949..95336ff 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -417,6 +417,9 @@ "backups_remote.megosztott_mappak_mentese": "Backing up shared folders…", "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli": "Which apps are backed up to the remote store?", "backups_remote.mentes": "Save", + "backups_remote.tavoli_mentesi_cel_torlese": "Remove the remote backup destination", + "backups_remote.cel_torlese_magyarazat": "The box forgets this remote backup destination and deletes the SSH key that belongs to it. It does not touch the backups on the destination: they stay where they are. No remote backup runs after this until you set up a new destination.", + "backups_remote.cel_torlese_megerosites": "Yes, remove the destination", "backups_remote.mentes_2": "Backup:", "backups_remote.mentes_folyamatban": "Backup in progress", "backups_remote.napi_automatikus_tavoli_mentes_engedelye": "Daily automatic remote backup allowed", @@ -1419,6 +1422,11 @@ "flash.offbox.mgr_unreachable": "Backups cannot be managed right now. Try again in a few minutes; if it still does not work, contact Felhom support.", "flash.offbox.needs_confirmation": "The restore needs your confirmation.", "flash.offbox.not_orphaned": "There is no need to start a new remote backup: your current remote backup opens normally and keeps working. If you still see a problem with it, contact Felhom support.", + "flash.offbox.clear_needs_confirmation": "Removing the destination needs a confirmation.", + "flash.offbox.clear_hub_tier": "This remote storage is provided by Felhom, so it cannot be removed here. If you no longer want it, contact Felhom support.", + "flash.offbox.clear_busy": "A backup or a restore is running now. Wait until it finishes, then try again.", + "flash.offbox.clear_abandon_pending": "The deletion of the old, set-aside remote backups is in progress. While it runs, the destination cannot be removed. Try again later.", + "flash.offbox.target_cleared": "The remote backup destination is removed. The backups on the destination were not touched.", "flash.offbox.path_absolute": "The storage path must be absolute (it has to start with /).", "flash.offbox.recover_started": "The recovery has started — the status updates here.", "flash.offbox.repo_orphaned": "The remote store is orphaned — start a new remote backup first, the way the card shows.", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 9bace62..381db6e 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -413,6 +413,9 @@ "backups_remote.megosztott_mappak_mentese": "Megosztott mappák mentése…", "backups_remote.mely_alkalmazasok_mentodnek_a_tavoli": "Mely alkalmazások mentődnek a távoli tárolóra?", "backups_remote.mentes": "Mentés", + "backups_remote.tavoli_mentesi_cel_torlese": "Távoli mentési cél törlése", + "backups_remote.cel_torlese_magyarazat": "A gép elfelejti ezt a távoli mentési célt, és törli a hozzá tartozó SSH kulcsot. A célgépen lévő mentésekhez nem nyúl: azok ott maradnak, ahol vannak. Ezután nem fut távoli mentés, amíg új célt nem állítasz be.", + "backups_remote.cel_torlese_megerosites": "Igen, töröld a célt", "backups_remote.mentes_2": "Mentés:", "backups_remote.mentes_folyamatban": "Mentés folyamatban", "backups_remote.napi_automatikus_tavoli_mentes_engedelye": "Napi automatikus távoli mentés engedélyezve", @@ -1410,6 +1413,11 @@ "flash.offbox.mgr_unreachable": "A mentések kezelése most nem érhető el. Próbáld újra néhány perc múlva; ha akkor sem megy, keresd a Felhom ügyfélszolgálatát.", "flash.offbox.needs_confirmation": "A visszaállításhoz megerősítés szükséges.", "flash.offbox.not_orphaned": "Új távoli mentést nem kell indítani: a mostani távoli mentésed rendben megnyitható, és tovább működik. Ha mégis gondot látsz vele, keresd a Felhom ügyfélszolgálatát.", + "flash.offbox.clear_needs_confirmation": "A cél törléséhez megerősítés szükséges.", + "flash.offbox.clear_hub_tier": "Ezt a távoli tárhelyet a Felhom adja, ezért itt nem törölhető. Ha nem kéred tovább, keresd a Felhom ügyfélszolgálatát.", + "flash.offbox.clear_busy": "Most mentés vagy visszaállítás fut. Várd meg, amíg befejeződik, aztán próbáld újra.", + "flash.offbox.clear_abandon_pending": "A régi, félretett távoli mentések törlése folyamatban van. Amíg ez tart, a cél nem törölhető. Próbáld újra később.", + "flash.offbox.target_cleared": "A távoli mentési cél törölve. A célgépen lévő mentésekhez nem nyúltunk.", "flash.offbox.path_absolute": "A tárhely útvonalának abszolútnak kell lennie (/-rel kezdődjön).", "flash.offbox.recover_started": "A helyreállítás elindult — az állapot itt frissül.", "flash.offbox.repo_orphaned": "A távoli tároló elárvult — előbb indíts új távoli mentést a kártyán látható módon.", diff --git a/controller/internal/web/offbox_handlers.go b/controller/internal/web/offbox_handlers.go index b8f83f8..ca8b7bf 100644 --- a/controller/internal/web/offbox_handlers.go +++ b/controller/internal/web/offbox_handlers.go @@ -331,6 +331,49 @@ func (s *Server) offboxResetHandler(w http.ResponseWriter, r *http.Request) { offboxRedirect(w, r, "flash.offbox.restart_started", false) } +// offboxClearHandler is the household's „Távoli mentési cél törlése" press (R-729 / R-545): forget the +// target and delete its transport secrets, never the repository. Reveal-then-confirm like the reset +// (confirm=1). The refusals and what is kept are backup.ClearOffboxTarget's; this maps them to words. +func (s *Server) offboxClearHandler(w http.ResponseWriter, r *http.Request) { + if s.backupMgr == nil { + offboxRedirect(w, r, "flash.offbox.mgr_unavailable", true) + return + } + _ = r.ParseForm() + if r.FormValue("confirm") != "1" { + offboxRedirect(w, r, "flash.offbox.clear_needs_confirmation", true) + return + } + res, err := s.backupMgr.ClearOffboxTarget() + switch { + case errors.Is(err, backup.ErrOffboxClearNoTarget): + offboxRedirect(w, r, "flash.offbox.target_not_set", true) + return + case errors.Is(err, backup.ErrOffboxClearHubTier): + offboxRedirect(w, r, "flash.offbox.clear_hub_tier", true) + return + case errors.Is(err, backup.ErrOffboxClearBusy): + offboxRedirect(w, r, "flash.offbox.clear_busy", true) + return + case errors.Is(err, backup.ErrOffboxClearAbandonOn): + offboxRedirect(w, r, "flash.offbox.clear_abandon_pending", true) + return + case err != nil: + s.logger.Printf("[ERROR] [web] off-site target clear failed: %v", err) + offboxRedirect(w, r, "flash.offbox.save_failed", true) + return + } + if !res.KeptRepoPassword { + // The key is gone, so a copy still staged on the agent for an unfinished ceremony is a stale + // secret with nothing behind it. Best-effort, like the confirm path's wipe. + if werr := s.wipeStagedEscrow(r.Context()); werr != nil { + s.logger.Printf("[WARN] [web] off-site target cleared, but the agent-staged escrow copy was not wiped: %v", werr) + } + } + s.reportTriggerNow() + offboxRedirect(w, r, "flash.offbox.target_cleared", false) +} + // offboxStatusHandler (Part C) is the poll source for the remote-backup run status — the page polls it // after "Távoli mentés most" and flips to the terminal state without a manual reload. Session-auth'd. func (s *Server) offboxStatusHandler(w http.ResponseWriter, r *http.Request) { diff --git a/controller/internal/web/r729_offbox_clear_test.go b/controller/internal/web/r729_offbox_clear_test.go new file mode 100644 index 0000000..1638b3e --- /dev/null +++ b/controller/internal/web/r729_offbox_clear_test.go @@ -0,0 +1,119 @@ +package web + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/backup" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-729 / R-545 — the „Távoli mentési cél törlése" press: the route is wired, needs confirm=1, and +// clears the household's own target; the page offers it for an own target and NOT for the Felhom tier. + +func clearServer(t *testing.T, tgt *settings.OffboxTarget) (*Server, string) { + t.Helper() + s := noteServer(t) + s.cfg.Paths.DataDir = t.TempDir() + bm := backup.NewManager(s.cfg, s.settings, s.logger) + if err := s.settings.SetOffboxTarget(tgt); err != nil { + t.Fatal(err) + } + if err := bm.WriteOffboxSecrets("KEYMATERIAL", "nas.local ssh-ed25519 HOSTKEY"); err != nil { + t.Fatal(err) + } + s.backupMgr = bm + s.wipeStagedEscrowFn = func(context.Context) error { return nil } + return s, filepath.Join(s.cfg.Paths.DataDir, "offbox") +} + +func postClear(t *testing.T, s *Server, form string) (flash, flashErr string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/backup/offbox/clear", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + s.offboxClearHandler(w, req) + if w.Code != http.StatusFound { + t.Fatalf("want redirect, got %d", w.Code) + } + u, _ := url.Parse(w.Header().Get("Location")) + return u.Query().Get("flash"), u.Query().Get("flash_error") +} + +func TestR729_ClearHandler_NeedsConfirmAndThenClears(t *testing.T) { + s, dir := clearServer(t, &settings.OffboxTarget{Enabled: false, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo"}) + + // Without confirm=1 nothing happens. + if _, fe := postClear(t, s, ""); fe != "flash.offbox.clear_needs_confirmation" { + t.Fatalf("no-confirm: flash_error=%q", fe) + } + if s.settings.GetOffboxTarget() == nil { + t.Fatal("an unconfirmed press removed the target") + } + + f, fe := postClear(t, s, "confirm=1") + if fe != "" || f != "flash.offbox.target_cleared" { + t.Fatalf("confirmed clear: flash=%q flash_error=%q", f, fe) + } + if s.settings.GetOffboxTarget() != nil { + t.Fatal("R-729: the target survived a confirmed clear") + } + if _, err := os.Stat(filepath.Join(dir, "ssh_key")); !os.IsNotExist(err) { + t.Fatalf("R-545: the SSH key survived a confirmed clear (stat err=%v)", err) + } + // Both languages say the backups on the destination were not touched. + if hu := s.msgLang("hu", f); !strings.Contains(hu, "nem ny") { + t.Errorf("hu success text does not say the destination was untouched: %q", hu) + } + if en := s.msgLang("en", f); !strings.Contains(en, "not touched") { + t.Errorf("en success text does not say the destination was untouched: %q", en) + } +} + +func TestR729_ClearHandler_RefusesHubTier(t *testing.T) { + s, dir := clearServer(t, &settings.OffboxTarget{Enabled: true, Host: "box.example", Port: 23, User: "u1", RepoPath: "/home/repo", Transport: settings.TransportRclonePinned}) + if _, fe := postClear(t, s, "confirm=1"); fe != "flash.offbox.clear_hub_tier" { + t.Fatalf("hub tier: flash_error=%q", fe) + } + if s.settings.GetOffboxTarget() == nil { + t.Fatal("the Felhom tier was removed from the box") + } + if _, err := os.Stat(filepath.Join(dir, "ssh_key")); err != nil { + t.Fatalf("the Felhom tier's key was deleted: %v", err) + } +} + +// Render test per branch of the template gate (seam-built-but-never-wired lesson). +func TestR729_RemotePage_OffersClearOnlyForOwnTarget(t *testing.T) { + d := splitTestData() + html := renderBackupPage(t, "backups_remote", d) + if !strings.Contains(html, `action="/backup/offbox/clear"`) { + t.Fatal("R-729: an own target renders no clear press") + } + d["Offbox"] = &settings.OffboxTarget{Enabled: true, Host: "box.example", LastStatus: "ok", EscrowState: "escrowed", Transport: settings.TransportRclonePinned} + if strings.Contains(renderBackupPage(t, "backups_remote", d), `action="/backup/offbox/clear"`) { + t.Fatal("R-729: the Felhom tier must not be offered for removal") + } + d["Offbox"] = nil + d["OffboxConfigured"] = false + if strings.Contains(renderBackupPage(t, "backups_remote", d), `action="/backup/offbox/clear"`) { + t.Fatal("R-729: no target, yet a clear press renders") + } +} + +// The route reaches the handler (a case only in a comment is the decoy this guards against). +func TestR729_ClearRouteIsWired(t *testing.T) { + src, err := os.ReadFile("server.go") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(src), `path == "/backup/offbox/clear" && r.Method == http.MethodPost:`+" // R-729 / R-545\n\t\ts.offboxClearHandler(w, r)") { + t.Fatal("R-729: /backup/offbox/clear is not routed to offboxClearHandler") + } +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index dada471..d09fc9c 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -753,6 +753,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.offboxRunHandler(w, r) case path == "/backup/offbox/reset" && r.Method == http.MethodPost: s.offboxResetHandler(w, r) + case path == "/backup/offbox/clear" && r.Method == http.MethodPost: // R-729 / R-545 + s.offboxClearHandler(w, r) case path == "/backup/offbox/status" && r.Method == http.MethodGet: s.offboxStatusHandler(w, r) case path == "/backup/offbox/restore" && r.Method == http.MethodPost: diff --git a/controller/internal/web/templates/backups_remote.html b/controller/internal/web/templates/backups_remote.html index b604fbc..8fa76cf 100644 --- a/controller/internal/web/templates/backups_remote.html +++ b/controller/internal/web/templates/backups_remote.html @@ -270,6 +270,20 @@ + {{/* R-729 / R-545: the household's own target can be removed. Never the repository on it; the + Felhom-provided tier (Pinned) is not offered. Reveal-then-confirm, like the orphan reset. */}} + {{if .Offbox}}{{if not .Offbox.Pinned}} +
+ + +
+ {{end}}{{end}} {{end}} diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html b/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html index f378e61..c9bd74b 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_abandon_overdue.html @@ -276,6 +276,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html index 22a78ef..9f34eb8 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_error.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_error.html @@ -262,6 +262,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html index bf77c65..ca5ca8d 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_escrowed.html @@ -265,6 +265,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html index 8d03407..195e0b5 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_full.html @@ -363,6 +363,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html index 14ecc60..ddb7e33 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_incomplete.html @@ -277,6 +277,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html index fb23c02..f4712a0 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf.html @@ -213,6 +213,8 @@ + + diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html index 50dd24f..1417f96 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_notconf_hub.html @@ -214,6 +214,8 @@ + + diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html b/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html index 7ac80ff..f33e7ea 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_offsite_offer_fit.html @@ -278,6 +278,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html index fbf8bab..8dd8792 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_agent.html @@ -268,6 +268,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html index dcc2806..c55f6e1 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_pending_old.html @@ -268,6 +268,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html index bcad14c..da9284d 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_running.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_running.html @@ -282,6 +282,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html index 22b8e1f..668b887 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale.html @@ -266,6 +266,19 @@ + + +
+ + +
+ diff --git a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html index f7f0831..5317e57 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html +++ b/controller/internal/web/testdata/i18n_parity/backups_remote_stale_old.html @@ -266,6 +266,19 @@ + + +
+ + +
+ diff --git a/controller/scripts/i18n_go_keys.json b/controller/scripts/i18n_go_keys.json index fbcad04..f706499 100644 --- a/controller/scripts/i18n_go_keys.json +++ b/controller/scripts/i18n_go_keys.json @@ -136,6 +136,11 @@ "err.backup.unit_version_mismatch": "BORN AS A KEY, v0.275.0 (R-696, `07` §6.6 which version a restore brings back) -- a NEW sentence, never a Go literal. Pinned by internal/backup/a_version_travel_test.go / internal/web/a_version_travel_test.go.", "deploy.login_from_backup": "BORN AS A KEY, v0.275.0 (R-694) -- a NEW sentence, never a Go literal. Pinned by internal/web/r694_restored_login_test.go.", "flash.offbox.enabled_all": "v0.283.0 decision 50: born as a key (the one-press off-site offer)", + "flash.offbox.clear_needs_confirmation": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", + "flash.offbox.clear_hub_tier": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", + "flash.offbox.clear_busy": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", + "flash.offbox.clear_abandon_pending": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", + "flash.offbox.target_cleared": "R-729/R-545: born as a key (the household's off-site target clear press); pinned by TestR729_ClearHandler_*", "login.msg.empty_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", "login.msg.rate_limited": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.", "login.msg.wrong_password": "R-753 (v0.286.0) -- the dashboard login's messages moved to the informal voice on purpose (brief 2026-10-01: formal „Kérjük adja meg …\" → informal), so byte parity with the base literal cannot hold. Pinned in both languages by TestLoginMessagesFollowTheReader.",