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 <ho3ein.sanaei@gmail.com>
This commit is contained in:
Rouzbeh†
2026-08-15 18:20:20 +03:30
committed by GitHub
parent 1793a9b8b4
commit 694ad6deae
45 changed files with 1212 additions and 50 deletions
@@ -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
View File
@@ -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>
</>
);
}