Quellcode durchsuchen

feat(inbounds): add sub/client link endpoints; hide panel version on login

- New GET /panel/api/inbounds/getSubLinks/:subId and /getClientLinks/:id/:email
  return the same protocol URLs the panel UI's Copy button emits, honouring
  X-Forwarded-Host / X-Forwarded-Proto. Documented in the API docs page.
- Refactor: sub package no longer imports web. The embedded dist FS is
  injected via sub.SetDistFS, and the link generator is registered with the
  service layer via service.RegisterSubLinkProvider, avoiding the circular
  import the new endpoints would otherwise introduce.
- Security: stop emitting window.X_UI_CUR_VER on login.html and drop the
  visible version chip from the login page, so the panel version is no
  longer pre-auth info disclosure. Authenticated pages still receive it.
- Bump config/version.
MHSanaei vor 14 Stunden
Ursprung
Commit
6a90f98412

+ 24 - 1
frontend/src/pages/api-docs/endpoints.js

@@ -43,7 +43,7 @@ export const sections = [
     id: 'inbounds',
     title: 'Inbounds API',
     description:
-      'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token.',
+      'Manage inbound configurations and their clients. All endpoints live under /panel/api/inbounds and require a logged-in session or Bearer token. Link-generating endpoints honour X-Forwarded-Host / X-Forwarded-Proto, so callers behind a reverse proxy get the correct external host in returned URLs.',
     endpoints: [
       {
         method: 'GET',
@@ -210,6 +210,29 @@ export const sections = [
         path: '/panel/api/inbounds/lastOnline',
         summary: 'Map of client email → last-seen unix timestamp.',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getSubLinks/:subId',
+        summary:
+          'Return every protocol URL (vless://, vmess://, trojan://, ss://, hysteria://, hy2://) for clients matching the subscription ID. Same result set as /sub/<subId>, but as a JSON array — no base64. When an inbound has streamSettings.externalProxy set, one URL is emitted per external proxy. Empty array when the subId has no enabled clients.',
+        params: [
+          { name: 'subId', in: 'path', type: 'string', desc: "Subscription ID, taken from the client's subId field." },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?security=reality&...#user1",\n    "vmess://eyJ2IjoyLC..."\n  ]\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/inbounds/getClientLinks/:id/:email',
+        summary:
+          "Return the URL(s) for one client on one inbound — the same string the Copy URL button copies in the panel UI. Supported protocols: vmess, vless, trojan, shadowsocks, hysteria, hysteria2. If streamSettings.externalProxy is set, returns one URL per external proxy. Protocols without a URL form (socks, http, mixed, wireguard, dokodemo, tunnel) return an empty array.",
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Inbound ID.' },
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        response:
+          '{\n  "success": true,\n  "obj": [\n    "vless://uuid@host:443?...#user1"\n  ]\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/inbounds/updateClientTraffic/:email',

+ 0 - 9
frontend/src/pages/login/LoginPage.vue

@@ -18,7 +18,6 @@ const { t } = useI18n();
 const fetched = ref(false);
 const submitting = ref(false);
 const twoFactorEnable = ref(false);
-const version = computed(() => window.X_UI_CUR_VER || '');
 
 const user = reactive({
   username: '',
@@ -178,7 +177,6 @@ function cycleTheme() {
               </a-form-item>
             </a-form>
 
-            <div v-if="version" class="version">v{{ version }}</div>
           </div>
         </div>
       </a-layout-content>
@@ -475,13 +473,6 @@ function cycleTheme() {
   margin-bottom: 0;
 }
 
-.version {
-  text-align: center;
-  font-size: 12px;
-  color: var(--color-text-subtle);
-  margin-top: 16px;
-}
-
 .settings-popover {
   min-width: 220px;
 }

+ 3 - 0
main.go

@@ -61,6 +61,8 @@ func runWebServer() {
 	}
 
 	var subServer *sub.Server
+	sub.SetDistFS(web.EmbeddedDist())
+	service.RegisterSubLinkProvider(sub.NewLinkProvider())
 	subServer = sub.NewServer()
 	global.SetSubServer(subServer)
 	err = subServer.Start()
@@ -101,6 +103,7 @@ func runWebServer() {
 			}
 			log.Println("Web server restarted successfully.")
 
+			sub.SetDistFS(web.EmbeddedDist())
 			subServer = sub.NewServer()
 			global.SetSubServer(subServer)
 			err = subServer.Start()

+ 16 - 0
sub/dist.go

@@ -0,0 +1,16 @@
+package sub
+
+import "embed"
+
+// distFS holds the Vite-built frontend filesystem, injected from main at
+// startup. The `web` package owns the //go:embed directive (because dist/
+// is at web/dist/), and hands the FS over via SetDistFS so the sub package
+// doesn't import web — that would create an import cycle once any
+// web/controller handler reuses sub's link-building service.
+var distFS embed.FS
+
+// SetDistFS installs the embedded frontend filesystem the sub server uses
+// for its info page assets. Must be called before NewServer().Start().
+func SetDistFS(fs embed.FS) {
+	distFS = fs
+}

+ 59 - 0
sub/links.go

@@ -0,0 +1,59 @@
+package sub
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/database/model"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+)
+
+type LinkProvider struct {
+	settingService service.SettingService
+}
+
+func NewLinkProvider() *LinkProvider {
+	return &LinkProvider{}
+}
+
+func (p *LinkProvider) build(host string) *SubService {
+	showInfo, _ := p.settingService.GetSubShowInfo()
+	rModel, err := p.settingService.GetRemarkModel()
+	if err != nil {
+		rModel = "-ieo"
+	}
+	svc := NewSubService(showInfo, rModel)
+	svc.PrepareForRequest(host)
+	return svc
+}
+
+func (p *LinkProvider) SubLinksForSubId(host, subId string) ([]string, error) {
+	svc := p.build(host)
+	links, _, _, err := svc.GetSubs(subId, host)
+	if err != nil {
+		return nil, err
+	}
+	out := make([]string, 0, len(links))
+	for _, l := range links {
+		out = append(out, splitLinkLines(l)...)
+	}
+	return out, nil
+}
+
+func (p *LinkProvider) LinksForClient(host string, inbound *model.Inbound, email string) []string {
+	svc := p.build(host)
+	return splitLinkLines(svc.GetLink(inbound, email))
+}
+
+func splitLinkLines(raw string) []string {
+	if raw == "" {
+		return nil
+	}
+	parts := strings.Split(raw, "\n")
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if p = strings.TrimSpace(p); p != "" {
+			out = append(out, p)
+		}
+	}
+	return out
+}

+ 1 - 2
sub/sub.go

@@ -16,7 +16,6 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/logger"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
-	webpkg "github.com/mhsanaei/3x-ui/v3/web"
 	"github.com/mhsanaei/3x-ui/v3/web/locale"
 	"github.com/mhsanaei/3x-ui/v3/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/web/network"
@@ -189,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	var assetsFS http.FileSystem
 	if _, err := os.Stat("web/dist/assets"); err == nil {
 		assetsFS = http.FS(os.DirFS("web/dist/assets"))
-	} else if subFS, err := fs.Sub(webpkg.EmbeddedDist(), "dist/assets"); err == nil {
+	} else if subFS, err := fs.Sub(distFS, "dist/assets"); err == nil {
 		assetsFS = http.FS(subFS)
 	} else {
 		logger.Error("sub: failed to mount embedded dist assets:", err)

+ 1 - 3
sub/subController.go

@@ -10,7 +10,6 @@ import (
 	"strconv"
 	"strings"
 
-	webpkg "github.com/mhsanaei/3x-ui/v3/web"
 	"github.com/mhsanaei/3x-ui/v3/web/service"
 
 	"github.com/gin-gonic/gin"
@@ -159,8 +158,7 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
 	if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
 		body = diskBody
 	} else {
-		dist := webpkg.EmbeddedDist()
-		readBody, err := dist.ReadFile("dist/subpage.html")
+		readBody, err := distFS.ReadFile("dist/subpage.html")
 		if err != nil {
 			c.String(http.StatusInternalServerError, "missing embedded subpage")
 			return

+ 6 - 2
sub/subService.go

@@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
 				if client.Enable {
 					hasEnabledClient = true
 				}
-				result = append(result, s.getLink(inbound, client.Email))
+				result = append(result, s.GetLink(inbound, client.Email))
 				var ct xray.ClientTraffic
 				ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
 				if ct.LastOnline > lastOnline {
@@ -198,7 +198,11 @@ func (s *SubService) getFallbackMaster(dest string, streamSettings string) (stri
 	return inbound.Listen, inbound.Port, string(modifiedStream), nil
 }
 
-func (s *SubService) getLink(inbound *model.Inbound, email string) string {
+// GetLink dispatches to the protocol-specific generator for one (inbound, client)
+// pair. Returns "" when the inbound's protocol doesn't produce a subscription URL
+// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may
+// contain multiple `\n`-separated URLs when the inbound has externalProxy set.
+func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
 	switch inbound.Protocol {
 	case "vmess":
 		return s.genVmessLink(inbound, email)

+ 7 - 3
web/controller/dist.go

@@ -50,7 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
 		"&", `&`,
 	)
 	escapedBase := jsEscape.Replace(basePath)
-	escapedVer := jsEscape.Replace(config.GetVersion())
 	csrfToken, err := session.EnsureCSRFToken(c)
 	if err != nil {
 		logger.Warning("Unable to mint CSRF token for", name+":", err)
@@ -58,8 +57,13 @@ func serveDistPage(c *gin.Context, name string) {
 	}
 	csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
 
-	inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase +
-		`";window.X_UI_CUR_VER="` + escapedVer + `";</script>`)
+	script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
+	if name != "login.html" {
+		escapedVer := jsEscape.Replace(config.GetVersion())
+		script += `;window.X_UI_CUR_VER="` + escapedVer + `"`
+	}
+	script += `;</script>`
+	inject := []byte(script)
 	inject = append(inject, csrfMeta...)
 	inject = append(inject, []byte(`</head>`)...)
 	out := bytes.Replace(body, []byte("</head>"), inject, 1)

+ 56 - 0
web/controller/inbound.go

@@ -3,7 +3,9 @@ package controller
 import (
 	"encoding/json"
 	"fmt"
+	"net"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/database/model"
@@ -62,6 +64,8 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
 	g.GET("/get/:id", a.getInbound)
 	g.GET("/getClientTraffics/:email", a.getClientTraffics)
 	g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
+	g.GET("/getSubLinks/:subId", a.getSubLinks)
+	g.GET("/getClientLinks/:id/:email", a.getClientLinks)
 
 	g.POST("/add", a.addInbound)
 	g.POST("/del/:id", a.delInbound)
@@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
 		a.xrayService.SetToNeedRestart()
 	}
 }
+
+// resolveHost mirrors what sub.SubService.ResolveRequest does for the host
+// field: prefers X-Forwarded-Host (first entry of any list, port stripped),
+// then X-Real-IP, then the host portion of c.Request.Host. Keeping it in the
+// controller layer means the service interface stays HTTP-agnostic — service
+// methods receive a plain host string instead of a *gin.Context.
+func resolveHost(c *gin.Context) string {
+	if h := strings.TrimSpace(c.GetHeader("X-Forwarded-Host")); h != "" {
+		if i := strings.Index(h, ","); i >= 0 {
+			h = strings.TrimSpace(h[:i])
+		}
+		if hp, _, err := net.SplitHostPort(h); err == nil {
+			return hp
+		}
+		return h
+	}
+	if h := c.GetHeader("X-Real-IP"); h != "" {
+		return h
+	}
+	if h, _, err := net.SplitHostPort(c.Request.Host); err == nil {
+		return h
+	}
+	return c.Request.Host
+}
+
+// getSubLinks returns every protocol URL produced for the given subscription
+// ID — the JSON-array equivalent of /sub/<subId> (no base64 wrap).
+func (a *InboundController) getSubLinks(c *gin.Context) {
+	links, err := a.inboundService.GetSubLinks(resolveHost(c), c.Param("subId"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}
+
+// getClientLinks returns the URL(s) for one client on one inbound — the same
+// string the Copy URL button copies in the panel UI. Empty array when the
+// protocol has no URL form, or when the email isn't found on the inbound.
+func (a *InboundController) getClientLinks(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	links, err := a.inboundService.GetClientLinks(resolveHost(c), id, c.Param("email"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, links, nil)
+}

+ 28 - 0
web/service/inbound.go

@@ -3866,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
 
 	return needRestart, db.Save(oldInbound).Error
 }
+
+type SubLinkProvider interface {
+	SubLinksForSubId(host, subId string) ([]string, error)
+	LinksForClient(host string, inbound *model.Inbound, email string) []string
+}
+
+var registeredSubLinkProvider SubLinkProvider
+
+func RegisterSubLinkProvider(p SubLinkProvider) {
+	registeredSubLinkProvider = p
+}
+
+func (s *InboundService) GetSubLinks(host, subId string) ([]string, error) {
+	if registeredSubLinkProvider == nil {
+		return nil, common.NewError("sub link provider not registered")
+	}
+	return registeredSubLinkProvider.SubLinksForSubId(host, subId)
+}
+func (s *InboundService) GetClientLinks(host string, id int, email string) ([]string, error) {
+	inbound, err := s.GetInbound(id)
+	if err != nil {
+		return nil, err
+	}
+	if registeredSubLinkProvider == nil {
+		return nil, common.NewError("sub link provider not registered")
+	}
+	return registeredSubLinkProvider.LinksForClient(host, inbound, email), nil
+}