From 1a525b4cb43f5983b40d188267d5a59f60344612 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 12 Jun 2026 01:22:15 +0200 Subject: [PATCH] fix(client): apply per-field client edits to every inbound of the email (#5039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyClientFieldByEmail patched only the first inbound that the client_traffics row pointed at. For a multi-inbound client the sibling inbounds kept the old expiryTime/totalGB/limitIp in their settings JSON, and the next SyncInbound over a stale sibling reverted the edit in the normalized records — the Telegram bot's expiry change appeared to apply and then sprang back. Patch the field on every inbound linked to the email, falling back to the legacy single-inbound lookup for clients that were never normalized. --- .../web/service/client_apply_field_test.go | 84 +++++++++++++++ internal/web/service/client_inbound_apply.go | 102 +++++++++++------- 2 files changed, 147 insertions(+), 39 deletions(-) create mode 100644 internal/web/service/client_apply_field_test.go diff --git a/internal/web/service/client_apply_field_test.go b/internal/web/service/client_apply_field_test.go new file mode 100644 index 000000000..b8368c075 --- /dev/null +++ b/internal/web/service/client_apply_field_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "encoding/json" + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// TestResetClientExpiryTimeByEmail_MultiInbound reproduces #5039: a client +// attached to several inbounds had its expiry patched only on the first +// inbound's JSON, so the stale siblings reverted the change on the next sync. +func TestResetClientExpiryTimeByEmail_MultiInbound(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + + db := database.GetDB() + + const email = "multi@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c111" + const oldExpiry = int64(1700000000000) + const newExpiry = int64(1800000000000) + + clientJSON := func(expiry int64) string { + b, _ := json.Marshal(map[string]any{"clients": []map[string]any{{ + "email": email, "id": uid, "enable": true, "expiryTime": expiry, "subId": "sub-multi-1", + }}}) + return string(b) + } + + first := &model.Inbound{Tag: "vless-a", Enable: true, Port: 50001, Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON(oldExpiry)} + second := &model.Inbound{Tag: "vless-b", Enable: true, Port: 50002, Protocol: model.VLESS, + StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON(oldExpiry)} + for _, ib := range []*model.Inbound{first, second} { + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound %s: %v", ib.Tag, err) + } + } + + clientSvc := ClientService{} + inboundSvc := InboundService{} + for _, ib := range []*model.Inbound{first, second} { + clients, err := inboundSvc.GetClients(ib) + if err != nil { + t.Fatalf("GetClients(%s): %v", ib.Tag, err) + } + if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + if _, err := clientSvc.ResetClientExpiryTimeByEmail(&inboundSvc, email, newExpiry); err != nil { + t.Fatalf("ResetClientExpiryTimeByEmail: %v", err) + } + + for _, ib := range []*model.Inbound{first, second} { + fresh, err := inboundSvc.GetInbound(ib.Id) + if err != nil { + t.Fatalf("GetInbound(%s): %v", ib.Tag, err) + } + clients, err := inboundSvc.GetClients(fresh) + if err != nil { + t.Fatalf("GetClients(%s): %v", ib.Tag, err) + } + if len(clients) != 1 || clients[0].ExpiryTime != newExpiry { + t.Errorf("inbound %s settings expiry = %d, want %d (#5039)", ib.Tag, clients[0].ExpiryTime, newExpiry) + } + } + + rec, err := clientSvc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.ExpiryTime != newExpiry { + t.Errorf("client record expiry = %d, want %d", rec.ExpiryTime, newExpiry) + } +} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 6f79ae343..3d6c30eb1 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -949,54 +949,78 @@ func (s *ClientService) SetClientEnableByEmail(inboundSvc *InboundService, clien // the matched client — that is the input contract UpdateInboundClient expects // (clients[0] is the new data; clientEmail locates the row to replace). It // backs the single-field by-email setters below. +// applyClientFieldByEmail mutates a client field on every inbound the email is +// attached to. A multi-inbound client is one logical identity: patching only +// the first inbound's JSON would leave the siblings stale, and the next +// SyncInbound over a stale sibling would revert the edit in the normalized +// records (#5039). func (s *ClientService) applyClientFieldByEmail(inboundSvc *InboundService, clientEmail string, mutate func(c map[string]any)) (bool, error) { - _, inbound, err := inboundSvc.GetClientInboundByEmail(clientEmail) + inboundIds, err := s.GetInboundIdsForEmail(database.GetDB(), clientEmail) if err != nil { return false, err } - if inbound == nil { - return false, common.NewError("Inbound Not Found For Email:", clientEmail) - } - - oldClients, err := inboundSvc.GetClients(inbound) - if err != nil { - return false, err - } - - found := false - for _, oldClient := range oldClients { - if oldClient.Email == clientEmail { - found = true - break + if len(inboundIds) == 0 { + // Legacy fallback for clients that only live in the inbound JSON and + // were never normalized into client_inbounds. + _, inbound, gErr := inboundSvc.GetClientInboundByEmail(clientEmail) + if gErr != nil { + return false, gErr } + if inbound == nil { + return false, common.NewError("Inbound Not Found For Email:", clientEmail) + } + inboundIds = []int{inbound.Id} + } + + needRestart := false + found := false + for _, ibId := range inboundIds { + inbound, gErr := inboundSvc.GetInbound(ibId) + if gErr != nil { + return needRestart, gErr + } + + var settings map[string]any + if uErr := json.Unmarshal([]byte(inbound.Settings), &settings); uErr != nil { + return needRestart, uErr + } + clients, _ := settings["clients"].([]any) + // UpdateInboundClient expects a single-client payload, so keep only the + // matching entry in the scratch copy; it splices the result back into + // the inbound's full client list itself. + var newClients []any + for client_index := range clients { + c, ok := clients[client_index].(map[string]any) + if !ok { + continue + } + if c["email"] == clientEmail { + mutate(c) + c["updated_at"] = time.Now().Unix() * 1000 + newClients = append(newClients, any(c)) + } + } + if len(newClients) == 0 { + continue + } + found = true + settings["clients"] = newClients + modifiedSettings, mErr := json.MarshalIndent(settings, "", " ") + if mErr != nil { + return needRestart, mErr + } + inbound.Settings = string(modifiedSettings) + nr, uErr := s.UpdateInboundClient(inboundSvc, inbound, clientEmail) + if uErr != nil { + return needRestart, uErr + } + needRestart = needRestart || nr } if !found { - return false, common.NewError("Client Not Found For Email:", clientEmail) + return needRestart, common.NewError("Client Not Found For Email:", clientEmail) } - - var settings map[string]any - err = json.Unmarshal([]byte(inbound.Settings), &settings) - if err != nil { - return false, err - } - clients := settings["clients"].([]any) - var newClients []any - for client_index := range clients { - c := clients[client_index].(map[string]any) - if c["email"] == clientEmail { - mutate(c) - c["updated_at"] = time.Now().Unix() * 1000 - newClients = append(newClients, any(c)) - } - } - settings["clients"] = newClients - modifiedSettings, err := json.MarshalIndent(settings, "", " ") - if err != nil { - return false, err - } - inbound.Settings = string(modifiedSettings) - return s.UpdateInboundClient(inboundSvc, inbound, clientEmail) + return needRestart, nil } func (s *ClientService) ResetClientIpLimitByEmail(inboundSvc *InboundService, clientEmail string, count int) (bool, error) {