Compare commits

...

9 commits

Author SHA1 Message Date
javadtgh
57d87241b5
Merge 3299d15f28 into 2198e7a28f 2025-08-17 16:23:27 +03:30
Alireza Ahmand
2198e7a28f
feat: Add remaining time to tgbot #3355 (#3360)
Some checks failed
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
2025-08-17 13:43:25 +02:00
mhsanaei
6b23b416a7
minor changes 2025-08-17 13:37:49 +02:00
mhsanaei
16f53ce4c2
go v1.25 2025-08-17 12:27:21 +02:00
mhsanaei
27445b30e9
DNS outbound: Set "reject" as the default value for nonIPQuery 2025-08-17 12:22:33 +02:00
Sanaei
3299d15f28
Merge branch 'main' into feature/multi-server-support 2025-08-14 18:06:16 +02:00
Sanaei
ae82373457
Merge branch 'main' into feature/multi-server-support 2025-08-04 11:22:53 +02:00
Sanaei
d65233cc2c
Merge branch 'main' into feature/multi-server-support 2025-08-04 10:33:41 +02:00
google-labs-jules[bot]
11dc06863e feat: Add multi-server support for Sanai panel
This commit introduces a multi-server architecture to the Sanai panel, allowing you to manage clients across multiple servers from a central panel.

Key changes include:

- **Database Schema:** Added a `servers` table to store information about slave servers.
- **Server Management:** Implemented a new service and controller (`MultiServerService` and `MultiServerController`) for CRUD operations on servers.
- **Web UI:** Created a new web page for managing servers, accessible from the sidebar.
- **Client Synchronization:** Modified the `InboundService` to synchronize client additions, updates, and deletions across all active slave servers via a REST API.
- **API Security:** Added an API key authentication middleware to secure the communication between the master and slave panels.
- **Multi-Server Subscriptions:** Updated the subscription service to generate links that include configurations for all active servers.
- **Installation Script:** Modified the `install.sh` script to generate a random API key during installation.

**Known Issues:**

- The integration test for client synchronization (`TestInboundServiceSync`) is currently failing. It seems that the API request to the mock slave server is not being sent correctly or the API key is not being included in the request header. Further investigation is needed to resolve this issue.
2025-07-27 17:25:58 +02:00
39 changed files with 867 additions and 192 deletions

View file

@ -1,7 +1,7 @@
# ======================================================== # ========================================================
# Stage: Builder # Stage: Builder
# ======================================================== # ========================================================
FROM golang:1.24-alpine AS builder FROM golang:1.25-alpine AS builder
WORKDIR /app WORKDIR /app
ARG TARGETARCH ARG TARGETARCH

View file

@ -35,6 +35,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 {

View file

@ -105,3 +105,12 @@ type Client struct {
Comment string `json:"comment" form:"comment"` Comment string `json:"comment" form:"comment"`
Reset int `json:"reset" form:"reset"` Reset int `json:"reset" form:"reset"`
} }
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"`
}

9
go.mod
View file

@ -1,6 +1,6 @@
module x-ui module x-ui
go 1.24.5 go 1.25.0
require ( require (
github.com/gin-contrib/gzip v1.2.3 github.com/gin-contrib/gzip v1.2.3
@ -15,7 +15,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 github.com/pelletier/go-toml/v2 v2.2.4
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.7 github.com/shirou/gopsutil/v4 v4.25.7
github.com/valyala/fasthttp v1.64.0 github.com/valyala/fasthttp v1.65.0
github.com/xlzd/gotp v0.1.0 github.com/xlzd/gotp v0.1.0
github.com/xtls/xray-core v1.250803.0 github.com/xtls/xray-core v1.250803.0
go.uber.org/atomic v1.11.0 go.uber.org/atomic v1.11.0
@ -56,11 +56,12 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.30 // indirect github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/miekg/dns v1.1.68 // indirect github.com/miekg/dns v1.1.68 // indirect
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
@ -92,7 +93,7 @@ require (
golang.org/x/tools v0.36.0 // indirect golang.org/x/tools v0.36.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect

12
go.sum
View file

@ -95,8 +95,8 @@ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr32
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.30 h1:bVreufq3EAIG1Quvws73du3/QgdeZ3myglJlrzSYYCY= github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.30/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -162,8 +162,8 @@ github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e h1:5QefA066A1tF
github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU= github.com/v2fly/ss-bloomring v0.0.0-20210312155135-28617310f63e/go.mod h1:5t19P9LBIrNamL6AcMQOncg/r10y3Pc01AbHeMhwlpU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og= github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA= github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@ -226,8 +226,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
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=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8= google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a h1:tPE/Kp+x9dMSwUm/uM0JKK0IfdiJkwAbSMSeZBXXJXc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250811230008-5f3141c8851a/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=

View file

@ -130,6 +130,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() {

15
main.go
View file

@ -232,7 +232,7 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
} }
} }
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)
@ -242,6 +242,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 {
@ -388,9 +397,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")
@ -440,7 +451,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)

View file

@ -209,9 +209,10 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
var streamSettings map[string]any var streamSettings map[string]any
json.Unmarshal([]byte(stream), &streamSettings) json.Unmarshal([]byte(stream), &streamSettings)
security, _ := streamSettings["security"].(string) security, _ := streamSettings["security"].(string)
if security == "tls" { switch security {
case "tls":
streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any)) streamSettings["tlsSettings"] = s.tlsData(streamSettings["tlsSettings"].(map[string]any))
} else if security == "reality" { case "reality":
streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any)) streamSettings["realitySettings"] = s.realityData(streamSettings["realitySettings"].(map[string]any))
} }
delete(streamSettings, "sockopt") delete(streamSettings, "sockopt")

View file

