// Package backup creates and restores SQLite database backups. // // Backups use SQLite's VACUUM INTO, which writes a transactionally consistent // copy of the database while it is being written to. Copying the .db file with // the filesystem would capture a torn snapshot whose committed data lives in a // write-ahead log the copy does not include. package backup import ( "context" "database/sql" "errors" "fmt" "io" "log/slog" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/owen/vibedns/internal/database" ) // pendingSuffix marks a restore staged for the next start. const pendingSuffix = ".restore-pending" // Info describes one backup file. type Info struct { Name string `json:"name"` Path string `json:"path"` SizeBytes int64 `json:"size_bytes"` CreatedAt time.Time `json:"created_at"` } // SizeMB renders the size for the UI. func (i Info) SizeMB() float64 { return float64(i.SizeBytes) / (1024 * 1024) } // Manager runs manual and scheduled backups. type Manager struct { db *database.DB log *slog.Logger mu sync.RWMutex enabled bool dir string interval time.Duration retention int running sync.Mutex // serialises backup runs lastRun time.Time lastError string wg sync.WaitGroup once sync.Once } // Config controls the backup schedule. type Config struct { Enabled bool Directory string IntervalHours int Retention int } // New creates a backup manager. func New(db *database.DB, log *slog.Logger, cfg Config) *Manager { m := &Manager{db: db, log: log} m.SetConfig(cfg) return m } // SetConfig replaces the backup configuration. func (m *Manager) SetConfig(cfg Config) { if cfg.IntervalHours < 1 { cfg.IntervalHours = 24 } if cfg.Retention < 1 { cfg.Retention = 7 } m.mu.Lock() m.enabled = cfg.Enabled m.dir = strings.TrimSpace(cfg.Directory) m.interval = time.Duration(cfg.IntervalHours) * time.Hour m.retention = cfg.Retention m.mu.Unlock() } // Directory returns the configured backup directory. func (m *Manager) Directory() string { m.mu.RLock() defer m.mu.RUnlock() return m.dir } // Run creates a backup now and prunes old ones. func (m *Manager) Run(ctx context.Context) (Info, error) { m.running.Lock() defer m.running.Unlock() m.mu.RLock() dir, retention := m.dir, m.retention m.mu.RUnlock() if dir == "" { return Info{}, errors.New("no backup directory is configured") } if err := os.MkdirAll(dir, 0o750); err != nil { return Info{}, fmt.Errorf("create backup directory %s: %w", dir, err) } name := fmt.Sprintf("vibedns-%s.db", time.Now().UTC().Format("20060102-150405")) path := filepath.Join(dir, name) // VACUUM INTO fails if the target exists, which is exactly the behaviour we // want: a backup must never silently overwrite another. if _, err := os.Stat(path); err == nil { return Info{}, fmt.Errorf("a backup named %s already exists", name) } // Checkpointing first keeps the WAL small and the copy quick. if err := m.db.Checkpoint(ctx); err != nil { m.log.Warn("could not checkpoint the write-ahead log before backup", "error", err) } if _, err := m.db.ExecContext(ctx, `VACUUM INTO ?`, path); err != nil { m.recordError(err) return Info{}, fmt.Errorf("write backup to %s: %w", path, err) } if err := os.Chmod(path, 0o600); err != nil { m.log.Warn("could not restrict backup file permissions", "path", path, "error", err) } fi, err := os.Stat(path) if err != nil { m.recordError(err) return Info{}, fmt.Errorf("verify backup %s: %w", path, err) } m.mu.Lock() m.lastRun = time.Now() m.lastError = "" m.mu.Unlock() info := Info{Name: name, Path: path, SizeBytes: fi.Size(), CreatedAt: fi.ModTime()} m.log.Info("database backup created", "path", path, "bytes", info.SizeBytes) if removed, err := Prune(dir, retention); err != nil { m.log.Warn("could not prune old backups", "error", err) } else if removed > 0 { m.log.Info("pruned old backups", "removed", removed, "retention", retention) } return info, nil } func (m *Manager) recordError(err error) { m.mu.Lock() m.lastError = err.Error() m.mu.Unlock() } // List returns the backups in the configured directory, newest first. func (m *Manager) List() ([]Info, error) { return List(m.Directory()) } // List returns the backups in dir, newest first. func List(dir string) ([]Info, error) { if dir == "" { return nil, nil } entries, err := os.ReadDir(dir) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("read backup directory %s: %w", dir, err) } var out []Info for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".db") { continue } fi, err := e.Info() if err != nil { continue } out = append(out, Info{ Name: e.Name(), Path: filepath.Join(dir, e.Name()), SizeBytes: fi.Size(), CreatedAt: fi.ModTime(), }) } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out, nil } // Prune deletes all but the newest `keep` backups. func Prune(dir string, keep int) (int, error) { if keep < 1 { return 0, nil } backups, err := List(dir) if err != nil { return 0, err } if len(backups) <= keep { return 0, nil } removed := 0 for _, b := range backups[keep:] { if err := os.Remove(b.Path); err != nil { return removed, fmt.Errorf("remove old backup %s: %w", b.Name, err) } removed++ } return removed, nil } // Resolve validates that name refers to a backup inside dir and returns its // full path. It exists to keep a crafted name from escaping the directory. func Resolve(dir, name string) (string, error) { if dir == "" { return "", errors.New("no backup directory is configured") } clean := filepath.Base(filepath.Clean("/" + name)) if clean == "." || clean == "/" || clean == "" { return "", fmt.Errorf("%q is not a valid backup name", name) } if !strings.HasSuffix(clean, ".db") { return "", fmt.Errorf("%q is not a backup file", name) } path := filepath.Join(dir, clean) if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("backup %s was not found", clean) } return path, nil } // Delete removes one backup by name. func Delete(dir, name string) error { path, err := Resolve(dir, name) if err != nil { return err } if err := os.Remove(path); err != nil { return fmt.Errorf("delete backup %s: %w", name, err) } return nil } // Start launches the scheduled backup loop. func (m *Manager) Start(ctx context.Context) { m.once.Do(func() { m.wg.Add(1) go m.loop(ctx) }) } // Stop waits for the scheduler to exit. func (m *Manager) Stop() { m.wg.Wait() } func (m *Manager) loop(ctx context.Context) { defer m.wg.Done() // Check every few minutes rather than sleeping for the whole interval, so // a settings change takes effect promptly. const tick = 5 * time.Minute t := time.NewTicker(tick) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: m.mu.RLock() enabled, interval, last := m.enabled, m.interval, m.lastRun m.mu.RUnlock() if !enabled { continue } if !last.IsZero() && time.Since(last) < interval { continue } if _, err := m.Run(ctx); err != nil { m.log.Error("scheduled backup failed", "error", err) } } } } // Status describes the backup subsystem for the settings page. type Status struct { Enabled bool `json:"enabled"` Directory string `json:"directory"` IntervalHours int `json:"interval_hours"` Retention int `json:"retention"` LastRun time.Time `json:"last_run"` LastError string `json:"last_error,omitempty"` Count int `json:"count"` TotalBytes int64 `json:"total_bytes"` } // Status returns the current backup status. func (m *Manager) Status() Status { m.mu.RLock() s := Status{ Enabled: m.enabled, Directory: m.dir, IntervalHours: int(m.interval / time.Hour), Retention: m.retention, LastRun: m.lastRun, LastError: m.lastError, } m.mu.RUnlock() if backups, err := List(s.Directory); err == nil { s.Count = len(backups) for _, b := range backups { s.TotalBytes += b.SizeBytes } } return s } // --- Restore ------------------------------------------------------------ // StageRestore validates a backup and stages it to replace the live database // on the next start. // // Overwriting the database file underneath a running process would leave open // connections reading a file that no longer exists, so the swap is deferred to // startup, where nothing is holding the database open. func StageRestore(dbPath, backupPath string) error { if err := Verify(backupPath); err != nil { return err } pending := dbPath + pendingSuffix if err := copyFile(backupPath, pending, 0o600); err != nil { return fmt.Errorf("stage restore: %w", err) } return nil } // PendingRestore reports whether a restore is staged. func PendingRestore(dbPath string) (string, bool) { p := dbPath + pendingSuffix if _, err := os.Stat(p); err == nil { return p, true } return "", false } // CancelRestore discards a staged restore. func CancelRestore(dbPath string) error { p := dbPath + pendingSuffix if err := os.Remove(p); err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("cancel staged restore: %w", err) } return nil } // ApplyPendingRestore swaps a staged backup into place. It must be called // before the database is opened. // // The database being replaced is preserved alongside it, so a restore that // turns out to be the wrong choice is still recoverable. func ApplyPendingRestore(dbPath string, log *slog.Logger) (bool, error) { pending := dbPath + pendingSuffix if _, err := os.Stat(pending); err != nil { return false, nil } if _, err := os.Stat(dbPath); err == nil { safety := fmt.Sprintf("%s.pre-restore-%s", dbPath, time.Now().UTC().Format("20060102-150405")) if err := os.Rename(dbPath, safety); err != nil { return false, fmt.Errorf("preserve the current database before restoring: %w", err) } log.Info("previous database preserved", "path", safety) } // The WAL and shared-memory sidecars belong to the replaced database and // would corrupt the restored one. for _, suffix := range []string{"-wal", "-shm"} { if err := os.Remove(dbPath + suffix); err != nil && !errors.Is(err, os.ErrNotExist) { return false, fmt.Errorf("remove stale %s file: %w", suffix, err) } } if err := os.Rename(pending, dbPath); err != nil { return false, fmt.Errorf("move the staged database into place: %w", err) } if err := os.Chmod(dbPath, 0o600); err != nil { log.Warn("could not restrict restored database permissions", "error", err) } log.Info("database restored from backup", "path", dbPath) return true, nil } // Verify checks that a file is a usable vibedns database. func Verify(path string) error { fi, err := os.Stat(path) if err != nil { return fmt.Errorf("backup file %s cannot be read: %w", filepath.Base(path), err) } if fi.Size() < 512 { return fmt.Errorf("backup file %s is too small to be a database", filepath.Base(path)) } db, err := sql.Open("sqlite", "file:"+path+"?mode=ro&_pragma=query_only(1)") if err != nil { return fmt.Errorf("backup file %s could not be opened: %w", filepath.Base(path), err) } defer db.Close() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() var check string if err := db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&check); err != nil { return fmt.Errorf("backup file %s failed its integrity check: %w", filepath.Base(path), err) } if check != "ok" { return fmt.Errorf("backup file %s failed its integrity check: %s", filepath.Base(path), check) } var n int err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'`).Scan(&n) if err != nil || n == 0 { return fmt.Errorf("%s does not look like a vibedns database: no migration table was found", filepath.Base(path)) } return nil } func copyFile(src, dst string, mode os.FileMode) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() tmp := dst + ".tmp" out, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) if err != nil { return err } if _, err := io.Copy(out, in); err != nil { out.Close() os.Remove(tmp) return err } if err := out.Sync(); err != nil { out.Close() os.Remove(tmp) return err } if err := out.Close(); err != nil { os.Remove(tmp) return err } return os.Rename(tmp, dst) }