3x-ui/web/service/backup.go

490 lines
12 KiB
Go

package service
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/logger"
)
var backupFilenameRegex = regexp.MustCompile(`^(backup|pre-restore)-\d{4}-\d{2}-\d{2}-\d{6}\.tar\.gz$`)
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
}
// ValidateBackupFilename checks that the filename matches the expected backup naming pattern
// and does not contain path traversal sequences.
func ValidateBackupFilename(filename string) error {
if !backupFilenameRegex.MatchString(filename) {
return fmt.Errorf("invalid backup filename: %s", filename)
}
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() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".tar.gz") {
continue
}
if !strings.HasPrefix(name, "backup-") && !strings.HasPrefix(name, "pre-restore-") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
ts := extractTimestamp(name)
result = append(result, BackupEntry{
Filename: 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
}
if err := ValidateBackupFilename(filename); 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 {
if err := ValidateBackupFilename(filename); 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)
}
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)
}
// ValidateFilename validates a backup filename against the allowed pattern.
func (s *BackupService) ValidateFilename(filename string) error {
return ValidateBackupFilename(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, "pre-restore-")
name = strings.TrimPrefix(name, "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, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
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
}