Explorar o código

feat(server): keep this machine's own settings when importing a database (#6227)

* feat(server): keep this machine's own settings when importing a database

Import replaces the database wholesale, so the uploaded file's listen
addresses, ports, base path, certificate paths and node identity land on the
destination. Moving a configuration to a new host therefore leaves the panel
answering on an address it does not own, presenting certificates it does not
have, and claiming the source machine's identity towards its nodes.

Capture the host-bound settings before the swap and write them back once the
imported database opens. Everything else — inbounds, clients, templates, the
rest of the settings — still comes from the file.

A checkbox controls it, defaulting to keeping this machine's values; clearing
it restores the old behaviour for anyone deliberately cloning a host.

* fix(server): drop imported host settings this machine never had, and cover Postgres

Two gaps in the previous commit. The snapshot only recorded rows that existed,
so a key with no row here — the default for every certificate path, both listen
addresses and all the node mTLS material — kept the imported value: exactly the
case the change is meant to fix. The snapshot now records which keys were
absent and deletes the imported row for them, letting the default apply again.

The PostgreSQL path took the flag and ignored it, so a dump restore still
adopted the source machine's settings. It now captures and restores the same
way the SQLite path does.

* chore: drop the accidentally committed dist build stub

internal/web/dist/.gitkeep is what make dist-stub creates locally. Committing
it changes fresh-clone behaviour for everyone: today a bare go build fails
loudly on //go:embed all:dist, which is the documented signal to run the stub
target; with the file present the build succeeds and the panel serves an empty
dist instead.

---------

Co-authored-by: n0ctal <[email protected]>
Co-authored-by: Sanaei <[email protected]>
n0ctal hai 20 horas
pai
achega
6e80a468e3

+ 13 - 1
frontend/src/pages/index/BackupModal.tsx

@@ -1,5 +1,6 @@
+import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Modal } from 'antd';
+import { Button, Checkbox, Modal } from 'antd';
 import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
 
 import { HttpUtil, PromiseUtil } from '@/utils';
