diff --git a/docs/public/openapi.json b/docs/public/openapi.json index b50cb368f..90244b90d 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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": "", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index b50cb368f..90244b90d 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": "", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index ce0ef8676..837911129 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -702,6 +702,7 @@ export const EXAMPLES: Record = { "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -751,6 +752,7 @@ export const EXAMPLES: Record = { "" ], "port": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 6208b5d3f..11c3f6a3c 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2712,6 +2712,10 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "randomCount": { + "example": 1, + "type": "integer" + }, "remark": { "example": "cdn-front", "maxLength": 256, @@ -2784,6 +2788,7 @@ export const SCHEMAS: Record = { "path", "pinnedPeerCertSha256", "port", + "randomCount", "remark", "security", "serverDescription", @@ -2893,6 +2898,11 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "randomCount": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, "remark": { "maxLength": 256, "type": "string" @@ -2958,6 +2968,7 @@ export const SCHEMAS: Record = { "path", "pinnedPeerCertSha256", "port", + "randomCount", "remark", "security", "serverDescription", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index 86334f324..e18d0247e 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 744b93c06..47546a1a5 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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), diff --git a/frontend/src/lib/hosts/host-cidr.ts b/frontend/src/lib/hosts/host-cidr.ts new file mode 100644 index 000000000..3c3f45148 --- /dev/null +++ b/frontend/src/lib/hosts/host-cidr.ts @@ -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(); + 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(); + 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; + } + } +} diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index 5bdd32a0b..7dc75d202 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/frontend/src/lib/hosts/host-link.ts @@ -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; diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 172e71160..931f7173b 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -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" /> + {hasCidr && ( + + + + )} ; @@ -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; diff --git a/frontend/src/test/tunnel-host-endpoints.test.ts b/frontend/src/test/tunnel-host-endpoints.test.ts index 8a4955d61..2cb5374e4 100644 --- a/frontend/src/test/tunnel-host-endpoints.test.ts +++ b/frontend/src/test/tunnel-host-endpoints.test.ts @@ -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]$/); + } + }); }); diff --git a/internal/database/host_test.go b/internal/database/host_test.go index bf577e41f..d6b497ffc 100644 --- a/internal/database/host_test.go +++ b/internal/database/host_test.go @@ -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", } } diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 18c61158f..fa6deb4a0 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -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"` diff --git a/internal/sub/host_cidr.go b/internal/sub/host_cidr.go new file mode 100644 index 000000000..cbaa099e1 --- /dev/null +++ b/internal/sub/host_cidr.go @@ -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() +} diff --git a/internal/sub/host_cidr_test.go b/internal/sub/host_cidr_test.go new file mode 100644 index 000000000..6143c5501 --- /dev/null +++ b/internal/sub/host_cidr_test.go @@ -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") + } +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 6dabec503..9e668f2b1 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -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 diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 6cd02ba0b..08a3e081c 100644 --- a/internal/sub/host_sub_test.go +++ b/internal/sub/host_sub_test.go @@ -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) + } +} diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 3b2dbf612..c37a7069e 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -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"` } diff --git a/internal/web/service/host.go b/internal/web/service/host.go index 6739af376..a8c18c1e2 100644 --- a/internal/web/service/host.go +++ b/internal/web/service/host.go @@ -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 +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index ccf5c6c28..e0149a86b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2349,6 +2349,7 @@ "mihomoIpVersion": "إصدار IP", "mihomoX25519": "Mihomo X25519", "shuffleHost": "خلط المضيف", + "randomCount": "عدد عناوين IP العشوائية", "tags": "وسوم", "nodeGuids": "النودز", "excludeFromSubTypes": "استبعاد من الصيغ", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 1e36a4f10..05efa56ed 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index a7ecf7081..fe0853c67 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index da42d9691..39ec1d384 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "نسخه IP", "mihomoX25519": "Mihomo X25519", "shuffleHost": "درهم‌سازی میزبان", + "randomCount": "تعداد IP تصادفی", "tags": "برچسب‌ها", "nodeGuids": "نودها", "excludeFromSubTypes": "حذف از فرمت‌ها", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 024054197..ca269ff25 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 2ad0a2f8c..65cc0b86b 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "IP バージョン", "mihomoX25519": "Mihomo X25519", "shuffleHost": "ホストをシャッフル", + "randomCount": "ランダム IP 数", "tags": "タグ", "nodeGuids": "ノード", "excludeFromSubTypes": "形式から除外", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e62c85309..f4c47a7e5 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index b8b4029bc..1bf8aac97 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "Версия IP", "mihomoX25519": "Mihomo X25519", "shuffleHost": "Перемешивать хост", + "randomCount": "Количество случайных IP", "tags": "Теги", "nodeGuids": "Узлы", "excludeFromSubTypes": "Исключить из форматов", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 1d487bcb7..13ac2e985 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 991e112b5..b8ee12be5 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "Версія IP", "mihomoX25519": "Mihomo X25519", "shuffleHost": "Перемішувати host", + "randomCount": "Кількість випадкових IP", "tags": "Теги", "nodeGuids": "Вузли", "excludeFromSubTypes": "Виключити з форматів", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 6df8d3ac7..d0c3451bc 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index a083b48bb..c526277ad 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "IP 版本", "mihomoX25519": "Mihomo X25519", "shuffleHost": "随机打乱 Host", + "randomCount": "随机 IP 数量", "tags": "标签", "nodeGuids": "节点", "excludeFromSubTypes": "从格式中排除", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 7a22cbca5..d7a5e4e72 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2350,6 +2350,7 @@ "mihomoIpVersion": "IP 版本", "mihomoX25519": "Mihomo X25519", "shuffleHost": "隨機排序 Host", + "randomCount": "隨機 IP 數量", "tags": "標籤", "nodeGuids": "節點", "excludeFromSubTypes": "從格式中排除",