diff --git a/go.mod b/go.mod index daf1d537..d77c23cc 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-contrib/gzip v1.2.3 github.com/gin-contrib/sessions v1.0.4 github.com/gin-gonic/gin v1.11.0 + github.com/go-ldap/ldap/v3 v3.4.11 github.com/goccy/go-json v0.10.5 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -29,6 +30,7 @@ require ( ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.14.1 // indirect @@ -39,6 +41,7 @@ require ( github.com/ebitengine/purego v0.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect diff --git a/go.sum b/go.sum index a4610d2b..1cae2aae 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -33,6 +37,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= +github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU= +github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -75,6 +83,20 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -234,8 +256,6 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M= google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= diff --git a/util/ldap/ldap.go b/util/ldap/ldap.go new file mode 100644 index 00000000..1c7a20e7 --- /dev/null +++ b/util/ldap/ldap.go @@ -0,0 +1,144 @@ +package ldaputil + +import ( + "crypto/tls" + "fmt" + + "github.com/go-ldap/ldap/v3" +) + +type Config struct { + Host string + Port int + UseTLS bool + BindDN string + Password string + BaseDN string + UserFilter string + UserAttr string + FlagField string + TruthyVals []string + Invert bool +} + +// FetchVlessFlags returns map[email]enabled +func FetchVlessFlags(cfg Config) (map[string]bool, error) { + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + var conn *ldap.Conn + var err error + if cfg.UseTLS { + conn, err = ldap.DialTLS("tcp", addr, &tls.Config{InsecureSkipVerify: false}) + } else { + conn, err = ldap.Dial("tcp", addr) + } + 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 := false + for _, t := range cfg.TruthyVals { + if val == t { + enabled = true + break + } + } + 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) + var conn *ldap.Conn + var err error + if cfg.UseTLS { + conn, err = ldap.DialTLS("tcp", addr, &tls.Config{InsecureSkipVerify: false}) + } else { + conn, err = ldap.Dial("tcp", addr) + } + 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/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index daf03799..53ffae1a 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -50,6 +50,28 @@ class AllSetting { this.timeLocation = "Local"; + // LDAP settings + this.ldapEnable = false; + this.ldapHost = ""; + this.ldapPort = 389; + this.ldapUseTLS = false; + this.ldapBindDN = ""; + this.ldapPassword = ""; + this.ldapBaseDN = ""; + this.ldapUserFilter = "(objectClass=person)"; + this.ldapUserAttr = "mail"; + this.ldapVlessField = "vless_enabled"; + this.ldapSyncCron = "@every 1m"; + this.ldapFlagField = ""; + this.ldapTruthyValues = "true,1,yes,on"; + this.ldapInvertFlag = false; + this.ldapInboundTags = ""; + this.ldapAutoCreate = false; + this.ldapAutoDelete = false; + this.ldapDefaultTotalGB = 0; + this.ldapDefaultExpiryDays = 0; + this.ldapDefaultLimitIP = 0; + if (data == null) { return } diff --git a/web/assets/js/util/index.js b/web/assets/js/util/index.js index bb47f538..902974f0 100644 --- a/web/assets/js/util/index.js +++ b/web/assets/js/util/index.js @@ -316,23 +316,13 @@ class ObjectUtil { } static equals(a, b) { - for (const key in a) { - if (!a.hasOwnProperty(key)) { - continue; - } - if (!b.hasOwnProperty(key)) { - return false; - } else if (a[key] !== b[key]) { - return false; - } - } - for (const key in b) { - if (!b.hasOwnProperty(key)) { - continue; - } - if (!a.hasOwnProperty(key)) { - return false; - } + // shallow, symmetric comparison so newly added fields also affect equality + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + if (!Object.prototype.hasOwnProperty.call(b, key)) return false; + if (a[key] !== b[key]) return false; } return true; } diff --git a/web/entity/entity.go b/web/entity/entity.go index adb60972..de054e2b 100644 --- a/web/entity/entity.go +++ b/web/entity/entity.go @@ -74,7 +74,31 @@ type AllSetting struct { SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration - SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` // JSON subscription routing rules + SubJsonRules string `json:"subJsonRules" form:"subJsonRules"` + + // LDAP settings + LdapEnable bool `json:"ldapEnable" form:"ldapEnable"` + LdapHost string `json:"ldapHost" form:"ldapHost"` + LdapPort int `json:"ldapPort" form:"ldapPort"` + LdapUseTLS bool `json:"ldapUseTLS" form:"ldapUseTLS"` + 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"` // e.g., mail or uid + LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"` + LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"` + // Generic flag configuration + 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"` + LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays"` + LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP"` + // JSON subscription routing rules } // CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values. diff --git a/web/html/settings.html b/web/html/settings.html index 22ad3907..26b936fa 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -119,6 +119,7 @@ saveBtnDisable: true, user: {}, lang: LanguageManager.getLanguage(), + inboundOptions: [], remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' }, remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'], datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }], @@ -242,6 +243,17 @@ this.saveBtnDisable = true; } }, + async loadInboundTags() { + const msg = await HttpUtil.get("/panel/api/inbounds/list"); + if (msg && msg.success && Array.isArray(msg.obj)) { + this.inboundOptions = msg.obj.map(ib => ({ + label: `${ib.tag} (${ib.protocol}@${ib.port})`, + value: ib.tag, + })); + } else { + this.inboundOptions = []; + } + }, async updateAllSetting() { this.loading(true); const msg = await HttpUtil.post("/panel/setting/update", this.allSetting); @@ -368,6 +380,15 @@ }, }, computed: { + ldapInboundTagList: { + get: function() { + const csv = this.allSetting.ldapInboundTags || ""; + return csv.length ? csv.split(',').map(s => s.trim()).filter(Boolean) : []; + }, + set: function(list) { + this.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : ''; + } + }, fragment: { get: function () { return this.allSetting?.subJsonFragment != ""; }, set: function (v) { @@ -534,7 +555,7 @@ }, async mounted() { await this.getAllSetting(); - + await this.loadInboundTags(); while (true) { await PromiseUtil.sleep(1000); this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting); diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html index 64fd050c..6969a1b4 100644 --- a/web/html/settings/panel/general.html +++ b/web/html/settings/panel/general.html @@ -146,5 +146,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{end}} \ No newline at end of file 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() +} + + diff --git a/web/service/inbound.go b/web/service/inbound.go index 448e6832..93414801 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1569,6 +1569,23 @@ func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bo return !clientOldEnabled, needRestart, nil } + +// SetClientEnableByEmail sets client enable state to desired value; returns (changed, needRestart, error) +func (s *InboundService) SetClientEnableByEmail(clientEmail string, enable bool) (bool, bool, error) { + current, err := s.checkIsEnabledByEmail(clientEmail) + if err != nil { + return false, false, err + } + if current == enable { + return false, false, nil + } + newEnabled, needRestart, err := s.ToggleClientEnableByEmail(clientEmail) + if err != nil { + return false, needRestart, err + } + return newEnabled == enable, needRestart, nil +} + func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) { _, inbound, err := s.GetClientInboundByEmail(clientEmail) if err != nil { diff --git a/web/service/setting.go b/web/service/setting.go index fc6513c5..fa85d58c 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -73,6 +73,27 @@ var defaultValueMap = map[string]string{ "warp": "", "externalTrafficInformEnable": "false", "externalTrafficInformURI": "", + // LDAP defaults + "ldapEnable": "false", + "ldapHost": "", + "ldapPort": "389", + "ldapUseTLS": "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", } // SettingService provides business logic for application settings management. @@ -542,6 +563,87 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) { return (accessLogPath != "none" && accessLogPath != ""), nil } +// LDAP exported getters +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) 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") +} + func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error { if err := allSetting.CheckValid(); err != nil { return err diff --git a/web/service/user.go b/web/service/user.go index f42c3cf8..87c46bf2 100644 --- a/web/service/user.go +++ b/web/service/user.go @@ -7,7 +7,7 @@ import ( "github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/logger" "github.com/mhsanaei/3x-ui/v2/util/crypto" - + ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap" "github.com/xlzd/gotp" "gorm.io/gorm" ) @@ -49,9 +49,38 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode return nil } - if !crypto.CheckPasswordHash(user.Password, password) { - return nil - } + // If LDAP enabled and local password check fails, attempt LDAP auth + if !crypto.CheckPasswordHash(user.Password, password) { + ldapEnabled, _ := s.settingService.GetLdapEnable() + if !ldapEnabled { + return nil + } + + host, _ := s.settingService.GetLdapHost() + port, _ := s.settingService.GetLdapPort() + useTLS, _ := s.settingService.GetLdapUseTLS() + 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, + BindDN: bindDN, + Password: ldapPass, + BaseDN: baseDN, + UserFilter: userFilter, + UserAttr: userAttr, + } + ok, err := ldaputil.AuthenticateUser(cfg, username, password) + if err != nil || !ok { + return nil + } + // On successful LDAP auth, continue 2FA checks below + } twoFactorEnable, err := s.settingService.GetTwoFactorEnable() if err != nil { diff --git a/web/web.go b/web/web.go index 3a1a33b0..9080f899 100644 --- a/web/web.go +++ b/web/web.go @@ -314,6 +314,18 @@ func (s *Server) startTask() { // Run once a month, midnight, first of month s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly")) + // 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) + } + + // Make a traffic condition every day, 8:30 var entry cron.EntryID isTgbotenabled, err := s.settingService.GetTgbotEnabled()