@@ -20,6 +21,7 @@ interface BackupModalProps {
 export default function BackupModal({ open, basePath: _basePath, onClose, onBusy }: BackupModalProps) {
   const { t } = useTranslation();
   const isPostgres = window.X_UI_DB_TYPE === 'postgres';
+  const [keepHostSettings, setKeepHostSettings] = useState(true);
 
   function exportDb() {
     window.location.href = (window.X_UI_BASE_PATH || '') + 'panel/api/server/getDb';
@@ -39,6 +41,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
 
       const formData = new FormData();
       formData.append('db', dbFile);
+      formData.append('keepHostSettings', String(keepHostSettings));
 
       onClose();
       onBusy({ busy: true, tip: `${t('pages.index.importDatabase')}…` });
@@ -105,6 +108,15 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
           </div>
           <Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
         </div>
+
+        <div className="backup-item">
+          <div className="backup-meta">
+            <Checkbox checked={keepHostSettings} onChange={(e) => setKeepHostSettings(e.target.checked)}>
+              {t('pages.index.importKeepHostSettings')}
+            </Checkbox>
+            <div className="backup-description">{t('pages.index.importKeepHostSettingsDesc')}</div>
+          </div>
+        </div>
       </div>
     </Modal>
   );

+ 5 - 1
internal/web/controller/server.go

@@ -375,7 +375,11 @@ func (a *ServerController) importDB(c *gin.Context) {
 		return
 	}
 	defer file.Close()
-	if err := a.serverService.ImportDB(file); err != nil {
+	// Absent field keeps this machine's own listen addresses, certificates and
+	// node identity: the safe default for the common case of moving a config to
+	// a new host. Send keepHostSettings=false to clone a machine wholesale.
+	keepHostSettings := c.Request.FormValue("keepHostSettings") != "false"
+	if err := a.serverService.ImportDB(file, keepHostSettings); err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
 		return
 	}

+ 99 - 0
internal/web/service/import_host_settings_test.go

@@ -0,0 +1,99 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// An imported database carries the source machine's listen addresses,
+// certificates and node identity. Keeping this machine's own values is what
+// stops the panel from becoming unreachable on its own address after a restore.
+func TestImportKeepsHostBoundSettings(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	mine := map[string]string{
+		"webPort":               "8443",
+		"webCertFile":           "/etc/ssl/this-host.pem",
+		"webBasePath":           "/mine/",
+		"subURI":                "https://this-host.example/sub/",
+		"panelGuid":             "this-host-guid",
+		"nodeMtlsClientCertPem": "this-host-leaf",
+	}
+	for key, value := range mine {
+		if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+			t.Fatalf("seed %s: %v", key, err)
+		}
+	}
+	// A setting that belongs to the configuration, not the machine.
+	if err := db.Create(&model.Setting{Key: "remarkTemplate", Value: "mine"}).Error; err != nil {
+		t.Fatal(err)
+	}
+
+	kept := captureHostBoundSettings()
+	if len(kept.values) != len(mine) {
+		t.Fatalf("captured %d host settings, want %d: %v", len(kept.values), len(mine), kept.values)
+	}
+
+	// Stand in for the import: every row now holds the source machine's value.
+	for key := range mine {
+		if err := db.Model(&model.Setting{}).Where("key = ?", key).
+			Update("value", "from-imported-file").Error; err != nil {
+			t.Fatalf("overwrite %s: %v", key, err)
+		}
+	}
+	if err := db.Model(&model.Setting{}).Where("key = ?", "remarkTemplate").
+		Update("value", "from-imported-file").Error; err != nil {
+		t.Fatal(err)
+	}
+
+	restoreHostBoundSettings(kept)
+
+	for key, want := range mine {
+		var got model.Setting
+		if err := db.Where("key = ?", key).First(&got).Error; err != nil {
+			t.Fatalf("read back %s: %v", key, err)
+		}
+		if got.Value != want {
+			t.Fatalf("setting %s = %q after import, want this machine's %q", key, got.Value, want)
+		}
+	}
+
+	var carried model.Setting
+	if err := db.Where("key = ?", "remarkTemplate").First(&carried).Error; err != nil {
+		t.Fatal(err)
+	}
+	if carried.Value != "from-imported-file" {
+		t.Fatalf("remarkTemplate = %q, want the imported value: only host-bound keys may survive", carried.Value)
+	}
+}
+
+// The destination usually has no row at all for the certificate paths and the
+// node identity — the built-in default applies. The imported row must go, or
+// the panel quietly adopts the source machine's certificate path.
+func TestImportDropsHostBoundSettingsThisMachineNeverHad(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	kept := captureHostBoundSettings()
+
+	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+		if err := db.Create(&model.Setting{Key: key, Value: "from-imported-file"}).Error; err != nil {
+			t.Fatalf("seed imported %s: %v", key, err)
+		}
+	}
+
+	restoreHostBoundSettings(kept)
+
+	for _, key := range []string{"webCertFile", "subCertFile", "nodeMtlsClientCertPem"} {
+		var count int64
+		if err := db.Model(&model.Setting{}).Where("key = ?", key).Count(&count).Error; err != nil {
+			t.Fatal(err)
+		}
+		if count != 0 {
+			t.Fatalf("imported %s survived although this machine had no row for it", key)
+		}
+	}
+}

+ 92 - 5
internal/web/service/server.go

@@ -30,6 +30,7 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/sys"
@@ -1434,9 +1435,81 @@ func (s *ServerService) GetMigration() ([]byte, string, error) {
 	return data, "x-ui.dump", nil
 }
 
