diff --git a/docs/content/docs/en/reference/api/clients.mdx b/docs/content/docs/en/reference/api/clients.mdx index d120a8e3e..f745c5249 100644 --- a/docs/content/docs/en/reference/api/clients.mdx +++ b/docs/content/docs/en/reference/api/clients.mdx @@ -28,6 +28,9 @@ _openapi: title: Fetch one client by email, including the inbound IDs and external config IDs it is attached to. url: '#fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to' + - depth: 2 + title: Fetch clients by subscription ID. + url: '#fetch-clients-by-subscription-id' - depth: 2 title: Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID. @@ -319,6 +322,8 @@ _openapi: - content: Fetch one client by email, including the inbound IDs and external config IDs it is attached to. id: fetch-one-client-by-email-including-the-inbound-ids-and-external-config-ids-it-is-attached-to + - content: Fetch clients by subscription ID. + id: fetch-clients-by-subscription-id - content: Fetch clients by Telegram user ID. Returns an array since multiple clients can share the same Telegram ID. id: fetch-clients-by-telegram-user-id-returns-an-array-since-multiple-clients-can-share-the-same-telegram-id @@ -555,6 +560,13 @@ _openapi: tunnel) contribute nothing.' id: return-every-url-for-one-client-across-all-attached-inbounds-one-per-advertised-endpoint-the-managed-hosts-of-the-inbound-else-its-streamsettingsexternalproxy-entries-else-its-own-address-supported-protocols-vmess-vless-trojan-shadowsocks-hysteria-mtproto-protocols-without-a-url-form-socks-http-mixed-wireguard-dokodemo-tunnel-contribute-nothing contents: + - content: Matches the exact subscription ID and orders results by client ID. + Returns an array of the same payloads as GET /clients/get/:email, + since multiple clients can share a subscription ID. Includes disabled, + expired and unattached clients. An unknown ID returns HTTP 200 with + success:false, obj:null and a record-not-found message, matching the + email lookup. Requires an admin token or a panel login session. + heading: fetch-clients-by-subscription-id - content: >- Fields the server fills in when they are omitted — a valid value sent by the caller is never overwritten. Re-adding an email that already @@ -667,7 +679,7 @@ export default function Layout(props) { return ( <> {props.children} - + ); } \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 668cd73dd..e18a6107b 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -8501,6 +8501,81 @@ } } }, + "/panel/api/clients/get/subId/{subId}": { + "get": { + "tags": [ + "Clients" + ], + "summary": "Fetch clients by subscription ID.", + "operationId": "get_panel_api_clients_get_subId_subId", + "description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.", + "parameters": [ + { + "name": "subId", + "in": "path", + "required": true, + "description": "Exact subscription ID (URL-encoded).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "client": { + "$ref": "#/components/schemas/ClientRecord" + }, + "inboundIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "externalLinks": { + "type": "array", + "items": { + "type": "object" + } + }, + "usedTraffic": { + "type": "integer", + "format": "int64" + }, + "tunnelAllowedIPs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/panel/api/clients/get/tgId/{tgId}": { "get": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 668cd73dd..e18a6107b 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -8501,6 +8501,81 @@ } } }, + "/panel/api/clients/get/subId/{subId}": { + "get": { + "tags": [ + "Clients" + ], + "summary": "Fetch clients by subscription ID.", + "operationId": "get_panel_api_clients_get_subId_subId", + "description": "Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.", + "parameters": [ + { + "name": "subId", + "in": "path", + "required": true, + "description": "Exact subscription ID (URL-encoded).", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": { + "type": "array", + "nullable": true, + "items": { + "type": "object", + "properties": { + "client": { + "$ref": "#/components/schemas/ClientRecord" + }, + "inboundIds": { + "type": "array", + "items": { + "type": "integer" + } + }, + "externalLinks": { + "type": "array", + "items": { + "type": "object" + } + }, + "usedTraffic": { + "type": "integer", + "format": "int64" + }, + "tunnelAllowedIPs": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + } + } + } + } + } + } + }, "/panel/api/clients/get/tgId/{tgId}": { "get": { "tags": [ diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 5d6bddd2d..b69a99f36 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1142,6 +1142,35 @@ export const sections: readonly Section[] = [ response: '{\n "success": true,\n "obj": {\n "client": { "id": 1, "email": "alice@example.com", ... },\n "inboundIds": [3, 5],\n "externalLinks": [\n { "id": 11, "kind": "link", "value": "vless://...", "remark": "DE", "enable": true, "expiryTime": 0 },\n { "id": 12, "kind": "subscription", "value": "https://provider.example/sub/abc", "remark": "Provider", "enable": false, "expiryTime": 1767225600000, "namePrefix": "[zjh] ", "lastFetchAt": 1767220000000, "lastFetchError": "" }\n ]\n }\n}', }, + { + method: 'GET', + path: '/panel/api/clients/get/subId/:subId', + summary: 'Fetch clients by subscription ID.', + description: + 'Matches the exact subscription ID and orders results by client ID. Returns an array of the same payloads as GET /clients/get/:email, since multiple clients can share a subscription ID. Includes disabled, expired and unattached clients. An unknown ID returns HTTP 200 with success:false, obj:null and a record-not-found message, matching the email lookup. Requires an admin token or a panel login session.', + params: [ + { + name: 'subId', + in: 'path', + type: 'string', + desc: 'Exact subscription ID (URL-encoded).', + }, + ], + responseObjectSchema: { + type: 'array', + nullable: true, + items: { + type: 'object', + properties: { + client: { $ref: '#/components/schemas/ClientRecord' }, + inboundIds: { type: 'array', items: { type: 'integer' } }, + externalLinks: { type: 'array', items: { type: 'object' } }, + usedTraffic: { type: 'integer', format: 'int64' }, + tunnelAllowedIPs: { type: 'object', additionalProperties: { type: 'string' } }, + }, + }, + }, + }, { method: 'GET', path: '/panel/api/clients/get/tgId/:tgId', diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index da3a723c2..2c7c5e3ef 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -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) diff --git a/internal/web/controller/client_lookup_test.go b/internal/web/controller/client_lookup_test.go new file mode 100644 index 000000000..1e7cd892c --- /dev/null +++ b/internal/web/controller/client_lookup_test.go @@ -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()) + } + }) + } +}