mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-23 19:37:13 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user