fix(web): restart panel after ImportDB so subPath routes match (#6446) (#6456)

* fix(web): restart panel after ImportDB so subPath routes match (#6446)

ImportDB only restarted Xray, leaving the subscription HTTP server on
startup-registered paths. Schedule the same in-process restart hook used
by restartPanel so restored subPath (and related) routes take effect
without relying on a browser follow-up that can fail after session invalidation.

* fix(web): schedule the post-import panel restart once, via PanelService

ImportDB grew a private copy of PanelService.RestartPanel (same hook check,
same Windows bail-out, same SIGHUP fallback, already diverging in log
severity) while BackupModal kept POSTing restartPanel after a successful
import, so one restore bounced the panel and the public sub server twice
back to back. The service package cannot reuse PanelService (panel imports
service), so the importDB controller now calls the existing
RestartPanel(3s) after ImportDB succeeds, the duplicated helper is dropped,
and the browser follow-up is removed; it waits out the restart and reloads.

Test drives the importDB handler against a stub xray binary and fails when
no restart is scheduled through the global restart hook.

---------

Co-authored-by: mrchatam <mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-13 23:09:15 +03:30
committed by GitHub
parent d0ad773edf
commit 435ed976c0
3 changed files with 97 additions and 7 deletions

View File

@@ -59,14 +59,10 @@ export default function BackupModal({
return;
}
// importDB schedules the panel restart server-side; wait it out, then reload.
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}` });
const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
if (restart?.success) {
await PromiseUtil.sleep(5000);
window.location.reload();
} else {
onBusy({ busy: false });
}
await PromiseUtil.sleep(5000);
window.location.reload();
});
fileInput.click();
}

View File

@@ -0,0 +1,91 @@
package controller
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/web/global"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/gin-gonic/gin"
)
// A successful import must schedule the panel restart itself: the browser's
// restartPanel follow-up can 401 once the imported users table lands (#6446).
func TestImportDBSchedulesPanelRestart(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("the stub xray binary is a shell script")
}
uploadPath := filepath.Join(t.TempDir(), "x-ui.db")
if err := database.InitDB(uploadPath); err != nil {
t.Fatalf("InitDB(upload): %v", err)
}
if err := database.CloseDB(); err != nil {
t.Fatalf("CloseDB(upload): %v", err)
}
upload, err := os.ReadFile(uploadPath)
if err != nil {
t.Fatalf("read upload: %v", err)
}
newHostTestDB(t)
binDir := t.TempDir()
t.Setenv("XUI_BIN_FOLDER", binDir)
t.Setenv("XUI_LOG_FOLDER", t.TempDir())
if err := os.WriteFile(filepath.Join(binDir, xray.GetBinaryName()), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write stub xray: %v", err)
}
restarts := make(chan struct{}, 1)
global.SetRestartHook(func() {
select {
case restarts <- struct{}{}:
default:
}
})
t.Cleanup(func() { global.SetRestartHook(func() {}) })
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, err := mw.CreateFormFile("db", "x-ui.db")
if err != nil {
t.Fatalf("CreateFormFile: %v", err)
}
if _, err := part.Write(upload); err != nil {
t.Fatalf("write part: %v", err)
}
if err := mw.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
a := &ServerController{}
engine := gin.New()
engine.POST("/panel/api/server/importDB", a.importDB)
req := httptest.NewRequest(http.MethodPost, "/panel/api/server/importDB", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
w := httptest.NewRecorder()
engine.ServeHTTP(w, req)
var env hostEnvelope
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
t.Fatalf("decode envelope: %v body=%s", err, w.Body.String())
}
if !env.Success {
t.Fatalf("importDB failed: %s", env.Msg)
}
select {
case <-restarts:
case <-time.After(6 * time.Second):
t.Fatal("importDB succeeded but no panel restart was scheduled within 6s")
}
}

View File

@@ -391,6 +391,9 @@ func (a *ServerController) importDB(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.importDatabaseError"), err)
return
}
// Startup-registered routes (subPath) must match the restored DB, and the
// browser's restartPanel follow-up can 401 once the imported users land (#6446).
_ = a.panelService.RestartPanel(3 * time.Second)
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
}