@ -154,26 +154,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 {
switch inbound.Protocol { serverService := service.MultiServerService{}
case "vmess": servers, err := serverService.GetServers()
return s.genVmessLink(inbound, email) if err != nil {
case "vless": logger.Warning("Failed to get servers for subscription:", err)
return s.genVlessLink(inbound, email) return ""
case "trojan":
return s.genTrojanLink(inbound, email)
case "shadowsocks":
return s.genShadowsocksLink(inbound, email)
} }
return ""
var links []string
for _, server := range servers {
if !server.Enable {
continue
}
var link string
switch inbound.Protocol {
case "vmess":
link = s.genVmessLink(inbound, email, server)
case "vless":
link = s.genVlessLink(inbound, email, server)
case "trojan":
link = s.genTrojanLink(inbound, email, server)
case "shadowsocks":
link = s.genShadowsocksLink(inbound, email, server)
}
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",
} }
@ -286,7 +303,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))
@ -302,14 +319,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 ""
} }
@ -482,7 +499,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"
@ -503,12 +520,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 ""
} }
@ -677,7 +694,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"
@ -699,12 +716,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 ""
} }
@ -844,7 +861,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"
@ -865,17 +882,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
@ -886,6 +904,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++ {

View file

@ -995,7 +995,7 @@ Outbound.DNSSettings = class extends CommonClass {
network = 'udp', network = 'udp',
address = '', address = '',
port = 53, port = 53,
nonIPQuery = 'drop', nonIPQuery = 'reject',
blockTypes = [] blockTypes = []
) { ) {
super(); super();

View file

@ -6,6 +6,7 @@ import (
"strconv" "strconv"
"x-ui/database/model" "x-ui/database/model"
"x-ui/web/middleware"
"x-ui/web/service" "x-ui/web/service"
"x-ui/web/session" "x-ui/web/session"
@ -32,15 +33,26 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/update/:id", a.updateInbound) g.POST("/update/:id", a.updateInbound)
g.POST("/clientIps/:email", a.getClientIps) g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps) g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic) g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
g.POST("/resetAllTraffics", a.resetAllTraffics) g.POST("/resetAllTraffics", a.resetAllTraffics)
g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics) g.POST("/resetAllClientTraffics/:id", a.resetAllClientTraffics)
g.POST("/delDepletedClients/:id", a.delDepletedClients) g.POST("/delDepletedClients/:id", a.delDepletedClients)
g.POST("/import", a.importInbound) g.POST("/import", a.importInbound)
g.POST("/onlines", a.onlines) g.POST("/onlines", a.onlines)
// Routes for UI
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
// Routes for API (for slave servers)
apiGroup := g.Group("/api")
apiGroup.Use(middleware.ApiAuth())
{
apiGroup.POST("/addClient", a.addInboundClient)
apiGroup.POST("/:id/delClient/:clientId", a.delInboundClient)
apiGroup.POST("/updateClient/:clientId", a.updateInboundClient)
}
} }
func (a *InboundController) getInbounds(c *gin.Context) { func (a *InboundController) getInbounds(c *gin.Context) {

View 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)
}

View file

@ -24,6 +24,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)
@ -47,3 +48,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)
}

View file

@ -2,10 +2,10 @@ package entity
import ( import (
"crypto/tls" "crypto/tls"
"math"
"net" "net"
"strings" "strings"
"time" "time"
"math"
"x-ui/util/common" "x-ui/util/common"
) )
@ -39,8 +39,8 @@ type AllSetting struct {
TgCpu int `json:"tgCpu" form:"tgCpu"` TgCpu int `json:"tgCpu" form:"tgCpu"`
TgLang string `json:"tgLang" form:"tgLang"` TgLang string `json:"tgLang" form:"tgLang"`
TimeLocation string `json:"timeLocation" form:"timeLocation"` TimeLocation string `json:"timeLocation" form:"timeLocation"`
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
SubEnable bool `json:"subEnable" form:"subEnable"` SubEnable bool `json:"subEnable" form:"subEnable"`
SubTitle string `json:"subTitle" form:"subTitle"` SubTitle string `json:"subTitle" form:"subTitle"`
SubListen string `json:"subListen" form:"subListen"` SubListen string `json:"subListen" form:"subListen"`

View file

@ -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',

View file

@ -105,7 +105,7 @@
</a-form-item> </a-form-item>
<a-form-item label='non-IP queries'> <a-form-item label='non-IP queries'>
<a-select v-model="outbound.settings.nonIPQuery" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="outbound.settings.nonIPQuery" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="s in ['drop','skip']" :value="s">[[ s ]]</a-select-option> <a-select-option v-for="s in ['reject','drop','skip']" :value="s">[[ s ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-form-item v-if="outbound.settings.nonIPQuery === 'skip'" label='Block Types' > <a-form-item v-if="outbound.settings.nonIPQuery === 'skip'" label='Block Types' >

165
web/html/servers.html Normal file
View 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">&times;</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" .}}

View file

@ -11,7 +11,6 @@ import (
"sort" "sort"
"time" "time"
"slices"
"x-ui/database" "x-ui/database"
"x-ui/database/model" "x-ui/database/model"
"x-ui/logger" "x-ui/logger"
@ -58,21 +57,21 @@ func (j *CheckClientIpJob) Run() {
func (j *CheckClientIpJob) clearAccessLog() { func (j *CheckClientIpJob) clearAccessLog() {
logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) logAccessP, err := os.OpenFile(xray.GetAccessPersistentLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
j.checkError(err) j.checkError(err)
defer logAccessP.Close()
accessLogPath, err := xray.GetAccessLogPath() accessLogPath, err := xray.GetAccessLogPath()
j.checkError(err) j.checkError(err)
file, err := os.Open(accessLogPath) file, err := os.Open(accessLogPath)
j.checkError(err) j.checkError(err)
defer file.Close()
_, err = io.Copy(logAccessP, file) _, err = io.Copy(logAccessP, file)
j.checkError(err) j.checkError(err)
logAccessP.Close()
file.Close()
err = os.Truncate(accessLogPath, 0) err = os.Truncate(accessLogPath, 0)
j.checkError(err) j.checkError(err)
j.lastClear = time.Now().Unix() j.lastClear = time.Now().Unix()
} }
@ -193,10 +192,6 @@ func (j *CheckClientIpJob) checkError(e error) {
} }
} }
func (j *CheckClientIpJob) contains(s []string, str string) bool {
return slices.Contains(s, str)
}
func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) { func (j *CheckClientIpJob) getInboundClientIps(clientEmail string) (*model.InboundClientIps, error) {
db := database.GetDB() db := database.GetDB()
InboundClientIps := &model.InboundClientIps{} InboundClientIps := &model.InboundClientIps{}

34
web/middleware/auth.go Normal file
View 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()
}
}

