diff --git a/internal/web/job/xray_traffic_job.go b/internal/web/job/xray_traffic_job.go index 9a89bd86d..7f302bcaf 100644 --- a/internal/web/job/xray_traffic_job.go +++ b/internal/web/job/xray_traffic_job.go @@ -50,8 +50,8 @@ func (j *XrayTrafficJob) Run() { logger.Warning("get RestartXrayOnClientDisable failed:", settingErr) } if restartOnDisable { - if err := j.xrayService.RestartXray(true); err != nil { - logger.Warning("restart xray after disabling clients failed:", err) + if err := j.xrayService.RestartXray(false); err != nil { + logger.Warning("reconcile xray after disabling clients failed:", err) j.xrayService.SetToNeedRestart() } } diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 7e027a08d..4b84e4c34 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -1004,6 +1004,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool { // Removals first so changed handlers and port swaps never collide with // the additions that follow. + for _, u := range diff.RemovedUsers { + if err := hotAPI.RemoveUser(u.Tag, u.Email); err != nil && !xray.IsMissingHandlerErr(err) { + logger.Info("hot apply: remove user [", u.Email, "] from [", u.Tag, "] failed:", err) + return false + } + } for _, tag := range diff.RemovedInboundTags { if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) { logger.Info("hot apply: remove inbound [", tag, "] failed:", err) @@ -1028,6 +1034,12 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool { return false } } + for _, u := range diff.AddedUsers { + if err := addUserReconciling(&hotAPI, u); err != nil { + logger.Info("hot apply: add user [", u.Email, "] to [", u.Tag, "] failed:", err) + return false + } + } if diff.RoutingConfig != nil { if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil { logger.Info("hot apply: apply routing config failed:", err) @@ -1039,6 +1051,19 @@ func (s *XrayService) tryHotApply(newCfg *xray.Config) bool { return true } +// 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 { + err := api.AddUser(u.Protocol, u.Tag, u.User) + if err == nil || !xray.IsUserExistsErr(err) { + return err + } + if delErr := api.RemoveUser(u.Tag, u.Email); delErr != nil && !xray.IsMissingHandlerErr(delErr) { + return delErr + } + return api.AddUser(u.Protocol, u.Tag, u.User) +} + // addInboundReconciling adds an inbound, and on a tag conflict (the handler // was already created through the runtime API while the stored snapshot was // stale) replaces the existing handler instead. diff --git a/internal/xray/api.go b/internal/xray/api.go index 6bfa35645..d3f95e1f4 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -397,6 +397,15 @@ func IsExistingTagErr(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "existing tag") } +// IsUserExistsErr reports whether err is xray's response to adding a user whose +// email is already registered on the inbound. +func IsUserExistsErr(err error) bool { + if err == nil { + return false + } + return strings.Contains(strings.ToLower(err.Error()), "already exists") +} + // ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core // config builders run inside the panel process. The xray binary resolves assets // relative to its own executable, but the panel binary lives one level above diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index 21a4ed616..50458dc2e 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -15,15 +15,27 @@ import ( type HotDiff struct { RemovedInboundTags []string AddedInbounds [][]byte + RemovedUsers []UserOp + AddedUsers []UserOp RemovedOutboundTags []string AddedOutbounds [][]byte RoutingConfig []byte // full new routing section; nil when unchanged } +// UserOp is a per-user AlterInbound operation; User is nil for removals. +type UserOp struct { + Tag string + Protocol string + Email string + User map[string]any +} + // Empty reports whether the diff contains no operations. func (d *HotDiff) Empty() bool { return len(d.RemovedInboundTags) == 0 && len(d.AddedInbounds) == 0 && + len(d.RemovedUsers) == 0 && + len(d.AddedUsers) == 0 && len(d.RemovedOutboundTags) == 0 && len(d.AddedOutbounds) == 0 && d.RoutingConfig == nil @@ -112,6 +124,9 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool { logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap") return false } + if exists && diffInboundUsers(oldIb, newIb, diff) { + continue + } diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag) if exists { raw, err := json.Marshal(newIb) @@ -138,6 +153,99 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool { return true } +var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}} + +// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds +// differ only in settings.clients, so the handler (and its listener) survives. +func diffInboundUsers(oldIb, newIb *InboundConfig, diff *HotDiff) bool { + if oldIb.Port != newIb.Port || oldIb.Protocol != newIb.Protocol || oldIb.Tag != newIb.Tag { + return false + } + if _, ok := userDiffableProtocols[oldIb.Protocol]; !ok { + return false + } + if !rawEqualNormalized(oldIb.Listen, newIb.Listen) || + !rawEqualNormalized(oldIb.StreamSettings, newIb.StreamSettings) || + !rawEqualNormalized(oldIb.Sniffing, newIb.Sniffing) { + return false + } + oldClients, oldRest, ok := splitSettingsClients(oldIb.Settings) + if !ok { + return false + } + newClients, newRest, ok := splitSettingsClients(newIb.Settings) + if !ok { + return false + } + if !bytes.Equal(oldRest, newRest) { + return false + } + for email, oldC := range oldClients { + newC, exists := newClients[email] + if exists && bytes.Equal(oldC.norm, newC.norm) { + continue + } + diff.RemovedUsers = append(diff.RemovedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email}) + if exists { + diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user}) + } + } + for email, newC := range newClients { + if _, exists := oldClients[email]; !exists { + diff.AddedUsers = append(diff.AddedUsers, UserOp{Tag: oldIb.Tag, Protocol: oldIb.Protocol, Email: email, User: newC.user}) + } + } + return true +} + +type clientEntry struct { + user map[string]any + norm []byte +} + +// splitSettingsClients indexes settings.clients by email and returns the rest of +// the settings in canonical form; ok is false when a client has no unique email. +func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []byte, bool) { + if len(raw) == 0 { + return nil, nil, false + } + settings := map[string]any{} + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&settings); err != nil { + return nil, nil, false + } + clientsRaw, hasClients := settings["clients"].([]any) + if !hasClients { + return nil, nil, false + } + clients := make(map[string]clientEntry, len(clientsRaw)) + for _, c := range clientsRaw { + obj, ok := c.(map[string]any) + if !ok { + return nil, nil, false + } + email, _ := obj["email"].(string) + if email == "" { + return nil, nil, false + } + if _, dup := clients[email]; dup { + return nil, nil, false + } + norm, err := json.Marshal(obj) + if err != nil { + return nil, nil, false + } + clients[email] = clientEntry{user: obj, norm: norm} + } + delete(settings, "clients") + rest, err := json.Marshal(settings) + if err != nil { + return nil, nil, false + } + return clients, rest, true +} + func inboundHasReverseClient(ib *InboundConfig) bool { if ib == nil { return false diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 430abf3d3..a70db3f87 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -148,8 +148,8 @@ func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) { func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) { oldCfg := makeHotConfig() newCfg := makeHotConfig() - // change existing - newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`) + // change existing beyond the clients list, so no user-level shortcut applies + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[],"decryption":"none"}`) // add new newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{ Port: 2080, Protocol: "vmess", Tag: "inbound-2080", @@ -171,6 +171,80 @@ func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) { } } +func TestComputeHotDiff_ClientOnlyChangeUsesUserOps(t *testing.T) { + oldCfg := makeHotConfig() + oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`) + newCfg := makeHotConfig() + // b expired and is stripped from the generated config (#5712); a's id rotated. + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a2"},{"email":"c","id":"uuid-c"}],"decryption":"none"}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("client-only change must be hot-appliable") + } + if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 { + t.Fatalf("client-only change must not replace the handler, got %+v", diff) + } + removed := map[string]bool{} + for _, u := range diff.RemovedUsers { + if u.Tag != "inbound-1080" || u.Protocol != "vless" { + t.Fatalf("removed user op has wrong target: %+v", u) + } + removed[u.Email] = true + } + if len(removed) != 2 || !removed["a"] || !removed["b"] { + t.Fatalf("expected users a (changed) and b (gone) removed, got %v", removed) + } + added := map[string]string{} + for _, u := range diff.AddedUsers { + id, _ := u.User["id"].(string) + added[u.Email] = id + } + if len(added) != 2 || added["a"] != "uuid-a2" || added["c"] != "uuid-c" { + t.Fatalf("expected users a (new id) and c added, got %v", added) + } +} + +func TestComputeHotDiff_ClientChangeFallsBackToReplace(t *testing.T) { + cases := []struct { + name string + mutate func(cfg *Config) + }{ + { + name: "unsupported protocol", + mutate: func(cfg *Config) { + cfg.InboundConfigs[1].Protocol = "shadowsocks" + }, + }, + { + name: "client without email", + mutate: func(cfg *Config) { + cfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"id":"uuid-a"}]}`) + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + oldCfg := makeHotConfig() + newCfg := makeHotConfig() + tc.mutate(oldCfg) + tc.mutate(newCfg) + newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"x","id":"uuid-x","password":"pw"}]}`) + + diff, ok := ComputeHotDiff(oldCfg, newCfg) + if !ok { + t.Fatal("change must still be hot-appliable via handler replacement") + } + if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 0 { + t.Fatalf("expected no user ops, got %+v", diff) + } + if len(diff.RemovedInboundTags) != 1 || len(diff.AddedInbounds) != 1 { + t.Fatalf("expected handler replacement, got %+v", diff) + } + }) + } +} + func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) { newCfg := makeHotConfig() newCfg.InboundConfigs[0].Port = 62790