feat(api): add client lookup by subscription ID (#6908)

Integrations could only find a client by subscription ID through a substring
search over the paged client list followed by a second lookup by email.
GET /panel/api/clients/get/subId/:subId now returns the full client records
(settings, attached inbounds, external links, traffic, tunnel addresses) for an
exact subId match, as a list because older rows may share an ID. It sits behind
the same admin login or admin-token check as the email lookup.
This commit is contained in:
CHANGAO MA
2026-10-24 10:54:14 +08:00
committed by GitHub
parent 3051aebb28
commit 0516a1f87c
6 changed files with 369 additions and 1 deletions
+23
View File
@@ -13,6 +13,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func notifyClientsChanged() {
@@ -53,6 +54,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.list)
g.GET("/list/paged", a.listPaged)
g.GET("/get/:email", a.get)
g.GET("/get/subId/:subId", a.getBySubId)
g.GET("/get/tgId/:tgId", a.getByTgId)
g.GET("/traffic/:email", a.getTrafficByEmail)
g.GET("/subLinks/:subId", a.getSubLinks)
@@ -172,6 +174,27 @@ func (a *ClientController) get(c *gin.Context) {
jsonObj(c, payload, nil)
}
func (a *ClientController) getBySubId(c *gin.Context) {
records, err := a.clientService.GetRecordsBySubID(c.Param("subId"))
if err == nil && len(records) == 0 {
err = gorm.ErrRecordNotFound
}
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
results := make([]gin.H, 0, len(records))
for _, rec := range records {
payload, err := a.buildClientPayload(rec)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
results = append(results, payload)
}
jsonObj(c, results, nil)
}
func (a *ClientController) getByTgId(c *gin.Context) {
tgIdStr := c.Param("tgId")
tgId, err := strconv.ParseInt(tgIdStr, 10, 64)
@@ -0,0 +1,154 @@
package controller
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
"github.com/mhsanaei/3x-ui/v3/internal/web/locale"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func TestClientGetBySubIDHTTP(t *testing.T) {
dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
db := database.GetDB()
clients := []model.ClientRecord{
{Email: "alice@example.com", SubID: "shared_%", UUID: "alice-uuid", Enable: true},
{Email: "bob@example.com", SubID: "shared_%", UUID: "bob-uuid", ExpiryTime: 1},
{Email: "carol@example.com", SubID: "solo", Enable: true},
{Email: "shared_%", SubID: "shared_extra", Enable: true},
}
for i := range clients {
if err := db.Create(&clients[i]).Error; err != nil {
t.Fatal(err)
}
}
if err := db.Model(&clients[1]).Update("enable", false).Error; err != nil {
t.Fatal(err)
}
inbound := model.Inbound{Tag: "lookup", Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
if err := db.Create(&inbound).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&model.ClientInbound{ClientId: clients[0].Id, InboundId: inbound.Id, FlowOverride: "xtls-rprx-vision"}).Error; err != nil {
t.Fatal(err)
}
if err := db.Create(&xray.ClientTraffic{Email: clients[0].Email, Up: 111, Down: 222}).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key })
c.Next()
})
NewClientController(router.Group("/panel/api/clients"))
for _, tt := range []struct {
name string
subID string
emails []string
}{
{"shared subscription includes disabled orphan", "shared_%", []string{"alice@example.com", "bob@example.com"}},
{"single match", "solo", []string{"carol@example.com"}},
{"missing subscription", "missing", nil},
{"partial subscription is not a match", "shared", nil},
{"wildcard is not a match", "%", nil},
} {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/panel/api/clients/get/subId/"+url.PathEscape(tt.subID), nil))
if w.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
}
var response struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj []struct {
Client model.ClientRecord `json:"client"`
InboundIDs []int `json:"inboundIds"`
UsedTraffic int64 `json:"usedTraffic"`
} `json:"obj"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if tt.emails == nil {
if response.Success || !strings.Contains(response.Msg, gorm.ErrRecordNotFound.Error()) || response.Obj != nil {
t.Fatalf("want record-not-found envelope, got %s", w.Body.String())
}
return
}
if !response.Success || len(response.Obj) != len(tt.emails) {
t.Fatalf("want %d client payloads, got %s", len(tt.emails), w.Body.String())
}
for i, payload := range response.Obj {
if payload.Client.Email != tt.emails[i] || payload.Client.SubID != tt.subID {
t.Fatalf("client %d = %+v, want email %q and subId %q", i, payload.Client, tt.emails[i], tt.subID)
}
switch payload.Client.Email {
case "alice@example.com":
if payload.Client.UUID != "alice-uuid" || payload.Client.Flow != "xtls-rprx-vision" || payload.UsedTraffic != 333 || !reflect.DeepEqual(payload.InboundIDs, []int{inbound.Id}) {
t.Fatalf("attached client payload = %+v", payload)
}
case "bob@example.com":
if payload.Client.Enable || payload.Client.ExpiryTime != 1 || payload.Client.UUID != "bob-uuid" || len(payload.InboundIDs) != 0 {
t.Fatalf("disabled orphan payload = %+v", payload)
}
}
}
})
}
}
func TestClientGetBySubIDRequiresAdmin(t *testing.T) {
router, api := newAPIAuthTestEngine(t)
NewClientController(router.Group("/base/panel/api/clients", api.checkAPIAuth, api.enforceTokenScope))
if err := database.GetDB().Create(&model.ClientRecord{Email: "private@example.com", SubID: "private-sub", UUID: "private-uuid"}).Error; err != nil {
t.Fatal(err)
}
for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeMonitor, model.ApiScopeNodeSync, model.ApiScopeNodeAdmin} {
if err := database.GetDB().Create(&model.ApiToken{Name: scope, Token: crypto.HashTokenSHA256(scope), Enabled: true, Scope: scope}).Error; err != nil {
t.Fatal(err)
}
}
for _, tt := range []struct {
scope string
code int
}{
{"", http.StatusUnauthorized},
{model.ApiScopeMonitor, http.StatusForbidden},
{model.ApiScopeNodeSync, http.StatusForbidden},
{model.ApiScopeNodeAdmin, http.StatusForbidden},
{model.ApiScopeAdmin, http.StatusOK},
} {
t.Run("scope="+tt.scope, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/base/panel/api/clients/get/subId/private-sub", nil)
req.Header.Set("X-Requested-With", "XMLHttpRequest")
if tt.scope != "" {
req.Header.Set("Authorization", "Bearer "+tt.scope)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != tt.code {
t.Fatalf("status = %d, want %d, body = %s", w.Code, tt.code, w.Body.String())
}
hasCredentials := strings.Contains(w.Body.String(), "private-uuid")
if hasCredentials != (tt.code == http.StatusOK) {
t.Fatalf("unexpected credential access: %s", w.Body.String())
}
})
}
}