mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2025-10-13 19:49:12 +00:00
Merge branch 'MHSanaei:main' into main
This commit is contained in:
commit
00bca506e8
17 changed files with 332 additions and 63 deletions
3
.github/workflows/docker.yml
vendored
3
.github/workflows/docker.yml
vendored
|
@ -1,4 +1,7 @@
|
|||
name: Release 3X-UI for Docker
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1 +1 @@
|
|||
2.8.2
|
||||
2.8.3
|
6
go.mod
6
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
|
||||
)
|
||||
|
|
10
go.sum
10
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=
|
||||
|
|
2
main.go
2
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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -12,13 +12,14 @@
|
|||
<a-layout-content>
|
||||
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
|
||||
<transition name="list" appear>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }" message='{{ i18n "secAlertTitle" }}'
|
||||
color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
|
||||
message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
|
||||
</a-alert>
|
||||
</transition>
|
||||
<transition name="list" appear>
|
||||
<a-row v-if="!loadingStates.fetched">
|
||||
<a-card :style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
||||
<a-card
|
||||
:style="{ textAlign: 'center', padding: '30px 0', marginTop: '10px', background: 'transparent', border: 'none' }">
|
||||
<a-spin tip='{{ i18n "loading" }}'></a-spin>
|
||||
</a-card>
|
||||
</a-row>
|
||||
|
@ -37,7 +38,8 @@
|
|||
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
|
||||
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
|
||||
<template slot="content">
|
||||
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line ]]</span>
|
||||
<span :style="{ maxWidth: '400px' }" v-for="line in restartResult.split('\n')">[[ line
|
||||
]]</span>
|
||||
</template>
|
||||
<a-icon type="question-circle"></a-icon>
|
||||
</a-popover>
|
||||
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -697,14 +698,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 {
|
||||
|
@ -971,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
|
||||
|
@ -984,6 +1039,25 @@ func (s *ServerService) UpdateGeofile(fileName string) error {
|
|||
{"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 {
|
||||
isAllowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isAllowed {
|
||||
return common.NewErrorf("Invalid geofile name: %s not in allowlist", fileName)
|
||||
}
|
||||
}
|
||||
downloadFile := func(url, destPath string) error {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
|
@ -1009,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 {
|
||||
|
@ -1028,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"`
|
||||
|
|
Loading…
Reference in a new issue