mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
485 lines
12 KiB
Go
485 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)
|
|
}
|
|
|
|
// 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
|
|
}
|