diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 9ec4c870..39ddf2e0 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -1,7 +1,9 @@
name: Release 3X-UI for Docker
+
permissions:
contents: read
packages: write
+
on:
workflow_dispatch:
push:
@@ -13,48 +15,48 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
- with:
- submodules: true
-
- - name: Docker meta
- id: meta
- uses: docker/metadata-action@v5
- with:
- images: |
- hsanaeii/3x-ui
- ghcr.io/mhsanaei/3x-ui
- tags: |
- type=ref,event=branch
- type=ref,event=tag
- type=pep440,pattern={{version}}
+ - uses: actions/checkout@v5
+ with:
+ submodules: true
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: |
+ hsanaeii/3x-ui
+ ghcr.io/mhsanaei/3x-ui
+ tags: |
+ type=ref,event=branch
+ type=ref,event=tag
+ type=semver,pattern={{version}}
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
- with:
- install: true
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKER_HUB_USERNAME }}
- password: ${{ secrets.DOCKER_HUB_TOKEN }}
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ install: true
- - name: Login to GHCR
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.repository_owner }}
- password: ${{ secrets.GITHUB_TOKEN }}
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ password: ${{ secrets.DOCKER_HUB_TOKEN }}
- - name: Build and push Docker image
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- platforms: linux/amd64, linux/arm64/v8, linux/arm/v7, linux/arm/v6, linux/386
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
\ No newline at end of file
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64/v8,linux/arm/v7,linux/arm/v6,linux/386
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
diff --git a/config/version b/config/version
index 642c63c4..0409c163 100644
--- a/config/version
+++ b/config/version
@@ -1 +1 @@
-2.8.3
\ No newline at end of file
+2.8.4
\ No newline at end of file
diff --git a/go.mod b/go.mod
index 434829d8..55b4874d 100644
--- a/go.mod
+++ b/go.mod
@@ -96,7 +96,7 @@ require (
golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
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/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 // indirect
google.golang.org/protobuf v1.36.9 // 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 b15795b9..a4610d2b 100644
--- a/go.sum
+++ b/go.sum
@@ -236,6 +236,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9 h1:V1jCN2HBa8sySkR5vLcCSqJSTMv093Rw9EJefhQGP7M=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250922171735-9219d122eba9/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
diff --git a/install.sh b/install.sh
index 32657189..f55e61a0 100644
--- a/install.sh
+++ b/install.sh
@@ -56,6 +56,9 @@ install_base() {
opensuse-tumbleweed)
zypper refresh && zypper -q install -y wget curl tar timezone
;;
+ alpine)
+ apk update && apk add wget curl tar tzdata
+ ;;
*)
apt-get update && apt-get install -y -q wget curl tar tzdata
;;
@@ -184,7 +187,11 @@ install_x-ui() {
# Stop x-ui service and remove old resources
if [[ -e /usr/local/x-ui/ ]]; then
- systemctl stop x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ else
+ systemctl stop x-ui
+ fi
rm /usr/local/x-ui/ -rf
fi
@@ -208,10 +215,18 @@ install_x-ui() {
chmod +x /usr/bin/x-ui
config_after_install
- cp -f x-ui.service /etc/systemd/system/
- systemctl daemon-reload
- systemctl enable x-ui
- systemctl start x-ui
+ if [[ $release == "alpine" ]]; then
+ wget -O /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
+ chmod +x /etc/init.d/x-ui
+ rc-update add x-ui
+ rc-service x-ui start
+ else
+ cp -f x-ui.service /etc/systemd/system/
+ systemctl daemon-reload
+ systemctl enable x-ui
+ systemctl start x-ui
+ fi
+
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐
diff --git a/sub/sub.go b/sub/sub.go
index c5445339..1a1a7d9e 100644
--- a/sub/sub.go
+++ b/sub/sub.go
@@ -98,8 +98,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
}
// Set base_path based on LinksPath for template rendering
+ // Ensure LinksPath ends with "/" for proper asset URL generation
+ basePath := LinksPath
+ if basePath != "/" && !strings.HasSuffix(basePath, "/") {
+ basePath += "/"
+ }
+ logger.Debug("sub: Setting base_path to:", basePath)
engine.Use(func(c *gin.Context) {
- c.Set("base_path", LinksPath)
+ c.Set("base_path", basePath)
})
Encrypt, err := s.settingService.GetSubEncrypt()
@@ -179,22 +185,48 @@ func (s *Server) initRouter() (*gin.Engine, error) {
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets"
}
+ // Mount assets in multiple paths to handle different URL patterns
+ var assetsFS http.FileSystem
if _, err := os.Stat("web/assets"); err == nil {
- engine.StaticFS("/assets", http.FS(os.DirFS("web/assets")))
- if linksPathForAssets != "/assets" {
- engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
- }
+ assetsFS = http.FS(os.DirFS("web/assets"))
} else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
- engine.StaticFS("/assets", http.FS(subFS))
- if linksPathForAssets != "/assets" {
- engine.StaticFS(linksPathForAssets, http.FS(subFS))
- }
+ assetsFS = http.FS(subFS)
} else {
logger.Error("sub: failed to mount embedded assets:", err)
}
}
+ if assetsFS != nil {
+ engine.StaticFS("/assets", assetsFS)
+ if linksPathForAssets != "/assets" {
+ engine.StaticFS(linksPathForAssets, assetsFS)
+ }
+
+ // Add middleware to handle dynamic asset paths with subid
+ if LinksPath != "/" {
+ engine.Use(func(c *gin.Context) {
+ path := c.Request.URL.Path
+ // Check if this is an asset request with subid pattern: /sub/path/{subid}/assets/...
+ pathPrefix := strings.TrimRight(LinksPath, "/") + "/"
+ if strings.HasPrefix(path, pathPrefix) && strings.Contains(path, "/assets/") {
+ // Extract the asset path after /assets/
+ assetsIndex := strings.Index(path, "/assets/")
+ if assetsIndex != -1 {
+ assetPath := path[assetsIndex+8:] // +8 to skip "/assets/"
+ if assetPath != "" {
+ // Serve the asset file
+ c.FileFromFS(assetPath, assetsFS)
+ c.Abort()
+ return
+ }
+ }
+ }
+ c.Next()
+ })
+ }
+ }
+
g := engine.Group("/")
s.sub = NewSUBController(
diff --git a/sub/subController.go b/sub/subController.go
index 42a33ee6..ec574d6e 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -87,7 +87,20 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled {
subJsonURL = ""
}
- page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL)
+ // Get base_path from context (set by middleware)
+ basePath, exists := c.Get("base_path")
+ if !exists {
+ basePath = "/"
+ }
+ // Add subId to base_path for asset URLs
+ basePathStr := basePath.(string)
+ if basePathStr == "/" {
+ basePathStr = "/" + subId + "/"
+ } else {
+ // Remove trailing slash if exists, add subId, then add trailing slash
+ basePathStr = strings.TrimRight(basePathStr, "/") + "/" + subId + "/"
+ }
+ page := a.subService.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, subURL, subJsonURL, basePathStr)
c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title",
"cur_ver": config.GetVersion(),
diff --git a/sub/subService.go b/sub/subService.go
index 0bd07da9..24bdbc57 100644
--- a/sub/subService.go
+++ b/sub/subService.go
@@ -1169,7 +1169,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page.
-func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string) PageData {
+func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray.ClientTraffic, lastOnline int64, subs []string, subURL, subJsonURL string, basePath string) PageData {
download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up)
total := "∞"
@@ -1188,7 +1188,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{
Host: hostHeader,
- BasePath: "/", // kept as "/"; templates now use context base_path injected from router
+ BasePath: basePath,
SId: subId,
Download: download,
Upload: upload,
diff --git a/web/assets/js/subscription.js b/web/assets/js/subscription.js
index 0af95890..c7627837 100644
--- a/web/assets/js/subscription.js
+++ b/web/assets/js/subscription.js
@@ -142,7 +142,10 @@
},
npvtunUrl() {
return this.app.subUrl;
- }
+ },
+ happUrl() {
+ return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
+ }
},
methods: {
renderLink,
diff --git a/web/controller/api.go b/web/controller/api.go
index dbd3f28d..1a39f8ed 100644
--- a/web/controller/api.go
+++ b/web/controller/api.go
@@ -1,7 +1,10 @@
package controller
import (
+ "net/http"
+
"github.com/mhsanaei/3x-ui/v2/web/service"
+ "github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin"
)
@@ -21,11 +24,21 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
return a
}
+// checkAPIAuth is a middleware that returns 404 for unauthenticated API requests
+// to hide the existence of API endpoints from unauthorized users
+func (a *APIController) checkAPIAuth(c *gin.Context) {
+ if !session.IsLogin(c) {
+ c.AbortWithStatus(http.StatusNotFound)
+ return
+ }
+ c.Next()
+}
+
// initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group
api := g.Group("/panel/api")
- api.Use(a.checkLogin)
+ api.Use(a.checkAPIAuth)
// Inbounds API
inbounds := api.Group("/inbounds")
diff --git a/web/controller/index.go b/web/controller/index.go
index 89de710b..5f9e1c2c 100644
--- a/web/controller/index.go
+++ b/web/controller/index.go
@@ -39,8 +39,9 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index)
- g.POST("/login", a.login)
g.GET("/logout", a.logout)
+
+ g.POST("/login", a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
}
diff --git a/web/controller/xui.go b/web/controller/xui.go
index a6a25708..c3623368 100644
--- a/web/controller/xui.go
+++ b/web/controller/xui.go
@@ -8,8 +8,6 @@ import (
type XUIController struct {
BaseController
- inboundController *InboundController
- serverController *ServerController
settingController *SettingController
xraySettingController *XraySettingController
}
@@ -32,8 +30,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings)
- a.inboundController = NewInboundController(g)
- a.serverController = NewServerController(g)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}
diff --git a/web/html/form/inbound.html b/web/html/form/inbound.html
index ca4dc66a..00f97f6e 100644
--- a/web/html/form/inbound.html
+++ b/web/html/form/inbound.html
@@ -28,7 +28,7 @@
-
+
@@ -51,8 +51,9 @@
{{ i18n "pages.inbounds.periodicTrafficResetDesc" }}
- {{ i18n "pages.inbounds.lastReset" }}:
- [[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]
+ {{ i18n "pages.inbounds.lastReset" }}:
+ [[
+ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]
[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]
@@ -145,4 +146,4 @@
-{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/html/form/stream/external_proxy.html b/web/html/form/stream/external_proxy.html
index 187090d8..5c13df1b 100644
--- a/web/html/form/stream/external_proxy.html
+++ b/web/html/form/stream/external_proxy.html
@@ -3,12 +3,14 @@
-
+
-
+
{{ i18n "pages.inbounds.same" }}
{{ i18n "none" }}
TLS
@@ -17,7 +19,7 @@
-
+
@@ -26,4 +28,4 @@
-{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/html/login.html b/web/html/login.html
index 26a6ca86..a09ec915 100644
--- a/web/html/login.html
+++ b/web/html/login.html
@@ -4,7 +4,7 @@
{{ template "page/body_start" .}}
-
+
-
+
@@ -35,8 +35,8 @@
{{ i18n "pages.settings.language" }}
-
+
@@ -68,7 +68,7 @@
-
@@ -81,7 +81,8 @@
-
+
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]
diff --git a/web/html/modals/xray_dns_modal.html b/web/html/modals/xray_dns_modal.html
index 484bd2f8..97970555 100644
--- a/web/html/modals/xray_dns_modal.html
+++ b/web/html/modals/xray_dns_modal.html
@@ -7,12 +7,13 @@
-
+
- [[ l ]]
+ [[ l ]]
+
@@ -75,7 +76,7 @@
isEdit: false,
confirm: null,
dnsServer: { ...defaultDnsObject },
- ok() {
+ ok() {
ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer });
},
show({
@@ -106,7 +107,7 @@
}
} else {
this.dnsServer = { ...defaultDnsObject };
-
+
this.dnsServer.domains = [];
this.dnsServer.expectIPs = [];
this.dnsServer.unexpectedIPs = [];
diff --git a/web/html/settings/panel/general.html b/web/html/settings/panel/general.html
index df011521..64fd050c 100644
--- a/web/html/settings/panel/general.html
+++ b/web/html/settings/panel/general.html
@@ -39,7 +39,7 @@
{{ i18n "pages.settings.panelPort"}}
{{ i18n "pages.settings.panelPortDesc"}}
-
+
@@ -137,7 +137,8 @@
{{ i18n "pages.settings.datepicker"}}
{{ i18n "pages.settings.datepickerDescription"}}
-
+
diff --git a/web/html/settings/panel/subscription/general.html b/web/html/settings/panel/subscription/general.html
index afa956fb..e65b2738 100644
--- a/web/html/settings/panel/subscription/general.html
+++ b/web/html/settings/panel/subscription/general.html
@@ -40,7 +40,7 @@
{{ i18n "pages.settings.subPort"}}
{{ i18n "pages.settings.subPortDesc"}}
-
@@ -48,13 +48,10 @@
{{ i18n "pages.settings.subPath"}}
{{ i18n "pages.settings.subPathDesc"}}
- { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)"
- placeholder="/sub/"
- >
+ placeholder="/sub/">
@@ -108,4 +105,4 @@
-{{end}}
+{{end}}
\ No newline at end of file
diff --git a/web/html/settings/panel/subscription/subpage.html b/web/html/settings/panel/subscription/subpage.html
index 6d56496b..670cc37b 100644
--- a/web/html/settings/panel/subscription/subpage.html
+++ b/web/html/settings/panel/subscription/subpage.html
@@ -218,6 +218,8 @@
NPV
Tunnel
+ Happ
@@ -244,6 +246,8 @@
@click="copy(npvtunUrl)">NPV
Tunnel
+ Happ
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" }}
- [[ line ]]
+ [[ line
+ ]]
@@ -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/inbound.go b/web/service/inbound.go
index cc8205bf..bc99cecf 100644
--- a/web/service/inbound.go
+++ b/web/service/inbound.go
@@ -38,6 +38,25 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
+ // Enrich client stats with UUID/SubId from inbound settings
+ for _, inbound := range inbounds {
+ clients, _ := s.GetClients(inbound)
+ if len(clients) == 0 || len(inbound.ClientStats) == 0 {
+ continue
+ }
+ // Build a map email -> client
+ cMap := make(map[string]model.Client, len(clients))
+ for _, c := range clients {
+ cMap[strings.ToLower(c.Email)] = c
+ }
+ for i := range inbound.ClientStats {
+ email := strings.ToLower(inbound.ClientStats[i].Email)
+ if c, ok := cMap[email]; ok {
+ inbound.ClientStats[i].UUID = c.ID
+ inbound.ClientStats[i].SubId = c.SubID
+ }
+ }
+ }
return inbounds, nil
}
@@ -50,6 +69,24 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
+ // Enrich client stats with UUID/SubId from inbound settings
+ for _, inbound := range inbounds {
+ clients, _ := s.GetClients(inbound)
+ if len(clients) == 0 || len(inbound.ClientStats) == 0 {
+ continue
+ }
+ cMap := make(map[string]model.Client, len(clients))
+ for _, c := range clients {
+ cMap[strings.ToLower(c.Email)] = c
+ }
+ for i := range inbound.ClientStats {
+ email := strings.ToLower(inbound.ClientStats[i].Email)
+ if c, ok := cMap[email]; ok {
+ inbound.ClientStats[i].UUID = c.ID
+ inbound.ClientStats[i].SubId = c.SubID
+ }
+ }
+ }
return inbounds, nil
}
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
}
diff --git a/web/web.go b/web/web.go
index 3c869f17..f2bc59fd 100644
--- a/web/web.go
+++ b/web/web.go
@@ -95,10 +95,9 @@ type Server struct {
httpServer *http.Server
listener net.Listener
- index *controller.IndexController
- server *controller.ServerController
- panel *controller.XUIController
- api *controller.APIController
+ index *controller.IndexController
+ panel *controller.XUIController
+ api *controller.APIController
xrayService service.XrayService
settingService service.SettingService
@@ -264,7 +263,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath)
s.index = controller.NewIndexController(g)
- s.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g)
diff --git a/x-ui.rc b/x-ui.rc
new file mode 100644
index 00000000..1323d76a
--- /dev/null
+++ b/x-ui.rc
@@ -0,0 +1,13 @@
+#!/sbin/openrc-run
+
+command="/usr/local/x-ui/x-ui"
+command_background=true
+pidfile="/run/x-ui.pid"
+description="x-ui Service"
+procname="x-ui"
+depend() {
+ need net
+}
+start_pre(){
+ cd /usr/local/x-ui
+}
\ No newline at end of file
diff --git a/x-ui.sh b/x-ui.sh
index cec86ba0..6038db7d 100644
--- a/x-ui.sh
+++ b/x-ui.sh
@@ -153,11 +153,19 @@ uninstall() {
fi
return 0
fi
- systemctl stop x-ui
- systemctl disable x-ui
- rm /etc/systemd/system/x-ui.service -f
- systemctl daemon-reload
- systemctl reset-failed
+
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ rc-update del x-ui
+ rm /etc/init.d/x-ui -f
+ else
+ systemctl stop x-ui
+ systemctl disable x-ui
+ rm /etc/systemd/system/x-ui.service -f
+ systemctl daemon-reload
+ systemctl reset-failed
+ fi
+
rm /etc/x-ui/ -rf
rm /usr/local/x-ui/ -rf
@@ -286,7 +294,11 @@ start() {
echo ""
LOGI "Panel is running, No need to start again, If you need to restart, please select restart"
else
- systemctl start x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui start
+ else
+ systemctl start x-ui
+ fi
sleep 2
check_status
if [[ $? == 0 ]]; then
@@ -307,7 +319,11 @@ stop() {
echo ""
LOGI "Panel stopped, No need to stop again!"
else
- systemctl stop x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ else
+ systemctl stop x-ui
+ fi
sleep 2
check_status
if [[ $? == 1 ]]; then
@@ -323,7 +339,11 @@ stop() {
}
restart() {
- systemctl restart x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui restart
+ else
+ systemctl restart x-ui
+ fi
sleep 2
check_status
if [[ $? == 0 ]]; then
@@ -337,14 +357,22 @@ restart() {
}
status() {
- systemctl status x-ui -l
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui status
+ else
+ systemctl status x-ui -l
+ fi
if [[ $# == 0 ]]; then
before_show_menu
fi
}
enable() {
- systemctl enable x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-update add x-ui
+ else
+ systemctl enable x-ui
+ fi
if [[ $? == 0 ]]; then
LOGI "x-ui Set to boot automatically on startup successfully"
else
@@ -357,7 +385,11 @@ enable() {
}
disable() {
- systemctl disable x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-update del x-ui
+ else
+ systemctl disable x-ui
+ fi
if [[ $? == 0 ]]; then
LOGI "x-ui Autostart Cancelled successfully"
else
@@ -370,32 +402,54 @@ disable() {
}
show_log() {
- echo -e "${green}\t1.${plain} Debug Log"
- echo -e "${green}\t2.${plain} Clear All logs"
- echo -e "${green}\t0.${plain} Back to Main Menu"
- read -rp "Choose an option: " choice
+ if [[ $release == "alpine" ]]; then
+ echo -e "${green}\t1.${plain} Debug Log"
+ echo -e "${green}\t0.${plain} Back to Main Menu"
+ read -rp "Choose an option: " choice
- case "$choice" in
- 0)
- show_menu
- ;;
- 1)
- journalctl -u x-ui -e --no-pager -f -p debug
- if [[ $# == 0 ]]; then
- before_show_menu
- fi
- ;;
- 2)
- sudo journalctl --rotate
- sudo journalctl --vacuum-time=1s
- echo "All Logs cleared."
- restart
- ;;
- *)
- echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
- show_log
- ;;
- esac
+ case "$choice" in
+ 0)
+ show_menu
+ ;;
+ 1)
+ grep -F 'x-ui[' /var/log/messages
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ ;;
+ *)
+ echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
+ show_log
+ ;;
+ esac
+ else
+ echo -e "${green}\t1.${plain} Debug Log"
+ echo -e "${green}\t2.${plain} Clear All logs"
+ echo -e "${green}\t0.${plain} Back to Main Menu"
+ read -rp "Choose an option: " choice
+
+ case "$choice" in
+ 0)
+ show_menu
+ ;;
+ 1)
+ journalctl -u x-ui -e --no-pager -f -p debug
+ if [[ $# == 0 ]]; then
+ before_show_menu
+ fi
+ ;;
+ 2)
+ sudo journalctl --rotate
+ sudo journalctl --vacuum-time=1s
+ echo "All Logs cleared."
+ restart
+ ;;
+ *)
+ echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
+ show_log
+ ;;
+ esac
+ fi
}
bbr_menu() {
@@ -464,6 +518,9 @@ enable_bbr() {
arch | manjaro | parch)
pacman -Sy --noconfirm ca-certificates
;;
+ alpine)
+ apk add ca-certificates
+ ;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@@ -500,23 +557,42 @@ update_shell() {
# 0: running, 1: not running, 2: not installed
check_status() {
- if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
- return 2
- fi
- temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
- if [[ "${temp}" == "running" ]]; then
- return 0
+ if [[ $release == "alpine" ]]; then
+ if [[ ! -f /etc/init.d/x-ui ]]; then
+ return 2
+ fi
+ if [[ $(rc-service x-ui status | grep -F 'status: started' -c) == 1 ]]; then
+ return 0
+ else
+ return 1
+ fi
else
- return 1
+ if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
+ return 2
+ fi
+ temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
+ if [[ "${temp}" == "running" ]]; then
+ return 0
+ else
+ return 1
+ fi
fi
}
check_enabled() {
- temp=$(systemctl is-enabled x-ui)
- if [[ "${temp}" == "enabled" ]]; then
- return 0
+ if [[ $release == "alpine" ]]; then
+ if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
+ return 0
+ else
+ return 1
+ fi
else
- return 1
+ temp=$(systemctl is-enabled x-ui)
+ if [[ "${temp}" == "enabled" ]]; then
+ return 0
+ else
+ return 1
+ fi
fi
}
@@ -798,7 +874,11 @@ update_geo() {
show_menu
;;
1)
- systemctl stop x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ else
+ systemctl stop x-ui
+ fi
rm -f geoip.dat geosite.dat
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
@@ -806,7 +886,11 @@ update_geo() {
restart
;;
2)
- systemctl stop x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ else
+ systemctl stop x-ui
+ fi
rm -f geoip_IR.dat geosite_IR.dat
wget -O geoip_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
wget -O geosite_IR.dat -N https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
@@ -814,7 +898,11 @@ update_geo() {
restart
;;
3)
- systemctl stop x-ui
+ if [[ $release == "alpine" ]]; then
+ rc-service x-ui stop
+ else
+ systemctl stop x-ui
+ fi
rm -f geoip_RU.dat geosite_RU.dat
wget -O geoip_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
wget -O geosite_RU.dat -N https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
@@ -985,6 +1073,9 @@ ssl_cert_issue() {
arch | manjaro | parch)
pacman -Sy --noconfirm socat
;;
+ alpine)
+ apk add socat
+ ;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@@ -1335,7 +1426,11 @@ iplimit_main() {
read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM
if [[ $NUM =~ ^[0-9]+$ ]]; then
create_iplimit_jails ${NUM}
- systemctl restart fail2ban
+ if [[ $release == "alpine" ]]; then
+ rc-service fail2ban restart
+ else
+ systemctl restart fail2ban
+ fi
else
echo -e "${red}${NUM} is not a number! Please, try again.${plain}"
fi
@@ -1388,7 +1483,11 @@ iplimit_main() {
iplimit_main
;;
9)
- systemctl restart fail2ban
+ if [[ $release == "alpine" ]]; then
+ rc-service fail2ban restart
+ else
+ systemctl restart fail2ban
+ fi
iplimit_main
;;
10)
@@ -1436,6 +1535,9 @@ install_iplimit() {
arch | manjaro | parch)
pacman -Syu --noconfirm fail2ban
;;
+ alpine)
+ apk add fail2ban
+ ;;
*)
echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1
@@ -1472,12 +1574,21 @@ install_iplimit() {
create_iplimit_jails
# Launching fail2ban
- if ! systemctl is-active --quiet fail2ban; then
- systemctl start fail2ban
+ if [[ $release == "alpine" ]]; then
+ if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
+ rc-service fail2ban start
+ else
+ rc-service fail2ban restart
+ fi
+ rc-update add fail2ban
else
- systemctl restart fail2ban
+ if ! systemctl is-active --quiet fail2ban; then
+ systemctl start fail2ban
+ else
+ systemctl restart fail2ban
+ fi
+ systemctl enable fail2ban
fi
- systemctl enable fail2ban
echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
before_show_menu
@@ -1493,13 +1604,21 @@ remove_iplimit() {
rm -f /etc/fail2ban/filter.d/3x-ipl.conf
rm -f /etc/fail2ban/action.d/3x-ipl.conf
rm -f /etc/fail2ban/jail.d/3x-ipl.conf
- systemctl restart fail2ban
+ if [[ $release == "alpine" ]]; then
+ rc-service fail2ban restart
+ else
+ systemctl restart fail2ban
+ fi
echo -e "${green}IP Limit removed successfully!${plain}\n"
before_show_menu
;;
2)
rm -rf /etc/fail2ban
- systemctl stop fail2ban
+ if [[ $release == "alpine" ]]; then
+ rc-service fail2ban stop
+ else
+ systemctl stop fail2ban
+ fi
case "${release}" in
ubuntu | debian | armbian)
apt-get remove -y fail2ban
@@ -1517,6 +1636,9 @@ remove_iplimit() {
arch | manjaro | parch)
pacman -Rns --noconfirm fail2ban
;;
+ alpine)
+ apk del fail2ban
+ ;;
*)
echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n"
exit 1
@@ -1540,9 +1662,16 @@ show_banlog() {
echo -e "${green}Checking ban logs...${plain}\n"
- if ! systemctl is-active --quiet fail2ban; then
- echo -e "${red}Fail2ban service is not running!${plain}\n"
- return 1
+ if [[ $release == "alpine" ]]; then
+ if [[ $(rc-service fail2ban status | grep -F 'status: started' -c) == 0 ]]; then
+ echo -e "${red}Fail2ban service is not running!${plain}\n"
+ return 1
+ fi
+ else
+ if ! systemctl is-active --quiet fail2ban; then
+ echo -e "${red}Fail2ban service is not running!${plain}\n"
+ return 1
+ fi
fi
if [[ -f "$system_log" ]]; then