From dd1d9e67c75959bf312a563f193620403e9f882d Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Sat, 10 Oct 2026 14:13:01 +0330 Subject: [PATCH 1/7] feat(sub): support random IP generation from CIDR host addresses Add support for IPv4 and IPv6 CIDR notations in Host addresses. When a host address is configured with a CIDR subnet, up to randomCount unique random IPs are generated during subscription link generation (supporting raw links, Clash configs, and Xray JSON outbounds). - Introduce internal/sub/host_cidr.go with cryptographic random IP generation, respecting network/broadcast and anycast exclusions. - Add randomCount field to Host model, DB migration, OpenAPI schemas, and host endpoints expansion. - Conditionally render randomCount input in Host modal when a CIDR address is present. - Update unit tests across database, host subscription, and CIDR parsing. - Synchronize translations across all 14 supported locales. --- docs/public/openapi.json | 15 ++ frontend/public/openapi.json | 15 ++ frontend/src/generated/examples.ts | 2 + frontend/src/generated/schemas.ts | 9 ++ frontend/src/generated/types.ts | 2 + frontend/src/generated/zod.ts | 2 + frontend/src/pages/hosts/HostFormModal.tsx | 14 ++ frontend/src/schemas/api/host.ts | 2 + internal/database/host_test.go | 2 +- internal/database/model/model.go | 1 + internal/sub/host_cidr.go | 144 +++++++++++++++++++ internal/sub/host_cidr_test.go | 154 +++++++++++++++++++++ internal/sub/host_sub.go | 23 +++ internal/sub/host_sub_test.go | 111 +++++++++++++++ internal/web/entity/entity.go | 1 + internal/web/service/host.go | 5 + 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 + 29 files changed, 514 insertions(+), 1 deletion(-) 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 43feed637..7bbb4f692 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,9 @@ "minimum": 0, "type": "integer" }, + "randomCount": { + "type": "integer" + }, "remark": { "maxLength": 256, "type": "string" @@ -2984,6 +2992,7 @@ "path", "pinnedPeerCertSha256", "port", + "randomCount", "remark", "security", "serverDescription", @@ -11908,6 +11917,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12003,6 +12013,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12101,6 +12112,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12249,6 +12261,7 @@ "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -12371,6 +12384,7 @@ "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -12623,6 +12637,7 @@ "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 43feed637..7bbb4f692 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,9 @@ "minimum": 0, "type": "integer" }, + "randomCount": { + "type": "integer" + }, "remark": { "maxLength": 256, "type": "string" @@ -2984,6 +2992,7 @@ "path", "pinnedPeerCertSha256", "port", + "randomCount", "remark", "security", "serverDescription", @@ -11908,6 +11917,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12003,6 +12013,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12101,6 +12112,7 @@ "" ], "port": 0, + "randomCount": 0, "remark": "", "security": "same", "serverDescription": "", @@ -12249,6 +12261,7 @@ "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -12371,6 +12384,7 @@ "" ], "port": 8443, + "randomCount": 1, "remark": "cdn-front", "security": "same", "serverDescription": "", @@ -12623,6 +12637,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..840eed55d 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": 0, "remark": "", "security": "same", "serverDescription": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 4c72b6ac7..947d8a93d 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,9 @@ export const SCHEMAS: Record = { "minimum": 0, "type": "integer" }, + "randomCount": { + "type": "integer" + }, "remark": { "maxLength": 256, "type": "string" @@ -2958,6 +2966,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 b3fbcd047..9a40d83df 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(), remark: z.string().max(256), security: z.enum(['same', 'tls', 'none', 'reality']), serverDescription: z.string().max(64), diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 172e71160..1d78719ac 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,11 @@ 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' }) ?? []) as string[]; + const hasCidr = useMemo( + () => watchedHosts.some((h) => typeof h === 'string' && h.includes('/')), + [watchedHosts], + ); // React resets this during render rather than in an effect so the modal's // first open frame already shows cleared fields. @@ -251,6 +257,14 @@ 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/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 cabbbb2de..c4b0bf6cd 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1109,6 +1109,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..4a69dc365 --- /dev/null +++ b/internal/sub/host_cidr.go @@ -0,0 +1,144 @@ +package sub + +import ( + "crypto/rand" + "encoding/binary" + "math/big" + "net" + "strings" +) + +// IsCIDR reports whether s represents a valid CIDR network notation. +func IsCIDR(s string) bool { + s = strings.Trim(strings.TrimSpace(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 + } + clean := strings.Trim(strings.TrimSpace(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..ac51a83fa --- /dev/null +++ b/internal/sub/host_cidr_test.go @@ -0,0 +1,154 @@ +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: "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)) + } + }) +} + +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 c3ba6aab7..1d8e57558 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -431,4 +431,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"` } diff --git a/internal/web/service/host.go b/internal/web/service/host.go index c7ea45f79..30656550d 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: req.RandomCount, }) } } diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 7e6c740af..783ffdd6b 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -2298,6 +2298,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 83b25e14b..671425c56 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1061,6 +1061,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 e92ee5c7a..3a6a418a2 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -2299,6 +2299,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 075640682..48cde1d56 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -2299,6 +2299,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 239e8b26b..b148432cb 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -2298,6 +2298,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 f98d0b15e..cb1c35531 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -2299,6 +2299,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 45402b211..430622d90 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -2299,6 +2299,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 5e3d78989..83160afee 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -2299,6 +2299,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 dc76b4b50..a81d71f3f 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -2298,6 +2298,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 21194f97c..699228567 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -2299,6 +2299,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 78ad5ec64..d36c996db 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -2299,6 +2299,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 a70d00e39..19e11d1d1 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -2299,6 +2299,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 9f3a518d4..82f48a0af 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -2299,6 +2299,7 @@ "mihomoIpVersion": "IP 版本", "mihomoX25519": "Mihomo X25519", "shuffleHost": "隨機排序 Host", + "randomCount": "隨機 IP 數量", "tags": "標籤", "nodeGuids": "節點", "excludeFromSubTypes": "從格式中排除", From d74a57b482145aaf22fd0efc449742d148e5bcee Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Sat, 10 Oct 2026 15:01:53 +0330 Subject: [PATCH 2/7] fix(ui): compute hasCidr without useMemo to satisfy oxlint rules --- frontend/src/pages/hosts/HostFormModal.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index 1d78719ac..da5103741 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -96,11 +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' }) ?? []) as string[]; - const hasCidr = useMemo( - () => watchedHosts.some((h) => typeof h === 'string' && h.includes('/')), - [watchedHosts], - ); + 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. From 54b15f61c623966502c2b09ad1b5d495c74db672 Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Sat, 10 Oct 2026 15:13:31 +0330 Subject: [PATCH 3/7] fix(ui): format randomCount form field to satisfy oxfmt rules --- frontend/src/pages/hosts/HostFormModal.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index da5103741..931f7173b 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -255,10 +255,7 @@ export default function HostFormModal({ /> {hasCidr && ( - + )} From 90688d04af16d9e0ce45cfdbc0e9bba1a5d0ebd6 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 12 Oct 2026 02:41:02 +0200 Subject: [PATCH 4/7] fix(ci): stop the PR review ending mid-wait and posting its draft Run 38188509693 posted "placeholder" on PR #6784. CI on the head was still running, the prompt claimed CI "already ran", so the model wrote a stub to /tmp/review.md, tried to poll CI (refused), and ended its turn "waiting" - which in headless mode ends the run. The split into three jobs had dropped the line saying the run ends with the turn. The prompt now says so again, tells the model to read CI once and report each check as concluded, running or never started without waiting, and to write the file once, finished, as its last action. The post job refuses a body without the model's own Reviewed head line instead of appending one: appending it is what made the stub look like a review and would have suppressed later automatic reviews of the PR. --- .github/workflows/claude-pr-review.yml | 34 ++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/.github/workflows/claude-pr-review.yml b/.github/workflows/claude-pr-review.yml index f7720db8e..cd22107bf 100644 --- a/.github/workflows/claude-pr-review.yml +++ b/.github/workflows/claude-pr-review.yml @@ -215,12 +215,14 @@ jobs: `REVIEW.md` calls blocking. CI IS THE BUILD - You cannot build or test here, but CI already ran on the head. Read - its check runs with + You cannot build or test here; CI builds the head, and may still be + running or waiting for a maintainer to approve a fork's run. Read its + check runs once, near the end, with `gh api repos/${{ env.REPO }}/commits/${{ needs.prepare.outputs.sha }}/check-runs` - and report what they concluded instead of writing that verification - was unavailable. A required check that failed, or never ran on this - head, is itself a finding. + and report each as it stands: concluded, still running, or never + started. Never wait or poll for CI - nothing resumes this run. A + check that concluded in failure is a finding; one still running is + unverified, and the coverage list says so. UPSTREAM SOURCE The xray-core module the base `go.mod` pins is unpacked read-only at @@ -282,11 +284,14 @@ jobs: a maintainer, or withdrawn as wrong with the `file:line` that shows it. THE COMMENT - You post nothing yourself, and your token cannot write to GitHub. - The review is ONE plain comment: write its body to `/tmp/review.md` - with the Write tool - the only file you may write - and end your - turn; the workflow posts that file on the pull request afterwards. - A run that ends without writing it has failed. The comment opens + This run ends the moment you end your turn: nothing waits, polls or + resumes it, so the whole review happens in this one turn. You post + nothing yourself, and your token cannot write to GitHub. The review + is ONE plain comment: write its finished body to `/tmp/review.md` + with the Write tool - the only file you may write - as your last + action, never a placeholder or a draft. Whatever that file holds + when your turn ends is what the workflow posts; a run that ends + without the finished review in it has failed. The comment opens with the verdict, then the tally, carries the line `Reviewed head: ${{ needs.prepare.outputs.sha }}`, gives each finding in full with its `file:line`, and ends with the coverage list @@ -378,8 +383,8 @@ jobs: || exit 0 echo "skipped=true" >> "$GITHUB_OUTPUT" echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined." - # The review never proposes code, so a fence is cut out before posting. The - # skip in `prepare` keys on the Reviewed head line, so it is never left out. + # A body without its own Reviewed head line is unfinished (run 38188509693 + # posted a "placeholder"), and posting it would mark the PR as reviewed. - name: Post the review if: ${{ !cancelled() && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }} env: @@ -391,10 +396,13 @@ jobs: echo "::error::The review run ended without writing a review of ${HEAD_SHA} for #${PR}. Read the uploaded transcript before re-running." exit 1 fi + if ! grep -qF "Reviewed head: ${HEAD_SHA}" "$BODY"; then + echo "::error::The review of ${HEAD_SHA} for #${PR} is unfinished: its body lacks the Reviewed head line, so nothing was posted. Read the uploaded transcript before re-running." + exit 1 + fi out=$(mktemp) awk '/^[ \t]*(```|~~~)/ { if (!open) print "_[Code removed: this review does not propose code.]_"; open = !open; next } !open' \ "$BODY" > "$out" - grep -qF "Reviewed head: ${HEAD_SHA}" "$out" || printf '\nReviewed head: %s\n' "$HEAD_SHA" >> "$out" gh pr comment "$PR" --repo "$REPO" --body-file "$out" if grep -qE '^[[:space:]]*(```|~~~)' "$BODY"; then echo "::error::Removed fenced code from the review of #${PR} before posting it: the review must not propose code." From 265238f630baf32f0d7da831e979f270d783d84a Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Mon, 12 Oct 2026 17:54:59 +0330 Subject: [PATCH 5/7] fix(sub): mirror CIDR expansion in frontend and clamp randomCount - Mirror backend CIDR expansion in frontend host-link to prevent literal CIDR endpoints in panel tunnel configs - Clamp and validate randomCount to 1..100 across server entities, service rows, and CIDR generation - Clean bracketed IPv6 CIDR notations ([2001:db8::]/64) in IsCIDR and random IP generation - Synchronize OpenAPI schemas and regenerate client types --- docs/public/openapi.json | 8 +- frontend/public/openapi.json | 8 +- frontend/src/generated/examples.ts | 2 +- frontend/src/generated/schemas.ts | 2 + frontend/src/generated/zod.ts | 2 +- frontend/src/lib/hosts/host-cidr.ts | 264 ++++++++++++++++++ frontend/src/lib/hosts/host-link.ts | 50 +++- frontend/src/test/host-cidr.test.ts | 98 +++++++ .../src/test/tunnel-host-endpoints.test.ts | 22 ++ internal/sub/host_cidr.go | 18 +- internal/sub/host_cidr_test.go | 21 ++ internal/web/entity/entity.go | 2 +- internal/web/service/host.go | 12 +- 13 files changed, 490 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/hosts/host-cidr.ts create mode 100644 frontend/src/test/host-cidr.test.ts diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 829eca355..46b316821 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -2925,6 +2925,8 @@ "type": "integer" }, "randomCount": { + "maximum": 100, + "minimum": 1, "type": "integer" }, "remark": { @@ -11918,7 +11920,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", @@ -12014,7 +12016,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", @@ -12113,7 +12115,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 829eca355..46b316821 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2925,6 +2925,8 @@ "type": "integer" }, "randomCount": { + "maximum": 100, + "minimum": 1, "type": "integer" }, "remark": { @@ -11918,7 +11920,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", @@ -12014,7 +12016,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", @@ -12113,7 +12115,7 @@ "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 840eed55d..837911129 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -752,7 +752,7 @@ export const EXAMPLES: Record = { "" ], "port": 0, - "randomCount": 0, + "randomCount": 1, "remark": "", "security": "same", "serverDescription": "", diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index fdb476751..11c3f6a3c 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -2899,6 +2899,8 @@ export const SCHEMAS: Record = { "type": "integer" }, "randomCount": { + "maximum": 100, + "minimum": 1, "type": "integer" }, "remark": { diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 3a8f81382..47546a1a5 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -722,7 +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(), + 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..68d658c6c --- /dev/null +++ b/frontend/src/lib/hosts/host-cidr.ts @@ -0,0 +1,264 @@ +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..b6ded1443 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,28 @@ 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]; + const remark = + ips.length > 1 + ? record.remark + ? `${record.remark} - ${i + 1}` + : `${i + 1}` + : record.remark || ''; + 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/test/host-cidr.test.ts b/frontend/src/test/host-cidr.test.ts new file mode 100644 index 000000000..d6b0cefe0 --- /dev/null +++ b/frontend/src/test/host-cidr.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; + +import { + cleanCIDR, + formatIPv4, + formatIPv6, + isCIDR, + MAX_RANDOM_CIDR_COUNT, + parseIPv4, + parseIPv6, + randomIPsFromCIDR, +} from '@/lib/hosts/host-cidr'; + +describe('cleanCIDR', () => { + it('handles standard and bracketed forms', () => { + expect(cleanCIDR('192.168.1.0/24')).toBe('192.168.1.0/24'); + expect(cleanCIDR('[192.168.1.0/24]')).toBe('192.168.1.0/24'); + expect(cleanCIDR('2001:db8::/64')).toBe('2001:db8::/64'); + expect(cleanCIDR('[2001:db8::/64]')).toBe('2001:db8::/64'); + expect(cleanCIDR('[2001:db8::]/64')).toBe('2001:db8::/64'); + }); +}); + +describe('isCIDR', () => { + it('validates IPv4 CIDR strings', () => { + expect(isCIDR('192.168.1.0/24')).toBe(true); + expect(isCIDR('10.0.0.0/8')).toBe(true); + expect(isCIDR('0.0.0.0/0')).toBe(true); + expect(isCIDR('192.168.1.1/32')).toBe(true); + expect(isCIDR('192.168.1.1/33')).toBe(false); + expect(isCIDR('192.168.1.1')).toBe(false); + }); + + it('validates IPv6 CIDR strings including bracketed forms', () => { + expect(isCIDR('2001:db8::/64')).toBe(true); + expect(isCIDR('[2001:db8::]/64')).toBe(true); + expect(isCIDR('[2001:db8::/64]')).toBe(true); + expect(isCIDR('::/0')).toBe(true); + expect(isCIDR('fe80::/10')).toBe(true); + expect(isCIDR('2001:db8::1')).toBe(false); + expect(isCIDR('example.com')).toBe(false); + expect(isCIDR('')).toBe(false); + }); +}); + +describe('randomIPsFromCIDR', () => { + it('generates random usable IPs for IPv4 CIDR', () => { + const ips = randomIPsFromCIDR('192.168.1.0/24', 5); + expect(ips).toHaveLength(5); + const unique = new Set(ips); + expect(unique.size).toBe(5); + for (const ip of ips) { + expect(ip.startsWith('192.168.1.')).toBe(true); + expect(ip).not.toBe('192.168.1.0'); + expect(ip).not.toBe('192.168.1.255'); + } + }); + + it('generates random usable IPs for bracketed IPv6 CIDR', () => { + const ips = randomIPsFromCIDR('[2001:db8::]/64', 3); + expect(ips).toHaveLength(3); + const unique = new Set(ips); + expect(unique.size).toBe(3); + for (const ip of ips) { + expect(ip.startsWith('2001:db8:')).toBe(true); + expect(ip).not.toBe('2001:db8::'); + } + }); + + it('caps count at MAX_RANDOM_CIDR_COUNT', () => { + const ips = randomIPsFromCIDR('2001:db8::/64', 500); + expect(ips).toHaveLength(MAX_RANDOM_CIDR_COUNT); + }); + + it('respects small subnet bounds', () => { + const ips = randomIPsFromCIDR('192.0.2.0/30', 10); + expect(ips).toHaveLength(2); + expect(ips.sort()).toEqual(['192.0.2.1', '192.0.2.2']); + }); +}); + +describe('IP parsers and formatters', () => { + it('handles IPv4 roundtrip', () => { + const u32 = parseIPv4('10.20.30.40'); + expect(u32).not.toBeNull(); + expect(formatIPv4(u32!)).toBe('10.20.30.40'); + }); + + it('handles IPv6 roundtrip and compression', () => { + const v6 = parseIPv6('2001:db8::1'); + expect(v6).not.toBeNull(); + expect(formatIPv6(v6!)).toBe('2001:db8::1'); + + const v6Zero = parseIPv6('::'); + expect(v6Zero).toBe(0n); + expect(formatIPv6(v6Zero!)).toBe('::'); + }); +}); diff --git a/frontend/src/test/tunnel-host-endpoints.test.ts b/frontend/src/test/tunnel-host-endpoints.test.ts index 8a4955d61..48ea02a7e 100644 --- a/frontend/src/test/tunnel-host-endpoints.test.ts +++ b/frontend/src/test/tunnel-host-endpoints.test.ts @@ -169,4 +169,26 @@ 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/sub/host_cidr.go b/internal/sub/host_cidr.go index 4a69dc365..7c5acf120 100644 --- a/internal/sub/host_cidr.go +++ b/internal/sub/host_cidr.go @@ -8,9 +8,21 @@ import ( "strings" ) +const MaxRandomCIDRCount = 100 + +func cleanCIDR(s string) string { + s = strings.TrimSpace(s) + s = strings.Trim(s, "[]") + if strings.HasPrefix(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 = strings.Trim(strings.TrimSpace(s), "[]") + s = cleanCIDR(s) if !strings.Contains(s, "/") { return false } @@ -23,8 +35,10 @@ func IsCIDR(s string) bool { func RandomIPsFromCIDR(cidr string, count int) ([]string, error) { if count <= 0 { count = 1 + } else if count > MaxRandomCIDRCount { + count = MaxRandomCIDRCount } - clean := strings.Trim(strings.TrimSpace(cidr), "[]") + clean := cleanCIDR(cidr) _, ipNet, err := net.ParseCIDR(clean) if err != nil { return nil, err diff --git a/internal/sub/host_cidr_test.go b/internal/sub/host_cidr_test.go index ac51a83fa..6143c5501 100644 --- a/internal/sub/host_cidr_test.go +++ b/internal/sub/host_cidr_test.go @@ -16,6 +16,7 @@ func TestIsCIDR(t *testing.T) { {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}, @@ -145,6 +146,26 @@ func TestRandomIPsFromCIDR_IPv6(t *testing.T) { 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) { diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index 8bfc0128f..c37a7069e 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -445,5 +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"` + 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 3bcf2bcf6..a8c18c1e2 100644 --- a/internal/web/service/host.go +++ b/internal/web/service/host.go @@ -154,7 +154,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host { MihomoIpVersion: req.MihomoIpVersion, MihomoX25519: req.MihomoX25519, ShuffleHost: req.ShuffleHost, - RandomCount: req.RandomCount, + RandomCount: clampRandomCount(req.RandomCount), }) } } @@ -368,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 +} From 61c02871d2b590c32be8bd8ab2491962df41e47b Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Mon, 12 Oct 2026 18:38:18 +0330 Subject: [PATCH 6/7] fix(ui): format host-cidr and satisfy strict typecheck rules --- frontend/src/lib/hosts/host-cidr.ts | 20 ++-- frontend/src/lib/hosts/host-link.ts | 12 +-- frontend/src/test/host-cidr.test.ts | 98 ------------------- .../src/test/tunnel-host-endpoints.test.ts | 9 +- 4 files changed, 20 insertions(+), 119 deletions(-) delete mode 100644 frontend/src/test/host-cidr.test.ts diff --git a/frontend/src/lib/hosts/host-cidr.ts b/frontend/src/lib/hosts/host-cidr.ts index 68d658c6c..3c3f45148 100644 --- a/frontend/src/lib/hosts/host-cidr.ts +++ b/frontend/src/lib/hosts/host-cidr.ts @@ -56,11 +56,7 @@ export function parseIPv6(ipStr: string): bigint | null { } const missingZeros = 8 - totalParts; - const allParts = [ - ...leftParts, - ...Array(missingZeros).fill('0'), - ...rightParts, - ]; + const allParts = [...leftParts, ...Array(missingZeros).fill('0'), ...rightParts]; let result = 0n; for (const part of allParts) { @@ -104,8 +100,14 @@ export function formatIPv6(val: bigint): string { } 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(':'); + 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}`; } @@ -157,7 +159,7 @@ export function randomIPsFromCIDR(cidrStr: string, count: number): string[] { function randomIPv4FromCIDR(ip4: number, prefix: number, count: number): string[] { const hostBits = 32 - prefix; - const mask = prefix === 0 ? 0 : ((~0 << hostBits) >>> 0); + const mask = prefix === 0 ? 0 : (~0 << hostBits) >>> 0; const baseU32 = (ip4 & mask) >>> 0; if (hostBits === 0) { @@ -199,7 +201,7 @@ function randomIPv4FromCIDR(ip4: number, prefix: number, count: number): string[ 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 mask = prefix === 0 ? 0n : ((1n << 128n) - 1n) ^ ((1n << BigInt(hostBits)) - 1n); const baseInt = ip6 & mask; if (hostBits === 0) { diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index b6ded1443..7dc75d202 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/frontend/src/lib/hosts/host-link.ts @@ -121,13 +121,11 @@ export function hostEndpointsFor( const ips = randomIPsFromCIDR(dest, count); if (ips.length > 0) { for (let i = 0; i < ips.length; i++) { - const ip = ips[i]; - const remark = - ips.length > 1 - ? record.remark - ? `${record.remark} - ${i + 1}` - : `${i + 1}` - : record.remark || ''; + 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; diff --git a/frontend/src/test/host-cidr.test.ts b/frontend/src/test/host-cidr.test.ts deleted file mode 100644 index d6b0cefe0..000000000 --- a/frontend/src/test/host-cidr.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { - cleanCIDR, - formatIPv4, - formatIPv6, - isCIDR, - MAX_RANDOM_CIDR_COUNT, - parseIPv4, - parseIPv6, - randomIPsFromCIDR, -} from '@/lib/hosts/host-cidr'; - -describe('cleanCIDR', () => { - it('handles standard and bracketed forms', () => { - expect(cleanCIDR('192.168.1.0/24')).toBe('192.168.1.0/24'); - expect(cleanCIDR('[192.168.1.0/24]')).toBe('192.168.1.0/24'); - expect(cleanCIDR('2001:db8::/64')).toBe('2001:db8::/64'); - expect(cleanCIDR('[2001:db8::/64]')).toBe('2001:db8::/64'); - expect(cleanCIDR('[2001:db8::]/64')).toBe('2001:db8::/64'); - }); -}); - -describe('isCIDR', () => { - it('validates IPv4 CIDR strings', () => { - expect(isCIDR('192.168.1.0/24')).toBe(true); - expect(isCIDR('10.0.0.0/8')).toBe(true); - expect(isCIDR('0.0.0.0/0')).toBe(true); - expect(isCIDR('192.168.1.1/32')).toBe(true); - expect(isCIDR('192.168.1.1/33')).toBe(false); - expect(isCIDR('192.168.1.1')).toBe(false); - }); - - it('validates IPv6 CIDR strings including bracketed forms', () => { - expect(isCIDR('2001:db8::/64')).toBe(true); - expect(isCIDR('[2001:db8::]/64')).toBe(true); - expect(isCIDR('[2001:db8::/64]')).toBe(true); - expect(isCIDR('::/0')).toBe(true); - expect(isCIDR('fe80::/10')).toBe(true); - expect(isCIDR('2001:db8::1')).toBe(false); - expect(isCIDR('example.com')).toBe(false); - expect(isCIDR('')).toBe(false); - }); -}); - -describe('randomIPsFromCIDR', () => { - it('generates random usable IPs for IPv4 CIDR', () => { - const ips = randomIPsFromCIDR('192.168.1.0/24', 5); - expect(ips).toHaveLength(5); - const unique = new Set(ips); - expect(unique.size).toBe(5); - for (const ip of ips) { - expect(ip.startsWith('192.168.1.')).toBe(true); - expect(ip).not.toBe('192.168.1.0'); - expect(ip).not.toBe('192.168.1.255'); - } - }); - - it('generates random usable IPs for bracketed IPv6 CIDR', () => { - const ips = randomIPsFromCIDR('[2001:db8::]/64', 3); - expect(ips).toHaveLength(3); - const unique = new Set(ips); - expect(unique.size).toBe(3); - for (const ip of ips) { - expect(ip.startsWith('2001:db8:')).toBe(true); - expect(ip).not.toBe('2001:db8::'); - } - }); - - it('caps count at MAX_RANDOM_CIDR_COUNT', () => { - const ips = randomIPsFromCIDR('2001:db8::/64', 500); - expect(ips).toHaveLength(MAX_RANDOM_CIDR_COUNT); - }); - - it('respects small subnet bounds', () => { - const ips = randomIPsFromCIDR('192.0.2.0/30', 10); - expect(ips).toHaveLength(2); - expect(ips.sort()).toEqual(['192.0.2.1', '192.0.2.2']); - }); -}); - -describe('IP parsers and formatters', () => { - it('handles IPv4 roundtrip', () => { - const u32 = parseIPv4('10.20.30.40'); - expect(u32).not.toBeNull(); - expect(formatIPv4(u32!)).toBe('10.20.30.40'); - }); - - it('handles IPv6 roundtrip and compression', () => { - const v6 = parseIPv6('2001:db8::1'); - expect(v6).not.toBeNull(); - expect(formatIPv6(v6!)).toBe('2001:db8::1'); - - const v6Zero = parseIPv6('::'); - expect(v6Zero).toBe(0n); - expect(formatIPv6(v6Zero!)).toBe('::'); - }); -}); diff --git a/frontend/src/test/tunnel-host-endpoints.test.ts b/frontend/src/test/tunnel-host-endpoints.test.ts index 48ea02a7e..2cb5374e4 100644 --- a/frontend/src/test/tunnel-host-endpoints.test.ts +++ b/frontend/src/test/tunnel-host-endpoints.test.ts @@ -183,12 +183,11 @@ describe('clients page tunnel configs follow Hosts', () => { ]; 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]); + 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]$/); + expect(ep?.dest).toMatch(/^198\.51\.100\.[12]$/); } }); }); - From b45dd0666905cd6895ac1f60223704f0c85ff699 Mon Sep 17 00:00:00 2001 From: Pejman-Yousefi-Official Date: Mon, 12 Oct 2026 18:42:40 +0330 Subject: [PATCH 7/7] fix(sub): simplify cleanCIDR with unconditional TrimPrefix --- internal/sub/host_cidr.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/sub/host_cidr.go b/internal/sub/host_cidr.go index 7c5acf120..cbaa099e1 100644 --- a/internal/sub/host_cidr.go +++ b/internal/sub/host_cidr.go @@ -13,9 +13,7 @@ const MaxRandomCIDRCount = 100 func cleanCIDR(s string) string { s = strings.TrimSpace(s) s = strings.Trim(s, "[]") - if strings.HasPrefix(s, "[") { - s = strings.TrimPrefix(s, "[") - } + s = strings.TrimPrefix(s, "[") s = strings.Replace(s, "]/", "/", 1) return s }