mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-11 15:45:00 +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": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {},
|
"sockoptParams": {},
|
||||||
"sortOrder": {
|
"sortOrder": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2885,6 +2894,7 @@
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -3017,6 +3027,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {
|
"sockoptParams": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3065,6 +3084,8 @@
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
|
"snis",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -12126,6 +12147,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12213,6 +12238,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12310,6 +12339,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12409,6 +12442,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12558,6 +12595,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12681,6 +12722,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12934,6 +12979,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -2832,6 +2832,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {},
|
"sockoptParams": {},
|
||||||
"sortOrder": {
|
"sortOrder": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2885,6 +2894,7 @@
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -3017,6 +3027,15 @@
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {
|
"sockoptParams": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3065,6 +3084,8 @@
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
|
"snis",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -12126,6 +12147,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12213,6 +12238,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12310,6 +12339,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12409,6 +12442,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12558,6 +12595,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12681,6 +12722,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -12934,6 +12979,10 @@
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -722,6 +722,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": null,
|
"sockoptParams": null,
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -772,6 +776,10 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"serverDescription": "",
|
"serverDescription": "",
|
||||||
"shuffleHost": false,
|
"shuffleHost": false,
|
||||||
"sni": "",
|
"sni": "",
|
||||||
|
"sniSharedRandom": false,
|
||||||
|
"snis": [
|
||||||
|
""
|
||||||
|
],
|
||||||
"sockoptParams": "",
|
"sockoptParams": "",
|
||||||
"sortOrder": 0,
|
"sortOrder": 0,
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@@ -2806,6 +2806,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {},
|
"sockoptParams": {},
|
||||||
"sortOrder": {
|
"sortOrder": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
@@ -2859,6 +2868,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
@@ -2991,6 +3001,15 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"sni": {
|
"sni": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"sniSharedRandom": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"snis": {
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
},
|
||||||
"sockoptParams": {
|
"sockoptParams": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -3039,6 +3058,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"serverDescription",
|
"serverDescription",
|
||||||
"shuffleHost",
|
"shuffleHost",
|
||||||
"sni",
|
"sni",
|
||||||
|
"sniSharedRandom",
|
||||||
|
"snis",
|
||||||
"sockoptParams",
|
"sockoptParams",
|
||||||
"sortOrder",
|
"sortOrder",
|
||||||
"tags",
|
"tags",
|
||||||
|
|||||||
@@ -658,6 +658,8 @@ export interface Host {
|
|||||||
serverDescription: string;
|
serverDescription: string;
|
||||||
shuffleHost: boolean;
|
shuffleHost: boolean;
|
||||||
sni: string;
|
sni: string;
|
||||||
|
sniSharedRandom: boolean;
|
||||||
|
snis?: string[];
|
||||||
sockoptParams: unknown;
|
sockoptParams: unknown;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -695,6 +697,8 @@ export interface HostGroup {
|
|||||||
serverDescription: string;
|
serverDescription: string;
|
||||||
shuffleHost: boolean;
|
shuffleHost: boolean;
|
||||||
sni: string;
|
sni: string;
|
||||||
|
sniSharedRandom: boolean;
|
||||||
|
snis: string[];
|
||||||
sockoptParams: string;
|
sockoptParams: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
|||||||
@@ -698,6 +698,8 @@ export const HostSchema = z.object({
|
|||||||
serverDescription: z.string().max(64),
|
serverDescription: z.string().max(64),
|
||||||
shuffleHost: z.boolean(),
|
shuffleHost: z.boolean(),
|
||||||
sni: z.string(),
|
sni: z.string(),
|
||||||
|
sniSharedRandom: z.boolean(),
|
||||||
|
snis: z.array(z.string()).optional(),
|
||||||
sockoptParams: z.unknown(),
|
sockoptParams: z.unknown(),
|
||||||
sortOrder: z.number().int(),
|
sortOrder: z.number().int(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
@@ -736,6 +738,8 @@ export const HostGroupSchema = z.object({
|
|||||||
serverDescription: z.string().max(64),
|
serverDescription: z.string().max(64),
|
||||||
shuffleHost: z.boolean(),
|
shuffleHost: z.boolean(),
|
||||||
sni: z.string(),
|
sni: z.string(),
|
||||||
|
sniSharedRandom: z.boolean(),
|
||||||
|
snis: z.array(z.string()),
|
||||||
sockoptParams: z.string(),
|
sockoptParams: z.string(),
|
||||||
sortOrder: z.number().int(),
|
sortOrder: z.number().int(),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
|
|||||||
@@ -88,6 +88,18 @@ function splitAdvertisedHost(value: string, inboundPort: number): [string, numbe
|
|||||||
return [addr, inboundPort];
|
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 {
|
export interface HostEndpoint {
|
||||||
dest: string;
|
dest: string;
|
||||||
port: number;
|
port: number;
|
||||||
@@ -115,6 +127,9 @@ export function hostEndpointsFor(
|
|||||||
) {
|
) {
|
||||||
continue;
|
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) {
|
for (const value of record.hosts) {
|
||||||
const [address, port] = splitAdvertisedHost(value, inboundPort);
|
const [address, port] = splitAdvertisedHost(value, inboundPort);
|
||||||
const dest = address || defaultDest;
|
const dest = address || defaultDest;
|
||||||
@@ -129,7 +144,7 @@ export function hostEndpointsFor(
|
|||||||
remark = record.remark ? `${record.remark} - ${i + 1}` : `${i + 1}`;
|
remark = record.remark ? `${record.remark} - ${i + 1}` : `${i + 1}`;
|
||||||
}
|
}
|
||||||
const endpoint: HostEndpoint = { dest: ip, port, remark };
|
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.keepSniBlank && sni) endpoint.sni = sni;
|
||||||
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
|
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
|
||||||
if (record.allowInsecure) endpoint.allowInsecure = true;
|
if (record.allowInsecure) endpoint.allowInsecure = true;
|
||||||
@@ -139,7 +154,7 @@ export function hostEndpointsFor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const endpoint: HostEndpoint = { dest, port, remark: record.remark || '' };
|
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.keepSniBlank && sni) endpoint.sni = sni;
|
||||||
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
|
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
|
||||||
if (record.allowInsecure) endpoint.allowInsecure = true;
|
if (record.allowInsecure) endpoint.allowInsecure = true;
|
||||||
|
|||||||
@@ -53,6 +53,13 @@ interface HostFormModalProps {
|
|||||||
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
const asString = (v: unknown): string => (typeof v === 'string' ? v : '');
|
||||||
|
|
||||||
function defaultsFor(host: HostRecord | null): FormShape {
|
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 {
|
return {
|
||||||
inboundIds: host?.inboundIds ?? [],
|
inboundIds: host?.inboundIds ?? [],
|
||||||
hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
|
hosts: (host?.hosts || []).filter((h) => h && h.trim() !== ''),
|
||||||
@@ -63,8 +70,10 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
|||||||
isHidden: host?.isHidden ?? false,
|
isHidden: host?.isHidden ?? false,
|
||||||
tags: host?.tags ?? [],
|
tags: host?.tags ?? [],
|
||||||
port: host?.port ?? 0,
|
port: host?.port ?? 0,
|
||||||
security: (host?.security as BulkAddHostValues['security']) ?? 'same',
|
security: host?.security ? (host.security as BulkAddHostValues['security']) : 'same',
|
||||||
sni: host?.sni ?? '',
|
sni: host?.sni ?? '',
|
||||||
|
snis: initialSnis,
|
||||||
|
sniSharedRandom: initialSnis.length > 1 && (host?.sniSharedRandom ?? false),
|
||||||
hostHeader: host?.hostHeader ?? '',
|
hostHeader: host?.hostHeader ?? '',
|
||||||
path: host?.path ?? '',
|
path: host?.path ?? '',
|
||||||
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
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 security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
|
||||||
const fingerprint = (useWatch({ control: methods.control, name: 'fingerprint' }) ?? '') 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 hostEchConfig = useWatch({ control: methods.control, name: 'echConfigList' }) ?? '';
|
||||||
const hostSni = useWatch({ control: methods.control, name: 'sni' }) ?? '';
|
|
||||||
const showTls = security === 'tls' || security === 'reality' || security === 'same';
|
const showTls = security === 'tls' || security === 'reality' || security === 'same';
|
||||||
const showTlsExtras = security === 'tls' || security === 'same';
|
const showTlsExtras = security === 'tls' || security === 'same';
|
||||||
const isCipherSuitesEnabled = showTlsExtras && (!fingerprint || fingerprint === 'unsafe');
|
const isCipherSuitesEnabled = showTlsExtras && (!fingerprint || fingerprint === 'unsafe');
|
||||||
@@ -173,13 +188,33 @@ export default function HostFormModal({
|
|||||||
return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
|
return Array.from(addresses).map((addr) => ({ value: addr, label: addr }));
|
||||||
}, [existingHosts]);
|
}, [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) => {
|
const onFinish = async (values: FormShape) => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
const { enable, ...rest } = values;
|
const { enable, ...rest } = values;
|
||||||
const isDisabled = !enable;
|
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 = {
|
const payload: BulkAddHostValues = {
|
||||||
...rest,
|
...rest,
|
||||||
hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
|
hosts: (rest.hosts || []).filter((h) => h && h.trim() !== ''),
|
||||||
|
sni: primarySni,
|
||||||
|
snis: cleanedSnis,
|
||||||
|
sniSharedRandom: cleanedSnis.length > 1 && Boolean(rest.sniSharedRandom),
|
||||||
isDisabled,
|
isDisabled,
|
||||||
};
|
};
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -335,9 +370,27 @@ export default function HostFormModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
{showTls && (
|
{showTls && (
|
||||||
<>
|
<>
|
||||||
<FormField name="sni" label={t('pages.hosts.fields.sni')}>
|
<FormField
|
||||||
<Input />
|
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>
|
</FormField>
|
||||||
|
{hasMultipleSnis && (
|
||||||
|
<FormField
|
||||||
|
name="sniSharedRandom"
|
||||||
|
label={t('pages.hosts.fields.sniSharedRandom')}
|
||||||
|
valueProp="checked"
|
||||||
|
>
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
<FormField
|
<FormField
|
||||||
name="overrideSniFromAddress"
|
name="overrideSniFromAddress"
|
||||||
label={t('pages.hosts.fields.overrideSniFromAddress')}
|
label={t('pages.hosts.fields.overrideSniFromAddress')}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const HostFormSchema = z.object({
|
|||||||
|
|
||||||
security: HostSecuritySchema.default('same'),
|
security: HostSecuritySchema.default('same'),
|
||||||
sni: z.string().default(''),
|
sni: z.string().default(''),
|
||||||
|
snis: z.array(z.string()).default([]),
|
||||||
|
sniSharedRandom: z.boolean().default(false),
|
||||||
hostHeader: z.string().default(''),
|
hostHeader: z.string().default(''),
|
||||||
path: z.string().default(''),
|
path: z.string().default(''),
|
||||||
alpn: z.array(AlpnSchema).default([]),
|
alpn: z.array(AlpnSchema).default([]),
|
||||||
@@ -85,6 +87,8 @@ export const HostRecordSchema = z
|
|||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
security: z.string().optional(),
|
security: z.string().optional(),
|
||||||
sni: z.string().optional(),
|
sni: z.string().optional(),
|
||||||
|
snis: z.array(z.string()).nullish(),
|
||||||
|
sniSharedRandom: z.boolean().optional(),
|
||||||
hostHeader: z.string().optional(),
|
hostHeader: z.string().optional(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
alpn: z.array(z.string()).nullish(),
|
alpn: z.array(z.string()).nullish(),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
import { describe, expect, it } from 'vitest';
|
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';
|
import { inboundFromDb } from '@/lib/xray/inbound-from-db';
|
||||||
|
|
||||||
describe('hostToExternalProxyEntry', () => {
|
describe('hostToExternalProxyEntry', () => {
|
||||||
@@ -137,3 +141,93 @@ describe('withHostEndpoints', () => {
|
|||||||
expect(got).toBe(inbound);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1018,6 +1018,7 @@ func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Ho
|
|||||||
Port: port,
|
Port: port,
|
||||||
Security: security,
|
Security: security,
|
||||||
Sni: sni,
|
Sni: sni,
|
||||||
|
Snis: anyToNonEmptyStrings([]any{sni}),
|
||||||
Fingerprint: fingerprint,
|
Fingerprint: fingerprint,
|
||||||
Alpn: anyToNonEmptyStrings(ep["alpn"]),
|
Alpn: anyToNonEmptyStrings(ep["alpn"]),
|
||||||
PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
|
PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
|
||||||
|
|||||||
@@ -1186,6 +1186,8 @@ type Host struct {
|
|||||||
|
|
||||||
Security string `json:"security" form:"security" gorm:"default:same" validate:"omitempty,oneof=same tls none reality" example:"same"`
|
Security string `json:"security" form:"security" gorm:"default:same" validate:"omitempty,oneof=same tls none reality" example:"same"`
|
||||||
Sni string `json:"sni" form:"sni"`
|
Sni string `json:"sni" form:"sni"`
|
||||||
|
Snis []string `json:"snis,omitempty" form:"snis" gorm:"serializer:json;column:snis"`
|
||||||
|
SniSharedRandom bool `json:"sniSharedRandom" form:"sniSharedRandom" gorm:"column:sni_shared_random"`
|
||||||
HostHeader string `json:"hostHeader" form:"hostHeader" gorm:"column:host_header"`
|
HostHeader string `json:"hostHeader" form:"hostHeader" gorm:"column:host_header"`
|
||||||
Path string `json:"path" form:"path"`
|
Path string `json:"path" form:"path"`
|
||||||
Alpn []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
|
Alpn []string `json:"alpn" form:"alpn" gorm:"serializer:json"`
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound)
|
|||||||
if v.host == nil {
|
if v.host == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port)
|
return s.hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
|
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package sub
|
package sub
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math/big"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
@@ -86,7 +88,7 @@ func (s *SubService) clientHostEndpoints(inbound *model.Inbound, client model.Cl
|
|||||||
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
|
eps = append(eps, s.hostEndpointMaps(h, defaultDest, inbound.Port)...)
|
||||||
}
|
}
|
||||||
return eps, true
|
return eps, true
|
||||||
}
|
}
|
||||||
@@ -136,14 +138,15 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
|
|||||||
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
|
eps = append(eps, s.hostEndpointMaps(h, defaultDest, inbound.Port)...)
|
||||||
}
|
}
|
||||||
return eps
|
return eps
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostEndpointMaps is one host's externalProxy entries: a CIDR address expands into RandomCount
|
// hostEndpointMaps is one host's externalProxy entries: a CIDR address expands into RandomCount
|
||||||
// random addresses from the range, any other address is a single entry.
|
// random addresses from the range, any other address is a single entry.
|
||||||
func hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[string]any {
|
func (s *SubService) hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[string]any {
|
||||||
|
shared := s.sharedHostSni(h)
|
||||||
if IsCIDR(h.Address) {
|
if IsCIDR(h.Address) {
|
||||||
count := max(h.RandomCount, 1)
|
count := max(h.RandomCount, 1)
|
||||||
if ips, err := RandomIPsFromCIDR(h.Address, count); err == nil && len(ips) > 0 {
|
if ips, err := RandomIPsFromCIDR(h.Address, count); err == nil && len(ips) > 0 {
|
||||||
@@ -158,12 +161,57 @@ func hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[
|
|||||||
hCopy.Remark = fmt.Sprintf("%d", i+1)
|
hCopy.Remark = fmt.Sprintf("%d", i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
hCopy.Sni = endpointSni(h, shared)
|
||||||
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, defaultPort))
|
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, defaultPort))
|
||||||
}
|
}
|
||||||
return eps
|
return eps
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []map[string]any{hostToExternalProxyMap(h, defaultDest, defaultPort)}
|
|
||||||
|
hCopy := *h
|
||||||
|
hCopy.Sni = endpointSni(h, shared)
|
||||||
|
return []map[string]any{hostToExternalProxyMap(&hCopy, defaultDest, defaultPort)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// endpointSni is the SNI one endpoint of h advertises: the request's shared pick, a fresh random
|
||||||
|
// entry of a multi-SNI pool, or the single SNI; the address and blank overrides apply later.
|
||||||
|
func endpointSni(h *model.Host, shared string) string {
|
||||||
|
switch {
|
||||||
|
case shared != "":
|
||||||
|
return shared
|
||||||
|
case len(h.Snis) > 1:
|
||||||
|
return randomSni(h.Snis)
|
||||||
|
}
|
||||||
|
return h.Sni
|
||||||
|
}
|
||||||
|
|
||||||
|
// sharedHostSni picks one SNI of a shared pool per host group and request, so every endpoint of
|
||||||
|
// the group carries the same one; "" when h does not share.
|
||||||
|
func (s *SubService) sharedHostSni(h *model.Host) string {
|
||||||
|
if !h.SniSharedRandom || len(h.Snis) < 2 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
key := h.GroupId
|
||||||
|
if key == "" {
|
||||||
|
key = fmt.Sprintf("host-%d", h.Id)
|
||||||
|
}
|
||||||
|
if sni, ok := s.sharedGroupSni[key]; ok {
|
||||||
|
return sni
|
||||||
|
}
|
||||||
|
if s.sharedGroupSni == nil {
|
||||||
|
s.sharedGroupSni = make(map[string]string)
|
||||||
|
}
|
||||||
|
sni := randomSni(h.Snis)
|
||||||
|
s.sharedGroupSni[key] = sni
|
||||||
|
return sni
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomSni(snis []string) string {
|
||||||
|
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(snis))))
|
||||||
|
if err != nil {
|
||||||
|
return snis[0]
|
||||||
|
}
|
||||||
|
return snis[idx.Int64()]
|
||||||
}
|
}
|
||||||
|
|
||||||
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
|
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
|
||||||
|
|||||||
@@ -1707,3 +1707,173 @@ func TestSub_ClientHostRule_SurvivesGroupEdit(t *testing.T) {
|
|||||||
t.Fatalf("clash must not contain the unbound host or old SNI:\n%s", yaml)
|
t.Fatalf("clash must not contain the unbound host or old SNI:\n%s", yaml)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSub_HostMultiSNI_RandomizePerConfig(t *testing.T) {
|
||||||
|
seedSubDB(t)
|
||||||
|
ib := seedSubInbound(t, "s1", "sni-rand", 4465, 1,
|
||||||
|
`{"network":"tcp","security":"tls"}`)
|
||||||
|
snis := []string{"sni1.example.com", "sni2.example.com"}
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 0,
|
||||||
|
Remark: "MultiSNI-CIDR",
|
||||||
|
Address: "198.51.100.0/24",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
RandomCount: 30,
|
||||||
|
Snis: snis,
|
||||||
|
})
|
||||||
|
|
||||||
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||||
|
if len(parts) != 30 {
|
||||||
|
t.Fatalf("len(parts) = %d, want 30", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
seenSNIs := make(map[string]int)
|
||||||
|
for _, l := range parts {
|
||||||
|
if strings.Contains(l, "sni=sni1.example.com") {
|
||||||
|
seenSNIs["sni1.example.com"]++
|
||||||
|
} else if strings.Contains(l, "sni=sni2.example.com") {
|
||||||
|
seenSNIs["sni2.example.com"]++
|
||||||
|
} else {
|
||||||
|
t.Fatalf("link carries unexpected SNI: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seenSNIs) < 2 {
|
||||||
|
t.Fatalf("expected both SNIs to be picked across 30 randomized configs, got: %v", seenSNIs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSub_HostMultiSNI_SharedRandom(t *testing.T) {
|
||||||
|
seedSubDB(t)
|
||||||
|
ib := seedSubInbound(t, "s1", "sni-shared", 4466, 1,
|
||||||
|
`{"network":"tcp","security":"tls"}`)
|
||||||
|
snis := []string{"sniA.example.com", "sniB.example.com"}
|
||||||
|
groupId := "grp_multi_sni_shared"
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
GroupId: groupId,
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 1,
|
||||||
|
Remark: "Host1",
|
||||||
|
Address: "h1.example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
SniSharedRandom: true,
|
||||||
|
})
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
GroupId: groupId,
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 2,
|
||||||
|
Remark: "Host2",
|
||||||
|
Address: "h2.example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
SniSharedRandom: true,
|
||||||
|
})
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
GroupId: groupId,
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 3,
|
||||||
|
Remark: "Host3",
|
||||||
|
Address: "h3.example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
SniSharedRandom: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
t.Fatalf("len(parts) = %d, want 3", len(parts))
|
||||||
|
}
|
||||||
|
|
||||||
|
var firstSNI string
|
||||||
|
for i, l := range parts {
|
||||||
|
var sni string
|
||||||
|
if strings.Contains(l, "sni=sniA.example.com") {
|
||||||
|
sni = "sniA.example.com"
|
||||||
|
} else if strings.Contains(l, "sni=sniB.example.com") {
|
||||||
|
sni = "sniB.example.com"
|
||||||
|
} else {
|
||||||
|
t.Fatalf("link[%d] missing expected SNI: %s", i, l)
|
||||||
|
}
|
||||||
|
if firstSNI == "" {
|
||||||
|
firstSNI = sni
|
||||||
|
} else if sni != firstSNI {
|
||||||
|
t.Fatalf("shared random mode mismatch: link[%d] had %s, expected %s across all configs in same group", i, sni, firstSNI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that across independent subscription fetches, both pool SNIs are selected
|
||||||
|
seenAcrossFetches := make(map[string]bool)
|
||||||
|
seenAcrossFetches[firstSNI] = true
|
||||||
|
for i := 0; i < 25; i++ {
|
||||||
|
l, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||||
|
if err == nil && len(l) > 0 {
|
||||||
|
if strings.Contains(l[0], "sni=sniA.example.com") {
|
||||||
|
seenAcrossFetches["sniA.example.com"] = true
|
||||||
|
} else if strings.Contains(l[0], "sni=sniB.example.com") {
|
||||||
|
seenAcrossFetches["sniB.example.com"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seenAcrossFetches) < 2 {
|
||||||
|
t.Fatalf("expected both SNIs to be selected across independent fetches, got: %v", seenAcrossFetches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSub_HostMultiSNI_Overrides(t *testing.T) {
|
||||||
|
seedSubDB(t)
|
||||||
|
ib := seedSubInbound(t, "s1", "sni-overrides", 4467, 1,
|
||||||
|
`{"network":"tcp","security":"tls"}`)
|
||||||
|
snis := []string{"sni1.example.com", "sni2.example.com"}
|
||||||
|
|
||||||
|
// When OverrideSniFromAddress is true, address is used as SNI.
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 1,
|
||||||
|
Remark: "AddrAsSni",
|
||||||
|
Address: "addr.example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
OverrideSniFromAddress: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// When KeepSniBlank is true, SNI is omitted.
|
||||||
|
seedHost(t, &model.Host{
|
||||||
|
InboundId: ib.Id,
|
||||||
|
SortOrder: 2,
|
||||||
|
Remark: "BlankSni",
|
||||||
|
Address: "blank.example.com",
|
||||||
|
Port: 8443,
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
KeepSniBlank: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetSubs: %v", err)
|
||||||
|
}
|
||||||
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
t.Fatalf("len(parts) = %d, want 2", len(parts))
|
||||||
|
}
|
||||||
|
if !strings.Contains(parts[0], "sni=addr.example.com") {
|
||||||
|
t.Errorf("expected address as sni: %s", parts[0])
|
||||||
|
}
|
||||||
|
if strings.Contains(parts[1], "sni=") {
|
||||||
|
t.Errorf("expected blank sni: %s", parts[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ type SubService struct {
|
|||||||
hostsByInbound map[int][]*model.Host
|
hostsByInbound map[int][]*model.Host
|
||||||
hostGroupOrders map[string]hostEntryOrder
|
hostGroupOrders map[string]hostEntryOrder
|
||||||
hostsFirst bool
|
hostsFirst bool
|
||||||
|
sharedGroupSni map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSubService creates a new subscription service with the given configuration.
|
// NewSubService creates a new subscription service with the given configuration.
|
||||||
@@ -117,6 +118,7 @@ func (s *SubService) PrepareForRequest(host string) {
|
|||||||
s.settingsByInbound = map[int]map[string]any{}
|
s.settingsByInbound = map[int]map[string]any{}
|
||||||
s.hostsByInbound = nil
|
s.hostsByInbound = nil
|
||||||
s.hostGroupOrders = nil
|
s.hostGroupOrders = nil
|
||||||
|
s.sharedGroupSni = nil
|
||||||
mode, _ := s.settingService.GetSubHostSortMode()
|
mode, _ := s.settingService.GetSubHostSortMode()
|
||||||
s.hostsFirst = mode == "hosts-first"
|
s.hostsFirst = mode == "hosts-first"
|
||||||
s.loadNodes()
|
s.loadNodes()
|
||||||
|
|||||||
@@ -428,6 +428,8 @@ type HostGroup struct {
|
|||||||
Port int `json:"port" validate:"gte=0,lte=65535"`
|
Port int `json:"port" validate:"gte=0,lte=65535"`
|
||||||
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
|
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
|
||||||
Sni string `json:"sni"`
|
Sni string `json:"sni"`
|
||||||
|
Snis []string `json:"snis"`
|
||||||
|
SniSharedRandom bool `json:"sniSharedRandom"`
|
||||||
HostHeader string `json:"hostHeader"`
|
HostHeader string `json:"hostHeader"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Alpn []string `json:"alpn"`
|
Alpn []string `json:"alpn"`
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
|
|||||||
Port: h.Port,
|
Port: h.Port,
|
||||||
Security: h.Security,
|
Security: h.Security,
|
||||||
Sni: h.Sni,
|
Sni: h.Sni,
|
||||||
|
Snis: h.Snis,
|
||||||
|
SniSharedRandom: h.SniSharedRandom,
|
||||||
HostHeader: h.HostHeader,
|
HostHeader: h.HostHeader,
|
||||||
Path: h.Path,
|
Path: h.Path,
|
||||||
Alpn: h.Alpn,
|
Alpn: h.Alpn,
|
||||||
@@ -96,6 +98,12 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
|||||||
if h.RandomCount > g.RandomCount {
|
if h.RandomCount > g.RandomCount {
|
||||||
g.RandomCount = h.RandomCount
|
g.RandomCount = h.RandomCount
|
||||||
}
|
}
|
||||||
|
if len(h.Snis) > 0 && len(g.Snis) == 0 {
|
||||||
|
g.Snis = h.Snis
|
||||||
|
}
|
||||||
|
if h.SniSharedRandom {
|
||||||
|
g.SniSharedRandom = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
|
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
|
||||||
@@ -117,6 +125,13 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
||||||
|
snis := hostSniPool(req.Sni, req.Snis)
|
||||||
|
primarySni := ""
|
||||||
|
if len(snis) > 0 {
|
||||||
|
primarySni = snis[0]
|
||||||
|
}
|
||||||
|
shared := req.SniSharedRandom && len(snis) > 1
|
||||||
|
|
||||||
hostsToProcess := req.Hosts
|
hostsToProcess := req.Hosts
|
||||||
if len(hostsToProcess) == 0 {
|
if len(hostsToProcess) == 0 {
|
||||||
hostsToProcess = []string{""}
|
hostsToProcess = []string{""}
|
||||||
@@ -137,7 +152,9 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
|||||||
Address: addr,
|
Address: addr,
|
||||||
Port: port,
|
Port: port,
|
||||||
Security: req.Security,
|
Security: req.Security,
|
||||||
Sni: req.Sni,
|
Sni: primarySni,
|
||||||
|
Snis: snis,
|
||||||
|
SniSharedRandom: shared,
|
||||||
HostHeader: req.HostHeader,
|
HostHeader: req.HostHeader,
|
||||||
Path: req.Path,
|
Path: req.Path,
|
||||||
Alpn: req.Alpn,
|
Alpn: req.Alpn,
|
||||||
@@ -483,3 +500,32 @@ func clampRandomCount(n int) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hostSniPool is the group's SNI pool, primary first: an sni outside the pool (a caller that only
|
||||||
|
// knows the single field) becomes the primary, replacing a one-entry pool.
|
||||||
|
func hostSniPool(sni string, pool []string) []string {
|
||||||
|
snis := cleanSnis(pool)
|
||||||
|
sni = strings.TrimSpace(sni)
|
||||||
|
switch {
|
||||||
|
case sni == "" || (len(snis) > 0 && snis[0] == sni):
|
||||||
|
return snis
|
||||||
|
case len(snis) <= 1:
|
||||||
|
return []string{sni}
|
||||||
|
}
|
||||||
|
return append([]string{sni}, slices.DeleteFunc(snis, func(s string) bool { return s == sni })...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanSnis(snis []string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, s := range snis {
|
||||||
|
trimmed := strings.TrimSpace(s)
|
||||||
|
if trimmed != "" {
|
||||||
|
if _, exists := seen[trimmed]; !exists {
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -525,3 +525,79 @@ func TestHostGroup_CipherSuitesRoundTrip(t *testing.T) {
|
|||||||
t.Fatalf("CipherSuites = %q, want %q", g.CipherSuites, suites)
|
t.Fatalf("CipherSuites = %q, want %q", g.CipherSuites, suites)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostGroup_MultiSNIRoundTrip(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &HostService{}
|
||||||
|
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||||
|
|
||||||
|
snis := []string{"sni1.example.com", "sni2.example.com"}
|
||||||
|
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||||
|
InboundIds: []int{ib.Id},
|
||||||
|
Remark: "multi-sni",
|
||||||
|
Hosts: []string{"h1.example.com", "h2.example.com"},
|
||||||
|
Security: "tls",
|
||||||
|
Snis: snis,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) != 2 {
|
||||||
|
t.Fatalf("created %d rows, want 2", len(created))
|
||||||
|
}
|
||||||
|
for i, row := range created {
|
||||||
|
if !reflect.DeepEqual(row.Snis, snis) {
|
||||||
|
t.Fatalf("row[%d].Snis = %v, want %v", i, row.Snis, snis)
|
||||||
|
}
|
||||||
|
if row.Sni != snis[0] {
|
||||||
|
t.Fatalf("row[%d].Sni = %q, want primary %q", i, row.Sni, snis[0])
|
||||||
|
}
|
||||||
|
if row.SniSharedRandom {
|
||||||
|
t.Fatalf("row[%d] shares its SNI by default", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := svc.GetHostGroup(created[0].GroupId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
if g.Security != "tls" {
|
||||||
|
t.Fatalf("g.Security = %q, want %q", g.Security, "tls")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(g.Snis, snis) {
|
||||||
|
t.Fatalf("g.Snis = %v, want %v", g.Snis, snis)
|
||||||
|
}
|
||||||
|
if g.SniSharedRandom {
|
||||||
|
t.Fatalf("g shares its SNI by default")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now update to SniSharedRandom and verify Security and mode preservation
|
||||||
|
g.SniSharedRandom = true
|
||||||
|
updated, err := svc.UpdateHostGroup(g.GroupId, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
for i, row := range updated {
|
||||||
|
if row.Security != "tls" {
|
||||||
|
t.Fatalf("updated row[%d].Security = %q, want %q", i, row.Security, "tls")
|
||||||
|
}
|
||||||
|
if !row.SniSharedRandom {
|
||||||
|
t.Fatalf("updated row[%d] lost SniSharedRandom", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify REST script updating req.Sni independently is honored and synchronized
|
||||||
|
g.Sni = "rest-updated.example.com"
|
||||||
|
updatedSni, err := svc.UpdateHostGroup(g.GroupId, g)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup with updated Sni: %v", err)
|
||||||
|
}
|
||||||
|
for i, row := range updatedSni {
|
||||||
|
if row.Sni != "rest-updated.example.com" {
|
||||||
|
t.Fatalf("row[%d].Sni = %q, want %q", i, row.Sni, "rest-updated.example.com")
|
||||||
|
}
|
||||||
|
if len(row.Snis) == 0 || row.Snis[0] != "rest-updated.example.com" {
|
||||||
|
t.Fatalf("row[%d].Snis[0] = %v, want %q", i, row.Snis, "rest-updated.example.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "الإجراءات",
|
"actions": "الإجراءات",
|
||||||
"security": "الأمان",
|
"security": "الأمان",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "نفس الـ SNI العشوائي لجميع التكوينات",
|
||||||
"overrideSniFromAddress": "استخدم العنوان كـ SNI",
|
"overrideSniFromAddress": "استخدم العنوان كـ SNI",
|
||||||
"keepSniBlank": "اترك SNI فارغاً",
|
"keepSniBlank": "اترك SNI فارغاً",
|
||||||
"hostHeader": "ترويسة Host",
|
"hostHeader": "ترويسة Host",
|
||||||
|
|||||||
@@ -1122,6 +1122,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"security": "Security",
|
"security": "Security",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Same random SNI for all configs",
|
||||||
"overrideSniFromAddress": "Use address as SNI",
|
"overrideSniFromAddress": "Use address as SNI",
|
||||||
"keepSniBlank": "Keep SNI blank",
|
"keepSniBlank": "Keep SNI blank",
|
||||||
"hostHeader": "Host header",
|
"hostHeader": "Host header",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Acciones",
|
"actions": "Acciones",
|
||||||
"security": "Seguridad",
|
"security": "Seguridad",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Mismo SNI aleatorio para todas las configuraciones",
|
||||||
"overrideSniFromAddress": "Usar la dirección como SNI",
|
"overrideSniFromAddress": "Usar la dirección como SNI",
|
||||||
"keepSniBlank": "Dejar el SNI en blanco",
|
"keepSniBlank": "Dejar el SNI en blanco",
|
||||||
"hostHeader": "Cabecera Host",
|
"hostHeader": "Cabecera Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "عملیات",
|
"actions": "عملیات",
|
||||||
"security": "امنیت",
|
"security": "امنیت",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "یک SNI تصادفی یکسان برای تمام کانفیگها",
|
||||||
"overrideSniFromAddress": "استفاده از آدرس بهعنوان SNI",
|
"overrideSniFromAddress": "استفاده از آدرس بهعنوان SNI",
|
||||||
"keepSniBlank": "SNI خالی بماند",
|
"keepSniBlank": "SNI خالی بماند",
|
||||||
"hostHeader": "هدر Host",
|
"hostHeader": "هدر Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Aksi",
|
"actions": "Aksi",
|
||||||
"security": "Keamanan",
|
"security": "Keamanan",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "SNI acak yang sama untuk semua konfigurasi",
|
||||||
"overrideSniFromAddress": "Gunakan alamat sebagai SNI",
|
"overrideSniFromAddress": "Gunakan alamat sebagai SNI",
|
||||||
"keepSniBlank": "Biarkan SNI kosong",
|
"keepSniBlank": "Biarkan SNI kosong",
|
||||||
"hostHeader": "Header Host",
|
"hostHeader": "Header Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"security": "セキュリティ",
|
"security": "セキュリティ",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "すべての設定で同じランダムな SNI",
|
||||||
"overrideSniFromAddress": "アドレスを SNI として使用",
|
"overrideSniFromAddress": "アドレスを SNI として使用",
|
||||||
"keepSniBlank": "SNI を空のままにする",
|
"keepSniBlank": "SNI を空のままにする",
|
||||||
"hostHeader": "Host ヘッダー",
|
"hostHeader": "Host ヘッダー",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Ações",
|
"actions": "Ações",
|
||||||
"security": "Segurança",
|
"security": "Segurança",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Mesmo SNI aleatório para todas as configurações",
|
||||||
"overrideSniFromAddress": "Usar endereço como SNI",
|
"overrideSniFromAddress": "Usar endereço como SNI",
|
||||||
"keepSniBlank": "Manter SNI em branco",
|
"keepSniBlank": "Manter SNI em branco",
|
||||||
"hostHeader": "Cabeçalho Host",
|
"hostHeader": "Cabeçalho Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Действия",
|
"actions": "Действия",
|
||||||
"security": "Безопасность",
|
"security": "Безопасность",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Один случайный SNI для всех конфигураций",
|
||||||
"overrideSniFromAddress": "Использовать адрес как SNI",
|
"overrideSniFromAddress": "Использовать адрес как SNI",
|
||||||
"keepSniBlank": "Оставить SNI пустым",
|
"keepSniBlank": "Оставить SNI пустым",
|
||||||
"hostHeader": "Заголовок Host",
|
"hostHeader": "Заголовок Host",
|
||||||
|
|||||||
@@ -1122,6 +1122,7 @@
|
|||||||
"actions": "Akcie",
|
"actions": "Akcie",
|
||||||
"security": "Bezpečnosť",
|
"security": "Bezpečnosť",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Rovnaké náhodné SNI pre všetky konfigurácie",
|
||||||
"overrideSniFromAddress": "Použiť adresu ako SNI",
|
"overrideSniFromAddress": "Použiť adresu ako SNI",
|
||||||
"keepSniBlank": "Ponechať SNI prázdne",
|
"keepSniBlank": "Ponechať SNI prázdne",
|
||||||
"hostHeader": "Hlavička Host",
|
"hostHeader": "Hlavička Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "İşlemler",
|
"actions": "İşlemler",
|
||||||
"security": "Güvenlik",
|
"security": "Güvenlik",
|
||||||
"sni": "Toplumsal ve Ulusal Kimlik",
|
"sni": "Toplumsal ve Ulusal Kimlik",
|
||||||
|
"sniSharedRandom": "Tüm yapılandırmalar için aynı rastgele SNI",
|
||||||
"overrideSniFromAddress": "Adresi SNI olarak kullan",
|
"overrideSniFromAddress": "Adresi SNI olarak kullan",
|
||||||
"keepSniBlank": "SNI'yi boş bırak",
|
"keepSniBlank": "SNI'yi boş bırak",
|
||||||
"hostHeader": "Host başlığı",
|
"hostHeader": "Host başlığı",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Дії",
|
"actions": "Дії",
|
||||||
"security": "Безпека",
|
"security": "Безпека",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Один випадковий SNI для всіх конфігурацій",
|
||||||
"overrideSniFromAddress": "Використовувати адресу як SNI",
|
"overrideSniFromAddress": "Використовувати адресу як SNI",
|
||||||
"keepSniBlank": "Залишити SNI порожнім",
|
"keepSniBlank": "Залишити SNI порожнім",
|
||||||
"hostHeader": "Заголовок Host",
|
"hostHeader": "Заголовок Host",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "Hành động",
|
"actions": "Hành động",
|
||||||
"security": "Bảo mật",
|
"security": "Bảo mật",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "Cùng một SNI ngẫu nhiên cho tất cả cấu hình",
|
||||||
"overrideSniFromAddress": "Dùng địa chỉ làm SNI",
|
"overrideSniFromAddress": "Dùng địa chỉ làm SNI",
|
||||||
"keepSniBlank": "Để trống SNI",
|
"keepSniBlank": "Để trống SNI",
|
||||||
"hostHeader": "Host header",
|
"hostHeader": "Host header",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"security": "安全",
|
"security": "安全",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "所有配置使用相同的随机 SNI",
|
||||||
"overrideSniFromAddress": "使用地址作为 SNI",
|
"overrideSniFromAddress": "使用地址作为 SNI",
|
||||||
"keepSniBlank": "保持 SNI 为空",
|
"keepSniBlank": "保持 SNI 为空",
|
||||||
"hostHeader": "Host 头",
|
"hostHeader": "Host 头",
|
||||||
|
|||||||
@@ -2394,6 +2394,7 @@
|
|||||||
"actions": "操作",
|
"actions": "操作",
|
||||||
"security": "安全",
|
"security": "安全",
|
||||||
"sni": "SNI",
|
"sni": "SNI",
|
||||||
|
"sniSharedRandom": "所有設定使用相同的隨機 SNI",
|
||||||
"overrideSniFromAddress": "使用地址作為 SNI",
|
"overrideSniFromAddress": "使用地址作為 SNI",
|
||||||
"keepSniBlank": "保持 SNI 留空",
|
"keepSniBlank": "保持 SNI 留空",
|
||||||
"hostHeader": "Host 標頭",
|
"hostHeader": "Host 標頭",
|
||||||
|
|||||||
Reference in New Issue
Block a user