Self-contained HTTP/HTTPS/TCP/UDP reverse proxy manager written in Go, with SQLite storage, a Bootstrap 5 admin UI (Go templates, no JS framework), and a REST API under /api/v1. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QTXFrNP1imCW7jtfL5SZrw
113 lines
2.9 KiB
Go
113 lines
2.9 KiB
Go
// Package backup handles SQLite backups: on-demand, scheduled, and
|
|
// restore. It uses SQLite's own online backup (via VACUUM INTO), which is
|
|
// safe to run against a live WAL-mode database without blocking writers
|
|
// for more than the duration of the copy.
|
|
package backup
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Run performs a single backup of the database at db into dir, named with
|
|
// a timestamp, then prunes old backups beyond keep.
|
|
func Run(db *sql.DB, dir string, keep int) (string, error) {
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", err
|
|
}
|
|
name := fmt.Sprintf("vibeproxy-%s.db", time.Now().UTC().Format("20060102-150405"))
|
|
path := filepath.Join(dir, name)
|
|
|
|
if _, err := db.Exec(`VACUUM INTO ?`, path); err != nil {
|
|
return "", fmt.Errorf("backup failed: %w", err)
|
|
}
|
|
if err := os.Chmod(path, 0o600); err != nil {
|
|
return "", err
|
|
}
|
|
if keep > 0 {
|
|
if err := prune(dir, keep); err != nil {
|
|
return path, err
|
|
}
|
|
}
|
|
return path, nil
|
|
}
|
|
|
|
func prune(dir string, keep int) error {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var names []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
names = append(names, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(names) // timestamped names sort chronologically
|
|
if len(names) <= keep {
|
|
return nil
|
|
}
|
|
for _, n := range names[:len(names)-keep] {
|
|
os.Remove(filepath.Join(dir, n))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// StartScheduledBackups runs Run on the given interval until ctx is done.
|
|
func StartScheduledBackups(ctx context.Context, db *sql.DB, log *slog.Logger, getDir func() string, getIntervalHours func() int, getKeep func() int, getEnabled func() bool) {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
lastRun := time.Time{}
|
|
go func() {
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if !getEnabled() {
|
|
continue
|
|
}
|
|
interval := time.Duration(getIntervalHours()) * time.Hour
|
|
if interval <= 0 {
|
|
interval = 24 * time.Hour
|
|
}
|
|
if time.Since(lastRun) < interval {
|
|
continue
|
|
}
|
|
lastRun = time.Now()
|
|
path, err := Run(db, getDir(), getKeep())
|
|
if err != nil {
|
|
log.Error("scheduled backup failed", "err", err)
|
|
} else {
|
|
log.Info("scheduled backup complete", "path", path)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// Restore replaces the live database file at dbPath with backupPath. The
|
|
// caller must have closed all connections to dbPath before calling this
|
|
// and reopen afterward.
|
|
func Restore(backupPath, dbPath string) error {
|
|
data, err := os.ReadFile(backupPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := dbPath + ".restoring"
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
// Remove any stale WAL/SHM files so SQLite doesn't try to replay a
|
|
// journal that belongs to the old database.
|
|
os.Remove(dbPath + "-wal")
|
|
os.Remove(dbPath + "-shm")
|
|
return os.Rename(tmp, dbPath)
|
|
}
|