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