Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions cmd/caddyui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions internal/server/backup_schedule.go
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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 {
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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)
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
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()))
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
}
}
108 changes: 108 additions & 0 deletions internal/server/backup_schedule_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading