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",
),
},
{