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:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
+8
View File
@@ -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',
+125 -26
View File
@@ -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"