43 KiB
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 tosystemIntegrationgroup or newbackupgroup) -
Step 1: Add backup fields to AllSetting struct
In web/entity/entity.go, add before the closing of the struct (line 124, after TurnstileSecretKey):
// 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",):
// 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):
"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):
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
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
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
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
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
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
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
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:
// 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:
// 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
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
<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:
<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:
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:
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:
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:
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():
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
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):
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
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:
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:
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:
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):
// 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):
"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
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:
"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:
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:
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:
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
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
git add -f docs/Tasktracking/2026-04-26-database-backup-snapshot.md
git commit -m "docs: add task tracking record for backup feature"