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.
This commit is contained in:
Pejman Yousefi
2026-10-24 22:35:42 +03:30
committed by GitHub
parent 14c9bdff89
commit 8da3b3c344
33 changed files with 675 additions and 13 deletions
+57 -4
View File
@@ -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<string>();
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({
</FormField>
{showTls && (
<>
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
<Input />
<FormField
name="snis"
label={t('pages.hosts.fields.sni')}
rules={{ validate: rhfZodValidate(BulkAddHostSchema.shape.snis) }}
>
<Select
mode="tags"
options={sniOptions}
tokenSeparators={[',', ';', ' ']}
placeholder="sni.example.com, sni2.example.com"
/>
</FormField>
{hasMultipleSnis && (
<FormField
name="sniSharedRandom"
label={t('pages.hosts.fields.sniSharedRandom')}
valueProp="checked"
>
<Switch />
</FormField>
)}
<FormField
name="overrideSniFromAddress"
label={t('pages.hosts.fields.overrideSniFromAddress')}