Преглед на файлове

feat(sub): add per-client subscription HWID limits (#5802)

* feat(sub): add per-client subscription HWID limits

* fix(sub): address HWID review on shared subId and bulk create

* fix(sub): store HWID devices by sub_id and drop anchor client workaround

* fix(sub): restore UA auto-detect and HTML page routing in subs()

The cherry-pick of the HWID gate onto main's refactored SUBController
had dropped main's UA-based format auto-detection and sub-page handling
from subs(). Restore those branches, slotting enforceHwid after the
HTML page and before format detection so the gate only applies to
machine-readable subscription bodies.

Also adapt tests to main's options-struct constructor and to the
ClientService.Update signature extended with limitHwid.

* fix(frontend): drop axios from HttpUtil.delete

The bulk-delete rework's committed version still referenced axios,
which this file no longer imports, breaking typecheck in CI. Use the
httpRequest wrapper like the other verbs.

---------

Co-authored-by: Sanaei <[email protected]>
Rouzbeh† преди 6 часа
родител
ревизия
694ad6deae
променени са 45 файла, в които са добавени 1212 реда и са изтрити 50 реда
  1. 105 1
      frontend/public/openapi.json
  2. 1 0
      frontend/src/generated/examples.ts
  3. 4 0
      frontend/src/generated/schemas.ts
  4. 1 0
      frontend/src/generated/types.ts
  5. 1 0
      frontend/src/generated/zod.ts
  6. 1 0
      frontend/src/hooks/useClients.ts
  7. 24 7
      frontend/src/pages/api-docs/endpoints.ts
  8. 11 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  9. 117 3
      frontend/src/pages/clients/ClientFormModal.tsx
  10. 3 0
      frontend/src/schemas/client.ts
  11. 17 0
      frontend/src/utils/index.ts
  12. 62 0
      internal/database/client_hwid_schema_test.go
  13. 1 0
      internal/database/db.go
  14. 1 0
      internal/database/migrate_data.go
  15. 25 0
      internal/database/model/model.go
  16. 45 0
      internal/sub/controller.go
  17. 134 0
      internal/sub/hwid_controller_test.go
  18. 21 3
      internal/web/controller/client.go
  19. 1 1
      internal/web/service/api_scale_postgres_test.go
  20. 30 0
      internal/web/service/client.go
  21. 13 3
      internal/web/service/client_bulk.go
  22. 13 3
      internal/web/service/client_crud.go
  23. 1 1
      internal/web/service/client_group_node_sync_test.go
  24. 271 0
      internal/web/service/client_hwid.go
  25. 172 0
      internal/web/service/client_hwid_test.go
  26. 25 15
      internal/web/service/client_paging.go
  27. 9 1
      internal/web/service/client_portable.go
  28. 1 1
      internal/web/service/client_stat_reuse_test.go
  29. 2 2
      internal/web/service/client_traffic.go
  30. 3 3
      internal/web/service/client_update_enable_test.go
  31. 2 2
      internal/web/service/client_update_no_inbound_test.go
  32. 4 4
      internal/web/service/client_update_rename_test.go
  33. 7 0
      internal/web/translation/ar-EG.json
  34. 7 0
      internal/web/translation/en-US.json
  35. 7 0
      internal/web/translation/es-ES.json
  36. 7 0
      internal/web/translation/fa-IR.json
  37. 7 0
      internal/web/translation/id-ID.json
  38. 7 0
      internal/web/translation/ja-JP.json
  39. 7 0
      internal/web/translation/pt-BR.json
  40. 7 0
      internal/web/translation/ru-RU.json
  41. 7 0
      internal/web/translation/tr-TR.json
  42. 7 0
      internal/web/translation/uk-UA.json
  43. 7 0
      internal/web/translation/vi-VN.json
  44. 7 0
      internal/web/translation/zh-CN.json
  45. 7 0
      internal/web/translation/zh-TW.json

+ 105 - 1
frontend/public/openapi.json

@@ -1225,6 +1225,9 @@
           "keepAlive": {
             "type": "integer"
           },
+          "limitHwid": {
+            "type": "integer"
+          },
           "limitIp": {
             "type": "integer"
           },
@@ -1282,6 +1285,7 @@
           "group",
           "id",
           "keepAlive",
+          "limitHwid",
           "limitIp",
           "password",
           "preSharedKey",
@@ -5813,6 +5817,7 @@
                         "totalGB": 53687091200,
                         "expiryTime": 1735689600000,
                         "limitIp": 0,
+                        "limitHwid": 0,
                         "reset": 0,
                         "inboundIds": [
                           3,
@@ -5958,6 +5963,7 @@
                   "expiryTime": 1735689600000,
                   "tgId": 0,
                   "limitIp": 0,
+                  "limitHwid": 0,
                   "enable": true
                 },
                 "inboundIds": [
@@ -6024,6 +6030,7 @@
                 "email": "[email protected]",
                 "totalGB": 107374182400,
                 "expiryTime": 1767225600000,
+                "limitHwid": 2,
                 "tgId": 123456789,
                 "enable": true
               }
@@ -6372,7 +6379,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
+        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
         "operationId": "post_panel_api_clients_delOrphans",
         "responses": {
           "200": {
@@ -6436,6 +6443,7 @@
                         "id": "...",
                         "totalGB": 53687091200,
                         "expiryTime": 0,
+                        "limitHwid": 2,
                         "enable": true,
                         "subId": "..."
                       },
@@ -6763,6 +6771,7 @@
                     "email": "[email protected]",
                     "totalGB": 53687091200,
                     "expiryTime": 0,
+                    "limitHwid": 2,
                     "enable": true
                   },
                   "inboundIds": [
@@ -6774,6 +6783,7 @@
                     "email": "[email protected]",
                     "totalGB": 53687091200,
                     "expiryTime": 0,
+                    "limitHwid": 0,
                     "enable": true
                   },
                   "inboundIds": [
@@ -7568,6 +7578,100 @@
         }
       }
     },
