diff --git a/frontend/src/pages/index/BackupModal.tsx b/frontend/src/pages/index/BackupModal.tsx
index 8c31fc0a4..bb9759250 100644
--- a/frontend/src/pages/index/BackupModal.tsx
+++ b/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
} />
+
+
+
+
setKeepHostSettings(e.target.checked)}>
+ {t('pages.index.importKeepHostSettings')}
+
+
{t('pages.index.importKeepHostSettingsDesc')}
+
+
);
diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go
index bd1078d60..c9cd0d3e4 100644
--- a/internal/web/controller/server.go
+++ b/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
}
diff --git a/internal/web/service/import_host_settings_test.go b/internal/web/service/import_host_settings_test.go
new file mode 100644
index 000000000..9228e508c
--- /dev/null
+++ b/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)
+ }
+ }
+}
diff --git a/internal/web/service/server.go b/internal/web/service/server.go
index 2d3acfde8..41e1479d6 100644
--- a/internal/web/service/server.go
+++ b/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 {
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index b4f1e815c..127b632e0 100644
--- a/internal/web/translation/ar-EG.json
+++ b/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": "إجمالي المرسل/المستقبل",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 70c81c06b..26f3eebef 100644
--- a/internal/web/translation/en-US.json
+++ b/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",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index 503a74abd..23539afe9 100644
--- a/internal/web/translation/es-ES.json
+++ b/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",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index c003cb1ce..e344850a6 100644
--- a/internal/web/translation/fa-IR.json
+++ b/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": "دریافت/ارسال کل",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index 3aad74345..bb88c5b2f 100644
--- a/internal/web/translation/id-ID.json
+++ b/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",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index c308f6f32..4cfa1bbb0 100644
--- a/internal/web/translation/ja-JP.json
+++ b/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": "総アップロード / ダウンロード",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 0f1b5bb3c..114bd6bbf 100644
--- a/internal/web/translation/pt-BR.json
+++ b/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",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index b51f0eb0a..6eb1bba97 100644
--- a/internal/web/translation/ru-RU.json
+++ b/internal/web/translation/ru-RU.json
@@ -266,7 +266,9 @@
"logLevelError": "Ошибка",
"accessDirect": "НАПРЯМУЮ",
"accessBlocked": "ЗАБЛОКИРОВАНО",
- "accessProxy": "ЧЕРЕЗ ПРОКСИ"
+ "accessProxy": "ЧЕРЕЗ ПРОКСИ",
+ "importKeepHostSettings": "Сохранить настройки этой машины",
+ "importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
},
"inbounds": {
"totalDownUp": "Отправлено/получено",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index f5fd4428d..9978cb729 100644
--- a/internal/web/translation/tr-TR.json
+++ b/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",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index 6b661baf5..e290e9402 100644
--- a/internal/web/translation/uk-UA.json
+++ b/internal/web/translation/uk-UA.json
@@ -266,7 +266,9 @@
"logLevelError": "Помилка",
"accessDirect": "НАПРЯМУ",
"accessBlocked": "ЗАБЛОКОВАНО",
- "accessProxy": "ЧЕРЕЗ ПРОКСІ"
+ "accessProxy": "ЧЕРЕЗ ПРОКСІ",
+ "importKeepHostSettings": "Зберегти налаштування цієї машини",
+ "importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
},
"inbounds": {
"totalDownUp": "Всього надісланих/отриманих",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 644903726..a1b9bd6be 100644
--- a/internal/web/translation/vi-VN.json
+++ b/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",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 59bb3244c..a14727f15 100644
--- a/internal/web/translation/zh-CN.json
+++ b/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": "总上传 / 下载",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index b08316d2a..4c6341272 100644
--- a/internal/web/translation/zh-TW.json
+++ b/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": "總上傳 / 下載",