-func (s *ServerService) ImportDB(file multipart.File) error {
+// hostBoundSettingKeys are the settings that describe *this* machine rather
+// than the configuration being carried: where the panel and the subscription
+// service listen, the certificates they present, and the identity this panel
+// uses towards its nodes. An import that overwrites them leaves the
+// destination unreachable on its own address, or impersonating the source.
+var hostBoundSettingKeys = []string{
+	"webListen", "webDomain", "webPort", "webCertFile", "webKeyFile", "webBasePath",
+	"subListen", "subDomain", "subPort", "subCertFile", "subKeyFile", "subURI", "subJsonURI",
+	"secret", "panelGuid",
+	"nodeMtlsCaCertPem", "nodeMtlsCaKeyPem", "nodeMtlsClientCertPem",
+	"nodeMtlsClientKeyPem", "nodeMtlsClientCertSha256", "nodeMtlsClientCAPem",
+}
+
+// hostBoundSnapshot records this machine's values, and just as importantly
+// which keys it had no row for: an absent row means the built-in default is in
+// force, and leaving the imported row in place would silently adopt the source
+// machine's certificate path or listen address.
+type hostBoundSnapshot struct {
+	values  map[string]string
+	present map[string]struct{}
+	taken   bool
+}
+
+func captureHostBoundSettings() hostBoundSnapshot {
+	db := database.GetDB()
+	if db == nil {
+		return hostBoundSnapshot{}
+	}
+	var rows []model.Setting
+	if err := db.Model(&model.Setting{}).Where("key IN ?", hostBoundSettingKeys).Find(&rows).Error; err != nil {
+		logger.Warningf("Import: could not read this machine's settings, they will come from the uploaded file: %v", err)
+		return hostBoundSnapshot{}
+	}
+	snap := hostBoundSnapshot{
+		values:  make(map[string]string, len(rows)),
+		present: make(map[string]struct{}, len(rows)),
+		taken:   true,
+	}
+	for _, row := range rows {
+		snap.values[row.Key] = row.Value
+		snap.present[row.Key] = struct{}{}
+	}
+	return snap
+}
+
+func restoreHostBoundSettings(snap hostBoundSnapshot) {
+	if !snap.taken {
+		return
+	}
+	db := database.GetDB()
+	if db == nil {
+		return
+	}
+	for _, key := range hostBoundSettingKeys {
+		if _, had := snap.present[key]; !had {
+			// No row here before the import, so the default applied. Drop the
+			// imported row rather than inherit the source machine's value.
+			if err := db.Where("key = ?", key).Delete(&model.Setting{}).Error; err != nil {
+				logger.Warningf("Import: could not drop imported setting %q: %v", key, err)
+			}
+			continue
+		}
+		// The imported row may or may not exist; settings are key-value, so an
+		// upsert keyed on the name is the only safe write here.
+		if err := db.Where(model.Setting{Key: key}).
+			Assign(model.Setting{Value: snap.values[key]}).
+			FirstOrCreate(&model.Setting{}).Error; err != nil {
+			logger.Warningf("Import: could not restore setting %q for this machine: %v", key, err)
+		}
+	}
+}
+
+func (s *ServerService) ImportDB(file multipart.File, keepHostSettings bool) error {
 	if database.IsPostgres() {
-		return s.importPostgresDB(file)
+		return s.importPostgresDB(file, keepHostSettings)
 	}
 	kind, err := sniffUploadKind(file)
 	if err != nil {
@@ -1488,6 +1561,11 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 		logger.Warningf("Failed to stop Xray before DB import: %v", errStop)
 	}
 
+	var keptSettings hostBoundSnapshot
+	if keepHostSettings {
+		keptSettings = captureHostBoundSettings()
+	}
+
 	if errClose := database.CloseDB(); errClose != nil {
 		logger.Warningf("Failed to close existing DB before replacement: %v", errClose)
 	}
@@ -1543,6 +1621,8 @@ func (s *ServerService) ImportDB(file multipart.File) error {
 	}
 	dbReopened = true
 
+	restoreHostBoundSettings(keptSettings)
+
 	s.inboundService.MigrateDB()
 
 	xrayStopped = false
@@ -1697,14 +1777,14 @@ func sniffUploadKind(file multipart.File) (int, error) {
 	return sniffImportKind(header[:n]), nil
 }
 
-func (s *ServerService) importPostgresDB(file multipart.File) error {
+func (s *ServerService) importPostgresDB(file multipart.File, keepHostSettings bool) error {
 	kind, err := sniffUploadKind(file)
 	if err != nil {
 		return common.NewErrorf("Error reading uploaded file: %v", err)
 	}
 	switch kind {
 	case importKindPgDump:
-		return s.restorePostgresDump(file)
+		return s.restorePostgresDump(file, keepHostSettings)
 	case importKindSQLiteDB:
 		return s.migrateSQLiteIntoPostgres(file, false)
 	case importKindSQLiteDump:
@@ -1714,7 +1794,7 @@ func (s *ServerService) importPostgresDB(file multipart.File) error {
 	}
 }
 
-func (s *ServerService) restorePostgresDump(file multipart.File) error {
+func (s *ServerService) restorePostgresDump(file multipart.File, keepHostSettings bool) error {
 	bin, err := exec.LookPath("pg_restore")
 	if err != nil {
 		return common.NewError("pg_restore not found on the server; install the postgresql-client package to restore a PostgreSQL database")
@@ -1754,6 +1834,11 @@ func (s *ServerService) restorePostgresDump(file multipart.File) error {
 		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)
 	}
@@ -1770,6 +1855,8 @@ func (s *ServerService) restorePostgresDump(file multipart.File) error {
 	if errInit := database.InitDB(config.GetDBPath()); errInit != nil {
 		return common.NewErrorf("Restore finished but reopening the database failed: %v", errInit)
 	}
+	restoreHostBoundSettings(keptSettings)
+
 	s.inboundService.MigrateDB()
 
 	if runErr != nil {

+ 3 - 1
internal/web/translation/ar-EG.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "إجمالي المرسل/المستقبل",

+ 3 - 1
internal/web/translation/en-US.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Sent/Received",

+ 3 - 1
internal/web/translation/es-ES.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Subidas/Descargas Totales",

+ 3 - 1
internal/web/translation/fa-IR.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "دریافت/ارسال کل",

+ 3 - 1
internal/web/translation/id-ID.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Terkirim/Diterima",

+ 3 - 1
internal/web/translation/ja-JP.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "総アップロード / ダウンロード",

+ 3 - 1
internal/web/translation/pt-BR.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Total Enviado/Recebido",

+ 3 - 1
internal/web/translation/ru-RU.json

@@ -266,7 +266,9 @@
       "logLevelError": "Ошибка",
       "accessDirect": "НАПРЯМУЮ",
       "accessBlocked": "ЗАБЛОКИРОВАНО",
-      "accessProxy": "ЧЕРЕЗ ПРОКСИ"
+      "accessProxy": "ЧЕРЕЗ ПРОКСИ",
+      "importKeepHostSettings": "Сохранить настройки этой машины",
+      "importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
     },
     "inbounds": {
       "totalDownUp": "Отправлено/получено",

+ 3 - 1
internal/web/translation/tr-TR.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Toplam Gönderilen/Alınan",

+ 3 - 1
internal/web/translation/uk-UA.json

@@ -266,7 +266,9 @@
       "logLevelError": "Помилка",
       "accessDirect": "НАПРЯМУ",
       "accessBlocked": "ЗАБЛОКОВАНО",
-      "accessProxy": "ЧЕРЕЗ ПРОКСІ"
+      "accessProxy": "ЧЕРЕЗ ПРОКСІ",
+      "importKeepHostSettings": "Зберегти налаштування цієї машини",
+      "importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
     },
     "inbounds": {
       "totalDownUp": "Всього надісланих/отриманих",

+ 3 - 1
internal/web/translation/vi-VN.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "Tổng tải lên/tải xuống",

+ 3 - 1
internal/web/translation/zh-CN.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "总上传 / 下载",

+ 3 - 1
internal/web/translation/zh-TW.json

@@ -266,7 +266,9 @@
       "logLevelError": "Error",
       "accessDirect": "DIRECT",
       "accessBlocked": "BLOCKED",
-      "accessProxy": "PROXY"
+      "accessProxy": "PROXY",
+      "importKeepHostSettings": "Keep this machine's settings",
+      "importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
     },
     "inbounds": {
       "totalDownUp": "總上傳 / 下載",