mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
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:
parent
9318c2105f
commit
6a90f98412
11 changed files with 201 additions and 20 deletions
|
|
@ -43,7 +43,7 @@ export const sections = [
|
||||||
id: 'inbounds',
|
id: 'inbounds',
|
||||||
title: 'Inbounds API',
|
title: 'Inbounds API',
|
||||||
description:
|
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: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
|
|
@ -210,6 +210,29 @@ export const sections = [
|
||||||
path: '/panel/api/inbounds/lastOnline',
|
path: '/panel/api/inbounds/lastOnline',
|
||||||
summary: 'Map of client email → last-seen unix timestamp.',
|
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',
|
method: 'POST',
|
||||||
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
path: '/panel/api/inbounds/updateClientTraffic/:email',
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ const { t } = useI18n();
|
||||||
const fetched = ref(false);
|
const fetched = ref(false);
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const twoFactorEnable = ref(false);
|
const twoFactorEnable = ref(false);
|
||||||
const version = computed(() => window.X_UI_CUR_VER || '');
|
|
||||||
|
|
||||||
const user = reactive({
|
const user = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
|
|
@ -178,7 +177,6 @@ function cycleTheme() {
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-form>
|
</a-form>
|
||||||
|
|
||||||
<div v-if="version" class="version">v{{ version }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-layout-content>
|
</a-layout-content>
|
||||||
|
|
@ -475,13 +473,6 @@ function cycleTheme() {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-subtle);
|
|
||||||
margin-top: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.settings-popover {
|
.settings-popover {
|
||||||
min-width: 220px;
|
min-width: 220px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
main.go
3
main.go
|
|
@ -61,6 +61,8 @@ func runWebServer() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var subServer *sub.Server
|
var subServer *sub.Server
|
||||||
|
sub.SetDistFS(web.EmbeddedDist())
|
||||||
|
service.RegisterSubLinkProvider(sub.NewLinkProvider())
|
||||||
subServer = sub.NewServer()
|
subServer = sub.NewServer()
|
||||||
global.SetSubServer(subServer)
|
global.SetSubServer(subServer)
|
||||||
err = subServer.Start()
|
err = subServer.Start()
|
||||||
|
|
@ -101,6 +103,7 @@ func runWebServer() {
|
||||||
}
|
}
|
||||||
log.Println("Web server restarted successfully.")
|
log.Println("Web server restarted successfully.")
|
||||||
|
|
||||||
|
sub.SetDistFS(web.EmbeddedDist())
|
||||||
subServer = sub.NewServer()
|
subServer = sub.NewServer()
|
||||||
global.SetSubServer(subServer)
|
global.SetSubServer(subServer)
|
||||||
err = subServer.Start()
|
err = subServer.Start()
|
||||||
|
|
|
||||||
16
sub/dist.go
Normal file
16
sub/dist.go
Normal 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
59
sub/links.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,6 @@ import (
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"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/locale"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
"github.com/mhsanaei/3x-ui/v3/web/middleware"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/network"
|
"github.com/mhsanaei/3x-ui/v3/web/network"
|
||||||
|
|
@ -189,7 +188,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
|
||||||
var assetsFS http.FileSystem
|
var assetsFS http.FileSystem
|
||||||
if _, err := os.Stat("web/dist/assets"); err == nil {
|
if _, err := os.Stat("web/dist/assets"); err == nil {
|
||||||
assetsFS = http.FS(os.DirFS("web/dist/assets"))
|
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)
|
assetsFS = http.FS(subFS)
|
||||||
} else {
|
} else {
|
||||||
logger.Error("sub: failed to mount embedded dist assets:", err)
|
logger.Error("sub: failed to mount embedded dist assets:", err)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
webpkg "github.com/mhsanaei/3x-ui/v3/web"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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 {
|
if diskBody, diskErr := os.ReadFile("web/dist/subpage.html"); diskErr == nil {
|
||||||
body = diskBody
|
body = diskBody
|
||||||
} else {
|
} else {
|
||||||
dist := webpkg.EmbeddedDist()
|
readBody, err := distFS.ReadFile("dist/subpage.html")
|
||||||
readBody, err := dist.ReadFile("dist/subpage.html")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
c.String(http.StatusInternalServerError, "missing embedded subpage")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ func (s *SubService) GetSubs(subId string, host string) ([]string, int64, xray.C
|
||||||
if client.Enable {
|
if client.Enable {
|
||||||
hasEnabledClient = true
|
hasEnabledClient = true
|
||||||
}
|
}
|
||||||
result = append(result, s.getLink(inbound, client.Email))
|
result = append(result, s.GetLink(inbound, client.Email))
|
||||||
var ct xray.ClientTraffic
|
var ct xray.ClientTraffic
|
||||||
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
ct, clientTraffics = s.appendUniqueTraffic(seenEmails, clientTraffics, inbound.ClientStats, client.Email)
|
||||||
if ct.LastOnline > lastOnline {
|
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
|
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 {
|
switch inbound.Protocol {
|
||||||
case "vmess":
|
case "vmess":
|
||||||
return s.genVmessLink(inbound, email)
|
return s.genVmessLink(inbound, email)
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,6 @@ func serveDistPage(c *gin.Context, name string) {
|
||||||
"&", `&`,
|
"&", `&`,
|
||||||
)
|
)
|
||||||
escapedBase := jsEscape.Replace(basePath)
|
escapedBase := jsEscape.Replace(basePath)
|
||||||
escapedVer := jsEscape.Replace(config.GetVersion())
|
|
||||||
csrfToken, err := session.EnsureCSRFToken(c)
|
csrfToken, err := session.EnsureCSRFToken(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("Unable to mint CSRF token for", name+":", err)
|
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) + `">`)
|
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
|
||||||
|
|
||||||
inject := []byte(`<script>window.X_UI_BASE_PATH="` + escapedBase +
|
script := `<script>window.X_UI_BASE_PATH="` + escapedBase + `"`
|
||||||
`";window.X_UI_CUR_VER="` + escapedVer + `";</script>`)
|
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, csrfMeta...)
|
||||||
inject = append(inject, []byte(`</head>`)...)
|
inject = append(inject, []byte(`</head>`)...)
|
||||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ package controller
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database/model"
|
"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("/get/:id", a.getInbound)
|
||||||
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
g.GET("/getClientTraffics/:email", a.getClientTraffics)
|
||||||
g.GET("/getClientTrafficsById/:id", a.getClientTrafficsById)
|
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("/add", a.addInbound)
|
||||||
g.POST("/del/:id", a.delInbound)
|
g.POST("/del/:id", a.delInbound)
|
||||||
|
|
@ -571,3 +575,55 @@ func (a *InboundController) delInboundClientByEmail(c *gin.Context) {
|
||||||
a.xrayService.SetToNeedRestart()
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3866,3 +3866,31 @@ func (s *InboundService) DelInboundClientByEmail(inboundId int, email string) (b
|
||||||
|
|
||||||
return needRestart, db.Save(oldInbound).Error
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue