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) && ( + + + + + + + )} + + + {tgBotEnable && ( - {tgBotEnable && ( - - Number(v) || 0 }} - > - - - - )} - {showReverseTag && ( - - - - - - )} + + Number(v) || 0 }} + > + + + )} @@ -1122,13 +1221,13 @@ export default function ClientFormModal({ methods.setValue('inboundIds', v)} + onChange={handleInboundChange} />