mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-07-04 11:54:24 +00:00
fix(xray): reconcile client auto-disable through the API instead of a forced restart
When a client expired or hit its traffic limit, XrayTrafficJob called RestartXray(true), stopping the whole process and dropping every live connection on every inbound (#5712 reported this as XHTTP on 443 dying) — even though disableInvalidClients had already removed the user from the running core over gRPC. The force restart existed only to re-sync the process's config snapshot. Switch the job to a non-forced restart and teach ComputeHotDiff to express a client-only inbound change as per-user AlterInbound operations for vless/vmess/trojan, so the reconcile is a no-op RemoveUser plus a snapshot update rather than a handler swap that would still blip that inbound's listener. Anything beyond the clients list still falls back to handler replacement or a full restart as before. Closes #5712
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user