mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-10-26 18:14:50 +00:00
Compare commits
14 commits
b2ad033bfc
...
56d1b75021
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d1b75021 | ||
|
|
b46a0b404b | ||
|
|
0ce58a095a | ||
|
|
59ea2645db | ||
|
|
8c8d280f14 | ||
|
|
c720008187 | ||
|
|
170d24499e | ||
|
|
5e953bae45 | ||
|
|
747af376f2 | ||
|
|
a3ccccfe52 | ||
|
|
3299d15f28 | ||
|
|
ae82373457 | ||
|
|
d65233cc2c | ||
|
|
11dc06863e |
47 changed files with 857 additions and 92 deletions
35
.vscode/launch.json
vendored
Normal file
35
.vscode/launch.json
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"$schema": "vscode://schemas/launch",
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run 3x-ui (Debug)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
},
|
||||
{
|
||||
"name": "Run 3x-ui (Debug, custom env)",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${workspaceFolder}",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
// Set to true to serve assets/templates directly from disk for development
|
||||
"XUI_DEBUG": "true",
|
||||
// Uncomment to override DB folder location (by default uses working dir on Windows when debug)
|
||||
// "XUI_DB_FOLDER": "${workspaceFolder}",
|
||||
// Example: override log level (debug|info|notice|warn|error)
|
||||
// "XUI_LOG_LEVEL": "debug"
|
||||
},
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
40
.vscode/tasks.json
vendored
Normal file
40
.vscode/tasks.json
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "go: build",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["build", "-o", "bin/3x-ui.exe", "./main.go"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": ["$go"],
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
},
|
||||
{
|
||||
"label": "go: run",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["run", "./main.go"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"XUI_DEBUG": "true"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$go"]
|
||||
},
|
||||
{
|
||||
"label": "go: test",
|
||||
"type": "shell",
|
||||
"command": "go",
|
||||
"args": ["test", "./..."],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
"problemMatcher": ["$go"],
|
||||
"group": "test"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ func initModels() error {
|
|||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.Server{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
|
|
|
|||
|
|
@ -115,3 +115,12 @@ type VLESSSettings struct {
|
|||
Encryption string `json:"encryption"`
|
||||
Fallbacks []any `json:"fallbacks"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -64,6 +64,7 @@ require (
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // 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/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
|
|
|
|||
|
|
@ -137,6 +137,13 @@ config_after_install() {
|
|||
fi
|
||||
|
||||
/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() {
|
||||
|
|
|
|||
15
main.go
15
main.go
|
|
@ -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())
|
||||
if err != nil {
|
||||
fmt.Println("Database initialization failed:", err)
|
||||
|
|
@ -242,6 +242,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
|
|||
settingService := service.SettingService{}
|
||||
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 {
|
||||
err := settingService.SetPort(port)
|
||||
if err != nil {
|
||||
|
|
@ -388,9 +397,11 @@ func main() {
|
|||
var show bool
|
||||
var getCert bool
|
||||
var resetTwoFactor bool
|
||||
var apiKey string
|
||||
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
|
||||
settingCmd.BoolVar(&show, "show", false, "Display current settings")
|
||||
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(&password, "password", "", "Set login password")
|
||||
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
|
||||
|
|
@ -440,7 +451,7 @@ func main() {
|
|||
if reset {
|
||||
resetSetting()
|
||||
} else {
|
||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
|
||||
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
|
||||
}
|
||||
if show {
|
||||
showSetting(show)
|
||||
|
|
|
|||
12
sub/sub.go
12
sub/sub.go
|
|
@ -30,7 +30,7 @@ func setEmbeddedTemplates(engine *gin.Engine) error {
|
|||
webpkg.EmbeddedHTML(),
|
||||
"html/common/page.html",
|
||||
"html/component/aThemeSwitch.html",
|
||||
"html/subscription.html",
|
||||
"html/settings/panel/subscription/subpage.html",
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -85,6 +85,12 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Determine if JSON subscription endpoint is enabled
|
||||
subJsonEnable, err := s.settingService.GetSubJsonEnable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Set base_path based on LinksPath for template rendering
|
||||
engine.Use(func(c *gin.Context) {
|
||||
c.Set("base_path", LinksPath)
|
||||
|
|
@ -186,7 +192,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
g := engine.Group("/")
|
||||
|
||||
s.sub = NewSUBController(
|
||||
g, LinksPath, JsonPath, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
g, LinksPath, JsonPath, subJsonEnable, Encrypt, ShowInfo, RemarkModel, SubUpdates,
|
||||
SubJsonFragment, SubJsonNoises, SubJsonMux, SubJsonRules, SubTitle)
|
||||
|
||||
return engine, nil
|
||||
|
|
@ -207,7 +213,7 @@ func (s *Server) getHtmlFiles() ([]string, error) {
|
|||
files = append(files, theme)
|
||||
}
|
||||
// page itself
|
||||
page := filepath.Join(dir, "web", "html", "subscription.html")
|
||||
page := filepath.Join(dir, "web", "html", "subpage.html")
|
||||
if _, err := os.Stat(page); err == nil {
|
||||
files = append(files, page)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type SUBController struct {
|
|||
subTitle string
|
||||
subPath string
|
||||
subJsonPath string
|
||||
jsonEnabled bool
|
||||
subEncrypt bool
|
||||
updateInterval string
|
||||
|
||||
|
|
@ -24,6 +25,7 @@ func NewSUBController(
|
|||
g *gin.RouterGroup,
|
||||
subPath string,
|
||||
jsonPath string,
|
||||
jsonEnabled bool,
|
||||
encrypt bool,
|
||||
showInfo bool,
|
||||
rModel string,
|
||||
|
|
@ -39,6 +41,7 @@ func NewSUBController(
|
|||
subTitle: subTitle,
|
||||
subPath: subPath,
|
||||
subJsonPath: jsonPath,
|
||||
jsonEnabled: jsonEnabled,
|
||||
subEncrypt: encrypt,
|
||||
updateInterval: update,
|
||||
|
||||
|
|
@ -51,10 +54,11 @@ func NewSUBController(
|
|||
|
||||
func (a *SUBController) initRouter(g *gin.RouterGroup) {
|
||||
gLink := g.Group(a.subPath)
|
||||
gJson := g.Group(a.subJsonPath)
|
||||
|
||||
gLink.GET(":subid", a.subs)
|
||||
gJson.GET(":subid", a.subJsons)
|
||||
if a.jsonEnabled {
|
||||
gJson := g.Group(a.subJsonPath)
|
||||
gJson.GET(":subid", a.subJsons)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *SUBController) subs(c *gin.Context) {
|
||||
|
|
@ -74,8 +78,11 @@ func (a *SUBController) subs(c *gin.Context) {
|
|||
if strings.Contains(strings.ToLower(accept), "text/html") || c.Query("html") == "1" || strings.EqualFold(c.Query("view"), "html") {
|
||||
// Build page data in service
|
||||
subURL, subJsonURL := a.subService.BuildURLs(scheme, hostWithPort, a.subPath, a.subJsonPath, subId)
|
||||
if !a.jsonEnabled {
|
||||
subJsonURL = ""
|
||||
}
|
||||
page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL)
|
||||
c.HTML(200, "subscription.html", gin.H{
|
||||
c.HTML(200, "subpage.html", gin.H{
|
||||
"title": "subscription.title",
|
||||
"cur_ver": config.GetVersion(),
|
||||
"host": page.Host,
|
||||
|
|
|
|||
|
|
@ -159,26 +159,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
|||
}
|
||||
|
||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
return s.genVmessLink(inbound, email)
|
||||
case "vless":
|
||||
return s.genVlessLink(inbound, email)
|
||||
case "trojan":
|
||||
return s.genTrojanLink(inbound, email)
|
||||
case "shadowsocks":
|
||||
return s.genShadowsocksLink(inbound, email)
|
||||
serverService := service.MultiServerService{}
|
||||
servers, err := serverService.GetServers()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get servers for subscription:", err)
|
||||
return ""
|
||||
}
|
||||
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 {
|
||||
return ""
|
||||
}
|
||||
obj := map[string]any{
|
||||
"v": "2",
|
||||
"add": s.address,
|
||||
"add": server.Address,
|
||||
"port": inbound.Port,
|
||||
"type": "none",
|
||||
}
|
||||
|
|
@ -291,7 +308,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
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["port"] = int(ep["port"].(float64))
|
||||
|
||||
|
|
@ -307,14 +324,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
|||
return links
|
||||
}
|
||||
|
||||
obj["ps"] = s.genRemark(inbound, email, "")
|
||||
obj["ps"] = s.genRemark(inbound, email, "", server.Name)
|
||||
|
||||
jsonStr, _ := json.MarshalIndent(obj, "", " ")
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
|
||||
}
|
||||
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
address := server.Address
|
||||
if inbound.Protocol != model.VLESS {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -493,7 +510,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||
// Set the new query values on the URL
|
||||
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 {
|
||||
links += "\n"
|
||||
|
|
@ -514,12 +531,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||
// Set the new query values on the URL
|
||||
url.RawQuery = q.Encode()
|
||||
|
||||
url.Fragment = s.genRemark(inbound, email, "")
|
||||
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
address := server.Address
|
||||
if inbound.Protocol != model.Trojan {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -688,7 +705,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||
// Set the new query values on the URL
|
||||
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 {
|
||||
links += "\n"
|
||||
|
|
@ -710,12 +727,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||
// Set the new query values on the URL
|
||||
url.RawQuery = q.Encode()
|
||||
|
||||
url.Fragment = s.genRemark(inbound, email, "")
|
||||
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||
return url.String()
|
||||
}
|
||||
|
||||
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
||||
address := s.address
|
||||
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
address := server.Address
|
||||
if inbound.Protocol != model.Shadowsocks {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -855,7 +872,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
|||
// Set the new query values on the URL
|
||||
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 {
|
||||
links += "\n"
|
||||
|
|
@ -876,17 +893,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
|||
// Set the new query values on the URL
|
||||
url.RawQuery = q.Encode()
|
||||
|
||||
url.Fragment = s.genRemark(inbound, email, "")
|
||||
url.Fragment = s.genRemark(inbound, email, "", server.Name)
|
||||
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])
|
||||
orderChars := s.remarkModel[1:]
|
||||
orders := map[byte]string{
|
||||
'i': "",
|
||||
'e': "",
|
||||
'o': "",
|
||||
's': "",
|
||||
}
|
||||
if len(email) > 0 {
|
||||
orders['e'] = email
|
||||
|
|
@ -897,6 +915,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
|
|||
if len(extra) > 0 {
|
||||
orders['o'] = extra
|
||||
}
|
||||
if len(serverName) > 0 {
|
||||
orders['s'] = serverName
|
||||
}
|
||||
|
||||
var remark []string
|
||||
for i := 0; i < len(orderChars); i++ {
|
||||
|
|
@ -1007,7 +1028,7 @@ func searchHost(headers any) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// PageData is a view model for subscription.html
|
||||
// PageData is a view model for subpage.html
|
||||
type PageData struct {
|
||||
Host string
|
||||
BasePath string
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ class AllSetting {
|
|||
this.twoFactorEnable = false;
|
||||
this.twoFactorToken = "";
|
||||
this.xrayTemplateConfig = "";
|
||||
this.subEnable = false;
|
||||
this.subEnable = true;
|
||||
this.subJsonEnable = false;
|
||||
this.subTitle = "";
|
||||
this.subListen = "";
|
||||
this.subPort = 2096;
|
||||
|
|
|
|||
|
|
@ -101,7 +101,10 @@
|
|||
if (sj) this.app.subJsonUrl = sj;
|
||||
drawQR(this.app.subUrl);
|
||||
try {
|
||||
new QRious({ element: document.getElementById('qrcode-subjson'), value: this.app.subJsonUrl || '', size: 220 });
|
||||
const elJson = document.getElementById('qrcode-subjson');
|
||||
if (elJson && this.app.subJsonUrl) {
|
||||
new QRious({ element: elJson, value: this.app.subJsonUrl, size: 220 });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
this._onResize = () => { this.viewportWidth = window.innerWidth; };
|
||||
window.addEventListener('resize', this._onResize);
|
||||
|
|
@ -127,6 +130,18 @@
|
|||
const base64Url = btoa(rawUrl);
|
||||
const remark = encodeURIComponent(this.app.sId || 'Subscription');
|
||||
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||
},
|
||||
v2boxUrl() {
|
||||
return `v2box://install-sub?url=${encodeURIComponent(this.app.subUrl)}&name=${encodeURIComponent(this.app.sId)}`;
|
||||
},
|
||||
streisandUrl() {
|
||||
return `streisand://import/${encodeURIComponent(this.app.subUrl)}`;
|
||||
},
|
||||
v2raytunUrl() {
|
||||
return this.app.subUrl;
|
||||
},
|
||||
npvtunUrl() {
|
||||
return this.app.subUrl;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strconv"
|
||||
|
||||
"x-ui/database/model"
|
||||
"x-ui/web/middleware"
|
||||
"x-ui/web/service"
|
||||
"x-ui/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)
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
|||
|
||||
g.GET("/", a.index)
|
||||
g.GET("/inbounds", a.inbounds)
|
||||
g.GET("/servers", a.servers)
|
||||
g.GET("/settings", a.settings)
|
||||
g.GET("/xray", a.xraySettings)
|
||||
|
||||
|
|
@ -49,3 +50,7 @@ func (a *XUIController) settings(c *gin.Context) {
|
|||
func (a *XUIController) xraySettings(c *gin.Context) {
|
||||
html(c, "xray.html", "pages.xray.title", nil)
|
||||
}
|
||||
|
||||
func (a *XUIController) servers(c *gin.Context) {
|
||||
html(c, "servers.html", "Servers", nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type AllSetting struct {
|
|||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
|
||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
|
||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||
SubListen string `json:"subListen" form:"subListen"`
|
||||
SubPort int `json:"subPort" form:"subPort"`
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@
|
|||
<a-space direction="horizontal" :size="2">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<template v-if="isClientDepleted">{{ i18n "depleted" }}</template>
|
||||
<template v-if="!isClientDepleted && !client.enable">{{ i18n "disabled" }}</template>
|
||||
<template v-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
|
||||
<template v-if="isClientDepleted(record, client.email)">{{ i18n "depleted" }}</template>
|
||||
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template>
|
||||
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
|
||||
</template>
|
||||
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
|
||||
</a-tooltip>
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
|
||||
</td>
|
||||
<td class="tr-table-bar" v-else-if="client.totalGB > 0">
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
</td>
|
||||
<td v-else class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :percent="100"></a-progress>
|
||||
|
|
@ -126,7 +126,7 @@
|
|||
<tr class="tr-table-box">
|
||||
<td class="tr-table-rt"> [[ remainedDays(client.expiryTime) ]] </td>
|
||||
<td class="infinite-bar tr-table-bar">
|
||||
<a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</td>
|
||||
<td class="tr-table-lt">[[ client.reset + "d" ]]</td>
|
||||
</tr>
|
||||
|
|
@ -213,7 +213,7 @@
|
|||
</tr>
|
||||
</table>
|
||||
</template>
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
|
||||
</a-popover>
|
||||
</td>
|
||||
<td width="120px" v-else class="infinite-bar">
|
||||
|
|
@ -247,7 +247,7 @@
|
|||
</template>
|
||||
</span>
|
||||
</template>
|
||||
<a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
|
||||
</a-popover>
|
||||
</td>
|
||||
<td width="60px">[[ client.reset + "d" ]]</td>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@
|
|||
icon: 'user',
|
||||
title: '{{ i18n "menu.inbounds"}}'
|
||||
},
|
||||
{
|
||||
key: '{{ .base_path }}panel/servers',
|
||||
icon: 'cloud-server',
|
||||
title: 'Servers'
|
||||
},
|
||||
{
|
||||
key: '{{ .base_path }}panel/settings',
|
||||
icon: 'setting',
|
||||
|
|
|
|||
|
|
@ -736,10 +736,11 @@
|
|||
refreshing: false,
|
||||
refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000,
|
||||
subSettings: {
|
||||
enable: false,
|
||||
enable: true,
|
||||
subTitle: '',
|
||||
subURI: '',
|
||||
subJsonURI: '',
|
||||
subJsonEnable: false,
|
||||
},
|
||||
remarkModel: '-ieo',
|
||||
datepicker: 'gregorian',
|
||||
|
|
@ -795,7 +796,8 @@
|
|||
enable: subEnable,
|
||||
subTitle: subTitle,
|
||||
subURI: subURI,
|
||||
subJsonURI: subJsonURI
|
||||
subJsonURI: subJsonURI,
|
||||
subJsonEnable: subJsonEnable,
|
||||
};
|
||||
this.pageSize = pageSize;
|
||||
this.remarkModel = remarkModel;
|
||||
|
|
@ -1432,15 +1434,19 @@
|
|||
clientStats = dbInbound.clientStats ? dbInbound.clientStats.find(stats => stats.email === email) : null;
|
||||
return clientStats ? clientStats['enable'] : true;
|
||||
},
|
||||
// Returns true when client's traffic is exhausted or expiry time is passed
|
||||
isClientDepleted(dbInbound, email) {
|
||||
if (!email || !dbInbound || !dbInbound.clientStats) return false;
|
||||
const stats = dbInbound.clientStats.find(s => s.email === email);
|
||||
if (!stats) return false;
|
||||
const now = new Date().getTime();
|
||||
const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
|
||||
const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
|
||||
return exhausted || expired;
|
||||
const total = stats.total ?? 0;
|
||||
const used = (stats.up ?? 0) + (stats.down ?? 0);
|
||||
const hasTotal = total > 0;
|
||||
const exhausted = hasTotal && used >= total;
|
||||
const expiryTime = stats.expiryTime ?? 0;
|
||||
const hasExpiry = expiryTime > 0;
|
||||
const now = Date.now();
|
||||
const expired = hasExpiry && expiryTime <= now;
|
||||
return expired || exhausted;
|
||||
},
|
||||
isClientOnline(email) {
|
||||
return this.onlineClients.includes(email);
|
||||
|
|
|
|||
|
|
@ -180,9 +180,9 @@
|
|||
<tr>
|
||||
<td>{{ i18n "status" }}</td>
|
||||
<td>
|
||||
<a-tag v-if="isEnable && isActive && !isDepleted" color="green">{{ i18n "enabled" }}</a-tag>
|
||||
<a-tag v-if="!isEnable && !isDepleted">{{ i18n "disabled" }}</a-tag>
|
||||
<a-tag v-if="isDepleted" color="red">{{ i18n "depleted" }}</a-tag>
|
||||
<a-tag v-else-if="isEnable" color="green">{{ i18n "enabled" }}</a-tag>
|
||||
<a-tag v-else>{{ i18n "disabled" }}</a-tag>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="infoModal.clientStats">
|
||||
|
|
@ -308,7 +308,7 @@
|
|||
</tr-info-title>
|
||||
<a :href="[[ infoModal.subLink ]]" target="_blank">[[ infoModal.subLink ]]</a>
|
||||
</tr-info-row>
|
||||
<tr-info-row class="tr-info-row">
|
||||
<tr-info-row class="tr-info-row" v-if="app.subSettings.subJsonEnable">
|
||||
<tr-info-title class="tr-info-title">
|
||||
<a-tag color="purple">Json Link</a-tag>
|
||||
<a-tooltip title='{{ i18n "copy" }}'>
|
||||
|
|
@ -524,7 +524,7 @@
|
|||
this.dbInbound = new DBInbound(dbInbound);
|
||||
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
|
||||
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
|
||||
this.clientStats = this.inbound.clients ? this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) : [];
|
||||
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
|
||||
|
||||
if (
|
||||
[
|
||||
|
|
@ -548,7 +548,7 @@
|
|||
if (this.clientSettings) {
|
||||
if (this.clientSettings.subId) {
|
||||
this.subLink = this.genSubLink(this.clientSettings.subId);
|
||||
this.subJsonLink = this.genSubJsonLink(this.clientSettings.subId);
|
||||
this.subJsonLink = app.subSettings.subJsonEnable ? this.genSubJsonLink(this.clientSettings.subId) : '';
|
||||
}
|
||||
}
|
||||
this.visible = true;
|
||||
|
|
@ -588,11 +588,21 @@
|
|||
return infoModal.dbInbound.isEnable;
|
||||
},
|
||||
get isDepleted() {
|
||||
const stats = this.infoModal.clientStats;
|
||||
if (!stats) return false;
|
||||
const now = new Date().getTime();
|
||||
const expired = stats.expiryTime > 0 && now >= stats.expiryTime;
|
||||
const exhausted = stats.total > 0 && (stats.up + stats.down) >= stats.total;
|
||||
const stats = infoModal.clientStats;
|
||||
const settings = infoModal.clientSettings;
|
||||
if (!stats || !settings) {
|
||||
return false;
|
||||
}
|
||||
const total = stats.total ?? 0;
|
||||
const used = (stats.up ?? 0) + (stats.down ?? 0);
|
||||
const hasTotal = total > 0;
|
||||
const exhausted = hasTotal && used >= total;
|
||||
|
||||
const expiryTime = settings.expiryTime ?? 0;
|
||||
const hasExpiry = expiryTime > 0;
|
||||
const now = Date.now();
|
||||
const expired = hasExpiry && now >= expiryTime;
|
||||
|
||||
return expired || exhausted;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
</tr-qr-bg-inner>
|
||||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
<tr-qr-box class="qr-box">
|
||||
<tr-qr-box class="qr-box" v-if="app.subSettings.subJsonEnable">
|
||||
<a-tag color="purple" class="qr-tag"><span>{{ i18n "pages.settings.subSettings"}} Json</span></a-tag>
|
||||
<tr-qr-bg class="qr-bg-sub">
|
||||
<tr-qr-bg-inner class="qr-bg-sub-inner">
|
||||
|
|
@ -262,7 +262,9 @@
|
|||
if (qrModal.client && qrModal.client.subId) {
|
||||
qrModal.subId = qrModal.client.subId;
|
||||
this.setQrCode("qrCode-sub", this.genSubLink(qrModal.subId));
|
||||
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
|
||||
if (app.subSettings.subJsonEnable) {
|
||||
this.setQrCode("qrCode-subJson", this.genSubJsonLink(qrModal.subId));
|
||||
}
|
||||
}
|
||||
qrModal.qrcodes.forEach((element, index) => {
|
||||
this.setQrCode("qrCode-" + index, element.link);
|
||||
|
|
|
|||
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" .}}
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
</template>
|
||||
{{ template "settings/panel/subscription/general" . }}
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="5" v-if="allSetting.subEnable" :style="{ paddingTop: '20px' }">
|
||||
<a-tab-pane key="5" v-if="allSetting.subJsonEnable" :style="{ paddingTop: '20px' }">
|
||||
<template #tab>
|
||||
<a-icon type="code"></a-icon>
|
||||
<span>{{ i18n "pages.settings.subSettings" }} (JSON)</span>
|
||||
|
|
@ -523,6 +523,8 @@
|
|||
if (this.allSetting.subEnable) {
|
||||
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath;
|
||||
if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}');
|
||||
}
|
||||
if (this.allSetting.subJsonEnable) {
|
||||
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
|
||||
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@
|
|||
<a-switch v-model="allSetting.subEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>JSON Subscription</template>
|
||||
<template #description>{{ i18n "pages.settings.subJsonEnable"}}</template>
|
||||
<template #control>
|
||||
<a-switch v-model="allSetting.subJsonEnable"></a-switch>
|
||||
</template>
|
||||
</a-setting-list-item>
|
||||
<a-setting-list-item paddings="small">
|
||||
<template #title>{{ i18n "pages.settings.subTitle"}}</template>
|
||||
<template #description>{{ i18n "pages.settings.subTitleDesc"}}</template>
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@
|
|||
<a-space direction="vertical" align="center">
|
||||
<a-row type="flex" :gutter="[8,8]"
|
||||
justify="center" style="width:100%">
|
||||
<a-col :xs="24" :sm="12"
|
||||
<a-col :xs="24" :sm="app.subJsonUrl ? 12 : 24"
|
||||
style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
|
|
@ -75,7 +75,7 @@
|
|||
</tr-qr-bg>
|
||||
</tr-qr-box>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="12"
|
||||
<a-col v-if="app.subJsonUrl" :xs="24" :sm="12"
|
||||
style="text-align:center;">
|
||||
<tr-qr-box class="qr-box">
|
||||
<a-tag color="purple"
|
||||
|
|
@ -235,14 +235,15 @@
|
|||
<a-menu-item key="ios-shadowrocket"
|
||||
@click="open(shadowrocketUrl)">Shadowrocket</a-menu-item>
|
||||
<a-menu-item key="ios-v2box"
|
||||
@click="open('v2box://install-sub?url=' + encodeURIComponent(app.subUrl) + '&name=' + encodeURIComponent(app.sId))">V2Box</a-menu-item>
|
||||
@click="open(v2boxUrl)">V2Box</a-menu-item>
|
||||
<a-menu-item key="ios-streisand"
|
||||
@click="open('streisand://import/' + encodeURIComponent(app.subUrl))">Streisand</a-menu-item>
|
||||
@click="open(streisandUrl)">Streisand</a-menu-item>
|
||||
<a-menu-item key="ios-v2raytun"
|
||||
@click="copy(app.subUrl)">V2RayTun</a-menu-item>
|
||||
@click="copy(v2raytunUrl)">V2RayTun</a-menu-item>
|
||||
<a-menu-item key="ios-npvtunnel"
|
||||
@click="copy(app.subUrl)">NPV
|
||||
Tunnel</a-menu-item>
|
||||
@click="copy(npvtunUrl)">NPV
|
||||
Tunnel
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</a-dropdown>
|
||||
</a-col>
|
||||
|
|
@ -20,12 +20,16 @@ func NewPeriodicTrafficResetJob(period Period) *PeriodicTrafficResetJob {
|
|||
|
||||
func (j *PeriodicTrafficResetJob) Run() {
|
||||
inbounds, err := j.inboundService.GetInboundsByTrafficReset(string(j.period))
|
||||
logger.Infof("Running periodic traffic reset job for period: %s", j.period)
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get inbounds for traffic reset:", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(inbounds) == 0 {
|
||||
return
|
||||
}
|
||||
logger.Infof("Running periodic traffic reset job for period: %s (%d matching inbounds)", j.period, len(inbounds))
|
||||
|
||||
resetCount := 0
|
||||
|
||||
for _, inbound := range inbounds {
|
||||
|
|
|
|||
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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -617,6 +620,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -705,6 +713,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -880,6 +893,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
|
|||
logger.Debug("Client old email not found")
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -2295,6 +2314,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
|
|||
|
||||
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) {
|
||||
oldInbound, err := s.GetInbound(inboundId)
|
||||
if err != nil {
|
||||
|
|
@ -2386,4 +2443,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
|||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
@ -50,7 +50,8 @@ var defaultValueMap = map[string]string{
|
|||
"tgLang": "en-US",
|
||||
"twoFactorEnable": "false",
|
||||
"twoFactorToken": "",
|
||||
"subEnable": "false",
|
||||
"subEnable": "true",
|
||||
"subJsonEnable": "false",
|
||||
"subTitle": "",
|
||||
"subListen": "",
|
||||
"subPort": "2096",
|
||||
|
|
@ -180,6 +181,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
|
|||
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 {
|
||||
setting, err := s.getSetting(key)
|
||||
db := database.GetDB()
|
||||
|
|
@ -427,6 +443,10 @@ func (s *SettingService) GetSubEnable() (bool, error) {
|
|||
return s.getBool("subEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubJsonEnable() (bool, error) {
|
||||
return s.getBool("subJsonEnable")
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubTitle() (string, error) {
|
||||
return s.getString("subTitle")
|
||||
}
|
||||
|
|
@ -575,6 +595,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||
"subTitle": func() (any, error) { return s.GetSubTitle() },
|
||||
"subURI": func() (any, error) { return s.GetSubURI() },
|
||||
"subJsonURI": func() (any, error) { return s.GetSubJsonURI() },
|
||||
|
|
@ -593,7 +614,14 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
result[key] = value
|
||||
}
|
||||
|
||||
if result["subEnable"].(bool) && (result["subURI"].(string) == "" || result["subJsonURI"].(string) == "") {
|
||||
subEnable := result["subEnable"].(bool)
|
||||
subJsonEnable := false
|
||||
if v, ok := result["subJsonEnable"]; ok {
|
||||
if b, ok2 := v.(bool); ok2 {
|
||||
subJsonEnable = b
|
||||
}
|
||||
}
|
||||
if (subEnable && result["subURI"].(string) == "") || (subJsonEnable && result["subJsonURI"].(string) == "") {
|
||||
subURI := ""
|
||||
subTitle, _ := s.GetSubTitle()
|
||||
subPort, _ := s.GetSubPort()
|
||||
|
|
@ -619,13 +647,13 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||
} else {
|
||||
subURI += fmt.Sprintf("%s:%d", subDomain, subPort)
|
||||
}
|
||||
if result["subURI"].(string) == "" {
|
||||
if subEnable && result["subURI"].(string) == "" {
|
||||
result["subURI"] = subURI + subPath
|
||||
}
|
||||
if result["subTitle"].(string) == "" {
|
||||
result["subTitle"] = subTitle
|
||||
}
|
||||
if result["subJsonURI"].(string) == "" {
|
||||
if subJsonEnable && result["subJsonURI"].(string) == "" {
|
||||
result["subJsonURI"] = subURI + subJsonPath
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2093,6 +2093,7 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||
subPort, _ := t.settingService.GetSubPort()
|
||||
subPath, _ := t.settingService.GetSubPath()
|
||||
subJsonPath, _ := t.settingService.GetSubJsonPath()
|
||||
subJsonEnable, _ := t.settingService.GetSubJsonEnable()
|
||||
subKeyFile, _ := t.settingService.GetSubKeyFile()
|
||||
subCertFile, _ := t.settingService.GetSubCertFile()
|
||||
|
||||
|
|
@ -2137,6 +2138,9 @@ func (t *Tgbot) buildSubscriptionURLs(email string) (string, string, error) {
|
|||
|
||||
subURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subPath, client.SubID)
|
||||
subJsonURL := fmt.Sprintf("%s://%s%s%s", scheme, host, subJsonPath, client.SubID)
|
||||
if !subJsonEnable {
|
||||
subJsonURL = ""
|
||||
}
|
||||
return subURL, subJsonURL, nil
|
||||
}
|
||||
|
||||
|
|
@ -2146,8 +2150,10 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
|
|||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
return
|
||||
}
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>\r\n\r\n" +
|
||||
"JSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
msg := "Subscription URL:\r\n<code>" + subURL + "</code>"
|
||||
if subJsonURL != "" {
|
||||
msg += "\r\n\r\nJSON URL:\r\n<code>" + subJsonURL + "</code>"
|
||||
}
|
||||
inlineKeyboard := tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
tu.InlineKeyboardButton(t.I18nBot("subscription.individualLinks")).WithCallbackData(t.encodeQuery("client_individual_links "+email)),
|
||||
|
|
@ -2271,15 +2277,17 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
|
|||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
|
||||
// Send JSON URL QR (filename: subjson.png)
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
// Send JSON URL QR (filename: subjson.png) when available
|
||||
if subJsonURL != "" {
|
||||
if png, err := createQR(subJsonURL, 320); err == nil {
|
||||
document := tu.Document(
|
||||
tu.ID(chatId),
|
||||
tu.FileFromBytes(png, "subjson.png"),
|
||||
)
|
||||
_, _ = bot.SendDocument(context.Background(), document)
|
||||
} else {
|
||||
t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Also generate a few individual links' QRs (first up to 5)
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "الاشتراك"
|
||||
"subEnable" = "تفعيل خدمة الاشتراك"
|
||||
"subEnableDesc" = "يفعل خدمة الاشتراك."
|
||||
"subJsonEnable" = "تمكين/تعطيل نقطة نهاية اشتراك JSON بشكل مستقل."
|
||||
"subTitle" = "عنوان الاشتراك"
|
||||
"subTitleDesc" = "العنوان اللي هيظهر في عميل VPN"
|
||||
"subListen" = "IP الاستماع"
|
||||
|
|
|
|||
|
|
@ -369,8 +369,9 @@
|
|||
"timeZone" = "Time Zone"
|
||||
"timeZoneDesc" = "Scheduled tasks will run based on this time zone."
|
||||
"subSettings" = "Subscription"
|
||||
"subEnable" = "Enable Subscription Service"
|
||||
"subEnableDesc" = "Enables the subscription service."
|
||||
"subEnable" = "Subscription Service"
|
||||
"subEnableDesc" = "Enable/Disable the subscription service."
|
||||
"subJsonEnable" = "Enable/Disable the JSON subscription endpoint independently."
|
||||
"subTitle" = "Subscription Title"
|
||||
"subTitleDesc" = "Title shown in VPN client"
|
||||
"subListen" = "Listen IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Suscripción"
|
||||
"subEnable" = "Habilitar Servicio"
|
||||
"subEnableDesc" = "Función de suscripción con configuración separada."
|
||||
"subJsonEnable" = "Habilitar/Deshabilitar el endpoint de suscripción JSON de forma independiente."
|
||||
"subTitle" = "Título de la Suscripción"
|
||||
"subTitleDesc" = "Título mostrado en el cliente de VPN"
|
||||
"subListen" = "Listening IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "سابسکریپشن"
|
||||
"subEnable" = "فعالسازی سرویس سابسکریپشن"
|
||||
"subEnableDesc" = "سرویس سابسکریپشن را فعالمیکند"
|
||||
"subJsonEnable" = "فعال/غیرفعالسازی مستقل نقطه دسترسی سابسکریپشن JSON."
|
||||
"subTitle" = "عنوان اشتراک"
|
||||
"subTitleDesc" = "عنوان نمایش داده شده در کلاینت VPN"
|
||||
"subListen" = "آدرس آیپی"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Langganan"
|
||||
"subEnable" = "Aktifkan Layanan Langganan"
|
||||
"subEnableDesc" = "Mengaktifkan layanan langganan."
|
||||
"subJsonEnable" = "Aktifkan/Nonaktifkan endpoint langganan JSON secara mandiri."
|
||||
"subTitle" = "Judul Langganan"
|
||||
"subTitleDesc" = "Judul yang ditampilkan di klien VPN"
|
||||
"subListen" = "IP Pendengar"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "サブスクリプション設定"
|
||||
"subEnable" = "サブスクリプションサービスを有効にする"
|
||||
"subEnableDesc" = "サブスクリプションサービス機能を有効にする"
|
||||
"subJsonEnable" = "JSON サブスクリプションのエンドポイントを個別に有効/無効にする。"
|
||||
"subTitle" = "サブスクリプションタイトル"
|
||||
"subTitleDesc" = "VPNクライアントに表示されるタイトル"
|
||||
"subListen" = "監視IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Assinatura"
|
||||
"subEnable" = "Ativar Serviço de Assinatura"
|
||||
"subEnableDesc" = "Ativa o serviço de assinatura."
|
||||
"subJsonEnable" = "Ativar/Desativar o endpoint de assinatura JSON de forma independente."
|
||||
"subTitle" = "Título da Assinatura"
|
||||
"subTitleDesc" = "Título exibido no cliente VPN"
|
||||
"subListen" = "IP de Escuta"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Подписка"
|
||||
"subEnable" = "Включить подписку"
|
||||
"subEnableDesc" = "Функция подписки с отдельной конфигурацией"
|
||||
"subJsonEnable" = "Включить/отключить JSON-эндпоинт подписки независимо."
|
||||
"subTitle" = "Заголовок подписки"
|
||||
"subTitleDesc" = "Название подписки, которое видит клиент в VPN клиенте"
|
||||
"subListen" = "Прослушивание IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Abonelik"
|
||||
"subEnable" = "Abonelik Hizmetini Etkinleştir"
|
||||
"subEnableDesc" = "Abonelik hizmetini etkinleştirir."
|
||||
"subJsonEnable" = "JSON abonelik uç noktasını bağımsız olarak Etkinleştir/Devre Dışı bırak."
|
||||
"subTitle" = "Abonelik Başlığı"
|
||||
"subTitleDesc" = "VPN istemcisinde gösterilen başlık"
|
||||
"subListen" = "Dinleme IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Підписка"
|
||||
"subEnable" = "Увімкнути службу підписки"
|
||||
"subEnableDesc" = "Вмикає службу підписки."
|
||||
"subJsonEnable" = "Увімкнути/вимкнути JSON-кінець підписки незалежно."
|
||||
"subTitle" = "Назва Підписки"
|
||||
"subTitleDesc" = "Назва, яка відображається у VPN-клієнті"
|
||||
"subListen" = "Слухати IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "Gói đăng ký"
|
||||
"subEnable" = "Bật dịch vụ"
|
||||
"subEnableDesc" = "Tính năng gói đăng ký với cấu hình riêng"
|
||||
"subJsonEnable" = "Bật/Tắt điểm cuối đăng ký JSON độc lập."
|
||||
"subTitle" = "Tiêu đề Đăng ký"
|
||||
"subTitleDesc" = "Tiêu đề hiển thị trong ứng dụng VPN"
|
||||
"subListen" = "Listening IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "订阅设置"
|
||||
"subEnable" = "启用订阅服务"
|
||||
"subEnableDesc" = "启用订阅服务功能"
|
||||
"subJsonEnable" = "单独启用/禁用 JSON 订阅端点。"
|
||||
"subTitle" = "订阅标题"
|
||||
"subTitleDesc" = "在VPN客户端中显示的标题"
|
||||
"subListen" = "监听 IP"
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@
|
|||
"subSettings" = "訂閱設定"
|
||||
"subEnable" = "啟用訂閱服務"
|
||||
"subEnableDesc" = "啟用訂閱服務功能"
|
||||
"subJsonEnable" = "獨立啟用/停用 JSON 訂閱端點。"
|
||||
"subTitle" = "訂閱標題"
|
||||
"subTitleDesc" = "在VPN客戶端中顯示的標題"
|
||||
"subListen" = "監聽 IP"
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
|||
g := engine.Group(basePath)
|
||||
|
||||
s.index = controller.NewIndexController(g)
|
||||
s.server = controller.NewServerController(g)
|
||||
s.server = controller.NewMultiServerController(g)
|
||||
s.panel = controller.NewXUIController(g)
|
||||
s.api = controller.NewAPIController(g)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue