mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
feat(clients): bind host rules to individual clients in subscriptions (#6620)
Host rules applied per inbound only, so every client of an inbound got the same subscription endpoints (#6618). A client can now be bound to one or more host rules, and its raw, JSON and Clash subscriptions render only those hosts, in either subscription order; an unbound client is unchanged. A binding whose hosts are deleted, disabled or excluded from the format fails closed: the client gets no links there but stays in traffic accounting. Host edits keep row ids, so bindings survive them; nil leaves a stored binding alone and an empty list clears it.
This commit is contained in:
@@ -1839,6 +1839,14 @@ export const sections: readonly Section[] = [
|
||||
responseSchema: 'HostGroup',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/raw',
|
||||
summary:
|
||||
'List every host row with its numeric id, for id-based pickers such as the per-client host rule override.',
|
||||
responseSchema: 'Host',
|
||||
responseSchemaArray: true,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/hosts/get/:groupId',
|
||||
|
||||
@@ -37,6 +37,7 @@ import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
|
||||
import { resolveExternalLinkExpiry } from '@/lib/clients/external-link';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { useClientHwids } from '@/hooks/useClientHwids';
|
||||
import { useRawHostsQuery } from '@/api/queries/useRawHostsQuery';
|
||||
import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import ClientHwidListModal from '@/components/clients/ClientHwidList';
|
||||
@@ -136,6 +137,7 @@ type Values = ClientFormValues & {
|
||||
expiryDate: number;
|
||||
limitHwid: number;
|
||||
externalLinks: ExternalLinkRow[];
|
||||
clientHostRuleIds: number[];
|
||||
wgPrivateKey: string;
|
||||
wgPublicKey: string;
|
||||
wgPreSharedKey: string;
|
||||
@@ -155,6 +157,7 @@ const EMPTY: Values = {
|
||||
auth: '',
|
||||
flow: '',
|
||||
security: 'auto',
|
||||
clientHostRuleIds: [],
|
||||
reverseTag: '',
|
||||
totalGB: 0,
|
||||
expiryDate: 0,
|
||||
@@ -373,6 +376,11 @@ export default function ClientFormModal({
|
||||
? 'auto'
|
||||
: client.security,
|
||||
reverseTag: client.reverse?.tag || '',
|
||||
clientHostRuleIds: [
|
||||
...new Set(
|
||||
(client.clientHostRuleIds || []).filter((id) => Number.isInteger(id) && id > 0),
|
||||
),
|
||||
],
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
reset: Number(client.reset) || 0,
|
||||
resetDay: Number(client.resetDay) || 0,
|
||||
@@ -573,6 +581,66 @@ export default function ClientFormModal({
|
||||
[inbounds, inboundIds],
|
||||
);
|
||||
|
||||
// Host options cover selected inbounds only; bound disabled hosts stay
|
||||
// visible with a suffix so open+save never silently drops them.
|
||||
const { hosts: rawHostRules } = useRawHostsQuery();
|
||||
const clientHostRuleIds = useWatch({ control: methods.control, name: 'clientHostRuleIds' });
|
||||
const hostRuleOptions = useMemo(() => {
|
||||
const selected = new Set(inboundIds || []);
|
||||
const bound = new Set(clientHostRuleIds || []);
|
||||
const formatBase = (h: { id?: number; remark?: string; address?: string }) =>
|
||||
h.remark || h.address
|
||||
? `${h.remark || h.address}${h.address && h.remark ? ` (${h.address})` : ''} (#${h.id})`
|
||||
: `Host #${h.id}`;
|
||||
const options: { value: number; label: string }[] = [];
|
||||
const seen = new Set<number>();
|
||||
for (const h of rawHostRules || []) {
|
||||
if (h.id === undefined || h.inboundId === undefined || !selected.has(h.inboundId)) continue;
|
||||
const id = h.id as number;
|
||||
const base = formatBase(h);
|
||||
if (!h.isDisabled) {
|
||||
options.push({ value: id, label: base });
|
||||
seen.add(id);
|
||||
} else if (bound.has(id)) {
|
||||
options.push({ value: id, label: t('pages.clients.hostRuleDisabled', { label: base }) });
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
for (const id of bound) {
|
||||
if (seen.has(id)) continue;
|
||||
const h = (rawHostRules || []).find((r) => r.id === id);
|
||||
if (h) {
|
||||
const base = formatBase(h);
|
||||
options.push({
|
||||
value: id,
|
||||
label: h.isDisabled ? t('pages.clients.hostRuleDisabled', { label: base }) : base,
|
||||
});
|
||||
} else {
|
||||
options.push({ value: id, label: `Host #${id}` });
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}, [rawHostRules, inboundIds, clientHostRuleIds, t]);
|
||||
|
||||
// Prune only bindings whose inbound was just deselected, never on
|
||||
// disabled state, so a temporarily disabled host survives untouched.
|
||||
function handleInboundChange(next: number[]) {
|
||||
const prevSelected = new Set(inboundIds || []);
|
||||
const nextSelected = new Set(next || []);
|
||||
const removed = new Set([...prevSelected].filter((id) => !nextSelected.has(id)));
|
||||
methods.setValue('inboundIds', next);
|
||||
if (removed.size === 0) return;
|
||||
const current = methods.getValues('clientHostRuleIds') || [];
|
||||
if (current.length === 0) return;
|
||||
const byId = new Map((rawHostRules || []).map((h) => [h.id as number, h]));
|
||||
const kept = current.filter((id) => {
|
||||
const host = byId.get(id);
|
||||
if (!host || host.inboundId === undefined) return true;
|
||||
return !removed.has(host.inboundId);
|
||||
});
|
||||
if (kept.length !== current.length) methods.setValue('clientHostRuleIds', kept);
|
||||
}
|
||||
|
||||
const expiryDayjs = useMemo<Dayjs | null>(
|
||||
() => (expiryDate > 0 ? dayjs(expiryDate) : null),
|
||||
[expiryDate],
|
||||
@@ -674,6 +742,7 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: values.flow,
|
||||
security: values.security,
|
||||
clientHostRuleIds: values.clientHostRuleIds,
|
||||
reverseTag: values.reverseTag,
|
||||
totalGB: values.totalGB,
|
||||
delayedStart: values.delayedStart,
|
||||
@@ -710,6 +779,10 @@ export default function ClientFormModal({
|
||||
auth: values.auth,
|
||||
flow: showFlow ? values.flow || '' : '',
|
||||
security: showSecurity ? values.security || 'auto' : 'auto',
|
||||
// An empty list clears the override.
|
||||
clientHostRuleIds: Array.isArray(values.clientHostRuleIds)
|
||||
? values.clientHostRuleIds.filter((id) => typeof id === 'number' && id > 0)
|
||||
: [],
|
||||
totalGB: totalBytes,
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
@@ -1086,31 +1159,57 @@ export default function ClientFormModal({
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{(tgBotEnable || showReverseTag) && (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item
|
||||
label={t('pages.clients.hostRuleOverride')}
|
||||
htmlFor={`${uid}-host-rule-override`}
|
||||
>
|
||||
<Select
|
||||
id={`${uid}-host-rule-override`}
|
||||
mode="multiple"
|
||||
value={clientHostRuleIds || []}
|
||||
onChange={(v) => methods.setValue('clientHostRuleIds', v)}
|
||||
options={hostRuleOptions}
|
||||
placeholder={t('pages.clients.hostRuleOverridePlaceholder')}
|
||||
maxTagCount="responsive"
|
||||
placement="topLeft"
|
||||
listHeight={220}
|
||||
allowClear
|
||||
showSearch={{
|
||||
filterOption: (input, option) =>
|
||||
((option?.label as string) || '')
|
||||
.toLowerCase()
|
||||
.includes(input.toLowerCase()),
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{showReverseTag && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
|
||||
<Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{tgBotEnable && (
|
||||
<Row gutter={16}>
|
||||
{tgBotEnable && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
name="tgId"
|
||||
label={t('pages.clients.telegramId')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
controls={false}
|
||||
placeholder={t('pages.clients.telegramIdPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
{showReverseTag && (
|
||||
<Col xs={24} md={12}>
|
||||
<FormField name="reverseTag" label={t('pages.clients.reverseTag')}>
|
||||
<Input placeholder={t('pages.clients.reverseTagPlaceholder')} />
|
||||
</FormField>
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={24} md={12}>
|
||||
<FormField
|
||||
name="tgId"
|
||||
label={t('pages.clients.telegramId')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
controls={false}
|
||||
placeholder={t('pages.clients.telegramIdPlaceholder')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
@@ -1122,13 +1221,13 @@ export default function ClientFormModal({
|
||||
<SelectAllClearButtons
|
||||
options={inboundOptions}
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
onChange={handleInboundChange}
|
||||
/>
|
||||
<Select
|
||||
id={`${uid}-inbounds`}
|
||||
mode="multiple"
|
||||
value={inboundIds}
|
||||
onChange={(v) => methods.setValue('inboundIds', v)}
|
||||
onChange={handleInboundChange}
|
||||
options={inboundOptions}
|
||||
placeholder={t('pages.clients.selectInbound')}
|
||||
maxTagCount="responsive"
|
||||
|
||||
Reference in New Issue
Block a user