From 2b9adaa6ffb9b23151eb0a479f903dc8a472712b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:51:31 +0100 Subject: [PATCH] fix(clients): set a client's Telegram user on every inbound it is on SetClientTelegramUserID patched tgId in the settings of only the inbound the client's traffic row points at. Sibling inbounds kept the old value, and the next sync of any of them wrote it back into the client record, so removing a Telegram user from a multi-inbound client silently re-bound that user to it. The write now goes through applyClientFieldByEmail, as the other by-email setters do, so every attached inbound, on the master or a node, receives the new tgId. --- .../web/service/client_apply_field_test.go | 73 +++++++++++++++++++ internal/web/service/client_inbound_apply.go | 47 ++---------- 2 files changed, 78 insertions(+), 42 deletions(-) diff --git a/internal/web/service/client_apply_field_test.go b/internal/web/service/client_apply_field_test.go index fd68e71a6..e4857115e 100644 --- a/internal/web/service/client_apply_field_test.go +++ b/internal/web/service/client_apply_field_test.go @@ -144,3 +144,76 @@ func TestSetClientEnableByEmail_MultiInbound(t *testing.T) { } } } + +// Regression test: removing a client's Telegram user cleared tgId on one inbound +// only, so the next sync of the sibling bound the removed account again. +func TestSetClientTelegramUserID_MultiInbound(t *testing.T) { + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db")) + + db := database.GetDB() + + const email = "multitg@example.com" + const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c333" + clientJSON := `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"subId":"sub-tg-1","tgId":4242}]}` + + first := &model.Inbound{ + Tag: "vless-tg-a", Enable: true, Port: 50021, Protocol: model.VLESS, + StreamSettings: `{"network":"tcp","security":"reality"}`, Settings: clientJSON, + } + second := &model.Inbound{ + Tag: "vless-tg-b", Enable: true, Port: 50022, Protocol: model.VLESS, + StreamSettings: `{"network":"ws","security":"tls"}`, Settings: clientJSON, + } + 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) + } + } + traffic := &xray.ClientTraffic{InboundId: first.Id, Email: email, Enable: true} + if err := db.Create(traffic).Error; err != nil { + t.Fatalf("seed traffic: %v", err) + } + + if _, err := clientSvc.SetClientTelegramUserID(&inboundSvc, traffic.Id, 0); err != nil { + t.Fatalf("SetClientTelegramUserID: %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].TgID != 0 { + t.Errorf("inbound %s still binds the removed Telegram user: %+v", ib.Tag, clients) + } + if err := clientSvc.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound(%s): %v", ib.Tag, err) + } + } + + rec, err := clientSvc.GetRecordByEmail(nil, email) + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.TgID != 0 { + t.Errorf("client record tgId = %d after the siblings were synced, want 0", rec.TgID) + } +} diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index 9da44d104..cc563cdf2 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -1339,48 +1339,11 @@ func (s *ClientService) SetClientTelegramUserID(inboundSvc *InboundService, traf return false, common.NewError("Inbound Not Found For Traffic ID:", trafficId) } - clientEmail := traffic.Email - - 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 !found { - return false, 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 { - c["tgId"] = tgId - 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) - needRestart, err := s.UpdateInboundClient(inboundSvc, inbound, clientEmail) - return needRestart, err + // On every attached inbound: a sibling left holding the old tgId hands it + // back to the client record on its next SyncInbound (#5039). + return s.applyClientFieldByEmail(inboundSvc, traffic.Email, func(c map[string]any) { + c["tgId"] = tgId + }) } func (s *ClientService) CheckIsEnabledByEmail(inboundSvc *InboundService, clientEmail string) (bool, error) {