mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
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:
@@ -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": [
|
||||
|
||||
@@ -722,6 +722,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"serverDescription": "",
|
||||
"shuffleHost": false,
|
||||
"sni": "",
|
||||
"sniSharedRandom": false,
|
||||
"snis": [
|
||||
""
|
||||
],
|
||||
"sockoptParams": null,
|
||||
"sortOrder": 0,
|
||||
"tags": [
|
||||
@@ -772,6 +776,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"serverDescription": "",
|
||||
"shuffleHost": false,
|
||||
"sni": "",
|
||||
"sniSharedRandom": false,
|
||||
"snis": [
|
||||
""
|
||||
],
|
||||
"sockoptParams": "",
|
||||
"sortOrder": 0,
|
||||
"tags": [
|
||||
|
||||
@@ -2806,6 +2806,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"sni": {
|
||||
"type": "string"
|
||||
},
|
||||
"sniSharedRandom": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"snis": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"sockoptParams": {},
|
||||
"sortOrder": {
|
||||
"type": "integer"
|
||||
@@ -2859,6 +2868,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"serverDescription",
|
||||
"shuffleHost",
|
||||
"sni",
|
||||
"sniSharedRandom",
|
||||
"sockoptParams",
|
||||
"sortOrder",
|
||||
"tags",
|
||||
@@ -2991,6 +3001,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"sni": {
|
||||
"type": "string"
|
||||
},
|
||||
"sniSharedRandom": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"snis": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"sockoptParams": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -3039,6 +3058,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"serverDescription",
|
||||
"shuffleHost",
|
||||
"sni",
|
||||
"sniSharedRandom",
|
||||
"snis",
|
||||
"sockoptParams",
|
||||
"sortOrder",
|
||||
"tags",
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const HostFormSchema = z.object({
|
||||
|
||||
security: HostSecuritySchema.default('same'),
|
||||
sni: z.string().default(''),
|
||||
snis: z.array(z.string()).default([]),
|
||||
sniSharedRandom: z.boolean().default(false),
|
||||
hostHeader: z.string().default(''),
|
||||
path: z.string().default(''),
|
||||
alpn: z.array(AlpnSchema).default([]),
|
||||
@@ -85,6 +87,8 @@ export const HostRecordSchema = z
|
||||
port: z.number().optional(),
|
||||
security: z.string().optional(),
|
||||
sni: z.string().optional(),
|
||||
snis: z.array(z.string()).nullish(),
|
||||
sniSharedRandom: z.boolean().optional(),
|
||||
hostHeader: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
alpn: z.array(z.string()).nullish(),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { hostToExternalProxyEntry, withHostEndpoints } from '@/lib/hosts/host-link';
|
||||
import {
|
||||
hostEndpointsFor,
|
||||
hostToExternalProxyEntry,
|
||||
withHostEndpoints,
|
||||
} from '@/lib/hosts/host-link';
|
||||
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||
|
||||
describe('hostToExternalProxyEntry', () => {
|
||||
@@ -137,3 +141,93 @@ describe('withHostEndpoints', () => {
|
||||
expect(got).toBe(inbound);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hostEndpointsFor - multi SNI', () => {
|
||||
it('applies the same random SNI to all endpoints when sniSharedRandom is true', () => {
|
||||
const snis = ['sni1.example.com', 'sni2.example.com'];
|
||||
const makeGroup = () =>
|
||||
hostEndpointsFor(
|
||||
[
|
||||
{
|
||||
groupId: 'shared',
|
||||
inboundIds: [1],
|
||||
hosts: ['h1.example.com', 'h2.example.com', 'h3.example.com'],
|
||||
port: 443,
|
||||
snis,
|
||||
sniSharedRandom: true,
|
||||
},
|
||||
],
|
||||
1,
|
||||
443,
|
||||
'default.com',
|
||||
);
|
||||
|
||||
const eps = makeGroup();
|
||||
expect(eps).toHaveLength(3);
|
||||
const chosenSni = eps[0]!.sni;
|
||||
expect(snis).toContain(chosenSni);
|
||||
for (const ep of eps) {
|
||||
expect(ep.sni).toBe(chosenSni);
|
||||
}
|
||||
|
||||
// Verify that across repeated calls, selection is randomized across the pool
|
||||
const picks = new Set(Array.from({ length: 30 }, () => makeGroup()[0]!.sni));
|
||||
expect(picks.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('randomizes SNI per endpoint for a multi-SNI pool', () => {
|
||||
const snis = ['sni1.example.com', 'sni2.example.com'];
|
||||
const hosts = Array.from({ length: 30 }, (_, i) => `h${i}.example.com`);
|
||||
const eps = hostEndpointsFor(
|
||||
[
|
||||
{
|
||||
groupId: 'rand',
|
||||
inboundIds: [1],
|
||||
hosts,
|
||||
port: 443,
|
||||
snis,
|
||||
},
|
||||
],
|
||||
1,
|
||||
443,
|
||||
'default.com',
|
||||
);
|
||||
expect(eps).toHaveLength(30);
|
||||
const seen = new Set<string>();
|
||||
for (const ep of eps) {
|
||||
expect(snis).toContain(ep.sni);
|
||||
if (ep.sni) seen.add(ep.sni);
|
||||
}
|
||||
expect(seen.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('gives precedence to overrideSniFromAddress and keepSniBlank over snis', () => {
|
||||
const snis = ['sni1.example.com', 'sni2.example.com'];
|
||||
const eps = hostEndpointsFor(
|
||||
[
|
||||
{
|
||||
groupId: 'override',
|
||||
inboundIds: [1],
|
||||
hosts: ['dest.example.com'],
|
||||
port: 443,
|
||||
snis,
|
||||
overrideSniFromAddress: true,
|
||||
},
|
||||
{
|
||||
groupId: 'blank',
|
||||
inboundIds: [1],
|
||||
hosts: ['blank.example.com'],
|
||||
port: 443,
|
||||
snis,
|
||||
keepSniBlank: true,
|
||||
},
|
||||
],
|
||||
1,
|
||||
443,
|
||||
'default.com',
|
||||
);
|
||||
expect(eps).toHaveLength(2);
|
||||
expect(eps[0]!.sni).toBe('dest.example.com');
|
||||
expect(eps[1]!.sni).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user