internal/server/backup_schedule.go
Rama main · réplica de lectura del repositorio original.
// SPDX-License-Identifier: Apache-2.0
package server
import (
"context"
"fmt"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
appdb "github.com/X4Applegate/caddyui/internal/db"
"github.com/X4Applegate/caddyui/internal/models"
)
// Scheduled off-host database backups (issue #104). A background loop writes a
// consistent SQLite snapshot (VACUUM INTO) to an admin-configured directory on
// an interval, keeping the newest N. Point the directory at a mounted volume /
// network share / object-store gateway to get the copy off the host. MariaDB is
// skipped — those installs use their platform's own backup tooling.
const (
settingBackupScheduleEnabled = "backup_schedule_enabled"
settingBackupScheduleDir = "backup_schedule_dir"
settingBackupScheduleInterval = "backup_schedule_interval_hours"
settingBackupScheduleKeep = "backup_schedule_keep"
backupFilePrefix = "caddyui-backup-"
backupFileSuffix = ".db"
)
type backupScheduleConfig struct {
Enabled bool
Dir string
IntervalHours int
Keep int // 0 = keep all
}
func loadBackupScheduleConfig(getSetting func(string) string) backupScheduleConfig {
interval, _ := strconv.Atoi(strings.TrimSpace(getSetting(settingBackupScheduleInterval)))
keep, _ := strconv.Atoi(strings.TrimSpace(getSetting(settingBackupScheduleKeep)))
return backupScheduleConfig{
Enabled: getSetting(settingBackupScheduleEnabled) == "1",
Dir: strings.TrimSpace(getSetting(settingBackupScheduleDir)),
IntervalHours: interval,
Keep: keep,
}
}
func (s *Server) backupScheduleConfig() backupScheduleConfig {
return loadBackupScheduleConfig(func(k string) string { return mustGetSetting(s.DB, k) })
}
// StartBackupScheduler runs the scheduled-backup loop until ctx is cancelled.
// It wakes every 10 minutes and backs up when the newest existing backup is
// older than the configured interval, so interval/dir changes take effect
// without a restart and a restart doesn't force an immediate backup.
func (s *Server) StartBackupScheduler(ctx context.Context) {
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
// A short initial delay so startup isn't competing with the first backup.
select {
case <-ctx.Done():
return
case <-time.After(1 * time.Minute):
}
for {
s.maybeRunScheduledBackup()
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
// maybeRunScheduledBackup runs a backup if one is due per the configured interval.
func (s *Server) maybeRunScheduledBackup() {
cfg := s.backupScheduleConfig()
if !cfg.Enabled || cfg.Dir == "" || cfg.IntervalHours <= 0 {
return
}
if appdb.BackendOf(s.DB) == appdb.BackendMariaDB {
return
}
newest := newestBackupModTime(cfg.Dir)
if !newest.IsZero() && time.Since(newest) < time.Duration(cfg.IntervalHours)*time.Hour {
return
}
if _, err := s.runScheduledBackupOnce(cfg.Dir, cfg.Keep); err != nil {
log.Printf("scheduled backup: %v", err)
_ = models.LogActivity(s.DB, 0, "system", "backup_scheduled_failed", cfg.Dir, err.Error(), false)
}
}
// runScheduledBackupOnce writes one VACUUM INTO snapshot into dir and prunes to
// the newest `keep` backups. Returns the written file path. SQLite only.
func (s *Server) runScheduledBackupOnce(dir string, keep int) (string, error) {
if appdb.BackendOf(s.DB) == appdb.BackendMariaDB {
return "", fmt.Errorf("scheduled backups are SQLite-only; MariaDB is managed by your database server")
}
dir = strings.TrimSpace(dir)
if dir == "" {
return "", fmt.Errorf("no backup directory configured")
}
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", fmt.Errorf("create backup dir: %w", err)
}
// Millisecond resolution so a scheduled run and a manual "Back up now" in
// the same second can't collide on the same path (VACUUM INTO fails if the
// target already exists).
ts := time.Now().Format("20060102-150405.000")
path := filepath.Join(dir, backupFilePrefix+ts+backupFileSuffix)
if _, err := s.DB.Exec("VACUUM INTO ?", path); err != nil {
return "", fmt.Errorf("vacuum into %s: %w", path, err)
}
if err := os.Chmod(path, 0o640); err != nil {
log.Printf("scheduled backup: chmod %s: %v", path, err)
}
pruneBackups(dir, keep)
_ = models.LogActivity(s.DB, 0, "system", "backup_scheduled", path, fmt.Sprintf("keep=%d", keep), true)
return path, nil
}
// postBackupRun runs one backup immediately (the "Back up now" button). Admin
// only. Uses the *saved* backup directory/retention — not values from the
// request — so an untrusted request path never reaches the filesystem (Save
// the directory first, then Back up now).
func (s *Server) postBackupRun(w http.ResponseWriter, r *http.Request) {
u := s.currentUser(r)
if u == nil || u.Role != models.RoleAdmin {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
cfg := s.backupScheduleConfig()
if cfg.Dir == "" {
http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape("set a backup directory and Save first"), http.StatusSeeOther)
return
}
path, err := s.runScheduledBackupOnce(cfg.Dir, cfg.Keep)
if err != nil {
http.Redirect(w, r, "/settings/backup?backuperr="+url.QueryEscape(err.Error()), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/settings/backup?backupok="+url.QueryEscape(filepath.Base(path)), http.StatusSeeOther)
}
// backupFiles returns the backup files in dir, sorted newest-first by mod time.
func backupFiles(dir string) []os.DirEntry {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []os.DirEntry
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, backupFilePrefix) && strings.HasSuffix(name, backupFileSuffix) {
out = append(out, e)
}
}
sort.Slice(out, func(i, j int) bool {
ii, _ := out[i].Info()
ji, _ := out[j].Info()
if ii == nil || ji == nil {
return false
}
return ii.ModTime().After(ji.ModTime())
})
return out
}
// newestBackupModTime returns the mod time of the most recent backup in dir, or
// the zero time if there are none.
func newestBackupModTime(dir string) time.Time {
files := backupFiles(dir)
if len(files) == 0 {
return time.Time{}
}
info, err := files[0].Info()
if err != nil {
return time.Time{}
}
return info.ModTime()
}
// pruneBackups deletes all but the newest `keep` backups in dir. keep <= 0
// keeps everything.
func pruneBackups(dir string, keep int) {
if keep <= 0 {
return
}
files := backupFiles(dir)
if keep >= len(files) {
return
}
for _, e := range files[keep:] {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}