mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 12:52:07 +00:00
feat(sub): generate random IPs from CIDR host addresses (#6784)
Operators who front a server with an IP range (a CDN or anycast prefix) had to list each address as its own host. A host address may now be a CIDR, and every subscription fetch expands it into up to randomCount (1–100, default 1) unique random host IPs from the range, IPv4 and IPv6 alike, skipping the network and broadcast addresses. The panel's own previews, QR codes and tunnel config exports (WireGuard, AmneziaWG, TUIC, MTProto) expand it the same way, and the externalProxy importer writes the model's default count so the re-sync still recognises untouched imported hosts.
This commit is contained in:
parent
f6a233220a
commit
40cea0c2fe
33 changed files with 886 additions and 8 deletions
|
|
@ -2758,6 +2758,10 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2830,6 +2834,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2939,6 +2944,11 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -3004,6 +3014,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -12004,6 +12015,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12099,6 +12111,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12197,6 +12210,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12345,6 +12359,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12467,6 +12482,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12719,6 +12735,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -2758,6 +2758,10 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2830,6 +2834,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2939,6 +2944,11 @@
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -3004,6 +3014,7 @@
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -12004,6 +12015,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12099,6 +12111,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12197,6 +12210,7 @@
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12345,6 +12359,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12467,6 +12482,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -12719,6 +12735,7 @@
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -706,6 +706,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||
""
|
||||
],
|
||||
"port": 8443,
|
||||
"randomCount": 1,
|
||||
"remark": "cdn-front",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
@ -755,6 +756,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||
""
|
||||
],
|
||||
"port": 0,
|
||||
"randomCount": 1,
|
||||
"remark": "",
|
||||
"security": "same",
|
||||
"serverDescription": "",
|
||||
|
|
|
|||
|
|
@ -2732,6 +2732,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"example": "cdn-front",
|
||||
"maxLength": 256,
|
||||
|
|
@ -2804,6 +2808,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
@ -2913,6 +2918,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"randomCount": {
|
||||
"maximum": 100,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"maxLength": 256,
|
||||
"type": "string"
|
||||
|
|
@ -2978,6 +2988,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||
"path",
|
||||
"pinnedPeerCertSha256",
|
||||
"port",
|
||||
"randomCount",
|
||||
"remark",
|
||||
"security",
|
||||
"serverDescription",
|
||||
|
|
|
|||
|
|
@ -648,6 +648,7 @@ export interface Host {
|
|||
path: string;
|
||||
pinnedPeerCertSha256: string[];
|
||||
port: number;
|
||||
randomCount: number;
|
||||
remark: string;
|
||||
security: string;
|
||||
serverDescription: string;
|
||||
|
|
@ -684,6 +685,7 @@ export interface HostGroup {
|
|||
path: string;
|
||||
pinnedPeerCertSha256: string[];
|
||||
port: number;
|
||||
randomCount: number;
|
||||
remark: string;
|
||||
security: string;
|
||||
serverDescription: string;
|
||||
|
|
|
|||
|
|
@ -688,6 +688,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),
|
||||
|
|
@ -725,6 +726,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.
|
||||
|
|
@ -255,6 +258,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]$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -998,6 +998,7 @@ func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Ho
|
|||
Alpn: anyToNonEmptyStrings(ep["alpn"]),
|
||||
PinnedPeerCertSha256: anyToNonEmptyStrings(ep["pinnedPeerCertSha256"]),
|
||||
EchConfigList: ech,
|
||||
RandomCount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,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, "", 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -447,4 +447,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2368,6 +2368,7 @@
|
|||
"mihomoIpVersion": "إصدار IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "خلط المضيف",
|
||||
"randomCount": "عدد عناوين IP العشوائية",
|
||||
"tags": "وسوم",
|
||||
"nodeGuids": "النودز",
|
||||
"excludeFromSubTypes": "استبعاد من الصيغ",
|
||||
|
|
|
|||
|
|
@ -1106,6 +1106,7 @@
|
|||
"mihomoIpVersion": "IP version",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Shuffle host",
|
||||
"randomCount": "Random IP count",
|
||||
"tags": "Tags",
|
||||
"nodeGuids": "Nodes",
|
||||
"excludeFromSubTypes": "Exclude from formats",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,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",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "نسخه IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "درهمسازی میزبان",
|
||||
"randomCount": "تعداد IP تصادفی",
|
||||
"tags": "برچسبها",
|
||||
"nodeGuids": "نودها",
|
||||
"excludeFromSubTypes": "حذف از فرمتها",
|
||||
|
|
|
|||
|
|
@ -2368,6 +2368,7 @@
|
|||
"mihomoIpVersion": "Versi IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Acak host",
|
||||
"randomCount": "Jumlah IP acak",
|
||||
"tags": "Tag",
|
||||
"nodeGuids": "Node",
|
||||
"excludeFromSubTypes": "Kecualikan dari format",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "IP バージョン",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "ホストをシャッフル",
|
||||
"randomCount": "ランダム IP 数",
|
||||
"tags": "タグ",
|
||||
"nodeGuids": "ノード",
|
||||
"excludeFromSubTypes": "形式から除外",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,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",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "Версия IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Перемешивать хост",
|
||||
"randomCount": "Количество случайных IP",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Узлы",
|
||||
"excludeFromSubTypes": "Исключить из форматов",
|
||||
|
|
|
|||
|
|
@ -2368,6 +2368,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",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "Версія IP",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "Перемішувати host",
|
||||
"randomCount": "Кількість випадкових IP",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Вузли",
|
||||
"excludeFromSubTypes": "Виключити з форматів",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,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",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "IP 版本",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "随机打乱 Host",
|
||||
"randomCount": "随机 IP 数量",
|
||||
"tags": "标签",
|
||||
"nodeGuids": "节点",
|
||||
"excludeFromSubTypes": "从格式中排除",
|
||||
|
|
|
|||
|
|
@ -2369,6 +2369,7 @@
|
|||
"mihomoIpVersion": "IP 版本",
|
||||
"mihomoX25519": "Mihomo X25519",
|
||||
"shuffleHost": "隨機排序 Host",
|
||||
"randomCount": "隨機 IP 數量",
|
||||
"tags": "標籤",
|
||||
"nodeGuids": "節點",
|
||||
"excludeFromSubTypes": "從格式中排除",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue