精简: 去掉 LDAP(目录登录+用户同步): util/ldap 包、同步 job、登录分支、全部 ldap* 设置

This commit is contained in:
Muse
2026-10-20 10:04:28 +00:00
parent f9b0672d84
commit 7ece6e079b
11 changed files with 114 additions and 947 deletions
-156
View File
@@ -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
}
-22
View File
@@ -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)
}
})
}
}
-2
View File
@@ -36,7 +36,6 @@ type updateSettingForm struct {
entity.AllSetting entity.AllSetting
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"` TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"` ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"` ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
} }
@@ -145,7 +144,6 @@ func (a *SettingController) updateSetting(c *gin.Context) {
} }
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{ err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
TgBotToken: form.ClearTgBotToken, TgBotToken: form.ClearTgBotToken,
LdapPassword: form.ClearLdapPassword,
SmtpPassword: form.ClearSmtpPassword, SmtpPassword: form.ClearSmtpPassword,
}) })
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable { if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
+38 -61
View File
@@ -68,66 +68,44 @@ type AllSetting struct {
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
SubEnable bool `json:"subEnable" form:"subEnable"` SubEnable bool `json:"subEnable" form:"subEnable"`
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"` SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"` SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"` SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"` SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"` SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
SubTitle string `json:"subTitle" form:"subTitle"` SubTitle string `json:"subTitle" form:"subTitle"`
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
SubListen string `json:"subListen" form:"subListen"` SubListen string `json:"subListen" form:"subListen"`
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
SubPath string `json:"subPath" form:"subPath"` SubPath string `json:"subPath" form:"subPath"`
SubDomain string `json:"subDomain" form:"subDomain"` SubDomain string `json:"subDomain" form:"subDomain"`
SubCertFile string `json:"subCertFile" form:"subCertFile"` SubCertFile string `json:"subCertFile" form:"subCertFile"`
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
SubURI string `json:"subURI" form:"subURI"` SubURI string `json:"subURI" form:"subURI"`
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
SubClashPath string `json:"subClashPath" form:"subClashPath"` SubClashPath string `json:"subClashPath" form:"subClashPath"`
SubClashURI string `json:"subClashURI" form:"subClashURI"` SubClashURI string `json:"subClashURI" form:"subClashURI"`
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
SubClashRules string `json:"subClashRules" form:"subClashRules"` SubClashRules string `json:"subClashRules" form:"subClashRules"`
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"` SubJsonObservatory string `json:"subJsonObservatory" form:"subJsonObservatory"`
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` 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"`
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"` WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
} }
@@ -137,7 +115,6 @@ type AllSettingView struct {
HasTgBotToken bool `json:"hasTgBotToken"` HasTgBotToken bool `json:"hasTgBotToken"`
HasTwoFactorToken bool `json:"hasTwoFactorToken"` HasTwoFactorToken bool `json:"hasTwoFactorToken"`
HasLdapPassword bool `json:"hasLdapPassword"`
HasApiToken bool `json:"hasApiToken"` HasApiToken bool `json:"hasApiToken"`
HasWarpSecret bool `json:"hasWarpSecret"` HasWarpSecret bool `json:"hasWarpSecret"`
HasNordSecret bool `json:"hasNordSecret"` HasNordSecret bool `json:"hasNordSecret"`
@@ -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)
}
})
}
}
-342
View File
@@ -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")
}
}
-93
View File
@@ -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")
}
}
+1 -31
View File
@@ -10,7 +10,6 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto" "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" "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) { if !crypto.CheckPasswordHash(user.Password, password) {
ldapEnabled, _ := s.settingService.GetLdapEnable() return nil, errors.New("invalid credentials")
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")
}
} }
twoFactorEnable, err := s.settingService.GetTwoFactorEnable() twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
+75 -194
View File
@@ -55,103 +55,80 @@ var defaultValueMap = map[string]string{
// cert are minted lazily on first use, and the node-side trust CA is pasted // 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 // in by the operator. Kept out of entity.AllSetting so private keys never
// reach the settings UI/export. // reach the settings UI/export.
"nodeMtlsCaCertPem": "", "nodeMtlsCaCertPem": "",
"nodeMtlsCaKeyPem": "", "nodeMtlsCaKeyPem": "",
"nodeMtlsClientCertPem": "", "nodeMtlsClientCertPem": "",
"nodeMtlsClientKeyPem": "", "nodeMtlsClientKeyPem": "",
"nodeMtlsClientCertSha256": "", "nodeMtlsClientCertSha256": "",
"nodeMtlsClientCAPem": "", "nodeMtlsClientCAPem": "",
"webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
"sessionMaxAge": "360", "sessionMaxAge": "360",
"trustedProxyCIDRs": DefaultTrustedProxyCIDRs, "trustedProxyCIDRs": DefaultTrustedProxyCIDRs,
"ipLimitAllowlist": "", "ipLimitAllowlist": "",
"pageSize": "25", "pageSize": "25",
"expireDiff": "0", "expireDiff": "0",
"trafficDiff": "0", "trafficDiff": "0",
"remarkTemplate": DefaultRemarkTemplate, "remarkTemplate": DefaultRemarkTemplate,
"subShowIdentityOnAllLinks": "false", "subShowIdentityOnAllLinks": "false",
"timeLocation": "Local", "timeLocation": "Local",
"tgBotEnable": "false", "tgBotEnable": "false",
"tgBotToken": "", "tgBotToken": "",
"tgBotProxy": "", "tgBotProxy": "",
"tgBotAPIServer": "", "tgBotAPIServer": "",
"tgBotChatId": "", "tgBotChatId": "",
"tgRunTime": "@daily", "tgRunTime": "@daily",
"tgBotBackup": "false", "tgBotBackup": "false",
"tgCpu": "80", "tgCpu": "80",
"tgMemory": "80", "tgMemory": "80",
"tgLang": "en-US", "tgLang": "en-US",
"twoFactorEnable": "false", "twoFactorEnable": "false",
"twoFactorToken": "", "twoFactorToken": "",
"subEnable": "true", "subEnable": "true",
"subJsonEnable": "false", "subJsonEnable": "false",
"subJsonAutoDetect": "false", "subJsonAutoDetect": "false",
"subJsonAlwaysArray": "false", "subJsonAlwaysArray": "false",
"subJsonUserAgentRegex": "", "subJsonUserAgentRegex": "",
"subClashAutoDetect": "false", "subClashAutoDetect": "false",
"subClashUserAgentRegex": "", "subClashUserAgentRegex": "",
"subTitle": "", "subTitle": "",
"subSupportUrl": "", "subSupportUrl": "",
"subProfileUrl": "", "subProfileUrl": "",
"subAnnounce": "", "subAnnounce": "",
"subEnableRouting": "false", "subEnableRouting": "false",
"subRoutingRules": "", "subRoutingRules": "",
"subHideSettings": "false", "subHideSettings": "false",
"subIncyEnableRouting": "false", "subIncyEnableRouting": "false",
"subIncyRoutingRules": "", "subIncyRoutingRules": "",
"subListen": "", "subListen": "",
"subPort": "2096", "subPort": "2096",
"subPath": "/sub/", "subPath": "/sub/",
"subDomain": "", "subDomain": "",
"subCertFile": "", "subCertFile": "",
"subKeyFile": "", "subKeyFile": "",
"subUpdates": "12", "subUpdates": "12",
"subEncrypt": "true", "subEncrypt": "true",
"subURI": "", "subURI": "",
"subJsonPath": "/json/", "subJsonPath": "/json/",
"subJsonURI": "", "subJsonURI": "",
"subClashEnable": "false", "subClashEnable": "false",
"subClashPath": "/clash/", "subClashPath": "/clash/",
"subClashURI": "", "subClashURI": "",
"subClashEnableRouting": "false", "subClashEnableRouting": "false",
"subClashRules": "", "subClashRules": "",
"subJsonMux": "", "subJsonMux": "",
"subJsonRules": "", "subJsonRules": "",
"subJsonFinalMask": "", "subJsonFinalMask": "",
"subJsonObservatory": "", "subJsonObservatory": "",
"subThemeDir": "", "subThemeDir": "",
"datepicker": "gregorian", "datepicker": "gregorian",
"warp": "", "warp": "",
"warpUpdateInterval": "0", "warpUpdateInterval": "0",
"nord": "", "nord": "",
"pia": "", "pia": "",
"restartXrayOnClientDisable": "true", "restartXrayOnClientDisable": "true",
"xrayOutboundTestUrl": "https://www.google.com/generate_204", "xrayOutboundTestUrl": "https://www.google.com/generate_204",
"panelOutbound": "", "panelOutbound": "",
"devChannelEnable": "false", "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",
// Event bus — per-subscriber event filtering (empty = all disabled) // Event bus — per-subscriber event filtering (empty = all disabled)
"tgEnabledEvents": "login.attempt,cpu.high", "tgEnabledEvents": "login.attempt,cpu.high",
@@ -270,7 +247,6 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
view := &entity.AllSettingView{AllSetting: *allSetting} view := &entity.AllSettingView{AllSetting: *allSetting}
view.HasTgBotToken = secretConfigured(allSetting.TgBotToken) view.HasTgBotToken = secretConfigured(allSetting.TgBotToken)
view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken) view.HasTwoFactorToken = secretConfigured(allSetting.TwoFactorToken)
view.HasLdapPassword = secretConfigured(allSetting.LdapPassword)
view.HasWarpSecret = secretConfigured(mustString(s.GetWarp())) view.HasWarpSecret = secretConfigured(mustString(s.GetWarp()))
view.HasNordSecret = secretConfigured(mustString(s.GetNord())) view.HasNordSecret = secretConfigured(mustString(s.GetNord()))
view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword) view.HasSmtpPassword = secretConfigured(allSetting.SmtpPassword)
@@ -280,7 +256,6 @@ func (s *SettingService) GetAllSettingView() (*entity.AllSettingView, error) {
} }
view.TgBotToken = "" view.TgBotToken = ""
view.TwoFactorToken = "" view.TwoFactorToken = ""
view.LdapPassword = ""
view.SmtpPassword = "" view.SmtpPassword = ""
return view, nil return view, nil
} }
@@ -965,91 +940,6 @@ func (s *SettingService) GetAccessLogEnable() (bool, error) {
return (accessLogPath != "none" && accessLogPath != ""), nil 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 // Event bus — per-subscriber event filtering
func (s *SettingService) GetTgEnabledEvents() (string, error) { 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. // blank to the browser) and the stored value is preserved.
type SecretClears struct { type SecretClears struct {
TgBotToken bool TgBotToken bool
LdapPassword bool
SmtpPassword bool SmtpPassword bool
} }
@@ -1278,13 +1167,6 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting,
} }
allSetting.TgBotToken = value 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) == "" { if allSetting.TwoFactorEnable && strings.TrimSpace(allSetting.TwoFactorToken) == "" {
value, err := s.GetTwoFactorToken() value, err := s.GetTwoFactorToken()
if err != nil { if err != nil {
@@ -1341,7 +1223,7 @@ func validateRemoteRoutingURLSetting(name string, value *string) error {
func (s *SettingService) UpdateSecret(key string, value string) error { func (s *SettingService) UpdateSecret(key string, value string) error {
switch key { switch key {
case "tgBotToken", "ldapPassword", "twoFactorToken": case "tgBotToken", "twoFactorToken":
return s.saveSetting(key, strings.TrimSpace(value)) return s.saveSetting(key, strings.TrimSpace(value))
default: default:
return common.NewError("secret key is not replaceable:", key) 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{ var factoryDefaultSecretKeys = map[string]bool{
"tgBotToken": true, "tgBotToken": true,
"twoFactorToken": true, "twoFactorToken": true,
"ldapPassword": true,
"smtpPassword": true, "smtpPassword": true,
} }
@@ -56,7 +56,6 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
"xrayTemplateConfig", "xrayTemplateConfig",
"tgBotToken", "tgBotToken",
"twoFactorToken", "twoFactorToken",
"ldapPassword",
"smtpPassword", "smtpPassword",
} { } {
t.Run(key, func(t *testing.T) { t.Run(key, func(t *testing.T) {
-11
View File
@@ -372,17 +372,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) {
// Check monthly reset days at midnight // Check monthly reset days at midnight
_, _ = s.cron.AddJob("@daily", job.NewPeriodicTrafficResetJob("monthly", loc)) _, _ = 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. // Telegram-bot–dependent jobs: periodic stats report + callback-hash cleanup.
isTgbotenabled, err := s.settingService.GetTgbotEnabled() isTgbotenabled, err := s.settingService.GetTgbotEnabled()
if (err == nil) && isTgbotenabled { if (err == nil) && isTgbotenabled {