Compare commits

...

16 commits

Author SHA1 Message Date
javadtgh
68719eef34
Merge 3b262cf180 into 4a75bd0a48 2025-11-01 15:27:38 +03:00
Дмитрий Олегович Саенко
4a75bd0a48
Feature: add setting certs for subscription while generating for panel (#3578)
Some checks are pending
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
2025-11-01 13:10:27 +01:00
Rashid Yusubov
b0c223c631
fix: improve russian localization (#3576)
* fix: improve russian localization

* fix: updating the Russian translation according to the suggestions
2025-11-01 13:07:49 +01:00
Denis Gorelov
313b51f96f
feat: Add random Reality Target/SNI selection from 52 popular services (#3577)
* feat: Add random Reality Target/SNI selection from 52 popular services

- Created reality_targets.js with list of 52 popular services
- Updated RealityStreamSettings to use random targets by default
- Added UI randomize buttons with sync icon in Reality settings form
- Implemented randomizeRealityTarget() method in inbound modal
- Replaces hardcoded google.com with diverse global services

* fix

---------

Co-authored-by: mhsanaei <ho3ein.sanaei@gmail.com>
2025-11-01 13:07:05 +01:00
OleksandrParshyn
020cd63e22
Fix: Graceful Telegram bot shutdown to prevent 409 Conflict (#3580)
* Fix: Graceful Telegram bot shutdown to prevent 409 Conflict

Introduces a `botCancel` context and a global `StopBot()` function to ensure the Telegram bot's Long Polling operation is safely terminated (via context cancellation) before the service restarts. This prevents the "Conflict: another update consumer is running" (409) error upon panel restart.

Changes:
- Added `botCancel context.CancelFunc` to manage context cancellation.
- Implemented global `StopBot()` function.
- Updated `Tgbot.Stop()` to call `StopBot()`.
- Modified `Tgbot.OnReceive()` to use the new cancellable context for `UpdatesViaLongPolling`.

* Fix: Prevent race condition and goroutine leak in TgBot

Addresses a critical race condition on the global `botCancel` variable, which could occur if `Tgbot.OnReceive()` was called concurrently (e.g., during rapid panel restarts or unexpected behavior).

Changes in tgbot.go:
- Added `tgBotMutex sync.Mutex` to ensure thread safety.
- Protected `botCancel` creation and assignment in `OnReceive()` using the mutex, and added a check to prevent overwriting an active context, which avoids goroutine leaks.
- Protected the cancellation and cleanup logic in `StopBot()` with the mutex.

* Refactor: Replace time.Sleep with sync.WaitGroup for reliable TgBot shutdown

Replaced the unreliable `time.Sleep(1 * time.Second)` in `service.StopBot()` with `sync.WaitGroup`. This ensures the Long Polling goroutine is explicitly waited for and reliably exits before the panel continues, preventing potential resource leaks and incomplete shutdowns during restarts.

Changes:
- Added `botWG sync.WaitGroup` variable.
- Updated `service.StopBot()` to call `botWG.Wait()` instead of `time.Sleep()`.
- Modified `Tgbot.OnReceive()` to correctly use `botWG.Add(1)` and `defer botWG.Done()` within the Long Polling goroutine.
- Corrected the goroutine structure in `OnReceive()` to properly encapsulate all message handling logic.
2025-11-01 13:01:44 +01:00
BOplaid
6e46e9b16e
Improve English README (#3579) 2025-11-01 12:48:16 +01:00
Sanaei
3b262cf180
Merge branch 'main' into feature/multi-server-support 2025-09-24 21:27:55 +02:00
Sanaei
4c7249c451
Merge branch 'main' into feature/multi-server-support 2025-09-21 23:55:26 +02:00
Sanaei
edd8b12988
Merge branch 'main' into feature/multi-server-support 2025-09-19 13:24:09 +02:00
Sanaei
5e953bae45
Merge branch 'main' into feature/multi-server-support 2025-09-12 12:17:12 +02:00
Sanaei
747af376f2
Merge branch 'main' into feature/multi-server-support 2025-09-09 20:53:50 +02:00
Sanaei
a3ccccfe52
Merge branch 'main' into feature/multi-server-support 2025-09-08 14:45:59 +02:00
Sanaei
3299d15f28
Merge branch 'main' into feature/multi-server-support 2025-08-14 18:06:16 +02:00
Sanaei
ae82373457
Merge branch 'main' into feature/multi-server-support 2025-08-04 11:22:53 +02:00
Sanaei
d65233cc2c
Merge branch 'main' into feature/multi-server-support 2025-08-04 10:33:41 +02:00
google-labs-jules[bot]
11dc06863e feat: Add multi-server support for Sanai panel
This commit introduces a multi-server architecture to the Sanai panel, allowing you to manage clients across multiple servers from a central panel.

Key changes include:

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

**Known Issues:**

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

View file

@ -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. **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] > [!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. As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.

View file

@ -38,6 +38,7 @@ func initModels() error {
&model.InboundClientIps{}, &model.InboundClientIps{},
&xray.ClientTraffic{}, &xray.ClientTraffic{},
&model.HistoryOfSeeders{}, &model.HistoryOfSeeders{},
&model.Server{},
} }
for _, model := range models { for _, model := range models {
if err := db.AutoMigrate(model); err != nil { if err := db.AutoMigrate(model); err != nil {

View file

@ -119,3 +119,12 @@ type Client struct {
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
} }
type Server struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"unique;not null"`
Address string `json:"address" gorm:"not null"`
Port int `json:"port" gorm:"not null"`
APIKey string `json:"apiKey" gorm:"not null"`
Enable bool `json:"enable" gorm:"default:true"`
}

1
go.mod
View file

@ -68,6 +68,7 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect github.com/quic-go/quic-go v0.55.0 // indirect

View file

@ -140,6 +140,13 @@ config_after_install() {
fi fi
/usr/local/x-ui/x-ui migrate /usr/local/x-ui/x-ui migrate
local existing_apiKey=$(/usr/local/x-ui/x-ui setting -show true | grep -oP 'ApiKey: \K.*')
if [[ -z "$existing_apiKey" ]]; then
local config_apiKey=$(gen_random_string 32)
/usr/local/x-ui/x-ui setting -apiKey "${config_apiKey}"
echo -e "${green}Generated random API Key: ${config_apiKey}${plain}"
fi
} }
install_x-ui() { install_x-ui() {

30
main.go
View file

@ -240,7 +240,8 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
} }
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication. // updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) { func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool, apiKey string) {
err := database.InitDB(config.GetDBPath()) err := database.InitDB(config.GetDBPath())
if err != nil { if err != nil {
fmt.Println("Database initialization failed:", err) fmt.Println("Database initialization failed:", err)
@ -250,6 +251,15 @@ func updateSetting(port int, username string, password string, webBasePath strin
settingService := service.SettingService{} settingService := service.SettingService{}
userService := service.UserService{} userService := service.UserService{}
if apiKey != "" {
err := settingService.SetAPIKey(apiKey)
if err != nil {
fmt.Println("Failed to set API Key:", err)
} else {
fmt.Printf("API Key set successfully: %v\n", apiKey)
}
}
if port > 0 { if port > 0 {
err := settingService.SetPort(port) err := settingService.SetPort(port)
if err != nil { if err != nil {
@ -321,6 +331,20 @@ func updateCert(publicKey string, privateKey string) {
} else { } else {
fmt.Println("set certificate private key success") 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 { } else {
fmt.Println("both public and private key should be entered.") fmt.Println("both public and private key should be entered.")
} }
@ -402,9 +426,11 @@ func main() {
var show bool var show bool
var getCert bool var getCert bool
var resetTwoFactor bool var resetTwoFactor bool
var apiKey string
settingCmd.BoolVar(&reset, "reset", false, "Reset all settings") settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
settingCmd.BoolVar(&show, "show", false, "Display current settings") settingCmd.BoolVar(&show, "show", false, "Display current settings")
settingCmd.IntVar(&port, "port", 0, "Set panel port number") settingCmd.IntVar(&port, "port", 0, "Set panel port number")
settingCmd.StringVar(&apiKey, "apiKey", "", "Set API Key")
settingCmd.StringVar(&username, "username", "", "Set login username") settingCmd.StringVar(&username, "username", "", "Set login username")
settingCmd.StringVar(&password, "password", "", "Set login password") settingCmd.StringVar(&password, "password", "", "Set login password")
settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel") settingCmd.StringVar(&webBasePath, "webBasePath", "", "Set base path for Panel")
@ -454,7 +480,7 @@ func main() {
if reset { if reset {
resetSetting() resetSetting()
} else { } else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor) updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor, apiKey)
} }
if show { if show {
showSetting(show) showSetting(show)

View file

@ -162,26 +162,43 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
} }
func (s *SubService) getLink(inbound *model.Inbound, email string) string { func (s *SubService) getLink(inbound *model.Inbound, email string) string {
switch inbound.Protocol { serverService := service.MultiServerService{}
case "vmess": servers, err := serverService.GetServers()
return s.genVmessLink(inbound, email) if err != nil {
case "vless": logger.Warning("Failed to get servers for subscription:", err)
return s.genVlessLink(inbound, email) return ""
case "trojan":
return s.genTrojanLink(inbound, email)
case "shadowsocks":
return s.genShadowsocksLink(inbound, email)
} }
return ""
var links []string
for _, server := range servers {
if !server.Enable {
continue
}
var link string
switch inbound.Protocol {
case "vmess":
link = s.genVmessLink(inbound, email, server)
case "vless":
link = s.genVlessLink(inbound, email, server)
case "trojan":
link = s.genTrojanLink(inbound, email, server)
case "shadowsocks":
link = s.genShadowsocksLink(inbound, email, server)
}
if link != "" {
links = append(links, link)
}
}
return strings.Join(links, "\n")
} }
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVmessLink(inbound *model.Inbound, email string, server *model.Server) string {
if inbound.Protocol != model.VMESS { if inbound.Protocol != model.VMESS {
return "" return ""
} }
obj := map[string]any{ obj := map[string]any{
"v": "2", "v": "2",
"add": s.address, "add": server.Address,
"port": inbound.Port, "port": inbound.Port,
"type": "none", "type": "none",
} }
@ -294,7 +311,7 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
newObj[key] = value newObj[key] = value
} }
} }
newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string)) newObj["ps"] = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
newObj["add"] = ep["dest"].(string) newObj["add"] = ep["dest"].(string)
newObj["port"] = int(ep["port"].(float64)) newObj["port"] = int(ep["port"].(float64))
@ -310,14 +327,14 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
return links return links
} }
obj["ps"] = s.genRemark(inbound, email, "") obj["ps"] = s.genRemark(inbound, email, "", server.Name)
jsonStr, _ := json.MarshalIndent(obj, "", " ") jsonStr, _ := json.MarshalIndent(obj, "", " ")
return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr) return "vmess://" + base64.StdEncoding.EncodeToString(jsonStr)
} }
func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { func (s *SubService) genVlessLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.VLESS { if inbound.Protocol != model.VLESS {
return "" return ""
} }
@ -497,7 +514,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -518,12 +535,12 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string { func (s *SubService) genTrojanLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Trojan { if inbound.Protocol != model.Trojan {
return "" return ""
} }
@ -692,7 +709,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -714,12 +731,12 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string, server *model.Server) string {
address := s.address address := server.Address
if inbound.Protocol != model.Shadowsocks { if inbound.Protocol != model.Shadowsocks {
return "" return ""
} }
@ -859,7 +876,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, ep["remark"].(string)) url.Fragment = s.genRemark(inbound, email, ep["remark"].(string), server.Name)
if index > 0 { if index > 0 {
links += "\n" links += "\n"
@ -880,17 +897,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
// Set the new query values on the URL // Set the new query values on the URL
url.RawQuery = q.Encode() url.RawQuery = q.Encode()
url.Fragment = s.genRemark(inbound, email, "") url.Fragment = s.genRemark(inbound, email, "", server.Name)
return url.String() return url.String()
} }
func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string) string { func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, serverName string) string {
separationChar := string(s.remarkModel[0]) separationChar := string(s.remarkModel[0])
orderChars := s.remarkModel[1:] orderChars := s.remarkModel[1:]
orders := map[byte]string{ orders := map[byte]string{
'i': "", 'i': "",
'e': "", 'e': "",
'o': "", 'o': "",
's': "",
} }
if len(email) > 0 { if len(email) > 0 {
orders['e'] = email orders['e'] = email
@ -901,6 +919,9 @@ func (s *SubService) genRemark(inbound *model.Inbound, email string, extra strin
if len(extra) > 0 { if len(extra) > 0 {
orders['o'] = extra orders['o'] = extra
} }
if len(serverName) > 0 {
orders['s'] = serverName
}
var remark []string var remark []string
for i := 0; i < len(orderChars); i++ { for i := 0; i < len(orderChars); i++ {

View file

@ -729,8 +729,8 @@ class RealityStreamSettings extends XrayCommonClass {
constructor( constructor(
show = false, show = false,
xver = 0, xver = 0,
target = 'google.com:443', target = '',
serverNames = 'google.com,www.google.com', serverNames = '',
privateKey = '', privateKey = '',
minClientVer = '', minClientVer = '',
maxClientVer = '', maxClientVer = '',
@ -740,6 +740,14 @@ class RealityStreamSettings extends XrayCommonClass {
settings = new RealityStreamSettings.Settings() settings = new RealityStreamSettings.Settings()
) { ) {
super(); 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.show = show;
this.xver = xver; this.xver = xver;
this.target = target; this.target = target;

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

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/mhsanaei/3x-ui/v2/database/model" "github.com/mhsanaei/3x-ui/v2/database/model"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session" "github.com/mhsanaei/3x-ui/v2/web/session"

View file

@ -0,0 +1,89 @@
package controller
import (
"strconv"
"x-ui/database/model"
"x-ui/web/service"
"github.com/gin-gonic/gin"
)
type MultiServerController struct {
multiServerService service.MultiServerService
}
func NewMultiServerController(g *gin.RouterGroup) *MultiServerController {
c := &MultiServerController{}
c.initRouter(g)
return c
}
func (c *MultiServerController) initRouter(g *gin.RouterGroup) {
g = g.Group("/server")
g.GET("/list", c.getServers)
g.POST("/add", c.addServer)
g.POST("/del/:id", c.delServer)
g.POST("/update/:id", c.updateServer)
}
func (c *MultiServerController) getServers(ctx *gin.Context) {
servers, err := c.multiServerService.GetServers()
if err != nil {
jsonMsg(ctx, "Failed to get servers", err)
return
}
jsonObj(ctx, servers, nil)
}
func (c *MultiServerController) addServer(ctx *gin.Context) {
server := &model.Server{}
err := ctx.ShouldBind(server)
if err != nil {
jsonMsg(ctx, "Invalid data", err)
return
}
err = c.multiServerService.AddServer(server)
if err != nil {
jsonMsg(ctx, "Failed to add server", err)
return
}
jsonMsg(ctx, "Server added successfully", nil)
}
func (c *MultiServerController) delServer(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
jsonMsg(ctx, "Invalid ID", err)
return
}
err = c.multiServerService.DeleteServer(id)
if err != nil {
jsonMsg(ctx, "Failed to delete server", err)
return
}
jsonMsg(ctx, "Server deleted successfully", nil)
}
func (c *MultiServerController) updateServer(ctx *gin.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
jsonMsg(ctx, "Invalid ID", err)
return
}
server := &model.Server{
Id: id,
}
err = ctx.ShouldBind(server)
if err != nil {
jsonMsg(ctx, "Invalid data", err)
return
}
err = c.multiServerService.UpdateServer(server)
if err != nil {
jsonMsg(ctx, "Failed to update server", err)
return
}
jsonMsg(ctx, "Server updated successfully", nil)
}

View file

@ -26,6 +26,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.GET("/inbounds", a.inbounds) g.GET("/inbounds", a.inbounds)
g.GET("/servers", a.servers)
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
@ -52,3 +53,7 @@ func (a *XUIController) settings(c *gin.Context) {
func (a *XUIController) xraySettings(c *gin.Context) { func (a *XUIController) xraySettings(c *gin.Context) {
html(c, "xray.html", "pages.xray.title", nil) html(c, "xray.html", "pages.xray.title", nil)
} }
func (a *XUIController) servers(c *gin.Context) {
html(c, "servers.html", "Servers", nil)
}

View file

@ -54,6 +54,11 @@
icon: 'user', icon: 'user',
title: '{{ i18n "menu.inbounds"}}' title: '{{ i18n "menu.inbounds"}}'
}, },
{
key: '{{ .base_path }}panel/servers',
icon: 'cloud-server',
title: 'Servers'
},
{ {
key: '{{ .base_path }}panel/settings', key: '{{ .base_path }}panel/settings',
icon: 'setting', icon: 'setting',

View file

@ -12,10 +12,26 @@
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option> <a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key ]]</a-select-option>
</a-select> </a-select>
</a-form-item> </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-input v-model.trim="inbound.stream.reality.target"></a-input>
</a-form-item> </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-input v-model.trim="inbound.stream.reality.serverNames"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='Max Time Diff (ms)'> <a-form-item label='Max Time Diff (ms)'>

View file

@ -602,6 +602,7 @@
{{template "page/body_scripts" .}} {{template "page/body_scripts" .}}
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script> <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/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/inbound.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script> <script src="{{ .base_path }}assets/js/model/dbinbound.js?{{ .cur_ver }}"></script>
{{template "component/aSidebar" .}} {{template "component/aSidebar" .}}

View file

@ -158,6 +158,13 @@
this.inbound.stream.reality.mldsa65Seed = ''; this.inbound.stream.reality.mldsa65Seed = '';
this.inbound.stream.reality.settings.mldsa65Verify = ''; 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() { async getNewEchCert() {
inModal.loading(true); inModal.loading(true);
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni: inModal.inbound.stream.tls.sni }); const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', { sni: inModal.inbound.stream.tls.sni });

165
web/html/servers.html Normal file
View file

@ -0,0 +1,165 @@
{{template "header" .}}
<div id="app" class="row" v-cloak>
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h3 class="card-title">Server Management</h3>
<div class="card-tools">
<button class="btn btn-primary" @click="showAddModal">Add Server</button>
</div>
</div>
<div class="card-body">
<table class="table table-bordered">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Address</th>
<th>Port</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="(server, index) in servers">
<td>{{index + 1}}</td>
<td>{{server.name}}</td>
<td>{{server.address}}</td>
<td>{{server.port}}</td>
<td>
<span v-if="server.enable" class="badge bg-success">Yes</span>
<span v-else class="badge bg-danger">No</span>
</td>
<td>
<button class="btn btn-info btn-sm" @click="showEditModal(server)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteServer(server.id)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Add/Edit Modal -->
<div class="modal fade" id="serverModal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{modal.title}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<form>
<div class="form-group">
<label>Name</label>
<input type="text" class="form-control" v-model="modal.server.name">
</div>
<div class="form-group">
<label>Address (IP or Domain)</label>
<input type="text" class="form-control" v-model="modal.server.address">
</div>
<div class="form-group">
<label>Port</label>
<input type="number" class="form-control" v-model.number="modal.server.port">
</div>
<div class="form-group">
<label>API Key</label>
<input type="text" class="form-control" v-model="modal.server.apiKey">
</div>
<div class="form-check">
<input type="checkbox" class="form-check-input" v-model="modal.server.enable">
<label class="form-check-label">Enabled</label>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" @click="saveServer">Save</button>
</div>
</div>
</div>
</div>
</div>
<script>
const app = new Vue({
el: '#app',
data: {
servers: [],
modal: {
title: '',
server: {
name: '',
address: '',
port: 0,
apiKey: '',
enable: true
}
}
},
methods: {
loadServers() {
axios.get('{{.base_path}}server/list')
.then(response => {
this.servers = response.data.obj;
})
.catch(error => {
alert(error.response.data.msg);
});
},
showAddModal() {
this.modal.title = 'Add Server';
this.modal.server = {
name: '',
address: '',
port: 0,
apiKey: '',
enable: true
};
$('#serverModal').modal('show');
},
showEditModal(server) {
this.modal.title = 'Edit Server';
this.modal.server = Object.assign({}, server);
$('#serverModal').modal('show');
},
saveServer() {
let url = '{{.base_path}}server/add';
if (this.modal.server.id) {
url = `{{.base_path}}server/update/${this.modal.server.id}`;
}
axios.post(url, this.modal.server)
.then(response => {
alert(response.data.msg);
$('#serverModal').modal('hide');
this.loadServers();
})
.catch(error => {
alert(error.response.data.msg);
});
},
deleteServer(id) {
if (!confirm('Are you sure you want to delete this server?')) {
return;
}
axios.post(`{{.base_path}}server/del/${id}`)
.then(response => {
alert(response.data.msg);
this.loadServers();
})
.catch(error => {
alert(error.response.data.msg);
});
}
},
mounted() {
this.loadServers();
}
});
</script>
{{template "footer" .}}

34
web/middleware/auth.go Normal file
View file

@ -0,0 +1,34 @@
package middleware
import (
"net/http"
"x-ui/web/service"
"github.com/gin-gonic/gin"
)
func ApiAuth() gin.HandlerFunc {
return func(c *gin.Context) {
apiKey := c.GetHeader("Api-Key")
if apiKey == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "API key is required"})
c.Abort()
return
}
settingService := service.SettingService{}
panelAPIKey, err := settingService.GetAPIKey()
if err != nil || panelAPIKey == "" {
c.JSON(http.StatusInternalServerError, gin.H{"error": "API key not configured on the panel"})
c.Abort()
return
}
if apiKey != panelAPIKey {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
c.Abort()
return
}
c.Next()
}
}

View file

@ -3,8 +3,11 @@
package service package service
import ( import (
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -673,6 +676,11 @@ func (s *InboundService) AddInboundClient(data *model.Inbound) (bool, error) {
} }
s.xrayApi.Close() s.xrayApi.Close()
if err == nil {
body, _ := json.Marshal(data)
s.syncWithSlaves("POST", "/panel/inbound/api/addClient", bytes.NewReader(body))
}
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
@ -761,6 +769,11 @@ func (s *InboundService) DelInboundClient(inboundId int, clientId string) (bool,
s.xrayApi.Close() s.xrayApi.Close()
} }
} }
if err == nil {
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/%d/delClient/%s", inboundId, clientId), nil)
}
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
} }
@ -936,6 +949,12 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
logger.Debug("Client old email not found") logger.Debug("Client old email not found")
needRestart = true needRestart = true
} }
if err == nil {
body, _ := json.Marshal(data)
s.syncWithSlaves("POST", fmt.Sprintf("/panel/inbound/api/updateClient/%s", clientId), bytes.NewReader(body))
}
return needRestart, tx.Save(oldInbound).Error return needRestart, tx.Save(oldInbound).Error
} }
@ -2379,6 +2398,44 @@ func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, [
return validEmails, extraEmails, nil return validEmails, extraEmails, nil
} }
func (s *InboundService) syncWithSlaves(method string, path string, body io.Reader) {
serverService := MultiServerService{}
servers, err := serverService.GetServers()
if err != nil {
logger.Warning("Failed to get servers for syncing:", err)
return
}
for _, server := range servers {
if !server.Enable {
continue
}
url := fmt.Sprintf("http://%s:%d%s", server.Address, server.Port, path)
req, err := http.NewRequest(method, url, body)
if err != nil {
logger.Warningf("Failed to create request for server %s: %v", server.Name, err)
continue
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Api-Key", server.APIKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
logger.Warningf("Failed to send request to server %s: %v", server.Name, err)
continue
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
logger.Warningf("Failed to sync with server %s. Status: %s, Body: %s", server.Name, resp.Status, string(bodyBytes))
}
}
func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) { func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (bool, error) {
oldInbound, err := s.GetInbound(inboundId) oldInbound, err := s.GetInbound(inboundId)
if err != nil { if err != nil {
@ -2470,4 +2527,5 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
} }
return needRestart, db.Save(oldInbound).Error return needRestart, db.Save(oldInbound).Error
} }

View file

@ -0,0 +1,72 @@
package service
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"x-ui/database"
"x-ui/database/model"
"github.com/stretchr/testify/assert"
)
func TestInboundServiceSync(t *testing.T) {
setup()
defer teardown()
// Mock server to simulate a slave
var receivedApiKey string
var receivedBody []byte
mockSlave := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedApiKey = r.Header.Get("Api-Key")
receivedBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer mockSlave.Close()
// Add the mock slave to the database
multiServerService := MultiServerService{}
mockSlaveURL, _ := url.Parse(mockSlave.URL)
mockSlavePort, _ := strconv.Atoi(mockSlaveURL.Port())
slaveServer := &model.Server{
Name: "mock-slave",
Address: mockSlaveURL.Hostname(),
Port: mockSlavePort,
APIKey: "slave-api-key",
Enable: true,
}
multiServerService.AddServer(slaveServer)
// Create a test inbound and client
inboundService := InboundService{}
db := database.GetDB()
testInbound := &model.Inbound{
UserId: 1,
Remark: "test-inbound",
Enable: true,
Settings: `{"clients":[]}`,
}
db.Create(testInbound)
clientData := model.Client{
Email: "test@example.com",
ID: "test-id",
}
clientBytes, _ := json.Marshal([]model.Client{clientData})
inboundData := &model.Inbound{
Id: testInbound.Id,
Settings: string(clientBytes),
}
// Test AddInboundClient sync
inboundService.AddInboundClient(inboundData)
assert.Equal(t, "slave-api-key", receivedApiKey)
var receivedInbound model.Inbound
json.Unmarshal(receivedBody, &receivedInbound)
assert.Equal(t, 1, receivedInbound.Id)
}

View file

@ -0,0 +1,37 @@
package service
import (
"x-ui/database"
"x-ui/database/model"
)
type MultiServerService struct{}
func (s *MultiServerService) GetServers() ([]*model.Server, error) {
db := database.GetDB()
var servers []*model.Server
err := db.Find(&servers).Error
return servers, err
}
func (s *MultiServerService) GetServer(id int) (*model.Server, error) {
db := database.GetDB()
var server model.Server
err := db.First(&server, id).Error
return &server, err
}
func (s *MultiServerService) AddServer(server *model.Server) error {
db := database.GetDB()
return db.Create(server).Error
}
func (s *MultiServerService) UpdateServer(server *model.Server) error {
db := database.GetDB()
return db.Save(server).Error
}
func (s *MultiServerService) DeleteServer(id int) error {
db := database.GetDB()
return db.Delete(&model.Server{}, id).Error
}

View file

@ -0,0 +1,63 @@
package service
import (
"os"
"testing"
"x-ui/database"
"x-ui/database/model"
"github.com/stretchr/testify/assert"
)
func setup() {
dbPath := "test.db"
os.Remove(dbPath)
database.InitDB(dbPath)
}
func teardown() {
db, _ := database.GetDB().DB()
db.Close()
os.Remove("test.db")
}
func TestMultiServerService(t *testing.T) {
setup()
defer teardown()
service := MultiServerService{}
// Test AddServer
server := &model.Server{
Name: "test-server",
Address: "127.0.0.1",
Port: 54321,
APIKey: "test-key",
Enable: true,
}
err := service.AddServer(server)
assert.NoError(t, err)
// Test GetServer
retrievedServer, err := service.GetServer(server.Id)
assert.NoError(t, err)
assert.Equal(t, server.Name, retrievedServer.Name)
// Test GetServers
servers, err := service.GetServers()
assert.NoError(t, err)
assert.Len(t, servers, 1)
// Test UpdateServer
retrievedServer.Name = "updated-server"
err = service.UpdateServer(retrievedServer)
assert.NoError(t, err)
updatedServer, _ := service.GetServer(server.Id)
assert.Equal(t, "updated-server", updatedServer.Name)
// Test DeleteServer
err = service.DeleteServer(server.Id)
assert.NoError(t, err)
_, err = service.GetServer(server.Id)
assert.Error(t, err)
}

View file

@ -204,6 +204,21 @@ func (s *SettingService) getSetting(key string) (*model.Setting, error) {
return setting, nil return setting, nil
} }
func (s *SettingService) GetAPIKey() (string, error) {
setting, err := s.getSetting("ApiKey")
if err != nil {
return "", err
}
if setting == nil {
return "", nil
}
return setting.Value, nil
}
func (s *SettingService) SetAPIKey(apiKey string) error {
return s.saveSetting("ApiKey", apiKey)
}
func (s *SettingService) saveSetting(key string, value string) error { func (s *SettingService) saveSetting(key string, value string) error {
setting, err := s.getSetting(key) setting, err := s.getSetting(key)
db := database.GetDB() db := database.GetDB()
@ -479,10 +494,18 @@ func (s *SettingService) GetSubDomain() (string, error) {
return s.getString("subDomain") return s.getString("subDomain")
} }
func (s *SettingService) SetSubCertFile(subCertFile string) error {
return s.setString("subCertFile", subCertFile)
}
func (s *SettingService) GetSubCertFile() (string, error) { func (s *SettingService) GetSubCertFile() (string, error) {
return s.getString("subCertFile") return s.getString("subCertFile")
} }
func (s *SettingService) SetSubKeyFile(subKeyFile string) error {
return s.setString("subKeyFile", subKeyFile)
}
func (s *SettingService) GetSubKeyFile() (string, error) { func (s *SettingService) GetSubKeyFile() (string, error) {
return s.getString("subKeyFile") return s.getString("subKeyFile")
} }

View file

@ -38,7 +38,15 @@ import (
) )
var ( var (
bot *telego.Bot 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 botHandler *th.BotHandler
adminIds []int64 adminIds []int64
isRunning bool isRunning bool
@ -306,8 +314,13 @@ func (t *Tgbot) SetHostname() {
hostname = host 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() { 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 { if botHandler != nil {
botHandler.Stop() botHandler.Stop()
} }
@ -316,6 +329,27 @@ func (t *Tgbot) Stop() {
adminIds = nil 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. // encodeQuery encodes the query string if it's longer than 64 characters.
func (t *Tgbot) encodeQuery(query string) string { func (t *Tgbot) encodeQuery(query string) string {
// NOTE: we only need to hash for more than 64 chars // NOTE: we only need to hash for more than 64 chars
@ -345,188 +379,207 @@ func (t *Tgbot) OnReceive() {
params := telego.GetUpdatesParams{ params := telego.GetUpdatesParams{
Timeout: 30, // Increased timeout to reduce API calls Timeout: 30, // Increased timeout to reduce API calls
} }
// --- GRACEFUL SHUTDOWN FIX: Context creation ---
tgBotMutex.Lock()
updates, _ := bot.UpdatesViaLongPolling(context.Background(), &params) // Create a context with cancellation and store the cancel function.
var ctx context.Context
botHandler, _ = th.NewBotHandler(bot, updates) // 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() // <<< ИЗМЕНЕНИЕ
}
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error { tgBotMutex.Unlock()
delete(userStates, message.Chat.ID)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
return nil
}, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error { // Get updates channel using the context.
// Use goroutine with worker pool for concurrent command processing updates, _ := bot.UpdatesViaLongPolling(ctx, &params)
go func() { botWG.Go(func() {
messageWorkerPool <- struct{}{} // Acquire worker
defer func() { <-messageWorkerPool }() // Release worker
botHandler, _ = th.NewBotHandler(bot, updates)
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
delete(userStates, message.Chat.ID) delete(userStates, message.Chat.ID)
t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.keyboardClosed"), tu.ReplyKeyboardRemove())
}() return nil
return nil }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard")))
}, th.AnyCommand())
botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
// Use goroutine with worker pool for concurrent callback processing // Use goroutine with worker pool for concurrent command processing
go func() { go func() {
messageWorkerPool <- struct{}{} // Acquire worker messageWorkerPool <- struct{}{} // Acquire worker
defer func() { <-messageWorkerPool }() // Release worker defer func() { <-messageWorkerPool }() // Release worker
delete(userStates, query.Message.GetChat().ID)
t.answerCallback(&query, checkAdmin(query.From.ID))
}()
return nil
}, th.AnyCallbackQueryWithMessage())
botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
if userState, exists := userStates[message.Chat.ID]; exists {
switch userState {
case "awaiting_id":
if client_Id == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
return nil
}
client_Id = strings.TrimSpace(message.Text)
if t.isSingleWord(client_Id) {
userStates[message.Chat.ID] = "awaiting_id"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_id"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_password_tr":
if client_TrPassword == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_TrPassword = strings.TrimSpace(message.Text)
if t.isSingleWord(client_TrPassword) {
userStates[message.Chat.ID] = "awaiting_password_tr"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_password_sh":
if client_ShPassword == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_ShPassword = strings.TrimSpace(message.Text)
if t.isSingleWord(client_ShPassword) {
userStates[message.Chat.ID] = "awaiting_password_sh"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_email":
if client_Email == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_Email = strings.TrimSpace(message.Text)
if t.isSingleWord(client_Email) {
userStates[message.Chat.ID] = "awaiting_email"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_comment":
if client_Comment == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_Comment = strings.TrimSpace(message.Text)
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID) delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID) t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID))
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol) }()
t.addClient(message.Chat.ID, message_text) return nil
} }, th.AnyCommand())
} else { botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error {
if message.UsersShared != nil { // Use goroutine with worker pool for concurrent callback processing
if checkAdmin(message.From.ID) { go func() {
for _, sharedUser := range message.UsersShared.Users { messageWorkerPool <- struct{}{} // Acquire worker
userID := sharedUser.UserID defer func() { <-messageWorkerPool }() // Release worker
needRestart, err := t.inboundService.SetClientTelegramUserID(message.UsersShared.RequestID, userID)
if needRestart { delete(userStates, query.Message.GetChat().ID)
t.xrayService.SetToNeedRestart() t.answerCallback(&query, checkAdmin(query.From.ID))
} }()
output := "" return nil
if err != nil { }, th.AnyCallbackQueryWithMessage())
output += t.I18nBot("tgbot.messages.selectUserFailed")
} else { botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error {
output += t.I18nBot("tgbot.messages.userSaved") if userState, exists := userStates[message.Chat.ID]; exists {
} switch userState {
t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove()) case "awaiting_id":
if client_Id == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
return nil
}
client_Id = strings.TrimSpace(message.Text)
if t.isSingleWord(client_Id) {
userStates[message.Chat.ID] = "awaiting_id"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_id"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_password_tr":
if client_TrPassword == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_TrPassword = strings.TrimSpace(message.Text)
if t.isSingleWord(client_TrPassword) {
userStates[message.Chat.ID] = "awaiting_password_tr"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_password_sh":
if client_ShPassword == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_ShPassword = strings.TrimSpace(message.Text)
if t.isSingleWord(client_ShPassword) {
userStates[message.Chat.ID] = "awaiting_password_sh"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_password"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_email":
if client_Email == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_Email = strings.TrimSpace(message.Text)
if t.isSingleWord(client_Email) {
userStates[message.Chat.ID] = "awaiting_email"
cancel_btn_markup := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.use_default")).WithCallbackData("add_client_default_info"),
),
)
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.messages.incorrect_input"), cancel_btn_markup)
} else {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_email"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
case "awaiting_comment":
if client_Comment == strings.TrimSpace(message.Text) {
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.using_default_value"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
return nil
}
client_Comment = strings.TrimSpace(message.Text)
t.SendMsgToTgbotDeleteAfter(message.Chat.ID, t.I18nBot("tgbot.messages.received_comment"), 3, tu.ReplyKeyboardRemove())
delete(userStates, message.Chat.ID)
inbound, _ := t.inboundService.GetInbound(receiver_inbound_ID)
message_text, _ := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
t.addClient(message.Chat.ID, message_text)
}
} else {
if message.UsersShared != nil {
if checkAdmin(message.From.ID) {
for _, sharedUser := range message.UsersShared.Users {
userID := sharedUser.UserID
needRestart, err := t.inboundService.SetClientTelegramUserID(message.UsersShared.RequestID, userID)
if needRestart {
t.xrayService.SetToNeedRestart()
}
output := ""
if err != nil {
output += t.I18nBot("tgbot.messages.selectUserFailed")
} else {
output += t.I18nBot("tgbot.messages.userSaved")
}
t.SendMsgToTgbot(message.Chat.ID, output, tu.ReplyKeyboardRemove())
}
} else {
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove())
} }
} else {
t.SendMsgToTgbot(message.Chat.ID, t.I18nBot("tgbot.noResult"), tu.ReplyKeyboardRemove())
} }
} }
} return nil
return nil }, th.AnyMessage())
}, th.AnyMessage())
botHandler.Start() botHandler.Start()
})
} }
// answerCommand processes incoming command messages from Telegram users. // answerCommand processes incoming command messages from Telegram users.

View file

@ -32,7 +32,7 @@
"copySuccess" = "Скопировано" "copySuccess" = "Скопировано"
"sure" = "Да" "sure" = "Да"
"encryption" = "Шифрование" "encryption" = "Шифрование"
"useIPv4ForHost" = "Использовать IPv4 для хоста" "useIPv4ForHost" = "Использовать IPv4 для подключения к хосту"
"transmission" = "Транспорт" "transmission" = "Транспорт"
"host" = "Хост" "host" = "Хост"
"path" = "Путь" "path" = "Путь"
@ -46,8 +46,8 @@
"online" = "Онлайн" "online" = "Онлайн"
"domainName" = "Домен" "domainName" = "Домен"
"monitor" = "Мониторинг IP" "monitor" = "Мониторинг IP"
"certificate" = "SSL сертификат" "certificate" = "SSL-сертификат"
"fail" = "Ошибка" "fail" = "Сбой"
"comment" = "Комментарий" "comment" = "Комментарий"
"success" = "Успешно" "success" = "Успешно"
"lastOnline" = "Был(а) в сети" "lastOnline" = "Был(а) в сети"
@ -55,17 +55,17 @@
"install" = "Установка" "install" = "Установка"
"clients" = "Клиенты" "clients" = "Клиенты"
"usage" = "Использование" "usage" = "Использование"
"twoFactorCode" = "Код" "twoFactorCode" = "Код 2FA"
"remained" = "Остаток" "remained" = "Остаток"
"security" = "Безопасность" "security" = "Безопасность"
"secAlertTitle" = "Предупреждение системы безопасности" "secAlertTitle" = "Предупреждение системы безопасности"
"secAlertSsl" = "Это соединение не защищено. Пожалуйста, не вводите конфиденциальную информацию, пока не установите SSL сертификат для защиты соединения" "secAlertSsl" = "Соединение не защищено. Не вводите конфиденциальные данные до установки SSL-сертификата."
"secAlertConf" = "Некоторые настройки уязвимы для атак. Чтобы в будущем не было проблем, нужно усилить защиту." "secAlertConf" = "Некоторые настройки уязвимы. Рекомендуется усилить защиту для предотвращения атак."
"secAlertSSL" = "Ваше подключение к панели не защищено. Установите SSL сертификат для защиты данных." "secAlertSSL" = "Подключение к панели не защищено. Установите SSL-сертификат для защиты данных."
"secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите случайный или просто другой порт." "secAlertPanelPort" = "Порт панели по умолчанию небезопасен. Установите нестандартный или случайный порт."
"secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Сделайте адрес сложным." "secAlertPanelURI" = "Адрес панели по умолчанию небезопасен. Настройте уникальный и сложный URI."
"secAlertSubURI" = "URI-адрес подписки по умолчанию небезопасен. Пожалуйста, настройте сложный URI-адрес." "secAlertSubURI" = "URI подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
"secAlertSubJsonURI" = "URI-адрес по умолчанию для JSON подписки небезопасен. Пожалуйста, настройте сложный URI-адрес." "secAlertSubJsonURI" = "URI JSON-подписки по умолчанию небезопасен. Настройте уникальный и сложный адрес."
"emptyDnsDesc" = "Нет добавленных DNS-серверов." "emptyDnsDesc" = "Нет добавленных DNS-серверов."
"emptyFakeDnsDesc" = "Нет добавленных Fake DNS-серверов." "emptyFakeDnsDesc" = "Нет добавленных Fake DNS-серверов."
"emptyBalancersDesc" = "Нет добавленных балансировщиков." "emptyBalancersDesc" = "Нет добавленных балансировщиков."
@ -83,15 +83,15 @@
"individualLinks" = "Индивидуальные ссылки" "individualLinks" = "Индивидуальные ссылки"
"active" = "Активна" "active" = "Активна"
"inactive" = "Неактивна" "inactive" = "Неактивна"
"unlimited" = "Безлимит" "unlimited" = "Неограниченно"
"noExpiry" = "Без срока" "noExpiry" = "Бессрочно"
[menu] [menu]
"theme" = "Тема" "theme" = "Тема"
"dark" = "Темная" "dark" = "Темная"
"ultraDark" = "Очень темная" "ultraDark" = "Очень темная"
"dashboard" = "Дашборд" "dashboard" = "Дашборд"
"inbounds" = "Инбаунды" "inbounds" = "Подключения"
"settings" = "Настройки" "settings" = "Настройки"
"xray" = "Настройки Xray" "xray" = "Настройки Xray"
"logout" = "Выход" "logout" = "Выход"
@ -107,7 +107,7 @@
"emptyUsername" = "Введите имя пользователя" "emptyUsername" = "Введите имя пользователя"
"emptyPassword" = "Введите пароль" "emptyPassword" = "Введите пароль"
"wrongUsernameOrPassword" = "Неверные данные учетной записи." "wrongUsernameOrPassword" = "Неверные данные учетной записи."
"successLogin" = "Вы успешно вошли в аккаунт" "successLogin" = "Вход выполнен успешно"
[pages.index] [pages.index]
"title" = "Дашборд" "title" = "Дашборд"
@ -122,7 +122,7 @@
"stopXray" = "Остановить" "stopXray" = "Остановить"
"restartXray" = "Перезапустить" "restartXray" = "Перезапустить"
"xraySwitch" = "Выбор версии" "xraySwitch" = "Выбор версии"
"xraySwitchClick" = "Выберите желаемую версию" "xraySwitchClick" = "Выберите нужную версию"
"xraySwitchClickDesk" = "Важно: старые версии могут не поддерживать текущие настройки" "xraySwitchClickDesk" = "Важно: старые версии могут не поддерживать текущие настройки"
"xrayStatusUnknown" = "Неизвестно" "xrayStatusUnknown" = "Неизвестно"
"xrayStatusRunning" = "Запущен" "xrayStatusRunning" = "Запущен"
@ -134,7 +134,7 @@
"systemLoadDesc" = "Средняя загрузка системы за последние 1, 5 и 15 минут" "systemLoadDesc" = "Средняя загрузка системы за последние 1, 5 и 15 минут"
"connectionCount" = "Количество соединений" "connectionCount" = "Количество соединений"
"ipAddresses" = "IP-адреса сервера" "ipAddresses" = "IP-адреса сервера"
"toggleIpVisibility" = "Переключить видимость IP-адресов сервера" "toggleIpVisibility" = "Скрыть или показать IP-адреса сервера"
"overallSpeed" = "Общая скорость передачи трафика" "overallSpeed" = "Общая скорость передачи трафика"
"upload" = "Отправка" "upload" = "Отправка"
"download" = "Загрузка" "download" = "Загрузка"
@ -168,10 +168,10 @@
[pages.inbounds] [pages.inbounds]
"allTimeTraffic" = "Общий трафик" "allTimeTraffic" = "Общий трафик"
"allTimeTrafficUsage" = "Общее использование за все время" "allTimeTrafficUsage" = "Общее использование за все время"
"title" = "Инбаунды" "title" = "Подключения"
"totalDownUp" = "Объем отправленного/полученного трафика" "totalDownUp" = "Отправлено/получено"
"totalUsage" = "Всего трафика" "totalUsage" = "Всего трафика"
"inboundCount" = "Всего инбаундов" "inboundCount" = "Всего подключений"
"operate" = "Меню" "operate" = "Меню"
"enable" = "Включить" "enable" = "Включить"
"remark" = "Примечание" "remark" = "Примечание"
@ -185,13 +185,13 @@
"createdAt" = "Создано" "createdAt" = "Создано"
"updatedAt" = "Обновлено" "updatedAt" = "Обновлено"
"resetTraffic" = "Сброс трафика" "resetTraffic" = "Сброс трафика"
"addInbound" = "Создать инбаунд" "addInbound" = "Создать подключение"
"generalActions" = "Общие действия" "generalActions" = "Общие действия"
"autoRefresh" = "Автообновление" "autoRefresh" = "Автообновление"
"autoRefreshInterval" = "Интервал" "autoRefreshInterval" = "Интервал"
"modifyInbound" = "Изменить инбаунд" "modifyInbound" = "Изменить подключение"
"deleteInbound" = "Удалить инбаунд" "deleteInbound" = "Удалить подключение"
"deleteInboundContent" = "Вы уверены, что хотите удалить инбаунд?" "deleteInboundContent" = "Вы уверены, что хотите удалить подключение?"
"deleteClient" = "Удалить клиента" "deleteClient" = "Удалить клиента"
"deleteClientContent" = "Вы уверены, что хотите удалить клиента?" "deleteClientContent" = "Вы уверены, что хотите удалить клиента?"
"resetTrafficContent" = "Вы уверены, что хотите сбросить трафик?" "resetTrafficContent" = "Вы уверены, что хотите сбросить трафик?"
@ -214,11 +214,11 @@
"export" = "Экспорт ссылок" "export" = "Экспорт ссылок"
"clone" = "Клонировать" "clone" = "Клонировать"
"cloneInbound" = "Клонировать" "cloneInbound" = "Клонировать"
"cloneInboundContent" = "Будут клонированы все настройки инбаундов, кроме списка клиентов, порта и IP-адреса прослушивания" "cloneInboundContent" = "Будут клонированы все настройки подключений, кроме списка клиентов, порта и IP-адреса прослушивания"
"cloneInboundOk" = "Клонировано" "cloneInboundOk" = "Клонировано"
"resetAllTraffic" = "Сброс трафика всех инбаундов" "resetAllTraffic" = "Сброс трафика всех подключений"
"resetAllTrafficTitle" = "Сброс трафика всех инбаундов" "resetAllTrafficTitle" = "Сброс трафика всех подключений"
"resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех инбаундов?" "resetAllTrafficContent" = "Вы уверены, что хотите сбросить трафик всех подключений?"
"resetInboundClientTraffics" = "Сброс трафика клиента" "resetInboundClientTraffics" = "Сброс трафика клиента"
"resetInboundClientTrafficTitle" = "Сброс трафика клиентов" "resetInboundClientTrafficTitle" = "Сброс трафика клиентов"
"resetInboundClientTrafficContent" = "Вы уверены, что хотите сбросить трафик для этих клиентов?" "resetInboundClientTrafficContent" = "Вы уверены, что хотите сбросить трафик для этих клиентов?"
@ -231,7 +231,7 @@
"email" = "Email" "email" = "Email"
"emailDesc" = "Пожалуйста, укажите уникальный Email" "emailDesc" = "Пожалуйста, укажите уникальный Email"
"IPLimit" = "Лимит по количеству IP" "IPLimit" = "Лимит по количеству IP"
"IPLimitDesc" = "Ограничение количества одновременных подключений с разных IP(0 отключить)" "IPLimitDesc" = "Ограничение числа одновременных подключений с разных IP (0 отключить)"
"IPLimitlog" = "Лог IP-адресов" "IPLimitlog" = "Лог IP-адресов"
"IPLimitlogDesc" = "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)" "IPLimitlogDesc" = "Лог IP-адресов (перед включением лога IP-адресов, вы должны очистить лог)"
"IPLimitlogclear" = "Очистить лог" "IPLimitlogclear" = "Очистить лог"
@ -240,19 +240,19 @@
"subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'" "subscriptionDesc" = "Вы можете найти свою ссылку подписки в разделе 'Подробнее'"
"info" = "Информация" "info" = "Информация"
"same" = "Тот же" "same" = "Тот же"
"inboundData" = "Данные инбаундов" "inboundData" = "Данные подключений"
"exportInbound" = "Экспорт инбаундов" "exportInbound" = "Экспорт подключений"
"import" = "Импортировать" "import" = "Импортировать"
"importInbound" = "Импорт инбаундов" "importInbound" = "Импорт подключений"
"periodicTrafficResetTitle" = "Сброс трафика" "periodicTrafficResetTitle" = "Сброс трафика"
"periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы" "periodicTrafficResetDesc" = "Автоматический сброс счетчика трафика через указанные интервалы"
"lastReset" = "Последний сброс" "lastReset" = "Последний сброс"
[pages.client] [pages.client]
"add" = "Создать клиента" "add" = "Добавить клиента"
"edit" = "Редактировать клиента" "edit" = "Редактировать клиента"
"submitAdd" = "Добавить" "submitAdd" = "Добавить"
"submitEdit" = "Сохранить" "submitEdit" = "Сохранить изменения"
"clientCount" = "Количество клиентов" "clientCount" = "Количество клиентов"
"bulk" = "Добавить несколько" "bulk" = "Добавить несколько"
"method" = "Метод" "method" = "Метод"
@ -276,13 +276,13 @@
"obtain" = "Получить" "obtain" = "Получить"
"updateSuccess" = "Обновление прошло успешно" "updateSuccess" = "Обновление прошло успешно"
"logCleanSuccess" = "Лог был очищен" "logCleanSuccess" = "Лог был очищен"
"inboundsUpdateSuccess" = "Инбаунды успешно обновлены" "inboundsUpdateSuccess" = "Подключения успешно обновлены"
"inboundUpdateSuccess" = "Инбаунд успешно обновлено" "inboundUpdateSuccess" = "Подключение успешно обновлено"
"inboundCreateSuccess" = "Инбаунд успешно создано" "inboundCreateSuccess" = "Подключение успешно создано"
"inboundDeleteSuccess" = "Инбаунд успешно удалено" "inboundDeleteSuccess" = "Подключение успешно удалено"
"inboundClientAddSuccess" = "Клиент(ы) инбаунда добавлен(ы)" "inboundClientAddSuccess" = "Клиент(ы) подключения добавлен(ы)"
"inboundClientDeleteSuccess" = "Клиент инбаунда удалён" "inboundClientDeleteSuccess" = "Клиент подключения удалён"
"inboundClientUpdateSuccess" = "Клиент инбаунда обновлён" "inboundClientUpdateSuccess" = "Клиент подключения обновлён"
"delDepletedClientsSuccess" = "Все исчерпанные клиенты удалены" "delDepletedClientsSuccess" = "Все исчерпанные клиенты удалены"
"resetAllClientTrafficSuccess" = "Весь трафик клиента сброшен" "resetAllClientTrafficSuccess" = "Весь трафик клиента сброшен"
"resetAllTrafficSuccess" = "Весь трафик сброшен" "resetAllTrafficSuccess" = "Весь трафик сброшен"
@ -310,7 +310,7 @@
[pages.settings] [pages.settings]
"title" = "Настройки" "title" = "Настройки"
"save" = "Сохранить" "save" = "Сохранить"
"infoDesc" = "Каждое внесённое изменение должно быть сохранено. Пожалуйста, перезапустите панель, чтобы изменения вступили в силу." "infoDesc" = "Сохраните изменения и перезапустите панель для их применения."
"restartPanel" = "Перезапуск панели" "restartPanel" = "Перезапуск панели"
"restartPanelDesc" = "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера" "restartPanelDesc" = "Вы уверены, что хотите перезапустить панель? Подтвердите, и перезапуск произойдёт через 3 секунды. Если панель будет недоступна, проверьте лог сервера"
"restartPanelSuccess" = "Панель успешно перезапущена" "restartPanelSuccess" = "Панель успешно перезапущена"
@ -318,11 +318,11 @@
"resetDefaultConfig" = "Восстановить настройки по умолчанию" "resetDefaultConfig" = "Восстановить настройки по умолчанию"
"panelSettings" = "Панель" "panelSettings" = "Панель"
"securitySettings" = "Учетная запись" "securitySettings" = "Учетная запись"
"TGBotSettings" = "Telegram" "TGBotSettings" = "Telegram-Бот"
"panelListeningIP" = "IP-адрес для управления панелью" "panelListeningIP" = "IP-адрес для управления панелью"
"panelListeningIPDesc" = "Оставьте пустым для подключения с любого IP" "panelListeningIPDesc" = "Оставьте пустым для подключения с любого IP"
"panelListeningDomain" = "Домен панели" "panelListeningDomain" = "Домен панели"
"panelListeningDomainDesc" = "По умолчанию оставьте пустым, чтобы подключаться с любых доменов и IP-адресов" "panelListeningDomainDesc" = "Оставьте пустым для подключения с любых доменов и IP."
"panelPort" = "Порт панели" "panelPort" = "Порт панели"
"panelPortDesc" = "Порт, на котором работает панель" "panelPortDesc" = "Порт, на котором работает панель"
"publicKeyPath" = "Путь к файлу публичного ключа сертификата панели" "publicKeyPath" = "Путь к файлу публичного ключа сертификата панели"
@ -332,11 +332,11 @@
"panelUrlPath" = "Корневой путь URL адреса панели" "panelUrlPath" = "Корневой путь URL адреса панели"
"panelUrlPathDesc" = "Должен начинаться с '/' и заканчиваться '/'" "panelUrlPathDesc" = "Должен начинаться с '/' и заканчиваться '/'"
"pageSize" = "Размер нумерации страниц" "pageSize" = "Размер нумерации страниц"
"pageSizeDesc" = "Определить размер страницы для таблицы инбаундов. Установите 0, чтобы отключить" "pageSizeDesc" = "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить"
"remarkModel" = "Модель примечания и символ разделения" "remarkModel" = "Модель примечания и символ разделения"
"datepicker" = "Выбор даты" "datepicker" = "Тип календаря"
"datepickerPlaceholder" = "Выберите дату" "datepickerPlaceholder" = "Выберите дату"
"datepickerDescription" = "Запланированные задачи будут выполняться в выбранное время" "datepickerDescription" = "Запланированные задачи будут выполняться в соответствии с этим календарем."
"sampleRemark" = "Пример примечания" "sampleRemark" = "Пример примечания"
"oldUsername" = "Текущий логин" "oldUsername" = "Текущий логин"
"currentPassword" = "Текущий пароль" "currentPassword" = "Текущий пароль"
@ -346,7 +346,7 @@
"telegramBotEnableDesc" = "Доступ к функциям панели через Telegram-бота" "telegramBotEnableDesc" = "Доступ к функциям панели через Telegram-бота"
"telegramToken" = "Токен Telegram бота" "telegramToken" = "Токен Telegram бота"
"telegramTokenDesc" = "Необходимо получить токен у менеджера ботов Telegram @botfather" "telegramTokenDesc" = "Необходимо получить токен у менеджера ботов Telegram @botfather"
"telegramProxy" = "Прокси Socks5" "telegramProxy" = "Прокси-сервер Socks5"
"telegramProxyDesc" = "Если для подключения к Telegram вам нужен прокси Socks5, настройте его параметры согласно руководству." "telegramProxyDesc" = "Если для подключения к Telegram вам нужен прокси Socks5, настройте его параметры согласно руководству."
"telegramAPIServer" = "API-сервер Telegram" "telegramAPIServer" = "API-сервер Telegram"
"telegramAPIServerDesc" = "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию." "telegramAPIServerDesc" = "Используемый API-сервер Telegram. Оставьте пустым, чтобы использовать сервер по умолчанию."
@ -451,11 +451,11 @@
"RoutingStrategy" = "Настройка маршрутизации доменов" "RoutingStrategy" = "Настройка маршрутизации доменов"
"RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS" "RoutingStrategyDesc" = "Установка общей стратегии маршрутизации разрешения DNS"
"Torrent" = "Заблокировать BitTorrent" "Torrent" = "Заблокировать BitTorrent"
"Inbounds" = "Инбаунды" "Inbounds" = "Входящие подключения"
"InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов" "InboundsDesc" = "Изменение шаблона конфигурации для подключения определенных клиентов"
"Outbounds" = "Аутбаунды" "Outbounds" = "Исходящие подключения"
"Balancers" = "Балансировщик" "Balancers" = "Балансировщик"
"OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить аутбаунды для этого сервера" "OutboundsDesc" = "Изменение шаблона конфигурации, чтобы определить исходящие подключения для этого сервера"
"Routings" = "Маршрутизация" "Routings" = "Маршрутизация"
"RoutingsDesc" = "Важен приоритет каждого правила!" "RoutingsDesc" = "Важен приоритет каждого правила!"
"completeTemplate" = "Все" "completeTemplate" = "Все"
@ -486,8 +486,8 @@
"down" = "Опустить вниз" "down" = "Опустить вниз"
"source" = "Источник" "source" = "Источник"
"dest" = "Пункт назначения" "dest" = "Пункт назначения"
"inbound" = "Инбаунд" "inbound" = "Входящее подключение"
"outbound" = "Аутбаунд" "outbound" = "Исходящее подключение"
"balancer" = "Балансировщик" "balancer" = "Балансировщик"
"info" = "Информация" "info" = "Информация"
"add" = "Создать правило" "add" = "Создать правило"
@ -495,9 +495,9 @@
"useComma" = "Элементы, разделённые запятыми" "useComma" = "Элементы, разделённые запятыми"
[pages.xray.outbound] [pages.xray.outbound]
"addOutbound" = "Создать аутбаунд" "addOutbound" = "Создать исходящее подключение"
"addReverse" = "Создать реверс-прокси" "addReverse" = "Создать реверс-прокси"
"editOutbound" = "Изменить аутбаунд" "editOutbound" = "Изменить исходящее подключение"
"editReverse" = "Редактировать реверс-прокси" "editReverse" = "Редактировать реверс-прокси"
"tag" = "Тег" "tag" = "Тег"
"tagDesc" = "Уникальный тег" "tagDesc" = "Уникальный тег"
@ -511,7 +511,7 @@
"intercon" = "Соединение" "intercon" = "Соединение"
"settings" = "Настройки" "settings" = "Настройки"
"accountInfo" = "Информация об учетной записи" "accountInfo" = "Информация об учетной записи"
"outboundStatus" = "Статус аутбаунда" "outboundStatus" = "Статус исходящего подключения"
"sendThrough" = "Отправить через" "sendThrough" = "Отправить через"
[pages.xray.balancer] [pages.xray.balancer]
@ -587,8 +587,8 @@
"modifyUser" = "Вы успешно изменили учетные данные администратора." "modifyUser" = "Вы успешно изменили учетные данные администратора."
"originalUserPassIncorrect" = "Неверное имя пользователя или пароль" "originalUserPassIncorrect" = "Неверное имя пользователя или пароль"
"userPassMustBeNotEmpty" = "Новое имя пользователя и новый пароль должны быть заполнены" "userPassMustBeNotEmpty" = "Новое имя пользователя и новый пароль должны быть заполнены"
"getOutboundTrafficError" = "Ошибка получения трафика аутбаунда" "getOutboundTrafficError" = "Ошибка получения трафика исходящего подключения"
"resetOutboundTrafficError" = "Ошибка сброса трафика аутбаунда" "resetOutboundTrafficError" = "Ошибка сброса трафика исходящего подключения"
[tgbot] [tgbot]
"keyboardClosed" = "❌ Клавиатура закрыта." "keyboardClosed" = "❌ Клавиатура закрыта."
@ -596,7 +596,7 @@
"noQuery" = "❌ Запрос не найден. Пожалуйста, повторите команду." "noQuery" = "❌ Запрос не найден. Пожалуйста, повторите команду."
"wentWrong" = "❌ Что-то пошло не так..." "wentWrong" = "❌ Что-то пошло не так..."
"noIpRecord" = "❗ Нет записей об IP-адресе." "noIpRecord" = "❗ Нет записей об IP-адресе."
"noInbounds" = "❗ У вас не настроено ни одного инбаунда." "noInbounds" = "❗ У вас не настроено ни одного входящего подключения."
"unlimited" = "♾ Безлимит" "unlimited" = "♾ Безлимит"
"add" = "Добавить" "add" = "Добавить"
"month" = "Месяц" "month" = "Месяц"
@ -606,7 +606,7 @@
"hours" = "Часов" "hours" = "Часов"
"minutes" = "Минуты" "minutes" = "Минуты"
"unknown" = "Неизвестно" "unknown" = "Неизвестно"
"inbounds" = "Инбаунды" "inbounds" = "Входящие подключения"
"clients" = "Клиенты" "clients" = "Клиенты"
"offline" = "🔴 Офлайн" "offline" = "🔴 Офлайн"
"online" = "🟢 Онлайн" "online" = "🟢 Онлайн"
@ -620,7 +620,7 @@
"status" = "✅ Бот функционирует нормально." "status" = "✅ Бот функционирует нормально."
"usage" = "❗ Пожалуйста, укажите email для поиска." "usage" = "❗ Пожалуйста, укажите email для поиска."
"getID" = "🆔 Ваш User ID: <code>{{ .ID }}</code>" "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>" "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>" "restartUsage" = "\r\n\r\n<code>/restart</code>"
"restartSuccess" = "✅ Ядро Xray успешно перезапущено." "restartSuccess" = "✅ Ядро Xray успешно перезапущено."
@ -656,7 +656,7 @@
"username" = "👤 Имя пользователя: {{ .Username }}\r\n" "username" = "👤 Имя пользователя: {{ .Username }}\r\n"
"password" = "👤 Пароль: {{ .Password }}\r\n" "password" = "👤 Пароль: {{ .Password }}\r\n"
"time" = "⏰ Время: {{ .Time }}\r\n" "time" = "⏰ Время: {{ .Time }}\r\n"
"inbound" = "📍 Входящий поток: {{ .Remark }}\r\n" "inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
"port" = "🔌 Порт: {{ .Port }}\r\n" "port" = "🔌 Порт: {{ .Port }}\r\n"
"expire" = "📅 Дата окончания: {{ .Time }}\r\n" "expire" = "📅 Дата окончания: {{ .Time }}\r\n"
"expireIn" = "📅 Окончание через: {{ .Time }}\r\n" "expireIn" = "📅 Окончание через: {{ .Time }}\r\n"
@ -685,12 +685,12 @@
"pass_prompt" = "🔑 Стандартный пароль: {{ .ClientPassword }}\n\nВведите ваш пароль." "pass_prompt" = "🔑 Стандартный пароль: {{ .ClientPassword }}\n\nВведите ваш пароль."
"email_prompt" = "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email." "email_prompt" = "📧 Стандартный email: {{ .ClientEmail }}\n\nВведите ваш email."
"comment_prompt" = "💬 Стандартный комментарий: {{ .ClientComment }}\n\nВведите ваш комментарий." "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_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_pass" = "🔄 Входящие подключения: {{ .InboundRemark }}\n\n🔑 Пароль: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Трафик: {{ .ClientTraffic }}\n📅 Срок действия: {{ .ClientExp }}\n💬 Комментарий: {{ .ClientComment }}\n\nТеперь вы можете добавить клиента в входящее подключение!"
"cancel" = "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄" "cancel" = "❌ Процесс отменён! \n\nВы можете снова начать с /start в любое время. 🔄"
"error_add_client" = "⚠️ Ошибка:\n\n {{ .error }}" "error_add_client" = "⚠️ Ошибка:\n\n {{ .error }}"
"using_default_value" = "Используется значение по умолчанию👌" "using_default_value" = "Используется значение по умолчанию👌"
"incorrect_input" ="Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫" "incorrect_input" = "Ваш ввод недействителен.\nФразы должны быть непрерывными без пробелов.\nПравильный пример: aaaaaa\nНеправильный пример: aaa aaa 🚫"
"AreYouSure" = "Вы уверены? 🤔" "AreYouSure" = "Вы уверены? 🤔"
"SuccessResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно" "SuccessResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успешно"
"FailedResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠 Ошибка: [ {{ .ErrorMessage }} ]" "FailedResetTraffic" = "📧 Почта: {{ .ClientEmail }}\n🏁 Результат: ❌ Неудача \n\n🛠 Ошибка: [ {{ .ErrorMessage }} ]"
@ -707,7 +707,7 @@
"confirmToggle" = "✅ Подтвердить вкл/выкл пользователя?" "confirmToggle" = "✅ Подтвердить вкл/выкл пользователя?"
"dbBackup" = "📂 Бэкап БД" "dbBackup" = "📂 Бэкап БД"
"serverUsage" = "💻 Состояние сервера" "serverUsage" = "💻 Состояние сервера"
"getInbounds" = "🔌 Инбаунды" "getInbounds" = "🔌 Входящие подключения"
"depleteSoon" = "⚠️ Скоро конец" "depleteSoon" = "⚠️ Скоро конец"
"clientUsage" = "Статистика клиента" "clientUsage" = "Статистика клиента"
"onlines" = "🟢 Онлайн" "onlines" = "🟢 Онлайн"
@ -731,7 +731,7 @@
"allClients" = "👥 Все клиенты" "allClients" = "👥 Все клиенты"
"addClient" = " Новый клиент" "addClient" = " Новый клиент"
"submitDisable" = "Добавить отключенным ☑️" "submitDisable" = "Добавить отключенным ☑️"
"submitEnable" = "Добавить включенныи ✅" "submitEnable" = "Добавить включенным ✅"
"use_default" = "🏷️ Использовать по умолчанию" "use_default" = "🏷️ Использовать по умолчанию"
"change_id" = "⚙️🔑 ID" "change_id" = "⚙️🔑 ID"
"change_password" = "⚙️🔑 Пароль" "change_password" = "⚙️🔑 Пароль"
@ -743,7 +743,7 @@
[tgbot.answers] [tgbot.answers]
"successfulOperation" = "✅ Успешно!" "successfulOperation" = "✅ Успешно!"
"errorOperation" = "❗ Ошибка в операции." "errorOperation" = "❗ Ошибка в операции."
"getInboundsFailed" = "❌ Не удалось получить инбаунды." "getInboundsFailed" = "❌ Не удалось получить входящие подключения."
"getClientsFailed" = "❌ Не удалось получить клиентов." "getClientsFailed" = "❌ Не удалось получить клиентов."
"canceled" = "❌ {{ .Email }}: Операция отменена." "canceled" = "❌ {{ .Email }}: Операция отменена."
"clientRefreshSuccess" = "✅ {{ .Email }}: Клиент успешно обновлен." "clientRefreshSuccess" = "✅ {{ .Email }}: Клиент успешно обновлен."
@ -760,5 +760,5 @@
"enableSuccess" = "✅ {{ .Email }}: Включено успешно." "enableSuccess" = "✅ {{ .Email }}: Включено успешно."
"disableSuccess" = "✅ {{ .Email }}: Отключено успешно." "disableSuccess" = "✅ {{ .Email }}: Отключено успешно."
"askToAddUserId" = "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>" "askToAddUserId" = "❌ Ваша конфигурация не найдена!\r\n💭 Пожалуйста, попросите администратора использовать ваш Telegram User ID в конфигурации.\r\n\r\n🆔 Ваш User ID: <code>{{ .TgUserID }}</code>"
"chooseClient" = "Выберите клиента для инбаунда {{ .Inbound }}" "chooseClient" = "Выберите клиента для входящего подключения {{ .Inbound }}"
"chooseInbound" = "Выберите инбаунд" "chooseInbound" = "Выберите входящее подключение"