mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-11-29 02:42:51 +00:00
Compare commits
16 commits
8945d13e87
...
68719eef34
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68719eef34 | ||
|
|
4a75bd0a48 | ||
|
|
b0c223c631 | ||
|
|
313b51f96f | ||
|
|
020cd63e22 | ||
|
|
6e46e9b16e | ||
|
|
3b262cf180 | ||
|
|
4c7249c451 | ||
|
|
edd8b12988 | ||
|
|
5e953bae45 | ||
|
|
747af376f2 | ||
|
|
a3ccccfe52 | ||
|
|
3299d15f28 | ||
|
|
ae82373457 | ||
|
|
d65233cc2c | ||
|
|
11dc06863e |
25 changed files with 1064 additions and 276 deletions
|
|
@ -18,7 +18,7 @@
|
|||
**3X-UI** — advanced, open-source web-based control panel designed for managing Xray-core server. It offers a user-friendly interface for configuring and monitoring various VPN and proxy protocols.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This project is only for personal using, please do not use it for illegal purposes, please do not use it in a production environment.
|
||||
> This project is only for personal usage, please do not use it for illegal purposes, and please do not use it in a production environment.
|
||||
|
||||
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func initModels() error {
|
|||
&model.InboundClientIps{},
|
||||
&xray.ClientTraffic{},
|
||||
&model.HistoryOfSeeders{},
|
||||
&model.Server{},
|
||||
}
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
|
|
|
|||
|
|
@ -119,3 +119,12 @@ type Client struct {
|
|||
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
|
||||
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
Name string `json:"name" gorm:"unique;not null"`
|
||||
Address string `json:"address" gorm:"not null"`
|
||||
Port int `json:"port" gorm:"not null"`
|
||||
APIKey string `json:"apiKey" gorm:"not null"`
|
||||
Enable bool `json:"enable" gorm:"default:true"`
|
||||
}
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -68,6 +68,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.55.0 // indirect
|
||||
|
|
|
|||
|
|
@ -140,6 +140,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() {
|
||||
|
|
|
|||
30
main.go
30
main.go
|
|
@ -240,7 +240,8 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
|
|||
}
|
||||
|
||||
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
|
||||
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)
|
||||
|
|
@ -250,6 +251,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 {
|
||||
|
|
@ -321,6 +331,20 @@ func updateCert(publicKey string, privateKey string) {
|
|||
} else {
|
||||
fmt.Println("set certificate private key success")
|
||||
}
|
||||
|
||||
err = settingService.SetSubCertFile(publicKey)
|
||||
if err != nil {
|
||||
fmt.Println("set certificate for subscription public key failed:", err)
|
||||
} else {
|
||||
fmt.Println("set certificate for subscription public key success")
|
||||
}
|
||||
|
||||
err = settingService.SetSubKeyFile(privateKey)
|
||||
if err != nil {
|
||||
fmt.Println("set certificate for subscription private key failed:", err)
|
||||
} else {
|
||||
fmt.Println("set certificate for subscription private key success")
|
||||
}
|
||||
} else {
|
||||
fmt.Println("both public and private key should be entered.")
|
||||
}
|
||||
|
|
@ -402,9 +426,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")
|
||||
|
|
@ -454,7 +480,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)
|
||||
|
|
|
|||
|
|
@ -162,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
|
|||
}
|
||||
|
||||
func (s *SubService) getLink(inbound *model.Inbound, email string) string {
|
||||
serverService := service.MultiServerService{}
|
||||
servers, err := serverService.GetServers()
|
||||
if err != nil {
|
||||
logger.Warning("Failed to get servers for subscription:", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
var links []string
|
||||
for _, server := range servers {
|
||||
if !server.Enable {
|
||||
continue
|
||||
}
|
||||
var link string
|
||||
switch inbound.Protocol {
|
||||
case "vmess":
|
||||
return s.genVmessLink(inbound, email)
|
||||
link = s.genVmessLink(inbound, email, server)
|
||||
case "vless":
|
||||
return s.genVlessLink(inbound, email)
|
||||
link = s.genVlessLink(inbound, email, server)
|
||||
case "trojan":
|
||||
return s.genTrojanLink(inbound, email)
|
||||
link = s.genTrojanLink(inbound, email, server)
|
||||
case "shadowsocks":
|
||||
return s.genShadowsocksLink(inbound, email)
|
||||
link = s.genShadowsocksLink(inbound, email, server)
|
||||
}
|
||||
return ""
|
||||
if link != "" {
|
||||
links = append(links, link)
|
||||
}
|
||||
}
|
||||
return strings.Join(links, "\n")
|
||||
}
|
||||
|
||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string {
|
||||
if inbound.Protocol != model.VMESS {
|
||||
return ""
|
||||
}
|
||||
obj := map[string]any{
|
||||
"v": "2",
|
||||
"add": s.address,
|
||||
"add": server.Address,
|
||||
"port": inbound.Port,
|
||||
"type": "none",
|
||||
}
|
||||
|
|
@ -294,7 +311,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))
|
||||
|
||||
|
|
@ -310,14 +327,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 ""
|
||||
}
|
||||
|
|
@ -497,7 +514,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"
|
||||
|
|
@ -518,12 +535,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 ""
|
||||
}
|
||||
|
|
@ -692,7 +709,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"
|
||||
|
|
@ -714,12 +731,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 ""
|
||||
}
|
||||
|
|
@ -859,7 +876,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"
|
||||
|
|
@ -880,17 +897,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
|
||||
|
|
@ -901,6 +919,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++ {
|
||||
|
|
|
|||
|
|
@ -729,8 +729,8 @@ class RealityStreamSettings extends XrayCommonClass {
|
|||
constructor(
|
||||
show = false,
|
||||
xver = 0,
|
||||
target = 'google.com:443',
|
||||
serverNames = 'google.com,www.google.com',
|
||||
target = '',
|
||||
serverNames = '',
|
||||
privateKey = '',
|
||||
minClientVer = '',
|
||||
maxClientVer = '',
|
||||
|
|
@ -740,6 +740,14 @@ class RealityStreamSettings extends XrayCommonClass {
|
|||
settings = new RealityStreamSettings.Settings()
|
||||
) {
|
||||
super();
|
||||
// If target/serverNames are not provided, use random values
|
||||
if (!target && !serverNames) {
|
||||
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
|
||||
? getRandomRealityTarget()
|
||||
: { target: 'google.com:443', sni: 'google.com,www.google.com' };
|
||||
target = randomTarget.target;
|
||||
serverNames = randomTarget.sni;
|
||||
}
|
||||
this.show = show;
|
||||
this.xver = xver;
|
||||
this.target = target;
|
||||
|
|
|
|||
86
web/assets/js/model/reality_targets.js
Normal file
86
web/assets/js/model/reality_targets.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// List of popular services for VLESS Reality Target/SNI randomization
|
||||
const REALITY_TARGETS = [
|
||||
// CDN & Cloud Infrastructure
|
||||
{ target: 'www.cloudflare.com:443', sni: 'www.cloudflare.com,cloudflare.com' },
|
||||
{ target: 'www.microsoft.com:443', sni: 'www.microsoft.com,microsoft.com' },
|
||||
{ target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' },
|
||||
{ target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' },
|
||||
{ target: 'cloud.google.com:443', sni: 'cloud.google.com,www.google.com' },
|
||||
{ target: 'azure.microsoft.com:443', sni: 'azure.microsoft.com,www.azure.com' },
|
||||
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com,amazon.com' },
|
||||
{ target: 'www.digitalocean.com:443', sni: 'www.digitalocean.com,digitalocean.com' },
|
||||
|
||||
// Social Media
|
||||
{ target: 'www.facebook.com:443', sni: 'www.facebook.com,facebook.com' },
|
||||
{ target: 'www.instagram.com:443', sni: 'www.instagram.com,instagram.com' },
|
||||
{ target: 'www.twitter.com:443', sni: 'www.twitter.com,twitter.com' },
|
||||
{ target: 'www.linkedin.com:443', sni: 'www.linkedin.com,linkedin.com' },
|
||||
{ target: 'www.reddit.com:443', sni: 'www.reddit.com,reddit.com' },
|
||||
{ target: 'www.pinterest.com:443', sni: 'www.pinterest.com,pinterest.com' },
|
||||
{ target: 'www.tumblr.com:443', sni: 'www.tumblr.com,tumblr.com' },
|
||||
|
||||
// Video & Streaming
|
||||
{ target: 'www.youtube.com:443', sni: 'www.youtube.com,youtube.com' },
|
||||
{ target: 'www.netflix.com:443', sni: 'www.netflix.com,netflix.com' },
|
||||
{ target: 'www.twitch.tv:443', sni: 'www.twitch.tv,twitch.tv' },
|
||||
{ target: 'vimeo.com:443', sni: 'vimeo.com,www.vimeo.com' },
|
||||
{ target: 'www.hulu.com:443', sni: 'www.hulu.com,hulu.com' },
|
||||
{ target: 'www.disneyplus.com:443', sni: 'www.disneyplus.com,disneyplus.com' },
|
||||
|
||||
// News & Media
|
||||
{ target: 'www.bbc.com:443', sni: 'www.bbc.com,bbc.com' },
|
||||
{ target: 'www.cnn.com:443', sni: 'www.cnn.com,cnn.com' },
|
||||
{ target: 'www.nytimes.com:443', sni: 'www.nytimes.com,nytimes.com' },
|
||||
{ target: 'www.theguardian.com:443', sni: 'www.theguardian.com,theguardian.com' },
|
||||
{ target: 'www.reuters.com:443', sni: 'www.reuters.com,reuters.com' },
|
||||
{ target: 'www.bloomberg.com:443', sni: 'www.bloomberg.com,bloomberg.com' },
|
||||
|
||||
// E-commerce
|
||||
{ target: 'www.ebay.com:443', sni: 'www.ebay.com,ebay.com' },
|
||||
{ target: 'www.alibaba.com:443', sni: 'www.alibaba.com,alibaba.com' },
|
||||
{ target: 'www.shopify.com:443', sni: 'www.shopify.com,shopify.com' },
|
||||
{ target: 'www.walmart.com:443', sni: 'www.walmart.com,walmart.com' },
|
||||
{ target: 'www.target.com:443', sni: 'www.target.com,target.com' },
|
||||
|
||||
// Tech Companies
|
||||
{ target: 'www.github.com:443', sni: 'www.github.com,github.com' },
|
||||
{ target: 'www.stackoverflow.com:443', sni: 'www.stackoverflow.com,stackoverflow.com' },
|
||||
{ target: 'www.gitlab.com:443', sni: 'www.gitlab.com,gitlab.com' },
|
||||
{ target: 'www.docker.com:443', sni: 'www.docker.com,docker.com' },
|
||||
{ target: 'www.nvidia.com:443', sni: 'www.nvidia.com,nvidia.com' },
|
||||
{ target: 'www.intel.com:443', sni: 'www.intel.com,intel.com' },
|
||||
{ target: 'www.amd.com:443', sni: 'www.amd.com,amd.com' },
|
||||
|
||||
// Communication & Productivity
|
||||
{ target: 'www.zoom.us:443', sni: 'www.zoom.us,zoom.us' },
|
||||
{ target: 'slack.com:443', sni: 'slack.com,www.slack.com' },
|
||||
{ target: 'www.dropbox.com:443', sni: 'www.dropbox.com,dropbox.com' },
|
||||
{ target: 'www.notion.so:443', sni: 'www.notion.so,notion.so' },
|
||||
{ target: 'www.atlassian.com:443', sni: 'www.atlassian.com,atlassian.com' },
|
||||
{ target: 'www.salesforce.com:443', sni: 'www.salesforce.com,salesforce.com' },
|
||||
|
||||
// Search & General
|
||||
{ target: 'www.wikipedia.org:443', sni: 'www.wikipedia.org,wikipedia.org' },
|
||||
{ target: 'www.bing.com:443', sni: 'www.bing.com,bing.com' },
|
||||
{ target: 'www.yahoo.com:443', sni: 'www.yahoo.com,yahoo.com' },
|
||||
{ target: 'www.duckduckgo.com:443', sni: 'www.duckduckgo.com,duckduckgo.com' },
|
||||
|
||||
// Gaming
|
||||
{ target: 'store.steampowered.com:443', sni: 'store.steampowered.com,steampowered.com' },
|
||||
{ target: 'www.ea.com:443', sni: 'www.ea.com,ea.com' },
|
||||
{ target: 'www.epicgames.com:443', sni: 'www.epicgames.com,epicgames.com' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns a random Reality target configuration from the predefined list
|
||||
* @returns {Object} Object with target and sni properties
|
||||
*/
|
||||
function getRandomRealityTarget() {
|
||||
const randomIndex = Math.floor(Math.random() * REALITY_TARGETS.length);
|
||||
const selected = REALITY_TARGETS[randomIndex];
|
||||
// Return a copy to avoid reference issues
|
||||
return {
|
||||
target: selected.target,
|
||||
sni: selected.sni
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"strconv"
|
||||
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v2/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/service"
|
||||
"github.com/mhsanaei/3x-ui/v2/web/session"
|
||||
|
|
|
|||
89
web/controller/multi_server_controller.go
Normal file
89
web/controller/multi_server_controller.go
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"x-ui/database/model"
|
||||
"x-ui/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type MultiServerController struct {
|
||||
multiServerService service.MultiServerService
|
||||
}
|
||||
|
||||
func NewMultiServerController(g *gin.RouterGroup) *MultiServerController {
|
||||
c := &MultiServerController{}
|
||||
c.initRouter(g)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *MultiServerController) initRouter(g *gin.RouterGroup) {
|
||||
g = g.Group("/server")
|
||||
|
||||
g.GET("/list", c.getServers)
|
||||
g.POST("/add", c.addServer)
|
||||
g.POST("/del/:id", c.delServer)
|
||||
g.POST("/update/:id", c.updateServer)
|
||||
}
|
||||
|
||||
func (c *MultiServerController) getServers(ctx *gin.Context) {
|
||||
servers, err := c.multiServerService.GetServers()
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Failed to get servers", err)
|
||||
return
|
||||
}
|
||||
jsonObj(ctx, servers, nil)
|
||||
}
|
||||
|
||||
func (c *MultiServerController) addServer(ctx *gin.Context) {
|
||||
server := &model.Server{}
|
||||
err := ctx.ShouldBind(server)
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Invalid data", err)
|
||||
return
|
||||
}
|
||||
err = c.multiServerService.AddServer(server)
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Failed to add server", err)
|
||||
return
|
||||
}
|
||||
jsonMsg(ctx, "Server added successfully", nil)
|
||||
}
|
||||
|
||||
func (c *MultiServerController) delServer(ctx *gin.Context) {
|
||||
id, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Invalid ID", err)
|
||||
return
|
||||
}
|
||||
err = c.multiServerService.DeleteServer(id)
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Failed to delete server", err)
|
||||
return
|
||||
}
|
||||
jsonMsg(ctx, "Server deleted successfully", nil)
|
||||
}
|
||||
|
||||
func (c *MultiServerController) updateServer(ctx *gin.Context) {
|
||||
id, err := strconv.Atoi(ctx.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Invalid ID", err)
|
||||
return
|
||||
}
|
||||
server := &model.Server{
|
||||
Id: id,
|
||||
}
|
||||
err = ctx.ShouldBind(server)
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Invalid data", err)
|
||||
return
|
||||
}
|
||||
err = c.multiServerService.UpdateServer(server)
|
||||
if err != nil {
|
||||
jsonMsg(ctx, "Failed to update server", err)
|
||||
return
|
||||
}
|
||||
jsonMsg(ctx, "Server updated successfully", nil)
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
|
|||
|
||||
g.GET("/", a.index)
|
||||
g.GET("/inbounds", a.inbounds)
|
||||
g.GET("/servers", a.servers)
|
||||
g.GET("/settings", a.settings)
|
||||
g.GET("/xray", a.xraySettings)
|
||||
|
||||
|
|
@ -52,3 +53,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -12,10 +12,26 @@
|
|||
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label='Target'>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> Target <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.target"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='SNI'>
|
||||
<a-form-item>
|
||||
<template slot="label">
|
||||
<a-tooltip>
|
||||
<template slot="title">
|
||||
<span>{{ i18n "reset" }}</span>
|
||||
</template> SNI <a-icon @click="randomizeRealityTarget()"
|
||||
type="sync"></a-icon>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
|
||||
</a-form-item>
|
||||
<a-form-item label='Max Time Diff (ms)'>
|
||||
|
|
|
|||
|
|
@ -602,6 +602,7 @@
|
|||
{{template "page/body_scripts" .}}
|
||||
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/uri/URI.min.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/reality_targets.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/inbound.js?{{ .cur_ver }}"></script>
|
||||
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
|
||||
{{template "component/aSidebar" .}}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@
|
|||
this.inbound.stream.reality.mldsa65Seed = '';
|
||||
this.inbound.stream.reality.settings.mldsa65Verify = '';
|
||||
},
|
||||
randomizeRealityTarget() {
|
||||
if (typeof getRandomRealityTarget !== 'undefined') {
|
||||
const randomTarget = getRandomRealityTarget();
|
||||
this.inbound.stream.reality.target = randomTarget.target;
|
||||
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
||||
}
|
||||
},
|
||||
async getNewEchCert() {
|
||||
inModal.loading(true);
|
||||
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni: inModal.inbound.stream.tls.sni });
|
||||
|
|
|
|||
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" .}}
|
||||
34
web/middleware/auth.go
Normal file
34
web/middleware/auth.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"x-ui/web/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func ApiAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
apiKey := c.GetHeader("Api-Key")
|
||||
if apiKey == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key is required"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
settingService := service.SettingService{}
|
||||
panelAPIKey, err := settingService.GetAPIKey()
|
||||
if err != nil || panelAPIKey == "" {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "API key not configured on the panel"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
if apiKey != panelAPIKey {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -673,6 +676,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
|
||||
}
|
||||
|
||||
|
|
@ -761,6 +769,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
|
||||
}
|
||||
|
||||
|
|
@ -936,6 +949,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
|
||||
}
|
||||
|
||||
|
|
@ -2379,6 +2398,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 {
|
||||
|
|
@ -2470,4 +2527,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)
|
||||
}
|
||||
|
|
@ -204,6 +204,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()
|
||||
|
|
@ -479,10 +494,18 @@ func (s *SettingService) GetSubDomain() (string, error) {
|
|||
return s.getString("subDomain")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubCertFile(subCertFile string) error {
|
||||
return s.setString("subCertFile", subCertFile)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubCertFile() (string, error) {
|
||||
return s.getString("subCertFile")
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSubKeyFile(subKeyFile string) error {
|
||||
return s.setString("subKeyFile", subKeyFile)
|
||||
}
|
||||
|
||||
func (s *SettingService) GetSubKeyFile() (string, error) {
|
||||
return s.getString("subKeyFile")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,14 @@ import (
|
|||
|
||||
var (
|
||||
bot *telego.Bot
|
||||
|
||||
// botCancel stores the function to cancel the context, stopping Long Polling gracefully.
|
||||
botCancel context.CancelFunc
|
||||
// tgBotMutex protects concurrent access to botCancel variable
|
||||
tgBotMutex sync.Mutex
|
||||
// botWG waits for the OnReceive Long Polling goroutine to finish.
|
||||
botWG sync.WaitGroup
|
||||
|
||||
botHandler *th.BotHandler
|
||||
adminIds []int64
|
||||
isRunning bool
|
||||
|
|
@ -306,8 +314,13 @@ func (t *Tgbot) SetHostname() {
|
|||
hostname = host
|
||||
}
|
||||
|
||||
// Stop stops the Telegram bot and cleans up resources.
|
||||
// Stop safely stops the Telegram bot's Long Polling operation.
|
||||
// This method now calls the global StopBot function and cleans up other resources.
|
||||
func (t *Tgbot) Stop() {
|
||||
// Call the global StopBot function to gracefully shut down Long Polling
|
||||
StopBot()
|
||||
|
||||
// Stop the bot handler (in case the goroutine hasn't exited yet)
|
||||
if botHandler != nil {
|
||||
botHandler.Stop()
|
||||
}
|
||||
|
|
@ -316,6 +329,27 @@ func (t *Tgbot) Stop() {
|
|||
adminIds = nil
|
||||
}
|
||||
|
||||
// StopBot safely stops the Telegram bot's Long Polling operation by cancelling its context.
|
||||
// This is the global function called from main.go's signal handler and t.Stop().
|
||||
func StopBot() {
|
||||
tgBotMutex.Lock()
|
||||
defer tgBotMutex.Unlock()
|
||||
|
||||
if botCancel != nil {
|
||||
logger.Info("Sending cancellation signal to Telegram bot...")
|
||||
|
||||
// Calling botCancel() cancels the context passed to UpdatesViaLongPolling,
|
||||
// which stops the Long Polling operation and closes the updates channel,
|
||||
// allowing the th.Start() goroutine to exit cleanly.
|
||||
botCancel()
|
||||
|
||||
botCancel = nil
|
||||
// Giving the goroutine a small delay to exit cleanly.
|
||||
botWG.Wait()
|
||||
logger.Info("Telegram bot successfully stopped.")
|
||||
}
|
||||
}
|
||||
|
||||
// encodeQuery encodes the query string if it's longer than 64 characters.
|
||||
func (t *Tgbot) encodeQuery(query string) string {
|
||||
// NOTE: we only need to hash for more than 64 chars
|
||||
|
|
@ -345,11 +379,29 @@ func (t *Tgbot) OnReceive() {
|
|||
params := telego.GetUpdatesParams{
|
||||
Timeout: 30, // Increased timeout to reduce API calls
|
||||
}
|
||||
// --- GRACEFUL SHUTDOWN FIX: Context creation ---
|
||||
tgBotMutex.Lock()
|
||||
|
||||
updates, _ := bot.UpdatesViaLongPolling(context.Background(), ¶ms)
|
||||
// Create a context with cancellation and store the cancel function.
|
||||
var ctx context.Context
|
||||
|
||||
// Check if botCancel is already set (to prevent race condition overwrite and goroutine leak)
|
||||
if botCancel == nil {
|
||||
ctx, botCancel = context.WithCancel(context.Background())
|
||||
} else {
|
||||
// If botCancel is already set, use a non-cancellable context for this redundant call.
|
||||
// This prevents overwriting the active botCancel and causing a goroutine leak from the previous call.
|
||||
logger.Warning("TgBot OnReceive called concurrently. Using background context for redundant call.")
|
||||
ctx = context.Background() // <<< ИЗМЕНЕНИЕ
|
||||
}
|
||||
|
||||
tgBotMutex.Unlock()
|
||||
|
||||
// Get updates channel using the context.
|
||||
updates, _ := bot.UpdatesViaLongPolling(ctx, ¶ms)
|
||||
botWG.Go(func() {
|
||||
|
||||
botHandler, _ = th.NewBotHandler(bot, updates)
|
||||
|
||||
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
|
||||
delete(userStates, message.Chat.ID)
|
||||
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
|
||||
|
|
@ -527,6 +579,7 @@ func (t *Tgbot) OnReceive() {
|
|||
}, th.AnyMessage())
|
||||
|
||||
botHandler.Start()
|
||||
})
|
||||
}
|
||||
|
||||
// answerCommand processes incoming command messages from Telegram users.
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
"copySuccess" = "Скопировано"
|
||||
"sure" = "Да"
|
||||
"encryption" = "Шифрование"
|
||||
"useIPv4ForHost" = "Использовать IPv4 для хоста"
|
||||
"useIPv4ForHost" = "Использовать IPv4 для подключения к хосту"
|
||||
"transmission" = "Транспорт"
|
||||
"host" = "Хост"
|
||||
"path" = "Путь"
|
||||
|
|
@ -46,8 +46,8 @@
|
|||
"online" = "Онлайн"
|
||||
"domainName" = "Домен"
|
||||
"monitor" = "Мониторинг IP"
|
||||
"certificate" = "SSL сертификат"
|
||||
"fail" = "Ошибка"
|
||||
"certificate" = "SSL-сертификат"
|
||||
"fail" = "Сбой"
|
||||
"comment" = "Комментарий"
|
||||
"success" = "Успешно"
|
||||
"lastOnline" = "Был(а) в сети"
|
||||
|
|
@ -55,17 +55,17 @@
|
|||
"install" = "Установка"
|
||||
"clients" = "Клиенты"
|
||||
"usage" = "Использование"
|
||||
"twoFactorCode" = "Код"
|
||||
"twoFactorCode" = "Код 2FA"
|
||||
"remained" = "Остаток"
|
||||
"security" = "Безопасность"
|
||||
"secAlertTitle" = "Предупреждение системы безопасности"
|
||||
"secAlertSsl" = "Это соединение не защищено. Пожалуйста, не вводите конфиденциальную информацию, пока не установите SSL сертификат для защиты соединения"
|
||||
"secAlertConf" = "Некоторые настройки уязвимы для атак. Чтобы в будущем не было проблем, нужно усилить защиту."
|
||||
"secAlertSSL" = "Ваше подключение к панели не защищено. Установите SSL сертификат для защиты данных."
|
||||
"secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите случайный или просто другой порт."
|
||||
"secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Сделайте адрес сложным."
|
||||
"secAlertSubURI" = "URI-адрес подписки по умолчанию небезопасен. Пожалуйста, настройте сложный URI-адрес."
|
||||
"secAlertSubJsonURI" = "URI-адрес по умолчанию для JSON подписки небезопасен. Пожалуйста, настройте сложный URI-адрес."
|
||||
"secAlertSsl" = "Соединение не защищено. Не вводите конфиденциальные данные до установки SSL-сертификата."
|
||||
"secAlertConf" = "Некоторые настройки уязвимы. Рекомендуется усилить защиту для предотвращения атак."
|
||||
"secAlertSSL" = "Подключение к панели не защищено. Установите SSL-сертификат для защиты данных."
|
||||
"secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите нестандартный или случайный порт."
|
||||
"secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Настройте уникальный и сложный URI."
|
||||
"secAlertSubURI" = "URI подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
|
||||
"secAlertSubJsonURI" = "URI JSON-подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
|
||||
"emptyDnsDesc" = "Нет добавленных DNS-серверов."
|
||||
"emptyFakeDnsDesc" = "Нет добавленных Fake DNS-серверов."
|
||||
"emptyBalancersDesc" = "Нет добавленных балансировщиков."
|
||||
|
|
@ -83,15 +83,15 @@
|
|||
"individualLinks" = "Индивидуальные ссылки"
|
||||
"active" = "Активна"
|
||||
"inactive" = "Неактивна"
|
||||
"unlimited" = "Безлимит"
|
||||
"noExpiry" = "Без срока"
|
||||
"unlimited" = "Неограниченно"
|
||||
"noExpiry" = "Бессрочно"
|
||||
|
||||
[menu]
|
||||
"theme" = "Тема"
|
||||
"dark" = "Темная"
|
||||
"ultraDark" = "Очень темная"
|
||||
"dashboard" = "Дашборд"
|
||||
"inbounds" = "Инбаунды"
|
||||
"inbounds" = "Подключения"
|
||||
"settings" = "Настройки"
|
||||
"xray" = "Настройки Xray"
|
||||
"logout" = "Выход"
|
||||
|
|
@ -107,7 +107,7 @@
|
|||
"emptyUsername" = "Введите имя пользователя"
|
||||
"emptyPassword" = "Введите пароль"
|
||||
"wrongUsernameOrPassword" = "Неверные данные учетной записи."
|
||||
"successLogin" = "Вы успешно вошли в аккаунт"
|
||||
"successLogin" = "Вход выполнен успешно"
|
||||
|
||||
[pages.index]
|
||||
"title" = "Дашборд"
|
||||
|
|
@ -122,7 +122,7 @@
|
|||
"stopXray" = "Остановить"
|
||||
"restartXray" = "Перезапустить"
|
||||
"xraySwitch" = "Выбор версии"
|
||||
"xraySwitchClick" = "Выберите желаемую версию"
|
||||
"xraySwitchClick" = "Выберите нужную версию"
|
||||
"xraySwitchClickDesk" = "Важно: старые версии могут не поддерживать текущие настройки"
|
||||
"xrayStatusUnknown" = "Неизвестно"
|
||||
"xrayStatusRunning" = "Запущен"
|
||||
|
|
@ -134,7 +134,7 @@
|
|||
"systemLoadDesc" = "Средняя загрузка системы за последние 1, 5 и 15 минут"
|
||||
"connectionCount" = "Количество соединений"
|
||||
"ipAddresses" = "IP-адреса сервера"
|
||||
"toggleIpVisibility" = "Переключить видимость IP-адресов сервера"
|
||||
"toggleIpVisibility" = "Скрыть или показать IP-адреса сервера"
|
||||
"overallSpeed" = "Общая скорость передачи трафика"
|
||||
"upload" = "Отправка"
|
||||
"download" = "Загрузка"
|
||||
|
|
@ -168,10 +168,10 @@
|
|||
[pages.inbounds]
|
||||
"allTimeTraffic" = "Общий трафик"
|
||||
"allTimeTrafficUsage" = "Общее использование за все время"
|
||||
"title" = "Инбаунды"
|
||||
"totalDownUp" = "Объем отправленного/полученного трафика"
|
||||
"title" = "Подключения"
|
||||
"totalDownUp" = "Отправлено/получено"
|
||||
"totalUsage" = "Всего трафика"
|
||||
"inboundCount" = "Всего инбаундов"
|
||||
"inboundCount" = "Всего подключений"
|
||||
"operate" = "Меню"
|
||||
"enable" = "Включить"
|
||||
"remark" = "Примечание"
|
||||
|
|
@ -185,13 +185,13 @@
|
|||
"createdAt" = "Создано"
|
||||
"updatedAt" = "Обновлено"
|
||||
"resetTraffic" = "Сброс трафика"
|
||||
"addInbound" = "Создать инбаунд"
|
||||
"addInbound" = "Создать подключение"
|
||||
"generalActions" = "Общие действия"
|
||||
"autoRefresh" = "Автообновление"
|
||||
"autoRefreshInterval" = "Интервал"
|
||||
"modifyInbound" = "Изменить инбаунд"
|
||||
"deleteInbound" = "Удалить инбаунд"
|
||||
"deleteInboundContent" = "Вы уверены, что хотите удалить инбаунд?"
|
||||
"modifyInbound" = "Изменить подключение"
|
||||
"deleteInbound" = "Удалить подключение"
|
||||
"deleteInboundContent" = "Вы уверены, что хотите удалить подключение?"
|
||||
"deleteClient" = "Удалить клиента"
|
||||
"deleteClientContent" = "Вы уверены, что хотите удалить клиента?"
|
||||
"resetTrafficContent" = "Вы уверены, что хотите сбросить трафик?"
|
||||
|
|
@ -214,11 +214,11 @@
|
|||
"export" = "Экспорт ссылок"
|
||||
"clone" = "Клонировать"
|
||||
"cloneInbound" = "Клонировать"
|
||||
"cloneInboundContent" = "Будут клонированы все настройки инбаундов, кроме списка клиентов, порта и IP-адреса прослушивания"
|
||||
"cloneInboundContent" = "Будут клонированы все настройки подключений, кроме списка клиентов, порта и IP-адреса прослушивания"
|
||||
"cloneInboundOk" = "Клонировано"
|
||||
"resetAllTraffic" = "Сброс трафика всех инбаундов"
|
||||
"resetAllTrafficTitle" = "Сброс трафика всех инбаундов"
|
||||
"resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех инбаундов?"
|
||||
"resetAllTraffic" = "Сброс трафика всех подключений"
|
||||
"resetAllTrafficTitle" = "Сброс трафика всех подключений"
|
||||
"resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех подключений?"
|
||||
"resetInboundClientTraffics" = "Сброс трафика клиента"
|
||||
"resetInboundClientTrafficTitle" = "Сброс трафика клиентов"
|
||||
"resetInboundClientTrafficContent" = "Вы уверены, что хотите сбросить трафик для этих клиентов?"
|
||||
|
|
@ -231,7 +231,7 @@
|
|||
"email" = "Email"
|
||||
"emailDesc" = "Пожалуйста, укажите уникальный Email"
|
||||
"IPLimit" = "Лимит по количеству IP"
|
||||
"IPLimitDesc" = "Ограничение количества одновременных подключений с разных IP(0 – отключить)"
|
||||
"IPLimitDesc" = "Ограничение числа одновременных подключений с разных IP (0 – отключить)"
|
||||
"IPLimitlog" = "Лог IP-адресов"
|
||||
"IPLimitlogDesc" = "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)"
|
||||
"IPLimitlogclear" = "Очистить лог"
|
||||
|
|
@ -240,19 +240,19 @@
|
|||
"subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'"
|
||||
"info" = "Информация"
|
||||
"same" = "Тот же"
|
||||
"inboundData" = "Данные инбаундов"
|
||||
"exportInbound" = "Экспорт инбаундов"
|
||||
"inboundData" = "Данные подключений"
|
||||
"exportInbound" = "Экспорт подключений"
|
||||
"import" = "Импортировать"
|
||||
"importInbound" = "Импорт инбаундов"
|
||||
"importInbound" = "Импорт подключений"
|
||||
"periodicTrafficResetTitle" = "Сброс трафика"
|
||||
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
|
||||
"lastReset" = "Последний сброс"
|
||||
|
||||
[pages.client]
|
||||
"add" = "Создать клиента"
|
||||
"add" = "Добавить клиента"
|
||||
"edit" = "Редактировать клиента"
|
||||
"submitAdd" = "Добавить"
|
||||
"submitEdit" = "Сохранить"
|
||||
"submitEdit" = "Сохранить изменения"
|
||||
"clientCount" = "Количество клиентов"
|
||||
"bulk" = "Добавить несколько"
|
||||
"method" = "Метод"
|
||||
|
|
@ -276,13 +276,13 @@
|
|||
"obtain" = "Получить"
|
||||
"updateSuccess" = "Обновление прошло успешно"
|
||||
"logCleanSuccess" = "Лог был очищен"
|
||||
"inboundsUpdateSuccess" = "Инбаунды успешно обновлены"
|
||||
"inboundUpdateSuccess" = "Инбаунд успешно обновлено"
|
||||
"inboundCreateSuccess" = "Инбаунд успешно создано"
|
||||
"inboundDeleteSuccess" = "Инбаунд успешно удалено"
|
||||
"inboundClientAddSuccess" = "Клиент(ы) инбаунда добавлен(ы)"
|
||||
"inboundClientDeleteSuccess" = "Клиент инбаунда удалён"
|
||||
"inboundClientUpdateSuccess" = "Клиент инбаунда обновлён"
|
||||
"inboundsUpdateSuccess" = "Подключения успешно обновлены"
|
||||
"inboundUpdateSuccess" = "Подключение успешно обновлено"
|
||||
"inboundCreateSuccess" = "Подключение успешно создано"
|
||||
"inboundDeleteSuccess" = "Подключение успешно удалено"
|
||||
"inboundClientAddSuccess" = "Клиент(ы) подключения добавлен(ы)"
|
||||
"inboundClientDeleteSuccess" = "Клиент подключения удалён"
|
||||
"inboundClientUpdateSuccess" = "Клиент подключения обновлён"
|
||||
"delDepletedClientsSuccess" = "Все исчерпанные клиенты удалены"
|
||||
"resetAllClientTrafficSuccess" = "Весь трафик клиента сброшен"
|
||||
"resetAllTrafficSuccess" = "Весь трафик сброшен"
|
||||
|
|
@ -310,7 +310,7 @@
|
|||
[pages.settings]
|
||||
"title" = "Настройки"
|
||||
"save" = "Сохранить"
|
||||
"infoDesc" = "Каждое внесённое изменение должно быть сохранено. Пожалуйста, перезапустите панель, чтобы изменения вступили в силу."
|
||||
"infoDesc" = "Сохраните изменения и перезапустите панель для их применения."
|
||||
"restartPanel" = "Перезапуск панели"
|
||||
"restartPanelDesc" = "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера"
|
||||
"restartPanelSuccess" = "Панель успешно перезапущена"
|
||||
|
|
@ -318,11 +318,11 @@
|
|||
"resetDefaultConfig" = "Восстановить настройки по умолчанию"
|
||||
"panelSettings" = "Панель"
|
||||
"securitySettings" = "Учетная запись"
|
||||
"TGBotSettings" = "Telegram"
|
||||
"TGBotSettings" = "Telegram-Бот"
|
||||
"panelListeningIP" = "IP-адрес для управления панелью"
|
||||
"panelListeningIPDesc" = "Оставьте пустым для подключения с любого IP"
|
||||
"panelListeningDomain" = "Домен панели"
|
||||
"panelListeningDomainDesc" = "По умолчанию оставьте пустым, чтобы подключаться с любых доменов и IP-адресов"
|
||||
"panelListeningDomainDesc" = "Оставьте пустым для подключения с любых доменов и IP."
|
||||
"panelPort" = "Порт панели"
|
||||
"panelPortDesc" = "Порт, на котором работает панель"
|
||||
"publicKeyPath" = "Путь к файлу публичного ключа сертификата панели"
|
||||
|
|
@ -332,11 +332,11 @@
|
|||
"panelUrlPath" = "Корневой путь URL адреса панели"
|
||||
"panelUrlPathDesc" = "Должен начинаться с '/' и заканчиваться '/'"
|
||||
"pageSize" = "Размер нумерации страниц"
|
||||
"pageSizeDesc" = "Определить размер страницы для таблицы инбаундов. Установите 0, чтобы отключить"
|
||||
"pageSizeDesc" = "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить"
|
||||
"remarkModel" = "Модель примечания и символ разделения"
|
||||
"datepicker" = "Выбор даты"
|
||||
"datepicker" = "Тип календаря"
|
||||
"datepickerPlaceholder" = "Выберите дату"
|
||||
"datepickerDescription" = "Запланированные задачи будут выполняться в выбранное время"
|
||||
"datepickerDescription" = "Запланированные задачи будут выполняться в соответствии с этим календарем."
|
||||
"sampleRemark" = "Пример примечания"
|
||||
"oldUsername" = "Текущий логин"
|
||||
"currentPassword" = "Текущий пароль"
|
||||
|
|
@ -346,7 +346,7 @@
|
|||
"telegramBotEnableDesc" = "Доступ к функциям панели через Telegram-бота"
|
||||
"telegramToken" = "Токен Telegram бота"
|
||||
"telegramTokenDesc" = "Необходимо получить токен у менеджера ботов Telegram @botfather"
|
||||
"telegramProxy" = "Прокси Socks5"
|
||||
"telegramProxy" = "Прокси-сервер Socks5"
|
||||
"telegramProxyDesc" = "Если для подключения к Telegram вам нужен прокси Socks5, настройте его параметры согласно руководству."
|
||||
"telegramAPIServer" = "API-сервер Telegram"
|
||||
"telegramAPIServerDesc" = "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию."
|
||||
|
|
@ -451,11 +451,11 @@
|
|||
"RoutingStrategy" = "Настройка маршрутизации доменов"
|
||||
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
|
||||
"Torrent" = "Заблокировать BitTorrent"
|
||||
"Inbounds" = "Инбаунды"
|
||||
"Inbounds" = "Входящие подключения"
|
||||
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
|
||||
"Outbounds" = "Аутбаунды"
|
||||
"Outbounds" = "Исходящие подключения"
|
||||
"Balancers" = "Балансировщик"
|
||||
"OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить аутбаунды для этого сервера"
|
||||
"OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить исходящие подключения для этого сервера"
|
||||
"Routings" = "Маршрутизация"
|
||||
"RoutingsDesc" = "Важен приоритет каждого правила!"
|
||||
"completeTemplate" = "Все"
|
||||
|
|
@ -486,8 +486,8 @@
|
|||
"down" = "Опустить вниз"
|
||||
"source" = "Источник"
|
||||
"dest" = "Пункт назначения"
|
||||
"inbound" = "Инбаунд"
|
||||
"outbound" = "Аутбаунд"
|
||||
"inbound" = "Входящее подключение"
|
||||
"outbound" = "Исходящее подключение"
|
||||
"balancer" = "Балансировщик"
|
||||
"info" = "Информация"
|
||||
"add" = "Создать правило"
|
||||
|
|
@ -495,9 +495,9 @@
|
|||
"useComma" = "Элементы, разделённые запятыми"
|
||||
|
||||
[pages.xray.outbound]
|
||||
"addOutbound" = "Создать аутбаунд"
|
||||
"addOutbound" = "Создать исходящее подключение"
|
||||
"addReverse" = "Создать реверс-прокси"
|
||||
"editOutbound" = "Изменить аутбаунд"
|
||||
"editOutbound" = "Изменить исходящее подключение"
|
||||
"editReverse" = "Редактировать реверс-прокси"
|
||||
"tag" = "Тег"
|
||||
"tagDesc" = "Уникальный тег"
|
||||
|
|
@ -511,7 +511,7 @@
|
|||
"intercon" = "Соединение"
|
||||
"settings" = "Настройки"
|
||||
"accountInfo" = "Информация об учетной записи"
|
||||
"outboundStatus" = "Статус аутбаунда"
|
||||
"outboundStatus" = "Статус исходящего подключения"
|
||||
"sendThrough" = "Отправить через"
|
||||
|
||||
[pages.xray.balancer]
|
||||
|
|
@ -587,8 +587,8 @@
|
|||
"modifyUser" = "Вы успешно изменили учетные данные администратора."
|
||||
"originalUserPassIncorrect" = "Неверное имя пользователя или пароль"
|
||||
"userPassMustBeNotEmpty" = "Новое имя пользователя и новый пароль должны быть заполнены"
|
||||
"getOutboundTrafficError" = "Ошибка получения трафика аутбаунда"
|
||||
"resetOutboundTrafficError" = "Ошибка сброса трафика аутбаунда"
|
||||
"getOutboundTrafficError" = "Ошибка получения трафика исходящего подключения"
|
||||
"resetOutboundTrafficError" = "Ошибка сброса трафика исходящего подключения"
|
||||
|
||||
[tgbot]
|
||||
"keyboardClosed" = "❌ Клавиатура закрыта."
|
||||
|
|
@ -596,7 +596,7 @@
|
|||
"noQuery" = "❌ Запрос не найден. Пожалуйста, повторите команду."
|
||||
"wentWrong" = "❌ Что-то пошло не так..."
|
||||
"noIpRecord" = "❗ Нет записей об IP-адресе."
|
||||
"noInbounds" = "❗ У вас не настроено ни одного инбаунда."
|
||||
"noInbounds" = "❗ У вас не настроено ни одного входящего подключения."
|
||||
"unlimited" = "♾ Безлимит"
|
||||
"add" = "Добавить"
|
||||
"month" = "Месяц"
|
||||
|
|
@ -606,7 +606,7 @@
|
|||
"hours" = "Часов"
|
||||
"minutes" = "Минуты"
|
||||
"unknown" = "Неизвестно"
|
||||
"inbounds" = "Инбаунды"
|
||||
"inbounds" = "Входящие подключения"
|
||||
"clients" = "Клиенты"
|
||||
"offline" = "🔴 Офлайн"
|
||||
"online" = "🟢 Онлайн"
|
||||
|
|
@ -620,7 +620,7 @@
|
|||
"status" = "✅ Бот функционирует нормально."
|
||||
"usage" = "❗ Пожалуйста, укажите email для поиска."
|
||||
"getID" = "🆔 Ваш User ID: <code>{{ .ID }}</code>"
|
||||
"helpAdminCommands" = "🔃 Для перезапуска Xray Core:\r\n<code>/restart</code>\r\n\r\n🔎 Для поиска клиента по email:\r\n<code>/usage [Email]</code>\r\n\r\n📊 Для поиска инбаундов (со статистикой клиентов):\r\n<code>/inbound [имя подключения]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"helpAdminCommands" = "🔃 Для перезапуска Xray Core:\r\n<code>/restart</code>\r\n\r\n🔎 Для поиска клиента по email:\r\n<code>/usage [Email]</code>\r\n\r\n📊 Для поиска входящих подключений (со статистикой клиентов):\r\n<code>/inbound [имя подключения]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"helpClientCommands" = "💲 Для просмотра информации о вашей подписке используйте команду:\r\n<code>/usage [Email]</code>\r\n\r\n🆔 Ваш Telegram User ID:\r\n<code>/id</code>"
|
||||
"restartUsage" = "\r\n\r\n<code>/restart</code>"
|
||||
"restartSuccess" = "✅ Ядро Xray успешно перезапущено."
|
||||
|
|
@ -656,7 +656,7 @@
|
|||
"username" = "👤 Имя пользователя: {{ .Username }}\r\n"
|
||||
"password" = "👤 Пароль: {{ .Password }}\r\n"
|
||||
"time" = "⏰ Время: {{ .Time }}\r\n"
|
||||
"inbound" = "📍 Входящий поток: {{ .Remark }}\r\n"
|
||||
"inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
|
||||
"port" = "🔌 Порт: {{ .Port }}\r\n"
|
||||
"expire" = "📅 Дата окончания: {{ .Time }}\r\n"
|
||||
"expireIn" = "📅 Окончание через: {{ .Time }}\r\n"
|
||||
|
|
@ -685,12 +685,12 @@
|
|||
"pass_prompt" = "🔑 Стандартный пароль: {{ .ClientPassword }}\n\nВведите ваш пароль."
|
||||
"email_prompt" = "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email."
|
||||
"comment_prompt" = "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий."
|
||||
"inbound_client_data_id" = "🔄 Инбаунды: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Дата исчерпания: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в инбаунд!"
|
||||
"inbound_client_data_pass" = "🔄 Инбаунды: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Дата исчерпания: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в инбаунд!"
|
||||
"inbound_client_data_id" = "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!"
|
||||
"inbound_client_data_pass" = "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!"
|
||||
"cancel" = "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄"
|
||||
"error_add_client" = "⚠️ Ошибка:\n\n {{ .error }}"
|
||||
"using_default_value" = "Используется значение по умолчанию👌"
|
||||
"incorrect_input" ="Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫"
|
||||
"incorrect_input" = "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫"
|
||||
"AreYouSure" = "Вы уверены? 🤔"
|
||||
"SuccessResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно"
|
||||
"FailedResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠️ Ошибка: [ {{ .ErrorMessage }} ]"
|
||||
|
|
@ -707,7 +707,7 @@
|
|||
"confirmToggle" = "✅ Подтвердить вкл/выкл пользователя?"
|
||||
"dbBackup" = "📂 Бэкап БД"
|
||||
"serverUsage" = "💻 Состояние сервера"
|
||||
"getInbounds" = "🔌 Инбаунды"
|
||||
"getInbounds" = "🔌 Входящие подключения"
|
||||
"depleteSoon" = "⚠️ Скоро конец"
|
||||
"clientUsage" = "Статистика клиента"
|
||||
"onlines" = "🟢 Онлайн"
|
||||
|
|
@ -731,7 +731,7 @@
|
|||
"allClients" = "👥 Все клиенты"
|
||||
"addClient" = "➕ Новый клиент"
|
||||
"submitDisable" = "Добавить отключенным ☑️"
|
||||
"submitEnable" = "Добавить включенныи ✅"
|
||||
"submitEnable" = "Добавить включенным ✅"
|
||||
"use_default" = "🏷️ Использовать по умолчанию"
|
||||
"change_id" = "⚙️🔑 ID"
|
||||
"change_password" = "⚙️🔑 Пароль"
|
||||
|
|
@ -743,7 +743,7 @@
|
|||
[tgbot.answers]
|
||||
"successfulOperation" = "✅ Успешно!"
|
||||
"errorOperation" = "❗ Ошибка в операции."
|
||||
"getInboundsFailed" = "❌ Не удалось получить инбаунды."
|
||||
"getInboundsFailed" = "❌ Не удалось получить входящие подключения."
|
||||
"getClientsFailed" = "❌ Не удалось получить клиентов."
|
||||
"canceled" = "❌ {{ .Email }}: Операция отменена."
|
||||
"clientRefreshSuccess" = "✅ {{ .Email }}: Клиент успешно обновлен."
|
||||
|
|
@ -760,5 +760,5 @@
|
|||
"enableSuccess" = "✅ {{ .Email }}: Включено успешно."
|
||||
"disableSuccess" = "✅ {{ .Email }}: Отключено успешно."
|
||||
"askToAddUserId" = "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>"
|
||||
"chooseClient" = "Выберите клиента для инбаунда {{ .Inbound }}"
|
||||
"chooseInbound" = "Выберите инбаунд"
|
||||
"chooseClient" = "Выберите клиента для входящего подключения {{ .Inbound }}"
|
||||
"chooseInbound" = "Выберите входящее подключение"
|
||||
|
|
|
|||
Loading…
Reference in a new issue