diff options
| author | konstpic <156318483+konstpic@users.noreply.github.com> | 2025-09-28 22:00:16 +0300 |
|---|---|---|
| committer | mhsanaei <ho3ein.sanaei@gmail.com> | 2025-09-28 22:04:54 +0300 |
| commit | 28a17a80ec0c4a0f82e8acfca351651d762b3ec9 (patch) | |
| tree | 7902b7b4cba04bce816ad17c9490f7228574a096 /web/job | |
| parent | 30565833889171afe5c934f97bc0e767534e8310 (diff) | |
feat: add ldap component (#3568)
* add ldap component
* fix: fix russian comments, tls cert verify default true
* feat: remove replaces go mod for local dev
Diffstat (limited to 'web/job')
| -rw-r--r-- | web/job/ldap_sync_job.go | 393 |
1 files changed, 393 insertions, 0 deletions
diff --git a/web/job/ldap_sync_job.go b/web/job/ldap_sync_job.go new file mode 100644 index 00000000..326123a6 --- /dev/null +++ b/web/job/ldap_sync_job.go @@ -0,0 +1,393 @@ +package job + +import ( + "time" + + "github.com/mhsanaei/3x-ui/v2/database/model" + "github.com/mhsanaei/3x-ui/v2/logger" + ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap" + "github.com/mhsanaei/3x-ui/v2/web/service" + "strings" + + "github.com/google/uuid" + "strconv" +) + +var DefaultTruthyValues = []string{"true", "1", "yes", "on"} + +type LdapSyncJob struct { + settingService service.SettingService + inboundService service.InboundService + xrayService service.XrayService +} + +// --- Helper functions for mustGet --- +func mustGetString(fn func() (string, error)) string { + v, err := fn() + if err != nil { + panic(err) + } + return v +} + +func mustGetInt(fn func() (int, error)) int { + v, err := fn() + if err != nil { + panic(err) + } + return v +} + +func mustGetBool(fn func() (bool, error)) bool { + v, err := fn() + if err != nil { + panic(err) + } + return v +} + +func mustGetStringOr(fn func() (string, error), fallback string) string { + v, err := fn() + if err != nil || v == "" { + return fallback + } + return v +} + + +func NewLdapSyncJob() *LdapSyncJob { + return new(LdapSyncJob) +} + +func (j *LdapSyncJob) Run() { + logger.Info("LDAP sync job started") + + enabled, err := j.settingService.GetLdapEnable() + if err != nil || !enabled { + logger.Warning("LDAP disabled or failed to fetch flag") + return + } + + // --- LDAP fetch --- + cfg := ldaputil.Config{ + Host: mustGetString(j.settingService.GetLdapHost), + Port: mustGetInt(j.settingService.GetLdapPort), + UseTLS: mustGetBool(j.settingService.GetLdapUseTLS), + BindDN: mustGetString(j.settingService.GetLdapBindDN), + Password: mustGetString(j.settingService.GetLdapPassword), + BaseDN: mustGetString(j.settingService.GetLdapBaseDN), + UserFilter: mustGetString(j.settingService.GetLdapUserFilter), + UserAttr: mustGetString(j.settingService.GetLdapUserAttr), + FlagField: mustGetStringOr(j.settingService.GetLdapFlagField, mustGetString(j.settingService.GetLdapVlessField)), + TruthyVals: splitCsv(mustGetString(j.settingService.GetLdapTruthyValues)), + Invert: mustGetBool(j.settingService.GetLdapInvertFlag), + } + + flags, err := ldaputil.FetchVlessFlags(cfg) + if err != nil { + logger.Warning("LDAP fetch failed:", err) + return + } + logger.Infof("Fetched %d LDAP flags", len(flags)) + + // --- Load all inbounds and all clients once --- + inboundTags := splitCsv(mustGetString(j.settingService.GetLdapInboundTags)) + inbounds, err := j.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Failed to get inbounds:", err) + return + } + + allClients := map[string]*model.Client{} // email -> client + inboundMap := map[string]*model.Inbound{} // tag -> inbound + for _, ib := range inbounds { + inboundMap[ib.Tag] = ib + clients, _ := j.inboundService.GetClients(ib) + for i := range clients { + allClients[clients[i].Email] = &clients[i] + } + } + + // --- Prepare batch operations --- + autoCreate := mustGetBool(j.settingService.GetLdapAutoCreate) + defGB := mustGetInt(j.settingService.GetLdapDefaultTotalGB) + 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) + } + } + } + } + + // --- Execute batch create --- + for tag, newClients := range clientsToCreate { + if len(newClients) == 0 { + continue + } + payload := &model.Inbound{Id: inboundMap[tag].Id} + payload.Settings = j.clientsToJSON(newClients) + if _, err := j.inboundService.AddInboundClient(payload); err != nil { + logger.Warningf("Failed to add clients for tag %s: %v", tag, err) + } else { + logger.Infof("LDAP auto-create: %d clients for %s", len(newClients), tag) + j.xrayService.SetToNeedRestart() + } + } + + // --- Execute enable/disable batch --- + for tag, emails := range clientsToEnable { + j.batchSetEnable(inboundMap[tag], emails, true) + } + for tag, emails := range clientsToDisable { + j.batchSetEnable(inboundMap[tag], emails, false) + } + + // --- Auto delete clients not in LDAP --- + autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete) + if autoDelete { + ldapEmailSet := map[string]struct{}{} + for e := range flags { + ldapEmailSet[e] = struct{}{} + } + for _, tag := range inboundTags { + j.deleteClientsNotInLDAP(tag, ldapEmailSet) + } + } +} + + + +func splitCsv(s string) []string { + if s == "" { + return DefaultTruthyValues + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v != "" { + out = append(out, v) + } + } + 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 { + c := model.Client{ + Email: email, + Enable: true, + LimitIP: defLimitIP, + TotalGB: int64(defGB), + } + 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 +} + +// batchSetEnable enables/disables clients in batch through a single call +func (j *LdapSyncJob) batchSetEnable(ib *model.Inbound, emails []string, enable bool) { + if len(emails) == 0 { + return + } + + // Подготовка JSON для массового обновления + clients := make([]model.Client, 0, len(emails)) + for _, email := range emails { + clients = append(clients, model.Client{ + Email: email, + Enable: enable, + }) + } + + payload := &model.Inbound{ + Id: ib.Id, + Settings: j.clientsToJSON(clients), + } + + // Use a single AddInboundClient call to update enable + if _, err := j.inboundService.AddInboundClient(payload); err != nil { + logger.Warningf("Batch set enable failed for inbound %s: %v", ib.Tag, err) + return + } + + logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, len(emails), ib.Tag) + j.xrayService.SetToNeedRestart() +} + +// deleteClientsNotInLDAP performs batch deletion of clients not in LDAP +func (j *LdapSyncJob) deleteClientsNotInLDAP(inboundTag string, ldapEmails map[string]struct{}) { + inbounds, err := j.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("Failed to get inbounds for deletion:", err) + return + } + + for _, ib := range inbounds { + if ib.Tag != inboundTag { + continue + } + clients, err := j.inboundService.GetClients(ib) + if err != nil { + continue + } + + // Сбор клиентов для удаления + toDelete := []model.Client{} + for _, c := range clients { + if _, ok := ldapEmails[c.Email]; !ok { + // Use appropriate field depending on protocol + client := model.Client{Email: c.Email, ID: c.ID, Password: c.Password} + toDelete = append(toDelete, client) + } + } + + if len(toDelete) == 0 { + continue + } + + payload := &model.Inbound{ + Id: ib.Id, + Settings: j.clientsToJSON(toDelete), + } + + if _, err := j.inboundService.DelInboundClient(payload.Id, payload.Settings); err != nil { + logger.Warningf("Batch delete failed for inbound %s: %v", ib.Tag, err) + } else { + logger.Infof("Batch deleted %d clients from inbound %s", len(toDelete), ib.Tag) + j.xrayService.SetToNeedRestart() + } + } +} + +// clientsToJSON сериализует массив клиентов в JSON +func (j *LdapSyncJob) clientsToJSON(clients []model.Client) string { + b := strings.Builder{} + b.WriteString("{\"clients\":[") + for i, c := range clients { + if i > 0 { b.WriteString(",") } + b.WriteString(j.clientToJSON(c)) + } + b.WriteString("]}") + return b.String() +} + + +// ensureClientExists adds client with defaults to inbound tag if not present +func (j *LdapSyncJob) ensureClientExists(inboundTag string, email string, defGB int, defExpiryDays int, defLimitIP int) { + inbounds, err := j.inboundService.GetAllInbounds() + if err != nil { + logger.Warning("ensureClientExists: get inbounds failed:", err) + return + } + var target *model.Inbound + for _, ib := range inbounds { + if ib.Tag == inboundTag { + target = ib + break + } + } + if target == nil { + logger.Debugf("ensureClientExists: inbound tag %s not found", inboundTag) + return + } + // check if email already exists in this inbound + clients, err := j.inboundService.GetClients(target) + if err == nil { + for _, c := range clients { + if c.Email == email { + return + } + } + } + + // build new client according to protocol + newClient := model.Client{ + Email: email, + Enable: true, + LimitIP: defLimitIP, + TotalGB: int64(defGB), + } + if defExpiryDays > 0 { + newClient.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli() + } + + switch target.Protocol { + case model.Trojan: + newClient.Password = uuid.NewString() + case model.Shadowsocks: + newClient.Password = uuid.NewString() + default: // VMESS/VLESS and others using ID + newClient.ID = uuid.NewString() + } + + // prepare inbound payload with only the new client + payload := &model.Inbound{Id: target.Id} + payload.Settings = `{"clients":[` + j.clientToJSON(newClient) + `]}` + + if _, err := j.inboundService.AddInboundClient(payload); err != nil { + logger.Warning("ensureClientExists: add client failed:", err) + } else { + j.xrayService.SetToNeedRestart() + logger.Infof("LDAP auto-create: %s in %s", email, inboundTag) + } +} + +// clientToJSON serializes minimal client fields to JSON object string without extra deps +func (j *LdapSyncJob) clientToJSON(c model.Client) string { + // construct minimal JSON manually to avoid importing json for simple case + b := strings.Builder{} + b.WriteString("{") + if c.ID != "" { + b.WriteString("\"id\":\"") + b.WriteString(c.ID) + b.WriteString("\",") + } + if c.Password != "" { + b.WriteString("\"password\":\"") + b.WriteString(c.Password) + b.WriteString("\",") + } + b.WriteString("\"email\":\"") + b.WriteString(c.Email) + b.WriteString("\",") + b.WriteString("\"enable\":") + if c.Enable { b.WriteString("true") } else { b.WriteString("false") } + b.WriteString(",") + b.WriteString("\"limitIp\":") + b.WriteString(strconv.Itoa(c.LimitIP)) + b.WriteString(",") + b.WriteString("\"totalGB\":") + b.WriteString(strconv.FormatInt(c.TotalGB, 10)) + if c.ExpiryTime > 0 { + b.WriteString(",\"expiryTime\":") + b.WriteString(strconv.FormatInt(c.ExpiryTime, 10)) + } + b.WriteString("}") + return b.String() +} + + |
