diff options
| author | konstpic <156318483+konstpic@users.noreply.github.com> | 2025-09-28 22:00:16 +0300 |
|---|---|---|
| committer | mhsanaei <ho3ein.sanaei@gmail.com> | 2025-09-28 22:04:54 +0300 |
| commit | 28a17a80ec0c4a0f82e8acfca351651d762b3ec9 (patch) | |
| tree | 7902b7b4cba04bce816ad17c9490f7228574a096 | |
| parent | 30565833889171afe5c934f97bc0e767534e8310 (diff) | |
feat: add ldap component (#3568)
* add ldap component
* fix: fix russian comments, tls cert verify default true
* feat: remove replaces go mod for local dev
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 24 | ||||
| -rw-r--r-- | util/ldap/ldap.go | 144 | ||||
| -rw-r--r-- | web/assets/js/model/setting.js | 22 | ||||
| -rw-r--r-- | web/assets/js/util/index.js | 24 | ||||
| -rw-r--r-- | web/entity/entity.go | 26 | ||||
| -rw-r--r-- | web/html/settings.html | 23 | ||||
| -rw-r--r-- | web/html/settings/panel/general.html | 130 | ||||
| -rw-r--r-- | web/job/ldap_sync_job.go | 393 | ||||
| -rw-r--r-- | web/service/inbound.go | 17 | ||||
| -rw-r--r-- | web/service/setting.go | 102 | ||||
| -rw-r--r-- | web/service/user.go | 37 | ||||
| -rw-r--r-- | web/web.go | 12 |
13 files changed, 932 insertions, 25 deletions
@@ -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 @@ -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 @@ </template> </a-setting-list-item> </a-collapse-panel> + <a-collapse-panel key="6" header='LDAP'> + <a-setting-list-item paddings="small"> + <template #title>Enable LDAP sync</template> + <template #control> + <a-switch v-model="allSetting.ldapEnable"></a-switch> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>LDAP Host</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapHost"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>LDAP Port</template> + <template #control> + <a-input-number :min="1" :max="65535" v-model="allSetting.ldapPort" :style="{ width: '100%' }"></a-input-number> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Use TLS (LDAPS)</template> + <template #control> + <a-switch v-model="allSetting.ldapUseTLS"></a-switch> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Bind DN</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapBindDN"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Password</template> + <template #control> + <a-input type="password" v-model="allSetting.ldapPassword"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Base DN</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapBaseDN"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>User filter</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapUserFilter"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>User attribute (username/email)</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapUserAttr"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>VLESS flag attribute</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapVlessField"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Generic flag attribute (optional)</template> + <template #description>If set, overrides VLESS flag; e.g. shadowInactive</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapFlagField"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Truthy values</template> + <template #description>Comma-separated; default: true,1,yes,on</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapTruthyValues"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Invert flag</template> + <template #description>Enable when attribute means disabled (e.g., shadowInactive)</template> + <template #control> + <a-switch v-model="allSetting.ldapInvertFlag"></a-switch> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Sync schedule</template> + <template #description>cron-like string, e.g. @every 1m</template> + <template #control> + <a-input type="text" v-model="allSetting.ldapSyncCron"></a-input> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Inbound tags</template> + <template #description>Select inbounds to manage (auto create/delete)</template> + <template #control> + <a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }" v-model="ldapInboundTagList"> + <a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">[[ opt.label ]]</a-select-option> + </a-select> + <div v-if="inboundOptions.length==0" style="margin-top:6px;color:#999">No inbounds found. Please create one in Inbounds.</div> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Auto create clients</template> + <template #control> + <a-switch v-model="allSetting.ldapAutoCreate"></a-switch> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Auto delete clients</template> + <template #control> + <a-switch v-model="allSetting.ldapAutoDelete"></a-switch> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Default total (GB)</template> + <template #control> + <a-input-number :min="0" v-model="allSetting.ldapDefaultTotalGB" :style="{ width: '100%' }"></a-input-number> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Default expiry (days)</template> + <template #control> + <a-input-number :min="0" v-model="allSetting.ldapDefaultExpiryDays" :style="{ width: '100%' }"></a-input-number> + </template> + </a-setting-list-item> + <a-setting-list-item paddings="small"> + <template #title>Default Limit IP</template> + <template #control> + <a-input-number :min="0" v-model="allSetting.ldapDefaultLimitIP" :style="{ width: '100%' }"></a-input-number> + </template> + </a-setting-list-item> + </a-collapse-panel> </a-collapse> {{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 сериализует массив клиентов
|
