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 && ( <> - - + +