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:
Hamed
2026-11-02 17:43:58 +03:30
committed by GitHub
parent 4180899f15
commit d2450e103c
3 changed files with 296 additions and 3 deletions
+9
View File
@@ -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)
}
}
}
})
}
}
}
}
+11 -3
View File
@@ -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 {