Merge branch 'main' into feature/multi-server-support

This commit is contained in:
Sanaei 2025-09-24 21:27:55 +02:00 committed by GitHub
commit 3b262cf180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 444 additions and 180 deletions

View file

@ -1,7 +1,9 @@
name: Release 3X-UI for Docker name: Release 3X-UI for Docker
permissions: permissions:
contents: read contents: read
packages: write packages: write
on: on:
workflow_dispatch: workflow_dispatch:
push: push:
@ -27,7 +29,7 @@ jobs:
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=tag type=ref,event=tag
type=pep440,pattern={{version}} type=semver,pattern={{version}}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -47,7 +49,7 @@ jobs:
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.repository_owner }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push Docker image

View file

@ -1 +1 @@
2.8.3 2.8.4

2
go.mod
View file

@ -96,7 +96,7 @@ require (
golang.org/x/tools v0.37.0 // indirect golang.org/x/tools v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // 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 google.golang.org/protobuf v1.36.9 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect

2
go.sum
View file

@ -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= 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 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-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 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=

View file

@ -56,6 +56,9 @@ install_base() {
opensuse-tumbleweed) opensuse-tumbleweed)
zypper refresh && zypper -q install -y wget curl tar timezone 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 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 # Stop x-ui service and remove old resources
if [[ -e /usr/local/x-ui/ ]]; then if [[ -e /usr/local/x-ui/ ]]; then
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui systemctl stop x-ui
fi
rm /usr/local/x-ui/ -rf rm /usr/local/x-ui/ -rf
fi fi
@ -208,10 +215,18 @@ install_x-ui() {
chmod +x /usr/bin/x-ui chmod +x /usr/bin/x-ui
config_after_install config_after_install
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/ cp -f x-ui.service /etc/systemd/system/
systemctl daemon-reload systemctl daemon-reload
systemctl enable x-ui systemctl enable x-ui
systemctl start x-ui systemctl start x-ui
fi
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..." echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
echo -e "" echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐ echo -e "┌───────────────────────────────────────────────────────┐

View file

@ -98,8 +98,14 @@ func (s *Server) initRouter() (*gin.Engine, error) {
} }
// Set base_path based on LinksPath for template rendering // 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) { engine.Use(func(c *gin.Context) {
c.Set("base_path", LinksPath) c.Set("base_path", basePath)
}) })
Encrypt, err := s.settingService.GetSubEncrypt() Encrypt, err := s.settingService.GetSubEncrypt()
@ -179,22 +185,48 @@ func (s *Server) initRouter() (*gin.Engine, error) {
linksPathForAssets = strings.TrimRight(LinksPath, "/") + "/assets" 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 { if _, err := os.Stat("web/assets"); err == nil {
engine.StaticFS("/assets", http.FS(os.DirFS("web/assets"))) assetsFS = http.FS(os.DirFS("web/assets"))
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(os.DirFS("web/assets")))
}
} else { } else {
if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil { if subFS, err := fs.Sub(webpkg.EmbeddedAssets(), "assets"); err == nil {
engine.StaticFS("/assets", http.FS(subFS)) assetsFS = http.FS(subFS)
if linksPathForAssets != "/assets" {
engine.StaticFS(linksPathForAssets, http.FS(subFS))
}
} else { } else {
logger.Error("sub: failed to mount embedded assets:", err) 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("/") g := engine.Group("/")
s.sub = NewSUBController( s.sub = NewSUBController(

View file

@ -87,7 +87,20 @@ func (a *SUBController) subs(c *gin.Context) {
if !a.jsonEnabled { if !a.jsonEnabled {
subJsonURL = "" 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{ c.HTML(200, "subpage.html", gin.H{
"title": "subscription.title", "title": "subscription.title",
"cur_ver": config.GetVersion(), "cur_ver": config.GetVersion(),

View file

@ -1169,7 +1169,7 @@ func (s *SubService) joinPathWithID(basePath, subId string) string {
// BuildPageData parses header and prepares the template view model. // BuildPageData parses header and prepares the template view model.
// BuildPageData constructs page data for rendering the subscription information page. // 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) download := common.FormatTraffic(traffic.Down)
upload := common.FormatTraffic(traffic.Up) upload := common.FormatTraffic(traffic.Up)
total := "∞" total := "∞"
@ -1188,7 +1188,7 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
return PageData{ return PageData{
Host: hostHeader, Host: hostHeader,
BasePath: "/", // kept as "/"; templates now use context base_path injected from router BasePath: basePath,
SId: subId, SId: subId,
Download: download, Download: download,
Upload: upload, Upload: upload,

View file

@ -142,6 +142,9 @@
}, },
npvtunUrl() { npvtunUrl() {
return this.app.subUrl; return this.app.subUrl;
},
happUrl() {
return `happ://add/${encodeURIComponent(this.app.subUrl)}`;
} }
}, },
methods: { methods: {

View file

@ -1,7 +1,10 @@
package controller package controller
import ( import (
"net/http"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@ -21,11 +24,21 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
return a 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. // initRouter sets up the API routes for inbounds, server, and other endpoints.
func (a *APIController) initRouter(g *gin.RouterGroup) { func (a *APIController) initRouter(g *gin.RouterGroup) {
// Main API group // Main API group
api := g.Group("/panel/api") api := g.Group("/panel/api")
api.Use(a.checkLogin) api.Use(a.checkAPIAuth)
// Inbounds API // Inbounds API
inbounds := api.Group("/inbounds") inbounds := api.Group("/inbounds")

View file

@ -39,8 +39,9 @@ func NewIndexController(g *gin.RouterGroup) *IndexController {
// initRouter sets up the routes for index, login, logout, and two-factor authentication. // initRouter sets up the routes for index, login, logout, and two-factor authentication.
func (a *IndexController) initRouter(g *gin.RouterGroup) { func (a *IndexController) initRouter(g *gin.RouterGroup) {
g.GET("/", a.index) g.GET("/", a.index)
g.POST("/login", a.login)
g.GET("/logout", a.logout) g.GET("/logout", a.logout)
g.POST("/login", a.login)
g.POST("/getTwoFactorEnable", a.getTwoFactorEnable) g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
} }

View file

@ -8,8 +8,6 @@ import (
type XUIController struct { type XUIController struct {
BaseController BaseController
inboundController *InboundController
serverController *ServerController
settingController *SettingController settingController *SettingController
xraySettingController *XraySettingController xraySettingController *XraySettingController
} }
@ -32,8 +30,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
g.GET("/settings", a.settings) g.GET("/settings", a.settings)
g.GET("/xray", a.xraySettings) g.GET("/xray", a.xraySettings)
a.inboundController = NewInboundController(g)
a.serverController = NewServerController(g)
a.settingController = NewSettingController(g) a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g) a.xraySettingController = NewXraySettingController(g)
} }

View file

@ -28,7 +28,7 @@
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.port" }}'> <a-form-item label='{{ i18n "pages.inbounds.port" }}'>
<a-input-number v-model.number="inbound.port" :min="1" :max="65531"></a-input-number> <a-input-number v-model.number="inbound.port" :min="1" :max="65535"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
@ -52,7 +52,8 @@
<br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> <br v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0"> <span v-if="dbInbound.lastTrafficResetTime && dbInbound.lastTrafficResetTime > 0">
<strong>{{ i18n "pages.inbounds.lastReset" }}:</strong> <strong>{{ i18n "pages.inbounds.lastReset" }}:</strong>
<span v-if="datepicker == 'gregorian'">[[ moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span> <span v-if="datepicker == 'gregorian'">[[
moment(dbInbound.lastTrafficResetTime).format('YYYY-MM-DD HH:mm:ss') ]]</span>
<span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span> <span v-else>[[ DateUtil.convertToJalalian(moment(dbInbound.lastTrafficResetTime)) ]]</span>
</span> </span>
</template> </template>

View file

@ -3,12 +3,14 @@
<a-divider :style="{ margin: '5px 0 0' }"></a-divider> <a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<a-form-item label="External Proxy"> <a-form-item label="External Proxy">
<a-switch v-model="externalProxy"></a-switch> <a-switch v-model="externalProxy"></a-switch>
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small" @click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button> <a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
</a-form-item> </a-form-item>
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy"> <a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
<template> <template>
<a-tooltip title="Force TLS"> <a-tooltip title="Force TLS">
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }" :dropdown-class-name="themeSwitcher.currentTheme"> <a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option> <a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
<a-select-option value="none">{{ i18n "none" }}</a-select-option> <a-select-option value="none">{{ i18n "none" }}</a-select-option>
<a-select-option value="tls">TLS</a-select-option> <a-select-option value="tls">TLS</a-select-option>
@ -17,7 +19,7 @@
</template> </template>
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input> <a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'> <a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65531"></a-input-number> <a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number>
</a-tooltip> </a-tooltip>
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'> <a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
<template slot="addonAfter"> <template slot="addonAfter">

View file

@ -35,8 +35,8 @@
<a-space direction="vertical" :size="10"> <a-space direction="vertical" :size="10">
<a-theme-switch-login></a-theme-switch-login> <a-theme-switch-login></a-theme-switch-login>
<span>{{ i18n "pages.settings.language" }}</span> <span>{{ i18n "pages.settings.language" }}</span>
<a-select ref="selectLang" class="w-100" v-model="lang" <a-select ref="selectLang" class="w-100" v-model="lang" @change="LanguageManager.setLanguage(lang)"
@change="LanguageManager.setLanguage(lang)" :dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages"> <a-select-option :value="l.value" label="English" v-for="l in LanguageManager.supportedLanguages">
<span role="img" aria-label="l.name" v-text="l.icon"></span> <span role="img" aria-label="l.name" v-text="l.icon"></span>
&nbsp;&nbsp;<span v-text="l.name"></span> &nbsp;&nbsp;<span v-text="l.name"></span>
@ -68,7 +68,7 @@
</a-input> </a-input>
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-input-password autocomplete="password" name="password" v-model.trim="user.password" <a-input-password autocomplete="current-password" name="password" v-model.trim="user.password"
placeholder='{{ i18n "password" }}' required> placeholder='{{ i18n "password" }}' required>
<a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon> <a-icon slot="prefix" type="lock" class="fs-1rem"></a-icon>
</a-input-password> </a-input-password>
@ -81,7 +81,8 @@
</a-form-item> </a-form-item>
<a-form-item> <a-form-item>
<a-row justify="center" class="centered"> <a-row justify="center" class="centered">
<div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem" :style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'"> <div class="wave-btn-bg wave-btn-bg-cl h-50px mt-1rem"
:style="loadingStates.spinning ? 'width: 52px' : 'display: inline-block'">
<a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning" <a-button class="ant-btn-primary-login" type="primary" :loading="loadingStates.spinning"
:icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit"> :icon="loadingStates.spinning ? 'poweroff' : undefined" html-type="submit">
[[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]] [[ loadingStates.spinning ? '' : '{{ i18n "login" }}' ]]

View file

@ -7,12 +7,13 @@
<a-input v-model.trim="dnsModal.dnsServer.address"></a-input> <a-input v-model.trim="dnsModal.dnsServer.address"></a-input>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.port" }}'> <a-form-item label='{{ i18n "pages.inbounds.port" }}'>
<a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65531"></a-input-number> <a-input-number v-model.number="dnsModal.dnsServer.port" :min="1" :max="65535"></a-input-number>
</a-form-item> </a-form-item>
<a-form-item label='{{ i18n "pages.xray.dns.strategy" }}'> <a-form-item label='{{ i18n "pages.xray.dns.strategy" }}'>
<a-select v-model="dnsModal.dnsServer.queryStrategy" :style="{ width: '100%' }" <a-select v-model="dnsModal.dnsServer.queryStrategy" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"> :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]] </a-select-option> <a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']"> [[ l ]]
</a-select-option>
</a-select> </a-select>
</a-form-item> </a-form-item>
<a-divider :style="{ margin: '5px 0' }"></a-divider> <a-divider :style="{ margin: '5px 0' }"></a-divider>

View file

@ -39,7 +39,7 @@
<template #title>{{ i18n "pages.settings.panelPort"}}</template> <template #title>{{ i18n "pages.settings.panelPort"}}</template>
<template #description>{{ i18n "pages.settings.panelPortDesc"}}</template> <template #description>{{ i18n "pages.settings.panelPortDesc"}}</template>
<template #control> <template #control>
<a-input-number :min="1" :min="65531" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input> <a-input-number :min="1" :min="65535" v-model="allSetting.webPort" :style="{ width: '100%' }"></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">
@ -137,7 +137,8 @@
<template #title>{{ i18n "pages.settings.datepicker"}}</template> <template #title>{{ i18n "pages.settings.datepicker"}}</template>
<template #description>{{ i18n "pages.settings.datepickerDescription"}}</template> <template #description>{{ i18n "pages.settings.datepickerDescription"}}</template>
<template #control> <template #control>
<a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme" v-model="datepicker"> <a-select :style="{ width: '100%' }" :dropdown-class-name="themeSwitcher.currentTheme"
v-model="datepicker">
<a-select-option v-for="item in datepickerList" :value="item.value"> <a-select-option v-for="item in datepickerList" :value="item.value">
<span v-text="item.name"></span> <span v-text="item.name"></span>
</a-select-option> </a-select-option>

View file

@ -40,7 +40,7 @@
<template #title>{{ i18n "pages.settings.subPort"}}</template> <template #title>{{ i18n "pages.settings.subPort"}}</template>
<template #description>{{ i18n "pages.settings.subPortDesc"}}</template> <template #description>{{ i18n "pages.settings.subPortDesc"}}</template>
<template #control> <template #control>
<a-input-number v-model="allSetting.subPort" :min="1" :min="65531" <a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
:style="{ width: '100%' }"></a-input-number> :style="{ width: '100%' }"></a-input-number>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
@ -48,13 +48,10 @@
<template #title>{{ i18n "pages.settings.subPath"}}</template> <template #title>{{ i18n "pages.settings.subPath"}}</template>
<template #description>{{ i18n "pages.settings.subPathDesc"}}</template> <template #description>{{ i18n "pages.settings.subPathDesc"}}</template>
<template #control> <template #control>
<a-input <a-input type="text" v-model="allSetting.subPath"
type="text"
v-model="allSetting.subPath"
@input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')" @input="allSetting.subPath = ((typeof $event === 'string' ? $event : ($event && $event.target ? $event.target.value : '')) || '').replace(/[:*]/g, '')"
@blur="allSetting.subPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)" @blur="allSetting.subPath = (p => { p = p || '/'; if (!p.startsWith('/')) p='/' + p; if (!p.endsWith('/')) p += '/'; return p.replace(/\/+/g,'/'); })(allSetting.subPath)"
placeholder="/sub/" placeholder="/sub/"></a-input>
></a-input>
</template> </template>
</a-setting-list-item> </a-setting-list-item>
<a-setting-list-item paddings="small"> <a-setting-list-item paddings="small">

View file

@ -218,6 +218,8 @@
<a-menu-item key="android-npvtunnel" <a-menu-item key="android-npvtunnel"
@click="copy(app.subUrl)">NPV @click="copy(app.subUrl)">NPV
Tunnel</a-menu-item> Tunnel</a-menu-item>
<a-menu-item key="android-happ"
@click="open('happ://add/' + encodeURIComponent(app.subUrl))">Happ</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>
@ -244,6 +246,8 @@
@click="copy(npvtunUrl)">NPV @click="copy(npvtunUrl)">NPV
Tunnel Tunnel
</a-menu-item> </a-menu-item>
<a-menu-item key="ios-happ"
@click="open(happUrl)">Happ</a-menu-item>
</a-menu> </a-menu>
</a-dropdown> </a-dropdown>
</a-col> </a-col>

View file

@ -12,13 +12,14 @@
<a-layout-content> <a-layout-content>
<a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'> <a-spin :spinning="loadingStates.spinning" :delay="500" tip='{{ i18n "loading"}}'>
<transition name="list" appear> <transition name="list" appear>
<a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }" message='{{ i18n "secAlertTitle" }}' <a-alert type="error" v-if="showAlert && loadingStates.fetched" :style="{ marginBottom: '10px' }"
color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable> message='{{ i18n "secAlertTitle" }}' color="red" description='{{ i18n "secAlertSsl" }}' show-icon closable>
</a-alert> </a-alert>
</transition> </transition>
<transition name="list" appear> <transition name="list" appear>
<a-row v-if="!loadingStates.fetched"> <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-spin tip='{{ i18n "loading" }}'></a-spin>
</a-card> </a-card>
</a-row> </a-row>
@ -37,7 +38,8 @@
<a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme"> <a-popover v-if="restartResult" :overlay-class-name="themeSwitcher.currentTheme">
<span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span> <span slot="title">{{ i18n "pages.index.xrayErrorPopoverTitle" }}</span>
<template slot="content"> <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> </template>
<a-icon type="question-circle"></a-icon> <a-icon type="question-circle"></a-icon>
</a-popover> </a-popover>
@ -537,6 +539,7 @@
serverObj = o.settings.vnext; serverObj = o.settings.vnext;
break; break;
case Protocols.VLESS: case Protocols.VLESS:
return [o.settings?.address + ':' + o.settings?.port];
case Protocols.HTTP: case Protocols.HTTP:
case Protocols.Socks: case Protocols.Socks:
case Protocols.Shadowsocks: case Protocols.Shadowsocks:

View file

@ -38,6 +38,25 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
if err != nil && err != gorm.ErrRecordNotFound { if err != nil && err != gorm.ErrRecordNotFound {
return nil, err 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 return inbounds, nil
} }
@ -50,6 +69,24 @@ func (s *InboundService) GetAllInbounds() ([]*model.Inbound, error) {
if err != nil && err != gorm.ErrRecordNotFound { if err != nil && err != gorm.ErrRecordNotFound {
return nil, err 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 return inbounds, nil
} }

View file

@ -96,7 +96,6 @@ type Server struct {
listener net.Listener listener net.Listener
index *controller.IndexController index *controller.IndexController
server *controller.ServerController
panel *controller.XUIController panel *controller.XUIController
api *controller.APIController api *controller.APIController
@ -264,7 +263,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
g := engine.Group(basePath) g := engine.Group(basePath)
s.index = controller.NewIndexController(g) s.index = controller.NewIndexController(g)
s.server = controller.NewMultiServerController(g)
s.panel = controller.NewXUIController(g) s.panel = controller.NewXUIController(g)
s.api = controller.NewAPIController(g) s.api = controller.NewAPIController(g)

13
x-ui.rc Normal file
View file

@ -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
}

129
x-ui.sh
View file

@ -153,11 +153,19 @@ uninstall() {
fi fi
return 0 return 0
fi fi
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 stop x-ui
systemctl disable x-ui systemctl disable x-ui
rm /etc/systemd/system/x-ui.service -f rm /etc/systemd/system/x-ui.service -f
systemctl daemon-reload systemctl daemon-reload
systemctl reset-failed systemctl reset-failed
fi
rm /etc/x-ui/ -rf rm /etc/x-ui/ -rf
rm /usr/local/x-ui/ -rf rm /usr/local/x-ui/ -rf
@ -285,8 +293,12 @@ start() {
if [[ $? == 0 ]]; then if [[ $? == 0 ]]; then
echo "" echo ""
LOGI "Panel is running, No need to start again, If you need to restart, please select restart" LOGI "Panel is running, No need to start again, If you need to restart, please select restart"
else
if [[ $release == "alpine" ]]; then
rc-service x-ui start
else else
systemctl start x-ui systemctl start x-ui
fi
sleep 2 sleep 2
check_status check_status
if [[ $? == 0 ]]; then if [[ $? == 0 ]]; then
@ -306,8 +318,12 @@ stop() {
if [[ $? == 1 ]]; then if [[ $? == 1 ]]; then
echo "" echo ""
LOGI "Panel stopped, No need to stop again!" LOGI "Panel stopped, No need to stop again!"
else
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else else
systemctl stop x-ui systemctl stop x-ui
fi
sleep 2 sleep 2
check_status check_status
if [[ $? == 1 ]]; then if [[ $? == 1 ]]; then
@ -323,7 +339,11 @@ stop() {
} }
restart() { restart() {
if [[ $release == "alpine" ]]; then
rc-service x-ui restart
else
systemctl restart x-ui systemctl restart x-ui
fi
sleep 2 sleep 2
check_status check_status
if [[ $? == 0 ]]; then if [[ $? == 0 ]]; then
@ -337,14 +357,22 @@ restart() {
} }
status() { status() {
if [[ $release == "alpine" ]]; then
rc-service x-ui status
else
systemctl status x-ui -l systemctl status x-ui -l
fi
if [[ $# == 0 ]]; then if [[ $# == 0 ]]; then
before_show_menu before_show_menu
fi fi
} }
enable() { enable() {
if [[ $release == "alpine" ]]; then
rc-update add x-ui
else
systemctl enable x-ui systemctl enable x-ui
fi
if [[ $? == 0 ]]; then if [[ $? == 0 ]]; then
LOGI "x-ui Set to boot automatically on startup successfully" LOGI "x-ui Set to boot automatically on startup successfully"
else else
@ -357,7 +385,11 @@ enable() {
} }
disable() { disable() {
if [[ $release == "alpine" ]]; then
rc-update del x-ui
else
systemctl disable x-ui systemctl disable x-ui
fi
if [[ $? == 0 ]]; then if [[ $? == 0 ]]; then
LOGI "x-ui Autostart Cancelled successfully" LOGI "x-ui Autostart Cancelled successfully"
else else
@ -370,6 +402,27 @@ disable() {
} }
show_log() { show_log() {
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)
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}\t1.${plain} Debug Log"
echo -e "${green}\t2.${plain} Clear All logs" echo -e "${green}\t2.${plain} Clear All logs"
echo -e "${green}\t0.${plain} Back to Main Menu" echo -e "${green}\t0.${plain} Back to Main Menu"
@ -396,6 +449,7 @@ show_log() {
show_log show_log
;; ;;
esac esac
fi
} }
bbr_menu() { bbr_menu() {
@ -464,6 +518,9 @@ enable_bbr() {
arch | manjaro | parch) arch | manjaro | parch)
pacman -Sy --noconfirm ca-certificates 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" echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1 exit 1
@ -500,6 +557,16 @@ update_shell() {
# 0: running, 1: not running, 2: not installed # 0: running, 1: not running, 2: not installed
check_status() { check_status() {
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
if [[ ! -f /etc/systemd/system/x-ui.service ]]; then if [[ ! -f /etc/systemd/system/x-ui.service ]]; then
return 2 return 2
fi fi
@ -509,15 +576,24 @@ check_status() {
else else
return 1 return 1
fi fi
fi
} }
check_enabled() { check_enabled() {
if [[ $release == "alpine" ]]; then
if [[ $(rc-update show | grep -F 'x-ui' | grep default -c) == 1 ]]; then
return 0
else
return 1
fi
else
temp=$(systemctl is-enabled x-ui) temp=$(systemctl is-enabled x-ui)
if [[ "${temp}" == "enabled" ]]; then if [[ "${temp}" == "enabled" ]]; then
return 0 return 0
else else
return 1 return 1
fi fi
fi
} }
check_uninstall() { check_uninstall() {
@ -798,7 +874,11 @@ update_geo() {
show_menu show_menu
;; ;;
1) 1)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui systemctl stop x-ui
fi
rm -f geoip.dat geosite.dat 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/geoip.dat
wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat wget -N https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
@ -806,7 +886,11 @@ update_geo() {
restart restart
;; ;;
2) 2)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui systemctl stop x-ui
fi
rm -f geoip_IR.dat geosite_IR.dat 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 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 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 restart
;; ;;
3) 3)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop
else
systemctl stop x-ui systemctl stop x-ui
fi
rm -f geoip_RU.dat geosite_RU.dat 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 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 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) arch | manjaro | parch)
pacman -Sy --noconfirm socat 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" echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1 exit 1
@ -1335,7 +1426,11 @@ iplimit_main() {
read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM read -rp "Please enter new Ban Duration in Minutes [default 30]: " NUM
if [[ $NUM =~ ^[0-9]+$ ]]; then if [[ $NUM =~ ^[0-9]+$ ]]; then
create_iplimit_jails ${NUM} create_iplimit_jails ${NUM}
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban systemctl restart fail2ban
fi
else else
echo -e "${red}${NUM} is not a number! Please, try again.${plain}" echo -e "${red}${NUM} is not a number! Please, try again.${plain}"
fi fi
@ -1388,7 +1483,11 @@ iplimit_main() {
iplimit_main iplimit_main
;; ;;
9) 9)
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban systemctl restart fail2ban
fi
iplimit_main iplimit_main
;; ;;
10) 10)
@ -1436,6 +1535,9 @@ install_iplimit() {
arch | manjaro | parch) arch | manjaro | parch)
pacman -Syu --noconfirm fail2ban 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" echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
exit 1 exit 1
@ -1472,12 +1574,21 @@ install_iplimit() {
create_iplimit_jails create_iplimit_jails
# Launching fail2ban # Launching 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
if ! systemctl is-active --quiet fail2ban; then if ! systemctl is-active --quiet fail2ban; then
systemctl start fail2ban systemctl start fail2ban
else else
systemctl restart fail2ban systemctl restart fail2ban
fi fi
systemctl enable fail2ban systemctl enable fail2ban
fi
echo -e "${green}IP Limit installed and configured successfully!${plain}\n" echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
before_show_menu before_show_menu
@ -1493,13 +1604,21 @@ remove_iplimit() {
rm -f /etc/fail2ban/filter.d/3x-ipl.conf rm -f /etc/fail2ban/filter.d/3x-ipl.conf
rm -f /etc/fail2ban/action.d/3x-ipl.conf rm -f /etc/fail2ban/action.d/3x-ipl.conf
rm -f /etc/fail2ban/jail.d/3x-ipl.conf rm -f /etc/fail2ban/jail.d/3x-ipl.conf
if [[ $release == "alpine" ]]; then
rc-service fail2ban restart
else
systemctl restart fail2ban systemctl restart fail2ban
fi
echo -e "${green}IP Limit removed successfully!${plain}\n" echo -e "${green}IP Limit removed successfully!${plain}\n"
before_show_menu before_show_menu
;; ;;
2) 2)
rm -rf /etc/fail2ban rm -rf /etc/fail2ban
if [[ $release == "alpine" ]]; then
rc-service fail2ban stop
else
systemctl stop fail2ban systemctl stop fail2ban
fi
case "${release}" in case "${release}" in
ubuntu | debian | armbian) ubuntu | debian | armbian)
apt-get remove -y fail2ban apt-get remove -y fail2ban
@ -1517,6 +1636,9 @@ remove_iplimit() {
arch | manjaro | parch) arch | manjaro | parch)
pacman -Rns --noconfirm fail2ban pacman -Rns --noconfirm fail2ban
;; ;;
alpine)
apk del fail2ban
;;
*) *)
echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n" echo -e "${red}Unsupported operating system. Please uninstall Fail2ban manually.${plain}\n"
exit 1 exit 1
@ -1540,10 +1662,17 @@ show_banlog() {
echo -e "${green}Checking ban logs...${plain}\n" echo -e "${green}Checking ban logs...${plain}\n"
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 if ! systemctl is-active --quiet fail2ban; then
echo -e "${red}Fail2ban service is not running!${plain}\n" echo -e "${red}Fail2ban service is not running!${plain}\n"
return 1 return 1
fi fi
fi
if [[ -f "$system_log" ]]; then if [[ -f "$system_log" ]]; then
echo -e "${green}Recent system ban activities from fail2ban.log:${plain}" echo -e "${green}Recent system ban activities from fail2ban.log:${plain}"