View file

@ -1,8 +1,11 @@
package service package service
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -177,15 +180,16 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
// Secure client ID // Secure client ID
for _, client := range clients { for _, client := range clients {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
if client.Password == "" { if client.Password == "" {
return inbound, false, common.NewError("empty client ID") return inbound, false, common.NewError("empty client ID")
} }
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
if client.Email == "" { if client.Email == "" {
return inbound, false, common.NewError("empty client ID") return inbound, false, common.NewError("empty client ID")
} }
} else { default:
if client.ID == "" { if client.ID == "" {
return inbound, false, common.NewError("empty client ID") return inbound, false, common.NewError("empty client ID")
} }
@ -436,15 +440,16 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
// Secure client ID // Secure client ID
for _, client := range clients { for _, client := range clients {
if oldInbound.Protocol == "trojan" { switch oldInbound.Protocol {
case "trojan":
if client.Password == "" { if client.Password == "" {
return false, common.NewError("empty client ID") return false, common.NewError("empty client ID")
} }
} else if oldInbound.Protocol == "shadowsocks" { case "shadowsocks":
if client.Email == "" { if client.Email == "" {
return false, common.NewError("empty client ID") return false, common.NewError("empty client ID")
} }
} else { default:
if client.ID == "" { if client.ID == "" {
return false, common.NewError("empty client ID") return false, common.NewError("empty client ID")
} }
@ -511,6 +516,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
} }
@ -599,6 +609,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
} }
@ -631,13 +646,14 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
clientIndex := -1 clientIndex := -1
for index, oldClient := range oldClients { for index, oldClient := range oldClients {
oldClientId := "" oldClientId := ""
if oldInbound.Protocol == "trojan" { switch oldInbound.Protocol {
case "trojan":
oldClientId = oldClient.Password oldClientId = oldClient.Password
newClientId = clients[0].Password newClientId = clients[0].Password
} else if oldInbound.Protocol == "shadowsocks" { case "shadowsocks":
oldClientId = oldClient.Email oldClientId = oldClient.Email
newClientId = clients[0].Email newClientId = clients[0].Email
} else { default:
oldClientId = oldClient.ID oldClientId = oldClient.ID
newClientId = clients[0].ID newClientId = clients[0].ID
} }
@ -753,6 +769,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
} }
@ -1244,11 +1266,12 @@ func (s *InboundService) SetClientTelegramUserID(trafficId int, tgId int64) (boo
for _, oldClient := range oldClients { for _, oldClient := range oldClients {
if oldClient.Email == clientEmail { if oldClient.Email == clientEmail {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password clientId = oldClient.Password
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
clientId = oldClient.Email clientId = oldClient.Email
} else { default:
clientId = oldClient.ID clientId = oldClient.ID
} }
break break
@ -1328,11 +1351,12 @@ func (s *InboundService) ToggleClientEnableByEmail(clientEmail string) (bool, bo
for _, oldClient := range oldClients { for _, oldClient := range oldClients {
if oldClient.Email == clientEmail { if oldClient.Email == clientEmail {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password clientId = oldClient.Password
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
clientId = oldClient.Email clientId = oldClient.Email
} else { default:
clientId = oldClient.ID clientId = oldClient.ID
} }
clientOldEnabled = oldClient.Enable clientOldEnabled = oldClient.Enable
@ -1391,11 +1415,12 @@ func (s *InboundService) ResetClientIpLimitByEmail(clientEmail string, count int
for _, oldClient := range oldClients { for _, oldClient := range oldClients {
if oldClient.Email == clientEmail { if oldClient.Email == clientEmail {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password clientId = oldClient.Password
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
clientId = oldClient.Email clientId = oldClient.Email
} else { default:
clientId = oldClient.ID clientId = oldClient.ID
} }
break break
@ -1448,11 +1473,12 @@ func (s *InboundService) ResetClientExpiryTimeByEmail(clientEmail string, expiry
for _, oldClient := range oldClients { for _, oldClient := range oldClients {
if oldClient.Email == clientEmail { if oldClient.Email == clientEmail {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password clientId = oldClient.Password
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
clientId = oldClient.Email clientId = oldClient.Email
} else { default:
clientId = oldClient.ID clientId = oldClient.ID
} }
break break
@ -1508,11 +1534,12 @@ func (s *InboundService) ResetClientTrafficLimitByEmail(clientEmail string, tota
for _, oldClient := range oldClients { for _, oldClient := range oldClients {
if oldClient.Email == clientEmail { if oldClient.Email == clientEmail {
if inbound.Protocol == "trojan" { switch inbound.Protocol {
case "trojan":
clientId = oldClient.Password clientId = oldClient.Password
} else if inbound.Protocol == "shadowsocks" { case "shadowsocks":
clientId = oldClient.Email clientId = oldClient.Email
} else { default:
clientId = oldClient.ID clientId = oldClient.ID
} }
break break
@ -2075,3 +2102,41 @@ 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))
}
}
}

View 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)
}

View 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
}

View 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)
}

View file

@ -180,6 +180,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()

View file

@ -40,7 +40,6 @@ var (
isRunning bool isRunning bool
hostname string hostname string
hashStorage *global.HashStorage hashStorage *global.HashStorage
handler *th.Handler
// clients data to adding new client // clients data to adding new client
receiver_inbound_ID int receiver_inbound_ID int
@ -641,13 +640,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 4 { if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3]) num, err := strconv.Atoi(dataArray[3])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -704,6 +704,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@ -715,13 +719,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 3 { if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2]) num, err := strconv.Atoi(dataArray[2])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -844,13 +849,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 4 { if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3]) num, err := strconv.Atoi(dataArray[3])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -919,6 +925,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@ -930,13 +940,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 3 { if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2]) num, err := strconv.Atoi(dataArray[2])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -1035,13 +1046,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 4 { if len(dataArray) == 4 {
num, err := strconv.Atoi(dataArray[3]) num, err := strconv.Atoi(dataArray[3])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -1101,6 +1113,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId) t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation")) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
@ -1112,13 +1128,14 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
if len(dataArray) == 3 { if len(dataArray) == 3 {
num, err := strconv.Atoi(dataArray[2]) num, err := strconv.Atoi(dataArray[2])
if err == nil { if err == nil {
if num == -2 { switch num {
case -2:
inputNumber = 0 inputNumber = 0
} else if num == -1 { case -1:
if inputNumber > 0 { if inputNumber > 0 {
inputNumber = (inputNumber / 10) inputNumber = (inputNumber / 10)
} }
} else { default:
inputNumber = (inputNumber * 10) + num inputNumber = (inputNumber * 10) + num
} }
} }
@ -1288,6 +1305,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text) t.addClient(callbackQuery.Message.GetChat().ID, message_text)
} }
@ -1524,6 +1545,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(chatId, message_text, messageId) t.addClient(chatId, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email))
case "add_client_default_ip_limit": case "add_client_default_ip_limit":
@ -1534,6 +1559,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(chatId, message_text, messageId) t.addClient(chatId, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email)) t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.canceled", "Email=="+client_Email))
case "add_client_submit_disable": case "add_client_submit_disable":
@ -1598,6 +1627,10 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return return
} }
valid_emails, extra_emails, err := t.inboundService.FilterAndSortClientEmails(emails) valid_emails, extra_emails, err := t.inboundService.FilterAndSortClientEmails(emails)
if err != nil {
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation"), tu.ReplyKeyboardRemove())
return
}
for _, valid_emails := range valid_emails { for _, valid_emails := range valid_emails {
traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails) traffic, err := t.inboundService.GetClientTrafficByEmail(valid_emails)
@ -1760,6 +1793,10 @@ func (t *Tgbot) SubmitAddClient() (bool, error) {
} }
jsonString, err := t.BuildJSONForProtocol(inbound.Protocol) jsonString, err := t.BuildJSONForProtocol(inbound.Protocol)
if err != nil {
logger.Warning("BuildJSONForProtocol run failed:", err)
return false, errors.New("failed to build JSON for protocol")
}
newInbound := &model.Inbound{ newInbound := &model.Inbound{
Id: receiver_inbound_ID, Id: receiver_inbound_ID,
@ -2008,10 +2045,11 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
} }
msg := "" msg := ""
if status == LoginSuccess { switch status {
case LoginSuccess:
msg += t.I18nBot("tgbot.messages.loginSuccess") msg += t.I18nBot("tgbot.messages.loginSuccess")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
} else if status == LoginFail { case LoginFail:
msg += t.I18nBot("tgbot.messages.loginFailed") msg += t.I18nBot("tgbot.messages.loginFailed")
msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
msg += t.I18nBot("tgbot.messages.password", "Password=="+password) msg += t.I18nBot("tgbot.messages.password", "Password=="+password)
@ -2171,6 +2209,22 @@ func (t *Tgbot) clientInfoMsg(
expiryTime = t.I18nBot("tgbot.unlimited") expiryTime = t.I18nBot("tgbot.unlimited")
} else if diff > 172800 || !traffic.Enable { } else if diff > 172800 || !traffic.Enable {
expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05") expiryTime = time.Unix((traffic.ExpiryTime / 1000), 0).Format("2006-01-02 15:04:05")
if diff > 0 {
days := diff / 86400
hours := (diff % 86400) / 3600
minutes := (diff % 3600) / 60
remainingTime := ""
if days > 0 {
remainingTime += fmt.Sprintf("%d %s ", days, t.I18nBot("tgbot.days"))
}
if hours > 0 {
remainingTime += fmt.Sprintf("%d %s ", hours, t.I18nBot("tgbot.hours"))
}
if minutes > 0 {
remainingTime += fmt.Sprintf("%d %s", minutes, t.I18nBot("tgbot.minutes"))
}
expiryTime += fmt.Sprintf(" (%s)", remainingTime)
}
} else if traffic.ExpiryTime < 0 { } else if traffic.ExpiryTime < 0 {
expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days")) expiryTime = fmt.Sprintf("%d %s", traffic.ExpiryTime/-86400000, t.I18nBot("tgbot.days"))
flag = true flag = true

View file

@ -561,24 +561,25 @@
"resetOutboundTrafficError" = "خطأ في إعادة تعيين حركات المرور الصادرة" "resetOutboundTrafficError" = "خطأ في إعادة تعيين حركات المرور الصادرة"
[tgbot] [tgbot]
"keyboardClosed" = "❌ الكيبورد المخصص اتقفلت!" "keyboardClosed" = "❌ لوحة المفاتيح مغلقة!"
"noResult" = "❗ مفيش نتيجة!" "noResult" = "❗ لا يوجد نتائج!"
"noQuery" = "❌ مش لاقي السؤال! استخدم الأمر تاني!" "noQuery" = "❌ لم يتم العثور على الاستعلام! يرجى استخدام الأمر مرة أخرى!"
"wentWrong" = "❌ حصل خطأ!" "wentWrong" = "❌ حدث خطأ ما!"
"noIpRecord" = "❗ مفيش سجل IP!" "noIpRecord" = "❗ لا يوجد سجل IP!"
"noInbounds" = "❗ مفيش إدخال متواجد!" "noInbounds" = "❗ لم يتم العثور على أي وارد!"
"unlimited" = "♾ غير محدود (إعادة ضبط)" "unlimited" = "♾ غير محدود (إعادة تعيين)"
"add" = "أضف" "add" = "إضافة"
"month" = "شهر" "month" = "شهر"
"months" = "شهور" "months" = "أشهر"
"day" = "يوم" "day" = "يوم"
"days" = "أيام" "days" = "أيام"
"hours" = "ساعات" "hours" = "ساعات"
"unknown" = "مش معروف" "minutes" = "دقائق"
"inbounds" = "الإدخالات" "unknown" = "غير معروف"
"inbounds" = "الواردات"
"clients" = "العملاء" "clients" = "العملاء"
"offline" = "🔴 أوفلاين" "offline" = "🔴 غير متصل"
"online" = "🟢 أونلاين" "online" = "🟢 متصل"
[tgbot.commands] [tgbot.commands]
"unknown" = "❗ أمر مش معروف." "unknown" = "❗ أمر مش معروف."

View file

@ -574,6 +574,7 @@
"day" = "Day" "day" = "Day"
"days" = "Days" "days" = "Days"
"hours" = "Hours" "hours" = "Hours"
"minutes" = "Minutes"
"unknown" = "Unknown" "unknown" = "Unknown"
"inbounds" = "Inbounds" "inbounds" = "Inbounds"
"clients" = "Clients" "clients" = "Clients"

View file

@ -91,7 +91,7 @@
"invalidFormData" = "El formato de los datos de entrada es inválido." "invalidFormData" = "El formato de los datos de entrada es inválido."
"emptyUsername" = "Por favor ingresa el nombre de usuario." "emptyUsername" = "Por favor ingresa el nombre de usuario."
"emptyPassword" = "Por favor ingresa la contraseña." "emptyPassword" = "Por favor ingresa la contraseña."
"wrongUsernameOrPassword" = "Nombre de usuario, contraseña o código de dos factores incorrecto." "wrongUsernameOrPassword" = "Nombre de usuario, contraseña o código de dos factores incorrecto."
"successLogin" = "Has iniciado sesión en tu cuenta correctamente." "successLogin" = "Has iniciado sesión en tu cuenta correctamente."
[pages.index] [pages.index]
@ -535,9 +535,9 @@
[pages.settings.security] [pages.settings.security]
"admin" = "Credenciales de administrador" "admin" = "Credenciales de administrador"
"twoFactor" = "Autenticación de dos factores" "twoFactor" = "Autenticación de dos factores"
"twoFactorEnable" = "Habilitar 2FA" "twoFactorEnable" = "Habilitar 2FA"
"twoFactorEnableDesc" = "Añade una capa adicional de autenticación para mayor seguridad." "twoFactorEnableDesc" = "Añade una capa adicional de autenticación para mayor seguridad."
"twoFactorModalSetTitle" = "Activar autenticación de dos factores" "twoFactorModalSetTitle" = "Activar autenticación de dos factores"
"twoFactorModalDeleteTitle" = "Desactivar autenticación de dos factores" "twoFactorModalDeleteTitle" = "Desactivar autenticación de dos factores"
"twoFactorModalSteps" = "Para configurar la autenticación de dos factores, sigue estos pasos:" "twoFactorModalSteps" = "Para configurar la autenticación de dos factores, sigue estos pasos:"
@ -561,23 +561,24 @@
"resetOutboundTrafficError" = "Error al reiniciar el tráfico saliente" "resetOutboundTrafficError" = "Error al reiniciar el tráfico saliente"
[tgbot] [tgbot]
"keyboardClosed" = "❌ ¡Teclado personalizado cerrado!" "keyboardClosed" = "❌ Teclado cerrado!"
"noResult" = "❗ ¡Sin resultados!" "noResult" = "❗ ¡No hay resultados!"
"noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor utiliza el comando nuevamente!" "noQuery" = "❌ ¡Consulta no encontrada! ¡Por favor, use el comando de nuevo!"
"wentWrong" = "❌ ¡Algo salió mal!" "wentWrong" = "❌ ¡Algo salió mal!"
"noIpRecord" = "❗ ¡Sin Registro de IP!" "noIpRecord" = "❗ ¡No hay registro de IP!"
"noInbounds" = "❗ ¡No se encontraron entradas!" "noInbounds" = "❗ ¡No se encontraron entradas!"
"unlimited" = "♾ Ilimitado" "unlimited" = "♾ Ilimitado (Restablecer)"
"add" = "Agregar" "add" = "Añadir"
"month" = "Mes" "month" = "Mes"
"months" = "Meses" "months" = "Meses"
"day" = "Día" "day" = "Día"
"days" = "Días" "days" = "Días"
"hours" = "Horas" "hours" = "Horas"
"minutes" = "Minutos"
"unknown" = "Desconocido" "unknown" = "Desconocido"
"inbounds" = "Entradas" "inbounds" = "Entradas"
"clients" = "Clientes" "clients" = "Clientes"
"offline" = "🔴 Sin conexión" "offline" = "🔴 Desconectado"
"online" = "🟢 En línea" "online" = "🟢 En línea"
[tgbot.commands] [tgbot.commands]

View file

@ -561,22 +561,23 @@
"resetOutboundTrafficError" = "خطا در بازنشانی ترافیک خروجی" "resetOutboundTrafficError" = "خطا در بازنشانی ترافیک خروجی"
[tgbot] [tgbot]
"keyboardClosed" = "❌ کیبورد سفارشی بسته شد!" "keyboardClosed" = "❌ صفحه کلید بسته شد!"
"noResult" = "❗ نتیجهای یافت نشد!" "noResult" = "❗ نتیجه ای یافت نشد!"
"noQuery" = "❌ کوئری یافت نشد! لطفاً دستور را مجدداً استفاده کنید!" "noQuery" = "❌ درخواست یافت نشد! لطفا دوباره تلاش کنید!"
"wentWrong" = "❌ مشکلی رخ داده است!" "wentWrong" = "❌ مشکلی پیش آمد!"
"noIpRecord" = "❗ رکورد IP یافت نشد!" "noIpRecord" = "❗ رکورد آی پی وجود ندارد!"
"noInbounds" = "❗ هیچ ورودی یافت نشد!" "noInbounds" = "❗ هیچ ورودی یافت نشد!"
"unlimited" = "♾ - نامحدود(ریست)" "unlimited" = "♾ نامحدود(ریست)"
"add" = "اضافه کردن" "add" = "افزودن"
"month" = "ماه" "month" = "ماه"
"months" = "ماه" "months" = "ماه"
"day" = "روز" "day" = "روز"
"days" = "روز" "days" = "روز"
"hours" = "ساعت‌" "hours" = "ساعت"
"minutes" = "دقیقه"
"unknown" = "نامشخص" "unknown" = "نامشخص"
"inbounds" = "ورودیها" "inbounds" = "ورودی ها"
"clients" = لاینت‌ها" "clients" = اربران"
"offline" = "🔴 آفلاین" "offline" = "🔴 آفلاین"
"online" = "🟢 آنلاین" "online" = "🟢 آنلاین"

View file

@ -561,21 +561,22 @@
"resetOutboundTrafficError" = "Gagal mereset lalu lintas keluar" "resetOutboundTrafficError" = "Gagal mereset lalu lintas keluar"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Papan ketik kustom ditutup!" "keyboardClosed" = "❌ Keyboard ditutup!"
"noResult" = "❗ Tidak ada hasil!" "noResult" = "❗ Tidak ada hasil!"
"noQuery" = "❌ Permintaan tidak ditemukan! Harap gunakan perintah lagi!" "noQuery" = "❌ Kueri tidak ditemukan! Silakan gunakan perintah lagi!"
"wentWrong" = "❌ Ada yang salah!" "wentWrong" = "❌ Terjadi kesalahan!"
"noIpRecord" = "❗ Tidak ada Catatan IP!" "noIpRecord" = "❗ Tidak ada Catatan IP!"
"noInbounds" = "❗ Tidak ada masuk ditemukan!" "noInbounds" = "❗ Tidak ada inbound yang ditemukan!"
"unlimited" = "♾ Tak terbatas" "unlimited" = "♾ Tidak terbatas (Reset)"
"add" = "Tambah" "add" = "Tambah"
"month" = "Bulan" "month" = "Bulan"
"months" = "Bulan" "months" = "Bulan"
"day" = "Hari" "day" = "Hari"
"days" = "Hari" "days" = "Hari"
"hours" = "Jam" "hours" = "Jam"
"minutes" = "Menit"
"unknown" = "Tidak diketahui" "unknown" = "Tidak diketahui"
"inbounds" = "Masuk" "inbounds" = "Inbound"
"clients" = "Klien" "clients" = "Klien"
"offline" = "🔴 Offline" "offline" = "🔴 Offline"
"online" = "🟢 Online" "online" = "🟢 Online"

View file

@ -561,21 +561,22 @@
"resetOutboundTrafficError" = "送信トラフィックのリセットエラー" "resetOutboundTrafficError" = "送信トラフィックのリセットエラー"
[tgbot] [tgbot]
"keyboardClosed" = "❌ カスタムキーボードが閉じられました!" "keyboardClosed" = "❌ キーボードを閉じました!"
"noResult" = "❗ 結果がありません!" "noResult" = "❗ 結果がありません!"
"noQuery" = "❌ クエリが見つかりませんでした!もう一度コマンドを使用してください!" "noQuery" = "❌ クエリが見つかりません!コマンドを再利用してください!"
"wentWrong" = "❌ 問題が発生しました!" "wentWrong" = "❌ 何かがうまくいかなかった!"
"noIpRecord" = "❗ IP記録がありません!" "noIpRecord" = "❗ IPレコードがありません!"
"noInbounds" = "❗ インバウンド接続が見つかりません!" "noInbounds" = "❗ インバウンドが見つかりません!"
"unlimited" = "♾ 無制限" "unlimited" = "♾ 無制限(リセット)"
"add" = "追加" "add" = "追加"
"month" = "月" "month" = "月"
"months" = "月" "months" = "月"
"day" = "日" "day" = "日"
"days" = "日" "days" = "日"
"hours" = "時間" "hours" = "時間"
"minutes" = "分"
"unknown" = "不明" "unknown" = "不明"
"inbounds" = "インバウンド接続" "inbounds" = "インバウンド"
"clients" = "クライアント" "clients" = "クライアント"
"offline" = "🔴 オフライン" "offline" = "🔴 オフライン"
"online" = "🟢 オンライン" "online" = "🟢 オンライン"

View file

@ -561,21 +561,22 @@
"resetOutboundTrafficError" = "Erro ao redefinir tráfego de saída" "resetOutboundTrafficError" = "Erro ao redefinir tráfego de saída"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Teclado personalizado fechado!" "keyboardClosed" = "❌ Teclado fechado!"
"noResult" = "❗ Nenhum resultado!" "noResult" = "❗ Nenhum resultado!"
"noQuery" = "❌ Consulta não encontrada! Por favor, use o comando novamente!" "noQuery" = "❌ Consulta não encontrada! Por favor, use o comando novamente!"
"wentWrong" = "❌ Algo deu errado!" "wentWrong" = "❌ Algo deu errado!"
"noIpRecord" = "❗ Nenhum registro de IP!" "noIpRecord" = "❗ Nenhum registro de IP!"
"noInbounds" = "❗ Nenhuma entrada encontrada!" "noInbounds" = "❗ Nenhum inbound encontrado!"
"unlimited" = "♾ Ilimitado (Reiniciar)" "unlimited" = "♾ Ilimitado (Reset)"
"add" = "Adicionar" "add" = "Adicionar"
"month" = "Mês" "month" = "Mês"
"months" = "Meses" "months" = "Meses"
"day" = "Dia" "day" = "Dia"
"days" = "Dias" "days" = "Dias"
"hours" = "Horas" "hours" = "Horas"
"minutes" = "Minutos"
"unknown" = "Desconhecido" "unknown" = "Desconhecido"
"inbounds" = "Entradas" "inbounds" = "Inbounds"
"clients" = "Clientes" "clients" = "Clientes"
"offline" = "🔴 Offline" "offline" = "🔴 Offline"
"online" = "🟢 Online" "online" = "🟢 Online"

View file

@ -574,6 +574,7 @@
"day" = "День" "day" = "День"
"days" = "Дней" "days" = "Дней"
"hours" = "Часов" "hours" = "Часов"
"minutes" = "Минуты"
"unknown" = "Неизвестно" "unknown" = "Неизвестно"
"inbounds" = "Инбаунды" "inbounds" = "Инбаунды"
"clients" = "Клиенты" "clients" = "Клиенты"

View file

@ -561,22 +561,23 @@
"resetOutboundTrafficError" = "Giden trafik sıfırlanırken hata" "resetOutboundTrafficError" = "Giden trafik sıfırlanırken hata"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Özel klavye kapalı!" "keyboardClosed" = "❌ Klavye kapatıldı!"
"noResult" = "❗ Sonuç yok!" "noResult" = "❗ Sonuç yok!"
"noQuery" = "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!" "noQuery" = "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!"
"wentWrong" = "❌ Bir şeyler yanlış gitti!" "wentWrong" = "❌ Bir şeyler yanlış gitti!"
"noIpRecord" = "❗ IP Kaydı yok!" "noIpRecord" = "❗ IP Kaydı Yok!"
"noInbounds" = "❗ Gelen bulunamadı!" "noInbounds" = "❗ Gelen bağlantı bulunamadı!"
"unlimited" = "♾ Sınırsız(Sıfırla)" "unlimited" = "♾ Sınırsız (Sıfırla)"
"add" = "Ekle" "add" = "Ekle"
"month" = "Ay" "month" = "Ay"
"months" = "Aylar" "months" = "Aylar"
"day" = "Gün" "day" = "Gün"
"days" = "Günler" "days" = "Günler"
"hours" = "Saatler" "hours" = "Saatler"
"unknown" = "Bilinmiyor" "minutes" = "Dakika"
"unknown" = "Bilinmeyen"
"inbounds" = "Gelenler" "inbounds" = "Gelenler"
"clients" = "Müşteriler" "clients" = "İstemciler"
"offline" = "🔴 Çevrimdışı" "offline" = "🔴 Çevrimdışı"
"online" = "🟢 Çevrimiçi" "online" = "🟢 Çevrimiçi"

View file

@ -561,19 +561,20 @@
"resetOutboundTrafficError" = "Помилка скидання вихідного трафіку" "resetOutboundTrafficError" = "Помилка скидання вихідного трафіку"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Спеціальна клавіатура закрита!" "keyboardClosed" = "❌ Клавіатуру закрито!"
"noResult" = "❗ Немає результату!" "noResult" = "❗ Немає результату!"
"noQuery" = "❌ Запит не знайдено! Скористайтеся командою ще раз!" "noQuery" = "❌ Запит не знайдено! Будь ласка, використовуйте команду ще раз!"
"wentWrong" = "❌ Щось пішло не так!" "wentWrong" = "❌ Щось пішло не так!"
"noIpRecord" = "❗ Немає IP-запису!" "noIpRecord" = "❗ Немає запису IP!"
"noInbounds" = "❗ Вхідних не знайдено!" "noInbounds" = "❗ Вхідні не знайдені!"
"unlimited" = "♾ Необмежений (скинути)" "unlimited" = "♾ Необмежено (Скинути)"
"add" = "Додати" "add" = "Додати"
"month" = "Місяць" "month" = "Місяць"
"months" = "Місяці" "months" = "Місяці"
"day" = "День" "day" = "День"
"days" = "Дні" "days" = "Дні"
"hours" = "Годинник" "hours" = "Години"
"minutes" = "Хвилини"
"unknown" = "Невідомо" "unknown" = "Невідомо"
"inbounds" = "Вхідні" "inbounds" = "Вхідні"
"clients" = "Клієнти" "clients" = "Клієнти"

View file

@ -91,7 +91,7 @@
"invalidFormData" = "Dạng dữ liệu nhập không hợp lệ." "invalidFormData" = "Dạng dữ liệu nhập không hợp lệ."
"emptyUsername" = "Vui lòng nhập tên người dùng." "emptyUsername" = "Vui lòng nhập tên người dùng."
"emptyPassword" = "Vui lòng nhập mật khẩu." "emptyPassword" = "Vui lòng nhập mật khẩu."
"wrongUsernameOrPassword" = "Tên người dùng, mật khẩu hoặc mã xác thực hai yếu tố không hợp lệ." "wrongUsernameOrPassword" = "Tên người dùng, mật khẩu hoặc mã xác thực hai yếu tố không hợp lệ."
"successLogin" = "Bạn đã đăng nhập vào tài khoản thành công." "successLogin" = "Bạn đã đăng nhập vào tài khoản thành công."
[pages.index] [pages.index]
@ -535,9 +535,9 @@
[pages.settings.security] [pages.settings.security]
"admin" = "Thông tin đăng nhập quản trị viên" "admin" = "Thông tin đăng nhập quản trị viên"
"twoFactor" = "Xác thực hai yếu tố" "twoFactor" = "Xác thực hai yếu tố"
"twoFactorEnable" = "Bật 2FA" "twoFactorEnable" = "Bật 2FA"
"twoFactorEnableDesc" = "Thêm một lớp bảo mật bổ sung để tăng cường an toàn." "twoFactorEnableDesc" = "Thêm một lớp bảo mật bổ sung để tăng cường an toàn."
"twoFactorModalSetTitle" = "Bật xác thực hai yếu tố" "twoFactorModalSetTitle" = "Bật xác thực hai yếu tố"
"twoFactorModalDeleteTitle" = "Tắt xác thực hai yếu tố" "twoFactorModalDeleteTitle" = "Tắt xác thực hai yếu tố"
"twoFactorModalSteps" = "Để thiết lập xác thực hai yếu tố, hãy thực hiện các bước sau:" "twoFactorModalSteps" = "Để thiết lập xác thực hai yếu tố, hãy thực hiện các bước sau:"
@ -561,22 +561,23 @@
"resetOutboundTrafficError" = "Lỗi khi đặt lại lưu lượng truy cập đi" "resetOutboundTrafficError" = "Lỗi khi đặt lại lưu lượng truy cập đi"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Bàn phím tùy chỉnh đã đóng!" "keyboardClosed" = "❌ Bàn phím đã đóng!"
"noResult" = "❗ Không có kết quả!" "noResult" = "❗ Không có kết quả!"
"noQuery" = "❌ Không tìm thấy truy vấn! Vui lòng sử dụng lệnh lại!" "noQuery" = "❌ Không tìm thấy truy vấn! Vui lòng sử dụng lại lệnh!"
"wentWrong" = "❌ Đã xảy ra lỗi!" "wentWrong" = "❌ Đã xảy ra lỗi!"
"noIpRecord" = "❗ Không có bản ghi IP!" "noIpRecord" = "❗ Không có bản ghi IP!"
"noInbounds" = "❗ Không tìm thấy inbound!" "noInbounds" = "❗ Không tìm thấy inbound!"
"unlimited" = "♾ Không giới hạn" "unlimited" = "♾ Không giới hạn (Đặt lại)"
"add" = "Thêm" "add" = "Thêm"
"month" = "Tháng" "month" = "Tháng"
"months" = "Tháng" "months" = "Tháng"
"day" = "Ngày" "day" = "Ngày"
"days" = "Ngày" "days" = "Ngày"
"hours" = "Giờ" "hours" = "Giờ"
"unknown" = "Không rõ" "minutes" = "Phút"
"inbounds" = "Vào" "unknown" = "Không xác định"
"clients" = "Các người dùng" "inbounds" = "Inbound"
"clients" = "Client"
"offline" = "🔴 Ngoại tuyến" "offline" = "🔴 Ngoại tuyến"
"online" = "🟢 Trực tuyến" "online" = "🟢 Trực tuyến"

View file

@ -563,19 +563,20 @@
[tgbot] [tgbot]
"keyboardClosed" = "❌ 自定义键盘已关闭!" "keyboardClosed" = "❌ 自定义键盘已关闭!"
"noResult" = "❗ 没有结果!" "noResult" = "❗ 没有结果!"
"noQuery" = "❌ 未找到查询!请重新使用命令!" "noQuery" = "❌ 未找到查询!请再次使用该命令!"
"wentWrong" = "❌ 出了点问题!" "wentWrong" = "❌ 出了点问题!"
"noIpRecord" = "❗ 没有 IP 记录!" "noIpRecord" = "❗ 没有IP记录"
"noInbounds" = "❗ 没有找到入站连接" "noInbounds" = "❗ 未找到入站"
"unlimited" = "♾ 无限" "unlimited" = "♾ 无限(重置)"
"add" = "添加" "add" = "添加"
"month" = "月" "month" = "月"
"months" = "月" "months" = "月"
"day" = "天" "day" = "天"
"days" = "天" "days" = "天"
"hours" = "小时" "hours" = "小时"
"minutes" = "分钟"
"unknown" = "未知" "unknown" = "未知"
"inbounds" = "入站连接" "inbounds" = "入站"
"clients" = "客户端" "clients" = "客户端"
"offline" = "🔴 离线" "offline" = "🔴 离线"
"online" = "🟢 在线" "online" = "🟢 在线"

View file

@ -563,22 +563,23 @@
[tgbot] [tgbot]
"keyboardClosed" = "❌ 自定義鍵盤已關閉!" "keyboardClosed" = "❌ 自定義鍵盤已關閉!"
"noResult" = "❗ 沒有結果!" "noResult" = "❗ 沒有結果!"
"noQuery" = "❌ 未找到查詢!請重新使用命令!" "noQuery" = "❌ 未找到查詢!請再次使用該命令!"
"wentWrong" = "❌ 出了點問題!" "wentWrong" = "❌ 出了點問題!"
"noIpRecord" = "❗ 沒有 IP 記錄!" "noIpRecord" = "❗ 沒有IP記錄"
"noInbounds" = "❗ 沒有找到入站連線" "noInbounds" = "❗ 未找到入站"
"unlimited" = "♾ 無限" "unlimited" = "♾ 無限(重置)"
"add" = "新增" "add" = "添加"
"month" = "月" "month" = "月"
"months" = "月" "months" = "月"
"day" = "天" "day" = "天"
"days" = "天" "days" = "天"
"hours" = "小時" "hours" = "小時"
"minutes" = "分鐘"
"unknown" = "未知" "unknown" = "未知"
"inbounds" = "入站連線" "inbounds" = "入站"
"clients" = "客戶端" "clients" = "客戶端"
"offline" = "🔴 離線" "offline" = "🔴 離線"
"online" = "🟢 " "online" = "🟢 線"
[tgbot.commands] [tgbot.commands]
"unknown" = "❗ 未知命令" "unknown" = "❗ 未知命令"

View file

@ -229,7 +229,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath) g := engine.Group(basePath)
s.index = controller.NewIndexController(g) s.index = controller.NewIndexController(g)
s.server = controller.NewServerController(g) s.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g) s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g) s.api = controller.NewAPIController(g)