feat: configuration backup management with manual and auto backup

Add backup/restore functionality for the SQLite database. Users can
trigger manual backups, configure automatic backups on an interval
with retention policies, list/download/delete backups, and restore
from any backup.

- Backup engine using VACUUM INTO (safe with WAL mode)
- Backup metadata tracked in DB, files stored in DATA_DIR/backups/
- Settings: backup_enabled, backup_interval_hours, backup_retention_count
- API: POST/GET/DELETE /api/backups, download, restore endpoints
- Autobackup via cron scheduler with configurable interval
- Retention: prune on startup, after each backup (manual and auto)
- Orphan cleanup: removes backup files without metadata on startup
- Restore: replaces DB and triggers graceful server shutdown
- Settings UI: /settings/backup with toggle, interval, retention config
- Backup list with download, delete, restore actions
- i18n: English and Russian translations
This commit is contained in:
2026-04-02 15:32:15 +03:00
parent 1c37bb2ccf
commit a9c7775bb7
21 changed files with 1230 additions and 17 deletions
+142
View File
@@ -0,0 +1,142 @@
package api
import (
"net/http"
"os"
"path/filepath"
"github.com/alexei/docker-watcher/internal/store"
"github.com/go-chi/chi/v5"
)
// listBackups handles GET /api/backups.
func (s *Server) listBackups(w http.ResponseWriter, r *http.Request) {
if s.backupEngine == nil {
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
return
}
backups, err := s.backupEngine.ListBackups()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list backups: "+err.Error())
return
}
if backups == nil {
backups = []store.Backup{}
}
respondJSON(w, http.StatusOK, backups)
}
// triggerBackup handles POST /api/backups.
func (s *Server) triggerBackup(w http.ResponseWriter, r *http.Request) {
if s.backupEngine == nil {
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
return
}
backup, err := s.backupEngine.CreateBackup("manual")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create backup: "+err.Error())
return
}
// Prune after manual backup too.
settings, err := s.store.GetSettings()
if err == nil && settings.BackupRetentionCount > 0 {
s.backupEngine.Prune(settings.BackupRetentionCount)
}
respondJSON(w, http.StatusCreated, backup)
}
// downloadBackup handles GET /api/backups/{id}/download.
func (s *Server) downloadBackup(w http.ResponseWriter, r *http.Request) {
if s.backupEngine == nil {
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
return
}
id := chi.URLParam(r, "id")
backup, err := s.backupEngine.GetBackup(id)
if err != nil {
respondError(w, http.StatusNotFound, "backup not found")
return
}
filePath := s.backupEngine.FilePath(backup)
if _, err := os.Stat(filePath); err != nil {
respondError(w, http.StatusNotFound, "backup file not found on disk")
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(backup.Filename)+"\"")
http.ServeFile(w, r, filePath)
}
// deleteBackup handles DELETE /api/backups/{id}.
func (s *Server) deleteBackup(w http.ResponseWriter, r *http.Request) {
if s.backupEngine == nil {
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
return
}
id := chi.URLParam(r, "id")
if err := s.backupEngine.DeleteBackup(id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete backup: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// restoreBackup handles POST /api/backups/{id}/restore.
// This replaces the current database with the backup. The server should be restarted after.
func (s *Server) restoreBackup(w http.ResponseWriter, r *http.Request) {
if s.backupEngine == nil {
respondError(w, http.StatusServiceUnavailable, "backup engine not initialized")
return
}
id := chi.URLParam(r, "id")
restorePath, err := s.backupEngine.RestorePath(id)
if err != nil {
respondError(w, http.StatusNotFound, "backup not found: "+err.Error())
return
}
// Read the backup file.
backupData, err := os.ReadFile(restorePath)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to read backup file: "+err.Error())
return
}
// Close the current database to release locks.
if err := s.store.Close(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to close database: "+err.Error())
return
}
// Write backup over the main database file.
if err := os.WriteFile(s.dbPath, backupData, 0o644); err != nil {
respondError(w, http.StatusInternalServerError, "failed to write database: "+err.Error())
return
}
// Remove WAL and SHM files to ensure clean state.
os.Remove(s.dbPath + "-wal")
os.Remove(s.dbPath + "-shm")
respondJSON(w, http.StatusOK, map[string]any{
"status": "restored",
"message": "Database restored. The server needs to be restarted to apply changes.",
})
// Signal the server to shut down gracefully so it can be restarted.
if s.shutdownFunc != nil {
go s.shutdownFunc()
}
}
+27
View File
@@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/backup"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/dns"
"github.com/alexei/docker-watcher/internal/docker"
@@ -40,6 +41,10 @@ type Server struct {
dnsProviderMu sync.RWMutex
dnsProvider dns.Provider
onDNSProviderChanged DNSProviderChangedFunc
backupEngine *backup.Engine
dbPath string
shutdownFunc func() // called after restore to trigger graceful shutdown
}
// NewServer creates a new API Server with all required dependencies.
@@ -86,6 +91,21 @@ func (s *Server) SetProxyManager(pm *proxy.Manager) {
s.proxyManager = pm
}
// SetBackupEngine sets the backup engine on the server.
func (s *Server) SetBackupEngine(engine *backup.Engine) {
s.backupEngine = engine
}
// SetDBPath sets the database file path (needed for restore).
func (s *Server) SetDBPath(path string) {
s.dbPath = path
}
// SetShutdownFunc sets the function called after a restore to trigger graceful shutdown.
func (s *Server) SetShutdownFunc(fn func()) {
s.shutdownFunc = fn
}
// SetDNSProvider sets the current DNS provider on the server.
func (s *Server) SetDNSProvider(provider dns.Provider) {
s.dnsProviderMu.Lock()
@@ -287,6 +307,13 @@ func (s *Server) Router() chi.Router {
r.Get("/dns/records", s.listDNSRecords)
r.Post("/dns/sync", s.syncDNSRecords)
r.Delete("/dns/records/{fqdn}", s.deleteDNSRecord)
// Backup endpoints.
r.Get("/backups", s.listBackups)
r.Post("/backups", s.triggerBackup)
r.Get("/backups/{id}/download", s.downloadBackup)
r.Delete("/backups/{id}", s.deleteBackup)
r.Post("/backups/{id}/restore", s.restoreBackup)
})
})
})
+29 -4
View File
@@ -30,10 +30,13 @@ type settingsRequest struct {
SSLCertificateID *int `json:"ssl_certificate_id,omitempty"`
StaleThresholdDays *int `json:"stale_threshold_days,omitempty"`
AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"`
WildcardDNS *bool `json:"wildcard_dns,omitempty"`
DNSProvider *string `json:"dns_provider,omitempty"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
WildcardDNS *bool `json:"wildcard_dns,omitempty"`
DNSProvider *string `json:"dns_provider,omitempty"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID *string `json:"cloudflare_zone_id,omitempty"`
BackupEnabled *bool `json:"backup_enabled,omitempty"`
BackupIntervalHours *int `json:"backup_interval_hours,omitempty"`
BackupRetentionCount *int `json:"backup_retention_count,omitempty"`
}
// getSettings handles GET /api/settings.
@@ -62,6 +65,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
"dns_provider": settings.DNSProvider,
"has_cloudflare_api_token": settings.CloudflareAPIToken != "",
"cloudflare_zone_id": settings.CloudflareZoneID,
"backup_enabled": settings.BackupEnabled,
"backup_interval_hours": settings.BackupIntervalHours,
"backup_retention_count": settings.BackupRetentionCount,
"updated_at": settings.UpdatedAt,
})
}
@@ -160,6 +166,25 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) {
updated.CloudflareZoneID = *req.CloudflareZoneID
}
// Backup settings.
if req.BackupEnabled != nil {
updated.BackupEnabled = *req.BackupEnabled
}
if req.BackupIntervalHours != nil {
if *req.BackupIntervalHours < 1 {
respondError(w, http.StatusBadRequest, "backup_interval_hours must be at least 1")
return
}
updated.BackupIntervalHours = *req.BackupIntervalHours
}
if req.BackupRetentionCount != nil {
if *req.BackupRetentionCount < 1 {
respondError(w, http.StatusBadRequest, "backup_retention_count must be at least 1")
return
}
updated.BackupRetentionCount = *req.BackupRetentionCount
}
if err := s.store.UpdateSettings(updated); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error())
return
+197
View File
@@ -0,0 +1,197 @@
package backup
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/alexei/docker-watcher/internal/store"
)
// Engine manages database backup operations.
type Engine struct {
store *store.Store
dbPath string
backupDir string
}
// New creates a new backup engine. It ensures the backup directory exists.
func New(st *store.Store, dbPath, dataDir string) (*Engine, error) {
backupDir := filepath.Join(dataDir, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return nil, fmt.Errorf("create backup directory: %w", err)
}
return &Engine{
store: st,
dbPath: dbPath,
backupDir: backupDir,
}, nil
}
// BackupDir returns the path to the backup directory.
func (e *Engine) BackupDir() string {
return e.backupDir
}
// CreateBackup creates a new database backup using VACUUM INTO.
// Returns the backup metadata record.
func (e *Engine) CreateBackup(backupType string) (store.Backup, error) {
timestamp := time.Now().UTC().Format("20060102-150405")
filename := fmt.Sprintf("docker-watcher-%s-%s.db", backupType, timestamp)
destPath := filepath.Join(e.backupDir, filename)
// VACUUM INTO creates a clean, standalone copy of the database.
// It is safe to use while the database is open and in WAL mode.
_, err := e.store.DB().Exec(`VACUUM INTO ?`, destPath)
if err != nil {
return store.Backup{}, fmt.Errorf("vacuum into %s: %w", destPath, err)
}
// Get file size.
info, err := os.Stat(destPath)
if err != nil {
return store.Backup{}, fmt.Errorf("stat backup file: %w", err)
}
// Store metadata.
backup, err := e.store.CreateBackup(store.Backup{
Filename: filename,
SizeBytes: info.Size(),
BackupType: backupType,
})
if err != nil {
// Best effort: remove the file if metadata insert fails.
os.Remove(destPath)
return store.Backup{}, fmt.Errorf("store backup metadata: %w", err)
}
slog.Info("backup created", "id", backup.ID, "filename", filename, "size", info.Size(), "type", backupType)
return backup, nil
}
// ListBackups returns all backup records.
func (e *Engine) ListBackups() ([]store.Backup, error) {
return e.store.ListBackups()
}
// GetBackup returns a single backup record.
func (e *Engine) GetBackup(id string) (store.Backup, error) {
return e.store.GetBackup(id)
}
// FilePath returns the full filesystem path for a backup.
func (e *Engine) FilePath(backup store.Backup) string {
return filepath.Join(e.backupDir, backup.Filename)
}
// DeleteBackup removes a backup file and its metadata record.
func (e *Engine) DeleteBackup(id string) error {
backup, err := e.store.GetBackup(id)
if err != nil {
return fmt.Errorf("get backup: %w", err)
}
// Remove file.
filePath := filepath.Join(e.backupDir, backup.Filename)
if err := os.Remove(filePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove backup file: %w", err)
}
// Remove metadata.
if err := e.store.DeleteBackup(id); err != nil {
return fmt.Errorf("delete backup metadata: %w", err)
}
slog.Info("backup deleted", "id", id, "filename", backup.Filename)
return nil
}
// RestorePath returns the path of a backup file for restore operations.
// The caller is responsible for actually replacing the database.
func (e *Engine) RestorePath(id string) (string, error) {
backup, err := e.store.GetBackup(id)
if err != nil {
return "", fmt.Errorf("get backup: %w", err)
}
filePath := filepath.Join(e.backupDir, backup.Filename)
if _, err := os.Stat(filePath); err != nil {
return "", fmt.Errorf("backup file not found: %w", err)
}
return filePath, nil
}
// Prune removes old backups exceeding the retention count.
// Returns the number of backups pruned.
func (e *Engine) Prune(retentionCount int) (int, error) {
if retentionCount <= 0 {
return 0, nil
}
count, err := e.store.CountBackups()
if err != nil {
return 0, fmt.Errorf("count backups: %w", err)
}
excess := count - retentionCount
if excess <= 0 {
return 0, nil
}
oldest, err := e.store.GetOldestBackups(excess)
if err != nil {
return 0, fmt.Errorf("get oldest backups: %w", err)
}
pruned := 0
for _, b := range oldest {
if err := e.DeleteBackup(b.ID); err != nil {
slog.Warn("prune: failed to delete backup", "id", b.ID, "error", err)
continue
}
pruned++
}
if pruned > 0 {
slog.Info("backups pruned", "pruned", pruned, "retention", retentionCount)
}
return pruned, nil
}
// CleanOrphans removes backup files in the backup directory that have no metadata record.
func (e *Engine) CleanOrphans() (int, error) {
entries, err := os.ReadDir(e.backupDir)
if err != nil {
return 0, fmt.Errorf("read backup directory: %w", err)
}
backups, err := e.store.ListBackups()
if err != nil {
return 0, fmt.Errorf("list backups: %w", err)
}
tracked := make(map[string]bool, len(backups))
for _, b := range backups {
tracked[b.Filename] = true
}
cleaned := 0
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !tracked[entry.Name()] {
filePath := filepath.Join(e.backupDir, entry.Name())
if err := os.Remove(filePath); err != nil {
slog.Warn("clean orphan: failed to remove file", "file", entry.Name(), "error", err)
continue
}
slog.Info("removed orphaned backup file", "file", entry.Name())
cleaned++
}
}
return cleaned, nil
}
+106
View File
@@ -0,0 +1,106 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// CreateBackup inserts a new backup metadata record.
func (s *Store) CreateBackup(b Backup) (Backup, error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
b.CreatedAt = Now()
_, err := s.db.Exec(
`INSERT INTO backups (id, filename, size_bytes, backup_type, created_at)
VALUES (?, ?, ?, ?, ?)`,
b.ID, b.Filename, b.SizeBytes, b.BackupType, b.CreatedAt,
)
if err != nil {
return Backup{}, fmt.Errorf("insert backup: %w", err)
}
return b, nil
}
// GetBackup returns a backup by ID.
func (s *Store) GetBackup(id string) (Backup, error) {
var b Backup
err := s.db.QueryRow(
`SELECT id, filename, size_bytes, backup_type, created_at
FROM backups WHERE id = ?`, id,
).Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return Backup{}, fmt.Errorf("backup %s: %w", id, ErrNotFound)
}
if err != nil {
return Backup{}, fmt.Errorf("query backup: %w", err)
}
return b, nil
}
// ListBackups returns all backups ordered by creation date descending.
func (s *Store) ListBackups() ([]Backup, error) {
rows, err := s.db.Query(
`SELECT id, filename, size_bytes, backup_type, created_at
FROM backups ORDER BY created_at DESC`,
)
if err != nil {
return nil, fmt.Errorf("query backups: %w", err)
}
defer rows.Close()
var backups []Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan backup: %w", err)
}
backups = append(backups, b)
}
return backups, rows.Err()
}
// DeleteBackup removes a backup metadata record by ID.
func (s *Store) DeleteBackup(id string) error {
_, err := s.db.Exec(`DELETE FROM backups WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete backup: %w", err)
}
return nil
}
// CountBackups returns the total number of backup records.
func (s *Store) CountBackups() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM backups`).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count backups: %w", err)
}
return count, nil
}
// GetOldestBackups returns the N oldest backups (for pruning).
func (s *Store) GetOldestBackups(limit int) ([]Backup, error) {
rows, err := s.db.Query(
`SELECT id, filename, size_bytes, backup_type, created_at
FROM backups ORDER BY created_at ASC LIMIT ?`, limit,
)
if err != nil {
return nil, fmt.Errorf("query oldest backups: %w", err)
}
defer rows.Close()
var backups []Backup
for rows.Next() {
var b Backup
if err := rows.Scan(&b.ID, &b.Filename, &b.SizeBytes, &b.BackupType, &b.CreatedAt); err != nil {
return nil, fmt.Errorf("scan backup: %w", err)
}
backups = append(backups, b)
}
return backups, rows.Err()
}
+17 -5
View File
@@ -58,11 +58,23 @@ type Settings struct {
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
UpdatedAt string `json:"updated_at"`
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
UpdatedAt string `json:"updated_at"`
}
// Backup represents a backup metadata record.
type Backup struct {
ID string `json:"id"`
Filename string `json:"filename"`
SizeBytes int64 `json:"size_bytes"`
BackupType string `json:"backup_type"` // "manual" or "auto"
CreatedAt string `json:"created_at"`
}
// DNSRecord tracks a DNS record managed by the application.
+18 -5
View File
@@ -7,23 +7,28 @@ import (
// GetSettings returns the global settings (single-row pattern, always row id=1).
func (s *Store) GetSettings() (Settings, error) {
var st Settings
var wildcardDNS int
var wildcardDNS, backupEnabled int
err := s.db.QueryRow(
`SELECT domain, server_ip, network, subdomain_pattern, notification_url,
npm_url, npm_email, npm_password, webhook_secret, polling_interval,
base_volume_path, ssl_certificate_id, stale_threshold_days,
allowed_volume_paths, wildcard_dns, dns_provider,
cloudflare_api_token, cloudflare_zone_id, updated_at
cloudflare_api_token, cloudflare_zone_id,
backup_enabled, backup_interval_hours, backup_retention_count,
updated_at
FROM settings WHERE id = 1`,
).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL,
&st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval,
&st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays,
&st.AllowedVolumePaths, &wildcardDNS, &st.DNSProvider,
&st.CloudflareAPIToken, &st.CloudflareZoneID, &st.UpdatedAt)
&st.CloudflareAPIToken, &st.CloudflareZoneID,
&backupEnabled, &st.BackupIntervalHours, &st.BackupRetentionCount,
&st.UpdatedAt)
if err != nil {
return Settings{}, fmt.Errorf("query settings: %w", err)
}
st.WildcardDNS = wildcardDNS != 0
st.BackupEnabled = backupEnabled != 0
return st, nil
}
@@ -34,19 +39,27 @@ func (s *Store) UpdateSettings(st Settings) error {
if st.WildcardDNS {
wildcardDNS = 1
}
backupEnabled := 0
if st.BackupEnabled {
backupEnabled = 1
}
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?,
base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?,
allowed_volume_paths=?, wildcard_dns=?, dns_provider=?,
cloudflare_api_token=?, cloudflare_zone_id=?, updated_at=?
cloudflare_api_token=?, cloudflare_zone_id=?,
backup_enabled=?, backup_interval_hours=?, backup_retention_count=?,
updated_at=?
WHERE id = 1`,
st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL,
st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval,
st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays,
st.AllowedVolumePaths, wildcardDNS, st.DNSProvider,
st.CloudflareAPIToken, st.CloudflareZoneID, st.UpdatedAt,
st.CloudflareAPIToken, st.CloudflareZoneID,
backupEnabled, st.BackupIntervalHours, st.BackupRetentionCount,
st.UpdatedAt,
)
if err != nil {
return fmt.Errorf("update settings: %w", err)
+12
View File
@@ -95,6 +95,10 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
// Add backup management fields to settings (2026-04-02).
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
}
for _, m := range migrations {
@@ -313,6 +317,14 @@ CREATE TABLE IF NOT EXISTS dns_records (
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS backups (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL UNIQUE,
size_bytes INTEGER NOT NULL DEFAULT 0,
backup_type TEXT NOT NULL DEFAULT 'manual',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`
// Now returns the current time formatted for SQLite storage.