From 8da3b3c34480b4728a7800e25c28e83c9ee4bea3 Mon Sep 17 00:00:00 2001 From: Pejman Yousefi Date: Sat, 24 Oct 2026 22:35:42 +0330 Subject: [PATCH] feat(hosts): give a host a pool of SNIs, random per config or shared (#6923) Rotating SNIs meant one host entry per domain. A host now takes a pool of SNIs: with more than one, every generated config gets a random entry, or, with the shared switch on, the whole host group uses one pick per subscription fetch. Raw, JSON and Clash output, the panel's link preview and node adoption all carry the pool. sni stays the pool's first entry, so a rolled-back binary and API callers that only know the single field keep working; an sni outside the pool becomes its primary. A host with one SNI renders exactly as before. --- docs/public/openapi.json | 49 ++++++ frontend/public/openapi.json | 49 ++++++ frontend/src/generated/examples.ts | 8 + frontend/src/generated/schemas.ts | 21 +++ frontend/src/generated/types.ts | 4 + frontend/src/generated/zod.ts | 4 + frontend/src/lib/hosts/host-link.ts | 19 ++- frontend/src/pages/hosts/HostFormModal.tsx | 61 +++++++- frontend/src/schemas/api/host.ts | 4 + frontend/src/test/host-link.test.ts | 96 +++++++++++- internal/database/db.go | 1 + internal/database/model/model.go | 2 + internal/sub/host_order.go | 2 +- internal/sub/host_sub.go | 56 ++++++- internal/sub/host_sub_test.go | 170 +++++++++++++++++++++ internal/sub/service.go | 2 + internal/web/entity/entity.go | 2 + internal/web/service/host.go | 48 +++++- internal/web/service/host_test.go | 76 +++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/sk-SK.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 33 files changed, 675 insertions(+), 13 deletions(-) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index a155f78e4..f470c1811 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2832,6 +2832,15 @@ "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": {}, "sortOrder": { "type": "integer" @@ -2885,6 +2894,7 @@ "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", "sockoptParams", "sortOrder", "tags", @@ -3017,6 +3027,15 @@ "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": { "type": "string" }, @@ -3065,6 +3084,8 @@ "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", + "snis", "sockoptParams", "sortOrder", "tags", @@ -12126,6 +12147,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12213,6 +12238,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12310,6 +12339,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12409,6 +12442,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12558,6 +12595,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12681,6 +12722,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12934,6 +12979,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index a155f78e4..f470c1811 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2832,6 +2832,15 @@ "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": {}, "sortOrder": { "type": "integer" @@ -2885,6 +2894,7 @@ "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", "sockoptParams", "sortOrder", "tags", @@ -3017,6 +3027,15 @@ "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": { "type": "string" }, @@ -3065,6 +3084,8 @@ "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", + "snis", "sockoptParams", "sortOrder", "tags", @@ -12126,6 +12147,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12213,6 +12238,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12310,6 +12339,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12409,6 +12442,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ @@ -12558,6 +12595,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12681,6 +12722,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -12934,6 +12979,10 @@ "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 0af9b99c2..ca8a9f83c 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -722,6 +722,10 @@ export const EXAMPLES: Record = { "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": null, "sortOrder": 0, "tags": [ @@ -772,6 +776,10 @@ export const EXAMPLES: Record = { "serverDescription": "", "shuffleHost": false, "sni": "", + "sniSharedRandom": false, + "snis": [ + "" + ], "sockoptParams": "", "sortOrder": 0, "tags": [ diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 28cc3f3ac..4f795f5f5 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2806,6 +2806,15 @@ export const SCHEMAS: Record = { "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": {}, "sortOrder": { "type": "integer" @@ -2859,6 +2868,7 @@ export const SCHEMAS: Record = { "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", "sockoptParams", "sortOrder", "tags", @@ -2991,6 +3001,15 @@ export const SCHEMAS: Record = { "sni": { "type": "string" }, + "sniSharedRandom": { + "type": "boolean" + }, + "snis": { + "items": { + "type": "string" + }, + "type": "array" + }, "sockoptParams": { "type": "string" }, @@ -3039,6 +3058,8 @@ export const SCHEMAS: Record = { "serverDescription", "shuffleHost", "sni", + "sniSharedRandom", + "snis", "sockoptParams", "sortOrder", "tags", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 8522f0ce6..bbfe37d8e 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -658,6 +658,8 @@ export interface Host { serverDescription: string; shuffleHost: boolean; sni: string; + sniSharedRandom: boolean; + snis?: string[]; sockoptParams: unknown; sortOrder: number; tags: string[]; @@ -695,6 +697,8 @@ export interface HostGroup { serverDescription: string; shuffleHost: boolean; sni: string; + sniSharedRandom: boolean; + snis: string[]; sockoptParams: string; sortOrder: number; tags: string[]; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 550301f97..cc4a6b0e6 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -698,6 +698,8 @@ export const HostSchema = z.object({ serverDescription: z.string().max(64), shuffleHost: z.boolean(), sni: z.string(), + sniSharedRandom: z.boolean(), + snis: z.array(z.string()).optional(), sockoptParams: z.unknown(), sortOrder: z.number().int(), tags: z.array(z.string()), @@ -736,6 +738,8 @@ export const HostGroupSchema = z.object({ serverDescription: z.string().max(64), shuffleHost: z.boolean(), sni: z.string(), + sniSharedRandom: z.boolean(), + snis: z.array(z.string()), sockoptParams: z.string(), sortOrder: z.number().int(), tags: z.array(z.string()), diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index 5c44c76e5..719ded3fc 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/frontend/src/lib/hosts/host-link.ts @@ -88,6 +88,18 @@ function splitAdvertisedHost(value: string, inboundPort: number): [string, numbe return [addr, inboundPort]; } +function randomSni(snis: string[]): string { + return snis[Math.floor(Math.random() * snis.length)] ?? ''; +} + +// The SNI one endpoint advertises before the address and blank overrides: the group's shared +// pick, a fresh random entry of a multi-SNI pool, or the single SNI. +function poolSni(record: HostRecord, shared: string | undefined): string | undefined { + if (shared) return shared; + const snis = (record.snis ?? []).filter((s) => s.trim() !== ''); + return snis.length > 1 ? randomSni(snis) : record.sni || undefined; +} + export interface HostEndpoint { dest: string; port: number; @@ -115,6 +127,9 @@ export function hostEndpointsFor( ) { continue; } + const snis = (record.snis ?? []).filter((s) => s.trim() !== ''); + const shared = record.sniSharedRandom && snis.length > 1 ? randomSni(snis) : undefined; + for (const value of record.hosts) { const [address, port] = splitAdvertisedHost(value, inboundPort); const dest = address || defaultDest; @@ -129,7 +144,7 @@ export function hostEndpointsFor( remark = record.remark ? `${record.remark} - ${i + 1}` : `${i + 1}`; } const endpoint: HostEndpoint = { dest: ip, port, remark }; - const sni = record.overrideSniFromAddress ? ip : record.sni; + const sni = record.overrideSniFromAddress ? ip : poolSni(record, shared); if (!record.keepSniBlank && sni) endpoint.sni = sni; if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn; if (record.allowInsecure) endpoint.allowInsecure = true; @@ -139,7 +154,7 @@ export function hostEndpointsFor( } } const endpoint: HostEndpoint = { dest, port, remark: record.remark || '' }; - const sni = record.overrideSniFromAddress ? dest : record.sni; + const sni = record.overrideSniFromAddress ? dest : poolSni(record, shared); if (!record.keepSniBlank && sni) endpoint.sni = sni; if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn; if (record.allowInsecure) endpoint.allowInsecure = true; diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index b126cff65..e29b84e54 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -53,6 +53,13 @@ interface HostFormModalProps { const asString = (v: unknown): string => (typeof v === 'string' ? v : ''); function defaultsFor(host: HostRecord | null): FormShape { + const initialSnis = + host?.snis && host.snis.length > 0 + ? host.snis.filter((s) => s && s.trim() !== '') + : host?.sni && host.sni.trim() !== '' + ? [host.sni.trim()] + : []; + return { inboundIds: host?.inboundIds ?? [], hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''), @@ -63,8 +70,10 @@ function defaultsFor(host: HostRecord | null): FormShape { isHidden: host?.isHidden ?? false, tags: host?.tags ?? [], port: host?.port ?? 0, - security: (host?.security as BulkAddHostValues['security']) ?? 'same', + security: host?.security ? (host.security as BulkAddHostValues['security']) : 'same', sni: host?.sni ?? '', + snis: initialSnis, + sniSharedRandom: initialSnis.length > 1 && (host?.sniSharedRandom ?? false), hostHeader: host?.hostHeader ?? '', path: host?.path ?? '', alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [], @@ -107,8 +116,14 @@ export default function HostFormModal({ const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string; const fingerprint = (useWatch({ control: methods.control, name: 'fingerprint' }) ?? '') as string; + const watchedSnis = useWatch({ control: methods.control, name: 'snis' }); + const hasMultipleSnis = Boolean( + watchedSnis && watchedSnis.filter((s) => typeof s === 'string' && s.trim() !== '').length > 1, + ); + + const watchedSingleSni = useWatch({ control: methods.control, name: 'sni' }) ?? ''; + const hostSni = (watchedSnis && watchedSnis.length > 0 ? watchedSnis[0] : '') || watchedSingleSni; const hostEchConfig = useWatch({ control: methods.control, name: 'echConfigList' }) ?? ''; - const hostSni = useWatch({ control: methods.control, name: 'sni' }) ?? ''; const showTls = security === 'tls' || security === 'reality' || security === 'same'; const showTlsExtras = security === 'tls' || security === 'same'; const isCipherSuitesEnabled = showTlsExtras && (!fingerprint || fingerprint === 'unsafe'); @@ -173,13 +188,33 @@ export default function HostFormModal({ return Array.from(addresses).map((addr) => ({ value: addr, label: addr })); }, [existingHosts]); + const sniOptions = useMemo(() => { + const snis = new Set(); + for (const h of existingHosts || []) { + if (h.snis) { + for (const s of h.snis) { + if (s && s.trim() !== '') snis.add(s.trim()); + } + } + if (h.sni && h.sni.trim() !== '') { + snis.add(h.sni.trim()); + } + } + return Array.from(snis).map((s) => ({ value: s, label: s })); + }, [existingHosts]); + const onFinish = async (values: FormShape) => { if (loading) return; const { enable, ...rest } = values; const isDisabled = !enable; + const cleanedSnis = (rest.snis || []).map((s) => s.trim()).filter(Boolean); + const primarySni = cleanedSnis.length > 0 ? cleanedSnis[0] : rest.sni || ''; const payload: BulkAddHostValues = { ...rest, hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''), + sni: primarySni, + snis: cleanedSnis, + sniSharedRandom: cleanedSnis.length > 1 && Boolean(rest.sniSharedRandom), isDisabled, }; setLoading(true); @@ -335,9 +370,27 @@ export default function HostFormModal({ {showTls && ( <> - - + +