From 3b731cd657d343e61469d9c9dc60a3267244b680 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sat, 11 Jul 2026 21:05:58 +0200 Subject: [PATCH] fix(clients): reuse stored credentials when re-adding an existing identity Create permits a repeat add for an email that already exists when the payload subId matches the stored one (the documented way to attach an identity to more inbounds), but it never seeded the payload from the existing record, so an omitted id minted a fresh UUID via fillProtocolDefaults. SyncInbound then overwrote the shared clients.uuid row by email while previously-attached inbounds kept the original UUID in their settings JSON, silently desyncing panel credentials from subscription links. BulkCreate had the identical gap. Seed ID/Password/Auth/Secret from the existing record in both paths (mirroring what Update, Attach and BulkAttach already do), and preserve Secret in Update too so partial edits of MTProto clients cannot rotate the stored secret. Closes #5903 --- internal/web/service/client_bulk.go | 26 ++++++--- .../web/service/client_create_reuse_test.go | 54 +++++++++++++++++++ internal/web/service/client_crud.go | 17 ++++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 internal/web/service/client_create_reuse_test.go diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index e3305c8d2..81daa1346 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -1208,7 +1208,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client db := database.GetDB() const lookupChunk = 400 - existingEmailSub := make(map[string]string, len(emails)) + existingByEmail := make(map[string]model.ClientRecord, len(emails)) for start := 0; start < len(emails); start += lookupChunk { end := min(start+lookupChunk, len(emails)) var rows []model.ClientRecord @@ -1216,7 +1216,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client return result, false, e } for i := range rows { - existingEmailSub[strings.ToLower(rows[i].Email)] = rows[i].SubID + existingByEmail[strings.ToLower(rows[i].Email)] = rows[i] } } existingSubOwner := make(map[string]string, len(subIDs)) @@ -1252,10 +1252,24 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client for idx := range prep { le := strings.ToLower(prep[idx].client.Email) - if existSub, ok := existingEmailSub[le]; ok && existSub != prep[idx].client.SubID { - failed[idx] = true - reason[idx] = "email already in use: " + prep[idx].client.Email - continue + if rec, ok := existingByEmail[le]; ok { + if rec.SubID != prep[idx].client.SubID { + failed[idx] = true + reason[idx] = "email already in use: " + prep[idx].client.Email + continue + } + if prep[idx].client.ID == "" { + prep[idx].client.ID = rec.UUID + } + if prep[idx].client.Password == "" { + prep[idx].client.Password = rec.Password + } + if prep[idx].client.Auth == "" { + prep[idx].client.Auth = rec.Auth + } + if prep[idx].client.Secret == "" { + prep[idx].client.Secret = rec.Secret + } } if owner, ok := existingSubOwner[prep[idx].client.SubID]; ok && owner != le { failed[idx] = true diff --git a/internal/web/service/client_create_reuse_test.go b/internal/web/service/client_create_reuse_test.go new file mode 100644 index 000000000..c889961f5 --- /dev/null +++ b/internal/web/service/client_create_reuse_test.go @@ -0,0 +1,54 @@ +package service + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +func settingsHoldUUID(t *testing.T, inboundSvc *InboundService, inboundId int, uuid string) bool { + t.Helper() + ib, err := inboundSvc.GetInbound(inboundId) + if err != nil { + t.Fatalf("GetInbound %d: %v", inboundId, err) + } + return strings.Contains(ib.Settings, uuid) +} + +func TestCreateRepeatKeepsExistingUUID(t *testing.T) { + setupBulkDB(t) + svc := &ClientService{} + inboundSvc := &InboundService{} + + ibA := mkInbound(t, 21001, model.VLESS, `{"clients":[]}`) + ibB := mkInbound(t, 21002, model.VLESS, `{"clients":[]}`) + + const originalUUID = "aaaaaaaa-1111-2222-3333-444444444444" + if _, err := svc.Create(inboundSvc, &ClientCreatePayload{ + Client: model.Client{Email: "repeat@x", ID: originalUUID, SubID: "sub-repeat", Enable: true}, + InboundIds: []int{ibA.Id}, + }); err != nil { + t.Fatalf("first Create: %v", err) + } + if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID { + t.Fatalf("record UUID after first Create = %q, want %q", rec.UUID, originalUUID) + } + + if _, err := svc.Create(inboundSvc, &ClientCreatePayload{ + Client: model.Client{Email: "repeat@x", SubID: "sub-repeat", Enable: true}, + InboundIds: []int{ibB.Id}, + }); err != nil { + t.Fatalf("repeat Create: %v", err) + } + + if rec := lookupClientRecord(t, "repeat@x"); rec.UUID != originalUUID { + t.Fatalf("record UUID after repeat Create = %q, want %q", rec.UUID, originalUUID) + } + if !settingsHoldUUID(t, inboundSvc, ibA.Id, originalUUID) { + t.Fatalf("inbound A settings lost the original UUID") + } + if !settingsHoldUUID(t, inboundSvc, ibB.Id, originalUUID) { + t.Fatalf("inbound B settings did not reuse the original UUID") + } +} diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index dc36d9d2a..2bca49d7e 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -82,6 +82,20 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate if existing.SubID == "" || existing.SubID != client.SubID { return false, common.NewError("email already in use:", client.Email) } + // Reuse stored credentials when re-adding an existing identity, or + // fillProtocolDefaults mints a fresh UUID that desyncs other inbounds. + if client.ID == "" { + client.ID = existing.UUID + } + if client.Password == "" { + client.Password = existing.Password + } + if client.Auth == "" { + client.Auth = existing.Auth + } + if client.Secret == "" { + client.Secret = existing.Secret + } } if client.SubID != "" { @@ -345,6 +359,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if updated.Auth == "" { updated.Auth = existing.Auth } + if updated.Secret == "" { + updated.Secret = existing.Secret + } if updated.Email != existing.Email { var collisionCount int64