mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-20 19:01:17 +00:00
Merge b45dd06669 into 097bb7646a
This commit is contained in:
commit
79978d5223
32 changed files with 885 additions and 8 deletions
|
|
@ -2738,6 +2738,10 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2810,6 +2814,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2919,6 +2924,11 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -2984,6 +2994,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -11909,6 +11920,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12004,6 +12016,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12102,6 +12115,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12250,6 +12264,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12372,6 +12387,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12624,6 +12640,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -2738,6 +2738,10 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2810,6 +2814,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2919,6 +2924,11 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -2984,6 +2994,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -11909,6 +11920,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12004,6 +12016,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12102,6 +12115,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12250,6 +12264,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12372,6 +12387,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12624,6 +12640,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -702,6 +702,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -751,6 +752,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -2712,6 +2712,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2784,6 +2788,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2893,6 +2898,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -2958,6 +2968,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
|
|||
|
|
@ -644,6 +644,7 @@ export interface Host {
|
|||
path: string;
|
||||
pinnedPeerCertSha256: string[];
|
||||
port: number;
|
||||
randomCount: number;
|
||||
remark: string;
|
||||
security: string;
|
||||
serverDescription: string;
|
||||
|
|
@ -680,6 +681,7 @@ export interface HostGroup {
|
|||
path: string;
|
||||
pinnedPeerCertSha256: string[];
|
||||
port: number;
|
||||
randomCount: number;
|
||||
remark: string;
|
||||
security: string;
|
||||
serverDescription: string;
|
||||
|
|
|
|||
|
|
@ -684,6 +684,7 @@ export const HostSchema = z.object({
|
|||
path: z.string(),
|
||||
pinnedPeerCertSha256: z.array(z.string()),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
randomCount: z.number().int(),
|
||||
remark: z.string().max(256),
|
||||
security: z.enum(['same', 'tls', 'none', 'reality']),
|
||||
serverDescription: z.string().max(64),
|
||||
|
|
@ -721,6 +722,7 @@ export const HostGroupSchema = z.object({
|
|||
path: z.string(),
|
||||
pinnedPeerCertSha256: z.array(z.string()),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
randomCount: z.number().int().min(1).max(100),
|
||||
remark: z.string().max(256),
|
||||
security: z.enum(['same', 'tls', 'none', 'reality']),
|
||||
serverDescription: z.string().max(64),
|
||||
|
|
|
|||
266
frontend/src/lib/hosts/host-cidr.ts
Normal file
266
frontend/src/lib/hosts/host-cidr.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
export const MAX_RANDOM_CIDR_COUNT = 100;
|
||||
|
||||
export function cleanCIDR(s: string): string {
|
||||
let clean = s.trim();
|
||||
if (clean.startsWith('[') && clean.endsWith(']')) {
|
||||
clean = clean.slice(1, -1);
|
||||
}
|
||||
if (clean.startsWith('[')) {
|
||||
clean = clean.slice(1);
|
||||
}
|
||||
clean = clean.replace(/\]\//, '/');
|
||||
return clean;
|
||||
}
|
||||
|
||||
export function parseIPv4(ipStr: string): number | null {
|
||||
const parts = ipStr.trim().split('.');
|
||||
if (parts.length !== 4) return null;
|
||||
let u32 = 0;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return null;
|
||||
const n = Number(part);
|
||||
if (n < 0 || n > 255) return null;
|
||||
u32 = ((u32 << 8) | n) >>> 0;
|
||||
}
|
||||
return u32;
|
||||
}
|
||||
|
||||
export function formatIPv4(val: number): string {
|
||||
return `${(val >>> 24) & 255}.${(val >>> 16) & 255}.${(val >>> 8) & 255}.${val & 255}`;
|
||||
}
|
||||
|
||||
export function parseIPv6(ipStr: string): bigint | null {
|
||||
let s = ipStr.trim().toLowerCase();
|
||||
if (s.startsWith('[') && s.endsWith(']')) {
|
||||
s = s.slice(1, -1);
|
||||
}
|
||||
const doubleColonCount = (s.match(/::/g) ?? []).length;
|
||||
if (doubleColonCount > 1) return null;
|
||||
|
||||
let leftParts: string[] = [];
|
||||
let rightParts: string[] = [];
|
||||
|
||||
if (s.includes('::')) {
|
||||
const [left, right] = s.split('::');
|
||||
leftParts = left ? left.split(':') : [];
|
||||
rightParts = right ? right.split(':') : [];
|
||||
} else {
|
||||
leftParts = s.split(':');
|
||||
}
|
||||
|
||||
const totalParts = leftParts.length + rightParts.length;
|
||||
if (s.includes('::')) {
|
||||
if (totalParts > 7) return null;
|
||||
} else {
|
||||
if (totalParts !== 8) return null;
|
||||
}
|
||||
|
||||
const missingZeros = 8 - totalParts;
|
||||
const allParts = [...leftParts, ...Array(missingZeros).fill('0'), ...rightParts];
|
||||
|
||||
let result = 0n;
|
||||
for (const part of allParts) {
|
||||
if (!/^[0-9a-f]{1,4}$/.test(part)) return null;
|
||||
result = (result << 16n) | BigInt(parseInt(part, 16));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatIPv6(val: bigint): string {
|
||||
const parts: number[] = [];
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
parts.push(Number((val >> BigInt(i * 16)) & 0xffffn));
|
||||
}
|
||||
|
||||
let bestStart = -1;
|
||||
let bestLen = 0;
|
||||
let currentStart = -1;
|
||||
let currentLen = 0;
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (parts[i] === 0) {
|
||||
if (currentStart === -1) {
|
||||
currentStart = i;
|
||||
currentLen = 1;
|
||||
} else {
|
||||
currentLen++;
|
||||
}
|
||||
} else {
|
||||
if (currentLen > bestLen) {
|
||||
bestLen = currentLen;
|
||||
bestStart = currentStart;
|
||||
}
|
||||
currentStart = -1;
|
||||
currentLen = 0;
|
||||
}
|
||||
}
|
||||
if (currentLen > bestLen) {
|
||||
bestLen = currentLen;
|
||||
bestStart = currentStart;
|
||||
}
|
||||
|
||||
if (bestLen >= 2) {
|
||||
const left = parts
|
||||
.slice(0, bestStart)
|
||||
.map((p) => p.toString(16))
|
||||
.join(':');
|
||||
const right = parts
|
||||
.slice(bestStart + bestLen)
|
||||
.map((p) => p.toString(16))
|
||||
.join(':');
|
||||
return `${left}::${right}`;
|
||||
}
|
||||
|
||||
return parts.map((p) => p.toString(16)).join(':');
|
||||
}
|
||||
|
||||
export function isCIDR(s: string): boolean {
|
||||
const clean = cleanCIDR(s);
|
||||
const slashIdx = clean.indexOf('/');
|
||||
if (slashIdx === -1) return false;
|
||||
const ipPart = clean.slice(0, slashIdx);
|
||||
const prefixStr = clean.slice(slashIdx + 1);
|
||||
if (!/^\d+$/.test(prefixStr)) return false;
|
||||
const prefix = Number(prefixStr);
|
||||
|
||||
const v4 = parseIPv4(ipPart);
|
||||
if (v4 !== null) {
|
||||
return prefix >= 0 && prefix <= 32;
|
||||
}
|
||||
const v6 = parseIPv6(ipPart);
|
||||
if (v6 !== null) {
|
||||
return prefix >= 0 && prefix <= 128;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function randomIPsFromCIDR(cidrStr: string, count: number): string[] {
|
||||
if (count <= 0) count = 1;
|
||||
if (count > MAX_RANDOM_CIDR_COUNT) count = MAX_RANDOM_CIDR_COUNT;
|
||||
|
||||
const clean = cleanCIDR(cidrStr);
|
||||
const slashIdx = clean.indexOf('/');
|
||||
if (slashIdx === -1) return [];
|
||||
const ipPart = clean.slice(0, slashIdx);
|
||||
const prefixStr = clean.slice(slashIdx + 1);
|
||||
if (!/^\d+$/.test(prefixStr)) return [];
|
||||
const prefix = Number(prefixStr);
|
||||
|
||||
const v4 = parseIPv4(ipPart);
|
||||
if (v4 !== null && prefix >= 0 && prefix <= 32) {
|
||||
return randomIPv4FromCIDR(v4, prefix, count);
|
||||
}
|
||||
const v6 = parseIPv6(ipPart);
|
||||
if (v6 !== null && prefix >= 0 && prefix <= 128) {
|
||||
return randomIPv6FromCIDR(v6, prefix, count);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function randomIPv4FromCIDR(ip4: number, prefix: number, count: number): string[] {
|
||||
const hostBits = 32 - prefix;
|
||||
const mask = prefix === 0 ? 0 : (~0 << hostBits) >>> 0;
|
||||
const baseU32 = (ip4 & mask) >>> 0;
|
||||
|
||||
if (hostBits === 0) {
|
||||
return [formatIPv4(baseU32)];
|
||||
}
|
||||
if (hostBits === 1) {
|
||||
const out = [formatIPv4(baseU32)];
|
||||
if (count > 1) {
|
||||
out.push(formatIPv4((baseU32 + 1) >>> 0));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const total = 2 ** hostBits;
|
||||
const usableCount = total - 2;
|
||||
if (usableCount <= 0) {
|
||||
return [formatIPv4(baseU32)];
|
||||
}
|
||||
if (count > usableCount) {
|
||||
count = usableCount;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
let maxAttempts = count * 100;
|
||||
|
||||
while (result.length < count && maxAttempts > 0) {
|
||||
maxAttempts--;
|
||||
const offset = Math.floor(Math.random() * usableCount) + 1;
|
||||
const targetU32 = (baseU32 + offset) >>> 0;
|
||||
const ipStr = formatIPv4(targetU32);
|
||||
if (!seen.has(ipStr)) {
|
||||
seen.add(ipStr);
|
||||
result.push(ipStr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomIPv6FromCIDR(ip6: bigint, prefix: number, count: number): string[] {
|
||||
const hostBits = 128 - prefix;
|
||||
const mask = prefix === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << BigInt(hostBits)) - 1n);
|
||||
const baseInt = ip6 & mask;
|
||||
|
||||
if (hostBits === 0) {
|
||||
return [formatIPv6(baseInt)];
|
||||
}
|
||||
if (hostBits === 1) {
|
||||
const out = [formatIPv6(baseInt)];
|
||||
if (count > 1) {
|
||||
out.push(formatIPv6(baseInt + 1n));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const maxHost = 1n << BigInt(hostBits);
|
||||
// Exclude the Subnet-Router Anycast address (all host bits 0, RFC 4291 §2.6.1)
|
||||
const usableCount = maxHost - 1n;
|
||||
if (usableCount < BigInt(count)) {
|
||||
count = Number(usableCount);
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
let maxAttempts = count * 100;
|
||||
|
||||
while (result.length < count && maxAttempts > 0) {
|
||||
maxAttempts--;
|
||||
const offset = randomBigIntInRange(1n, usableCount);
|
||||
const targetInt = baseInt + offset;
|
||||
const ipStr = formatIPv6(targetInt);
|
||||
if (!seen.has(ipStr)) {
|
||||
seen.add(ipStr);
|
||||
result.push(ipStr);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function randomBigIntInRange(min: bigint, max: bigint): bigint {
|
||||
const range = max - min + 1n;
|
||||
const bits = range.toString(2).length;
|
||||
const bytes = Math.ceil(bits / 8);
|
||||
const buf = new Uint8Array(bytes);
|
||||
|
||||
while (true) {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(buf);
|
||||
} else {
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
buf[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
let val = 0n;
|
||||
for (let i = 0; i < bytes; i++) {
|
||||
val = (val << 8n) | BigInt(buf[i]);
|
||||
}
|
||||
const mask = (1n << BigInt(bits)) - 1n;
|
||||
val = val & mask;
|
||||
if (val < range) {
|
||||
return min + val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { isCIDR, MAX_RANDOM_CIDR_COUNT, randomIPsFromCIDR } from '@/lib/hosts/host-cidr';
|
||||
import type { ExternalProxyEntry } from '@/schemas/protocols/stream/external-proxy';
|
||||
import type { HostFormValues, HostRecord } from '@/schemas/api/host';
|
||||
import type { Inbound } from '@/schemas/api/inbound';
|
||||
|
|
@ -61,15 +62,28 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
|
|||
|
||||
function splitAdvertisedHost(value: string, inboundPort: number): [string, number] {
|
||||
const host = value.trim();
|
||||
if (host.startsWith('[')) {
|
||||
const close = host.indexOf(']');
|
||||
if (close > 0) {
|
||||
const port = host.slice(close + 1).match(/^:(\d+)$/)?.[1];
|
||||
return [host.slice(1, close), port ? Number(port) : inboundPort];
|
||||
if (host.split(':').length > 2 && !host.includes('[')) {
|
||||
return [host, inboundPort];
|
||||
}
|
||||
const lastColon = host.lastIndexOf(':');
|
||||
if (lastColon !== -1 && lastColon < host.length - 1) {
|
||||
const portStr = host.slice(lastColon + 1);
|
||||
if (/^\d+$/.test(portStr)) {
|
||||
const port = Number(portStr);
|
||||
if (port >= 0 && port <= 65535) {
|
||||
let addr = host.slice(0, lastColon);
|
||||
if (addr.startsWith('[') && addr.endsWith(']')) {
|
||||
addr = addr.slice(1, -1);
|
||||
}
|
||||
return [addr, port];
|
||||
}
|
||||
}
|
||||
}
|
||||
const match = host.match(/^([^:]*):(\d+)$/);
|
||||
return match ? [match[1], Number(match[2])] : [host, inboundPort];
|
||||
let addr = host;
|
||||
if (addr.startsWith('[') && addr.endsWith(']')) {
|
||||
addr = addr.slice(1, -1);
|
||||
}
|
||||
return [addr, inboundPort];
|
||||
}
|
||||
|
||||
export interface HostEndpoint {
|
||||
|
|
@ -102,6 +116,26 @@ export function hostEndpointsFor(
|
|||
for (const value of record.hosts) {
|
||||
const [address, port] = splitAdvertisedHost(value, inboundPort);
|
||||
const dest = address || defaultDest;
|
||||
if (isCIDR(dest)) {
|
||||
const count = Math.min(Math.max(record.randomCount ?? 1, 1), MAX_RANDOM_CIDR_COUNT);
|
||||
const ips = randomIPsFromCIDR(dest, count);
|
||||
if (ips.length > 0) {
|
||||
for (let i = 0; i < ips.length; i++) {
|
||||
const ip = ips[i]!;
|
||||
let remark = record.remark ?? '';
|
||||
if (ips.length > 1) {
|
||||
remark = record.remark ? `${record.remark} - ${i + 1}` : `${i + 1}`;
|
||||
}
|
||||
const endpoint: HostEndpoint = { dest: ip, port, remark };
|
||||
const sni = record.overrideSniFromAddress ? ip : record.sni;
|
||||
if (!record.keepSniBlank && sni) endpoint.sni = sni;
|
||||
if (record.alpn && record.alpn.length > 0) endpoint.alpn = record.alpn;
|
||||
if (record.allowInsecure) endpoint.allowInsecure = true;
|
||||
endpoints.push(endpoint);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const endpoint: HostEndpoint = { dest, port, remark: record.remark || '' };
|
||||
const sni = record.overrideSniFromAddress ? dest : record.sni;
|
||||
if (!record.keepSniBlank && sni) endpoint.sni = sni;
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
|||
mihomoIpVersion: host?.mihomoIpVersion as BulkAddHostValues['mihomoIpVersion'],
|
||||
mihomoX25519: host?.mihomoX25519 ?? false,
|
||||
shuffleHost: host?.shuffleHost ?? false,
|
||||
randomCount: host?.randomCount ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -95,6 +96,8 @@ export default function HostFormModal({
|
|||
const security = (useWatch({ control: methods.control, name: 'security' }) ?? 'same') as string;
|
||||
const showTls = security === 'tls' || security === 'reality' || security === 'same';
|
||||
const showTlsExtras = security === 'tls' || security === 'same';
|
||||
const watchedHosts = useWatch({ control: methods.control, name: 'hosts' });
|
||||
const hasCidr = Boolean(watchedHosts?.some((h) => typeof h === 'string' && h.includes('/')));
|
||||
|
||||
// React resets this during render rather than in an effect so the modal's
|
||||
// first open frame already shows cleared fields.
|
||||
|
|
@ -251,6 +254,11 @@ export default function HostFormModal({
|
|||
placeholder="cdn.example.com, cdn2.example.com:443"
|
||||
/>
|
||||
</FormField>
|
||||
{hasCidr && (
|
||||
<FormField name="randomCount" label={t('pages.hosts.fields.randomCount')}>
|
||||
<InputNumber min={1} max={100} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
)}
|
||||
<FormField
|
||||
name="port"
|
||||
label={t('pages.hosts.fields.port')}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export const HostFormSchema = z.object({
|
|||
),
|
||||
mihomoX25519: z.boolean().default(false),
|
||||
shuffleHost: z.boolean().default(false),
|
||||
randomCount: z.number().int().min(1).max(100).default(1),
|
||||
});
|
||||
export type HostFormValues = z.infer<typeof HostFormSchema>;
|
||||
|
||||
|
|
@ -107,6 +108,7 @@ export const HostRecordSchema = z
|
|||
mihomoIpVersion: z.string().optional(),
|
||||
mihomoX25519: z.boolean().optional(),
|
||||
shuffleHost: z.boolean().optional(),
|
||||
randomCount: z.number().optional(),
|
||||
})
|
||||
.loose();
|
||||
export type HostRecord = z.infer<typeof HostRecordSchema>;
|
||||
|
|
|
|||
|
|
@ -169,4 +169,25 @@ describe('clients page tunnel configs follow Hosts', () => {
|
|||
'wg - edge2.example.com:51820',
|
||||
]);
|
||||
});
|
||||
|
||||
it('expands a CIDR Host into distinct endpoint configs', () => {
|
||||
const inbound = { id: 7, remark: 'wg', protocol: 'wireguard', port: 51820 } as InboundOption;
|
||||
const cidrHosts: HostRecord[] = [
|
||||
{
|
||||
groupId: 'cidr-group',
|
||||
inboundIds: [7],
|
||||
hosts: ['198.51.100.0/30'],
|
||||
randomCount: 2,
|
||||
remark: 'CIDR-WG',
|
||||
},
|
||||
];
|
||||
const eps = tunnelConfigEndpoints(inbound, cidrHosts, PANEL, '');
|
||||
expect(eps).toHaveLength(2);
|
||||
expect(eps[0]?.remark).toBe('CIDR-WG - 1');
|
||||
expect(eps[1]?.remark).toBe('CIDR-WG - 2');
|
||||
expect(eps.map((e) => e?.port)).toEqual([51820, 51820]);
|
||||
for (const ep of eps) {
|
||||
expect(ep?.dest).toMatch(/^198\.51\.100\.[12]$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ func hostColumns() []string {
|
|||
"override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256",
|
||||
"verify_peer_cert_by_name", "allow_insecure", "ech_config_list",
|
||||
"mux_params", "sockopt_params", "final_mask", "vless_route",
|
||||
"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "node_guids",
|
||||
"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "random_count", "node_guids",
|
||||
"created_at", "updated_at",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1154,6 +1154,7 @@ type Host struct {
|
|||
MihomoIpVersion string `json:"mihomoIpVersion" form:"mihomoIpVersion" gorm:"column:mihomo_ip_version" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
|
||||
MihomoX25519 bool `json:"mihomoX25519" form:"mihomoX25519" gorm:"column:mihomo_x25519"`
|
||||
ShuffleHost bool `json:"shuffleHost" form:"shuffleHost" gorm:"column:shuffle_host"`
|
||||
RandomCount int `json:"randomCount" form:"randomCount" gorm:"default:1;column:random_count" example:"1"`
|
||||
|
||||
NodeGuids []string `json:"nodeGuids,omitempty" form:"nodeGuids" gorm:"serializer:json;column:node_guids"`
|
||||
|
||||
|
|
|
|||
156
internal/sub/host_cidr.go
Normal file
156
internal/sub/host_cidr.go
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
package sub
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const MaxRandomCIDRCount = 100
|
||||
|
||||
func cleanCIDR(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, "[]")
|
||||
s = strings.TrimPrefix(s, "[")
|
||||
s = strings.Replace(s, "]/", "/", 1)
|
||||
return s
|
||||
}
|
||||
|
||||
// IsCIDR reports whether s represents a valid CIDR network notation.
|
||||
func IsCIDR(s string) bool {
|
||||
s = cleanCIDR(s)
|
||||
if !strings.Contains(s, "/") {
|
||||
return false
|
||||
}
|
||||
_, _, err := net.ParseCIDR(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// RandomIPsFromCIDR returns count unique random host IPs from the specified CIDR.
|
||||
// Supported across IPv4 and IPv6; caps at the usable address space when smaller.
|
||||
func RandomIPsFromCIDR(cidr string, count int) ([]string, error) {
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
} else if count > MaxRandomCIDRCount {
|
||||
count = MaxRandomCIDRCount
|
||||
}
|
||||
clean := cleanCIDR(cidr)
|
||||
_, ipNet, err := net.ParseCIDR(clean)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ip4 := ipNet.IP.To4(); ip4 != nil {
|
||||
return randomIPv4FromCIDR(ip4, ipNet.Mask, count)
|
||||
}
|
||||
return randomIPv6FromCIDR(ipNet.IP.To16(), ipNet.Mask, count)
|
||||
}
|
||||
|
||||
func randomIPv4FromCIDR(ip4 net.IP, mask net.IPMask, count int) ([]string, error) {
|
||||
ones, bits := mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits == 0 {
|
||||
return []string{ip4.String()}, nil
|
||||
}
|
||||
if hostBits == 1 {
|
||||
base := binary.BigEndian.Uint32(ip4)
|
||||
out := []string{ip4.String()}
|
||||
if count > 1 {
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, base+1)
|
||||
out = append(out, net.IP(b).String())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
total := uint64(1) << hostBits
|
||||
// Avoid network (.0) and broadcast (.255) for /30 and larger subnets.
|
||||
usableCount := total - 2
|
||||
if usableCount == 0 {
|
||||
return []string{ip4.String()}, nil
|
||||
}
|
||||
if uint64(count) > usableCount {
|
||||
count = int(usableCount)
|
||||
}
|
||||
|
||||
baseU32 := binary.BigEndian.Uint32(ip4)
|
||||
seen := make(map[string]struct{}, count)
|
||||
result := make([]string, 0, count)
|
||||
maxOffset := big.NewInt(int64(usableCount))
|
||||
maxAttempts := count * 100
|
||||
|
||||
for len(result) < count && maxAttempts > 0 {
|
||||
maxAttempts--
|
||||
offsetBig, err := rand.Int(rand.Reader, maxOffset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
targetU32 := baseU32 + uint32(offsetBig.Int64()+1)
|
||||
b := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(b, targetU32)
|
||||
ipStr := net.IP(b).String()
|
||||
if _, exists := seen[ipStr]; !exists {
|
||||
seen[ipStr] = struct{}{}
|
||||
result = append(result, ipStr)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func randomIPv6FromCIDR(ip6 net.IP, mask net.IPMask, count int) ([]string, error) {
|
||||
ones, bits := mask.Size()
|
||||
hostBits := bits - ones
|
||||
if hostBits == 0 {
|
||||
return []string{ip6.String()}, nil
|
||||
}
|
||||
if hostBits == 1 {
|
||||
baseInt := new(big.Int).SetBytes(ip6)
|
||||
out := []string{ip6.String()}
|
||||
if count > 1 {
|
||||
next := new(big.Int).Add(baseInt, big.NewInt(1))
|
||||
out = append(out, formatIPv6(next))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
maxHost := new(big.Int).Lsh(big.NewInt(1), uint(hostBits))
|
||||
// Exclude the Subnet-Router Anycast address (all host bits 0, RFC 4291 §2.6.1).
|
||||
usableCount := new(big.Int).Sub(maxHost, big.NewInt(1))
|
||||
if usableCount.Cmp(big.NewInt(int64(count))) < 0 {
|
||||
count = int(usableCount.Int64())
|
||||
}
|
||||
|
||||
baseInt := new(big.Int).SetBytes(ip6)
|
||||
seen := make(map[string]struct{}, count)
|
||||
result := make([]string, 0, count)
|
||||
maxOffset := new(big.Int).Set(usableCount)
|
||||
maxAttempts := count * 100
|
||||
|
||||
for len(result) < count && maxAttempts > 0 {
|
||||
maxAttempts--
|
||||
offsetBig, err := rand.Int(rand.Reader, maxOffset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
offset := new(big.Int).Add(offsetBig, big.NewInt(1))
|
||||
targetInt := new(big.Int).Add(baseInt, offset)
|
||||
ipStr := formatIPv6(targetInt)
|
||||
if _, exists := seen[ipStr]; !exists {
|
||||
seen[ipStr] = struct{}{}
|
||||
result = append(result, ipStr)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func formatIPv6(val *big.Int) string {
|
||||
b := val.Bytes()
|
||||
if len(b) < 16 {
|
||||
padded := make([]byte, 16)
|
||||
copy(padded[16-len(b):], b)
|
||||
b = padded
|
||||
}
|
||||
return net.IP(b).String()
|
||||
}
|
||||
175
internal/sub/host_cidr_test.go
Normal file
175
internal/sub/host_cidr_test.go
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
package sub
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{input: "192.168.1.0/24", want: true},
|
||||
{input: "10.0.0.0/8", want: true},
|
||||
{input: "[192.168.1.0/24]", want: true},
|
||||
{input: "2a01:4f8:1c1b:729::/64", want: true},
|
||||
{input: "[2a01:4f8:1c1b:729::/64]", want: true},
|
||||
{input: "[2001:db8::]/64", want: true},
|
||||
{input: "fe80::/10", want: true},
|
||||
{input: "example.com", want: false},
|
||||
{input: "192.168.1.1", want: false},
|
||||
{input: "2001:db8::1", want: false},
|
||||
{input: "", want: false},
|
||||
{input: "invalid/33", want: false},
|
||||
{input: "999.999.999.999/24", want: false},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
if got := IsCIDR(tc.input); got != tc.want {
|
||||
t.Fatalf("IsCIDR(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRandomIPsFromCIDR_IPv4(t *testing.T) {
|
||||
t.Run("single host /32", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("192.0.2.1/32", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "192.0.2.1" {
|
||||
t.Fatalf("RandomIPsFromCIDR(/32) = %v, want [192.0.2.1]", ips)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("small subnet /30 caps count", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("198.51.100.0/30", 5)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 2 {
|
||||
t.Fatalf("len(ips) = %d, want 2", len(ips))
|
||||
}
|
||||
for _, ipStr := range ips {
|
||||
if ipStr == "198.51.100.0" || ipStr == "198.51.100.3" {
|
||||
t.Fatalf("got network or broadcast address: %s", ipStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("subnet /24 generates unique IPs within range", func(t *testing.T) {
|
||||
cidr := "203.0.113.0/24"
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cidr: %v", err)
|
||||
}
|
||||
|
||||
ips, err := RandomIPsFromCIDR(cidr, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 10 {
|
||||
t.Fatalf("len(ips) = %d, want 10", len(ips))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(ips))
|
||||
for _, ipStr := range ips {
|
||||
if _, dup := seen[ipStr]; dup {
|
||||
t.Fatalf("duplicate IP emitted: %s", ipStr)
|
||||
}
|
||||
seen[ipStr] = struct{}{}
|
||||
|
||||
parsed := net.ParseIP(ipStr)
|
||||
if parsed == nil || !ipNet.Contains(parsed) {
|
||||
t.Fatalf("IP %s outside expected subnet %s", ipStr, cidr)
|
||||
}
|
||||
if strings.HasSuffix(ipStr, ".0") || strings.HasSuffix(ipStr, ".255") {
|
||||
t.Fatalf("unusable host IP picked: %s", ipStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRandomIPsFromCIDR_IPv6(t *testing.T) {
|
||||
t.Run("single host /128", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("2001:db8::1/128", 4)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 1 || ips[0] != "2001:db8::1" {
|
||||
t.Fatalf("RandomIPsFromCIDR(/128) = %v, want [2001:db8::1]", ips)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("standard /64 datacenter block", func(t *testing.T) {
|
||||
cidr := "2a01:4f8:1c1b:729::/64"
|
||||
_, ipNet, err := net.ParseCIDR(cidr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cidr: %v", err)
|
||||
}
|
||||
|
||||
ips, err := RandomIPsFromCIDR(cidr, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 10 {
|
||||
t.Fatalf("len(ips) = %d, want 10", len(ips))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(ips))
|
||||
for _, ipStr := range ips {
|
||||
if _, dup := seen[ipStr]; dup {
|
||||
t.Fatalf("duplicate IP emitted: %s", ipStr)
|
||||
}
|
||||
seen[ipStr] = struct{}{}
|
||||
|
||||
parsed := net.ParseIP(ipStr)
|
||||
if parsed == nil || !ipNet.Contains(parsed) {
|
||||
t.Fatalf("IP %s outside expected subnet %s", ipStr, cidr)
|
||||
}
|
||||
if ipStr == "2a01:4f8:1c1b:729::" {
|
||||
t.Fatalf("subnet-router anycast address was emitted")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bracketed cidr notation", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("[2a01:4f8:1c1b:729::/64]", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 3 {
|
||||
t.Fatalf("len(ips) = %d, want 3", len(ips))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("bracketed host form /64", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("[2001:db8::]/64", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != 3 {
|
||||
t.Fatalf("len(ips) = %d, want 3", len(ips))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unbounded count clamped to MaxRandomCIDRCount", func(t *testing.T) {
|
||||
ips, err := RandomIPsFromCIDR("2001:db8::/64", 1000)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(ips) != MaxRandomCIDRCount {
|
||||
t.Fatalf("len(ips) = %d, want %d", len(ips), MaxRandomCIDRCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRandomIPsFromCIDR_Invalid(t *testing.T) {
|
||||
if _, err := RandomIPsFromCIDR("example.com", 1); err == nil {
|
||||
t.Fatalf("RandomIPsFromCIDR(domain) expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package sub
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
|
|
@ -34,6 +35,28 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
|
|||
if slices.Contains(h.ExcludeFromSubTypes, format) {
|
||||
continue
|
||||
}
|
||||
if IsCIDR(h.Address) {
|
||||
count := h.RandomCount
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
ips, err := RandomIPsFromCIDR(h.Address, count)
|
||||
if err == nil && len(ips) > 0 {
|
||||
for i, ip := range ips {
|
||||
hCopy := *h
|
||||
hCopy.Address = ip
|
||||
if len(ips) > 1 {
|
||||
if h.Remark != "" {
|
||||
hCopy.Remark = fmt.Sprintf("%s - %d", h.Remark, i+1)
|
||||
} else {
|
||||
hCopy.Remark = fmt.Sprintf("%d", i+1)
|
||||
}
|
||||
}
|
||||
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, inbound.Port))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port))
|
||||
}
|
||||
return eps
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package sub
|
|||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
|
@ -664,3 +665,113 @@ func TestSub_HostTLSVerificationJSONAtXrayLevel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostCIDR_RawLinks(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "cidr-raw", 4462, 1,
|
||||
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"sni.example.com"}}`)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id,
|
||||
SortOrder: 0,
|
||||
Remark: "IPv6Node",
|
||||
Address: "2a01:4f8:1c1b:729::/64",
|
||||
Port: 8443,
|
||||
Security: "tls",
|
||||
RandomCount: 3,
|
||||
})
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
_, ipNet, _ := net.ParseCIDR("2a01:4f8:1c1b:729::/64")
|
||||
seenIPs := make(map[string]struct{}, len(parts))
|
||||
|
||||
for i, link := range parts {
|
||||
wantRemark := fmt.Sprintf("IPv6Node%%20-%%20%d", i+1)
|
||||
if !strings.Contains(link, wantRemark) {
|
||||
t.Errorf("link[%d] does not contain expected remark %q: %s", i, wantRemark, link)
|
||||
}
|
||||
|
||||
atIdx := strings.Index(link, "@")
|
||||
colonIdx := strings.LastIndex(link, ":8443")
|
||||
if atIdx == -1 || colonIdx == -1 || colonIdx <= atIdx {
|
||||
t.Fatalf("malformed link host: %s", link)
|
||||
}
|
||||
rawAddr := strings.Trim(link[atIdx+1:colonIdx], "[]")
|
||||
parsed := net.ParseIP(rawAddr)
|
||||
if parsed == nil || !ipNet.Contains(parsed) {
|
||||
t.Errorf("link[%d] IP %q not inside 2a01:4f8:1c1b:729::/64", i, rawAddr)
|
||||
}
|
||||
if _, dup := seenIPs[rawAddr]; dup {
|
||||
t.Errorf("duplicate IP %q emitted across links", rawAddr)
|
||||
}
|
||||
seenIPs[rawAddr] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostCIDR_SingleCountKeepsBaseRemark(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "cidr-single", 4463, 1,
|
||||
`{"network":"tcp","security":"none"}`)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id,
|
||||
SortOrder: 0,
|
||||
Remark: "SingleCIDR",
|
||||
Address: "198.51.100.0/24",
|
||||
Port: 8080,
|
||||
Security: "none",
|
||||
RandomCount: 1,
|
||||
})
|
||||
|
||||
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) != 1 {
|
||||
t.Fatalf("len(parts) = %d, want 1", len(parts))
|
||||
}
|
||||
if strings.Contains(parts[0], "SingleCIDR - 1") {
|
||||
t.Errorf("count=1 must not append index suffix: %s", parts[0])
|
||||
}
|
||||
if !strings.Contains(parts[0], "SingleCIDR") {
|
||||
t.Errorf("link does not contain base remark: %s", parts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSub_HostCIDR_ClashAndJson(t *testing.T) {
|
||||
seedSubDB(t)
|
||||
ib := seedSubInbound(t, "s1", "cidr-clash", 4464, 1,
|
||||
`{"network":"tcp","security":"none"}`)
|
||||
seedHost(t, &model.Host{
|
||||
InboundId: ib.Id,
|
||||
SortOrder: 0,
|
||||
Remark: "V4Node",
|
||||
Address: "203.0.113.0/24",
|
||||
Port: 8080,
|
||||
Security: "none",
|
||||
RandomCount: 2,
|
||||
})
|
||||
|
||||
clashYaml, _, err := NewSubClashService(false, "", NewSubService("")).GetClash("s1", "req.example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GetClash: %v", err)
|
||||
}
|
||||
if !strings.Contains(clashYaml, "V4Node - 1") || !strings.Contains(clashYaml, "V4Node - 2") {
|
||||
t.Errorf("Clash YAML should contain numbered host proxies: %s", clashYaml)
|
||||
}
|
||||
|
||||
jsonDoc, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
||||
if err != nil {
|
||||
t.Fatalf("GetJson: %v", err)
|
||||
}
|
||||
if !strings.Contains(jsonDoc, "V4Node - 1") || !strings.Contains(jsonDoc, "V4Node - 2") {
|
||||
t.Errorf("JSON config should contain numbered host outbounds: %s", jsonDoc)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -445,4 +445,5 @@ type HostGroup struct {
|
|||
MihomoIpVersion string `json:"mihomoIpVersion" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
|
||||
MihomoX25519 bool `json:"mihomoX25519"`
|
||||
ShuffleHost bool `json:"shuffleHost"`
|
||||
RandomCount int `json:"randomCount" validate:"omitempty,min=1,max=100"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
|
|||
MihomoIpVersion: h.MihomoIpVersion,
|
||||
MihomoX25519: h.MihomoX25519,
|
||||
ShuffleHost: h.ShuffleHost,
|
||||
RandomCount: h.RandomCount,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,6 +92,9 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
|||
if h.SortOrder < g.SortOrder {
|
||||
g.SortOrder = h.SortOrder
|
||||
}
|
||||
if h.RandomCount > g.RandomCount {
|
||||
g.RandomCount = h.RandomCount
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
|
||||
|
|
@ -150,6 +154,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
|||
MihomoIpVersion: req.MihomoIpVersion,
|
||||
MihomoX25519: req.MihomoX25519,
|
||||
ShuffleHost: req.ShuffleHost,
|
||||
RandomCount: clampRandomCount(req.RandomCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -363,3 +368,13 @@ func parseHostAndPort(hostStr string, defaultPort int) (string, int) {
|
|||
}
|
||||
return addr, defaultPort
|
||||
}
|
||||
|
||||
func clampRandomCount(n int) int {
|
||||
if n <= 0 {
|
||||
return 1
|
||||
}
|
||||
if n > 100 {
|
||||
return 100
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,7 @@
|
|||
"mihomoIpVersion": "إصدار IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "خلط المضيف",
|
||||
"randomCount": "عدد عناوين IP العشوائية",
|
||||
"tags": "وسوم",
|
||||
"nodeGuids": "النودز",
|
||||
"excludeFromSubTypes": "استبعاد من الصيغ",
|
||||
|
|
|
|||
|
|
@ -1099,6 +1099,7 @@
|
|||
"mihomoIpVersion": "IP version",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Shuffle host",
|
||||
"randomCount": "Random IP count",
|
||||
"tags": "Tags",
|
||||
"nodeGuids": "Nodes",
|
||||
"excludeFromSubTypes": "Exclude from formats",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "Versión de IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Barajar host",
|
||||
"randomCount": "Cantidad de IPs aleatorias",
|
||||
"tags": "Etiquetas",
|
||||
"nodeGuids": "Nodos",
|
||||
"excludeFromSubTypes": "Excluir de formatos",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "نسخه IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "درهمسازی میزبان",
|
||||
"randomCount": "تعداد IP تصادفی",
|
||||
"tags": "برچسبها",
|
||||
"nodeGuids": "نودها",
|
||||
"excludeFromSubTypes": "حذف از فرمتها",
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,7 @@
|
|||
"mihomoIpVersion": "Versi IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Acak host",
|
||||
"randomCount": "Jumlah IP acak",
|
||||
"tags": "Tag",
|
||||
"nodeGuids": "Node",
|
||||
"excludeFromSubTypes": "Kecualikan dari format",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "IP バージョン",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "ホストをシャッフル",
|
||||
"randomCount": "ランダム IP 数",
|
||||
"tags": "タグ",
|
||||
"nodeGuids": "ノード",
|
||||
"excludeFromSubTypes": "形式から除外",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "Versão de IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Embaralhar host",
|
||||
"randomCount": "Contagem de IPs aleatórios",
|
||||
"tags": "Tags",
|
||||
"nodeGuids": "Nós",
|
||||
"excludeFromSubTypes": "Excluir dos formatos",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "Версия IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Перемешивать хост",
|
||||
"randomCount": "Количество случайных IP",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Узлы",
|
||||
"excludeFromSubTypes": "Исключить из форматов",
|
||||
|
|
|
|||
|
|
@ -2349,6 +2349,7 @@
|
|||
"mihomoIpVersion": "IP sürümü",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Host'u karıştır",
|
||||
"randomCount": "Rastgele IP sayısı",
|
||||
"tags": "Etiketler",
|
||||
"nodeGuids": "Düğümler",
|
||||
"excludeFromSubTypes": "Formatlardan hariç tut",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "Версія IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Перемішувати host",
|
||||
"randomCount": "Кількість випадкових IP",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Вузли",
|
||||
"excludeFromSubTypes": "Виключити з форматів",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "Phiên bản IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Xáo trộn host",
|
||||
"randomCount": "Số lượng IP ngẫu nhiên",
|
||||
"tags": "Tag",
|
||||
"nodeGuids": "Nút",
|
||||
"excludeFromSubTypes": "Loại trừ khỏi định dạng",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "IP 版本",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "随机打乱 Host",
|
||||
"randomCount": "随机 IP 数量",
|
||||
"tags": "标签",
|
||||
"nodeGuids": "节点",
|
||||
"excludeFromSubTypes": "从格式中排除",
|
||||
|
|
|
|||
|
|
@ -2350,6 +2350,7 @@
|
|||
"mihomoIpVersion": "IP 版本",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "隨機排序 Host",
|
||||
"randomCount": "隨機 IP 數量",
|
||||
"tags": "標籤",
|
||||
"nodeGuids": "節點",
|
||||
"excludeFromSubTypes": "從格式中排除",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue