From 40cea0c2fea588dcbbc9fefa545fe37cdec5cd22 Mon Sep 17 00:00:00 2001 From: Pejman Yousefi Date: Sat, 24 Oct 2026 06:43:44 +0330 Subject: [PATCH] feat(sub): generate random IPs from CIDR host addresses (#6784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/public/openapi.json | 17 ++ frontend/public/openapi.json | 17 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 11 + frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/lib/hosts/host-cidr.ts | 266 ++++++++++++++++++ frontend/src/lib/hosts/host-link.ts | 48 +++- frontend/src/pages/hosts/HostFormModal.tsx | 8 + frontend/src/schemas/api/host.ts | 2 + .../src/test/tunnel-host-endpoints.test.ts | 21 ++ internal/database/db.go | 1 + internal/database/host_test.go | 2 +- internal/database/model/model.go | 1 + internal/sub/host_cidr.go | 156 ++++++++++ internal/sub/host_cidr_test.go | 175 ++++++++++++ internal/sub/host_sub.go | 23 ++ internal/sub/host_sub_test.go | 111 ++++++++ internal/web/entity/entity.go | 1 + internal/web/service/host.go | 15 + internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 33 files changed, 886 insertions(+), 8 deletions(-) create mode 100644 frontend/src/lib/hosts/host-cidr.ts create mode 100644 internal/sub/host_cidr.go create mode 100644 internal/sub/host_cidr_test.go diff --git a/docs/public/openapi.json b/docs/public/openapi.json index e18a6107b..e9dc2f9fc 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -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": "", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e18a6107b..e9dc2f9fc 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -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": "", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 9b9bf9603..ded5d7826 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -706,6 +706,7 @@ export const EXAMPLES: Record = { "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -755,6 +756,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 0e39ecdd6..5902ec19b 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2732,6 +2732,10 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "randomCount": { + "example": 1, + "type": "integer" + }, "remark": { "example": "cdn-front", "maxLength": 256, @@ -2804,6 +2808,7 @@ export const SCHEMAS: Record = { "path", "pinnedPeerCertSha256", "port", + "randomCount", "remark", "security", "serverDescription", @@ -2913,6 +2918,11 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "randomCount": { + "maximum": 100, + "minimum": 1, + "type": "integer" + }, "remark": { "maxLength": 256, "type": "string" @@ -2978,6 +2988,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 34945dfd2..0954c5ea5 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -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; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 8633c2846..b8fc7bafd 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -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), 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 ca2efeebb..8354148db 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. @@ -255,6 +258,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/db.go b/internal/database/db.go index cf319df60..2c23c51cd 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -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, } } 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 e8a3ebc1a..4a3a3edbe 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -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"` 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 75d01961c..9a450a7ed 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, "", 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 4a5adb235..f8a3f6226 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -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"` } 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 f0fa5d5cd..e02e18729 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2368,6 +2368,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 923475e76..10b336f74 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 5c66a0c4d..d534624f2 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index a4726af21..a6ade6731 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2369,6 +2369,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 9854cc354..bf45d55e2 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 7f1be3ddb..2e290f3d3 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2369,6 +2369,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 39d880e13..bf5b53f06 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 656b063ff..4ace5f356 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2369,6 +2369,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 4e997f03c..e429ffaac 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 02169e8be..0f23326cc 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2369,6 +2369,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 b9e269fbc..ba659183d 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index f9bcf5295..9b34ce068 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2369,6 +2369,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 7213233d0..e0cfb4ca3 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2369,6 +2369,7 @@ "mihomoIpVersion": "IP 版本", "mihomoX25519": "Mihomo X25519", "shuffleHost": "隨機排序 Host", + "randomCount": "隨機 IP 數量", "tags": "標籤", "nodeGuids": "節點", "excludeFromSubTypes": "從格式中排除",