diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 3d343177d..6985f262a 100644 --- a/frontend/public/openapi.json +++ b/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": "alice@example.com", "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": "alice@example.com", "totalGB": 53687091200, "expiryTime": 0, + "limitHwid": 2, "enable": true }, "inboundIds": [ @@ -6774,6 +6783,7 @@ "email": "bob@example.com", "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": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 0edf3c286..76564fd6e 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -283,6 +283,7 @@ export const EXAMPLES: Record = { "group": "", "id": 0, "keepAlive": 0, + "limitHwid": 0, "limitIp": 0, "password": "", "preSharedKey": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 1e516de41..39fd1abac 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1199,6 +1199,9 @@ export const SCHEMAS: Record = { "keepAlive": { "type": "integer" }, + "limitHwid": { + "type": "integer" + }, "limitIp": { "type": "integer" }, @@ -1256,6 +1259,7 @@ export const SCHEMAS: Record = { "group", "id", "keepAlive", + "limitHwid", "limitIp", "password", "preSharedKey", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index c569b0ab1..ffb28694c 100644 --- a/frontend/src/generated/types.ts +++ b/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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 810c44a9a..1b352140e 100644 --- a/frontend/src/generated/zod.ts +++ b/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(), diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 70d5f4ca7..7ac59bbeb 100644 --- a/frontend/src/hooks/useClients.ts +++ b/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 || '', diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 88cd911cf..9d5215dc7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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": "alice@example.com",\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": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\n }\n }\n}', +'{\n "success": true,\n "obj": {\n "items": [\n {\n "email": "alice@example.com",\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": ["alice@example.com"],\n "depleted": [],\n "expiring": [],\n "deactive": ["bob@example.com"]\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": "alice@example.com",\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": "alice@example.com",\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": "alice@example.com",\n "totalGB": 107374182400,\n "expiryTime": 1767225600000,\n "tgId": 123456789,\n "enable": true\n}', + body: '{\n "email": "alice@example.com",\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": "alice@example.com",\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": "alice@example.com",\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": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "enable": true\n },\n "inboundIds": [7, 9]\n }\n]', + body: '[\n {\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 0,\n "limitHwid": 2,\n "enable": true\n },\n "inboundIds": [7]\n },\n {\n "client": {\n "email": "bob@example.com",\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": "alice@example.com", "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', diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 2b05d682e..c3be24234 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/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({ /> + Number(v) || 0 }} + > + + + diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index b7673053d..04096426c 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -57,6 +57,16 @@ interface ApiMsg { 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([]); + 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; + 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({ } > - +
+ + + + methods.setValue('limitHwid', Number(v) || 0)} /> + {isEdit && ( + + + + )} + + + @@ -1012,6 +1077,55 @@ export default function ClientFormModal({ {t('tgbot.noIpRecord')} )} + + setHwidsModalOpen(false)} + footer={[ + , + , + , + ]} + > + {clientHwids.length > 0 ? ( +
+ {clientHwids.map((entry) => ( +
+ {entry.deviceModel || entry.userAgent || t('pages.clients.hwidDevice')} +
+ + {[entry.deviceOs, entry.osVersion].filter(Boolean).join(' ')} + +
+ + {t('pages.clients.firstSeen')}: {entry.firstSeen ? dayjs(entry.firstSeen).format('YYYY-MM-DD HH:mm') : '-'} + +
+ + {t('pages.clients.lastSeen')}: {entry.lastSeen ? dayjs(entry.lastSeen).format('YYYY-MM-DD HH:mm') : '-'} + + {entry.userAgent && ( + <> +
+ {entry.userAgent} + + )} +
+ ))} +
+ ) : ( + {t('pages.clients.noHwids')} + )} +
); } diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 225a33a77..107793981 100644 --- a/frontend/src/schemas/client.ts +++ b/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), diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts index 25ee3d74f..9bbecc1fe 100644 --- a/frontend/src/utils/index.ts +++ b/frontend/src/utils/index.ts @@ -105,6 +105,23 @@ export class HttpUtil { } } + static async delete(url: string, options: HttpOptions = {}): Promise> { + const { silent, silentSuccess, ...rest } = options; + try { + const resp = await httpRequest('DELETE', url, undefined, rest); + const msg = this._respToMsg(resp) as Msg; + 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(false, data?.msg || data?.message || err.message || 'Request failed'); + if (!silent) this._handleMsg(errorMsg); + return errorMsg; + } + } + static async postWithModal(url: string, data?: unknown, modal?: HttpModal | null): Promise> { if (modal) { modal.loading(true); diff --git a/internal/database/client_hwid_schema_test.go b/internal/database/client_hwid_schema_test.go new file mode 100644 index 000000000..a0230f289 --- /dev/null +++ b/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) +} diff --git a/internal/database/db.go b/internal/database/db.go index c01848798..755f0a7fa 100644 --- a/internal/database/db.go +++ b/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{}, diff --git a/internal/database/migrate_data.go b/internal/database/migrate_data.go index cc5b7c536..fa27fea8b 100644 --- a/internal/database/migrate_data.go +++ b/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{}, diff --git a/internal/database/model/model.go b/internal/database/model/model.go index a4d129cfb..5b8d36a27 100644 --- a/internal/database/model/model.go +++ b/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) diff --git a/internal/sub/controller.go b/internal/sub/controller.go index e6a1386ce..228e3e881 100644 --- a/internal/sub/controller.go +++ b/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) } diff --git a/internal/sub/hwid_controller_test.go b/internal/sub/hwid_controller_test.go new file mode 100644 index 000000000..c2a871198 --- /dev/null +++ b/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(""), 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 = "route@example.com" + 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()) + } +} diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 827b5bd81..46b944c41 100644 --- a/internal/web/controller/client.go +++ b/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) } diff --git a/internal/web/service/api_scale_postgres_test.go b/internal/web/service/api_scale_postgres_test.go index 39ece795d..3448ba443 100644 --- a/internal/web/service/api_scale_postgres_test.go +++ b/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 }) diff --git a/internal/web/service/client.go b/internal/web/service/client.go index 882bc3efa..80ec4ad55 100644 --- a/internal/web/service/client.go +++ b/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, + }) +} diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index 9a2831f4e..02350b68f 100644 --- a/internal/web/service/client_bulk.go +++ b/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 } diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index 9f9eaff0b..ec610fddb 100644 --- a/internal/web/service/client_crud.go +++ b/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) { diff --git a/internal/web/service/client_group_node_sync_test.go b/internal/web/service/client_group_node_sync_test.go index 509cf7869..23e4874ea 100644 --- a/internal/web/service/client_group_node_sync_test.go +++ b/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) } diff --git a/internal/web/service/client_hwid.go b/internal/web/service/client_hwid.go new file mode 100644 index 000000000..c73734ee7 --- /dev/null +++ b/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 +} diff --git a/internal/web/service/client_hwid_test.go b/internal/web/service/client_hwid_test.go new file mode 100644 index 000000000..a15a42369 --- /dev/null +++ b/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: "hwid@example.com", + 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: "a@ex.com", 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: "b@ex.com", 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) + } +} diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go index 9031c5ef2..a64c2f271 100644 --- a/internal/web/service/client_paging.go +++ b/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 { diff --git a/internal/web/service/client_portable.go b/internal/web/service/client_portable.go index 4c35e86e3..1acd921f0 100644 --- a/internal/web/service/client_portable.go +++ b/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 diff --git a/internal/web/service/client_stat_reuse_test.go b/internal/web/service/client_stat_reuse_test.go index 69affa270..ec188a210 100644 --- a/internal/web/service/client_stat_reuse_test.go +++ b/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) } diff --git a/internal/web/service/client_traffic.go b/internal/web/service/client_traffic.go index f82bae668..ed6132298 100644 --- a/internal/web/service/client_traffic.go +++ b/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) } } diff --git a/internal/web/service/client_update_enable_test.go b/internal/web/service/client_update_enable_test.go index 6c52ab6a6..191c73533 100644 --- a/internal/web/service/client_update_enable_test.go +++ b/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) } diff --git a/internal/web/service/client_update_no_inbound_test.go b/internal/web/service/client_update_no_inbound_test.go index eb7d440fc..993f848c4 100644 --- a/internal/web/service/client_update_no_inbound_test.go +++ b/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) } diff --git a/internal/web/service/client_update_rename_test.go b/internal/web/service/client_update_rename_test.go index 7ead6fc83..163874217 100644 --- a/internal/web/service/client_update_rename_test.go +++ b/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") diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 6112f5c00..e421578cf 100644 --- a/internal/web/translation/ar-EG.json +++ b/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 معطّلة على هذا الخادم.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index de385ae79..085dbdd99 100644 --- a/internal/web/translation/en-US.json +++ b/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.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 58eef72a1..f4da85e5a 100644 --- a/internal/web/translation/es-ES.json +++ b/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.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 6feb05bff..3fa958392 100644 --- a/internal/web/translation/fa-IR.json +++ b/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 روی این سرور غیرفعال است.", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index bdd833cda..26a910272 100644 --- a/internal/web/translation/id-ID.json +++ b/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.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index da86bc74d..c77a0c1a0 100644 --- a/internal/web/translation/ja-JP.json +++ b/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 制限機能が無効になっています。", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index cc684eb3b..ae7884eb7 100644 --- a/internal/web/translation/pt-BR.json +++ b/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.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index bf5b80a30..9972a322f 100644 --- a/internal/web/translation/ru-RU.json +++ b/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 отключена на этом сервере.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 2592bbb77..1813e82cb 100644 --- a/internal/web/translation/tr-TR.json +++ b/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ışı.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index b02de82e0..7e0d737df 100644 --- a/internal/web/translation/uk-UA.json +++ b/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 вимкнено на цьому сервері.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index e36249c8e..a5858c13b 100644 --- a/internal/web/translation/vi-VN.json +++ b/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.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index f2aeb6f19..498c9b02b 100644 --- a/internal/web/translation/zh-CN.json +++ b/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 限制功能。", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 93c743743..fd5afb47b 100644 --- a/internal/web/translation/zh-TW.json +++ b/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 限制功能。",