diff --git a/docs/Tasktracking/2026-04-26-backup-service.md b/docs/Tasktracking/2026-04-26-backup-service.md new file mode 100644 index 00000000..91ce303b --- /dev/null +++ b/docs/Tasktracking/2026-04-26-backup-service.md @@ -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 diff --git a/web/service/backup.go b/web/service/backup.go new file mode 100644 index 00000000..5cf3a01c --- /dev/null +++ b/web/service/backup.go @@ -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 +}