3x-ui/database/backup.go
The_Just 6c9ef87fbe add PostgreSQL backend and portable backup system.
- Add SQLite/PostgreSQL switching via panel UI and env variables
- Introduce portable .xui-backup format for cross-backend backups
- Add connection pooling and PrepareStmt cache for PostgreSQL
- Fix raw SQL double-quote bug breaking queries on PostgreSQL
- Fix GORM record-not-found log spam on every Xray config poll
- Add database section to Settings with full EN/RU i18n
2026-04-07 19:18:32 +03:00

396 lines
11 KiB
Go

package database
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/mhsanaei/3x-ui/v2/config"
"github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/xray"
"gorm.io/gorm"
)
const PortableBackupFormatVersion = 1
type BackupManifest struct {
FormatVersion int `json:"formatVersion"`
CreatedAt string `json:"createdAt"`
SourceDriver string `json:"sourceDriver"`
AppVersion string `json:"appVersion"`
IncludesConfig bool `json:"includesConfig"`
}
type BackupSnapshot struct {
Manifest BackupManifest `json:"manifest"`
Users []model.User `json:"users"`
Inbounds []model.Inbound `json:"inbounds"`
ClientTraffics []xray.ClientTraffic `json:"clientTraffics"`
OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"`
Settings []model.Setting `json:"settings"`
InboundClientIps []model.InboundClientIps `json:"inboundClientIps"`
HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"`
}
func newBackupSnapshot(sourceDriver string) *BackupSnapshot {
return &BackupSnapshot{
Manifest: BackupManifest{
FormatVersion: PortableBackupFormatVersion,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
SourceDriver: sourceDriver,
AppVersion: config.GetVersion(),
IncludesConfig: false,
},
}
}
func loadSnapshotRows(conn *gorm.DB, modelRef any, dest any) error {
if !conn.Migrator().HasTable(modelRef) {
return nil
}
return conn.Model(modelRef).Order("id ASC").Find(dest).Error
}
// ExportSnapshot extracts a logical snapshot from an arbitrary database connection.
func ExportSnapshot(conn *gorm.DB, sourceDriver string) (*BackupSnapshot, error) {
snapshot := newBackupSnapshot(sourceDriver)
if err := loadSnapshotRows(conn, &model.User{}, &snapshot.Users); err != nil {
return nil, err
}
if err := loadSnapshotRows(conn, &model.Inbound{}, &snapshot.Inbounds); err != nil {
return nil, err
}
for i := range snapshot.Inbounds {
snapshot.Inbounds[i].ClientStats = nil
}
if err := loadSnapshotRows(conn, &xray.ClientTraffic{}, &snapshot.ClientTraffics); err != nil {
return nil, err
}
if err := loadSnapshotRows(conn, &model.OutboundTraffics{}, &snapshot.OutboundTraffics); err != nil {
return nil, err
}
if err := loadSnapshotRows(conn, &model.Setting{}, &snapshot.Settings); err != nil {
return nil, err
}
if err := loadSnapshotRows(conn, &model.InboundClientIps{}, &snapshot.InboundClientIps); err != nil {
return nil, err
}
if err := loadSnapshotRows(conn, &model.HistoryOfSeeders{}, &snapshot.HistoryOfSeeders); err != nil {
return nil, err
}
return snapshot, nil
}
// ExportCurrentSnapshot extracts a logical snapshot from the active database.
func ExportCurrentSnapshot() (*BackupSnapshot, error) {
if db == nil {
return nil, errors.New("database is not initialized")
}
return ExportSnapshot(db, GetDriver())
}
// LoadSnapshotFromSQLiteFile extracts a logical snapshot from a legacy SQLite database file.
func LoadSnapshotFromSQLiteFile(path string) (*BackupSnapshot, error) {
if err := ValidateSQLiteDB(path); err != nil {
return nil, err
}
cfg := config.DefaultDatabaseConfig()
cfg.Driver = config.DatabaseDriverSQLite
cfg.SQLite.Path = path
conn, err := OpenDatabase(cfg)
if err != nil {
return nil, err
}
defer CloseConnection(conn)
if err := MigrateModels(conn); err != nil {
return nil, err
}
return ExportSnapshot(conn, config.DatabaseDriverSQLite)
}
// EncodePortableBackup serializes a logical snapshot into the portable .xui-backup format.
func EncodePortableBackup(snapshot *BackupSnapshot) ([]byte, error) {
if snapshot == nil {
return nil, errors.New("backup snapshot is nil")
}
manifestBytes, err := json.MarshalIndent(snapshot.Manifest, "", " ")
if err != nil {
return nil, err
}
payload := struct {
Users []model.User `json:"users"`
Inbounds []model.Inbound `json:"inbounds"`
ClientTraffics []xray.ClientTraffic `json:"clientTraffics"`
OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"`
Settings []model.Setting `json:"settings"`
InboundClientIps []model.InboundClientIps `json:"inboundClientIps"`
HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"`
}{
Users: snapshot.Users,
Inbounds: snapshot.Inbounds,
ClientTraffics: snapshot.ClientTraffics,
OutboundTraffics: snapshot.OutboundTraffics,
Settings: snapshot.Settings,
InboundClientIps: snapshot.InboundClientIps,
HistoryOfSeeders: snapshot.HistoryOfSeeders,
}
dataBytes, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return nil, err
}
buffer := &bytes.Buffer{}
archive := zip.NewWriter(buffer)
manifestWriter, err := archive.Create("manifest.json")
if err != nil {
return nil, err
}
if _, err := manifestWriter.Write(manifestBytes); err != nil {
return nil, err
}
dataWriter, err := archive.Create("data.json")
if err != nil {
return nil, err
}
if _, err := dataWriter.Write(dataBytes); err != nil {
return nil, err
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
// EncodeCurrentPortableBackup serializes the active database into the portable backup format.
func EncodeCurrentPortableBackup() ([]byte, error) {
snapshot, err := ExportCurrentSnapshot()
if err != nil {
return nil, err
}
return EncodePortableBackup(snapshot)
}
// DecodePortableBackup parses a .xui-backup archive back into a logical snapshot.
func DecodePortableBackup(data []byte) (*BackupSnapshot, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, err
}
files := make(map[string]*zip.File, len(reader.File))
for _, file := range reader.File {
files[file.Name] = file
}
manifestFile, ok := files["manifest.json"]
if !ok {
return nil, errors.New("portable backup is missing manifest.json")
}
dataFile, ok := files["data.json"]
if !ok {
return nil, errors.New("portable backup is missing data.json")
}
readZipFile := func(file *zip.File) ([]byte, error) {
rc, err := file.Open()
if err != nil {
return nil, err
}
defer rc.Close()
return io.ReadAll(rc)
}
manifestBytes, err := readZipFile(manifestFile)
if err != nil {
return nil, err
}
dataBytes, err := readZipFile(dataFile)
if err != nil {
return nil, err
}
snapshot := &BackupSnapshot{}
if err := json.Unmarshal(manifestBytes, &snapshot.Manifest); err != nil {
return nil, err
}
if snapshot.Manifest.FormatVersion != PortableBackupFormatVersion {
return nil, fmt.Errorf("unsupported backup format version: %d", snapshot.Manifest.FormatVersion)
}
payload := struct {
Users []model.User `json:"users"`
Inbounds []model.Inbound `json:"inbounds"`
ClientTraffics []xray.ClientTraffic `json:"clientTraffics"`
OutboundTraffics []model.OutboundTraffics `json:"outboundTraffics"`
Settings []model.Setting `json:"settings"`
InboundClientIps []model.InboundClientIps `json:"inboundClientIps"`
HistoryOfSeeders []model.HistoryOfSeeders `json:"historyOfSeeders"`
}{}
if err := json.Unmarshal(dataBytes, &payload); err != nil {
return nil, err
}
snapshot.Users = payload.Users
snapshot.Inbounds = payload.Inbounds
snapshot.ClientTraffics = payload.ClientTraffics
snapshot.OutboundTraffics = payload.OutboundTraffics
snapshot.Settings = payload.Settings
snapshot.InboundClientIps = payload.InboundClientIps
snapshot.HistoryOfSeeders = payload.HistoryOfSeeders
return snapshot, nil
}
func clearApplicationTables(tx *gorm.DB) error {
deleteAll := func(modelRef any) error {
return tx.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(modelRef).Error
}
if err := deleteAll(&xray.ClientTraffic{}); err != nil {
return err
}
if err := deleteAll(&model.OutboundTraffics{}); err != nil {
return err
}
if err := deleteAll(&model.InboundClientIps{}); err != nil {
return err
}
if err := deleteAll(&model.HistoryOfSeeders{}); err != nil {
return err
}
if err := deleteAll(&model.Setting{}); err != nil {
return err
}
if err := deleteAll(&model.Inbound{}); err != nil {
return err
}
if err := deleteAll(&model.User{}); err != nil {
return err
}
return nil
}
func resetPostgresSequence(tx *gorm.DB, tableName string) error {
var seq string
if err := tx.Raw("SELECT pg_get_serial_sequence(?, ?)", tableName, "id").Scan(&seq).Error; err != nil {
return err
}
if seq == "" {
return nil
}
var maxID int64
if err := tx.Raw(fmt.Sprintf("SELECT COALESCE(MAX(id), 0) FROM %s", tableName)).Scan(&maxID).Error; err != nil {
return err
}
if maxID > 0 {
return tx.Exec("SELECT setval(CAST(? AS regclass), ?, true)", seq, maxID).Error
}
return tx.Exec("SELECT setval(CAST(? AS regclass), ?, false)", seq, 1).Error
}
func resetSequences(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
tables := []string{
"users",
"inbounds",
"client_traffics",
"outbound_traffics",
"settings",
"inbound_client_ips",
"history_of_seeders",
}
for _, tableName := range tables {
if err := resetPostgresSequence(tx, tableName); err != nil {
return err
}
}
return nil
}
// ApplySnapshot fully replaces application data in the target database using a logical snapshot.
func ApplySnapshot(conn *gorm.DB, snapshot *BackupSnapshot) error {
if conn == nil {
return errors.New("target database is nil")
}
if snapshot == nil {
return errors.New("backup snapshot is nil")
}
if err := MigrateModels(conn); err != nil {
return err
}
return conn.Transaction(func(tx *gorm.DB) error {
if err := clearApplicationTables(tx); err != nil {
return err
}
for i := range snapshot.Inbounds {
snapshot.Inbounds[i].ClientStats = nil
}
if len(snapshot.Users) > 0 {
if err := tx.Create(&snapshot.Users).Error; err != nil {
return err
}
}
if len(snapshot.Inbounds) > 0 {
if err := tx.Create(&snapshot.Inbounds).Error; err != nil {
return err
}
}
if len(snapshot.ClientTraffics) > 0 {
if err := tx.Create(&snapshot.ClientTraffics).Error; err != nil {
return err
}
}
if len(snapshot.OutboundTraffics) > 0 {
if err := tx.Create(&snapshot.OutboundTraffics).Error; err != nil {
return err
}
}
if len(snapshot.Settings) > 0 {
if err := tx.Create(&snapshot.Settings).Error; err != nil {
return err
}
}
if len(snapshot.InboundClientIps) > 0 {
if err := tx.Create(&snapshot.InboundClientIps).Error; err != nil {
return err
}
}
if len(snapshot.HistoryOfSeeders) > 0 {
if err := tx.Create(&snapshot.HistoryOfSeeders).Error; err != nil {
return err
}
}
return resetSequences(tx)
})
}
// SavePortableBackup writes a portable backup archive to disk.
func SavePortableBackup(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}