mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-04-14 19:45:47 +00:00
397 lines
11 KiB
Go
397 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)
|
||
|
|
}
|