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 567a64b4..daf1d537 100644 --- a/go.mod +++ b/go.mod @@ -95,7 +95,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 4c959a2a..2c1a6822 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 ;; @@ -177,7 +180,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 @@ -201,10 +208,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 77a60356..55bddf7f 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -1148,7 +1148,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 := "∞" @@ -1167,7 +1167,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 ba415ac9..51502900 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 } @@ -31,8 +29,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/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" .}} - +
- +