mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(server): honor keepHostSettings for SQLite imports into PostgreSQL (#6995)
importPostgresDB forwarded keepHostSettings only for native PostgreSQL dumps; both SQLite branches dropped it, so importing a .db or SQL dump copied the source's ports, paths, certificates and node identity over the destination's. Both formats now capture the host-bound settings first and restore them after a successful migration. CI runs the new integration test against PostgreSQL. Fixes #6993.
This commit is contained in:
@@ -191,6 +191,15 @@ jobs:
|
||||
echo "expected at least 4 passing PostgreSQL migration tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
- name: SQLite import host settings
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/web/service -run '^TestImportSQLiteIntoPostgresHostSettings$' -count=1 -v | tee /tmp/postgres-import-host-settings.log
|
||||
passed=$(grep -c -- '^--- PASS: TestImportSQLiteIntoPostgresHostSettings ' /tmp/postgres-import-host-settings.log || true)
|
||||
if [ "$passed" -ne 1 ]; then
|
||||
echo "expected the SQLite import integration test to pass, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
codegen:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
func sqliteImportHostValues(machine string) map[string]string {
|
||||
port, address := "2053", "127.0.0.2"
|
||||
if machine == "source" {
|
||||
port, address = "2087", "127.0.0.3"
|
||||
}
|
||||
return map[string]string{
|
||||
"webListen": address, "webDomain": machine + ".example", "webPort": port,
|
||||
"webCertFile": "/" + machine + "/panel.crt", "webKeyFile": "/" + machine + "/panel.key", "webBasePath": "/" + machine + "/",
|
||||
"subListen": address, "subDomain": "sub." + machine + ".example", "subPort": port,
|
||||
"subCertFile": "/" + machine + "/sub.crt", "subKeyFile": "/" + machine + "/sub.key",
|
||||
"subURI": "https://" + machine + ".example/sub/", "subJsonURI": "https://" + machine + ".example/json/",
|
||||
"secret": machine + "-secret", "panelGuid": machine + "-guid",
|
||||
"nodeMtlsCaCertPem": machine + "-ca-cert", "nodeMtlsCaKeyPem": machine + "-ca-key",
|
||||
"nodeMtlsClientCertPem": machine + "-client-cert", "nodeMtlsClientKeyPem": machine + "-client-key",
|
||||
"nodeMtlsClientCertSha256": machine + "-client-sha", "nodeMtlsClientCAPem": machine + "-client-ca",
|
||||
}
|
||||
}
|
||||
|
||||
func seedSQLiteImportSettings(t *testing.T, values map[string]string, template string) {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
if err := db.Exec("DELETE FROM settings").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, value := range values {
|
||||
if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := db.Create(&model.Setting{Key: "remarkTemplate", Value: template}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every migration gets its own schema, even when CI supplies a shared database.
|
||||
func sqliteImportPostgresDestination(t *testing.T, dsn string) {
|
||||
t.Helper()
|
||||
admin, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pool, err := admin.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
schema := fmt.Sprintf("import_host_%d", time.Now().UnixNano())
|
||||
if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = database.CloseDB()
|
||||
if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
scoped := dsn + " search_path=" + schema
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
parsed, err := url.Parse(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
query.Set("search_path", schema)
|
||||
parsed.RawQuery = query.Encode()
|
||||
scoped = parsed.String()
|
||||
}
|
||||
t.Setenv("XUI_DB_TYPE", "postgres")
|
||||
t.Setenv("XUI_DB_DSN", scoped)
|
||||
if err := database.InitDB(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.User{Id: 101, Username: "destination-user", Password: "test-password"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Database import/reopen is real; the core executable is a harmless local stub.
|
||||
// No real Xray binary, network listener, or production configuration is used.
|
||||
func sqliteImportTestCore(t *testing.T) {
|
||||
t.Helper()
|
||||
bin := t.TempDir()
|
||||
t.Setenv("XUI_BIN_FOLDER", bin)
|
||||
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
|
||||
stub := "#!/bin/sh\nif [ \"$1\" = -version ]; then echo 'Xray 0.0.0 test'; exit 0; fi\nexec sleep 60\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, xray.GetBinaryName()), []byte(stub), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previousProcess, previousResult := xrayState.snapshot()
|
||||
previousHeldBack := xrayState.heldBackReason()
|
||||
previousStopped := isManuallyStopped.Load()
|
||||
xrayState.replace(nil)
|
||||
t.Cleanup(func() {
|
||||
if process := currentXrayProcess(); process != nil {
|
||||
_ = process.Stop()
|
||||
}
|
||||
xrayState.mu.Lock()
|
||||
xrayState.process, xrayState.result, xrayState.heldBack = previousProcess, previousResult, previousHeldBack
|
||||
xrayState.mu.Unlock()
|
||||
isManuallyStopped.Store(previousStopped)
|
||||
})
|
||||
}
|
||||
|
||||
func TestImportSQLiteIntoPostgresHostSettings(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
|
||||
if dsn == "" {
|
||||
t.Skip("set XUI_TEST_PG_DSN to run real SQLite-to-PostgreSQL imports")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("the isolated core executable fixture requires a POSIX environment")
|
||||
}
|
||||
t.Setenv("XUI_DB_TYPE", "sqlite")
|
||||
t.Setenv("XUI_DB_DSN", "")
|
||||
t.Setenv("XUI_DB_FOLDER", t.TempDir())
|
||||
sourcePath := config.GetDBPath()
|
||||
dbtest.InitDB(t, sourcePath)
|
||||
sourceValues := sqliteImportHostValues("source")
|
||||
seedSQLiteImportSettings(t, sourceValues, "source-template")
|
||||
db := database.GetDB()
|
||||
if err := db.Model(&model.User{}).Where("id = 1").Update("username", "source-user").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.Inbound{UserId: 1, Tag: "source-inbound", Port: 62001, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp"}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&model.ClientRecord{Email: "source-client", SubID: "source-sub", Enable: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.CloseDB(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dump, err := database.DumpSQLiteToBytes(sourcePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for format, uploadBytes := range map[string][]byte{"db": data, "dump": dump} {
|
||||
for _, keep := range []bool{true, false} {
|
||||
for _, profile := range []string{"local", "sparse", "absent", "failed-copy"} {
|
||||
t.Run(fmt.Sprintf("%s/keep=%t/%s", format, keep, profile), func(t *testing.T) {
|
||||
sqliteImportPostgresDestination(t, dsn)
|
||||
sqliteImportTestCore(t)
|
||||
local := sqliteImportHostValues("destination")
|
||||
switch profile {
|
||||
case "sparse":
|
||||
local["webCertFile"], local["nodeMtlsClientKeyPem"] = "", ""
|
||||
for _, key := range []string{"webKeyFile", "subListen", "nodeMtlsClientCertSha256", "nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCAPem"} {
|
||||
delete(local, key)
|
||||
}
|
||||
case "absent":
|
||||
clear(local)
|
||||
}
|
||||
seedSQLiteImportSettings(t, local, "destination-template")
|
||||
if err := database.GetDB().Create(&model.Inbound{UserId: 101, Tag: "destination-inbound", Port: 62002, Protocol: model.VLESS, Settings: `{"clients":[],"decryption":"none"}`, StreamSettings: `{"network":"tcp"}`}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.GetDB().Create(&model.ClientRecord{Email: "destination-client", SubID: "destination-sub", Enable: true}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile == "failed-copy" {
|
||||
for _, sql := range []string{
|
||||
`ALTER TABLE settings ADD CONSTRAINT reject_import CHECK (value <> 'source-template')`,
|
||||
`CREATE TABLE host_restore_audit (key text)`,
|
||||
`CREATE FUNCTION audit_host_restore() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO host_restore_audit VALUES (NEW.key); RETURN NEW; END $$`,
|
||||
`CREATE TRIGGER audit_host_restore AFTER UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION audit_host_restore()`,
|
||||
} {
|
||||
if err := database.GetDB().Exec(sql).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
uploadPath := filepath.Join(t.TempDir(), "backup."+format)
|
||||
if err := os.WriteFile(uploadPath, uploadBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
upload, err := os.Open(uploadPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer upload.Close()
|
||||
err = (&ServerService{}).ImportDB(upload, keep)
|
||||
want := maps.Clone(sourceValues)
|
||||
if profile == "failed-copy" {
|
||||
if err == nil || !strings.Contains(err.Error(), "Importing the SQLite data into PostgreSQL failed") {
|
||||
t.Fatalf("ImportDB error = %v, want a rolled-back migration failure", err)
|
||||
}
|
||||
want = maps.Clone(local)
|
||||
var updates int64
|
||||
if err := database.GetDB().Table("host_restore_audit").Count(&updates).Error; err != nil || updates != 0 {
|
||||
t.Fatalf("host restoration ran after failed migration: updates=%d, error=%v", updates, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("ImportDB: %v", err)
|
||||
}
|
||||
if keep {
|
||||
want = maps.Clone(local)
|
||||
for _, key := range []string{"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem", "nodeMtlsClientKeyPem", "nodeMtlsClientCAPem"} {
|
||||
if _, exists := local[key]; !exists {
|
||||
want[key] = sourceValues[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var settings []model.Setting
|
||||
if err := database.GetDB().Find(&settings).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make(map[string]string)
|
||||
for _, row := range settings {
|
||||
got[row.Key] = row.Value
|
||||
}
|
||||
for key := range sourceValues {
|
||||
value, exists := got[key]
|
||||
wantValue, wantExists := want[key]
|
||||
if exists != wantExists || value != wantValue {
|
||||
t.Errorf("%s = %q (present=%t), want %q (present=%t)", key, value, exists, wantValue, wantExists)
|
||||
}
|
||||
}
|
||||
username, template, count := "source-user", "source-template", int64(1)
|
||||
if profile == "failed-copy" {
|
||||
username, template, count = "destination-user", "destination-template", 0
|
||||
}
|
||||
var user model.User
|
||||
if err := database.GetDB().First(&user).Error; err != nil || user.Username != username {
|
||||
t.Errorf("user = %q, error=%v, want %q", user.Username, err, username)
|
||||
}
|
||||
if got["remarkTemplate"] != template {
|
||||
t.Errorf("ordinary setting = %q, want %q", got["remarkTemplate"], template)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
model any
|
||||
column, source, destination string
|
||||
}{
|
||||
{&model.Inbound{}, "tag", "source-inbound", "destination-inbound"},
|
||||
{&model.ClientRecord{}, "email", "source-client", "destination-client"},
|
||||
} {
|
||||
for value, wantCount := range map[string]int64{check.source: count, check.destination: 1 - count} {
|
||||
var rows int64
|
||||
if err := database.GetDB().Model(check.model).Where(check.column+" = ?", value).Count(&rows).Error; err != nil || rows != wantCount {
|
||||
t.Errorf("%s count=%d, error=%v, want %d", value, rows, err, wantCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2147,9 +2147,9 @@ func (s *ServerService) importPostgresDB(file multipart.File, keepHostSettings b
|
||||
case importKindPgDump:
|
||||
return s.restorePostgresDump(file, keepHostSettings)
|
||||
case importKindSQLiteDB:
|
||||
return s.migrateSQLiteIntoPostgres(file, false)
|
||||
return s.migrateSQLiteIntoPostgres(file, false, keepHostSettings)
|
||||
case importKindSQLiteDump:
|
||||
return s.migrateSQLiteIntoPostgres(file, true)
|
||||
return s.migrateSQLiteIntoPostgres(file, true, keepHostSettings)
|
||||
default:
|
||||
return common.NewError("Invalid file: expected a PostgreSQL custom-format dump (.dump) from this panel's Back Up, a SQLite database (.db), or a SQLite migration dump")
|
||||
}
|
||||
@@ -2231,7 +2231,7 @@ func (s *ServerService) restorePostgresDump(file multipart.File, keepHostSetting
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump bool) error {
|
||||
func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump, keepHostSettings bool) error {
|
||||
tempDir, err := os.MkdirTemp("", "x-ui-pg-migrate-*")
|
||||
if err != nil {
|
||||
return common.NewErrorf("Error creating temporary folder: %v", err)
|
||||
@@ -2272,6 +2272,11 @@ func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump
|
||||
logger.Warningf("Failed to stop Xray before DB restore: %v", errStop)
|
||||
}
|
||||
|
||||
var keptSettings hostBoundSnapshot
|
||||
if keepHostSettings {
|
||||
keptSettings = captureHostBoundSettings()
|
||||
}
|
||||
|
||||
if errClose := database.CloseDB(); errClose != nil {
|
||||
logger.Warningf("Failed to close existing DB before restore: %v", errClose)
|
||||
}
|
||||
@@ -2281,6 +2286,9 @@ func (s *ServerService) migrateSQLiteIntoPostgres(file multipart.File, isSQLDump
|
||||
if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
|
||||
return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
|
||||
}
|
||||
if migrateErr == nil {
|
||||
restoreHostBoundSettings(keptSettings)
|
||||
}
|
||||
s.inboundService.MigrateDB()
|
||||
|
||||
if migrateErr != nil {
|
||||
|
||||
Reference in New Issue
Block a user