From eacfbc86b5f7ba08c4be1bdf14a3d552aeb1874b Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 17:39:30 +0200 Subject: [PATCH 01/12] security fix: Command built from user-controlled sources CWE-78 https://cwe.mitre.org/data/definitions/78.html https://owasp.org/www-community/attacks/Command_Injection --- config/config.go | 10 +++++----- main.go | 2 +- web/service/server.go | 35 ++++++++++++++++++++++++++++++----- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/config/config.go b/config/config.go index c9a3e83c..17c9a77f 100644 --- a/config/config.go +++ b/config/config.go @@ -23,11 +23,11 @@ type LogLevel string // Logging level constants const ( - Debug LogLevel = "debug" - Info LogLevel = "info" - Notice LogLevel = "notice" - Warn LogLevel = "warn" - Error LogLevel = "error" + Debug LogLevel = "debug" + Info LogLevel = "info" + Notice LogLevel = "notice" + Warning LogLevel = "warning" + Error LogLevel = "error" ) // GetVersion returns the version string of the 3x-ui application. diff --git a/main.go b/main.go index 119dc4d9..8ab8b13f 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,7 @@ func runWebServer() { logger.InitLogger(logging.INFO) case config.Notice: logger.InitLogger(logging.NOTICE) - case config.Warn: + case config.Warning: logger.InitLogger(logging.WARNING) case config.Error: logger.InitLogger(logging.ERROR) diff --git a/web/service/server.go b/web/service/server.go index 9fe42e2c..5fea423b 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -697,14 +697,39 @@ func (s *ServerService) GetLogs(count string, level string, syslog string) []str var lines []string if syslog == "true" { - cmdArgs := []string{"journalctl", "-u", "x-ui", "--no-pager", "-n", count, "-p", level} - // Run the command - cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...) + // Check if running on Windows - journalctl is not available + if runtime.GOOS == "windows" { + return []string{"Syslog is not supported on Windows. Please use application logs instead by unchecking the 'Syslog' option."} + } + + // Validate and sanitize count parameter + countInt, err := strconv.Atoi(count) + if err != nil || countInt < 1 || countInt > 10000 { + return []string{"Invalid count parameter - must be a number between 1 and 10000"} + } + + // Validate level parameter - only allow valid syslog levels + validLevels := map[string]bool{ + "0": true, "emerg": true, + "1": true, "alert": true, + "2": true, "crit": true, + "3": true, "err": true, + "4": true, "warning": true, + "5": true, "notice": true, + "6": true, "info": true, + "7": true, "debug": true, + } + if !validLevels[level] { + return []string{"Invalid level parameter - must be a valid syslog level"} + } + + // Use hardcoded command with validated parameters + cmd := exec.Command("journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level) var out bytes.Buffer cmd.Stdout = &out - err := cmd.Run() + err = cmd.Run() if err != nil { - return []string{"Failed to run journalctl command!"} + return []string{"Failed to run journalctl command! Make sure systemd is available and x-ui service is registered."} } lines = strings.Split(out.String(), "\n") } else { From 9f024b9e6a5c5a8d7adbac36fa2f8e38a29455f0 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 17:47:01 +0200 Subject: [PATCH 02/12] security fix: Workflow with permissions CWE-275 --- .github/workflows/docker.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 0e460d24..9ec4c870 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,4 +1,7 @@ name: Release 3X-UI for Docker +permissions: + contents: read + packages: write on: workflow_dispatch: push: From e64e6327ef4cfda8f612c98882fe649c02918ac7 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 17:52:18 +0200 Subject: [PATCH 03/12] security fix: Uncontrolled data used in path expression --- web/service/server.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/web/service/server.go b/web/service/server.go index 5fea423b..a268a13e 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -1008,7 +1008,19 @@ func (s *ServerService) UpdateGeofile(fileName string) error { {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, } - + // Strict allowlist check to avoid writing uncontrolled files + if fileName != "" { + isAllowed := false + for _, file := range files { + if fileName == file.FileName { + isAllowed = true + break + } + } + if !isAllowed { + return common.NewErrorf("Invalid geofile name: %s", fileName) + } + } downloadFile := func(url, destPath string) error { resp, err := http.Get(url) if err != nil { From ae79b43cdb1fdcec772e9c411bb81243cae1de0a Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 17:59:17 +0200 Subject: [PATCH 04/12] security fix: Use of insufficient randomness as the key of a cryptographic algorithm --- util/random/random.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/util/random/random.go b/util/random/random.go index 9610e26c..c746df63 100644 --- a/util/random/random.go +++ b/util/random/random.go @@ -2,7 +2,8 @@ package random import ( - "math/rand" + "crypto/rand" + "math/big" ) var ( @@ -40,12 +41,21 @@ func init() { func Seq(n int) string { runes := make([]rune, n) for i := 0; i < n; i++ { - runes[i] = allSeq[rand.Intn(len(allSeq))] + idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(allSeq)))) + if err != nil { + panic("crypto/rand failed: " + err.Error()) + } + runes[i] = allSeq[idx.Int64()] } return string(runes) } // Num generates a random integer between 0 and n-1. func Num(n int) int { - return rand.Intn(n) + bn := big.NewInt(int64(n)) + r, err := rand.Int(rand.Reader, bn) + if err != nil { + panic("crypto/rand failed: " + err.Error()) + } + return int(r.Int64()) } From 55f1d72af51b3b282c9cb83db12dd58e7688ff22 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 18:13:28 +0200 Subject: [PATCH 05/12] security fix: Uncontrolled data used in path expression --- web/controller/server.go | 8 ++++++ web/service/server.go | 54 ++++++++++++++++++++++++++++++++++------ 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/web/controller/server.go b/web/controller/server.go index 60d165c5..292ef338 100644 --- a/web/controller/server.go +++ b/web/controller/server.go @@ -138,6 +138,14 @@ func (a *ServerController) installXray(c *gin.Context) { // updateGeofile updates the specified geo file for Xray. func (a *ServerController) updateGeofile(c *gin.Context) { fileName := c.Param("fileName") + + // Validate the filename for security (prevent path traversal attacks) + if fileName != "" && !a.serverService.IsValidGeofileName(fileName) { + jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), + fmt.Errorf("invalid filename: contains unsafe characters or path traversal patterns")) + return + } + err := a.serverService.UpdateGeofile(fileName) jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err) } diff --git a/web/service/server.go b/web/service/server.go index a268a13e..45a76f86 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -13,6 +13,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strconv" "strings" @@ -996,6 +997,35 @@ func (s *ServerService) ImportDB(file multipart.File) error { return nil } +// IsValidGeofileName validates that the filename is safe for geofile operations. +// It checks for path traversal attempts and ensures the filename contains only safe characters. +func (s *ServerService) IsValidGeofileName(filename string) bool { + if filename == "" { + return false + } + + // Check for path traversal attempts + if strings.Contains(filename, "..") { + return false + } + + // Check for path separators (both forward and backward slash) + if strings.ContainsAny(filename, `/\`) { + return false + } + + // Check for absolute path indicators + if filepath.IsAbs(filename) { + return false + } + + // Additional security: only allow alphanumeric, dots, underscores, and hyphens + // This is stricter than the general filename regex + validGeofilePattern := `^[a-zA-Z0-9._-]+\.dat$` + matched, _ := regexp.MatchString(validGeofilePattern, filename) + return matched +} + func (s *ServerService) UpdateGeofile(fileName string) error { files := []struct { URL string @@ -1008,8 +1038,15 @@ func (s *ServerService) UpdateGeofile(fileName string) error { {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", "geoip_RU.dat"}, {"https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", "geosite_RU.dat"}, } + // Strict allowlist check to avoid writing uncontrolled files if fileName != "" { + // Use the centralized validation function + if !s.IsValidGeofileName(fileName) { + return common.NewErrorf("Invalid geofile name: contains unsafe path characters: %s", fileName) + } + + // Ensure the filename matches exactly one from our allowlist isAllowed := false for _, file := range files { if fileName == file.FileName { @@ -1018,7 +1055,7 @@ func (s *ServerService) UpdateGeofile(fileName string) error { } } if !isAllowed { - return common.NewErrorf("Invalid geofile name: %s", fileName) + return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName) } } downloadFile := func(url, destPath string) error { @@ -1046,14 +1083,17 @@ func (s *ServerService) UpdateGeofile(fileName string) error { if fileName == "" { for _, file := range files { - destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), file.FileName) + // Sanitize the filename from our allowlist as an extra precaution + destPath := filepath.Join(config.GetBinFolderPath(), filepath.Base(file.FileName)) if err := downloadFile(file.URL, destPath); err != nil { errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", file.FileName, err)) } } } else { - destPath := fmt.Sprintf("%s/%s", config.GetBinFolderPath(), fileName) + // Use filepath.Base to ensure we only get the filename component, no path traversal + safeName := filepath.Base(fileName) + destPath := filepath.Join(config.GetBinFolderPath(), safeName) var fileURL string for _, file := range files { @@ -1065,10 +1105,10 @@ func (s *ServerService) UpdateGeofile(fileName string) error { if fileURL == "" { errorMessages = append(errorMessages, fmt.Sprintf("File '%s' not found in the list of Geofiles", fileName)) - } - - if err := downloadFile(fileURL, destPath); err != nil { - errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err)) + } else { + if err := downloadFile(fileURL, destPath); err != nil { + errorMessages = append(errorMessages, fmt.Sprintf("Error downloading Geofile '%s': %v", fileName, err)) + } } } From 3007bcff97bee811beabcb43279fb957ba1fe4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=9E=D0=BB?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D0=B2=D0=B8=D1=87=20=D0=A1=D0=B0=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=BE?= Date: Sun, 21 Sep 2025 20:03:36 +0300 Subject: [PATCH 06/12] add EXPOSE port in Dockerfile (#3523) --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index b818a7cd..cddc945c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ RUN chmod +x \ /usr/bin/x-ui ENV XUI_ENABLE_FAIL2BAN="true" +EXPOSE 2053 VOLUME [ "/etc/x-ui" ] CMD [ "./x-ui" ] ENTRYPOINT [ "/app/DockerEntrypoint.sh" ] From b45e63a14a70663261c046fe0e080f1562c50947 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 19:16:54 +0200 Subject: [PATCH 07/12] API: UUID for getClientTraffics --- web/service/inbound.go | 11 +++++++++++ xray/client_traffic.go | 1 + 2 files changed, 12 insertions(+) diff --git a/web/service/inbound.go b/web/service/inbound.go index 5c6083ee..49916b52 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -1959,6 +1959,15 @@ func (s *InboundService) GetClientTrafficTgBot(tgId int64) ([]*xray.ClientTraffi return nil, err } + // Populate UUID and other client data for each traffic record + for i := range traffics { + if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { + traffics[i].Enable = client.Enable + traffics[i].UUID = client.ID + traffics[i].SubId = client.SubID + } + } + return traffics, nil } @@ -1971,6 +1980,7 @@ func (s *InboundService) GetClientTrafficByEmail(email string) (traffic *xray.Cl } if t != nil && client != nil { t.Enable = client.Enable + t.UUID = client.ID t.SubId = client.SubID return t, nil } @@ -2012,6 +2022,7 @@ func (s *InboundService) GetClientTrafficByID(id string) ([]xray.ClientTraffic, for i := range traffics { if ct, client, e := s.GetClientByEmail(traffics[i].Email); e == nil && ct != nil && client != nil { traffics[i].Enable = client.Enable + traffics[i].UUID = client.ID traffics[i].SubId = client.SubID } } diff --git a/xray/client_traffic.go b/xray/client_traffic.go index 4bb164d2..fcb2585e 100644 --- a/xray/client_traffic.go +++ b/xray/client_traffic.go @@ -7,6 +7,7 @@ type ClientTraffic struct { InboundId int `json:"inboundId" form:"inboundId"` Enable bool `json:"enable" form:"enable"` Email string `json:"email" form:"email" gorm:"unique"` + UUID string `json:"uuid" form:"uuid" gorm:"-"` SubId string `json:"subId" form:"subId" gorm:"-"` Up int64 `json:"up" form:"up"` Down int64 `json:"down" form:"down"` From 83f8a03b504a1e6b04a605decdd8300ea2b4b0f5 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 19:27:05 +0200 Subject: [PATCH 08/12] TGbot: improved (5x faster) --- web/service/tgbot.go | 128 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 116 insertions(+), 12 deletions(-) diff --git a/web/service/tgbot.go b/web/service/tgbot.go index e575bb28..762ffd25 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -16,6 +16,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/mhsanaei/3x-ui/v2/config" @@ -44,6 +45,23 @@ var ( hostname string hashStorage *global.HashStorage + // Performance improvements + messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing + optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts + + // Simple cache for frequently accessed data + statusCache struct { + data *Status + timestamp time.Time + mutex sync.RWMutex + } + + serverStatsCache struct { + data string + timestamp time.Time + mutex sync.RWMutex + } + // clients data to adding new client receiver_inbound_ID int client_Id string @@ -100,6 +118,46 @@ func (t *Tgbot) GetHashStorage() *global.HashStorage { return hashStorage } +// getCachedStatus returns cached server status if it's fresh enough (less than 5 seconds old) +func (t *Tgbot) getCachedStatus() (*Status, bool) { + statusCache.mutex.RLock() + defer statusCache.mutex.RUnlock() + + if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second { + return statusCache.data, true + } + return nil, false +} + +// setCachedStatus updates the status cache +func (t *Tgbot) setCachedStatus(status *Status) { + statusCache.mutex.Lock() + defer statusCache.mutex.Unlock() + + statusCache.data = status + statusCache.timestamp = time.Now() +} + +// getCachedServerStats returns cached server stats if it's fresh enough (less than 10 seconds old) +func (t *Tgbot) getCachedServerStats() (string, bool) { + serverStatsCache.mutex.RLock() + defer serverStatsCache.mutex.RUnlock() + + if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second { + return serverStatsCache.data, true + } + return "", false +} + +// setCachedServerStats updates the server stats cache +func (t *Tgbot) setCachedServerStats(stats string) { + serverStatsCache.mutex.Lock() + defer serverStatsCache.mutex.Unlock() + + serverStatsCache.data = stats + serverStatsCache.timestamp = time.Now() +} + // Start initializes and starts the Telegram bot with the provided translation files. func (t *Tgbot) Start(i18nFS embed.FS) error { // Initialize localizer @@ -111,6 +169,20 @@ func (t *Tgbot) Start(i18nFS embed.FS) error { // Initialize hash storage to store callback queries hashStorage = global.NewHashStorage(20 * time.Minute) + // Initialize worker pool for concurrent message processing (max 10 concurrent handlers) + messageWorkerPool = make(chan struct{}, 10) + + // Initialize optimized HTTP client with connection pooling + optimizedHTTPClient = &http.Client{ + Timeout: 15 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 30 * time.Second, + DisableKeepAlives: false, + }, + } + t.SetHostname() // Get Telegram bot token @@ -271,7 +343,7 @@ func (t *Tgbot) decodeQuery(query string) (string, error) { // OnReceive starts the message receiving loop for the Telegram bot. func (t *Tgbot) OnReceive() { params := telego.GetUpdatesParams{ - Timeout: 10, + Timeout: 30, // Increased timeout to reduce API calls } updates, _ := bot.UpdatesViaLongPolling(context.Background(), ¶ms) @@ -285,14 +357,26 @@ func (t *Tgbot) OnReceive() { }, th.TextEqual(t.I18nBot("tgbot.buttons.closeKeyboard"))) botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error { - delete(userStates, message.Chat.ID) - t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) + // Use goroutine with worker pool for concurrent command processing + go func() { + messageWorkerPool <- struct{}{} // Acquire worker + defer func() { <-messageWorkerPool }() // Release worker + + delete(userStates, message.Chat.ID) + t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) + }() return nil }, th.AnyCommand()) botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { - delete(userStates, query.Message.GetChat().ID) - t.answerCallback(&query, checkAdmin(query.From.ID)) + // Use goroutine with worker pool for concurrent callback processing + go func() { + messageWorkerPool <- struct{}{} // Acquire worker + defer func() { <-messageWorkerPool }() // Release worker + + delete(userStates, query.Message.GetChat().ID) + t.answerCallback(&query, checkAdmin(query.From.ID)) + }() return nil }, th.AnyCallbackQueryWithMessage()) @@ -2099,7 +2183,10 @@ func (t *Tgbot) SendMsgToTgbot(chatId int64, msg string, replyMarkup ...telego.R if err != nil { logger.Warning("Error sending telegram message :", err) } - time.Sleep(500 * time.Millisecond) + // Reduced delay to improve performance (only needed for rate limiting) + if n < len(allMessages)-1 { // Only delay between messages, not after the last one + time.Sleep(100 * time.Millisecond) + } } } @@ -2208,12 +2295,12 @@ func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) { // Force plain text to avoid HTML page; controller respects Accept header req.Header.Set("Accept", "text/plain, */*;q=0.1") - // Use default client with reasonable timeout via context + // Use optimized client with connection pooling ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req = req.WithContext(ctx) - resp, err := http.DefaultClient.Do(req) + resp, err := optimizedHTTPClient.Do(req) if err != nil { t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error()) return @@ -2323,7 +2410,7 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() req = req.WithContext(ctx) - if resp, err := http.DefaultClient.Do(req); err == nil { + if resp, err := optimizedHTTPClient.Do(req); err == nil { body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() encoded, _ := t.settingService.GetSubEncrypt() @@ -2356,7 +2443,10 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) { tu.FileFromBytes(png, filename), ) _, _ = bot.SendDocument(context.Background(), document) - time.Sleep(200 * time.Millisecond) + // Reduced delay for better performance + if i < max-1 { // Only delay between documents, not after the last one + time.Sleep(50 * time.Millisecond) + } } } } @@ -2443,10 +2533,20 @@ func (t *Tgbot) sendServerUsage() string { // prepareServerUsageInfo prepares the server usage information string. func (t *Tgbot) prepareServerUsageInfo() string { + // Check if we have cached data first + if cachedStats, found := t.getCachedServerStats(); found { + return cachedStats + } + info, ipv4, ipv6 := "", "", "" - // get latest status of server - t.lastStatus = t.serverService.GetStatus(t.lastStatus) + // get latest status of server with caching + if cachedStatus, found := t.getCachedStatus(); found { + t.lastStatus = cachedStatus + } else { + t.lastStatus = t.serverService.GetStatus(t.lastStatus) + t.setCachedStatus(t.lastStatus) + } onlines := p.GetOnlineClients() info += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname) @@ -2488,6 +2588,10 @@ func (t *Tgbot) prepareServerUsageInfo() string { info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount)) info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv))) info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State)) + + // Cache the complete server stats + t.setCachedServerStats(info) + return info } From d518979e4ff8ed21d209d8ef33eb993ca29b020d Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 20:47:34 +0200 Subject: [PATCH 09/12] pageSize to 25 --- web/assets/js/model/setting.js | 2 +- web/html/inbounds.html | 4 ++-- web/service/setting.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/web/assets/js/model/setting.js b/web/assets/js/model/setting.js index d3f7f3e2..daf03799 100644 --- a/web/assets/js/model/setting.js +++ b/web/assets/js/model/setting.js @@ -8,7 +8,7 @@ class AllSetting { this.webKeyFile = ""; this.webBasePath = "/"; this.sessionMaxAge = 360; - this.pageSize = 50; + this.pageSize = 25; this.expireDiff = 0; this.trafficDiff = 0; this.remarkModel = "-ieo"; diff --git a/web/html/inbounds.html b/web/html/inbounds.html index 0e285f95..2ab00f09 100644 --- a/web/html/inbounds.html +++ b/web/html/inbounds.html @@ -736,7 +736,7 @@ refreshing: false, refreshInterval: Number(localStorage.getItem("refreshInterval")) || 5000, subSettings: { - enable: true, + enable: false, subTitle: '', subURI: '', subJsonURI: '', @@ -747,7 +747,7 @@ tgBotEnable: false, showAlert: false, ipLimitEnable: false, - pageSize: 50, + pageSize: 0, }, methods: { loading(spinning = true) { diff --git a/web/service/setting.go b/web/service/setting.go index 530a6344..fc6513c5 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -33,7 +33,7 @@ var defaultValueMap = map[string]string{ "secret": random.Seq(32), "webBasePath": "/", "sessionMaxAge": "360", - "pageSize": "50", + "pageSize": "25", "expireDiff": "0", "trafficDiff": "0", "remarkModel": "-ieo", From 5620d739c6c9e00d2b732c0a89174f682e76e806 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 21:20:37 +0200 Subject: [PATCH 10/12] improved sub: BuildURLs --- sub/subService.go | 80 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index 6204fdee..77a60356 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1077,20 +1077,73 @@ func (s *SubService) ResolveRequest(c *gin.Context) (scheme string, host string, return } -// BuildURLs constructs absolute subscription and json URLs. -// BuildURLs constructs subscription and JSON subscription URLs for a given subscription ID. +// BuildURLs constructs absolute subscription and JSON subscription URLs for a given subscription ID. +// It prioritizes configured URIs, then individual settings, and finally falls back to request-derived components. func (s *SubService) BuildURLs(scheme, hostWithPort, subPath, subJsonPath, subId string) (subURL, subJsonURL string) { - if strings.HasSuffix(subPath, "/") { - subURL = scheme + "://" + hostWithPort + subPath + subId - } else { - subURL = scheme + "://" + hostWithPort + strings.TrimRight(subPath, "/") + "/" + subId + // Input validation + if subId == "" { + return "", "" } - if strings.HasSuffix(subJsonPath, "/") { - subJsonURL = scheme + "://" + hostWithPort + subJsonPath + subId - } else { - subJsonURL = scheme + "://" + hostWithPort + strings.TrimRight(subJsonPath, "/") + "/" + subId + + // Get configured URIs first (highest priority) + configuredSubURI, _ := s.settingService.GetSubURI() + configuredSubJsonURI, _ := s.settingService.GetSubJsonURI() + + // Determine base scheme and host (cached to avoid duplicate calls) + var baseScheme, baseHostWithPort string + if configuredSubURI == "" || configuredSubJsonURI == "" { + baseScheme, baseHostWithPort = s.getBaseSchemeAndHost(scheme, hostWithPort) } - return + + // Build subscription URL + subURL = s.buildSingleURL(configuredSubURI, baseScheme, baseHostWithPort, subPath, subId) + + // Build JSON subscription URL + subJsonURL = s.buildSingleURL(configuredSubJsonURI, baseScheme, baseHostWithPort, subJsonPath, subId) + + return subURL, subJsonURL +} + +// getBaseSchemeAndHost determines the base scheme and host from settings or falls back to request values +func (s *SubService) getBaseSchemeAndHost(requestScheme, requestHostWithPort string) (string, string) { + subDomain, err := s.settingService.GetSubDomain() + if err != nil || subDomain == "" { + return requestScheme, requestHostWithPort + } + + // Get port and TLS settings + subPort, _ := s.settingService.GetSubPort() + subKeyFile, _ := s.settingService.GetSubKeyFile() + subCertFile, _ := s.settingService.GetSubCertFile() + + // Determine scheme from TLS configuration + scheme := "http" + if subKeyFile != "" && subCertFile != "" { + scheme = "https" + } + + // Build host:port, always include port for clarity + hostWithPort := fmt.Sprintf("%s:%d", subDomain, subPort) + + return scheme, hostWithPort +} + +// buildSingleURL constructs a single URL using configured URI or base components +func (s *SubService) buildSingleURL(configuredURI, baseScheme, baseHostWithPort, basePath, subId string) string { + if configuredURI != "" { + return s.joinPathWithID(configuredURI, subId) + } + + baseURL := fmt.Sprintf("%s://%s", baseScheme, baseHostWithPort) + return s.joinPathWithID(baseURL+basePath, subId) +} + +// joinPathWithID safely joins a base path with a subscription ID +func (s *SubService) joinPathWithID(basePath, subId string) string { + if strings.HasSuffix(basePath, "/") { + return basePath + subId + } + return basePath + "/" + subId } // BuildPageData parses header and prepares the template view model. @@ -1103,10 +1156,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray remained := "" if traffic.Total > 0 { total = common.FormatTraffic(traffic.Total) - left := traffic.Total - (traffic.Up + traffic.Down) - if left < 0 { - left = 0 - } + left := max(traffic.Total-(traffic.Up+traffic.Down), 0) remained = common.FormatTraffic(left) } From 020bc9d77cc3c8e5b88c43305366cc31bbf9bc62 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Sun, 21 Sep 2025 21:20:45 +0200 Subject: [PATCH 11/12] v2.8.3 --- config/version | 2 +- go.mod | 6 +++--- go.sum | 10 ++++++---- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/config/version b/config/version index cae9add9..642c63c4 100644 --- a/config/version +++ b/config/version @@ -1 +1 @@ -2.8.2 \ No newline at end of file +2.8.3 \ No newline at end of file diff --git a/go.mod b/go.mod index 94bf4e15..567a64b4 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.1 require ( github.com/gin-contrib/gzip v1.2.3 github.com/gin-contrib/sessions v1.0.4 - github.com/gin-gonic/gin v1.10.1 + github.com/gin-gonic/gin v1.11.0 github.com/goccy/go-json v0.10.5 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 @@ -36,13 +36,14 @@ require ( github.com/cloudflare/circl v1.6.1 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 // indirect - github.com/ebitengine/purego v0.8.4 // indirect + github.com/ebitengine/purego v0.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect github.com/google/btree v1.1.3 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect @@ -96,7 +97,6 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect google.golang.org/protobuf v1.36.9 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect lukechampine.com/blake3 v1.4.1 // indirect ) diff --git a/go.sum b/go.sum index b5239296..b15795b9 100644 --- a/go.sum +++ b/go.sum @@ -19,8 +19,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgryski/go-metro v0.0.0-20200812162917-85c65e2d0165/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33 h1:ucRHb6/lvW/+mTEIGbvhcYU3S8+uSNkuMjx/qZFfhtM= github.com/dgryski/go-metro v0.0.0-20250106013310-edb8663e5e33/go.mod h1:c9O8+fpSOX1DM8cPNSkX/qsBWdkD4yd2dpciOWQjpBw= -github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= -github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= @@ -31,8 +31,8 @@ github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kb github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= -github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= -github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -50,6 +50,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= From 1016f3b4f9ac6df05a2b3479c485cdba1b0a4705 Mon Sep 17 00:00:00 2001 From: mhsanaei Date: Mon, 22 Sep 2025 00:20:05 +0200 Subject: [PATCH 12/12] fix: outbound address for vless --- web/html/xray.html | 11 +++++++---- web/service/tgbot.go | 34 +++++++++++++++++----------------- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/web/html/xray.html b/web/html/xray.html index 266f9eef..4dacd021 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -12,13 +12,14 @@ - + - + @@ -37,7 +38,8 @@ {{ i18n "pages.index.xrayErrorPopoverTitle" }} @@ -537,6 +539,7 @@ serverObj = o.settings.vnext; break; case Protocols.VLESS: + return [o.settings?.address + ':' + o.settings?.port]; case Protocols.HTTP: case Protocols.Socks: case Protocols.Shadowsocks: diff --git a/web/service/tgbot.go b/web/service/tgbot.go index 762ffd25..0c9d820c 100644 --- a/web/service/tgbot.go +++ b/web/service/tgbot.go @@ -46,22 +46,22 @@ var ( hashStorage *global.HashStorage // Performance improvements - messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing - optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts - + messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing + optimizedHTTPClient *http.Client // HTTP client with connection pooling and timeouts + // Simple cache for frequently accessed data statusCache struct { data *Status timestamp time.Time mutex sync.RWMutex } - + serverStatsCache struct { data string timestamp time.Time mutex sync.RWMutex } - + // clients data to adding new client receiver_inbound_ID int client_Id string @@ -122,7 +122,7 @@ func (t *Tgbot) GetHashStorage() *global.HashStorage { func (t *Tgbot) getCachedStatus() (*Status, bool) { statusCache.mutex.RLock() defer statusCache.mutex.RUnlock() - + if statusCache.data != nil && time.Since(statusCache.timestamp) < 5*time.Second { return statusCache.data, true } @@ -133,7 +133,7 @@ func (t *Tgbot) getCachedStatus() (*Status, bool) { func (t *Tgbot) setCachedStatus(status *Status) { statusCache.mutex.Lock() defer statusCache.mutex.Unlock() - + statusCache.data = status statusCache.timestamp = time.Now() } @@ -142,7 +142,7 @@ func (t *Tgbot) setCachedStatus(status *Status) { func (t *Tgbot) getCachedServerStats() (string, bool) { serverStatsCache.mutex.RLock() defer serverStatsCache.mutex.RUnlock() - + if serverStatsCache.data != "" && time.Since(serverStatsCache.timestamp) < 10*time.Second { return serverStatsCache.data, true } @@ -153,7 +153,7 @@ func (t *Tgbot) getCachedServerStats() (string, bool) { func (t *Tgbot) setCachedServerStats(stats string) { serverStatsCache.mutex.Lock() defer serverStatsCache.mutex.Unlock() - + serverStatsCache.data = stats serverStatsCache.timestamp = time.Now() } @@ -171,7 +171,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error { // Initialize worker pool for concurrent message processing (max 10 concurrent handlers) messageWorkerPool = make(chan struct{}, 10) - + // Initialize optimized HTTP client with connection pooling optimizedHTTPClient = &http.Client{ Timeout: 15 * time.Second, @@ -359,9 +359,9 @@ func (t *Tgbot) OnReceive() { botHandler.HandleMessage(func(ctx *th.Context, message telego.Message) error { // Use goroutine with worker pool for concurrent command processing go func() { - messageWorkerPool <- struct{}{} // Acquire worker + messageWorkerPool <- struct{}{} // Acquire worker defer func() { <-messageWorkerPool }() // Release worker - + delete(userStates, message.Chat.ID) t.answerCommand(&message, message.Chat.ID, checkAdmin(message.From.ID)) }() @@ -371,9 +371,9 @@ func (t *Tgbot) OnReceive() { botHandler.HandleCallbackQuery(func(ctx *th.Context, query telego.CallbackQuery) error { // Use goroutine with worker pool for concurrent callback processing go func() { - messageWorkerPool <- struct{}{} // Acquire worker + messageWorkerPool <- struct{}{} // Acquire worker defer func() { <-messageWorkerPool }() // Release worker - + delete(userStates, query.Message.GetChat().ID) t.answerCallback(&query, checkAdmin(query.From.ID)) }() @@ -2537,7 +2537,7 @@ func (t *Tgbot) prepareServerUsageInfo() string { if cachedStats, found := t.getCachedServerStats(); found { return cachedStats } - + info, ipv4, ipv6 := "", "", "" // get latest status of server with caching @@ -2588,10 +2588,10 @@ func (t *Tgbot) prepareServerUsageInfo() string { info += t.I18nBot("tgbot.messages.udpCount", "Count=="+strconv.Itoa(t.lastStatus.UdpCount)) info += t.I18nBot("tgbot.messages.traffic", "Total=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent+t.lastStatus.NetTraffic.Recv)), "Upload=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Sent)), "Download=="+common.FormatTraffic(int64(t.lastStatus.NetTraffic.Recv))) info += t.I18nBot("tgbot.messages.xrayStatus", "State=="+fmt.Sprint(t.lastStatus.Xray.State)) - + // Cache the complete server stats t.setCachedServerStats(info) - + return info }