package backup import ( "fmt" "os" "path/filepath" "sort" "strings" "time" ) // Verification copies — the listing/delete surface for `/backups/offsite-restore/`. // // WHY THIS EXISTS (v0.147.0, feedback slice 4a): an offsite verification restore wrote its result to // a path the customer was never told, and nothing anywhere listed what had accumulated. Pressing // „Ellenőrző visszaállítás" produced a flash saying it had been restored "to a verification folder // on the drive" — which folder, on which drive, and how much space it was now using were all // invisible. So copies piled up and the only way to find them was SSH. // // The path segments were already open-coded in three places; offsiteRestoreRootFor() is now the one // place `backups/offsite-restore` is spelled, and offboxRestoreScratchDir() builds on it. // OffsiteRestoreCopy is one verification copy on disk. type OffsiteRestoreCopy struct { Stack string `json:"stack"` // app slug, or SharesPseudoStack for the shares copy Path string `json:"path"` // absolute path — the thing the customer could not see Size int64 `json:"size"` // bytes SizeHuman string `json:"size_human"` // pre-humanized for the template Created time.Time `json:"created"` // dir mtime; restic writes the tree once, so this is the restore time } // offsiteRestoreRootFor returns `/backups/offsite-restore` for a drive path. THE single place // these segments are written. func (m *Manager) offsiteRestoreRootFor(drivePath string) string { return filepath.Join(m.namespaceRoot(drivePath), "backups", "offsite-restore") } // offsiteRestoreDriveRoots returns every drive path a verification copy could live under, in the same // preference order offboxRestoreScratchDir uses to CHOOSE one — so listing can never miss a copy the // restore path was capable of creating. Deduplicated, order preserved. func (m *Manager) offsiteRestoreDriveRoots() []string { seen := map[string]bool{} var roots []string add := func(p string) { p = strings.TrimSpace(p) if p == "" || seen[p] { return } seen[p] = true roots = append(roots, p) } // App HDDs first (offboxRestoreScratchDir's rule 1), then every schedulable path (rules 2 and 3). if m.stackProvider != nil { for _, s := range m.stackProvider.ListDeployedStacks() { add(m.stackProvider.GetStackHDDPath(s.Name)) } } if m.settings != nil { for _, sp := range m.settings.GetSchedulableStoragePaths() { add(sp.Path) } } return roots } // ListOffsiteRestoreCopies enumerates every verification copy across every candidate drive, newest // first. Missing directories are not an error — "none yet" is the normal state. func (m *Manager) ListOffsiteRestoreCopies() []OffsiteRestoreCopy { sizer := m.offboxSize() var out []OffsiteRestoreCopy seen := map[string]bool{} for _, drive := range m.offsiteRestoreDriveRoots() { root := m.offsiteRestoreRootFor(drive) entries, err := os.ReadDir(root) if err != nil { continue // no copies on this drive (or the drive is not mounted) — not an error } for _, e := range entries { if !e.IsDir() { continue } p := filepath.Join(root, e.Name()) if seen[p] { continue // two stacks can resolve to the same drive; list each path once } seen[p] = true c := OffsiteRestoreCopy{Stack: e.Name(), Path: p} if fi, err := e.Info(); err == nil { c.Created = fi.ModTime() } c.Size = sizer(p) c.SizeHuman = humanizeBytes(c.Size) out = append(out, c) } } sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) return out } // DeleteOffsiteRestoreCopy removes ONE verification copy. // // This is the only delete path v0.147.0 adds, so it is guarded twice over. The stack name must pass // isSafeStackName (no separators, no traversal), and the resolved path must sit STRICTLY INSIDE a // `backups/offsite-restore` root that this Manager itself computed — a path that merely looks right // is refused. Both checks are on the RESOLVED path, not the input, so a symlinked scratch cannot // walk the delete out of the sandbox. func (m *Manager) DeleteOffsiteRestoreCopy(stack string) error { if !isSafeStackName(stack) { return fmt.Errorf("érvénytelen alkalmazásnév") } for _, drive := range m.offsiteRestoreDriveRoots() { root := m.offsiteRestoreRootFor(drive) target := filepath.Join(root, stack) fi, err := os.Stat(target) if err != nil || !fi.IsDir() { continue } // Prefix safety: only ever remove strictly inside `backups/offsite-restore/`. Same shape as // the F5 stale-primary prune (backup.go) — refuse loudly rather than best-effort skip, since // reaching here with an out-of-sandbox path means a helper above is wrong. cleanTarget := filepath.Clean(target) cleanRoot := filepath.Clean(root) + string(filepath.Separator) if !strings.HasPrefix(cleanTarget+string(filepath.Separator), cleanRoot) { m.logger.Printf("[WARN] [offbox] refusing to delete verification copy outside %s: %s", root, cleanTarget) return fmt.Errorf("a törlés útvonala kívül esik az ellenőrző mappán") } if err := os.RemoveAll(cleanTarget); err != nil { return fmt.Errorf("a másolat törlése nem sikerült: %w", err) } m.logger.Printf("[INFO] [offbox] deleted verification copy: %s", cleanTarget) return nil } return fmt.Errorf("nincs ilyen ellenőrző másolat") } // OffsiteRestoreScratchPath exposes WHERE a verification restore for stack would land, so the UI can // name the full path in the completion message instead of saying "a verification folder somewhere". func (m *Manager) OffsiteRestoreScratchPath(stack string) string { scratch, _, err := m.offboxRestoreScratchDir(stack) if err != nil { return "" } return scratch }