This commit is contained in:
Pejman Yousefi 2026-10-12 15:13:00 +00:00 • committed by GitHub
commit 79978d5223
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 885 additions and 8 deletions

View file

@ -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": "",

View file

@ -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": "",

View file

@ -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": "",

View file

@ -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",

View file

@ -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;

View file

@ -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),

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;
}
}
}

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;

View file

@ -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')}

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>;

View file

@ -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]$/);
}
});
});

View file

@ -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",
}
}

View file

@ -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
View 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()
}

View 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")
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"`
}

View file

@ -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
}

View file

@ -2349,6 +2349,7 @@
"mihomoIpVersion": "إصدار IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "خلط المضيف",
"randomCount": "عدد عناوين IP العشوائية",
"tags": "وسوم",
"nodeGuids": "النودز",
"excludeFromSubTypes": "استبعاد من الصيغ",

View file

@ -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",

View file

@ -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",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "نسخه IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "درهم‌سازی میزبان",
"randomCount": "تعداد IP تصادفی",
"tags": "برچسب‌ها",
"nodeGuids": "نودها",
"excludeFromSubTypes": "حذف از فرمت‌ها",

View file

@ -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",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "IP バージョン",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "ホストをシャッフル",
"randomCount": "ランダム IP 数",
"tags": "タグ",
"nodeGuids": "ノード",
"excludeFromSubTypes": "形式から除外",

View file

@ -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",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "Версия IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "Перемешивать хост",
"randomCount": "Количество случайных IP",
"tags": "Теги",
"nodeGuids": "Узлы",
"excludeFromSubTypes": "Исключить из форматов",

View file

@ -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",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "Версія IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "Перемішувати host",
"randomCount": "Кількість випадкових IP",
"tags": "Теги",
"nodeGuids": "Вузли",
"excludeFromSubTypes": "Виключити з форматів",

View file

@ -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",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "IP 版本",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "随机打乱 Host",
"randomCount": "随机 IP 数量",
"tags": "标签",
"nodeGuids": "节点",
"excludeFromSubTypes": "从格式中排除",

View file

@ -2350,6 +2350,7 @@
"mihomoIpVersion": "IP 版本",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "隨機排序 Host",
"randomCount": "隨機 IP 數量",
"tags": "標籤",
"nodeGuids": "節點",
"excludeFromSubTypes": "從格式中排除",