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:
Pejman Yousefi
2026-10-24 06:43:44 +03:30
committed by GitHub
parent f6a233220a
commit 40cea0c2fe
33 changed files with 886 additions and 8 deletions
+17
View File
@@ -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": "",
+2
View File
@@ -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": "",
+11
View File
@@ -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",
+2
View File
@@ -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;
+2
View File
@@ -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
View 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;
}
}
}
+41 -7
View File
@@ -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')}
+2
View File
@@ -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]$/);
}
});
});