+    "/panel/api/clients/hwids/{email}": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "List registered HWID devices for a client. Hashes are not exposed.",
+        "operationId": "post_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "id": 1,
+                      "firstSeen": 1735000000000,
+                      "lastSeen": 1735100000000,
+                      "userAgent": "Happ/1.0",
+                      "deviceOs": "android",
+                      "osVersion": "15",
+                      "deviceModel": "Pixel 9"
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      },
+      "delete": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Clear all registered HWID devices for a client so new devices can register again.",
+        "operationId": "delete_panel_api_clients_hwids_email",
+        "parameters": [
+          {
+            "name": "email",
+            "in": "path",
+            "required": true,
+            "description": "Client email.",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/onlines": {
       "post": {
         "tags": [

+ 1 - 0
frontend/src/generated/examples.ts

@@ -283,6 +283,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "group": "",
     "id": 0,
     "keepAlive": 0,
+    "limitHwid": 0,
     "limitIp": 0,
     "password": "",
     "preSharedKey": "",

+ 4 - 0
frontend/src/generated/schemas.ts

@@ -1199,6 +1199,9 @@ export const SCHEMAS: Record<string, unknown> = {
       "keepAlive": {
         "type": "integer"
       },
+      "limitHwid": {
+        "type": "integer"
+      },
       "limitIp": {
         "type": "integer"
       },
@@ -1256,6 +1259,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "group",
       "id",
       "keepAlive",
+      "limitHwid",
       "limitIp",
       "password",
       "preSharedKey",

+ 1 - 0
frontend/src/generated/types.ts

@@ -294,6 +294,7 @@ export interface ClientRecord {
   group: string;
   id: number;
   keepAlive: number;
+  limitHwid: number;
   limitIp: number;
   password: string;
   preSharedKey: string;

+ 1 - 0
frontend/src/generated/zod.ts

@@ -314,6 +314,7 @@ export const ClientRecordSchema = z.object({
   group: z.string(),
   id: z.number().int(),
   keepAlive: z.number().int(),
+  limitHwid: z.number().int(),
   limitIp: z.number().int(),
   password: z.string(),
   preSharedKey: z.string(),

+ 1 - 0
frontend/src/hooks/useClients.ts

@@ -529,6 +529,7 @@ export function useClients(options: UseClientsOptions = {}) {
       totalGB: base.totalGB || 0,
       expiryTime: base.expiryTime || 0,
       limitIp: base.limitIp || 0,
+      limitHwid: base.limitHwid || 0,
       tgId: Number(base.tgId) || 0,
       reset: Number(base.reset) || 0,
       group: base.group || '',

+ 24 - 7
frontend/src/pages/api-docs/endpoints.ts

@@ -575,7 +575,7 @@ export const sections: readonly Section[] = [
           { name: 'order', in: 'query', type: 'string', desc: 'ascend or descend.' },
         ],
         response:
-          '{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
+'{\n  "success": true,\n  "obj": {\n    "items": [\n      {\n        "email": "[email protected]",\n        "subId": "abcd1234",\n        "enable": true,\n        "totalGB": 53687091200,\n        "expiryTime": 1735689600000,\n        "limitIp": 0,\n        "limitHwid": 0,\n        "reset": 0,\n        "inboundIds": [3, 5],\n        "traffic": { "up": 1024, "down": 4096, "enable": true },\n        "createdAt": 1735000000000,\n        "updatedAt": 1735100000000\n      }\n    ],\n    "total": 2000,\n    "filtered": 47,\n    "page": 1,\n    "pageSize": 25,\n    "summary": {\n      "total": 2000,\n      "active": 1850,\n      "onlineCount": 1,\n      "depletedCount": 0,\n      "expiringCount": 0,\n      "deactiveCount": 150,\n      "online": ["[email protected]"],\n      "depleted": [],\n      "expiring": [],\n      "deactive": ["[email protected]"]\n    }\n  }\n}',
       },
       {
         method: 'GET',
@@ -602,10 +602,10 @@ export const sections: readonly Section[] = [
         path: '/panel/api/clients/add',
         summary: 'Create a new client and attach it to one or more inbounds in a single call. Body is JSON. Per-protocol secrets (UUID for VLESS/VMess, password for Trojan/Shadowsocks, auth for Hysteria) are generated server-side when omitted, so callers can send only the universal fields.',
         params: [
-          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
+          { name: 'client', in: 'body (json)', type: 'object', desc: 'Client fields: email, subId, id (uuid), password, auth, flow, totalGB, expiryTime, limitIp, limitHwid, tgId (numeric Telegram user ID, 0 = none), comment, enable.' },
           { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to attach the client to. At least one required.' },
         ],
-        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
+        body: '{\n  "client": {\n    "email": "[email protected]",\n    "totalGB": 53687091200,\n    "expiryTime": 1735689600000,\n    "tgId": 0,\n    "limitIp": 0,\n    "limitHwid": 0,\n    "enable": true\n  },\n  "inboundIds": [3, 5]\n}',
         response: '{\n  "success": true,\n  "msg": "Client added"\n}',
       },
       {
@@ -615,7 +615,7 @@ export const sections: readonly Section[] = [
         params: [
           { name: 'email', in: 'path', type: 'string', desc: 'Current client email (unique identifier).' },
         ],
-        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "tgId": 123456789,\n  "enable": true\n}',
+        body: '{\n  "email": "[email protected]",\n  "totalGB": 107374182400,\n  "expiryTime": 1767225600000,\n  "limitHwid": 2,\n  "tgId": 123456789,\n  "enable": true\n}',
         response: '{\n  "success": true,\n  "msg": "Client updated"\n}',
       },
       {
@@ -676,14 +676,14 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/delOrphans',
-        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
+        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, HWID devices, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
       },
       {
         method: 'GET',
         path: '/panel/api/clients/export',
         summary: 'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "limitHwid": 2,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
       },
       {
         method: 'POST',
@@ -724,7 +724,7 @@ export const sections: readonly Section[] = [
         method: 'POST',
         path: '/panel/api/clients/bulkCreate',
         summary: 'Create many clients in one call. Body is a JSON array of {client, inboundIds} payloads — the same shape /add accepts. Items are processed sequentially; per-email skip reasons are returned for items that fail (e.g., duplicate email). Triggers a single Xray restart at the end if any inbound was running.',
-        body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
+        body: '[\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "limitHwid": 2,\n      "enable": true\n    },\n    "inboundIds": [7]\n  },\n  {\n    "client": {\n      "email": "[email protected]",\n      "totalGB": 53687091200,\n      "expiryTime": 0,\n      "limitHwid": 0,\n      "enable": true\n    },\n    "inboundIds": [7, 9]\n  }\n]',
         response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use" }\n    ]\n  }\n}',
       },
       {
@@ -846,6 +846,23 @@ export const sections: readonly Section[] = [
           { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
         ],
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/hwids/:email',
+        summary: 'List registered HWID devices for a client. Hashes are not exposed.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "firstSeen": 1735000000000,\n      "lastSeen": 1735100000000,\n      "userAgent": "Happ/1.0",\n      "deviceOs": "android",\n      "osVersion": "15",\n      "deviceModel": "Pixel 9"\n    }\n  ]\n}',
+      },
+      {
+        method: 'DELETE',
+        path: '/panel/api/clients/hwids/:email',
+        summary: 'Clear all registered HWID devices for a client so new devices can register again.',
+        params: [
+          { name: 'email', in: 'path', type: 'string', desc: 'Client email.' },
+        ],
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/onlines',

+ 11 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -33,6 +33,7 @@ const EMPTY: ClientBulkAddFormValues = {
   comment: '',
   flow: '',
   limitIp: 0,
+  limitHwid: 0,
   totalGB: 0,
   expiryTime: 0,
   reset: 0,
@@ -176,6 +177,7 @@ export default function ClientBulkAddModal({
           expiryTime: current.expiryTime,
           reset: Number(current.reset) || 0,
           limitIp: Number(current.limitIp) || 0,
+          limitHwid: Number(current.limitHwid) || 0,
           group: current.group,
           comment: current.comment,
           enable: true,
@@ -301,6 +303,15 @@ export default function ClientBulkAddModal({
               />
             </FormField>
 
+            <FormField
+              name="limitHwid"
+              label={t('pages.clients.limitHwid')}
+              tooltip={t('pages.clients.limitHwidDesc')}
+              transform={{ output: (v) => Number(v) || 0 }}
+            >
+              <InputNumber min={0} />
+            </FormField>
+
             <FormField name="comment" label={t('comment')}>
               <Input />
             </FormField>

+ 117 - 3
frontend/src/pages/clients/ClientFormModal.tsx

@@ -57,6 +57,16 @@ interface ApiMsg<T = unknown> {
   obj?: T;
 }
 
+interface ClientHwidInfo {
+  id: number;
+  firstSeen: number;
+  lastSeen: number;
+  userAgent: string;
+  deviceOs: string;
+  osVersion: string;
+  deviceModel: string;
+}
+
 type Mode = 'add' | 'edit';
 
 interface SaveMetaEdit {
@@ -97,6 +107,7 @@ interface ClientFormModalProps {
 
 type Values = ClientFormValues & {
   expiryDate: number;
+  limitHwid: number;
   externalLinks: ExternalLinkRow[];
   wgPrivateKey: string;
   wgPublicKey: string;
@@ -121,6 +132,7 @@ const EMPTY: Values = {
   delayedDays: 0,
   reset: 0,
   limitIp: 0,
+  limitHwid: 0,
   tgId: 0,
   group: '',
   comment: '',
@@ -189,6 +201,7 @@ export default function ClientFormModal({
   const uuid = useWatch({ control: methods.control, name: 'uuid' });
   const password = useWatch({ control: methods.control, name: 'password' });
   const subId = useWatch({ control: methods.control, name: 'subId' });
+  const limitHwid = useWatch({ control: methods.control, name: 'limitHwid' });
   const auth = useWatch({ control: methods.control, name: 'auth' });
   const wgPrivateKey = useWatch({ control: methods.control, name: 'wgPrivateKey' });
   const limitIp = useWatch({ control: methods.control, name: 'limitIp' });
@@ -204,6 +217,10 @@ export default function ClientFormModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const [clientHwids, setClientHwids] = useState<ClientHwidInfo[]>([]);
+  const [hwidsLoading, setHwidsLoading] = useState(false);
+  const [hwidsClearing, setHwidsClearing] = useState(false);
+  const [hwidsModalOpen, setHwidsModalOpen] = useState(false);
   const fail2ban = useFail2banStatusQuery();
   const limitIpDisabled = !fail2ban.usable;
   const limitIpNotice = getLimitIpNotice(fail2ban, t);
@@ -215,6 +232,7 @@ export default function ClientFormModal({
   useEffect(() => {
     if (!open) return;
     setIpsModalOpen(false);
+    setHwidsModalOpen(false);
 
     if (isEdit && client) {
       const et = Number(client.expiryTime) || 0;
@@ -233,6 +251,7 @@ export default function ClientFormModal({
         totalGB: bytesToGB(client.totalGB || 0),
         reset: Number(client.reset) || 0,
         limitIp: client.limitIp || 0,
+        limitHwid: client.limitHwid || 0,
         tgId: Number(client.tgId) || 0,
         group: client.group || '',
         comment: client.comment || '',
@@ -257,6 +276,7 @@ export default function ClientFormModal({
       }
       methods.reset(seed);
       void loadIps();
+      void loadHwids();
     } else {
       const wgKeypair = Wireguard.generateKeypair();
       methods.reset({
@@ -455,6 +475,34 @@ export default function ClientFormModal({
     }
   }
 
+  async function loadHwids() {
+    if (!isEdit || !client?.email) return;
+    setHwidsLoading(true);
+    try {
+      const msg = await HttpUtil.post(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg<unknown[]>;
+      if (!msg?.success || !Array.isArray(msg.obj)) { setClientHwids([]); return; }
+      setClientHwids(msg.obj.filter((x): x is ClientHwidInfo => !!x && typeof x === 'object' && typeof (x as ClientHwidInfo).id === 'number'));
+    } finally {
+      setHwidsLoading(false);
+    }
+  }
+
+  function openHwidsModal() {
+    setHwidsModalOpen(true);
+    if (clientHwids.length === 0) void loadHwids();
+  }
+
+  async function clearHwids() {
+    if (!isEdit || !client?.email) return;
+    setHwidsClearing(true);
+    try {
+      const msg = await HttpUtil.delete(`/panel/api/clients/hwids/${encodeURIComponent(client.email)}`) as ApiMsg;
+      if (msg?.success) setClientHwids([]);
+    } finally {
+      setHwidsClearing(false);
+    }
+  }
+
   function close() {
     onOpenChange(false);
   }
@@ -478,7 +526,7 @@ export default function ClientFormModal({
     const values = methods.getValues();
     const schema = isEdit ? ClientFormSchema : ClientCreateFormSchema;
     const validated = schema.safeParse({
-      email: values.email,
+email: values.email,
       subId: values.subId,
       uuid: values.uuid,
       password: values.password,
@@ -491,6 +539,7 @@ export default function ClientFormModal({
       delayedDays: values.delayedDays,
       reset: values.reset,
       limitIp: values.limitIp,
+      limitHwid: values.limitHwid,
       tgId: values.tgId,
       group: values.group,
       comment: values.comment,
@@ -516,8 +565,9 @@ export default function ClientFormModal({
       security: showSecurity ? (values.security || 'auto') : 'auto',
       totalGB: totalBytes,
       expiryTime,
-      reset: Number(values.reset) || 0,
+reset: Number(values.reset) || 0,
       limitIp: Number(values.limitIp) || 0,
+      limitHwid: Number(values.limitHwid) || 0,
       tgId: Number(values.tgId) || 0,
       group: values.group,
       comment: values.comment,
@@ -621,7 +671,7 @@ export default function ClientFormModal({
           </div>
         }
       >
-        <FormProvider {...methods}>
+<FormProvider {...methods}>
           <Form layout="vertical">
             <Tabs
               defaultActiveKey="basic"
@@ -677,6 +727,21 @@ export default function ClientFormModal({
                             </Tooltip>
                           </Form.Item>
                         </Col>
+                        <Col xs={24} md={6}>
+                          <Form.Item label={t('pages.clients.limitHwid')} tooltip={t('pages.clients.limitHwidDesc')}>
+                            <Space.Compact style={{ display: 'flex' }}>
+                              <InputNumber value={limitHwid} min={0} style={{ flex: 1 }}
+                                onChange={(v) => methods.setValue('limitHwid', Number(v) || 0)} />
+                              {isEdit && (
+                                <Tooltip title={t('pages.clients.hwidLog')}>
+                                  <Button aria-label={t('pages.clients.hwidLog')} icon={<EyeOutlined />} loading={hwidsLoading} onClick={openHwidsModal}>
+                                    {clientHwids.length > 0 ? clientHwids.length : ''}
+                                  </Button>
+                                </Tooltip>
+                              )}
+                            </Space.Compact>
+                          </Form.Item>
+                        </Col>
                       </Row>
 
                       <Row gutter={16}>
@@ -1012,6 +1077,55 @@ export default function ClientFormModal({
           <Tag>{t('tgbot.noIpRecord')}</Tag>
         )}
       </Modal>
+
+      <Modal
+        open={hwidsModalOpen}
+        title={`${t('pages.clients.hwidLog')}${client?.email ? ` — ${client.email}` : ''}`}
+        width={520}
+        zIndex={CLIENT_IP_LOG_MODAL_Z_INDEX}
+        onCancel={() => setHwidsModalOpen(false)}
+        footer={[
+          <Button key="refresh" icon={<ReloadOutlined />} loading={hwidsLoading} onClick={loadHwids}>
+            {t('refresh')}
+          </Button>,
+          <Button key="clear" danger loading={hwidsClearing} disabled={clientHwids.length === 0} onClick={clearHwids}>
+            {t('pages.clients.clearAll')}
+          </Button>,
+          <Button key="close" type="primary" onClick={() => setHwidsModalOpen(false)}>
+            {t('close')}
+          </Button>,
+        ]}
+      >
+        {clientHwids.length > 0 ? (
+          <div style={{ maxHeight: 360, overflowY: 'auto' }}>
+            {clientHwids.map((entry) => (
+              <div key={entry.id} style={{ borderBottom: '1px solid var(--ant-color-border-secondary)', padding: '8px 0' }}>
+                <Typography.Text strong>{entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')}</Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.firstSeen')}: {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'}
+                </Typography.Text>
+                <br />
+                <Typography.Text type="secondary">
+                  {t('pages.clients.lastSeen')}: {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'}
+                </Typography.Text>
+                {entry.userAgent && (
+                  <>
+                    <br />
+                    <Typography.Text type="secondary" style={{ wordBreak: 'break-all' }}>{entry.userAgent}</Typography.Text>
+                  </>
+                )}
+              </div>
+            ))}
+          </div>
+        ) : (
+          <Tag>{t('pages.clients.noHwids')}</Tag>
+        )}
+      </Modal>
     </>
   );
 }

+ 3 - 0
frontend/src/schemas/client.ts

@@ -25,6 +25,7 @@ export const ClientRecordSchema = z.object({
   totalGB: z.number().optional(),
   expiryTime: z.number().optional(),
   limitIp: z.number().optional(),
+  limitHwid: z.number().optional(),
   tgId: z.union([z.number(), z.string()]).optional(),
   group: z.string().optional(),
   comment: z.string().optional(),
@@ -205,6 +206,7 @@ export const ClientFormSchema = z.object({
   delayedDays: z.number().int().min(0),
   reset: z.number().int().min(0),
   limitIp: z.number().int().min(0),
+  limitHwid: z.number().int().min(0),
   tgId: z.number().int().min(0),
   group: z.string(),
   comment: z.string(),
@@ -238,6 +240,7 @@ export const ClientBulkAddFormSchema = z.object({
   comment: z.string(),
   flow: z.string(),
   limitIp: z.number().int().min(0),
+  limitHwid: z.number().int().min(0),
   totalGB: z.number().min(0),
   expiryTime: z.number(),
   reset: z.number().int().min(0),

+ 17 - 0
frontend/src/utils/index.ts

@@ -105,6 +105,23 @@ export class HttpUtil {
     }
   }
 
+  static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
+    const { silent, silentSuccess, ...rest } = options;
+    try {
+      const resp = await httpRequest('DELETE', url, undefined, rest);
+      const msg = this._respToMsg(resp) as Msg<T>;
+      if (!silent) this._handleMsg(msg, silentSuccess);
+      return msg;
+    } catch (error) {
+      console.error('DELETE request failed:', error);
+      const err = error as { response?: { data?: { msg?: string; message?: string } }; message?: string };
+      const data = err.response?.data;
+      const errorMsg = new Msg<T>(false, data?.msg || data?.message || err.message || 'Request failed');
+      if (!silent) this._handleMsg(errorMsg);
+      return errorMsg;
+    }
+  }
+
   static async postWithModal<T = unknown>(url: string, data?: unknown, modal?: HttpModal | null): Promise<Msg<T>> {
     if (modal) {
       modal.loading(true);

+ 62 - 0
internal/database/client_hwid_schema_test.go

@@ -0,0 +1,62 @@
+package database
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/driver/postgres"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+)
+
+func assertClientHwidSchema(t *testing.T, db *gorm.DB) {
+	t.Helper()
+	if !db.Migrator().HasColumn(&model.ClientRecord{}, "limit_hwid") {
+		t.Fatalf("clients.limit_hwid missing")
+	}
+	if !db.Migrator().HasTable(&model.ClientHwid{}) {
+		t.Fatalf("client_hwids table missing")
+	}
+	for _, col := range []string{"sub_id", "hwid_hash", "first_seen", "last_seen", "user_agent", "device_os", "os_version", "device_model"} {
+		if !db.Migrator().HasColumn(&model.ClientHwid{}, col) {
+			t.Fatalf("client_hwids.%s missing", col)
+		}
+	}
+	if !db.Migrator().HasIndex(&model.ClientHwid{}, "idx_client_hwids_sub_hash") {
+		t.Fatalf("client_hwids unique hash index missing")
+	}
+}
+
+func TestClientHwidSchemaSQLite(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	assertClientHwidSchema(t, GetDB())
+}
+
+func TestClientHwidSchemaPostgres(t *testing.T) {
+	dsn := strings.TrimSpace(os.Getenv("XUI_TEST_PG_DSN"))
+	if dsn == "" {
+		t.Skip("set XUI_TEST_PG_DSN to a reachable Postgres to run this test")
+	}
+	db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Discard})
+	if err != nil {
+		t.Fatalf("open postgres: %v", err)
+	}
+	sqlDB, err := db.DB()
+	if err != nil {
+		t.Fatalf("postgres db handle: %v", err)
+	}
+	t.Cleanup(func() { _ = sqlDB.Close() })
+	if err := db.AutoMigrate(&model.ClientRecord{}, &model.ClientHwid{}); err != nil {
+		t.Fatalf("automigrate postgres: %v", err)
+	}
+	assertClientHwidSchema(t, db)
+}

+ 1 - 0
internal/database/db.go

@@ -75,6 +75,7 @@ func allModels() []any {
 		&model.ApiToken{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientHwid{},
 		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},

+ 1 - 0
internal/database/migrate_data.go

@@ -48,6 +48,7 @@ func migrationModels() []any {
 		&model.InboundClientIps{},
 		&model.ClientRecord{},
 		&model.ClientInbound{},
+		&model.ClientHwid{},
 		&model.ClientExternalLink{},
 		&model.ClientGroup{},
 		&model.InboundFallback{},

+ 25 - 0
internal/database/model/model.go

@@ -914,6 +914,7 @@ type ClientRecord struct {
 	Secret       string `json:"secret" gorm:"column:secret"`
 	AdTag        string `json:"adTag" gorm:"column:ad_tag;default:''"`
 	LimitIP      int    `json:"limitIp" gorm:"column:limit_ip"`
+	LimitHwid    int    `json:"limitHwid" gorm:"column:limit_hwid;default:0"`
 	TotalGB      int64  `json:"totalGB" gorm:"column:total_gb"`
 	ExpiryTime   int64  `json:"expiryTime" gorm:"column:expiry_time"`
 	Enable       bool   `json:"enable" gorm:"default:true"`
@@ -981,6 +982,20 @@ type ClientInbound struct {
 
 func (ClientInbound) TableName() string { return "client_inbounds" }
 
+type ClientHwid struct {
+	Id          int    `json:"id" gorm:"primaryKey;autoIncrement"`
+	SubID       string `json:"subId" gorm:"column:sub_id;not null;index;uniqueIndex:idx_client_hwids_sub_hash,priority:1"`
+	HwidHash    string `json:"-" gorm:"column:hwid_hash;size:64;not null;uniqueIndex:idx_client_hwids_sub_hash,priority:2"`
+	FirstSeen   int64  `json:"firstSeen" gorm:"column:first_seen;not null"`
+	LastSeen    int64  `json:"lastSeen" gorm:"column:last_seen;not null;index"`
+	UserAgent   string `json:"userAgent" gorm:"column:user_agent"`
+	DeviceOS    string `json:"deviceOs" gorm:"column:device_os"`
+	OsVersion   string `json:"osVersion" gorm:"column:os_version"`
+	DeviceModel string `json:"deviceModel" gorm:"column:device_model"`
+}
+
+func (ClientHwid) TableName() string { return "client_hwids" }
+
 // ClientExternalLink is a per-client entry surfaced in the client's
 // subscription. Two kinds:
 //   - "link": a single third-party share link (vless://, vmess://, trojan://,
@@ -1267,6 +1282,16 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM
 			existing.LimitIP = picked
 		}
 	}
+	if existing.LimitHwid != incoming.LimitHwid && incoming.LimitHwid != 0 {
+		picked := existing.LimitHwid
+		if existing.LimitHwid == 0 || incoming.LimitHwid > existing.LimitHwid {
+			picked = incoming.LimitHwid
+		}
+		if picked != existing.LimitHwid {
+			keep("limitHwid", existing.LimitHwid, incoming.LimitHwid, picked)
+			existing.LimitHwid = picked
+		}
+	}
 	if existing.TgID != incoming.TgID && incoming.TgID != 0 {
 		if incomingNewer || existing.TgID == 0 {
 			keep("tgId", existing.TgID, incoming.TgID, incoming.TgID)

+ 45 - 0
internal/sub/controller.go

@@ -72,6 +72,7 @@ type SUBController struct {
 	subService      *SubService
 	subJsonService  *SubJsonService
 	subClashService *SubClashService
+	clientService   service.ClientService
 	settingService  service.SettingService
 
 	subTemplateMu    sync.RWMutex
@@ -384,6 +385,9 @@ func (a *SUBController) subs(c *gin.Context) {
 		logSubscriptionRoute(userAgent, "html")
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	if shouldAutoServeClash(a.subClashAutoDetect, a.clashEnabled, false, userAgent, a.clashUserAgent) && a.serveClashBody(c, false) {
 		a.recordSubscriptionFetch(c)
 		logSubscriptionRoute(userAgent, "clash")
@@ -605,6 +609,41 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
 	}
 }
 
+func (a *SUBController) enforceHwid(c *gin.Context) bool {
+	result, err := a.clientService.EnforceHwidForSubID(c.Param("subid"), service.HwidRequest{
+		Hwid:        c.GetHeader("X-HWID"),
+		UserAgent:   c.GetHeader("User-Agent"),
+		DeviceOS:    c.GetHeader("X-Device-OS"),
+		OsVersion:   c.GetHeader("X-Ver-OS"),
+		DeviceModel: c.GetHeader("X-Device-Model"),
+	})
+	if err != nil {
+		writeSubError(c, err)
+		return false
+	}
+	applyHwidHeaders(c, result)
+	if !result.Allowed {
+		c.Status(http.StatusNotFound)
+		return false
+	}
+	return true
+}
+
+func applyHwidHeaders(c *gin.Context, result service.HwidGateResult) {
+	if result.Active {
+		c.Header("X-Hwid-Active", "true")
+	}
+	if result.NotSupported {
+		c.Header("X-Hwid-Not-Supported", "true")
+	}
+	if result.LimitReached {
+		c.Header("X-Hwid-Limit", "true")
+	}
+	if result.MaxDevicesReached {
+		c.Header("X-Hwid-Max-Devices-Reached", "true")
+	}
+}
+
 // 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) {
@@ -668,6 +707,9 @@ func (a *SUBController) subJsons(c *gin.Context) {
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	a.serveJson(c, a.jsonAlwaysArray, "text/plain; charset=utf-8")
 }
 
@@ -713,6 +755,9 @@ func (a *SUBController) subClashs(c *gin.Context) {
 	if a.maybeServeSubPage(c) {
 		return
 	}
+	if !a.enforceHwid(c) {
+		return
+	}
 	if !a.serveClashBody(c, false) {
 		writeSubError(c, nil)
 	}

+ 134 - 0
internal/sub/hwid_controller_test.go

@@ -0,0 +1,134 @@
+package sub
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func initHwidSubRouter(t *testing.T, limit int) (*gin.Engine, string) {
+	t.Helper()
+	tmp := t.TempDir()
+	t.Chdir(tmp)
+	if err := os.MkdirAll("internal/web/dist", 0o755); err != nil {
+		t.Fatalf("mkdir dist: %v", err)
+	}
+	if err := os.WriteFile("internal/web/dist/subpage.html", []byte("<html><head></head><body></body></html>"), 0o644); err != nil {
+		t.Fatalf("write subpage: %v", err)
+	}
+
+	t.Setenv("XUI_DB_FOLDER", tmp)
+	if err := database.InitDB(filepath.Join(tmp, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	const subID = "sub-hwid-route"
+	const email = "[email protected]"
+	const uuid = "11111111-2222-4333-8444-555555555555"
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId:         1,
+		Tag:            "hwid-sub",
+		Enable:         true,
+		Port:           443,
+		Protocol:       model.VLESS,
+		Settings:       `{"clients":[]}`,
+		StreamSettings: `{"network":"tcp","security":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	client := &model.ClientRecord{Email: email, SubID: subID, UUID: uuid, Enable: true, LimitHwid: limit}
+	if err := db.Create(client).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client inbound: %v", err)
+	}
+
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	NewSUBController(
+		router.Group("/"),
+		WithSUBPath("/sub/"),
+		WithSUBJsonPath("/json/"),
+		WithSUBClashPath("/clash/"),
+		WithSUBClashAutoDetect(true),
+		WithSUBJsonAutoDetect(true),
+		WithSUBJsonEnabled(true),
+		WithSUBClashEnabled(true),
+	)
+	return router, subID
+}
+
+func requestSub(t *testing.T, router *gin.Engine, method string, path string, hwid string, accept string) *httptest.ResponseRecorder {
+	t.Helper()
+	req := httptest.NewRequest(method, path, nil)
+	req.Host = "sub.example.com"
+	if hwid != "" {
+		req.Header.Set("X-HWID", hwid)
+	}
+	if accept != "" {
+		req.Header.Set("Accept", accept)
+	}
+	rec := httptest.NewRecorder()
+	router.ServeHTTP(rec, req)
+	return rec
+}
+
+func TestSubscriptionHwidGateAcrossBodyRoutes(t *testing.T) {
+	router, subID := initHwidSubRouter(t, 1)
+
+	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+		rec := requestSub(t, router, http.MethodGet, path, "", "")
+		if rec.Code != http.StatusNotFound {
+			t.Fatalf("%s missing HWID status = %d, want 404", path, rec.Code)
+		}
+		if rec.Header().Get("X-Hwid-Active") != "true" || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
+			t.Fatalf("%s missing HWID headers = %#v", path, rec.Header())
+		}
+	}
+
+	rec := requestSub(t, router, http.MethodHead, "/sub/"+subID, "", "")
+	if rec.Code != http.StatusNotFound || rec.Header().Get("X-Hwid-Not-Supported") != "true" {
+		t.Fatalf("HEAD missing HWID = %d %#v", rec.Code, rec.Header())
+	}
+
+	for _, path := range []string{"/sub/" + subID, "/json/" + subID, "/clash/" + subID} {
+		rec = requestSub(t, router, http.MethodGet, path, "device-one", "")
+		if rec.Code != http.StatusOK {
+			t.Fatalf("%s registered HWID status = %d, body=%q", path, rec.Code, rec.Body.String())
+		}
+		if rec.Header().Get("X-Hwid-Active") != "true" {
+			t.Fatalf("%s allowed response missing active HWID header", path)
+		}
+	}
+
+	rec = requestSub(t, router, http.MethodGet, "/json/"+subID, "device-two", "")
+	if rec.Code != http.StatusNotFound {
+		t.Fatalf("new HWID after limit status = %d, want 404", rec.Code)
+	}
+	if rec.Header().Get("X-Hwid-Max-Devices-Reached") != "true" || rec.Header().Get("X-Hwid-Limit") != "true" {
+		t.Fatalf("limit headers missing: %#v", rec.Header())
+	}
+}
+
+func TestSubscriptionHwidGateSkipsHtmlInfoPage(t *testing.T) {
+	router, subID := initHwidSubRouter(t, 1)
+
+	rec := requestSub(t, router, http.MethodGet, "/sub/"+subID, "", "text/html")
+	if rec.Code != http.StatusOK {
+		t.Fatalf("HTML sub page status = %d, want 200, body=%q", rec.Code, rec.Body.String())
+	}
+	if rec.Header().Get("X-Hwid-Not-Supported") != "" {
+		t.Fatalf("HTML sub page should not be HWID-gated: %#v", rec.Header())
+	}
+}

+ 21 - 3
internal/web/controller/client.go

@@ -76,6 +76,8 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
 	g.POST("/clearIps/:email", a.clearIps)
+	g.POST("/hwids/:email", a.getHwids)
+	g.DELETE("/hwids/:email", a.clearHwids)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlinesByGuid", a.onlinesByGuid)
 	g.POST("/clientIpsByGuid", a.clientIpsByGuid)
@@ -191,13 +193,16 @@ func (a *ClientController) create(c *gin.Context) {
 
 func (a *ClientController) update(c *gin.Context) {
 	email := c.Param("email")
-	var updated model.Client
-	if err := c.ShouldBindJSON(&updated); err != nil {
+	var req struct {
+		model.Client
+		LimitHwid int `json:"limitHwid"`
+	}
+	if err := c.ShouldBindJSON(&req); err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 	}
 	inboundFilter := parseInboundIdsQuery(c.Query("inboundIds"))
-	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, updated, inboundFilter...)
+	needRestart, err := a.clientService.UpdateByEmail(&a.inboundService, email, req.Client, req.LimitHwid, inboundFilter...)
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
@@ -540,6 +545,19 @@ func (a *ClientController) clearIps(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
 }
 
+func (a *ClientController) getHwids(c *gin.Context) {
+	infos, err := a.clientService.ListClientHwids(c.Param("email"))
+	jsonObj(c, infos, err)
+}
+
+func (a *ClientController) clearHwids(c *gin.Context) {
+	if err := a.clientService.ClearClientHwids(c.Param("email")); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.updateSuccess"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.logCleanSuccess"), nil)
+}
+
 func (a *ClientController) onlines(c *gin.Context) {
 	jsonObj(c, a.inboundService.GetOnlineClients(), nil)
 }

+ 1 - 1
internal/web/service/api_scale_postgres_test.go

@@ -113,7 +113,7 @@ func TestAllAPIsPostgresScale(t *testing.T) {
 			run("UpdateByEmail", func() error {
 				upd := clients[n/3]
 				upd.Comment = "touched"
-				_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd)
+				_, err := svc.UpdateByEmail(inboundSvc, upd.Email, upd, 0)
 				return err
 			})
 			run("AttachByEmail", func() error { _, err := svc.AttachByEmail(inboundSvc, emails[n/3], []int{ib2.Id}); return err })

+ 30 - 0
internal/web/service/client.go

@@ -68,6 +68,36 @@ var ErrClientNotInInbound = errors.New("client not found in inbound")
 type ClientCreatePayload struct {
 	Client     model.Client `json:"client"`
 	InboundIds []int        `json:"inboundIds"`
+	LimitHwid  int          `json:"-"`
 }
 
 const sqlInChunk = 400
+
+type clientPayloadWithHwid struct {
+	model.Client
+	LimitHwid int `json:"limitHwid"`
+}
+
+func (p *ClientCreatePayload) UnmarshalJSON(data []byte) error {
+	var raw struct {
+		Client     clientPayloadWithHwid `json:"client"`
+		InboundIds []int                 `json:"inboundIds"`
+	}
+	if err := json.Unmarshal(data, &raw); err != nil {
+		return err
+	}
+	p.Client = raw.Client.Client
+	p.InboundIds = raw.InboundIds
+	p.LimitHwid = raw.Client.LimitHwid
+	return nil
+}
+
+func (p ClientCreatePayload) MarshalJSON() ([]byte, error) {
+	return json.Marshal(struct {
+		Client     clientPayloadWithHwid `json:"client"`
+		InboundIds []int                 `json:"inboundIds"`
+	}{
+		Client:     clientPayloadWithHwid{Client: p.Client, LimitHwid: p.LimitHwid},
+		InboundIds: p.InboundIds,
+	})
+}

+ 13 - 3
internal/web/service/client_bulk.go

@@ -816,6 +816,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 	successEmails := make([]string, 0, len(recordsByEmail))
 	successIds := make([]int, 0, len(recordsByEmail))
 	failedEmails := make([]string, 0, len(recordsByEmail))
+	successSubIDs := make([]string, 0, len(recordsByEmail))
 	for email, rec := range recordsByEmail {
 		if _, skipped := skippedReasons[email]; skipped {
 			failedEmails = append(failedEmails, email)
@@ -823,6 +824,7 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 		}
 		successEmails = append(successEmails, email)
 		successIds = append(successIds, rec.Id)
+		successSubIDs = append(successSubIDs, rec.SubID)
 	}
 	withdrawClientTombstones(failedEmails...)
 
@@ -833,6 +835,9 @@ func (s *ClientService) BulkDelete(inboundSvc *InboundService, emails []string,
 			if e := adjustGroupBaselinesForRemovedTraffic(tx, successEmails); e != nil {
 				return e
 			}
+			if e := clearClientHwidsBySubIDTx(tx, successSubIDs...); e != nil {
+				return e
+			}
 			for _, batch := range chunkInts(successIds, sqlInChunk) {
 				if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 					return e
@@ -1119,6 +1124,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 	type prepared struct {
 		client     model.Client
 		inboundIds []int
+		limitHwid  int
 	}
 	prep := make([]prepared, 0, len(payloads))
 	emails := make([]string, 0, len(payloads))
@@ -1171,7 +1177,7 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 		seenEmail[le] = struct{}{}
 		seenSubID[client.SubID] = le
 
-		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds})
+		prep = append(prep, prepared{client: client, inboundIds: payloads[i].InboundIds, limitHwid: payloads[i].LimitHwid})
 		emails = append(emails, email)
 		subIDs = append(subIDs, client.SubID)
 	}
@@ -1303,9 +1309,13 @@ func (s *ClientService) BulkCreate(inboundSvc *InboundService, payloads []Client
 	for idx := range prep {
 		if failed[idx] {
 			skip(prep[idx].client.Email, reason[idx])
-		} else {
-			result.Created++
+			continue
+		}
+		if err := s.setClientLimitHwidByEmail(nil, prep[idx].client.Email, prep[idx].limitHwid); err != nil {
+			skip(prep[idx].client.Email, err.Error())
+			continue
 		}
+		result.Created++
 	}
 	return result, needRestart, nil
 }

+ 13 - 3
internal/web/service/client_crud.go

@@ -140,6 +140,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate
 			needRestart = true
 		}
 	}
+	if err := s.setClientLimitHwidByEmail(nil, client.Email, payload.LimitHwid); err != nil {
+		return needRestart, err
+	}
 	return needRestart, nil
 }
 
@@ -309,7 +312,7 @@ func applyShadowsocksClientMethod(clients []any, settings map[string]any) {
 	}
 }
 
-func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, inboundFilter ...int) (bool, error) {
+func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
 	existing, err := s.GetByID(id)
 	if err != nil {
 		return false, err
@@ -507,6 +510,10 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model
 		return needRestart, err
 	}
 
+	if err := s.setClientLimitHwidByEmail(nil, updated.Email, limitHwid); err != nil {
+		return needRestart, err
+	}
+
 	if err := database.GetDB().Model(&model.ClientRecord{}).
 		Where("id = ?", id).
 		UpdateColumn("updated_at", time.Now().UnixMilli()).Error; err != nil {
@@ -581,6 +588,9 @@ func (s *ClientService) Delete(inboundSvc *InboundService, id int, keepTraffic b
 		if err := tx.Where("client_id = ?", id).Delete(&model.ClientExternalLink{}).Error; err != nil {
 			return err
 		}
+		if err := clearClientHwidsBySubIDTx(tx, existing.SubID); err != nil {
+			return err
+		}
 		if !keepTraffic && existing.Email != "" {
 			if err := tx.Where("email = ?", existing.Email).Delete(&xray.ClientTraffic{}).Error; err != nil {
 				return err
@@ -755,7 +765,7 @@ func (s *ClientService) DeleteByEmail(inboundSvc *InboundService, email string,
 	return needRestart, nil
 }
 
-func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, inboundFilter ...int) (bool, error) {
+func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string, updated model.Client, limitHwid int, inboundFilter ...int) (bool, error) {
 	if email == "" {
 		return false, common.NewError("client email is required")
 	}
@@ -763,7 +773,7 @@ func (s *ClientService) UpdateByEmail(inboundSvc *InboundService, email string,
 	if err != nil {
 		return false, err
 	}
-	return s.Update(inboundSvc, rec.Id, updated, inboundFilter...)
+	return s.Update(inboundSvc, rec.Id, updated, limitHwid, inboundFilter...)
 }
 
 func (s *ClientService) Detach(inboundSvc *InboundService, id int, inboundIds []int) (bool, error) {

+ 1 - 1
internal/web/service/client_group_node_sync_test.go

@@ -171,7 +171,7 @@ func TestClientUpdate_ClearsGroup(t *testing.T) {
 	// Edit the client and remove the group.
 	updated := *rec.ToClient()
 	updated.Group = ""
-	if _, err := svc.Update(inboundSvc, rec.Id, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, updated, 0); err != nil {
 		t.Fatalf("Update (clear group): %v", err)
 	}
 

+ 271 - 0
internal/web/service/client_hwid.go

@@ -0,0 +1,271 @@
+package service
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+	"strings"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+type HwidRequest struct {
+	Hwid        string
+	UserAgent   string
+	DeviceOS    string
+	OsVersion   string
+	DeviceModel string
+}
+
+type HwidGateResult struct {
+	Allowed           bool
+	Active            bool
+	NotSupported      bool
+	MaxDevicesReached bool
+	LimitReached      bool
+	Limit             int
+	Registered        int
+}
+
+const minHwidLength = 6
+
+type ClientHwidInfo struct {
+	Id          int    `json:"id"`
+	FirstSeen   int64  `json:"firstSeen"`
+	LastSeen    int64  `json:"lastSeen"`
+	UserAgent   string `json:"userAgent"`
+	DeviceOS    string `json:"deviceOs"`
+	OsVersion   string `json:"osVersion"`
+	DeviceModel string `json:"deviceModel"`
+}
+
+func hashHwid(raw string) string {
+	sum := sha256.Sum256([]byte(raw))
+	return hex.EncodeToString(sum[:])
+}
+
+func trimHwidMeta(s string) string {
+	s = strings.TrimSpace(s)
+	r := []rune(s)
+	if len(r) > 512 {
+		return string(r[:512])
+	}
+	return s
+}
+
+func normalizeHwidRequest(req HwidRequest) HwidRequest {
+	return HwidRequest{
+		Hwid:        strings.TrimSpace(req.Hwid),
+		UserAgent:   trimHwidMeta(req.UserAgent),
+		DeviceOS:    trimHwidMeta(req.DeviceOS),
+		OsVersion:   trimHwidMeta(req.OsVersion),
+		DeviceModel: trimHwidMeta(req.DeviceModel),
+	}
+}
+
+func effectiveHwidLimitForSubID(tx *gorm.DB, subID string) (int, error) {
+	var limit int
+	err := tx.Model(&model.ClientRecord{}).
+		Where("sub_id = ? AND enable = ?", subID, true).
+		Select("COALESCE(MAX(limit_hwid), 0)").
+		Scan(&limit).Error
+	return limit, err
+}
+
+func (s *ClientService) EnforceHwidForSubID(subID string, req HwidRequest) (HwidGateResult, error) {
+	var res HwidGateResult
+	subID = strings.TrimSpace(subID)
+	if subID == "" {
+		res.Allowed = true
+		return res, nil
+	}
+
+	db := database.GetDB()
+	limit, err := effectiveHwidLimitForSubID(db, subID)
+	if err != nil {
+		return res, err
+	}
+	if limit <= 0 {
+		res.Allowed = true
+		return res, nil
+	}
+
+	req = normalizeHwidRequest(req)
+	res.Active = true
+	res.Limit = limit
+	if len(req.Hwid) < minHwidLength {
+		res.NotSupported = true
+		return res, nil
+	}
+	hwidHash := hashHwid(req.Hwid)
+
+	err = db.Transaction(func(tx *gorm.DB) error {
+		limit, err := effectiveHwidLimitForSubID(tx, subID)
+		if err != nil {
+			return err
+		}
+		if limit <= 0 {
+			res = HwidGateResult{Allowed: true}
+			return nil
+		}
+		res.Active = true
+		res.Limit = limit
+		now := time.Now().UnixMilli()
+		var existing model.ClientHwid
+		err = tx.Where("sub_id = ? AND hwid_hash = ?", subID, hwidHash).First(&existing).Error
+		if err == nil {
+			if err := tx.Model(&model.ClientHwid{}).Where("id = ?", existing.Id).Updates(map[string]any{
+				"last_seen": now, "user_agent": req.UserAgent, "device_os": req.DeviceOS, "os_version": req.OsVersion, "device_model": req.DeviceModel,
+			}).Error; err != nil {
+				return err
+			}
+			var count int64
+			if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
+				return err
+			}
+			res.Allowed = true
+			res.Registered = int(count)
+			res.LimitReached = count >= int64(limit)
+			return nil
+		}
+		if !errors.Is(err, gorm.ErrRecordNotFound) {
+			return err
+		}
+		var count int64
+		if err := tx.Model(&model.ClientHwid{}).Where("sub_id = ?", subID).Count(&count).Error; err != nil {
+			return err
+		}
+		res.Registered = int(count)
+		if count >= int64(limit) {
+			res.MaxDevicesReached = true
+			res.LimitReached = true
+			return nil
+		}
+		if err := tx.Create(&model.ClientHwid{SubID: subID, HwidHash: hwidHash, FirstSeen: now, LastSeen: now, UserAgent: req.UserAgent, DeviceOS: req.DeviceOS, OsVersion: req.OsVersion, DeviceModel: req.DeviceModel}).Error; err != nil {
+			return err
+		}
+		res.Allowed = true
+		res.Registered = int(count) + 1
+		res.LimitReached = res.Registered >= limit
+		return nil
+	})
+	return res, err
+}
+
+func (s *ClientService) ListClientHwids(email string) ([]ClientHwidInfo, error) {
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return nil, err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil, nil
+	}
+	var rows []model.ClientHwid
+	if err := database.GetDB().
+		Where("sub_id = ?", subID).
+		Order("last_seen DESC").
+		Order("id DESC").
+		Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make([]ClientHwidInfo, 0, len(rows))
+	for _, r := range rows {
+		out = append(out, ClientHwidInfo{
+			Id:          r.Id,
+			FirstSeen:   r.FirstSeen,
+			LastSeen:    r.LastSeen,
+			UserAgent:   r.UserAgent,
+			DeviceOS:    r.DeviceOS,
+			OsVersion:   r.OsVersion,
+			DeviceModel: r.DeviceModel,
+		})
+	}
+	return out, nil
+}
+
+func (s *ClientService) ClearClientHwids(email string) error {
+	rec, err := s.GetRecordByEmail(nil, email)
+	if err != nil {
+		return err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil
+	}
+	return database.GetDB().Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
+}
+
+func (s *ClientService) setClientLimitHwidByEmail(tx *gorm.DB, email string, limit int) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	if limit < 0 {
+		limit = 0
+	}
+	var rec model.ClientRecord
+	if err := tx.Where("email = ?", email).First(&rec).Error; err != nil {
+		return err
+	}
+	if err := tx.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).UpdateColumn("limit_hwid", limit).Error; err != nil {
+		return err
+	}
+	subID := strings.TrimSpace(rec.SubID)
+	if subID == "" {
+		return nil
+	}
+	effective, err := effectiveHwidLimitForSubID(tx, subID)
+	if err != nil {
+		return err
+	}
+	return trimClientHwidsForSubID(tx, subID, effective)
+}
+
+func trimClientHwidsForSubID(tx *gorm.DB, subID string, limit int) error {
+	subID = strings.TrimSpace(subID)
+	if subID == "" || limit <= 0 {
+		return nil
+	}
+	var keep []int
+	if err := tx.Model(&model.ClientHwid{}).
+		Where("sub_id = ?", subID).
+		Order("last_seen DESC").
+		Order("id DESC").
+		Limit(limit).
+		Pluck("id", &keep).Error; err != nil {
+		return err
+	}
+	if len(keep) == 0 {
+		return tx.Where("sub_id = ?", subID).Delete(&model.ClientHwid{}).Error
+	}
+	return tx.Where("sub_id = ? AND id NOT IN ?", subID, keep).Delete(&model.ClientHwid{}).Error
+}
+
+func clearClientHwidsBySubIDTx(tx *gorm.DB, subIDs ...string) error {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	clean := make([]string, 0, len(subIDs))
+	seen := map[string]struct{}{}
+	for _, subID := range subIDs {
+		subID = strings.TrimSpace(subID)
+		if subID == "" {
+			continue
+		}
+		if _, ok := seen[subID]; ok {
+			continue
+		}
+		seen[subID] = struct{}{}
+		clean = append(clean, subID)
+	}
+	for _, batch := range chunkStrings(clean, sqlInChunk) {
+		if err := tx.Where("sub_id IN ?", batch).Delete(&model.ClientHwid{}).Error; err != nil {
+			return err
+		}
+	}
+	return nil
+}

+ 172 - 0
internal/web/service/client_hwid_test.go

@@ -0,0 +1,172 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func initClientHwidTestDB(t *testing.T) {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+}
+
+func seedHwidClient(t *testing.T, limit int) *model.ClientRecord {
+	t.Helper()
+	rec := &model.ClientRecord{
+		Email:     "[email protected]",
+		SubID:     "sub-hwid",
+		UUID:      "11111111-2222-4333-8444-555555555555",
+		Enable:    true,
+		LimitHwid: limit,
+	}
+	if err := database.GetDB().Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	return rec
+}
+
+func TestClientHwidGate(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+
+	seedHwidClient(t, 0)
+	res, err := svc.EnforceHwidForSubID("sub-hwid", HwidRequest{})
+	if err != nil {
+		t.Fatalf("no-limit gate: %v", err)
+	}
+	if !res.Allowed || res.Active {
+		t.Fatalf("no limit should allow missing HWID without active headers: %+v", res)
+	}
+}
+
+func TestClientHwidGateRegistersAndBlocks(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+	rec := seedHwidClient(t, 2)
+
+	res, err := svc.EnforceHwidForSubID(rec.SubID, HwidRequest{})
+	if err != nil {
+		t.Fatalf("missing HWID gate: %v", err)
+	}
+	if res.Allowed || !res.Active || !res.NotSupported {
+		t.Fatalf("missing HWID should be denied as not supported: %+v", res)
+	}
+
+	firstRaw := "device-one"
+	for _, raw := range []string{firstRaw, "device-two"} {
+		res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
+			Hwid:        raw,
+			UserAgent:   "Happ/1.0",
+			DeviceOS:    "android",
+			OsVersion:   "15",
+			DeviceModel: raw + "-model",
+		})
+		if err != nil {
+			t.Fatalf("register %s: %v", raw, err)
+		}
+		if !res.Allowed {
+			t.Fatalf("register %s denied: %+v", raw, res)
+		}
+	}
+
+	res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{Hwid: "device-three"})
+	if err != nil {
+		t.Fatalf("third HWID gate: %v", err)
+	}
+	if res.Allowed || !res.MaxDevicesReached || !res.LimitReached {
+		t.Fatalf("third unique HWID should be denied after limit: %+v", res)
+	}
+
+	res, err = svc.EnforceHwidForSubID(rec.SubID, HwidRequest{
+		Hwid:        firstRaw,
+		UserAgent:   "Karing/2.0",
+		DeviceOS:    "ios",
+		OsVersion:   "18",
+		DeviceModel: "updated-model",
+	})
+	if err != nil {
+		t.Fatalf("existing HWID after full limit: %v", err)
+	}
+	if !res.Allowed || !res.LimitReached {
+		t.Fatalf("existing registered HWID should pass after limit: %+v", res)
+	}
+
+	var hashes []string
+	if err := database.GetDB().Model(&model.ClientHwid{}).Pluck("hwid_hash", &hashes).Error; err != nil {
+		t.Fatalf("pluck hashes: %v", err)
+	}
+	if len(hashes) != 2 {
+		t.Fatalf("stored HWIDs = %d, want 2", len(hashes))
+	}
+	for _, h := range hashes {
+		if h == firstRaw || h == "device-two" || len(h) != 64 {
+			t.Fatalf("raw HWID leaked or invalid hash stored: %q", h)
+		}
+	}
+
+	list, err := svc.ListClientHwids(rec.Email)
+	if err != nil {
+		t.Fatalf("list HWIDs: %v", err)
+	}
+	if len(list) != 2 {
+		t.Fatalf("list count = %d, want 2", len(list))
+	}
+	foundUpdated := false
+	for _, row := range list {
+		if row.DeviceModel == "updated-model" && row.UserAgent == "Karing/2.0" && row.DeviceOS == "ios" && row.OsVersion == "18" {
+			foundUpdated = true
+		}
+	}
+	if !foundUpdated {
+		t.Fatalf("updated HWID metadata missing: %#v", list)
+	}
+
+	if err := svc.setClientLimitHwidByEmail(nil, rec.Email, 1); err != nil {
+		t.Fatalf("lower limit: %v", err)
+	}
+	var count int64
+	if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
+		t.Fatalf("count after trim: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("lowered limit should trim stored HWIDs to 1, got %d", count)
+	}
+
+	if err := svc.ClearClientHwids(rec.Email); err != nil {
+		t.Fatalf("clear HWIDs: %v", err)
+	}
+	if err := database.GetDB().Model(&model.ClientHwid{}).Where("sub_id = ?", rec.SubID).Count(&count).Error; err != nil {
+		t.Fatalf("count after clear: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("clear should remove all HWIDs, got %d", count)
+	}
+}
+
+func TestClientHwidGateSharedSubIdUsesMaxLimit(t *testing.T) {
+	initClientHwidTestDB(t)
+	svc := &ClientService{}
+	db := database.GetDB()
+	subID := "shared-sub"
+	if err := db.Create(&model.ClientRecord{Email: "[email protected]", SubID: subID, UUID: "11111111-2222-4333-8444-555555555555", Enable: true, LimitHwid: 0}).Error; err != nil {
+		t.Fatalf("seed anchor: %v", err)
+	}
+	if err := db.Create(&model.ClientRecord{Email: "[email protected]", SubID: subID, UUID: "22222222-2222-4333-8444-555555555555", Enable: true, LimitHwid: 2}).Error; err != nil {
+		t.Fatalf("seed second: %v", err)
+	}
+	res, err := svc.EnforceHwidForSubID(subID, HwidRequest{})
+	if err != nil || !res.Active || res.Limit != 2 {
+		t.Fatalf("expected active gate limit 2 from max row, err=%v res=%+v", err, res)
+	}
+	if res.Allowed || !res.NotSupported {
+		t.Fatalf("missing HWID should be denied: %+v", res)
+	}
+}

+ 25 - 15
internal/web/service/client_paging.go

@@ -24,6 +24,7 @@ type ClientSlim struct {
 	TotalGB    int64               `json:"totalGB"`
 	ExpiryTime int64               `json:"expiryTime"`
 	LimitIP    int                 `json:"limitIp"`
+	LimitHwid  int                 `json:"limitHwid"`
 	Reset      int                 `json:"reset"`
 	Group      string              `json:"group,omitempty"`
 	Comment    string              `json:"comment,omitempty"`
@@ -457,21 +458,11 @@ func (q clientQuery) pageRows(params ClientPageParams, onlines []string, offset,
 		if rec == nil {
 			continue
 		}
-		items = append(items, ClientSlim{
-			Email:      rec.Email,
-			SubID:      rec.SubID,
-			Enable:     rec.Enable,
-			TotalGB:    rec.TotalGB,
-			ExpiryTime: rec.ExpiryTime,
-			LimitIP:    rec.LimitIP,
-			Reset:      rec.Reset,
-			Group:      rec.Group,
-			Comment:    rec.Comment,
-			InboundIds: attachments[rec.Id],
-			Traffic:    trafficByEmail[rec.Email],
-			CreatedAt:  rec.CreatedAt,
-			UpdatedAt:  rec.UpdatedAt,
-		})
+		items = append(items, toClientSlim(ClientWithAttachments{
+			ClientRecord: *rec,
+			InboundIds:   attachments[rec.Id],
+			Traffic:      trafficByEmail[rec.Email],
+		}))
 	}
 	return items, nil
 }
@@ -604,6 +595,25 @@ func sqlInt(v int64) string {
 	return strconv.FormatInt(v, 10)
 }
 
+func toClientSlim(c ClientWithAttachments) ClientSlim {
+	return ClientSlim{
+		Email:      c.Email,
+		SubID:      c.SubID,
+		Enable:     c.Enable,
+		TotalGB:    c.TotalGB,
+		ExpiryTime: c.ExpiryTime,
+		LimitIP:    c.LimitIP,
+		LimitHwid:  c.LimitHwid,
+		Reset:      c.Reset,
+		Group:      c.Group,
+		Comment:    c.Comment,
+		InboundIds: c.InboundIds,
+		Traffic:    c.Traffic,
+		CreatedAt:  c.CreatedAt,
+		UpdatedAt:  c.UpdatedAt,
+	}
+}
+
 // escapeLikeLiteral neutralises LIKE wildcards so searching for "a_b" keeps
 // matching literally, the way strings.Contains did.
 func escapeLikeLiteral(s string) string {

+ 9 - 1
internal/web/service/client_portable.go

@@ -54,6 +54,7 @@ func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
 		out = append(out, ClientCreatePayload{
 			Client:     *client,
 			InboundIds: attachments[rows[i].Id],
+			LimitHwid:  rows[i].LimitHwid,
 		})
 	}
 	return out, nil
@@ -151,7 +152,9 @@ func (s *ClientService) ImportClients(inboundSvc *InboundService, items []Client
 		}
 		client.UpdatedAt = now
 
-		if err := db.Create(client.ToRecord()).Error; err != nil {
+		rec := client.ToRecord()
+		rec.LimitHwid = orphans[i].LimitHwid
+		if err := db.Create(rec).Error; err != nil {
 			skip(email, err.Error())
 			continue
 		}
@@ -178,11 +181,13 @@ func (s *ClientService) DeleteOrphans() (int, error) {
 
 	ids := make([]int, 0, len(rows))
 	emails := make([]string, 0, len(rows))
+	subIDs := make([]string, 0, len(rows))
 	for i := range rows {
 		ids = append(ids, rows[i].Id)
 		if rows[i].Email != "" {
 			emails = append(emails, rows[i].Email)
 		}
+		subIDs = append(subIDs, rows[i].SubID)
 	}
 	tombstoneClientEmails(emails)
 
@@ -190,6 +195,9 @@ func (s *ClientService) DeleteOrphans() (int, error) {
 		if e := adjustGroupBaselinesForRemovedTraffic(tx, emails); e != nil {
 			return e
 		}
+		if e := clearClientHwidsBySubIDTx(tx, subIDs...); e != nil {
+			return e
+		}
 		for _, batch := range chunkInts(ids, sqlInChunk) {
 			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
 				return e

+ 1 - 1
internal/web/service/client_stat_reuse_test.go

@@ -43,7 +43,7 @@ func TestAddClientStat_RefreshesStaleRowOnInboundDeleteThenReuse(t *testing.T) {
 	if _, err := svc.Update(inboundSvc, rec0.Id, model.Client{
 		Email: email, SubID: subID, Enable: false,
 		TotalGB: 0, ExpiryTime: 1000, Reset: 0,
-	}); err != nil {
+	}, 0); err != nil {
 		t.Fatalf("Update to disabled: %v", err)
 	}
 

+ 2 - 2
internal/web/service/client_traffic.go

@@ -30,7 +30,7 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	if !rec.Enable {
 		updated := rec.ToClient()
 		updated.Enable = true
-		nr, uErr := s.Update(inboundSvc, rec.Id, *updated)
+		nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
 		if uErr != nil {
 			logger.Warning("Failed to auto-enable client during traffic reset:", uErr)
 		}
@@ -84,7 +84,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 		if err == nil && !rec.Enable {
 			updated := rec.ToClient()
 			updated.Enable = true
-			if _, uErr := s.Update(inboundSvc, rec.Id, *updated); uErr != nil {
+			if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
 				logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
 			}
 		}

+ 3 - 3
internal/web/service/client_update_enable_test.go

@@ -26,7 +26,7 @@ func TestUpdate_PersistsRecordEnable_True(t *testing.T) {
 	}
 	updated := rec.ToClient()
 	updated.Enable = true
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 
@@ -60,7 +60,7 @@ func TestUpdate_PersistsRecordEnable_False(t *testing.T) {
 	}
 	updated := rec.ToClient()
 	updated.Enable = false
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 
@@ -88,7 +88,7 @@ func TestUpdate_PersistsRecordEnable_NoInbound(t *testing.T) {
 
 	updated := rec.ToClient()
 	updated.Enable = true
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, 0); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 

+ 2 - 2
internal/web/service/client_update_no_inbound_test.go

@@ -103,7 +103,7 @@ func TestUpdate_PersistsFields_NoInbound(t *testing.T) {
 
 			updated := rec.ToClient()
 			tc.mutate(updated)
-			if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+			if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
 				t.Fatalf("Update: %v", err)
 			}
 
@@ -142,7 +142,7 @@ func TestUpdate_NoInbound_PreservesCredentialsWhenOmitted(t *testing.T) {
 	updated.Auth = ""
 	updated.Secret = ""
 	updated.Comment = "only comment changed"
-	if _, err := svc.Update(inboundSvc, rec.Id, *updated); err != nil {
+	if _, err := svc.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); err != nil {
 		t.Fatalf("Update: %v", err)
 	}
 

+ 4 - 4
internal/web/service/client_update_rename_test.go

@@ -60,7 +60,7 @@ func TestUpdateInboundClientCaseOnlyRenameDoesNotDuplicateRecord(t *testing.T) {
 
 	updated := source[0]
 	updated.Email = "Test"
-	if _, err := svc.Update(inboundSvc, origId, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, origId, updated, 0); err != nil {
 		t.Fatalf("Update case-only email: %v", err)
 	}
 
@@ -132,7 +132,7 @@ func TestClientUpdateDuplicateSubIDDoesNotRenameEmail(t *testing.T) {
 	updated := source[0]
 	updated.Email = "kept@x"
 	updated.SubID = "sub-other"
-	if _, err := svc.Update(inboundSvc, origId, updated); err == nil {
+	if _, err := svc.Update(inboundSvc, origId, updated, 0); err == nil {
 		t.Fatalf("Update with colliding subId succeeded, want error")
 	}
 
@@ -165,7 +165,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
 
 	updated := source[0]
 	updated.TotalGB = 42
-	if _, err := svc.Update(inboundSvc, first.Id, updated); err != nil {
+	if _, err := svc.Update(inboundSvc, first.Id, updated, 0); err != nil {
 		t.Fatalf("Update of a client whose subId is already shared: %v", err)
 	}
 	if got := lookupClientRecord(t, "a@node").TotalGB; got != 42 {
@@ -175,7 +175,7 @@ func TestClientUpdateKeepsSharedSubIDEditable(t *testing.T) {
 	omitted := source[0]
 	omitted.SubID = ""
 	omitted.TotalGB = 43
-	if _, err := svc.Update(inboundSvc, first.Id, omitted); err != nil {
+	if _, err := svc.Update(inboundSvc, first.Id, omitted, 0); err != nil {
 		t.Fatalf("Update with subId omitted entirely: %v", err)
 	}
 	other := lookupClientRecord(t, "b@node")

+ 7 - 0
internal/web/translation/ar-EG.json

@@ -714,6 +714,13 @@
       "addClients": "إضافة عملاء",
       "limitIp": "حد عناوين IP",
       "limitIpDesc": "الحد الأقصى لعناوين IP المتزامنة. 0 = غير محدود.",
+      "limitHwid": "حد HWID",
+      "limitHwidDesc": "الحد الأقصى للأجهزة المسجلة لطلبات الاشتراك. 0 = غير محدود.",
+      "hwidLog": "أجهزة HWID",
+      "hwidDevice": "جهاز مسجل",
+      "noHwids": "لا توجد أجهزة HWID بعد",
+      "firstSeen": "أول ظهور",
+      "lastSeen": "آخر ظهور",
       "limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.",
       "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
       "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",

+ 7 - 0
internal/web/translation/en-US.json

@@ -714,6 +714,13 @@
       "addClients": "Add Clients",
       "limitIp": "IP Limit",
       "limitIpDesc": "Maximum simultaneous IPs. 0 = unlimited.",
+      "limitHwid": "HWID Limit",
+      "limitHwidDesc": "Maximum registered devices for subscription requests. 0 = unlimited.",
+      "hwidLog": "HWID Devices",
+      "hwidDevice": "Registered device",
+      "noHwids": "No HWID devices yet",
+      "firstSeen": "First seen",
+      "lastSeen": "Last seen",
       "limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.",
       "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
       "limitIpDisabled": "The IP limit feature is disabled on this server.",

+ 7 - 0
internal/web/translation/es-ES.json

@@ -714,6 +714,13 @@
       "addClients": "Añadir clientes",
       "limitIp": "Límite de IP",
       "limitIpDesc": "Máximo de IP simultáneas. 0 = ilimitado.",
+      "limitHwid": "Límite de HWID",
+      "limitHwidDesc": "Máximo de dispositivos registrados para solicitudes de suscripción. 0 = ilimitado.",
+      "hwidLog": "Dispositivos HWID",
+      "hwidDevice": "Dispositivo registrado",
+      "noHwids": "Aún no hay dispositivos HWID",
+      "firstSeen": "Visto por primera vez",
+      "lastSeen": "Visto por última vez",
       "limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.",
       "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
       "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",

+ 7 - 0
internal/web/translation/fa-IR.json

@@ -714,6 +714,13 @@
       "addClients": "افزودن کلاینت‌ها",
       "limitIp": "محدودیت IP",
       "limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
+      "limitHwid": "محدودیت HWID",
+      "limitHwidDesc": "حداکثر دستگاه ثبت‌شده برای درخواست‌های اشتراک. ۰ = نامحدود",
+      "hwidLog": "دستگاه‌های HWID",
+      "hwidDevice": "دستگاه ثبت‌شده",
+      "noHwids": "هنوز دستگاه HWID ثبت نشده است",
+      "firstSeen": "اولین مشاهده",
+      "lastSeen": "آخرین مشاهده",
       "limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
       "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
       "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",

+ 7 - 0
internal/web/translation/id-ID.json

@@ -714,6 +714,13 @@
       "addClients": "Tambah klien",
       "limitIp": "Batas IP",
       "limitIpDesc": "Jumlah maksimum IP bersamaan. 0 = tidak terbatas.",
+      "limitHwid": "Batas HWID",
+      "limitHwidDesc": "Jumlah maksimum perangkat terdaftar untuk permintaan langganan. 0 = tidak terbatas.",
+      "hwidLog": "Perangkat HWID",
+      "hwidDevice": "Perangkat terdaftar",
+      "noHwids": "Belum ada perangkat HWID",
+      "firstSeen": "Pertama terlihat",
+      "lastSeen": "Terakhir terlihat",
       "limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.",
       "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
       "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",

+ 7 - 0
internal/web/translation/ja-JP.json

@@ -714,6 +714,13 @@
       "addClients": "クライアントを追加",
       "limitIp": "IP 制限",
       "limitIpDesc": "同時接続 IP の最大数。0 = 無制限。",
+      "limitHwid": "HWID 制限",
+      "limitHwidDesc": "サブスクリプション要求で登録できる最大デバイス数。0 = 無制限。",
+      "hwidLog": "HWID デバイス",
+      "hwidDevice": "登録済みデバイス",
+      "noHwids": "HWID デバイスはまだありません",
+      "firstSeen": "初回確認",
+      "lastSeen": "最終確認",
       "limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。",
       "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
       "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",

+ 7 - 0
internal/web/translation/pt-BR.json

@@ -714,6 +714,13 @@
       "addClients": "Adicionar clientes",
       "limitIp": "Limite de IP",
       "limitIpDesc": "Máximo de IPs simultâneos. 0 = ilimitado.",
+      "limitHwid": "Limite de HWID",
+      "limitHwidDesc": "Máximo de dispositivos registrados para solicitações de assinatura. 0 = ilimitado.",
+      "hwidLog": "Dispositivos HWID",
+      "hwidDevice": "Dispositivo registrado",
+      "noHwids": "Ainda não há dispositivos HWID",
+      "firstSeen": "Visto primeiro",
+      "lastSeen": "Visto por último",
       "limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.",
       "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
       "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",

+ 7 - 0
internal/web/translation/ru-RU.json

@@ -714,6 +714,13 @@
       "addClients": "Добавить клиентов",
       "limitIp": "Лимит IP",
       "limitIpDesc": "Максимум одновременных IP-адресов. 0 = без ограничений.",
+      "limitHwid": "Лимит HWID",
+      "limitHwidDesc": "Максимум зарегистрированных устройств для запросов подписки. 0 = без ограничений.",
+      "hwidLog": "Устройства HWID",
+      "hwidDevice": "Зарегистрированное устройство",
+      "noHwids": "Устройств HWID пока нет",
+      "firstSeen": "Первое появление",
+      "lastSeen": "Последнее появление",
       "limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.",
       "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
       "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",

+ 7 - 0
internal/web/translation/tr-TR.json

@@ -714,6 +714,13 @@
       "addClients": "Kullanıcı Ekle",
       "limitIp": "IP Limiti",
       "limitIpDesc": "Eş zamanlı en fazla IP sayısı. 0 = sınırsız.",
+      "limitHwid": "HWID Limiti",
+      "limitHwidDesc": "Abonelik istekleri için en fazla kayıtlı cihaz. 0 = sınırsız.",
+      "hwidLog": "HWID Cihazları",
+      "hwidDevice": "Kayıtlı cihaz",
+      "noHwids": "Henüz HWID cihazı yok",
+      "firstSeen": "İlk görülme",
+      "lastSeen": "Son görülme",
       "limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.",
       "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
       "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",

+ 7 - 0
internal/web/translation/uk-UA.json

@@ -714,6 +714,13 @@
       "addClients": "Додати клієнтів",
       "limitIp": "Ліміт IP",
       "limitIpDesc": "Максимум одночасних IP-адрес. 0 = без обмежень.",
+      "limitHwid": "Ліміт HWID",
+      "limitHwidDesc": "Максимум зареєстрованих пристроїв для запитів підписки. 0 = без обмежень.",
+      "hwidLog": "Пристрої HWID",
+      "hwidDevice": "Зареєстрований пристрій",
+      "noHwids": "Пристроїв HWID ще немає",
+      "firstSeen": "Перша поява",
+      "lastSeen": "Остання поява",
       "limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.",
       "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
       "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",

+ 7 - 0
internal/web/translation/vi-VN.json

@@ -714,6 +714,13 @@
       "addClients": "Thêm khách hàng",
       "limitIp": "Giới hạn IP",
       "limitIpDesc": "Số IP đồng thời tối đa. 0 = không giới hạn.",
+      "limitHwid": "Giới hạn HWID",
+      "limitHwidDesc": "Số thiết bị đăng ký tối đa cho yêu cầu đăng ký. 0 = không giới hạn.",
+      "hwidLog": "Thiết bị HWID",
+      "hwidDevice": "Thiết bị đã đăng ký",
+      "noHwids": "Chưa có thiết bị HWID",
+      "firstSeen": "Lần đầu thấy",
+      "lastSeen": "Lần cuối thấy",
       "limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.",
       "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
       "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",

+ 7 - 0
internal/web/translation/zh-CN.json

@@ -714,6 +714,13 @@
       "addClients": "添加客户端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同时连接 IP 数。0 = 不限制。",
+      "limitHwid": "HWID 限制",
+      "limitHwidDesc": "订阅请求最多可注册的设备数。0 = 不限制。",
+      "hwidLog": "HWID 设备",
+      "hwidDevice": "已注册设备",
+      "noHwids": "暂无 HWID 设备",
+      "firstSeen": "首次出现",
+      "lastSeen": "最后出现",
       "limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。",
       "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
       "limitIpDisabled": "此服务器已禁用 IP 限制功能。",

+ 7 - 0
internal/web/translation/zh-TW.json

@@ -714,6 +714,13 @@
       "addClients": "新增客戶端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同時連線 IP 數。0 = 不限制。",
+      "limitHwid": "HWID 限制",
+      "limitHwidDesc": "訂閱請求最多可註冊的裝置數。0 = 不限制。",
+      "hwidLog": "HWID 裝置",
+      "hwidDevice": "已註冊裝置",
+      "noHwids": "尚無 HWID 裝置",
+      "firstSeen": "首次出現",
+      "lastSeen": "最後出現",
       "limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。",
       "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
       "limitIpDisabled": "此伺服器已停用 IP 限制功能。",