diff --git a/CHANGELOG.md b/CHANGELOG.md index 1975d84..a1caf50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · Versi --- +## [2.52.0] - 2026-09-17 - Scheduled backups & SSO login + +### Added + +- **Scheduled off-host database backups** ([issue #104](https://github.com/X4Applegate/caddyui/issues/104)): under **Settings → Backup**, enable automatic SQLite snapshots (`VACUUM INTO`) written to a directory on an interval, keeping the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host. A **Back up now** button runs one on demand. (MariaDB installs continue to use their platform's own backup tooling.) + ## [2.51.0] - 2026-09-17 - Per-host rate limiting ### Added diff --git a/README.md b/README.md index 35950cd..e31f82c 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,9 @@ run CaddyUI directly in an LXC, VM, or bare-metal host. ## Features -### Analytics, certificates and local services in v2.39 – v2.51 +### Analytics, certificates and local services in v2.39 – v2.52 +- **Scheduled off-host backups** *(v2.52.0)* — automatic SQLite snapshots to a directory on an interval (keep the newest N), plus a **Back up now** button, under Settings → Backup. Point the directory at a mounted volume or share to keep copies off the host. - **Per-host rate limiting** *(v2.51.0)* — cap a host at *N requests per M seconds, per client IP* (429 on excess), via the `caddy-ratelimit` module now built into `Dockerfile.caddy`. Rebuild your custom Caddy image to use it. - **Block an IP from analytics** *(v2.50.0)* — a visitor's drill-down page gains one-click **Block on this host** and **Block everywhere (fleet-wide)** actions; the global blocklist is managed under Settings → Security. Turns "who's probing me?" into a one-click 403. - **Configurable verification resolver** *(v2.49.0)* — point the deploy/readiness DNS checks at trusted, internal, or split-horizon resolvers (plain DNS servers or a DoH URL) under **Settings → DNS**, instead of the default Cloudflare DNS-over-HTTPS — for networks where outbound DNS to Cloudflare is filtered. diff --git a/cmd/caddyui/main.go b/cmd/caddyui/main.go index 9f320fc..c62d208 100644 --- a/cmd/caddyui/main.go +++ b/cmd/caddyui/main.go @@ -139,6 +139,8 @@ Fix by: srv.StartCustomCertificateProbes(pollerCtx) // v2.42.0: safety-net pass for certificate exports (event-driven otherwise). srv.StartCertificateExports(pollerCtx) + // v2.52.0 (issue #104): scheduled off-host database backups (SQLite). + srv.StartBackupScheduler(pollerCtx) // Opt-in startup sync. Default: no initial sync — pushing an empty config // would wipe Caddy's existing routes. Set CADDYUI_SYNC_ON_START=1 once all diff --git a/internal/server/backup_schedule.go b/internal/server/backup_schedule.go new file mode 100644 index 0000000..d343d63 --- /dev/null +++ b/internal/server/backup_schedule.go @@ -0,0 +1,207 @@ +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())) + } +} diff --git a/internal/server/backup_schedule_test.go b/internal/server/backup_schedule_test.go new file mode 100644 index 0000000..6312a65 --- /dev/null +++ b/internal/server/backup_schedule_test.go @@ -0,0 +1,108 @@ +package server + +import ( + "os" + "path/filepath" + "testing" + "time" + + appdb "github.com/X4Applegate/caddyui/internal/db" +) + +func TestLoadBackupScheduleConfig(t *testing.T) { + vals := map[string]string{ + settingBackupScheduleEnabled: "1", + settingBackupScheduleDir: " /backups ", + settingBackupScheduleInterval: "24", + settingBackupScheduleKeep: "7", + } + cfg := loadBackupScheduleConfig(func(k string) string { return vals[k] }) + if !cfg.Enabled || cfg.Dir != "/backups" || cfg.IntervalHours != 24 || cfg.Keep != 7 { + t.Fatalf("cfg = %+v", cfg) + } + // Disabled + junk numbers default to zero. + cfg = loadBackupScheduleConfig(func(k string) string { + if k == settingBackupScheduleInterval { + return "nope" + } + return "" + }) + if cfg.Enabled || cfg.Dir != "" || cfg.IntervalHours != 0 || cfg.Keep != 0 { + t.Fatalf("empty cfg = %+v", cfg) + } +} + +func TestPruneBackupsKeepsNewest(t *testing.T) { + dir := t.TempDir() + base := time.Now().Add(-10 * time.Hour) + // Create 5 backups with increasing mod times: bk0 oldest … bk4 newest. + for i := 0; i < 5; i++ { + p := filepath.Join(dir, backupFilePrefix+string(rune('a'+i))+backupFileSuffix) + if err := os.WriteFile(p, []byte("x"), 0o640); err != nil { + t.Fatal(err) + } + mt := base.Add(time.Duration(i) * time.Hour) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + } + // A non-backup file must never be touched. + other := filepath.Join(dir, "keepme.txt") + _ = os.WriteFile(other, []byte("x"), 0o640) + + pruneBackups(dir, 2) + + remaining := backupFiles(dir) + if len(remaining) != 2 { + t.Fatalf("kept %d backups, want 2", len(remaining)) + } + // The two newest (e, d) must survive; ordered newest-first. + if remaining[0].Name() != backupFilePrefix+"e"+backupFileSuffix || remaining[1].Name() != backupFilePrefix+"d"+backupFileSuffix { + t.Fatalf("kept the wrong files: %s, %s", remaining[0].Name(), remaining[1].Name()) + } + if _, err := os.Stat(other); err != nil { + t.Fatalf("non-backup file was removed: %v", err) + } + + // keep=0 keeps everything. + pruneBackups(dir, 0) + if len(backupFiles(dir)) != 2 { + t.Fatalf("keep=0 should not prune") + } +} + +func TestRunScheduledBackupOnce(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "caddyui.db") + conn, err := appdb.Open(dbPath) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = conn.Close() }) + s := &Server{DB: conn, DBPath: dbPath} + + dir := filepath.Join(t.TempDir(), "backups") + // Three backups, keep 2 → the oldest is pruned. + var last string + for i := 0; i < 3; i++ { + p, err := s.runScheduledBackupOnce(dir, 2) + if err != nil { + t.Fatal(err) + } + last = p + if fi, err := os.Stat(p); err != nil || fi.Size() == 0 { + t.Fatalf("backup %q not written: %v", p, err) + } + time.Sleep(1100 * time.Millisecond) // distinct second-resolution timestamps + } + files := backupFiles(dir) + if len(files) != 2 { + t.Fatalf("kept %d backups, want 2", len(files)) + } + if filepath.Base(last) != files[0].Name() { + t.Fatalf("newest backup %q not first in %v", filepath.Base(last), files[0].Name()) + } + // newestBackupModTime should be recent. + if time.Since(newestBackupModTime(dir)) > time.Minute { + t.Fatalf("newestBackupModTime looks stale") + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 0a8bf66..4132c5a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -850,6 +850,7 @@ func (s *Server) Routes() http.Handler { r.Post("/settings", s.postSettings) r.Post("/settings/analytics/prune", s.pruneAnalyticsHandler) // v2.43.0 r.Post("/settings/analytics/vacuum", s.vacuumAnalyticsHandler) // v2.43.0 + r.Post("/settings/backup/run", s.postBackupRun) // v2.52.0 (issue #104) r.Post("/settings/test-webhook", s.postTestWebhook) r.Post("/settings/test-email", s.postTestEmail) r.Post("/settings/test-crowdsec", s.postTestCrowdSec) @@ -14624,8 +14625,15 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "DNSVerifyResolver": mustGetSetting(s.DB, settingDNSVerifyResolver), // issue #100: fleet-wide IP blocklist. "GlobalIPBlocklist": mustGetSetting(s.DB, settingGlobalIPBlocklist), - "Success": success, - "ClearedName": clearedName, + // issue #104: scheduled backups. + "BackupScheduleEnabled": mustGetSetting(s.DB, settingBackupScheduleEnabled) == "1", + "BackupScheduleDir": mustGetSetting(s.DB, settingBackupScheduleDir), + "BackupScheduleInterval": mustGetSetting(s.DB, settingBackupScheduleInterval), + "BackupScheduleKeep": mustGetSetting(s.DB, settingBackupScheduleKeep), + "BackupOK": r.URL.Query().Get("backupok"), + "BackupErr": r.URL.Query().Get("backuperr"), + "Success": success, + "ClearedName": clearedName, // v2.7.0: analytics card "AnalyticsEnabled": analyticsCfg.Enabled, "ExpectationsAutoRollback": expectationsAutoRollbackEnabled(s), // v2.38.0 @@ -14883,14 +14891,23 @@ func (s *Server) postSettings(w http.ResponseWriter, r *http.Request) { // detect whether we need to retarget every managed DNS record. oldServerIP := s.serverIP() + backupSchedEnabled := "0" + if r.FormValue("backup_schedule_enabled") == "on" { + backupSchedEnabled = "1" + } kv := map[string]string{ - settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98 - settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100 - settingNotifyWebhookURL: webhookURL, - settingNotifyWebhookSecret: webhookSecret, - settingNotifyNtfyURL: ntfyURL, // v2.12.51 - settingNotifyDaysBefore: strconv.Itoa(daysBefore), - settingSMTPHost: smtpHost, + settingDNSVerifyResolver: strings.TrimSpace(r.FormValue("dns_verify_resolver")), // issue #98 + settingGlobalIPBlocklist: strings.TrimSpace(r.FormValue("global_ip_blocklist")), // issue #100 + // issue #104: scheduled off-host backups. + settingBackupScheduleEnabled: backupSchedEnabled, + settingBackupScheduleDir: strings.TrimSpace(r.FormValue("backup_schedule_dir")), + settingBackupScheduleInterval: strings.TrimSpace(r.FormValue("backup_schedule_interval_hours")), + settingBackupScheduleKeep: strings.TrimSpace(r.FormValue("backup_schedule_keep")), + settingNotifyWebhookURL: webhookURL, + settingNotifyWebhookSecret: webhookSecret, + settingNotifyNtfyURL: ntfyURL, // v2.12.51 + settingNotifyDaysBefore: strconv.Itoa(daysBefore), + settingSMTPHost: smtpHost, // v2.11.15: AI assistant settings. settingAIEnabled: func() string { for _, v := range r.PostForm["ai_enabled"] { diff --git a/internal/server/settings_sections.go b/internal/server/settings_sections.go index e25d827..7b22470 100644 --- a/internal/server/settings_sections.go +++ b/internal/server/settings_sections.go @@ -147,6 +147,11 @@ var settingsKeySection = map[string]string{ settingAIOpenAIModel: "ai", settingAIOpenAIAPIKey: "ai", settingAISystemPrompt: "ai", + + settingBackupScheduleEnabled: "backup", + settingBackupScheduleDir: "backup", + settingBackupScheduleInterval: "backup", + settingBackupScheduleKeep: "backup", } // settingsSectionSyncsCaddy reports whether saving a page can change the diff --git a/web/templates/settings.html b/web/templates/settings.html index 0dab995..ae4e2a6 100644 --- a/web/templates/settings.html +++ b/web/templates/settings.html @@ -1334,11 +1334,42 @@

Settings {{else}} + {{if .BackupOK}}
Backup written: {{.BackupOK}}
{{end}} + {{if .BackupErr}}
Backup failed: {{.BackupErr}}
{{end}}

Download a consistent snapshot of the CaddyUI database. To restore, stop the server, replace the caddyui.db file, and restart.

Download Backup + + {{/* issue #104: scheduled off-host backups */}} +
+ +

Write a snapshot to a directory on an interval and keep the newest N. Point the directory at a mounted volume, network share, or object-store gateway to keep copies off the host.

+
+ + +
+
+ Every + + hours, keep the newest + + (0 = keep all). +
+
+ + +
+

Back up now uses the saved directory — Save first if you just changed it.

+
{{end}}