From 52d4af71bc50fa73649f54bda0e0044003ee3f2b Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 7 Jul 2026 12:39:00 +0200 Subject: [PATCH] fix(ldap): attach auto-created clients to every configured inbound tag The sync job built an independent client per configured tag and called CreateOne once per tag. Each call generated a fresh random subId, and the email-uniqueness check in ClientService.Create only re-admits a taken email when the incoming subId matches the stored one - so the first tag succeeded and every other tag failed with "email already in use", leaving new LDAP users on a single inbound. Build the client once per email and hand ClientService.Create the full list of resolved inbound ids, the same path the panel's own client create endpoint uses: one identity (email, subId) attached to all configured tags, with per-protocol credentials filled per inbound. Unknown tags are now skipped with a warning instead of building clients against a nil inbound. Closes #5846 --- internal/web/job/ldap_sync_job.go | 105 ++++++++++++++----------- internal/web/job/ldap_sync_job_test.go | 85 ++++++++++++++++++++ 2 files changed, 142 insertions(+), 48 deletions(-) create mode 100644 internal/web/job/ldap_sync_job_test.go diff --git a/internal/web/job/ldap_sync_job.go b/internal/web/job/ldap_sync_job.go index d1c5ed548..bc965b2e7 100644 --- a/internal/web/job/ldap_sync_job.go +++ b/internal/web/job/ldap_sync_job.go @@ -4,8 +4,6 @@ import ( "strings" "time" - "github.com/google/uuid" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap" @@ -114,52 +112,41 @@ func (j *LdapSyncJob) Run() { defExpiryDays := mustGetInt(j.settingService.GetLdapDefaultExpiryDays) defLimitIP := mustGetInt(j.settingService.GetLdapDefaultLimitIP) - clientsToCreate := map[string][]model.Client{} // tag -> []new clients - clientsToEnable := map[string][]string{} // tag -> []email - clientsToDisable := map[string][]string{} // tag -> []email - - for email, allowed := range flags { - exists := allClients[email] != nil - for _, tag := range inboundTags { - if !exists && allowed && autoCreate { - newClient := j.buildClient(inboundMap[tag], email, defGB, defExpiryDays, defLimitIP) - clientsToCreate[tag] = append(clientsToCreate[tag], newClient) - } else if exists { - if allowed && !allClients[email].Enable { - clientsToEnable[tag] = append(clientsToEnable[tag], email) - } else if !allowed && allClients[email].Enable { - clientsToDisable[tag] = append(clientsToDisable[tag], email) - } - } - } - } - - for tag, newClients := range clientsToCreate { - if len(newClients) == 0 { + resolvedInboundIds := make([]int, 0, len(inboundTags)) + resolvedTags := make([]string, 0, len(inboundTags)) + for _, tag := range inboundTags { + ib := inboundMap[tag] + if ib == nil { + logger.Warningf("LDAP inbound tag %s does not match any inbound", tag) continue } - ib := inboundMap[tag] - created := 0 - restartNeeded := false - for _, c := range newClients { - nr, err := j.clientService.CreateOne(&j.inboundService, ib.Id, c) - if err != nil { - logger.Warningf("Failed to add client %s for tag %s: %v", c.Email, tag, err) - continue - } - created++ - if nr { - restartNeeded = true + resolvedInboundIds = append(resolvedInboundIds, ib.Id) + resolvedTags = append(resolvedTags, tag) + } + + clientsToCreate := []model.Client{} + clientsToEnable := map[string][]string{} // tag -> []email + clientsToDisable := map[string][]string{} // tag -> []email + + for email, allowed := range flags { + existing := allClients[email] + if existing == nil { + if allowed && autoCreate { + clientsToCreate = append(clientsToCreate, j.buildClient(email, defGB, defExpiryDays, defLimitIP)) } + continue } - if created > 0 { - logger.Infof("LDAP auto-create: %d clients for %s", created, tag) - if restartNeeded { - j.xrayService.SetToNeedRestart() + for _, tag := range resolvedTags { + if allowed && !existing.Enable { + clientsToEnable[tag] = append(clientsToEnable[tag], email) + } else if !allowed && existing.Enable { + clientsToDisable[tag] = append(clientsToDisable[tag], email) } } } + j.createClients(clientsToCreate, resolvedInboundIds, resolvedTags) + // --- Execute enable/disable batch --- for tag, emails := range clientsToEnable { j.batchSetEnable(inboundMap[tag], emails, true) @@ -196,8 +183,8 @@ func splitCsv(s string) []string { return out } -// buildClient creates a new client for auto-create -func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExpiryDays, defLimitIP int) model.Client { +// buildClient creates a new client for auto-create; ClientService.Create fills per-protocol credentials +func (j *LdapSyncJob) buildClient(email string, defGB, defExpiryDays, defLimitIP int) model.Client { c := model.Client{ Email: email, Enable: true, @@ -207,15 +194,37 @@ func (j *LdapSyncJob) buildClient(ib *model.Inbound, email string, defGB, defExp if defExpiryDays > 0 { c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli() } - switch ib.Protocol { - case model.Trojan, model.Shadowsocks: - c.Password = uuid.NewString() - default: - c.ID = uuid.NewString() - } return c } +// createClients adds each new LDAP client once, attached to every configured inbound +func (j *LdapSyncJob) createClients(newClients []model.Client, inboundIds []int, tags []string) { + if len(newClients) == 0 || len(inboundIds) == 0 { + return + } + tagList := strings.Join(tags, ",") + created := 0 + restartNeeded := false + for _, c := range newClients { + nr, err := j.clientService.Create(&j.inboundService, &service.ClientCreatePayload{Client: c, InboundIds: inboundIds}) + if err != nil { + logger.Warningf("Failed to add client %s for tags %s: %v", c.Email, tagList, err) + continue + } + created++ + if nr { + restartNeeded = true + } + } + if created == 0 { + return + } + logger.Infof("LDAP auto-create: %d clients for %s", created, tagList) + if restartNeeded { + j.xrayService.SetToNeedRestart() + } +} + func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) { if len(emails) == 0 { return diff --git a/internal/web/job/ldap_sync_job_test.go b/internal/web/job/ldap_sync_job_test.go new file mode 100644 index 000000000..f3ce38d7e --- /dev/null +++ b/internal/web/job/ldap_sync_job_test.go @@ -0,0 +1,85 @@ +package job + +import ( + "path/filepath" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/web/service" +) + +func initLdapJobDB(t *testing.T) { + t.Helper() + 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() }) +} + +func TestLdapCreateClients_AttachesToAllConfiguredInbounds(t *testing.T) { + initLdapJobDB(t) + db := database.GetDB() + + tags := []string{"in-1080-tcp", "in-1081-tcp", "in-1082-tcp"} + protocols := []model.Protocol{model.VLESS, model.Trojan, model.VLESS} + inboundIds := make([]int, 0, len(tags)) + for i, tag := range tags { + ib := &model.Inbound{ + UserId: 1, + Tag: tag, + Enable: true, + Port: 42080 + i, + Protocol: protocols[i], + Settings: `{"clients": []}`, + StreamSettings: `{"network":"tcp","security":"none"}`, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create inbound %s: %v", tag, err) + } + inboundIds = append(inboundIds, ib.Id) + } + + j := NewLdapSyncJob() + const email = "user@example.com" + j.createClients([]model.Client{j.buildClient(email, 0, 0, 0)}, inboundIds, tags) + + rec := &model.ClientRecord{} + if err := db.Where("email = ?", email).First(rec).Error; err != nil { + t.Fatalf("client record for %s not created: %v", email, err) + } + if rec.SubID == "" { + t.Error("created LDAP client must carry a subId") + } + + clientSvc := &service.ClientService{} + for i, id := range inboundIds { + clients, err := clientSvc.ListForInbound(nil, id) + if err != nil { + t.Fatalf("ListForInbound(%s): %v", tags[i], err) + } + if len(clients) != 1 || clients[0].Email != email { + t.Fatalf("inbound %s must carry exactly the LDAP client, got %d clients", tags[i], len(clients)) + } + if clients[0].SubID != rec.SubID { + t.Errorf("inbound %s client subId = %q, want the shared %q", tags[i], clients[0].SubID, rec.SubID) + } + } + + trojanClients, err := clientSvc.ListForInbound(nil, inboundIds[1]) + if err != nil { + t.Fatalf("ListForInbound(trojan): %v", err) + } + if trojanClients[0].Password == "" { + t.Error("trojan inbound client must get a generated password") + } + vlessClients, err := clientSvc.ListForInbound(nil, inboundIds[0]) + if err != nil { + t.Fatalf("ListForInbound(vless): %v", err) + } + if vlessClients[0].ID == "" { + t.Error("vless inbound client must get a generated uuid") + } +}