3x-ui/docs/superpowers/plans/2026-04-26-database-backup-snapshot.md

1591 lines
43 KiB
Markdown

# Database Backup & Snapshot Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add backup, scheduled snapshot, export, and restore functionality for both SQLite and MariaDB, operable via panel UI and x-ui.sh CLI.
**Architecture:** New `BackupService` in web/service handles mysqldump/sqlite3 dump execution, tar.gz packaging, and file management. `BackupController` exposes REST API endpoints. `BackupJob` is a cron-scheduled job that delegates to BackupService. Settings stored via existing `SettingService`/`AllSetting` reflection-based persistence. CLI subcommands in main.go call the same backup/restore logic directly. x-ui.sh wraps CLI commands.
**Tech Stack:** Go (Gin, GORM, exec.Command), bash (x-ui.sh), Vue.js + Ant Design Vue (panel UI), robfig/cron v3 (scheduling)
---
### Task 1: Add backup settings to entity and setting service
**Files:**
- Modify: `web/entity/entity.go:113-124`
- Modify: `web/service/setting.go:113-130` (add defaults)
- Modify: `web/service/setting.go:229-250` (add to `systemIntegration` group or new `backup` group)
- [ ] **Step 1: Add backup fields to AllSetting struct**
In `web/entity/entity.go`, add before the closing of the struct (line 124, after `TurnstileSecretKey`):
```go
// Backup settings
BackupEnabled bool `json:"backupEnabled" form:"backupEnabled"` // Enable scheduled backups
BackupFrequency string `json:"backupFrequency" form:"backupFrequency"` // hourly, every12h, daily, weekly
BackupHour int `json:"backupHour" form:"backupHour"` // hour of day (0-23)
BackupMaxCount int `json:"backupMaxCount" form:"backupMaxCount"` // max backups to retain (1-100)
```
- [ ] **Step 2: Add default values in setting.go**
In `web/service/setting.go`, add inside the `defaultValueMap` block (after line 129 `"trafficFlushInterval": "10",`):
```go
// Backup settings
"backupEnabled": "false",
"backupFrequency": "daily",
"backupHour": "3",
"backupMaxCount": "10",
```
- [ ] **Step 3: Add backup group to settingGroups**
In `web/service/setting.go`, add a new `"backup"` group to `settingGroups` (after the `"node"` group, line 248):
```go
"backup": {
"enabled": "backupEnabled",
"frequency": "backupFrequency",
"hour": "backupHour",
"maxCount": "backupMaxCount",
},
```
- [ ] **Step 4: Add getter/setter methods to SettingService**
In `web/service/setting.go`, add after the existing setting methods (e.g., after line 1089 `GetLdapSyncCron`):
```go
func (s *SettingService) GetBackupEnabled() (bool, error) { return s.getBool("backupEnabled") }
func (s *SettingService) GetBackupFrequency() (string, error) { return s.getString("backupFrequency") }
func (s *SettingService) GetBackupHour() (int, error) { return s.getInt("backupHour") }
func (s *SettingService) GetBackupMaxCount() (int, error) { return s.getInt("backupMaxCount") }
```
- [ ] **Step 5: Verify compilation**
Run: `go build ./...`
Expected: PASS
- [ ] **Step 6: Commit**
```bash
git add web/entity/entity.go web/service/setting.go
git commit -m "feat: add backup config fields to entity and setting service"
```
---
### Task 2: Create BackupService
**Files:**
- Create: `web/service/backup.go`
- [ ] **Step 1: Create `web/service/backup.go`**
```go
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"
minDiskFreeMB = 100
maxBackupDirSizeMB = 500
)
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
}
// Also count pre-restore safety backups for total size check, but exclude from retention deletion
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 {
// backup-2026-04-26-030000.tar.gz
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
}
```
- [ ] **Step 2: Verify compilation**
Run: `go build ./...`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add web/service/backup.go
git commit -m "feat: add BackupService with dump, archive, restore logic"
```
---
### Task 3: Create BackupController
**Files:**
- Create: `web/controller/backup.go`
- [ ] **Step 1: Create `web/controller/backup.go`**
```go
package controller
import (
"fmt"
"net/http"
"os"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// BackupController handles database backup and restore API endpoints.
type BackupController struct {
BaseController
backupService service.BackupService
}
// initRouter registers backup API routes.
func (a *BackupController) initRouter(g *gin.RouterGroup) {
g.POST("/backup", a.createBackup)
g.POST("/restore/:filename", a.restoreBackup)
g.POST("/deleteBackup/:filename", a.deleteBackup)
g.GET("/listBackups", a.listBackups)
g.GET("/downloadBackup/:filename", a.downloadBackup)
}
// createBackup creates an immediate manual backup.
func (a *BackupController) createBackup(c *gin.Context) {
filePath, err := a.backupService.CreateBackup()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.backup.createError"), err)
return
}
jsonObj(c, filePath, nil)
}
// restoreBackup restores the database from a backup file.
func (a *BackupController) restoreBackup(c *gin.Context) {
filename := c.Param("filename")
if !isValidFilename(filename) {
jsonMsg(c, "Invalid filename", fmt.Errorf("invalid filename"))
return
}
if err := a.backupService.RestoreBackup(filename); err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.backup.restoreError"), err)
return
}
jsonObj(c, "restore completed", nil)
}
// deleteBackup deletes a backup file.
func (a *BackupController) deleteBackup(c *gin.Context) {
filename := c.Param("filename")
if !isValidFilename(filename) {
jsonMsg(c, "Invalid filename", fmt.Errorf("invalid filename"))
return
}
if err := a.backupService.DeleteBackup(filename); err != nil {
jsonMsg(c, "delete failed", err)
return
}
jsonObj(c, "deleted", nil)
}
// listBackups lists all backup files.
func (a *BackupController) listBackups(c *gin.Context) {
entries, err := a.backupService.ListBackups()
if err != nil {
jsonMsg(c, "list backups failed", err)
return
}
jsonObj(c, entries, nil)
}
// downloadBackup downloads a backup file.
func (a *BackupController) downloadBackup(c *gin.Context) {
filename := c.Param("filename")
if !isValidFilename(filename) {
c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
return
}
filePath := a.backupService.GetBackupPath(filename)
data, err := os.ReadFile(filePath)
if err != nil {
jsonMsg(c, "read backup file failed", err)
return
}
c.Header("Content-Type", "application/gzip")
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Writer.Write(data)
}
```
- [ ] **Step 2: Verify compilation**
Run: `go build ./...`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add web/controller/backup.go
git commit -m "feat: add BackupController API endpoints"
```
---
### Task 4: Create BackupJob for scheduled snapshots
**Files:**
- Create: `web/job/backup_job.go`
- [ ] **Step 1: Create `web/job/backup_job.go`**
```go
package job
import (
"time"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/service"
)
// BackupJob handles scheduled database backups.
type BackupJob struct {
settingService service.SettingService
backupService service.BackupService
lastRun string // stores the frequency of the last run to detect change
}
// NewBackupJob creates a new BackupJob instance.
func NewBackupJob() *BackupJob {
return &BackupJob{}
}
// Run executes the scheduled backup if enabled and time matches the configured frequency.
func (j *BackupJob) Run() {
enabled, err := j.settingService.GetBackupEnabled()
if err != nil || !enabled {
return
}
frequency, err := j.settingService.GetBackupFrequency()
if err != nil {
return
}
if !j.shouldRun(frequency) {
return
}
if err := j.backupService.CreateSnapshot(j.settingService); err != nil {
logger.Warning("scheduled backup failed:", err)
}
}
// shouldRun checks if the backup should run based on the configured frequency.
func (j *BackupJob) shouldRun(frequency string) bool {
now := time.Now()
switch frequency {
case "hourly":
return now.Minute() == 0
case "every12h":
return (now.Hour() == 0 || now.Hour() == 12) && now.Minute() == 0
case "daily":
hour, err := j.settingService.GetBackupHour()
if err != nil {
hour = 3
}
return now.Hour() == hour && now.Minute() == 0
case "weekly":
if now.Weekday() != time.Sunday {
return false
}
hour, err := j.settingService.GetBackupHour()
if err != nil {
hour = 3
}
return now.Hour() == hour && now.Minute() == 0
default:
return false
}
}
```
- [ ] **Step 2: Verify compilation**
Run: `go build ./...`
Expected: PASS
- [ ] **Step 3: Commit**
```bash
git add web/job/backup_job.go
git commit -m "feat: add BackupJob for scheduled snapshots"
```
---
### Task 5: Wire up routes and job scheduling
**Files:**
- Modify: `web/controller/server.go:41-63` (add backup routes to initRouter)
- Modify: `web/web.go:360-413` (add backup job to cron)
- [ ] **Step 1: Register backup routes in ServerController**
In `web/controller/server.go`, inside `initRouter()` after line 62, add:
```go
// Backup routes
backupCtrl := BackupController{}
backupCtrl.initRouter(g)
```
- [ ] **Step 2: Register backup job in web.go**
In `web/web.go`, after `s.cron.AddJob("@daily", job.NewClearLogsJob())` (around line 365), add:
```go
// Schedule database backup job (runs every minute, checks schedule internally)
s.cron.AddJob("@every 1m", job.NewBackupJob())
```
- [ ] **Step 3: Verify compilation**
Run: `go build ./...`
Expected: PASS
- [ ] **Step 4: Run vet**
Run: `go vet ./...`
Expected: no errors
- [ ] **Step 5: Commit**
```bash
git add web/controller/server.go web/web.go
git commit -m "feat: wire backup routes and scheduling job"
```
---
### Task 6: Create backup UI page
**Files:**
- Create: `web/html/settings/backup.html`
- Modify: `web/html/settings.html:67-109` (add backup tab)
- [ ] **Step 1: Read current settings.html tabs section**
Read `web/html/settings.html:67-109` to understand the current tab structure before editing.
- [ ] **Step 2: Create `web/html/settings/backup.html`**
```html
<div>
<a-row :gutter="[16, 16]" :style="{ marginTop: '16px' }">
<a-col :span="24">
<a-card :title="'Backup Configuration'" size="small">
<a-form-model :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }" label-align="left">
<a-row :gutter="[16, 16]">
<a-col :xs="24" :sm="12">
<a-form-model-item :label="'Enable Scheduled Backup'">
<a-switch v-model="allSetting.backupEnabled"></a-switch>
</a-form-model-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-model-item :label="'Frequency'">
<a-select v-model="allSetting.backupFrequency" :disabled="!allSetting.backupEnabled">
<a-select-option value="hourly">Every Hour</a-select-option>
<a-select-option value="every12h">Every 12 Hours</a-select-option>
<a-select-option value="daily">Every Day</a-select-option>
<a-select-option value="weekly">Every Week</a-select-option>
</a-select>
</a-form-model-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-model-item :label="'Hour (0-23)'"
v-if="allSetting.backupFrequency === 'daily' || allSetting.backupFrequency === 'weekly'">
<a-input-number v-model="allSetting.backupHour" :min="0" :max="23"
:disabled="!allSetting.backupEnabled"></a-input-number>
</a-form-model-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-model-item :label="'Max Backups (1-100)'">
<a-input-number v-model="allSetting.backupMaxCount" :min="1" :max="100"
:disabled="!allSetting.backupEnabled"></a-input-number>
</a-form-model-item>
</a-col>
</a-row>
</a-form-model>
</a-card>
</a-col>
<a-col :span="24">
<a-card :title="'Manual Operations'" size="small">
<a-space>
<a-button type="primary" icon="plus" @click="createBackup" :loading="backupCreating">
Create Backup Now
</a-button>
</a-space>
</a-card>
</a-col>
<a-col :span="24">
<a-card size="small">
<span slot="title">Backup List
<a-badge :count="backupList.length" :number-style="{ backgroundColor: '#52c41a' }"
:style="{ marginLeft: '8px' }" />
</span>
<a-table :columns="backupColumns" :data-source="backupList" :pagination="false" :loading="backupLoading"
size="small" row-key="filename">
<template slot="size" slot-scope="text">
[[ formatFileSize(text) ]]
</template>
<template slot="timestamp" slot-scope="text">
[[ formatBackupTime(text) ]]
</template>
<template slot="action" slot-scope="text, record">
<a-space>
<a-button size="small" icon="download" @click="downloadBackup(record.filename)">Download</a-button>
<a-popconfirm :title="'Restore will stop the panel temporarily. Continue?'" ok-text="Yes"
cancel-text="No" @confirm="restoreBackup(record.filename)">
<a-button size="small" type="danger" icon="redo">Restore</a-button>
</a-popconfirm>
<a-popconfirm title="Delete this backup?" ok-text="Yes" cancel-text="No"
@confirm="deleteBackup(record.filename)">
<a-button size="small" icon="delete">Delete</a-button>
</a-popconfirm>
</a-space>
</template>
</a-table>
</a-card>
</a-col>
</a-row>
</div>
```
- [ ] **Step 3: Add backup tab to settings.html**
In `web/html/settings.html`, after the clash tab (closing `</a-tab-pane>` on line 109), add:
```html
<a-tab-pane key="7" :style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="database"></a-icon>
<span>Backup</span>
</template>
{{ template "settings/backup" . }}
</a-tab-pane>
```
- [ ] **Step 4: Add backup Vue.js methods and data to settings.html script**
In `web/html/settings.html`, in the `<script>` section (after the Vue data block), add to the `data` object:
```javascript
backupList: [],
backupColumns: [
{ title: 'Filename', dataIndex: 'filename', key: 'filename' },
{ title: 'Timestamp', dataIndex: 'timestamp', key: 'timestamp', scopedSlots: { customRender: 'timestamp' } },
{ title: 'Size', dataIndex: 'size', key: 'size', scopedSlots: { customRender: 'size' } },
{ title: 'Actions', key: 'action', scopedSlots: { customRender: 'action' } }
],
backupLoading: false,
backupCreating: false,
```
And add methods to the Vue instance:
```javascript
fetchBackups() {
this.backupLoading = true;
axios.get(this.entryHost + 'panel/api/server/listBackups', {
headers: this.authHeaders
}).then(res => {
this.backupList = res.data.obj || [];
}).catch(err => {
this.$message.error('Failed to load backups: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.backupLoading = false;
});
},
createBackup() {
this.backupCreating = true;
axios.post(this.entryHost + 'panel/api/server/backup', {}, {
headers: this.authHeaders
}).then(res => {
this.$message.success('Backup created successfully');
this.fetchBackups();
}).catch(err => {
this.$message.error('Backup failed: ' + (err.response?.data?.msg || err.message));
}).finally(() => {
this.backupCreating = false;
});
},
restoreBackup(filename) {
axios.post(this.entryHost + 'panel/api/server/restore/' + filename, {}, {
headers: this.authHeaders
}).then(res => {
this.$message.success('Restore completed successfully');
this.fetchBackups();
}).catch(err => {
this.$message.error('Restore failed: ' + (err.response?.data?.msg || err.message));
});
},
deleteBackup(filename) {
axios.post(this.entryHost + 'panel/api/server/deleteBackup/' + filename, {}, {
headers: this.authHeaders
}).then(res => {
this.$message.success('Backup deleted');
this.fetchBackups();
}).catch(err => {
this.$message.error('Delete failed: ' + (err.response?.data?.msg || err.message));
});
},
downloadBackup(filename) {
// Open download in new tab
window.open(this.entryHost + 'panel/api/server/downloadBackup/' + filename, '_blank');
},
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
},
formatBackupTime(ts) {
if (!ts) return '';
return ts.replace(/-/g, ':').replace(/(\d{4}):(\d{2}):(\d{2}):(\d{2})(\d{2})(\d{2})/, '$1-$2-$3 $4:$5:$6');
},
```
Also add a watcher or call `fetchBackups` when the backup tab is selected. Add to `onSettingsTabChange` method or add a `watch`:
```javascript
onSettingsTabChange(key) {
if (key === '7') {
this.fetchBackups();
}
},
```
If there's already an `onSettingsTabChange` method, merge the `key === '7'` condition.
Also add a periodic refresh timer. In the `mounted` hook or similar:
```javascript
startBackupRefresh() {
this.backupRefreshInterval = setInterval(() => {
const activeTab = this.$el?.querySelector?.('.ant-tabs-tab-active')?.getAttribute?.('data-node-key');
if (activeTab === '7') {
this.fetchBackups();
}
}, 30000);
},
```
And call `startBackupRefresh()` in `mounted()`, and clear it in `beforeDestroy()`:
```javascript
beforeDestroy() {
if (this.backupRefreshInterval) {
clearInterval(this.backupRefreshInterval);
}
},
```
- [ ] **Step 5: Verify template parses**
Run: `CGO_ENABLED=1 go build -ldflags "-w -s" ./...`
Expected: compilation passes
- [ ] **Step 6: Commit**
```bash
git add web/html/settings/backup.html web/html/settings.html
git commit -m "feat: add backup UI page and settings tab"
```
---
### Task 7: Add frontend model fields
**Files:**
- Modify: `web/assets/js/model/setting.js`
- [ ] **Step 1: Add backup fields to AllSetting JS class**
In `web/assets/js/model/setting.js`, add before the closing `}` of the AllSetting constructor (after line with `this.turnstileSecretKey` or after the LDAP fields):
```javascript
this.backupEnabled = false;
this.backupFrequency = "daily";
this.backupHour = 3;
this.backupMaxCount = 10;
```
- [ ] **Step 2: Match order with entity.go for presentKeys**
Verify the field order in `AllSetting` struct matches usage. The `UpdateAllSetting` function uses `json` tags — order doesn't matter as long as tag names match.
- [ ] **Step 3: Commit**
```bash
git add web/assets/js/model/setting.js
git commit -m "feat: add backup config fields to frontend model"
```
---
### Task 8: Add CLI subcommands to main.go
**Files:**
- Modify: `main.go:623-641` (add backup/restore flag sets)
- [ ] **Step 1: Add backup and restore command flag sets in main.go**
After the `migrateDbCmd` definition (around line 625), add:
```go
backupCmd := flag.NewFlagSet("backup", flag.ExitOnError)
restoreCmd := flag.NewFlagSet("restore", flag.ExitOnError)
var restoreFile string
restoreCmd.StringVar(&restoreFile, "file", "", "Backup file name to restore from")
```
- [ ] **Step 2: Add subcommands to the switch and usage**
Update the `flag.Usage` function (around line 636-641) to include new commands:
```go
fmt.Println(" backup create a database backup")
fmt.Println(" restore restore database from backup")
```
In the switch statement (after `case "setting":` block, around line 838), add:
```go
case "backup":
err := backupCmd.Parse(os.Args[2:])
if err != nil {
fmt.Println(err)
return
}
runBackup()
case "restore":
err := restoreCmd.Parse(os.Args[2:])
if err != nil {
fmt.Println(err)
return
}
if restoreFile == "" {
fmt.Println("--file flag is required")
return
}
runRestore(restoreFile)
```
- [ ] **Step 3: Add backup and restore functions in main.go**
Add these functions near other command functions (e.g., near `migrateDbBetweenDrivers`):
```go
// runBackup creates a database backup from the CLI.
func runBackup() {
checkNodeRoleOrExit()
dbCfg := config.GetDBConfigFromJSON()
// Initialize DB to read settings if needed
backupDir := "/etc/x-ui/backups"
os.MkdirAll(backupDir, 0755)
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 = dumpMariaDBCLI(dbCfg)
case "sqlite", "":
dbPath := config.GetDBPath()
dumpSQL, err = dumpSQLiteCLI(dbPath)
default:
fmt.Println("unsupported database type:", dbCfg.Type)
os.Exit(1)
}
if err != nil {
fmt.Println("dump failed:", err)
os.Exit(1)
}
meta := map[string]string{
"dbType": dbCfg.Type,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"version": config.GetVersion(),
}
if meta["dbType"] == "" {
meta["dbType"] = "sqlite"
}
if err := createTarGzCLI(filePath, meta, dumpSQL); err != nil {
fmt.Println("archive creation failed:", err)
os.Exit(1)
}
fmt.Println("backup created:", filePath)
}
// runRestore restores a database from a backup file via CLI.
func runRestore(filename string) {
checkNodeRoleOrExit()
filePath := filepath.Join("/etc/x-ui/backups", filename)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
fmt.Println("backup file not found:", filePath)
os.Exit(1)
}
meta, dumpSQL, err := extractTarGzCLI(filePath)
if err != nil {
fmt.Println("invalid backup file:", err)
os.Exit(1)
}
dbCfg := config.GetDBConfigFromJSON()
currentDBType := dbCfg.Type
if currentDBType == "" {
currentDBType = "sqlite"
}
if meta["dbType"] != currentDBType {
fmt.Printf("backup type (%s) does not match current database (%s)\n", meta["dbType"], currentDBType)
os.Exit(1)
}
// Create safety backup
timestamp := time.Now().Format("2006-01-02-150405")
safetyFile := filepath.Join("/etc/x-ui/backups", "pre-restore-"+timestamp+".tar.gz")
safetyMeta := map[string]string{
"dbType": currentDBType,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"version": config.GetVersion(),
}
var safetySQL string
switch currentDBType {
case "mariadb":
safetySQL, err = dumpMariaDBCLI(dbCfg)
default:
safetySQL, err = dumpSQLiteCLI(config.GetDBPath())
}
if err == nil {
if err := createTarGzCLI(safetyFile, safetyMeta, safetySQL); err == nil {
fmt.Println("safety backup created:", safetyFile)
}
}
if err := restoreDBCLI(dbCfg, dumpSQL); err != nil {
fmt.Println("restore failed:", err)
os.Exit(1)
}
fmt.Println("restore completed successfully")
}
func checkNodeRoleOrExit() {
nodeCfg := config.GetNodeConfigFromJSON()
if nodeCfg.Role == config.NodeRoleWorker {
fmt.Println("backup and restore can only be performed on the master node")
os.Exit(1)
}
}
func dumpMariaDBCLI(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
var stderr strings.Builder
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w: %s", err, stderr.String())
}
return out.String(), nil
}
func dumpSQLiteCLI(dbPath string) (string, error) {
cmd := exec.Command("sqlite3", dbPath, ".dump")
var out strings.Builder
var stderr strings.Builder
cmd.Stdout = &out
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("%w: %s", err, stderr.String())
}
return out.String(), nil
}
func restoreDBCLI(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("%w: %s", err, stderr.String())
}
default:
dbPath := config.GetDBPath()
cmd := exec.Command("sqlite3", dbPath)
cmd.Stdin = strings.NewReader(dumpSQL)
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%w: %s", err, stderr.String())
}
}
return nil
}
func createTarGzCLI(filePath string, meta map[string]string, 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()
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
}
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
}
func extractTarGzCLI(filePath string) (map[string]string, 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)
meta := make(map[string]string)
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":
json.Unmarshal([]byte(buf.String()), &meta)
case "dump.sql":
dumpSQL = buf.String()
}
}
if dumpSQL == "" {
return nil, "", fmt.Errorf("dump.sql not found")
}
if meta["dbType"] == "" {
return nil, "", fmt.Errorf("metadata.json missing dbType")
}
return meta, dumpSQL, nil
}
```
- [ ] **Step 4: Add required imports in main.go**
Ensure these imports are added to main.go's import block (in addition to existing ones):
```go
"archive/tar"
"compress/gzip"
"encoding/json"
"io"
"os/exec"
"path/filepath"
"strings"
"time"
```
- [ ] **Step 5: Verify compilation**
Run: `go build -ldflags "-w -s" -o /usr/local/x-ui/x-ui ./main.go`
Expected: compilation passes
- [ ] **Step 6: Test CLI backup (if SQLite)**
Run: `./x-ui backup`
Expected: "backup created: /etc/x-ui/backups/backup-YYYY-MM-DD-HHmmss.tar.gz"
- [ ] **Step 7: Commit**
```bash
git add main.go
git commit -m "feat: add backup and restore CLI subcommands"
```
---
### Task 9: Modify x-ui.sh
**Files:**
- Modify: `x-ui.sh:3826-3877` (add subcommands)
- Modify: `x-ui.sh:3591-3689` (add db_menu items)
- [ ] **Step 1: Add backup, restore, list-backups to subcommand router**
In `x-ui.sh`, add before the `*) show_usage ;;` line in the subcommand routing section:
```bash
"backup" ) check_install 0 && backup_db ;;
"restore" ) check_install 0 && restore_db "${2}" ;;
"list-backups" ) check_install 0 && list_backups ;;
```
- [ ] **Step 2: Add backup, restore, list-backups functions**
Add these functions in the script, near the db_menu section:
```bash
backup_db() {
echo -e "${green}Creating database backup...${plain}"
${xui_folder}/x-ui backup
}
restore_db() {
local backup_file="$1"
if [[ -z "$backup_file" ]]; then
echo -e "${red}Usage: x-ui restore <backup-filename>${plain}"
list_backups
return 1
fi
local full_path="/etc/x-ui/backups/${backup_file}"
if [[ ! -f "$full_path" ]]; then
echo -e "${red}Backup file not found: $full_path${plain}"
list_backups
return 1
fi
echo -e "${yellow}WARNING: Restore will stop the panel and replace the database.${plain}"
read -p "Continue? (y/n) " confirm
if [[ "$confirm" != "y" ]]; then
echo "Cancelled."
return 0
fi
echo "Stopping panel..."
stop
echo "Restoring from $backup_file..."
${xui_folder}/x-ui restore --file="$backup_file"
echo "Starting panel..."
start
echo -e "${green}Restore completed.${plain}"
}
list_backups() {
local backup_dir="/etc/x-ui/backups"
if [[ ! -d "$backup_dir" ]]; then
echo "No backups found."
return 0
fi
echo -e "${green}Backups in ${backup_dir}:${plain}"
ls -lh "$backup_dir" | grep "backup-" | awk '{print $5, $6, $7, $8, $9}'
}
```
Note: `xui_db_folder` is already defined in x-ui.sh as `/etc/x-ui` (default). The `backup` directory path in the script should use `/etc/x-ui/backups` (same as the Go constant).
- [ ] **Step 3: Add backup menu items to db_menu**
In the `db_menu()` function, at the end before the menu display (before the read/prompt), add these menu items:
```bash
17) echo -e " 17) ${green}Create database backup${plain}" ;;
18) echo -e " 18) ${green}Restore from backup${plain}" ;;
19) echo -e " 19) ${green}List all backups${plain}" ;;
```
And in the case statement after item 16:
```bash
17) backup_db ;;
18)
list_backups
echo ""
read -p "Enter backup filename to restore: " restore_filename
restore_db "$restore_filename"
;;
19) list_backups ;;
```
- [ ] **Step 4: Commit**
```bash
git add x-ui.sh
git commit -m "feat: add backup/restore subcommands and menu to x-ui.sh"
```
---
### Task 10: Build, format, and verify
- [ ] **Step 1: Format Go code**
Run: `gofmt -l -w .`
Expected: no output (or only files that were modified)
- [ ] **Step 2: Generate assets**
Run: `go run ./cmd/genassets`
Expected: completes successfully
- [ ] **Step 3: Build binary**
Run: `CGO_ENABLED=1 go build -ldflags "-w -s" -o /usr/local/x-ui/x-ui ./main.go`
Expected: compilation passes with no errors
- [ ] **Step 4: Run vet and staticcheck**
Run: `go vet ./...`
Expected: no errors
- [ ] **Step 5: Run tests (database-related)**
Run: `go test -race ./database/...`
Expected: PASS
Run: `go test -race ./web/service/...`
Expected: PASS
- [ ] **Step 6: Verify backup directory creation**
Run: `ls -la /etc/x-ui/backups/` (if running service)
Expected: directory created
- [ ] **Step 7: Create task tracking record**
Create file `docs/Tasktracking/2026-04-26-database-backup-snapshot.md` with the standard template.
- [ ] **Step 8: Final commit**
```bash
git add -f docs/Tasktracking/2026-04-26-database-backup-snapshot.md
git commit -m "docs: add task tracking record for backup feature"
```