mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +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