diff --git a/docs/content/docs/en/reference/api/hosts.mdx b/docs/content/docs/en/reference/api/hosts.mdx
index 4f14d7b5e..1abd431e4 100644
--- a/docs/content/docs/en/reference/api/hosts.mdx
+++ b/docs/content/docs/en/reference/api/hosts.mdx
@@ -12,6 +12,10 @@ _openapi:
title: List every host across all inbounds, grouped by inbound then ordered by
sort order.
url: '#list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order'
+ - depth: 2
+ title: List every host row with its numeric id, for id-based pickers such as the
+ per-client host rule override.
+ url: '#list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override'
- depth: 2
title: Fetch a single host group by Group ID.
url: '#fetch-a-single-host-group-by-group-id'
@@ -52,6 +56,9 @@ _openapi:
- content: List every host across all inbounds, grouped by inbound then ordered by
sort order.
id: list-every-host-across-all-inbounds-grouped-by-inbound-then-ordered-by-sort-order
+ - content: List every host row with its numeric id, for id-based pickers such as
+ the per-client host rule override.
+ id: list-every-host-row-with-its-numeric-id-for-id-based-pickers-such-as-the-per-client-host-rule-override
- content: Fetch a single host group by Group ID.
id: fetch-a-single-host-group-by-group-id
- content: Fetch one inbound's hosts, grouped by host group.
@@ -86,7 +93,7 @@ export default function Layout(props) {
return (
<>
{props.children}
-
+
>
);
}
\ No newline at end of file
diff --git a/docs/public/openapi.json b/docs/public/openapi.json
index 2d2022b87..f7ea3aa0d 100644
--- a/docs/public/openapi.json
+++ b/docs/public/openapi.json
@@ -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": [
diff --git a/frontend/package.json b/frontend/package.json
index 1aef378c3..c376188b2 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -29,7 +29,7 @@
},
"lint-staged": {
"src/**/*.{ts,tsx}": [
- "oxfmt",
+ "oxfmt --no-error-on-unmatched-pattern",
"oxlint --fix"
]
},
diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json
index 2d2022b87..f7ea3aa0d 100644
--- a/frontend/public/openapi.json
+++ b/frontend/public/openapi.json
@@ -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": [
diff --git a/frontend/src/api/queries/useRawHostsQuery.ts b/frontend/src/api/queries/useRawHostsQuery.ts
new file mode 100644
index 000000000..8c353daa5
--- /dev/null
+++ b/frontend/src/api/queries/useRawHostsQuery.ts
@@ -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;
+
+const RawHostListSchema = z
+ .array(RawHostSchema)
+ .nullable()
+ .transform((v) => v ?? []);
+
+async function fetchRawHosts(): Promise {
+ 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,
+ };
+}
diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts
index dc34222fa..4c6db1446 100644
--- a/frontend/src/api/queryKeys.ts
+++ b/frontend/src/api/queryKeys.ts
@@ -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,
},
diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts
index ea20e92d8..c839263cc 100644
--- a/frontend/src/generated/examples.ts
+++ b/frontend/src/generated/examples.ts
@@ -417,6 +417,10 @@ export const EXAMPLES: Record = {
],
"allowedIPsByInbound": {},
"auth": "",
+ "clientHostRuleIds": [
+ 1,
+ 2
+ ],
"comment": "",
"created_at": 0,
"email": "",
@@ -506,6 +510,10 @@ export const EXAMPLES: Record = {
"adTag": "",
"allowedIPs": "",
"auth": "",
+ "clientHostRuleIds": [
+ 1,
+ 2
+ ],
"comment": "",
"createdAt": 0,
"email": "",
diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts
index ab2119314..233e62ef3 100644
--- a/frontend/src/generated/schemas.ts
+++ b/frontend/src/generated/schemas.ts
@@ -1704,6 +1704,17 @@ export const SCHEMAS: Record = {
"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 = {
"auth": {
"type": "string"
},
+ "clientHostRuleIds": {
+ "example": [
+ 1,
+ 2
+ ],
+ "items": {
+ "type": "integer"
+ },
+ "type": "array"
+ },
"comment": {
"type": "string"
},
diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts
index 845096c9d..ecf17ef1f 100644
--- a/frontend/src/generated/types.ts
+++ b/frontend/src/generated/types.ts
@@ -413,6 +413,7 @@ export interface Client {
allowedIPs?: string[];
allowedIPsByInbound?: Record;
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;
diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts
index aeb8a3c53..921a6a0cd 100644
--- a/frontend/src/generated/zod.ts
+++ b/frontend/src/generated/zod.ts
@@ -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(),
diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts
index 9765ed52b..328ac75f5 100644
--- a/frontend/src/pages/api-docs/endpoints.ts
+++ b/frontend/src/pages/api-docs/endpoints.ts
@@ -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',
diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx
index 643471e8f..620ac9d29 100644
--- a/frontend/src/pages/clients/ClientFormModal.tsx
+++ b/frontend/src/pages/clients/ClientFormModal.tsx
@@ -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();
+ 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(
() => (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({
- {(tgBotEnable || showReverseTag) && (
+
+
+
+
+
+ {showReverseTag && (
+
+
+
+
+
+ )}
+
+
+ {tgBotEnable && (
- {tgBotEnable && (
-
- Number(v) || 0 }}
- >
-
-
-
- )}
- {showReverseTag && (
-
-
-
-
-
- )}
+
+ Number(v) || 0 }}
+ >
+
+
+
)}
@@ -1122,13 +1221,13 @@ export default function ClientFormModal({
methods.setValue('inboundIds', v)}
+ onChange={handleInboundChange}
/>