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.
This commit is contained in:
MHSanaei 2026-05-11 15:03:47 +02:00
parent 9318c2105f
commit 6a90f98412
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
11 changed files with 201 additions and 20 deletions

View file

@ -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',

View file

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

View file

@ -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
sub/dist.go Normal file
View file

@ -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
sub/links.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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