mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-10-13 19:49:12 +00:00
Compare commits
12 commits
51805ed17d
...
7f3e2828a2
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7f3e2828a2 | ||
![]() |
28a17a80ec | ||
![]() |
3b262cf180 | ||
![]() |
4c7249c451 | ||
![]() |
edd8b12988 | ||
![]() |
5e953bae45 | ||
![]() |
747af376f2 | ||
![]() |
a3ccccfe52 | ||
![]() |
3299d15f28 | ||
![]() |
ae82373457 | ||
![]() |
d65233cc2c | ||
![]() |
11dc06863e |
27 changed files with 1556 additions and 54 deletions
|
@ -37,6 +37,7 @@ func initModels() error {
|
||||||
&model.InboundClientIps{},
|
&model.InboundClientIps{},
|
||||||
&xray.ClientTraffic{},
|
&xray.ClientTraffic{},
|
||||||
&model.HistoryOfSeeders{},
|
&model.HistoryOfSeeders{},
|
||||||
|
&model.Server{},
|
||||||
}
|
}
|
||||||
for _, model := range models {
|
for _, model := range models {
|
||||||
if err := db.AutoMigrate(model); err != nil {
|
if err := db.AutoMigrate(model); err != nil {
|
||||||
|
|
|
@ -119,3 +119,12 @@ type Client struct {
|
||||||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Name string `json:"name" gorm:"unique;not null"`
|
||||||
|
Address string `json:"address" gorm:"not null"`
|
||||||
|
Port int `json:"port" gorm:"not null"`
|
||||||
|
APIKey string `json:"apiKey" gorm:"not null"`
|
||||||
|
Enable bool `json:"enable" gorm:"default:true"`
|
||||||
|
}
|
||||||
|
|
4
go.mod
4
go.mod
|
@ -6,6 +6,7 @@ require (
|
||||||
github.com/gin-contrib/gzip v1.2.3
|
github.com/gin-contrib/gzip v1.2.3
|
||||||
github.com/gin-contrib/sessions v1.0.4
|
github.com/gin-contrib/sessions v1.0.4
|
||||||
github.com/gin-gonic/gin v1.11.0
|
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/goccy/go-json v0.10.5
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
|
@ -29,6 +30,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.14.1 // indirect
|
github.com/bytedance/sonic v1.14.1 // indirect
|
||||||
|
@ -39,6 +41,7 @@ require (
|
||||||
github.com/ebitengine/purego v0.9.0 // indirect
|
github.com/ebitengine/purego v0.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // 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-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
@ -65,6 +68,7 @@ require (
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pires/go-proxyproto v0.8.1 // indirect
|
github.com/pires/go-proxyproto v0.8.1 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
|
24
go.sum
24
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 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
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 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
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-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 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
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 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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/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 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
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=
|
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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
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 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
|
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=
|
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
|
||||||
|
|
|
@ -140,6 +140,13 @@ config_after_install() {
|
||||||
fi
|
fi
|
||||||
|
|
||||||
/usr/local/x-ui/x-ui migrate
|
/usr/local/x-ui/x-ui migrate
|
||||||
|
|
||||||
|
local existing_apiKey=$(/usr/local/x-ui/x-ui setting -show true | grep -oP 'ApiKey: \K.*')
|
||||||
|
if [[ -z "$existing_apiKey" ]]; then
|
||||||
|
local config_apiKey=$(gen_random_string 32)
|
||||||
|
/usr/local/x-ui/x-ui setting -apiKey "${config_apiKey}"
|
||||||
|
echo -e "${green}Generated random API Key: ${config_apiKey}${plain}"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
install_x-ui() {
|
install_x-ui() {
|
||||||
|
|
16
main.go
16
main.go
|
@ -240,7 +240,8 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||||
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
|
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool, apiKey string) {
|
||||||
|
|
||||||
err := database.InitDB(config.GetDBPath())
|
err := database.InitDB(config.GetDBPath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Database initialization failed:", err)
|
fmt.Println("Database initialization failed:", err)
|
||||||
|
@ -250,6 +251,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
||||||
settingService := service.SettingService{}
|
settingService := service.SettingService{}
|
||||||
userService := service.UserService{}
|
userService := service.UserService{}
|
||||||
|
|
||||||
|
if apiKey != "" {
|
||||||
|
err := settingService.SetAPIKey(apiKey)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Failed to set API Key:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("API Key set successfully: %v\n", apiKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if port > 0 {
|
if port > 0 {
|
||||||
err := settingService.SetPort(port)
|
err := settingService.SetPort(port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -402,9 +412,11 @@ func main() {
|
||||||
var show bool
|
var show bool
|
||||||
var getCert bool
|
var getCert bool
|
||||||
var resetTwoFactor bool
|
var resetTwoFactor bool
|
||||||
|
var apiKey string
|
||||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||||
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
settingCmd.IntVar(&port, "port", 0, "Set panel port number")
|
||||||
|
settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key")
|
||||||
settingCmd.StringVar(&username, "username", "", "Set login username")
|
settingCmd.StringVar(&username, "username", "", "Set login username")
|
||||||
settingCmd.StringVar(&password, "password", "", "Set login password")
|
settingCmd.StringVar(&password, "password", "", "Set login password")
|
||||||
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
||||||
|
@ -454,7 +466,7 @@ func main() {
|
||||||
if reset {
|
if reset {
|
||||||
resetSetting()
|
resetSetting()
|
||||||
} else {
|
} else {
|
||||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
|
||||||
}
|
}
|
||||||
if show {
|
if show {
|
||||||
showSetting(show)
|
showSetting(show)
|
||||||
|
|
|
@ -162,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||||
|
serverService := service.MultiServerService{}
|
||||||
|
servers, err := serverService.GetServers()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Failed to get servers for subscription:", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var links []string
|
||||||
|
for _, server := range servers {
|
||||||
|
if !server.Enable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var link string
|
||||||
switch inbound.Protocol {
|
switch inbound.Protocol {
|
||||||
case "vmess":
|
case "vmess":
|
||||||
return s.genVmessLink(inbound, email)
|
link = s.genVmessLink(inbound, email, server)
|
||||||
case "vless":
|
case "vless":
|
||||||
return s.genVlessLink(inbound, email)
|
link = s.genVlessLink(inbound, email, server)
|
||||||
case "trojan":
|
case "trojan":
|
||||||
return s.genTrojanLink(inbound, email)
|
link = s.genTrojanLink(inbound, email, server)
|
||||||
case "shadowsocks":
|
case "shadowsocks":
|
||||||
return s.genShadowsocksLink(inbound, email)
|
link = s.genShadowsocksLink(inbound, email, server)
|
||||||
}
|
}
|
||||||
return ""
|
if link != "" {
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(links, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||||
if inbound.Protocol != model.VMESS {
|
if inbound.Protocol != model.VMESS {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
obj := map[string]any{
|
obj := map[string]any{
|
||||||
"v": "2",
|
"v": "2",
|
||||||
"add": s.address,
|
"add": server.Address,
|
||||||
"port": inbound.Port,
|
"port": inbound.Port,
|
||||||
"type": "none",
|
"type": "none",
|
||||||
}
|
}
|
||||||
|
@ -294,7 +311,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
newObj[key] = value
|
newObj[key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string))
|
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
|
||||||
newObj["add"] = ep["dest"].(string)
|
newObj["add"] = ep["dest"].(string)
|
||||||
newObj["port"] = int(ep["port"].(float64))
|
newObj["port"] = int(ep["port"].(float64))
|
||||||
|
|
||||||
|
@ -310,14 +327,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
obj["ps"] = s.genRemark(inbound, email, "")
|
obj["ps"] = s.genRemark(inbound, email, "", server.Name)
|
||||||
|
|
||||||
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
||||||
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||||
address := s.address
|
address := server.Address
|
||||||
if inbound.Protocol != model.VLESS {
|
if inbound.Protocol != model.VLESS {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -497,7 +514,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
|
||||||
|
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
|
@ -518,12 +535,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||||
return url.String()
|
return url.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||||
address := s.address
|
address := server.Address
|
||||||
if inbound.Protocol != model.Trojan {
|
if inbound.Protocol != model.Trojan {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -692,7 +709,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
|
||||||
|
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
|
@ -714,12 +731,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||||
return url.String()
|
return url.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||||
address := s.address
|
address := server.Address
|
||||||
if inbound.Protocol != model.Shadowsocks {
|
if inbound.Protocol != model.Shadowsocks {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
@ -859,7 +876,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string))
|
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
|
||||||
|
|
||||||
if index > 0 {
|
if index > 0 {
|
||||||
links += "\n"
|
links += "\n"
|
||||||
|
@ -880,17 +897,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
// Set the new query values on the URL
|
// Set the new query values on the URL
|
||||||
url.RawQuery = q.Encode()
|
url.RawQuery = q.Encode()
|
||||||
|
|
||||||
url.Fragment = s.genRemark(inbound, email, "")
|
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||||
return url.String()
|
return url.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string {
|
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, serverName string) string {
|
||||||
separationChar := string(s.remarkModel[0])
|
separationChar := string(s.remarkModel[0])
|
||||||
orderChars := s.remarkModel[1:]
|
orderChars := s.remarkModel[1:]
|
||||||
orders := map[byte]string{
|
orders := map[byte]string{
|
||||||
'i': "",
|
'i': "",
|
||||||
'e': "",
|
'e': "",
|
||||||
'o': "",
|
'o': "",
|
||||||
|
's': "",
|
||||||
}
|
}
|
||||||
if len(email) > 0 {
|
if len(email) > 0 {
|
||||||
orders['e'] = email
|
orders['e'] = email
|
||||||
|
@ -901,6 +919,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
||||||
if len(extra) > 0 {
|
if len(extra) > 0 {
|
||||||
orders['o'] = extra
|
orders['o'] = extra
|
||||||
}
|
}
|
||||||
|
if len(serverName) > 0 {
|
||||||
|
orders['s'] = serverName
|
||||||
|
}
|
||||||
|
|
||||||
var remark []string
|
var remark []string
|
||||||
for i := 0; i < len(orderChars); i++ {
|
for i := 0; i < len(orderChars); i++ {
|
||||||
|
|
144
util/ldap/ldap.go
Normal file
144
util/ldap/ldap.go
Normal file
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,6 +50,28 @@ class AllSetting {
|
||||||
|
|
||||||
this.timeLocation = "Local";
|
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) {
|
if (data == null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -316,23 +316,13 @@ class ObjectUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
static equals(a, b) {
|
static equals(a, b) {
|
||||||
for (const key in a) {
|
// shallow, symmetric comparison so newly added fields also affect equality
|
||||||
if (!a.hasOwnProperty(key)) {
|
const aKeys = Object.keys(a);
|
||||||
continue;
|
const bKeys = Object.keys(b);
|
||||||
}
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
if (!b.hasOwnProperty(key)) {
|
for (const key of aKeys) {
|
||||||
return false;
|
if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
|
||||||
} else if (a[key] !== b[key]) {
|
if (a[key] !== b[key]) return false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key in b) {
|
|
||||||
if (!b.hasOwnProperty(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!a.hasOwnProperty(key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||||
|
|
89
web/controller/multi_server_controller.go
Normal file
89
web/controller/multi_server_controller.go
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"x-ui/database/model"
|
||||||
|
"x-ui/web/service"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MultiServerController struct {
|
||||||
|
multiServerService service.MultiServerService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMultiServerController(g *gin.RouterGroup) *MultiServerController {
|
||||||
|
c := &MultiServerController{}
|
||||||
|
c.initRouter(g)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MultiServerController) initRouter(g *gin.RouterGroup) {
|
||||||
|
g = g.Group("/server")
|
||||||
|
|
||||||
|
g.GET("/list", c.getServers)
|
||||||
|
g.POST("/add", c.addServer)
|
||||||
|
g.POST("/del/:id", c.delServer)
|
||||||
|
g.POST("/update/:id", c.updateServer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MultiServerController) getServers(ctx *gin.Context) {
|
||||||
|
servers, err := c.multiServerService.GetServers()
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Failed to get servers", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonObj(ctx, servers, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MultiServerController) addServer(ctx *gin.Context) {
|
||||||
|
server := &model.Server{}
|
||||||
|
err := ctx.ShouldBind(server)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Invalid data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = c.multiServerService.AddServer(server)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Failed to add server", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(ctx, "Server added successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MultiServerController) delServer(ctx *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Invalid ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = c.multiServerService.DeleteServer(id)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Failed to delete server", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(ctx, "Server deleted successfully", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *MultiServerController) updateServer(ctx *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(ctx.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Invalid ID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server := &model.Server{
|
||||||
|
Id: id,
|
||||||
|
}
|
||||||
|
err = ctx.ShouldBind(server)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Invalid data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = c.multiServerService.UpdateServer(server)
|
||||||
|
if err != nil {
|
||||||
|
jsonMsg(ctx, "Failed to update server", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonMsg(ctx, "Server updated successfully", nil)
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
||||||
|
|
||||||
g.GET("/", a.index)
|
g.GET("/", a.index)
|
||||||
g.GET("/inbounds", a.inbounds)
|
g.GET("/inbounds", a.inbounds)
|
||||||
|
g.GET("/servers", a.servers)
|
||||||
g.GET("/settings", a.settings)
|
g.GET("/settings", a.settings)
|
||||||
g.GET("/xray", a.xraySettings)
|
g.GET("/xray", a.xraySettings)
|
||||||
|
|
||||||
|
@ -52,3 +53,7 @@ func (a *XUIController) settings(c *gin.Context) {
|
||||||
func (a *XUIController) xraySettings(c *gin.Context) {
|
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||||
html(c, "xray.html", "pages.xray.title", nil)
|
html(c, "xray.html", "pages.xray.title", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *XUIController) servers(c *gin.Context) {
|
||||||
|
html(c, "servers.html", "Servers", nil)
|
||||||
|
}
|
||||||
|
|
|
@ -74,7 +74,31 @@ type AllSetting struct {
|
||||||
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
SubJsonFragment string `json:"subJsonFragment" form:"subJsonFragment"` // JSON subscription fragment configuration
|
||||||
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
SubJsonNoises string `json:"subJsonNoises" form:"subJsonNoises"` // JSON subscription noise configuration
|
||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux 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.
|
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||||
|
|
|
@ -54,6 +54,11 @@
|
||||||
icon: 'user',
|
icon: 'user',
|
||||||
title: '{{ i18n "menu.inbounds"}}'
|
title: '{{ i18n "menu.inbounds"}}'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '{{ .base_path }}panel/servers',
|
||||||
|
icon: 'cloud-server',
|
||||||
|
title: 'Servers'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '{{ .base_path }}panel/settings',
|
key: '{{ .base_path }}panel/settings',
|
||||||
icon: 'setting',
|
icon: 'setting',
|
||||||
|
|
165
web/html/servers.html
Normal file
165
web/html/servers.html
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
{{template "header" .}}
|
||||||
|
|
||||||
|
<div id="app" class="row" v-cloak>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Server Management</h3>
|
||||||
|
<div class="card-tools">
|
||||||
|
<button class="btn btn-primary" @click="showAddModal">Add Server</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table table-bordered">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Address</th>
|
||||||
|
<th>Port</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(server, index) in servers">
|
||||||
|
<td>{{index + 1}}</td>
|
||||||
|
<td>{{server.name}}</td>
|
||||||
|
<td>{{server.address}}</td>
|
||||||
|
<td>{{server.port}}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="server.enable" class="badge bg-success">Yes</span>
|
||||||
|
<span v-else class="badge bg-danger">No</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-info btn-sm" @click="showEditModal(server)">Edit</button>
|
||||||
|
<button class="btn btn-danger btn-sm" @click="deleteServer(server.id)">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Modal -->
|
||||||
|
<div class="modal fade" id="serverModal" tabindex="-1" role="dialog">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">{{modal.title}}</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input type="text" class="form-control" v-model="modal.server.name">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Address (IP or Domain)</label>
|
||||||
|
<input type="text" class="form-control" v-model="modal.server.address">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Port</label>
|
||||||
|
<input type="number" class="form-control" v-model.number="modal.server.port">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="text" class="form-control" v-model="modal.server.apiKey">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" v-model="modal.server.enable">
|
||||||
|
<label class="form-check-label">Enabled</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="saveServer">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const app = new Vue({
|
||||||
|
el: '#app',
|
||||||
|
data: {
|
||||||
|
servers: [],
|
||||||
|
modal: {
|
||||||
|
title: '',
|
||||||
|
server: {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
port: 0,
|
||||||
|
apiKey: '',
|
||||||
|
enable: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
loadServers() {
|
||||||
|
axios.get('{{.base_path}}server/list')
|
||||||
|
.then(response => {
|
||||||
|
this.servers = response.data.obj;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(error.response.data.msg);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
showAddModal() {
|
||||||
|
this.modal.title = 'Add Server';
|
||||||
|
this.modal.server = {
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
port: 0,
|
||||||
|
apiKey: '',
|
||||||
|
enable: true
|
||||||
|
};
|
||||||
|
$('#serverModal').modal('show');
|
||||||
|
},
|
||||||
|
showEditModal(server) {
|
||||||
|
this.modal.title = 'Edit Server';
|
||||||
|
this.modal.server = Object.assign({}, server);
|
||||||
|
$('#serverModal').modal('show');
|
||||||
|
},
|
||||||
|
saveServer() {
|
||||||
|
let url = '{{.base_path}}server/add';
|
||||||
|
if (this.modal.server.id) {
|
||||||
|
url = `{{.base_path}}server/update/${this.modal.server.id}`;
|
||||||
|
}
|
||||||
|
axios.post(url, this.modal.server)
|
||||||
|
.then(response => {
|
||||||
|
alert(response.data.msg);
|
||||||
|
$('#serverModal').modal('hide');
|
||||||
|
this.loadServers();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(error.response.data.msg);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
deleteServer(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this server?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
axios.post(`{{.base_path}}server/del/${id}`)
|
||||||
|
.then(response => {
|
||||||
|
alert(response.data.msg);
|
||||||
|
this.loadServers();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert(error.response.data.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.loadServers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{{template "footer" .}}
|
|
@ -119,6 +119,7 @@
|
||||||
saveBtnDisable: true,
|
saveBtnDisable: true,
|
||||||
user: {},
|
user: {},
|
||||||
lang: LanguageManager.getLanguage(),
|
lang: LanguageManager.getLanguage(),
|
||||||
|
inboundOptions: [],
|
||||||
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
|
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
|
||||||
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
|
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
|
||||||
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
||||||
|
@ -242,6 +243,17 @@
|
||||||
this.saveBtnDisable = true;
|
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() {
|
async updateAllSetting() {
|
||||||
this.loading(true);
|
this.loading(true);
|
||||||
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
|
const msg = await HttpUtil.post("/panel/setting/update", this.allSetting);
|
||||||
|
@ -368,6 +380,15 @@
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
computed: {
|
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: {
|
fragment: {
|
||||||
get: function () { return this.allSetting?.subJsonFragment != ""; },
|
get: function () { return this.allSetting?.subJsonFragment != ""; },
|
||||||
set: function (v) {
|
set: function (v) {
|
||||||
|
@ -534,7 +555,7 @@
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.getAllSetting();
|
await this.getAllSetting();
|
||||||
|
await this.loadInboundTags();
|
||||||
while (true) {
|
while (true) {
|
||||||
await PromiseUtil.sleep(1000);
|
await PromiseUtil.sleep(1000);
|
||||||
this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting);
|
this.saveBtnDisable = this.oldAllSetting.equals(this.allSetting);
|
||||||
|
|
|
@ -146,5 +146,135 @@
|
||||||
</template>
|
</template>
|
||||||
</a-setting-list-item>
|
</a-setting-list-item>
|
||||||
</a-collapse-panel>
|
</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>
|
</a-collapse>
|
||||||
{{end}}
|
{{end}}
|
393
web/job/ldap_sync_job.go
Normal file
393
web/job/ldap_sync_job.go
Normal file
|
@ -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()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
34
web/middleware/auth.go
Normal file
34
web/middleware/auth.go
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"x-ui/web/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ApiAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
apiKey := c.GetHeader("Api-Key")
|
||||||
|
if apiKey == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key is required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settingService := service.SettingService{}
|
||||||
|
panelAPIKey, err := settingService.GetAPIKey()
|
||||||
|
if err != nil || panelAPIKey == "" {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "API key not configured on the panel"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiKey != panelAPIKey {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,8 +3,11 @@
|
||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -673,6 +676,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
||||||
}
|
}
|
||||||
s.xrayApi.Close()
|
s.xrayApi.Close()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
body, _ := json.Marshal(data)
|
||||||
|
s.syncWithSlaves("POST", "/panel/inbound/api/addClient", bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
return needRestart, tx.Save(oldInbound).Error
|
return needRestart, tx.Save(oldInbound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -761,6 +769,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
||||||
s.xrayApi.Close()
|
s.xrayApi.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/%d/delClient/%s", inboundId, clientId), nil)
|
||||||
|
}
|
||||||
|
|
||||||
return needRestart, db.Save(oldInbound).Error
|
return needRestart, db.Save(oldInbound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -936,6 +949,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
||||||
logger.Debug("Client old email not found")
|
logger.Debug("Client old email not found")
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
body, _ := json.Marshal(data)
|
||||||
|
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/updateClient/%s", clientId), bytes.NewReader(body))
|
||||||
|
}
|
||||||
|
|
||||||
return needRestart, tx.Save(oldInbound).Error
|
return needRestart, tx.Save(oldInbound).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1569,6 +1588,23 @@ func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bo
|
||||||
return !clientOldEnabled, needRestart, nil
|
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) {
|
func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int) (bool, error) {
|
||||||
_, inbound, err := s.GetClientInboundByEmail(clientEmail)
|
_, inbound, err := s.GetClientInboundByEmail(clientEmail)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -2363,6 +2399,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
|
||||||
|
|
||||||
return validEmails, extraEmails, nil
|
return validEmails, extraEmails, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) syncWithSlaves(method string, path string, body io.Reader) {
|
||||||
|
serverService := MultiServerService{}
|
||||||
|
servers, err := serverService.GetServers()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Failed to get servers for syncing:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, server := range servers {
|
||||||
|
if !server.Enable {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("http://%s:%d%s", server.Address, server.Port, path)
|
||||||
|
req, err := http.NewRequest(method, url, body)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("Failed to create request for server %s: %v", server.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Api-Key", server.APIKey)
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warningf("Failed to send request to server %s: %v", server.Name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
logger.Warningf("Failed to sync with server %s. Status: %s, Body: %s", server.Name, resp.Status, string(bodyBytes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
|
func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
|
||||||
oldInbound, err := s.GetInbound(inboundId)
|
oldInbound, err := s.GetInbound(inboundId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -2454,4 +2528,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
||||||
}
|
}
|
||||||
|
|
||||||
return needRestart, db.Save(oldInbound).Error
|
return needRestart, db.Save(oldInbound).Error
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
72
web/service/inbound_service_sync_test.go
Normal file
72
web/service/inbound_service_sync_test.go
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"x-ui/database"
|
||||||
|
"x-ui/database/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInboundServiceSync(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
// Mock server to simulate a slave
|
||||||
|
var receivedApiKey string
|
||||||
|
var receivedBody []byte
|
||||||
|
mockSlave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedApiKey = r.Header.Get("Api-Key")
|
||||||
|
receivedBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer mockSlave.Close()
|
||||||
|
|
||||||
|
// Add the mock slave to the database
|
||||||
|
multiServerService := MultiServerService{}
|
||||||
|
mockSlaveURL, _ := url.Parse(mockSlave.URL)
|
||||||
|
mockSlavePort, _ := strconv.Atoi(mockSlaveURL.Port())
|
||||||
|
slaveServer := &model.Server{
|
||||||
|
Name: "mock-slave",
|
||||||
|
Address: mockSlaveURL.Hostname(),
|
||||||
|
Port: mockSlavePort,
|
||||||
|
APIKey: "slave-api-key",
|
||||||
|
Enable: true,
|
||||||
|
}
|
||||||
|
multiServerService.AddServer(slaveServer)
|
||||||
|
|
||||||
|
// Create a test inbound and client
|
||||||
|
inboundService := InboundService{}
|
||||||
|
db := database.GetDB()
|
||||||
|
testInbound := &model.Inbound{
|
||||||
|
UserId: 1,
|
||||||
|
Remark: "test-inbound",
|
||||||
|
Enable: true,
|
||||||
|
Settings: `{"clients":[]}`,
|
||||||
|
}
|
||||||
|
db.Create(testInbound)
|
||||||
|
|
||||||
|
clientData := model.Client{
|
||||||
|
Email: "test@example.com",
|
||||||
|
ID: "test-id",
|
||||||
|
}
|
||||||
|
clientBytes, _ := json.Marshal([]model.Client{clientData})
|
||||||
|
inboundData := &model.Inbound{
|
||||||
|
Id: testInbound.Id,
|
||||||
|
Settings: string(clientBytes),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test AddInboundClient sync
|
||||||
|
inboundService.AddInboundClient(inboundData)
|
||||||
|
|
||||||
|
assert.Equal(t, "slave-api-key", receivedApiKey)
|
||||||
|
var receivedInbound model.Inbound
|
||||||
|
json.Unmarshal(receivedBody, &receivedInbound)
|
||||||
|
assert.Equal(t, 1, receivedInbound.Id)
|
||||||
|
}
|
37
web/service/multi_server_service.go
Normal file
37
web/service/multi_server_service.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"x-ui/database"
|
||||||
|
"x-ui/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MultiServerService struct{}
|
||||||
|
|
||||||
|
func (s *MultiServerService) GetServers() ([]*model.Server, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var servers []*model.Server
|
||||||
|
err := db.Find(&servers).Error
|
||||||
|
return servers, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MultiServerService) GetServer(id int) (*model.Server, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var server model.Server
|
||||||
|
err := db.First(&server, id).Error
|
||||||
|
return &server, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MultiServerService) AddServer(server *model.Server) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Create(server).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MultiServerService) UpdateServer(server *model.Server) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Save(server).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *MultiServerService) DeleteServer(id int) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
return db.Delete(&model.Server{}, id).Error
|
||||||
|
}
|
63
web/service/multi_server_service_test.go
Normal file
63
web/service/multi_server_service_test.go
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"x-ui/database"
|
||||||
|
"x-ui/database/model"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup() {
|
||||||
|
dbPath := "test.db"
|
||||||
|
os.Remove(dbPath)
|
||||||
|
database.InitDB(dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func teardown() {
|
||||||
|
db, _ := database.GetDB().DB()
|
||||||
|
db.Close()
|
||||||
|
os.Remove("test.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMultiServerService(t *testing.T) {
|
||||||
|
setup()
|
||||||
|
defer teardown()
|
||||||
|
|
||||||
|
service := MultiServerService{}
|
||||||
|
|
||||||
|
// Test AddServer
|
||||||
|
server := &model.Server{
|
||||||
|
Name: "test-server",
|
||||||
|
Address: "127.0.0.1",
|
||||||
|
Port: 54321,
|
||||||
|
APIKey: "test-key",
|
||||||
|
Enable: true,
|
||||||
|
}
|
||||||
|
err := service.AddServer(server)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Test GetServer
|
||||||
|
retrievedServer, err := service.GetServer(server.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, server.Name, retrievedServer.Name)
|
||||||
|
|
||||||
|
// Test GetServers
|
||||||
|
servers, err := service.GetServers()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, servers, 1)
|
||||||
|
|
||||||
|
// Test UpdateServer
|
||||||
|
retrievedServer.Name = "updated-server"
|
||||||
|
err = service.UpdateServer(retrievedServer)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
updatedServer, _ := service.GetServer(server.Id)
|
||||||
|
assert.Equal(t, "updated-server", updatedServer.Name)
|
||||||
|
|
||||||
|
// Test DeleteServer
|
||||||
|
err = service.DeleteServer(server.Id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
_, err = service.GetServer(server.Id)
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
|
@ -73,6 +73,27 @@ var defaultValueMap = map[string]string{
|
||||||
"warp": "",
|
"warp": "",
|
||||||
"externalTrafficInformEnable": "false",
|
"externalTrafficInformEnable": "false",
|
||||||
"externalTrafficInformURI": "",
|
"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.
|
// SettingService provides business logic for application settings management.
|
||||||
|
@ -183,6 +204,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
||||||
return setting, nil
|
return setting, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetAPIKey() (string, error) {
|
||||||
|
setting, err := s.getSetting("ApiKey")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if setting == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return setting.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetAPIKey(apiKey string) error {
|
||||||
|
return s.saveSetting("ApiKey", apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) saveSetting(key string, value string) error {
|
func (s *SettingService) saveSetting(key string, value string) error {
|
||||||
setting, err := s.getSetting(key)
|
setting, err := s.getSetting(key)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
@ -542,6 +578,87 @@ func (s *SettingService) GetIpLimitEnable() (bool, error) {
|
||||||
return (accessLogPath != "none" && accessLogPath != ""), nil
|
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 {
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
||||||
if err := allSetting.CheckValid(); err != nil {
|
if err := allSetting.CheckValid(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v2/logger"
|
"github.com/mhsanaei/3x-ui/v2/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
"github.com/mhsanaei/3x-ui/v2/util/crypto"
|
||||||
|
ldaputil "github.com/mhsanaei/3x-ui/v2/util/ldap"
|
||||||
"github.com/xlzd/gotp"
|
"github.com/xlzd/gotp"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
@ -49,10 +49,39 @@ func (s *UserService) CheckUser(username string, password string, twoFactorCode
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If LDAP enabled and local password check fails, attempt LDAP auth
|
||||||
if !crypto.CheckPasswordHash(user.Password, password) {
|
if !crypto.CheckPasswordHash(user.Password, password) {
|
||||||
|
ldapEnabled, _ := s.settingService.GetLdapEnable()
|
||||||
|
if !ldapEnabled {
|
||||||
return nil
|
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()
|
twoFactorEnable, err := s.settingService.GetTwoFactorEnable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("check two factor err:", err)
|
logger.Warning("check two factor err:", err)
|
||||||
|
|
12
web/web.go
12
web/web.go
|
@ -314,6 +314,18 @@ func (s *Server) startTask() {
|
||||||
// Run once a month, midnight, first of month
|
// Run once a month, midnight, first of month
|
||||||
s.cron.AddJob("@monthly", job.NewPeriodicTrafficResetJob("monthly"))
|
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
|
// Make a traffic condition every day, 8:30
|
||||||
var entry cron.EntryID
|
var entry cron.EntryID
|
||||||
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
isTgbotenabled, err := s.settingService.GetTgbotEnabled()
|
||||||
|
|
Loading…
Reference in a new issue