diff --git a/docs/content/docs/en/reference/api/subscription-server.mdx b/docs/content/docs/en/reference/api/subscription-server.mdx index 0469fd6f0..7fd6bc674 100644 --- a/docs/content/docs/en/reference/api/subscription-server.mdx +++ b/docs/content/docs/en/reference/api/subscription-server.mdx @@ -23,6 +23,17 @@ _openapi: title: Return the same status and subscription metadata headers as GET without a response body. url: '#return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body' + - depth: 2 + title: 'Return aggregate HWID device-slot usage for the subscription: whether an + HWID limit is active, the limit, how many devices are registered and how + many slots remain. Read-only — it never registers a device, so asking + does not consume a slot. Counters only: no HWID value, email or device + metadata. The path prefix is configured by subPath.' + url: '#return-aggregate-hwid-device-slot-usage-for-the-subscription-whether-an-hwid-limit-is-active-the-limit-how-many-devices-are-registered-and-how-many-slots-remain-read-only--it-never-registers-a-device-so-asking-does-not-consume-a-slot-counters-only-no-hwid-value-email-or-device-metadata-the-path-prefix-is-configured-by-subpath' + - depth: 2 + title: Return the HWID device-slot status code and headers as GET without a + response body. + url: '#return-the-hwid-device-slot-status-code-and-headers-as-get-without-a-response-body' - depth: 2 title: Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. The path @@ -53,6 +64,15 @@ _openapi: - content: Return the same status and subscription metadata headers as GET without a response body. id: return-the-same-status-and-subscription-metadata-headers-as-get-without-a-response-body + - content: 'Return aggregate HWID device-slot usage for the subscription: whether + an HWID limit is active, the limit, how many devices are registered + and how many slots remain. Read-only — it never registers a device, so + asking does not consume a slot. Counters only: no HWID value, email or + device metadata. The path prefix is configured by subPath.' + id: return-aggregate-hwid-device-slot-usage-for-the-subscription-whether-an-hwid-limit-is-active-the-limit-how-many-devices-are-registered-and-how-many-slots-remain-read-only--it-never-registers-a-device-so-asking-does-not-consume-a-slot-counters-only-no-hwid-value-email-or-device-metadata-the-path-prefix-is-configured-by-subpath + - content: Return the HWID device-slot status code and headers as GET without a + response body. + id: return-the-hwid-device-slot-status-code-and-headers-as-get-without-a-response-body - content: Return subscription as a JSON array of proxy configs (one per enabled client). Only when JSON subscription is enabled in settings. The path prefix is configured by subJsonPath. @@ -67,7 +87,12 @@ _openapi: - content: Return the Clash subscription status and metadata headers without a body. Registered only when Clash subscriptions are enabled. id: return-the-clash-subscription-status-and-metadata-headers-without-a-body-registered-only-when-clash-subscriptions-are-enabled - contents: [] + contents: + - content: Responds with the bare HwidSlotStatus object, not the + {success,msg,obj} panel envelope, like the other + subscription-server routes. With no HWID limit configured, + active is false and every counter is 0. + heading: return-aggregate-hwid-device-slot-usage-for-the-subscription-whether-an-hwid-limit-is-active-the-limit-how-many-devices-are-registered-and-how-many-slots-remain-read-only--it-never-registers-a-device-so-asking-does-not-consume-a-slot-counters-only-no-hwid-value-email-or-device-metadata-the-path-prefix-is-configured-by-subpath --- {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} @@ -79,7 +104,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 314a06c21..ce1eabfce 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2492,6 +2492,39 @@ ], "type": "object" }, + "HwidSlotStatus": { + "description": "HwidSlotStatus is the aggregate device-slot view exposed to subscribers:\ncounters only, no hwid value or hash, no email, no device metadata.", + "properties": { + "active": { + "example": true, + "type": "boolean" + }, + "full": { + "example": false, + "type": "boolean" + }, + "limit": { + "example": 2, + "type": "integer" + }, + "registered": { + "example": 1, + "type": "integer" + }, + "remaining": { + "example": 1, + "type": "integer" + } + }, + "required": [ + "active", + "full", + "limit", + "registered", + "remaining" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { @@ -14404,6 +14437,99 @@ } } }, + "/{subPath}{subid}/hwid-status": { + "get": { + "tags": [ + "Subscription Server" + ], + "summary": "Return aggregate HWID device-slot usage for the subscription: whether an HWID limit is active, the limit, how many devices are registered and how many slots remain. Read-only — it never registers a device, so asking does not consume a slot. Counters only: no HWID value, email or device metadata. The path prefix is configured by subPath.", + "operationId": "get_subPath_subid_hwid_status", + "description": "Responds with the bare HwidSlotStatus object, not the {success,msg,obj} panel envelope, like the other subscription-server routes. With no HWID limit configured, active is false and every counter is 0.", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Device-slot counters for the subscription.", + "content": { + "application/json": { + "example": { + "active": true, + "full": false, + "limit": 2, + "registered": 1, + "remaining": 1 + }, + "schema": { + "$ref": "#/components/schemas/HwidSlotStatus" + } + } + } + }, + "404": { + "description": "No enabled client matches the subscription ID. Empty body." + }, + "500": { + "description": "Database lookup failed. Empty body." + } + } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the HWID device-slot status code and headers as GET without a response body.", + "operationId": "head_subPath_subid_hwid_status", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Headers match GET; no response body." + }, + "404": { + "description": "No enabled client matches the subscription ID. Empty body." + }, + "500": { + "description": "Database lookup failed. Empty body." + } + } + } + }, "/{jsonPath}{subid}": { "get": { "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 314a06c21..ce1eabfce 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2492,6 +2492,39 @@ ], "type": "object" }, + "HwidSlotStatus": { + "description": "HwidSlotStatus is the aggregate device-slot view exposed to subscribers:\ncounters only, no hwid value or hash, no email, no device metadata.", + "properties": { + "active": { + "example": true, + "type": "boolean" + }, + "full": { + "example": false, + "type": "boolean" + }, + "limit": { + "example": 2, + "type": "integer" + }, + "registered": { + "example": 1, + "type": "integer" + }, + "remaining": { + "example": 1, + "type": "integer" + } + }, + "required": [ + "active", + "full", + "limit", + "registered", + "remaining" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { @@ -14404,6 +14437,99 @@ } } }, + "/{subPath}{subid}/hwid-status": { + "get": { + "tags": [ + "Subscription Server" + ], + "summary": "Return aggregate HWID device-slot usage for the subscription: whether an HWID limit is active, the limit, how many devices are registered and how many slots remain. Read-only — it never registers a device, so asking does not consume a slot. Counters only: no HWID value, email or device metadata. The path prefix is configured by subPath.", + "operationId": "get_subPath_subid_hwid_status", + "description": "Responds with the bare HwidSlotStatus object, not the {success,msg,obj} panel envelope, like the other subscription-server routes. With no HWID limit configured, active is false and every counter is 0.", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Device-slot counters for the subscription.", + "content": { + "application/json": { + "example": { + "active": true, + "full": false, + "limit": 2, + "registered": 1, + "remaining": 1 + }, + "schema": { + "$ref": "#/components/schemas/HwidSlotStatus" + } + } + } + }, + "404": { + "description": "No enabled client matches the subscription ID. Empty body." + }, + "500": { + "description": "Database lookup failed. Empty body." + } + } + }, + "head": { + "tags": [ + "Subscription Server" + ], + "summary": "Return the HWID device-slot status code and headers as GET without a response body.", + "operationId": "head_subPath_subid_hwid_status", + "parameters": [ + { + "name": "subid", + "in": "path", + "required": true, + "description": "Client subscription ID.", + "schema": { + "type": "string" + } + }, + { + "name": "subPath", + "in": "path", + "required": true, + "description": "", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Headers match GET; no response body." + }, + "404": { + "description": "No enabled client matches the subscription ID. Empty body." + }, + "500": { + "description": "Database lookup failed. Empty body." + } + } + } + }, "/{jsonPath}{subid}": { "get": { "tags": [ diff --git a/frontend/scripts/build-openapi.mjs b/frontend/scripts/build-openapi.mjs index 09651d150..8cd3ff844 100644 --- a/frontend/scripts/build-openapi.mjs +++ b/frontend/scripts/build-openapi.mjs @@ -112,6 +112,21 @@ function paramToOpenApi(p) { return out; } +// A `responses` entry that $refs a generated schema takes its example from the +// Go `example:` tags, the same source responseSchema uses — never hand-written. +function withGeneratedExample(ep, code, res) { + const json = res.content?.['application/json']; + const name = json?.schema?.$ref?.replace('#/components/schemas/', ''); + if (!name) return res; + if (SCHEMAS[name] === undefined || EXAMPLES[name] === undefined) { + throw new Error(`${ep.method} ${ep.path}: ${code} response schema "${name}" is not generated`); + } + return { + ...res, + content: { ...res.content, 'application/json': { example: EXAMPLES[name], ...json } }, + }; +} + function buildOperation(ep, tag) { const op = { tags: [tag], @@ -257,7 +272,9 @@ function buildOperation(ep, tag) { } } if (ep.responses) { - Object.assign(responses, ep.responses); + for (const [code, res] of Object.entries(ep.responses)) { + responses[code] = withGeneratedExample(ep, code, res); + } } else { responses['200'] = { description: 'Successful response', diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index fcd88ee00..a36b4b6a2 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -644,6 +644,13 @@ export const EXAMPLES: Record = { "verifyPeerCertByName": "", "vlessRoute": "" }, + "HwidSlotStatus": { + "active": true, + "full": false, + "limit": 2, + "registered": 1, + "remaining": 1 + }, "Inbound": { "clientStats": [ { diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index cb524291b..d55fe0b27 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2466,6 +2466,39 @@ export const SCHEMAS: Record = { ], "type": "object" }, + "HwidSlotStatus": { + "description": "HwidSlotStatus is the aggregate device-slot view exposed to subscribers:\ncounters only, no hwid value or hash, no email, no device metadata.", + "properties": { + "active": { + "example": true, + "type": "boolean" + }, + "full": { + "example": false, + "type": "boolean" + }, + "limit": { + "example": 2, + "type": "integer" + }, + "registered": { + "example": 1, + "type": "integer" + }, + "remaining": { + "example": 1, + "type": "integer" + } + }, + "required": [ + "active", + "full", + "limit", + "registered", + "remaining" + ], + "type": "object" + }, "Inbound": { "description": "Inbound represents an Xray inbound configuration with traffic statistics and settings.", "properties": { diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 26bb6743b..df6c48ccc 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -570,6 +570,14 @@ export interface HostGroup { vlessRoute: string; } +export interface HwidSlotStatus { + active: boolean; + full: boolean; + limit: number; + registered: number; + remaining: number; +} + export interface Inbound { clientStats: ClientTraffic[]; disableFlow: boolean; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index a6039170e..4b78792f4 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -609,6 +609,15 @@ export const HostGroupSchema = z.object({ }); export type HostGroup = z.infer; +export const HwidSlotStatusSchema = z.object({ + active: z.boolean(), + full: z.boolean(), + limit: z.number().int(), + registered: z.number().int(), + remaining: z.number().int(), +}); +export type HwidSlotStatus = z.infer; + export const InboundSchema = z.object({ clientStats: z.array(z.lazy(() => ClientTrafficSchema)), disableFlow: z.boolean(), diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 465a69bc3..0bda0fe4a 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -183,6 +183,11 @@ const subscriptionHeadResponses = { '500': { description: 'Subscription generation failed.' }, }; +const hwidStatusErrorResponses = { + '404': { description: 'No enabled client matches the subscription ID. Empty body.' }, + '500': { description: 'Database lookup failed. Empty body.' }, +}; + export const sections: readonly Section[] = [ { id: 'authentication', @@ -2618,6 +2623,35 @@ export const sections: readonly Section[] = [ params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }], responses: subscriptionHeadResponses, }, + { + method: 'GET', + path: '/{subPath}:subid/hwid-status', + summary: + 'Return aggregate HWID device-slot usage for the subscription: whether an HWID limit is active, the limit, how many devices are registered and how many slots remain. Read-only — it never registers a device, so asking does not consume a slot. Counters only: no HWID value, email or device metadata. The path prefix is configured by subPath.', + description: + 'Responds with the bare HwidSlotStatus object, not the {success,msg,obj} panel envelope, like the other subscription-server routes. With no HWID limit configured, active is false and every counter is 0.', + params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }], + responses: { + '200': { + description: 'Device-slot counters for the subscription.', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/HwidSlotStatus' } }, + }, + }, + ...hwidStatusErrorResponses, + }, + }, + { + method: 'HEAD', + path: '/{subPath}:subid/hwid-status', + summary: + 'Return the HWID device-slot status code and headers as GET without a response body.', + params: [{ name: 'subid', in: 'path', type: 'string', desc: 'Client subscription ID.' }], + responses: { + '200': { description: 'Headers match GET; no response body.' }, + ...hwidStatusErrorResponses, + }, + }, { method: 'GET', path: '/{jsonPath}:subid', diff --git a/frontend/src/test/openapi-runtime-contracts.test.ts b/frontend/src/test/openapi-runtime-contracts.test.ts index 155f88000..ac2c10d06 100644 --- a/frontend/src/test/openapi-runtime-contracts.test.ts +++ b/frontend/src/test/openapi-runtime-contracts.test.ts @@ -241,4 +241,19 @@ describe('generated OpenAPI runtime contracts', () => { expect(operation('/{jsonPath}{subid}', 'head')).toBeDefined(); expect(operation('/{clashPath}{subid}', 'head')).toBeDefined(); }); + + it('documents the HWID slot status as a bare generated object, not the panel envelope', () => { + const path = '/{subPath}{subid}/hwid-status'; + const json = operation(path, 'get').responses['200'].content?.['application/json']; + expect(json?.schema).toEqual({ $ref: '#/components/schemas/HwidSlotStatus' }); + expect(json?.example).toEqual(EXAMPLES.HwidSlotStatus); + expect(spec.components.schemas.HwidSlotStatus.required).toEqual([ + 'active', + 'full', + 'limit', + 'registered', + 'remaining', + ]); + expect(operation(path, 'head')).toBeDefined(); + }); }); diff --git a/internal/sub/controller.go b/internal/sub/controller.go index 6565d07cb..1df3a33de 100644 --- a/internal/sub/controller.go +++ b/internal/sub/controller.go @@ -311,6 +311,8 @@ func (a *SUBController) initRouter(g *gin.RouterGroup) { gLink := g.Group(a.subPath) gLink.GET(":subid", a.subs) gLink.HEAD(":subid", a.subs) + gLink.GET(":subid/hwid-status", a.hwidStatus) + gLink.HEAD(":subid/hwid-status", a.hwidStatus) if a.jsonEnabled { gJson := g.Group(a.subJsonPath) gJson.GET(":subid", a.subJsons) @@ -707,6 +709,18 @@ func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) { } } +// hwidStatus serves read-only device-slot counters for a subscription. It +// deliberately skips enforceHwid: asking about slots must not consume one. +func (a *SUBController) hwidStatus(c *gin.Context) { + status, found, err := a.clientService.HwidSlotStatusForSubID(c.Param("subid")) + if err != nil || !found { + writeSubError(c, err) + return + } + setNoCacheHeaders(c) + c.JSON(http.StatusOK, status) +} + // setNoCacheHeaders marks a subscription page response as non-cacheable so VPN // clients and browsers always fetch fresh traffic/expiry data. func setNoCacheHeaders(c *gin.Context) { diff --git a/internal/sub/hwid_controller_test.go b/internal/sub/hwid_controller_test.go index 7e7d3892c..1468d4a2a 100644 --- a/internal/sub/hwid_controller_test.go +++ b/internal/sub/hwid_controller_test.go @@ -1,10 +1,12 @@ package sub import ( + "encoding/json" "net/http" "net/http/httptest" "os" "path/filepath" + "reflect" "testing" "github.com/gin-gonic/gin" @@ -144,3 +146,113 @@ func TestSubscriptionHwidGateSkipsHtmlInfoPage(t *testing.T) { t.Fatalf("HTML sub page should not be HWID-gated: %#v", rec.Header()) } } + +// Decoding into a map rather than the service struct keeps the exact field set +// asserted, so an extra field leaking into the response fails the test. +func assertHwidStatus(t *testing.T, rec *httptest.ResponseRecorder, active bool, limit, registered, remaining int, full bool) { + t.Helper() + if rec.Code != http.StatusOK { + t.Fatalf("hwid-status status = %d, body=%q", rec.Code, rec.Body.String()) + } + var got map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode hwid-status body %q: %v", rec.Body.String(), err) + } + want := map[string]any{ + "active": active, + "limit": float64(limit), + "registered": float64(registered), + "remaining": float64(remaining), + "full": full, + } + if len(got) != len(want) { + t.Fatalf("hwid-status fields = %#v, want exactly %#v", got, want) + } + for key, value := range want { + if got[key] != value { + t.Fatalf("hwid-status[%q] = %#v, want %#v (body %#v)", key, got[key], value, got) + } + } +} + +func TestSubscriptionHwidStatusCountsRegisteredDevices(t *testing.T) { + router, subID := initHwidSubRouter(t, 2) + statusPath := "/sub/" + subID + "/hwid-status" + + assertHwidStatus(t, requestSub(t, router, http.MethodGet, statusPath, "", ""), true, 2, 0, 2, false) + + for i, hwid := range []string{"device-one", "device-two"} { + if rec := requestSub(t, router, http.MethodGet, "/sub/"+subID, hwid, ""); rec.Code != http.StatusOK { + t.Fatalf("register %s = %d, want 200", hwid, rec.Code) + } + registered := i + 1 + rec := requestSub(t, router, http.MethodGet, statusPath, "", "") + assertHwidStatus(t, rec, true, 2, registered, 2-registered, registered == 2) + } + + if rec := requestSub(t, router, http.MethodHead, statusPath, "", ""); rec.Code != http.StatusOK { + t.Fatalf("HEAD hwid-status = %d, want 200", rec.Code) + } +} + +// The endpoint must stay SELECT-only: asking about slots while carrying an +// X-HWID header must not spend the slot the caller is asking about. +func TestSubscriptionHwidStatusDoesNotRegisterDevice(t *testing.T) { + router, subID := initHwidSubRouter(t, 1) + + rec := requestSub(t, router, http.MethodGet, "/sub/"+subID+"/hwid-status", "device-probe", "") + assertHwidStatus(t, rec, true, 1, 0, 1, false) + for _, header := range []string{"X-Hwid-Active", "X-Hwid-Limit", "X-Hwid-Not-Supported", "X-Hwid-Max-Devices-Reached"} { + if value := rec.Header().Get(header); value != "" { + t.Fatalf("hwid-status leaked gate header %s = %q", header, value) + } + } + + var count int64 + if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil { + t.Fatalf("count hwids: %v", err) + } + if count != 0 { + t.Fatalf("client_hwids rows after status probe = %d, want 0", count) + } + if rec := requestSub(t, router, http.MethodGet, "/sub/"+subID, "device-probe", ""); rec.Code != http.StatusOK { + t.Fatalf("subscription fetch after probe = %d, want 200", rec.Code) + } +} + +func TestSubscriptionHwidStatusWithoutLimit(t *testing.T) { + router, subID := initHwidSubRouter(t, 0) + + assertHwidStatus(t, requestSub(t, router, http.MethodGet, "/sub/"+subID+"/hwid-status", "", ""), false, 0, 0, 0, false) +} + +// An unknown and a disabled subscription must be indistinguishable, so a +// caller cannot probe which subscription ids exist. +func TestSubscriptionHwidStatusHidesUnknownVersusDisabled(t *testing.T) { + router, subID := initHwidSubRouter(t, 1) + + unknown := requestSub(t, router, http.MethodGet, "/sub/does-not-exist/hwid-status", "", "") + if unknown.Code != http.StatusNotFound { + t.Fatalf("unknown subId status = %d, want 404", unknown.Code) + } + + if err := database.GetDB().Model(&model.ClientRecord{}). + Where("sub_id = ?", subID). + UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable client: %v", err) + } + disabled := requestSub(t, router, http.MethodGet, "/sub/"+subID+"/hwid-status", "", "") + + if disabled.Code != unknown.Code { + t.Fatalf("disabled status = %d, unknown status = %d, want identical", disabled.Code, unknown.Code) + } + if disabled.Body.String() != unknown.Body.String() { + t.Fatalf("disabled body = %q, unknown body = %q, want identical", disabled.Body.String(), unknown.Body.String()) + } + if !reflect.DeepEqual(disabled.Header(), unknown.Header()) { + t.Fatalf("disabled headers = %#v, unknown headers = %#v, want identical", disabled.Header(), unknown.Header()) + } + if disabled.Body.Len() != 0 { + t.Fatalf("404 body = %q, want empty", disabled.Body.String()) + } +} diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go index eb973f5a9..db62d5699 100644 --- a/internal/web/service/client_hwid.go +++ b/internal/web/service/client_hwid.go @@ -31,6 +31,16 @@ type HwidGateResult struct { Registered int } +// HwidSlotStatus is the aggregate device-slot view exposed to subscribers: +// counters only, no hwid value or hash, no email, no device metadata. +type HwidSlotStatus struct { + Active bool `json:"active" example:"true"` + Limit int `json:"limit" example:"2"` + Registered int `json:"registered" example:"1"` + Remaining int `json:"remaining" example:"1"` + Full bool `json:"full" example:"false"` +} + const minHwidLength = 6 type ClientHwidInfo struct { @@ -156,6 +166,45 @@ func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (Hwid return res, err } +// HwidSlotStatusForSubID is SELECT-only: it must never write client_hwids or +// last_seen. Enabled-clients scope mirrors the gate, so limit == limit enforced. +func (s *ClientService) HwidSlotStatusForSubID(subID string) (status HwidSlotStatus, found bool, err error) { + subID = strings.TrimSpace(subID) + if subID == "" { + return status, false, nil + } + + db := database.GetDB() + var enabled int64 + if err := db.Model(&model.ClientRecord{}). + Where("sub_id = ? AND enable = ?", subID, true). + Count(&enabled).Error; err != nil { + return status, false, err + } + if enabled == 0 { + return status, false, nil + } + + limit, err := effectiveHwidLimitForSubID(db, subID) + if err != nil { + return status, false, err + } + if limit <= 0 { + return status, true, nil + } + + var registered int64 + if err := db.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(®istered).Error; err != nil { + return status, false, err + } + status.Active = true + status.Limit = limit + status.Registered = int(registered) + status.Remaining = max(limit-status.Registered, 0) + status.Full = status.Registered >= limit + return status, true, nil +} + func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) { rec, err := s.GetRecordByEmail(nil, email) if err != nil { diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go index 392192d4e..294494e03 100644 --- a/internal/web/service/client_hwid_test.go +++ b/internal/web/service/client_hwid_test.go @@ -217,3 +217,61 @@ func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) { t.Fatalf("missing HWID should be denied: %+v", res) } } + +func TestClientHwidSlotStatus(t *testing.T) { + initClientHwidTestDB(t) + svc := &ClientService{} + db := database.GetDB() + rec := seedHwidClient(t, 1) + + status, found, err := svc.HwidSlotStatusForSubID("no-such-sub") + if err != nil || found || status != (HwidSlotStatus{}) { + t.Fatalf("unknown subId = (%+v, %v, %v), want zero status and found=false", status, found, err) + } + + status, found, err = svc.HwidSlotStatusForSubID(" " + rec.SubID + " ") + if err != nil || !found { + t.Fatalf("padded subId = (%+v, %v, %v), want found=true", status, found, err) + } + if want := (HwidSlotStatus{Active: true, Limit: 1, Remaining: 1}); status != want { + t.Fatalf("empty slots = %+v, want %+v", status, want) + } + + // A shared sub_id takes the highest limit, matching the enforcement gate. + if err := db.Create(&model.ClientRecord{Email: "second@example.com", SubID: rec.SubID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 3}).Error; err != nil { + t.Fatalf("seed second client: %v", err) + } + for _, hwid := range []string{"device-one", "device-two", "device-three"} { + if _, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: hwid}); err != nil { + t.Fatalf("register %s: %v", hwid, err) + } + } + status, found, err = svc.HwidSlotStatusForSubID(rec.SubID) + if err != nil || !found { + t.Fatalf("shared subId = (%+v, %v, %v), want found=true", status, found, err) + } + if want := (HwidSlotStatus{Active: true, Limit: 3, Registered: 3, Full: true}); status != want { + t.Fatalf("full slots = %+v, want %+v", status, want) + } + + // Deleting the highest-limit client drops the effective limit below the + // registered count, and remaining must clamp at zero instead of going negative. + if err := db.Where("email = ?", "second@example.com").Delete(&model.ClientRecord{}).Error; err != nil { + t.Fatalf("delete second client: %v", err) + } + status, _, err = svc.HwidSlotStatusForSubID(rec.SubID) + if err != nil { + t.Fatalf("lowered limit: %v", err) + } + if want := (HwidSlotStatus{Active: true, Limit: 1, Registered: 3, Remaining: 0, Full: true}); status != want { + t.Fatalf("over-limit slots = %+v, want %+v", status, want) + } + + if err := db.Model(&model.ClientRecord{}).Where("sub_id = ?", rec.SubID).UpdateColumn("enable", false).Error; err != nil { + t.Fatalf("disable clients: %v", err) + } + status, found, err = svc.HwidSlotStatusForSubID(rec.SubID) + if err != nil || found || status != (HwidSlotStatus{}) { + t.Fatalf("disabled subId = (%+v, %v, %v), want zero status and found=false", status, found, err) + } +} diff --git a/tools/openapigen/main.go b/tools/openapigen/main.go index 6df8ca04a..79aab848d 100644 --- a/tools/openapigen/main.go +++ b/tools/openapigen/main.go @@ -106,6 +106,7 @@ func run(root, outDir string) error { "GeodataTokenIssue", "AmneziaWGLogs", "PeerActivity", + "HwidSlotStatus", ), }, {