diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index e27d60a6b..a26cb6bc1 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -1454,6 +1454,7 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo } if diff.Empty() { process.SetConfig(newCfg) + persistHotConfig(process) return true } // The core's RemoveUser drops the credential only, so a disabled or deleted @@ -1522,9 +1523,18 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo } process.SetConfig(newCfg) + persistHotConfig(process) return true } +// persistHotConfig refreshes config.json after a hot apply; a write failure is +// logged only, since the running core already has the change. +func persistHotConfig(process *xray.Process) { + if err := process.PersistConfig(); err != nil { + logger.Warning("hot apply: failed to update config.json:", err) + } +} + // addUserReconciling adds a user, and on an email conflict (the user was // already applied through the runtime API) replaces the existing user instead. func addUserReconciling(api *xray.XrayAPI, u xray.UserOp) error { diff --git a/internal/web/service/xray_hot_apply_persist_test.go b/internal/web/service/xray_hot_apply_persist_test.go new file mode 100644 index 000000000..c0e64e2e3 --- /dev/null +++ b/internal/web/service/xray_hot_apply_persist_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "os" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// The empty-diff branch of tryHotApply makes no gRPC call, so it can prove the +// hot-apply path refreshes config.json without a running core. +func TestTryHotApplyWritesConfigFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + routing := json_util.RawMessage(`{"rules":[]}`) + process := xray.NewTestProcess(&xray.Config{RouterConfig: routing}, path) + + if !(&XrayService{}).tryHotApply(process, &xray.Config{RouterConfig: routing}) { + t.Fatal("tryHotApply = false, want true for an unchanged config") + } + if data, err := os.ReadFile(path); err != nil || len(data) == 0 { + t.Fatalf("config.json not written after hot apply (err %v)", err) + } +} diff --git a/internal/xray/process.go b/internal/xray/process.go index ab14f30c8..a7f0691e3 100644 --- a/internal/xray/process.go +++ b/internal/xray/process.go @@ -316,6 +316,22 @@ func (p *Process) SetConfig(config *Config) { p.config = config } +// PersistConfig writes the current configuration snapshot to the config file, +// keeping it in step after a hot apply (Start only writes it on a cold start). +func (p *Process) PersistConfig() error { + p.mu.RLock() + data, err := json.MarshalIndent(p.config, "", " ") + path := p.configPath + p.mu.RUnlock() + if err != nil { + return common.NewErrorf("Failed to generate XRAY configuration files: %v", err) + } + if path == "" { + path = GetConfigPath() + } + return writeFileAtomic(path, data, 0o600) +} + // GetOnlineClients returns the union of locally-online clients and // node-online clients from every registered remote panel. Dedupes by // email so a client connected to both a local and a node-managed inbound diff --git a/internal/xray/process_persist_test.go b/internal/xray/process_persist_test.go new file mode 100644 index 000000000..adbdd178f --- /dev/null +++ b/internal/xray/process_persist_test.go @@ -0,0 +1,27 @@ +package xray + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" +) + +func TestPersistConfigWritesCurrentSnapshot(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + p := NewTestProcess(&Config{RouterConfig: json_util.RawMessage(`{"rules":[]}`)}, path) + + p.SetConfig(&Config{RouterConfig: json_util.RawMessage(`{"rules":[{"outboundTag":"warp"}]}`)}) + if err := p.PersistConfig(); err != nil { + t.Fatalf("PersistConfig: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"outboundTag": "warp"`) && !strings.Contains(string(data), `"outboundTag":"warp"`) { + t.Fatalf("config file does not hold the new routing:\n%s", data) + } +}