From d2450e103c2b543a78f909705e573bfc7ebb1867 Mon Sep 17 00:00:00 2001 From: Hamed Date: Mon, 2 Nov 2026 17:43:58 +0330 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 9 + .../service/import_sqlite_postgres_test.go | 276 ++++++++++++++++++ internal/web/service/server.go | 14 +- 3 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 internal/web/service/import_sqlite_postgres_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 350a85e74..3e4e129ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/internal/web/service/import_sqlite_postgres_test.go b/internal/web/service/import_sqlite_postgres_test.go new file mode 100644 index 000000000..2683a26f8 --- /dev/null +++ b/internal/web/service/import_sqlite_postgres_test.go @@ -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) + } + } + } + }) + } + } + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 294625def..e5c3a3625 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -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 {