mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 13:44:24 +00:00
feat: add BackupService with dump, archive, restore logic
This commit is contained in:
parent
4da2346c62
commit
5b2946a46d
2 changed files with 488 additions and 0 deletions
33
docs/Tasktracking/2026-04-26-backup-service.md
Normal file
33
docs/Tasktracking/2026-04-26-backup-service.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Task Record
|
||||||
|
|
||||||
|
Date: 2026-04-26
|
||||||
|
Related Module: web/service
|
||||||
|
Change Type: Add
|
||||||
|
|
||||||
|
## Background
|
||||||
|
Need a BackupService to handle database backup, restore, and retention operations. Supports both SQLite (via sqlite3 .dump) and MariaDB (via mysqldump). Backups are stored as tar.gz archives containing dump.sql and metadata.json.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- Created web/service/backup.go with BackupService struct
|
||||||
|
- BackupService.CreateBackup: dumps database and creates tar.gz archive
|
||||||
|
- BackupService.ListBackups: lists all backup files sorted by time
|
||||||
|
- BackupService.RestoreBackup: validates dbType match, creates safety backup, restores from dump
|
||||||
|
- BackupService.DeleteBackup: deletes a backup file
|
||||||
|
- BackupService.ApplyRetention: keeps max N newest backups, removes older ones
|
||||||
|
- BackupService.CreateSnapshot: scheduled backup with retention policy
|
||||||
|
- Internal helper functions: dumpMariaDB, dumpSQLite, restoreDB, createTarGz, extractTarGz
|
||||||
|
- checkNodeRole enforces master-node-only restriction
|
||||||
|
- Safety backup (pre-restore-*.tar.gz) created before any restore
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
- New file: web/service/backup.go
|
||||||
|
- No existing files modified
|
||||||
|
- No API, database, or config changes
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- go build ./... PASS
|
||||||
|
- gofmt -l -w . PASS (no formatting changes needed)
|
||||||
|
|
||||||
|
## Risks And Follow-Up
|
||||||
|
- External dependencies on mysqldump, mysql, and sqlite3 CLI tools must be present on the system
|
||||||
|
- Not yet wired to any API endpoint or cron scheduler; those will be added in subsequent tasks
|
||||||
455
web/service/backup.go
Normal file
455
web/service/backup.go
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
backupDir = "/etc/x-ui/backups"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackupMeta struct {
|
||||||
|
DBType string `json:"dbType"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BackupEntry struct {
|
||||||
|
Filename string `json:"filename"`
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BackupService handles database backup, restore, and file management operations.
|
||||||
|
type BackupService struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureBackupDir creates the backup directory if it does not exist.
|
||||||
|
func ensureBackupDir() error {
|
||||||
|
if err := os.MkdirAll(backupDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("create backup directory: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkNodeRole verifies the current node is a master node.
|
||||||
|
func checkNodeRole() error {
|
||||||
|
nodeCfg := config.GetNodeConfigFromJSON()
|
||||||
|
if nodeCfg.Role == config.NodeRoleWorker {
|
||||||
|
return fmt.Errorf("backup and restore can only be performed on the master node")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBackup creates an immediate backup of the current database.
|
||||||
|
func (s *BackupService) CreateBackup() (string, error) {
|
||||||
|
if err := checkNodeRole(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := ensureBackupDir(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCfg := config.GetDBConfigFromJSON()
|
||||||
|
timestamp := time.Now().Format("2006-01-02-150405")
|
||||||
|
filename := fmt.Sprintf("backup-%s.tar.gz", timestamp)
|
||||||
|
filePath := filepath.Join(backupDir, filename)
|
||||||
|
|
||||||
|
var dumpSQL string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
switch dbCfg.Type {
|
||||||
|
case "mariadb":
|
||||||
|
dumpSQL, err = dumpMariaDB(dbCfg)
|
||||||
|
case "sqlite", "":
|
||||||
|
dumpSQL, err = dumpSQLite(config.GetDBPath())
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported database type: %s", dbCfg.Type)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("database dump failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := BackupMeta{
|
||||||
|
DBType: defaultDBType(dbCfg.Type),
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Version: config.GetVersion(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createTarGz(filePath, meta, dumpSQL); err != nil {
|
||||||
|
return "", fmt.Errorf("create archive failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBackups returns all backup files sorted by time (newest first).
|
||||||
|
func (s *BackupService) ListBackups() ([]BackupEntry, error) {
|
||||||
|
entries, err := os.ReadDir(backupDir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []BackupEntry{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read backup directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []BackupEntry
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasPrefix(entry.Name(), "backup-") || !strings.HasSuffix(entry.Name(), ".tar.gz") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ts := extractTimestamp(entry.Name())
|
||||||
|
result = append(result, BackupEntry{
|
||||||
|
Filename: entry.Name(),
|
||||||
|
Timestamp: ts,
|
||||||
|
Size: info.Size(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
return result[i].Timestamp > result[j].Timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreBackup restores the database from a backup file.
|
||||||
|
func (s *BackupService) RestoreBackup(filename string) error {
|
||||||
|
if err := checkNodeRole(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(backupDir, filename)
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup file not found: %s", filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta, dumpSQL, err := extractTarGz(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid backup file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbCfg := config.GetDBConfigFromJSON()
|
||||||
|
currentDBType := defaultDBType(dbCfg.Type)
|
||||||
|
|
||||||
|
if meta.DBType != currentDBType {
|
||||||
|
return fmt.Errorf("backup type (%s) does not match current database (%s)", meta.DBType, currentDBType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create safety backup before restoring
|
||||||
|
safetyFile, err := s.createSafetyBackup(dbCfg)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("failed to create safety backup before restore:", err)
|
||||||
|
} else {
|
||||||
|
logger.Info("safety backup created:", safetyFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := restoreDB(dbCfg, dumpSQL); err != nil {
|
||||||
|
return fmt.Errorf("restore failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBackup deletes a backup file.
|
||||||
|
func (s *BackupService) DeleteBackup(filename string) error {
|
||||||
|
filePath := filepath.Join(backupDir, filename)
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("backup file not found: %s", filePath)
|
||||||
|
}
|
||||||
|
return os.Remove(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBackupPath returns the full path to a backup file.
|
||||||
|
func (s *BackupService) GetBackupPath(filename string) string {
|
||||||
|
return filepath.Join(backupDir, filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyRetention applies retention policy, keeping maxCount newest backups.
|
||||||
|
func (s *BackupService) ApplyRetention(maxCount int) error {
|
||||||
|
entries, err := s.ListBackups()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entries) <= maxCount {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := maxCount; i < len(entries); i++ {
|
||||||
|
filePath := s.GetBackupPath(entries[i].Filename)
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
logger.Warning("failed to delete old backup:", entries[i].Filename, err)
|
||||||
|
} else {
|
||||||
|
logger.Info("deleted old backup:", entries[i].Filename)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSnapshot creates a scheduled backup and applies retention.
|
||||||
|
func (s *BackupService) CreateSnapshot(settingService SettingService) error {
|
||||||
|
enabled, err := settingService.GetBackupEnabled()
|
||||||
|
if err != nil || !enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath, err := s.CreateBackup()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("snapshot backup failed: %w", err)
|
||||||
|
}
|
||||||
|
logger.Info("scheduled backup created:", filePath)
|
||||||
|
|
||||||
|
maxCount := 10
|
||||||
|
if mc, err := settingService.GetBackupMaxCount(); err == nil && mc > 0 {
|
||||||
|
maxCount = mc
|
||||||
|
}
|
||||||
|
if err := s.ApplyRetention(maxCount); err != nil {
|
||||||
|
logger.Warning("retention apply failed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createSafetyBackup creates an emergency backup before restore.
|
||||||
|
func (s *BackupService) createSafetyBackup(dbCfg config.DBConfig) (string, error) {
|
||||||
|
timestamp := time.Now().Format("2006-01-02-150405")
|
||||||
|
filename := fmt.Sprintf("pre-restore-%s.tar.gz", timestamp)
|
||||||
|
filePath := filepath.Join(backupDir, filename)
|
||||||
|
|
||||||
|
var dumpSQL string
|
||||||
|
var err error
|
||||||
|
switch dbCfg.Type {
|
||||||
|
case "mariadb":
|
||||||
|
dumpSQL, err = dumpMariaDB(dbCfg)
|
||||||
|
default:
|
||||||
|
dumpSQL, err = dumpSQLite(config.GetDBPath())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := BackupMeta{
|
||||||
|
DBType: defaultDBType(dbCfg.Type),
|
||||||
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Version: config.GetVersion(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := createTarGz(filePath, meta, dumpSQL); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return filePath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultDBType normalizes empty string to "sqlite".
|
||||||
|
func defaultDBType(t string) string {
|
||||||
|
if t == "" {
|
||||||
|
return "sqlite"
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTimestamp extracts the timestamp string from backup filename.
|
||||||
|
func extractTimestamp(filename string) string {
|
||||||
|
name := strings.TrimPrefix(filename, "backup-")
|
||||||
|
name = strings.TrimSuffix(name, ".tar.gz")
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpMariaDB runs mysqldump and returns the SQL output.
|
||||||
|
func dumpMariaDB(dbCfg config.DBConfig) (string, error) {
|
||||||
|
args := []string{
|
||||||
|
"--single-transaction",
|
||||||
|
"--routines",
|
||||||
|
"--triggers",
|
||||||
|
"--no-tablespaces",
|
||||||
|
fmt.Sprintf("-h%s", dbCfg.Host),
|
||||||
|
fmt.Sprintf("-P%s", dbCfg.Port),
|
||||||
|
}
|
||||||
|
if dbCfg.User != "" {
|
||||||
|
args = append(args, fmt.Sprintf("-u%s", dbCfg.User))
|
||||||
|
}
|
||||||
|
if dbCfg.Password != "" {
|
||||||
|
args = append(args, fmt.Sprintf("-p%s", dbCfg.Password))
|
||||||
|
}
|
||||||
|
args = append(args, dbCfg.Name)
|
||||||
|
|
||||||
|
cmd := exec.Command("mysqldump", args...)
|
||||||
|
var out strings.Builder
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("mysqldump: %w (output: %s)", err, out.String())
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpSQLite runs sqlite3 .dump and returns the SQL output.
|
||||||
|
func dumpSQLite(dbPath string) (string, error) {
|
||||||
|
database.Checkpoint()
|
||||||
|
cmd := exec.Command("sqlite3", dbPath, ".dump")
|
||||||
|
var out strings.Builder
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &out
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("sqlite3 dump: %w (output: %s)", err, out.String())
|
||||||
|
}
|
||||||
|
return out.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreDB restores the database from SQL dump.
|
||||||
|
func restoreDB(dbCfg config.DBConfig, dumpSQL string) error {
|
||||||
|
switch dbCfg.Type {
|
||||||
|
case "mariadb":
|
||||||
|
args := []string{
|
||||||
|
fmt.Sprintf("-h%s", dbCfg.Host),
|
||||||
|
fmt.Sprintf("-P%s", dbCfg.Port),
|
||||||
|
}
|
||||||
|
if dbCfg.User != "" {
|
||||||
|
args = append(args, fmt.Sprintf("-u%s", dbCfg.User))
|
||||||
|
}
|
||||||
|
if dbCfg.Password != "" {
|
||||||
|
args = append(args, fmt.Sprintf("-p%s", dbCfg.Password))
|
||||||
|
}
|
||||||
|
args = append(args, dbCfg.Name)
|
||||||
|
|
||||||
|
cmd := exec.Command("mysql", args...)
|
||||||
|
cmd.Stdin = strings.NewReader(dumpSQL)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("mysql restore: %w (stderr: %s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
cmd := exec.Command("sqlite3", config.GetDBPath())
|
||||||
|
cmd.Stdin = strings.NewReader(dumpSQL)
|
||||||
|
var stderr strings.Builder
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("sqlite3 restore: %w (stderr: %s)", err, stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTarGz creates a tar.gz archive containing metadata.json and dump.sql.
|
||||||
|
func createTarGz(filePath string, meta BackupMeta, dumpSQL string) error {
|
||||||
|
f, err := os.Create(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gw := gzip.NewWriter(f)
|
||||||
|
defer gw.Close()
|
||||||
|
|
||||||
|
tw := tar.NewWriter(gw)
|
||||||
|
defer tw.Close()
|
||||||
|
|
||||||
|
// metadata.json
|
||||||
|
metaBytes, _ := json.MarshalIndent(meta, "", " ")
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "metadata.json",
|
||||||
|
Size: int64(len(metaBytes)),
|
||||||
|
Mode: 0644,
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(metaBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// dump.sql
|
||||||
|
dumpBytes := []byte(dumpSQL)
|
||||||
|
if err := tw.WriteHeader(&tar.Header{
|
||||||
|
Name: "dump.sql",
|
||||||
|
Size: int64(len(dumpBytes)),
|
||||||
|
Mode: 0644,
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(dumpBytes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTarGz reads a tar.gz archive and returns metadata and SQL dump.
|
||||||
|
func extractTarGz(filePath string) (*BackupMeta, string, error) {
|
||||||
|
f, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
gr, err := gzip.NewReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
defer gr.Close()
|
||||||
|
|
||||||
|
tr := tar.NewReader(gr)
|
||||||
|
|
||||||
|
var meta BackupMeta
|
||||||
|
var dumpSQL string
|
||||||
|
|
||||||
|
for {
|
||||||
|
hdr, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if _, err := io.Copy(&buf, tr); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch hdr.Name {
|
||||||
|
case "metadata.json":
|
||||||
|
if err := json.Unmarshal([]byte(buf.String()), &meta); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("invalid metadata.json: %w", err)
|
||||||
|
}
|
||||||
|
case "dump.sql":
|
||||||
|
dumpSQL = buf.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dumpSQL == "" {
|
||||||
|
return nil, "", fmt.Errorf("dump.sql not found in archive")
|
||||||
|
}
|
||||||
|
if meta.DBType == "" {
|
||||||
|
return nil, "", fmt.Errorf("metadata.json missing dbType")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &meta, dumpSQL, nil
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue