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
+1 -1
View File
@@ -29,7 +29,7 @@
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
"oxfmt",
"oxfmt --no-error-on-unmatched-pattern",
"oxlint --fix"
]
},
+109
View File
@@ -1730,6 +1730,17 @@
"description": "Auth password (Hysteria)",
"type": "string"
},
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"description": "Client comment",
"type": "string"
@@ -1967,6 +1978,16 @@
"auth": {
"type": "string"
},
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"type": "string"
},
@@ -12061,6 +12082,94 @@
}
}
},
"/panel/api/hosts/raw": {
"get": {
"tags": [
"Hosts"
],
"summary": "List every host row with its numeric id, for id-based pickers such as the per-client host rule override.",
"operationId": "get_panel_api_hosts_raw",
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"success": {
"type": "boolean"
},
"msg": {
"type": "string"
},
"obj": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
}
}
}
},
"example": {
"success": true,
"obj": [
{
"address": "cdn.example.com",
"allowInsecure": false,
"alpn": [
""
],
"cipherSuites": "",
"createdAt": 0,
"echConfigList": "",
"excludeFromSubTypes": [
""
],
"finalMask": "",
"fingerprint": "",
"groupId": "",
"hostHeader": "",
"id": 1,
"inboundId": 1,
"isDisabled": false,
"isHidden": false,
"keepSniBlank": false,
"mihomoIpVersion": "dual",
"mihomoX25519": false,
"muxParams": null,
"nodeGuids": [
""
],
"overrideSniFromAddress": false,
"path": "",
"pinnedPeerCertSha256": [
""
],
"port": 8443,
"randomCount": 1,
"remark": "cdn-front",
"security": "same",
"serverDescription": "",
"shuffleHost": false,
"sni": "",
"sockoptParams": null,
"sortOrder": 0,
"tags": [
""
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": "443"
}
]
}
}
}
}
}
}
},
"/panel/api/hosts/get/{groupId}": {
"get": {
"tags": [
@@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { z } from 'zod';
import { HttpUtil } from '@/utils';
import { parseMsg } from '@/utils/zodValidate';
import { keys } from '@/api/queryKeys';
// One row of the raw hosts table (GET /panel/api/hosts/raw), carrying the
// numeric id that per-client host rule bindings reference.
export const RawHostSchema = z
.object({
id: z.number(),
inboundId: z.number().optional(),
remark: z.string().optional(),
address: z.string().optional(),
port: z.number().optional(),
isDisabled: z.boolean().optional(),
})
.loose();
export type RawHost = z.infer<typeof RawHostSchema>;
const RawHostListSchema = z
.array(RawHostSchema)
.nullable()
.transform((v) => v ?? []);
async function fetchRawHosts(): Promise<RawHost[]> {
const msg = await HttpUtil.get('/panel/api/hosts/raw', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch hosts');
const validated = parseMsg(msg, RawHostListSchema, 'hosts/raw');
return Array.isArray(validated.obj) ? validated.obj : [];
}
export function useRawHostsQuery() {
const query = useQuery({
queryKey: keys.hosts.raw(),
queryFn: fetchRawHosts,
});
const hosts = useMemo(() => query.data ?? [], [query.data]);
return {
hosts,
loading: query.isFetching,
fetched: query.data !== undefined || query.isError,
fetchError: query.error ? (query.error as Error).message : '',
refetch: query.refetch,
};
}
+1
View File
@@ -11,6 +11,7 @@ export const keys = {
hosts: {
root: () => ['hosts'] as const,
list: () => ['hosts', 'list'] as const,
raw: () => ['hosts', 'raw'] as const,
byInbound: (inboundId: number) => ['hosts', 'byInbound', inboundId] as const,
tags: () => ['hosts', 'tags'] as const,
},
+8
View File
@@ -417,6 +417,10 @@ export const EXAMPLES: Record<string, unknown> = {
],
"allowedIPsByInbound": {},
"auth": "",
"clientHostRuleIds": [
1,
2
],
"comment": "",
"created_at": 0,
"email": "",
@@ -506,6 +510,10 @@ export const EXAMPLES: Record<string, unknown> = {
"adTag": "",
"allowedIPs": "",
"auth": "",
"clientHostRuleIds": [
1,
2
],
"comment": "",
"createdAt": 0,
"email": "",
+21
View File
@@ -1704,6 +1704,17 @@ export const SCHEMAS: Record<string, unknown> = {
"description": "Auth password (Hysteria)",
"type": "string"
},
"clientHostRuleIds": {
"description": "ClientHostRuleIds binds the client to these host rules, one link each; nil preserves, empty\nclears, and bindings with no usable host leave that inbound out of the subscription.",
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"description": "Client comment",
"type": "string"
@@ -1941,6 +1952,16 @@ export const SCHEMAS: Record<string, unknown> = {
"auth": {
"type": "string"
},
"clientHostRuleIds": {
"example": [
1,
2
],
"items": {
"type": "integer"
},
"type": "array"
},
"comment": {
"type": "string"
},
+2
View File
@@ -413,6 +413,7 @@ export interface Client {
allowedIPs?: string[];
allowedIPsByInbound?: Record<number, string[]>;
auth?: string;
clientHostRuleIds?: number[];
comment: string;
created_at?: number;
email: string;
@@ -464,6 +465,7 @@ export interface ClientRecord {
adTag: string;
allowedIPs: string;
auth: string;
clientHostRuleIds?: number[];
comment: string;
createdAt: number;
email: string;
+2
View File
@@ -434,6 +434,7 @@ export const ClientSchema = z.object({
allowedIPs: z.array(z.string()).optional(),
allowedIPsByInbound: z.record(z.number().int(), z.array(z.string())).optional(),
auth: z.string().optional(),
clientHostRuleIds: z.array(z.number().int()).optional(),
comment: z.string(),
created_at: z.number().int().optional(),
email: z.string(),
@@ -488,6 +489,7 @@ export const ClientRecordSchema = z.object({
adTag: z.string(),
allowedIPs: z.string(),
auth: z.string(),
clientHostRuleIds: z.array(z.number().int()).optional(),
comment: z.string(),
createdAt: z.number().int(),
email: z.string(),
+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"
+4
View File
@@ -32,6 +32,8 @@ export const ClientRecordSchema = z
auth: z.string().optional(),
flow: z.string().optional(),
security: z.string().optional(),
// Host rules the client is bound to; null/omitted preserves, empty clears.
clientHostRuleIds: z.array(z.number().int()).nullable().optional(),
totalGB: z.number().optional(),
expiryTime: z.number().optional(),
limitIp: z.number().optional(),
@@ -324,6 +326,8 @@ export const ClientFormSchema = z.object({
auth: z.string(),
flow: z.string(),
security: z.string(),
// Host rules the client is bound to; null/omitted preserves, empty clears.
clientHostRuleIds: z.array(z.number().int()).nullable().optional(),
reverseTag: z.string(),
totalGB: z.number().min(0),
delayedStart: z.boolean(),
@@ -0,0 +1,77 @@
import { describe, it, expect, vi } from 'vitest';
import type { ComponentProps } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
import ClientFormModal from '@/pages/clients/ClientFormModal';
import type { ClientRecord, InboundOption } from '@/hooks/useClients';
vi.mock('@/api/queries/useRawHostsQuery', () => ({
useRawHostsQuery: () => ({
hosts: [
{ id: 99, inboundId: 1, remark: 'cdn-disabled', address: 'c.example.com', isDisabled: true },
{ id: 100, inboundId: 1, remark: 'cdn-live', address: 'live.example.com', isDisabled: false },
{ id: 200, inboundId: 2, remark: 'other', address: 'o.example.com', isDisabled: false },
],
loading: false,
fetched: true,
fetchError: '',
refetch: vi.fn(),
}),
}));
function makeQC() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
}
const INBOUNDS = [
{ id: 1, port: 443, protocol: 'vless', tag: 'in-1', remark: 'one', enable: true },
{ id: 2, port: 8443, protocol: 'vless', tag: 'in-2', remark: 'two', enable: true },
] as unknown as InboundOption[];
const CLIENT = {
email: 'bound@example.com',
uuid: '11111111-1111-1111-1111-111111111111',
subId: 'subid123',
enable: true,
clientHostRuleIds: [99],
} as unknown as ClientRecord;
function renderEdit(save: ComponentProps<typeof ClientFormModal>['save']) {
const qc = makeQC();
return {
save,
view: (
<ThemeProvider>
<QueryClientProvider client={qc}>
<ClientFormModal
open
mode="edit"
client={CLIENT}
inbounds={INBOUNDS}
attachedIds={[1]}
save={save}
onOpenChange={() => {}}
/>
</QueryClientProvider>
</ThemeProvider>
),
};
}
describe('ClientFormModal — disabled host binding preservation', () => {
it('keeps a disabled host binding when saving an unrelated field', async () => {
const save = vi.fn().mockResolvedValue({ success: true });
const { view } = renderEdit(save);
render(view);
const comment = await screen.findByLabelText('Comment');
fireEvent.change(comment, { target: { value: 'touched' } });
fireEvent.click(await screen.findByRole('button', { name: /save/i }));
await waitFor(() => expect(save).toHaveBeenCalled());
const payload = save.mock.calls[0][0] as Record<string, unknown>;
expect(payload.clientHostRuleIds).toContain(99);
});
});
+10 -2
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi, afterEach } from 'vitest';
import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
import { EditorView } from 'codemirror';
@@ -39,6 +39,13 @@ function routingLink(value: Record<string, unknown>, mode = 'onadd') {
return `happ://routing/${mode}/${toBase64Utf8(JSON.stringify(value))}`;
}
// Unmount while jsdom is alive so CodeMirror destroys its view
// before the shared cleanup drains pending scheduler callbacks.
const mounted: Array<() => void> = [];
afterEach(() => {
while (mounted.length) mounted.pop()?.();
});
function renderSettings(input = routingLink(profile)) {
const updateSetting = vi.fn<(patch: Partial<AllSetting>) => void>();
@@ -60,7 +67,8 @@ function renderSettings(input = routingLink(profile)) {
);
}
renderWithProviders(<SettingsHarness />);
const { unmount } = renderWithProviders(<SettingsHarness />);
mounted.push(unmount);
return updateSetting;
}
+5 -3
View File
@@ -107,6 +107,8 @@ import { HttpUtil, Msg } from '@/utils';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
vi.spyOn(HttpUtil, 'get').mockImplementation(
async (url: string) => new Msg(true, '', url.includes('/panel/api/inbounds/options') ? [] : {}),
);
// Array-returning endpoints must resolve to [] so Zod list schemas validate.
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
const isList = url.includes('/panel/api/inbounds/options') || url.includes('/panel/api/hosts/');
return new Msg(true, '', isList ? [] : {});
});