diff --git a/internal/util/ldap/ldap.go b/internal/util/ldap/ldap.go deleted file mode 100644 index e5a023d..0000000 --- a/internal/util/ldap/ldap.go +++ /dev/null @@ -1,156 +0,0 @@ -package ldaputil - -import ( - "crypto/tls" - "fmt" - "slices" - - "github.com/go-ldap/ldap/v3" -) - -type Config struct { - Host string - Port int - UseTLS bool - InsecureSkipVerify bool - BindDN string - Password string - BaseDN string - UserFilter string - UserAttr string - FlagField string - TruthyVals []string - Invert bool -} - -func tlsConfig(cfg Config) *tls.Config { - return &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify} -} - -// FetchVlessFlags returns map[email]enabled -func FetchVlessFlags(cfg Config) (map[string]bool, error) { - addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - - scheme := "ldap" - if cfg.UseTLS { - scheme = "ldaps" - } - - ldapURL := fmt.Sprintf("%s://%s", scheme, addr) - - var opts []ldap.DialOpt - if cfg.UseTLS { - opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg))) - } - - conn, err := ldap.DialURL(ldapURL, opts...) - if err != nil { - return nil, err - } - defer conn.Close() - - if cfg.BindDN != "" { - if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil { - return nil, err - } - } - - if cfg.UserFilter == "" { - cfg.UserFilter = "(objectClass=person)" - } - if cfg.UserAttr == "" { - cfg.UserAttr = "mail" - } - // if field not set we fallback to legacy vless_enabled - if cfg.FlagField == "" { - cfg.FlagField = "vless_enabled" - } - - req := ldap.NewSearchRequest( - cfg.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - cfg.UserFilter, - []string{cfg.UserAttr, cfg.FlagField}, - nil, - ) - - res, err := conn.Search(req) - if err != nil { - return nil, err - } - - result := make(map[string]bool, len(res.Entries)) - for _, e := range res.Entries { - user := e.GetAttributeValue(cfg.UserAttr) - if user == "" { - continue - } - val := e.GetAttributeValue(cfg.FlagField) - enabled := slices.Contains(cfg.TruthyVals, val) - if cfg.Invert { - enabled = !enabled - } - result[user] = enabled - } - return result, nil -} - -// AuthenticateUser searches user by cfg.UserAttr and attempts to bind with provided password. -func AuthenticateUser(cfg Config, username, password string) (bool, error) { - addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - - scheme := "ldap" - if cfg.UseTLS { - scheme = "ldaps" - } - - ldapURL := fmt.Sprintf("%s://%s", scheme, addr) - - var opts []ldap.DialOpt - if cfg.UseTLS { - opts = append(opts, ldap.DialWithTLSConfig(tlsConfig(cfg))) - } - - conn, err := ldap.DialURL(ldapURL, opts...) - if err != nil { - return false, err - } - defer conn.Close() - - // Optional initial bind for search - if cfg.BindDN != "" { - if err := conn.Bind(cfg.BindDN, cfg.Password); err != nil { - return false, err - } - } - - if cfg.UserFilter == "" { - cfg.UserFilter = "(objectClass=person)" - } - if cfg.UserAttr == "" { - cfg.UserAttr = "uid" - } - - // Build filter to find specific user - filter := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, cfg.UserAttr, ldap.EscapeFilter(username)) - req := ldap.NewSearchRequest( - cfg.BaseDN, - ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 0, false, - filter, - []string{"dn"}, - nil, - ) - res, err := conn.Search(req) - if err != nil { - return false, err - } - if len(res.Entries) == 0 { - return false, nil - } - userDN := res.Entries[0].DN - // Try to bind as the user - if err := conn.Bind(userDN, password); err != nil { - return false, nil - } - return true, nil -} diff --git a/internal/util/ldap/ldap_test.go b/internal/util/ldap/ldap_test.go deleted file mode 100644 index 360d1b5..0000000 --- a/internal/util/ldap/ldap_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package ldaputil - -import "testing" - -func TestTLSConfig_InsecureSkipVerifyPropagates(t *testing.T) { - cases := []struct { - name string - skip bool - want bool - }{ - {"default verifies", false, false}, - {"skip flows through", true, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got := tlsConfig(Config{InsecureSkipVerify: c.skip}) - if got.InsecureSkipVerify != c.want { - t.Fatalf("InsecureSkipVerify = %v, want %v", got.InsecureSkipVerify, c.want) - } - }) - } -} diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 74f3525..4414d4a 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -36,7 +36,6 @@ type updateSettingForm struct { entity.AllSetting TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` - ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"` ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` } @@ -145,7 +144,6 @@ func (a *SettingController) updateSetting(c *gin.Context) { } err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ TgBotToken: form.ClearTgBotToken, - LdapPassword: form.ClearLdapPassword, SmtpPassword: form.ClearSmtpPassword, }) if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 4490469..a4a003a 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -68,66 +68,44 @@ type AllSetting struct { TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` - SubEnable bool `json:"subEnable" form:"subEnable"` - SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` - SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"` - SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"` - SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"` - SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"` - SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"` - SubTitle string `json:"subTitle" form:"subTitle"` - SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` - SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` - SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` - SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` - SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` - SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` - SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` - SubListen string `json:"subListen" form:"subListen"` - SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` - SubPath string `json:"subPath" form:"subPath"` - SubDomain string `json:"subDomain" form:"subDomain"` - SubCertFile string `json:"subCertFile" form:"subCertFile"` - SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` - SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` - RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` - SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` - SubURI string `json:"subURI" form:"subURI"` - SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` - SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` - SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` - SubClashPath string `json:"subClashPath" form:"subClashPath"` - SubClashURI string `json:"subClashURI" form:"subClashURI"` - SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` - SubClashRules string `json:"subClashRules" form:"subClashRules"` - SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` - SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` - SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` - SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"` - SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` - SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` - - LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` - LdapHost string `json:"ldapHost" form:"ldapHost"` - LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"` - LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"` - LdapInsecureSkipVerify bool `json:"ldapInsecureSkipVerify" form:"ldapInsecureSkipVerify"` - LdapBindDN string `json:"ldapBindDN" form:"ldapBindDN"` - LdapPassword string `json:"ldapPassword" form:"ldapPassword"` - LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"` - LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"` - LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` - LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"` - LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"` - LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"` - LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"` - LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"` - LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"` - LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"` - LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"` - LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"` - LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"` - LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"` + SubEnable bool `json:"subEnable" form:"subEnable"` + SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` + SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"` + SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"` + SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"` + SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"` + SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"` + SubTitle string `json:"subTitle" form:"subTitle"` + SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` + SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` + SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` + SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` + SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` + SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` + SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` + SubListen string `json:"subListen" form:"subListen"` + SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` + SubPath string `json:"subPath" form:"subPath"` + SubDomain string `json:"subDomain" form:"subDomain"` + SubCertFile string `json:"subCertFile" form:"subCertFile"` + SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` + SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` + RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` + SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` + SubURI string `json:"subURI" form:"subURI"` + SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` + SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` + SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` + SubClashPath string `json:"subClashPath" form:"subClashPath"` + SubClashURI string `json:"subClashURI" form:"subClashURI"` + SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` + SubClashRules string `json:"subClashRules" form:"subClashRules"` + SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` + SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` + SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` + SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"` + SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` + SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"` } @@ -137,7 +115,6 @@ type AllSettingView struct { HasTgBotToken bool `json:"hasTgBotToken"` HasTwoFactorToken bool `json:"hasTwoFactorToken"` - HasLdapPassword bool `json:"hasLdapPassword"` HasApiToken bool `json:"hasApiToken"` HasWarpSecret bool `json:"hasWarpSecret"` HasNordSecret bool `json:"hasNordSecret"` diff --git a/internal/web/job/ldap_auto_delete_guard_test.go b/internal/web/job/ldap_auto_delete_guard_test.go deleted file mode 100644 index c4846a2..0000000 --- a/internal/web/job/ldap_auto_delete_guard_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package job - -import "testing" - -// A bind that succeeds with an unusable search returns (empty, nil); the only -// guard was `err != nil`, so that answer detached the entire inbound. -func TestAutoDeleteAllowed(t *testing.T) { - cases := []struct { - name string - previous int64 - fetched int - want bool - }{ - {"empty fetch on a fresh job is refused", 0, 0, false}, - {"empty fetch after a healthy sync is refused", 500, 0, false}, - {"full fetch on a fresh job is allowed", 0, 500, true}, - {"steady fetch is allowed", 500, 500, true}, - {"growth is allowed", 500, 900, true}, - {"shrink above the retention floor is allowed", 500, 250, true}, - {"shrink below the retention floor is refused", 500, 249, false}, - {"collapse to a single user is refused", 500, 1, false}, - {"single user with no history is allowed", 0, 1, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - j := NewLdapSyncJob() - j.lastFlagCount.Store(c.previous) - if got := j.autoDeleteSafeForFetch(c.fetched); got != c.want { - t.Fatalf("autoDeleteSafeForFetch(previous=%d, fetched=%d) = %v, want %v", - c.previous, c.fetched, got, c.want) - } - }) - } -} diff --git a/internal/web/job/ldap_sync_job.go b/internal/web/job/ldap_sync_job.go deleted file mode 100644 index b5ad7ee..0000000 --- a/internal/web/job/ldap_sync_job.go +++ /dev/null @@ -1,342 +0,0 @@ -package job - -import ( - "strings" - "sync/atomic" - "time" - - "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" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" -) - -var DefaultTruthyValues = []string{"true", "1", "yes", "on"} - -// Share of the previous successful fetch a new one must still return to be -// trusted: a sudden collapse means a broken directory far more often than churn. -const ldapAutoDeleteMinRetainPercent = 50 - -type LdapSyncJob struct { - settingService service.SettingService - inboundService service.InboundService - clientService service.ClientService - xrayService service.XrayService - lastFlagCount atomic.Int64 -} - -// --- 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), - InsecureSkipVerify: mustGetBool(j.settingService.GetLdapInsecureSkipVerify), - 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: truthyValuesOrDefault(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) - - 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 - } - 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 - } - 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) - } - for tag, emails := range clientsToDisable { - j.batchSetEnable(inboundMap[tag], emails, false) - } - - // --- Auto delete clients not in LDAP --- - autoDelete := mustGetBool(j.settingService.GetLdapAutoDelete) - if autoDelete && j.autoDeleteSafeForFetch(len(flags)) { - ldapEmailSet := map[string]struct{}{} - for e := range flags { - ldapEmailSet[e] = struct{}{} - } - for _, tag := range inboundTags { - j.deleteClientsNotInLDAP(tag, ldapEmailSet) - } - } - j.lastFlagCount.Store(int64(len(flags))) -} - -// FetchVlessFlags returns (empty, nil) when the bind succeeds but the search -// yields nothing — a renamed OU, a lost read grant — which is not "all gone". -func (j *LdapSyncJob) autoDeleteSafeForFetch(fetched int) bool { - if fetched == 0 { - logger.Warning("LDAP auto-delete skipped: directory returned no usable users") - return false - } - previous := j.lastFlagCount.Load() - if previous > 0 && int64(fetched)*100 < previous*ldapAutoDeleteMinRetainPercent { - logger.Warningf("LDAP auto-delete skipped: fetched %d users, previous successful sync saw %d (below %d%% retention)", - fetched, previous, ldapAutoDeleteMinRetainPercent) - return false - } - return true -} - -func truthyValuesOrDefault(s string) []string { - if vals := splitCsv(s); len(vals) > 0 { - return vals - } - return DefaultTruthyValues -} - -func splitCsv(s string) []string { - if s == "" { - return nil - } - 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; 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, - LimitIP: defLimitIP, - TotalGB: int64(defGB) * 1024 * 1024 * 1024, - } - if defExpiryDays > 0 { - c.ExpiryTime = time.Now().Add(time.Duration(defExpiryDays) * 24 * time.Hour).UnixMilli() - } - 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 - } - restartNeeded := false - changed := 0 - for _, email := range emails { - ok, needRestart, err := j.clientService.SetClientEnableByEmail(&j.inboundService, email, enable) - if err != nil { - logger.Warningf("Batch set enable failed for %s in inbound %s: %v", email, ib.Tag, err) - continue - } - if ok { - changed++ - } - if needRestart { - restartNeeded = true - } - } - if changed > 0 { - logger.Infof("Batch set enable=%v for %d clients in inbound %s", enable, changed, ib.Tag) - } - if restartNeeded { - j.xrayService.SetToNeedRestart() - } -} - -// deleteClientsNotInLDAP deletes clients not in LDAP using batches and a single restart -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 - } - - batchSize := 50 // clients in 1 batch - restartNeeded := false - - for _, ib := range inbounds { - if ib.Tag != inboundTag { - continue - } - clients, err := j.inboundService.GetClients(ib) - if err != nil { - logger.Warningf("Failed to get clients for inbound %s: %v", ib.Tag, err) - continue - } - - // Collect clients for deletion - toDelete := []model.Client{} - for _, c := range clients { - if _, ok := ldapEmails[c.Email]; !ok { - toDelete = append(toDelete, c) - } - } - - if len(toDelete) == 0 { - continue - } - - for i := 0; i < len(toDelete); i += batchSize { - end := min(i+batchSize, len(toDelete)) - batch := toDelete[i:end] - - for _, c := range batch { - nr, err := j.clientService.DetachByEmail(&j.inboundService, ib.Id, c.Email) - if err != nil { - logger.Warningf("Failed to delete client %s from inbound id=%d(tag=%s): %v", - c.Email, ib.Id, ib.Tag, err) - continue - } - logger.Infof("Deleted client %s from inbound id=%d(tag=%s)", - c.Email, ib.Id, ib.Tag) - if nr { - restartNeeded = true - } - } - } - } - - if restartNeeded { - j.xrayService.SetToNeedRestart() - logger.Info("Xray restart scheduled after batch deletion") - } -} diff --git a/internal/web/job/ldap_sync_job_test.go b/internal/web/job/ldap_sync_job_test.go deleted file mode 100644 index 1df9dba..0000000 --- a/internal/web/job/ldap_sync_job_test.go +++ /dev/null @@ -1,93 +0,0 @@ -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 TestBuildClient_ConvertsDefaultTotalGBToBytes(t *testing.T) { - j := NewLdapSyncJob() - c := j.buildClient("user@example.com", 10, 0, 0) - if want := int64(10) * 1024 * 1024 * 1024; c.TotalGB != want { - t.Errorf("TotalGB = %d, want %d", c.TotalGB, want) - } -} - -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") - } -} diff --git a/internal/web/service/panel/user.go b/internal/web/service/panel/user.go index a114021..3971f67 100644 --- a/internal/web/service/panel/user.go +++ b/internal/web/service/panel/user.go @@ -10,7 +10,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" - ldaputil "github.com/mhsanaei/3x-ui/v3/internal/util/ldap" "github.com/mhsanaei/3x-ui/v3/internal/web/service" ) @@ -52,36 +51,7 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode } if !crypto.CheckPasswordHash(user.Password, password) { - ldapEnabled, _ := s.settingService.GetLdapEnable() - if !ldapEnabled { - return nil, errors.New("invalid credentials") - } - - host, _ := s.settingService.GetLdapHost() - port, _ := s.settingService.GetLdapPort() - useTLS, _ := s.settingService.GetLdapUseTLS() - skipVerify, _ := s.settingService.GetLdapInsecureSkipVerify() - bindDN, _ := s.settingService.GetLdapBindDN() - ldapPass, _ := s.settingService.GetLdapPassword() - baseDN, _ := s.settingService.GetLdapBaseDN() - userFilter, _ := s.settingService.GetLdapUserFilter() - userAttr, _ := s.settingService.GetLdapUserAttr() - - cfg := ldaputil.Config{ - Host: host, - Port: port, - UseTLS: useTLS, - InsecureSkipVerify: skipVerify, - BindDN: bindDN, - Password: ldapPass, - BaseDN: baseDN, - UserFilter: userFilter, - UserAttr: userAttr, - } - ok, err := ldaputil.AuthenticateUser(cfg, username, password) - if err != nil || !ok { - return nil, errors.New("invalid credentials") - } + return nil, errors.New("invalid credentials") } twoFactorEnable, err := s.settingService.GetTwoFactorEnable() diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index d9c8130..82bb4b0 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -55,103 +55,80 @@ var defaultValueMap = map[string]string{ // cert are minted lazily on first use, and the node-side trust CA is pasted // in by the operator. Kept out of entity.AllSetting so private keys never // reach the settings UI/export. - "nodeMtlsCaCertPem": "", - "nodeMtlsCaKeyPem": "", - "nodeMtlsClientCertPem": "", - "nodeMtlsClientKeyPem": "", - "nodeMtlsClientCertSha256": "", - "nodeMtlsClientCAPem": "", - "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), - "sessionMaxAge": "360", - "trustedProxyCIDRs": DefaultTrustedProxyCIDRs, - "ipLimitAllowlist": "", - "pageSize": "25", - "expireDiff": "0", - "trafficDiff": "0", - "remarkTemplate": DefaultRemarkTemplate, - "subShowIdentityOnAllLinks": "false", - "timeLocation": "Local", - "tgBotEnable": "false", - "tgBotToken": "", - "tgBotProxy": "", - "tgBotAPIServer": "", - "tgBotChatId": "", - "tgRunTime": "@daily", - "tgBotBackup": "false", - "tgCpu": "80", - "tgMemory": "80", - "tgLang": "en-US", - "twoFactorEnable": "false", - "twoFactorToken": "", - "subEnable": "true", - "subJsonEnable": "false", - "subJsonAutoDetect": "false", - "subJsonAlwaysArray": "false", - "subJsonUserAgentRegex": "", - "subClashAutoDetect": "false", - "subClashUserAgentRegex": "", - "subTitle": "", - "subSupportUrl": "", - "subProfileUrl": "", - "subAnnounce": "", - "subEnableRouting": "false", - "subRoutingRules": "", - "subHideSettings": "false", - "subIncyEnableRouting": "false", - "subIncyRoutingRules": "", - "subListen": "", - "subPort": "2096", - "subPath": "/sub/", - "subDomain": "", - "subCertFile": "", - "subKeyFile": "", - "subUpdates": "12", - "subEncrypt": "true", - "subURI": "", - "subJsonPath": "/json/", - "subJsonURI": "", - "subClashEnable": "false", - "subClashPath": "/clash/", - "subClashURI": "", - "subClashEnableRouting": "false", - "subClashRules": "", - "subJsonMux": "", - "subJsonRules": "", - "subJsonFinalMask": "", - "subJsonObservatory": "", - "subThemeDir": "", - "datepicker": "gregorian", - "warp": "", - "warpUpdateInterval": "0", - "nord": "", - "pia": "", - "restartXrayOnClientDisable": "true", - "xrayOutboundTestUrl": "https://www.google.com/generate_204", - "panelOutbound": "", - "devChannelEnable": "false", - - // LDAP defaults - "ldapEnable": "false", - "ldapHost": "", - "ldapPort": "389", - "ldapUseTLS": "false", - "ldapInsecureSkipVerify": "false", - "ldapBindDN": "", - "ldapPassword": "", - "ldapBaseDN": "", - "ldapUserFilter": "(objectClass=person)", - "ldapUserAttr": "mail", - "ldapVlessField": "vless_enabled", - "ldapSyncCron": "@every 1m", - "ldapFlagField": "", - "ldapTruthyValues": "true,1,yes,on", - "ldapInvertFlag": "false", - "ldapInboundTags": "", - "ldapAutoCreate": "false", - "ldapAutoDelete": "false", - "ldapDefaultTotalGB": "0", - "ldapDefaultExpiryDays": "0", - "ldapDefaultLimitIP": "0", + "nodeMtlsCaCertPem": "", + "nodeMtlsCaKeyPem": "", + "nodeMtlsClientCertPem": "", + "nodeMtlsClientKeyPem": "", + "nodeMtlsClientCertSha256": "", + "nodeMtlsClientCAPem": "", + "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), + "sessionMaxAge": "360", + "trustedProxyCIDRs": DefaultTrustedProxyCIDRs, + "ipLimitAllowlist": "", + "pageSize": "25", + "expireDiff": "0", + "trafficDiff": "0", + "remarkTemplate": DefaultRemarkTemplate, + "subShowIdentityOnAllLinks": "false", + "timeLocation": "Local", + "tgBotEnable": "false", + "tgBotToken": "", + "tgBotProxy": "", + "tgBotAPIServer": "", + "tgBotChatId": "", + "tgRunTime": "@daily", + "tgBotBackup": "false", + "tgCpu": "80", + "tgMemory": "80", + "tgLang": "en-US", + "twoFactorEnable": "false", + "twoFactorToken": "", + "subEnable": "true", + "subJsonEnable": "false", + "subJsonAutoDetect": "false", + "subJsonAlwaysArray": "false", + "subJsonUserAgentRegex": "", + "subClashAutoDetect": "false", + "subClashUserAgentRegex": "", + "subTitle": "", + "subSupportUrl": "", + "subProfileUrl": "", + "subAnnounce": "", + "subEnableRouting": "false", + "subRoutingRules": "", + "subHideSettings": "false", + "subIncyEnableRouting": "false", + "subIncyRoutingRules": "", + "subListen": "", + "subPort": "2096", + "subPath": "/sub/", + "subDomain": "", + "subCertFile": "", + "subKeyFile": "", + "subUpdates": "12", + "subEncrypt": "true", + "subURI": "", + "subJsonPath": "/json/", + "subJsonURI": "", + "subClashEnable": "false", + "subClashPath": "/clash/", + "subClashURI": "", + "subClashEnableRouting": "false", + "subClashRules": "", + "subJsonMux": "", + "subJsonRules": "", + "subJsonFinalMask": "", + "subJsonObservatory": "", + "subThemeDir": "", + "datepicker": "gregorian", + "warp": "", + "warpUpdateInterval": "0", + "nord": "", + "pia": "", + "restartXrayOnClientDisable": "true", + "xrayOutboundTestUrl": "https://www.google.com/generate_204", + "panelOutbound": "", + "devChannelEnable": "false", // Event bus — per-subscriber event filtering (empty = all disabled) "tgEnabledEvents": "login.attempt,cpu.high", @@ -270,7 +247,6 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { view := &entity.AllSettingView{AllSetting: *allSetting} view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken) - view.HasLdapPassword = secretConfigured(allSetting.LdapPassword) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasNordSecret = secretConfigured(mustString(s.GetNord())) view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword) @@ -280,7 +256,6 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) { } view.TgBotToken = "" view.TwoFactorToken = "" - view.LdapPassword = "" view.SmtpPassword = "" return view, nil } @@ -965,91 +940,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) { return (accessLogPath != "none" && accessLogPath != ""), nil } -// GetLdapEnable returns whether LDAP is enabled. -func (s *SettingService) GetLdapEnable() (bool, error) { - return s.getBool("ldapEnable") -} - -func (s *SettingService) GetLdapHost() (string, error) { - return s.getString("ldapHost") -} - -func (s *SettingService) GetLdapPort() (int, error) { - return s.getInt("ldapPort") -} - -func (s *SettingService) GetLdapUseTLS() (bool, error) { - return s.getBool("ldapUseTLS") -} - -func (s *SettingService) GetLdapInsecureSkipVerify() (bool, error) { - return s.getBool("ldapInsecureSkipVerify") -} - -func (s *SettingService) GetLdapBindDN() (string, error) { - return s.getString("ldapBindDN") -} - -func (s *SettingService) GetLdapPassword() (string, error) { - return s.getString("ldapPassword") -} - -func (s *SettingService) GetLdapBaseDN() (string, error) { - return s.getString("ldapBaseDN") -} - -func (s *SettingService) GetLdapUserFilter() (string, error) { - return s.getString("ldapUserFilter") -} - -func (s *SettingService) GetLdapUserAttr() (string, error) { - return s.getString("ldapUserAttr") -} - -func (s *SettingService) GetLdapVlessField() (string, error) { - return s.getString("ldapVlessField") -} - -func (s *SettingService) GetLdapSyncCron() (string, error) { - return s.getString("ldapSyncCron") -} - -func (s *SettingService) GetLdapFlagField() (string, error) { - return s.getString("ldapFlagField") -} - -func (s *SettingService) GetLdapTruthyValues() (string, error) { - return s.getString("ldapTruthyValues") -} - -func (s *SettingService) GetLdapInvertFlag() (bool, error) { - return s.getBool("ldapInvertFlag") -} - -func (s *SettingService) GetLdapInboundTags() (string, error) { - return s.getString("ldapInboundTags") -} - -func (s *SettingService) GetLdapAutoCreate() (bool, error) { - return s.getBool("ldapAutoCreate") -} - -func (s *SettingService) GetLdapAutoDelete() (bool, error) { - return s.getBool("ldapAutoDelete") -} - -func (s *SettingService) GetLdapDefaultTotalGB() (int, error) { - return s.getInt("ldapDefaultTotalGB") -} - -func (s *SettingService) GetLdapDefaultExpiryDays() (int, error) { - return s.getInt("ldapDefaultExpiryDays") -} - -func (s *SettingService) GetLdapDefaultLimitIP() (int, error) { - return s.getInt("ldapDefaultLimitIP") -} - // Event bus — per-subscriber event filtering func (s *SettingService) GetTgEnabledEvents() (string, error) { @@ -1174,7 +1064,6 @@ func (s *SettingService) SetOutboundDownThreshold(value int) error { // blank to the browser) and the stored value is preserved. type SecretClears struct { TgBotToken bool - LdapPassword bool SmtpPassword bool } @@ -1278,13 +1167,6 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, } allSetting.TgBotToken = value } - if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" { - value, err := s.GetLdapPassword() - if err != nil { - return err - } - allSetting.LdapPassword = value - } if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" { value, err := s.GetTwoFactorToken() if err != nil { @@ -1341,7 +1223,7 @@ func validateRemoteRoutingURLSetting(name string, value *string) error { func (s *SettingService) UpdateSecret(key string, value string) error { switch key { - case "tgBotToken", "ldapPassword", "twoFactorToken": + case "tgBotToken", "twoFactorToken": return s.saveSetting(key, strings.TrimSpace(value)) default: return common.NewError("secret key is not replaceable:", key) @@ -1476,7 +1358,6 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { var factoryDefaultSecretKeys = map[string]bool{ "tgBotToken": true, "twoFactorToken": true, - "ldapPassword": true, "smtpPassword": true, } diff --git a/internal/web/service/setting_factory_defaults_test.go b/internal/web/service/setting_factory_defaults_test.go index 333c096..f503b14 100644 --- a/internal/web/service/setting_factory_defaults_test.go +++ b/internal/web/service/setting_factory_defaults_test.go @@ -56,7 +56,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) { "xrayTemplateConfig", "tgBotToken", "twoFactorToken", - "ldapPassword", "smtpPassword", } { t.Run(key, func(t *testing.T) { diff --git a/internal/web/web.go b/internal/web/web.go index 9d6e96c..5dee61d 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -372,17 +372,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { // Check monthly reset days at midnight _, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc)) - // LDAP sync scheduling - if ldapEnabled, _ := s.settingService.GetLdapEnable(); ldapEnabled { - runtime, err := s.settingService.GetLdapSyncCron() - if err != nil || runtime == "" { - runtime = "@every 1m" - } - j := job.NewLdapSyncJob() - // job has zero-value services with method receivers that read settings on demand - _, _ = s.cron.AddJob(runtime, j) - } - // Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup. isTgbotenabled, err := s.settingService.GetTgbotEnabled() if (err == nil) && isTgbotenabled {