From 6a90f98412dc91cc3ac230768a4d4428071ab16f Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 11 May 2026 15:03:47 +0200 Subject: [PATCH] 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. --- frontend/src/pages/api-docs/endpoints.js | 25 +++++++++- frontend/src/pages/login/LoginPage.vue | 9 ---- main.go | 3 ++ sub/dist.go | 16 +++++++ sub/links.go | 59 ++++++++++++++++++++++++ sub/sub.go | 3 +- sub/subController.go | 4 +- sub/subService.go | 8 +++- web/controller/dist.go | 10 ++-- web/controller/inbound.go | 56 ++++++++++++++++++++++ web/service/inbound.go | 28 +++++++++++ 11 files changed, 201 insertions(+), 20 deletions(-) create mode 100644 sub/dist.go create mode 100644 sub/links.go diff --git a/frontend/src/pages/api-docs/endpoints.js b/frontend/src/pages/api-docs/endpoints.js index 47929738..baf2bda5 100644 --- a/frontend/src/pages/api-docs/endpoints.js +++ b/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/, 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', diff --git a/frontend/src/pages/login/LoginPage.vue b/frontend/src/pages/login/LoginPage.vue index e4e54507..fab7ba9d 100644 --- a/frontend/src/pages/login/LoginPage.vue +++ b/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() { -
v{{ version }}
@@ -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; } diff --git a/main.go b/main.go index 4d593898..9bb1d0b9 100644 --- a/main.go +++ b/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() diff --git a/sub/dist.go b/sub/dist.go new file mode 100644 index 00000000..ebd66036 --- /dev/null +++ b/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 +} diff --git a/sub/links.go b/sub/links.go new file mode 100644 index 00000000..234f8d79 --- /dev/null +++ b/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 +} diff --git a/sub/sub.go b/sub/sub.go index 5f9d509d..eb3fece7 100644 --- a/sub/sub.go +++ b/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) diff --git a/sub/subController.go b/sub/subController.go index 1f2be30b..9c7414c5 100644 --- a/sub/subController.go +++ b/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 diff --git a/sub/subService.go b/sub/subService.go index 2ec33e5f..887cf87c 100644 --- a/sub/subService.go +++ b/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) diff --git a/web/controller/dist.go b/web/controller/dist.go index 8bcfb84a..51bd3574 100644 --- a/web/controller/dist.go +++ b/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(``) - inject := []byte(``) + script := `` + inject := []byte(script) inject = append(inject, csrfMeta...) inject = append(inject, []byte(``)...) out := bytes.Replace(body, []byte(""), inject, 1) diff --git a/web/controller/inbound.go b/web/controller/inbound.go index 00d26cc1..79f5d4eb 100644 --- a/web/controller/inbound.go +++ b/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/ (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) +} diff --git a/web/service/inbound.go b/web/service/inbound.go index b0d42533..ac7481a2 100644 --- a/web/service/inbound.go +++ b/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 +}