feat(sub): generate random IPs from CIDR host addresses (#6784)

Operators who front a server with an IP range (a CDN or anycast prefix) had to
list each address as its own host. A host address may now be a CIDR, and every
subscription fetch expands it into up to randomCount (1–100, default 1) unique
random host IPs from the range, IPv4 and IPv6 alike, skipping the network and
broadcast addresses. The panel's own previews, QR codes and tunnel config
exports (WireGuard, AmneziaWG, TUIC, MTProto) expand it the same way, and the
externalProxy importer writes the model's default count so the re-sync still
recognises untouched imported hosts.
This commit is contained in:
Pejman Yousefi
2026-10-24 05:13:44 +02:00
committed by GitHub
parent f6a233220a
commit 40cea0c2fe
33 changed files with 886 additions and 8 deletions
+1
View File
@@ -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,
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ func hostColumns() []string {
"override_sni_from_address", "keep_sni_blank", "pinned_peer_cert_sha256",
"verify_peer_cert_by_name", "allow_insecure", "ech_config_list",
"mux_params", "sockopt_params", "final_mask", "vless_route",
"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "node_guids",
"exclude_from_sub_types", "mihomo_ip_version", "mihomo_x25519", "shuffle_host", "random_count", "node_guids",
"created_at", "updated_at",
}
}
+1
View File
@@ -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"`
+156
View File
@@ -0,0 +1,156 @@
package sub
import (
"crypto/rand"
"encoding/binary"
"math/big"
"net"
"strings"
)
const MaxRandomCIDRCount = 100
func cleanCIDR(s string) string {
s = strings.TrimSpace(s)
s = strings.Trim(s, "[]")
s = strings.TrimPrefix(s, "[")
s = strings.Replace(s, "]/", "/", 1)
return s
}
// IsCIDR reports whether s represents a valid CIDR network notation.
func IsCIDR(s string) bool {
s = cleanCIDR(s)
if !strings.Contains(s, "/") {
return false
}
_, _, err := net.ParseCIDR(s)
return err == nil
}
// RandomIPsFromCIDR returns count unique random host IPs from the specified CIDR.
// Supported across IPv4 and IPv6; caps at the usable address space when smaller.
func RandomIPsFromCIDR(cidr string, count int) ([]string, error) {
if count <= 0 {
count = 1
} else if count > MaxRandomCIDRCount {
count = MaxRandomCIDRCount
}
clean := cleanCIDR(cidr)
_, ipNet, err := net.ParseCIDR(clean)
if err != nil {
return nil, err
}
if ip4 := ipNet.IP.To4(); ip4 != nil {
return randomIPv4FromCIDR(ip4, ipNet.Mask, count)
}
return randomIPv6FromCIDR(ipNet.IP.To16(), ipNet.Mask, count)
}
func randomIPv4FromCIDR(ip4 net.IP, mask net.IPMask, count int) ([]string, error) {
ones, bits := mask.Size()
hostBits := bits - ones
if hostBits == 0 {
return []string{ip4.String()}, nil
}
if hostBits == 1 {
base := binary.BigEndian.Uint32(ip4)
out := []string{ip4.String()}
if count > 1 {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, base+1)
out = append(out, net.IP(b).String())
}
return out, nil
}
total := uint64(1) << hostBits
// Avoid network (.0) and broadcast (.255) for /30 and larger subnets.
usableCount := total - 2
if usableCount == 0 {
return []string{ip4.String()}, nil
}
if uint64(count) > usableCount {
count = int(usableCount)
}
baseU32 := binary.BigEndian.Uint32(ip4)
seen := make(map[string]struct{}, count)
result := make([]string, 0, count)
maxOffset := big.NewInt(int64(usableCount))
maxAttempts := count * 100
for len(result) < count && maxAttempts > 0 {
maxAttempts--
offsetBig, err := rand.Int(rand.Reader, maxOffset)
if err != nil {
return nil, err
}
targetU32 := baseU32 + uint32(offsetBig.Int64()+1)
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, targetU32)
ipStr := net.IP(b).String()
if _, exists := seen[ipStr]; !exists {
seen[ipStr] = struct{}{}
result = append(result, ipStr)
}
}
return result, nil
}
func randomIPv6FromCIDR(ip6 net.IP, mask net.IPMask, count int) ([]string, error) {
ones, bits := mask.Size()
hostBits := bits - ones
if hostBits == 0 {
return []string{ip6.String()}, nil
}
if hostBits == 1 {
baseInt := new(big.Int).SetBytes(ip6)
out := []string{ip6.String()}
if count > 1 {
next := new(big.Int).Add(baseInt, big.NewInt(1))
out = append(out, formatIPv6(next))
}
return out, nil
}
maxHost := new(big.Int).Lsh(big.NewInt(1), uint(hostBits))
// Exclude the Subnet-Router Anycast address (all host bits 0, RFC 4291 §2.6.1).
usableCount := new(big.Int).Sub(maxHost, big.NewInt(1))
if usableCount.Cmp(big.NewInt(int64(count))) < 0 {
count = int(usableCount.Int64())
}
baseInt := new(big.Int).SetBytes(ip6)
seen := make(map[string]struct{}, count)
result := make([]string, 0, count)
maxOffset := new(big.Int).Set(usableCount)
maxAttempts := count * 100
for len(result) < count && maxAttempts > 0 {
maxAttempts--
offsetBig, err := rand.Int(rand.Reader, maxOffset)
if err != nil {
return nil, err
}
offset := new(big.Int).Add(offsetBig, big.NewInt(1))
targetInt := new(big.Int).Add(baseInt, offset)
ipStr := formatIPv6(targetInt)
if _, exists := seen[ipStr]; !exists {
seen[ipStr] = struct{}{}
result = append(result, ipStr)
}
}
return result, nil
}
func formatIPv6(val *big.Int) string {
b := val.Bytes()
if len(b) < 16 {
padded := make([]byte, 16)
copy(padded[16-len(b):], b)
b = padded
}
return net.IP(b).String()
}
+175
View File
@@ -0,0 +1,175 @@
package sub
import (
"net"
"strings"
"testing"
)
func TestIsCIDR(t *testing.T) {
tests := []struct {
input string
want bool
}{
{input: "192.168.1.0/24", want: true},
{input: "10.0.0.0/8", want: true},
{input: "[192.168.1.0/24]", want: true},
{input: "2a01:4f8:1c1b:729::/64", want: true},
{input: "[2a01:4f8:1c1b:729::/64]", want: true},
{input: "[2001:db8::]/64", want: true},
{input: "fe80::/10", want: true},
{input: "example.com", want: false},
{input: "192.168.1.1", want: false},
{input: "2001:db8::1", want: false},
{input: "", want: false},
{input: "invalid/33", want: false},
{input: "999.999.999.999/24", want: false},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
if got := IsCIDR(tc.input); got != tc.want {
t.Fatalf("IsCIDR(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
func TestRandomIPsFromCIDR_IPv4(t *testing.T) {
t.Run("single host /32", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("192.0.2.1/32", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 1 || ips[0] != "192.0.2.1" {
t.Fatalf("RandomIPsFromCIDR(/32) = %v, want [192.0.2.1]", ips)
}
})
t.Run("small subnet /30 caps count", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("198.51.100.0/30", 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 2 {
t.Fatalf("len(ips) = %d, want 2", len(ips))
}
for _, ipStr := range ips {
if ipStr == "198.51.100.0" || ipStr == "198.51.100.3" {
t.Fatalf("got network or broadcast address: %s", ipStr)
}
}
})
t.Run("subnet /24 generates unique IPs within range", func(t *testing.T) {
cidr := "203.0.113.0/24"
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatalf("parse cidr: %v", err)
}
ips, err := RandomIPsFromCIDR(cidr, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 10 {
t.Fatalf("len(ips) = %d, want 10", len(ips))
}
seen := make(map[string]struct{}, len(ips))
for _, ipStr := range ips {
if _, dup := seen[ipStr]; dup {
t.Fatalf("duplicate IP emitted: %s", ipStr)
}
seen[ipStr] = struct{}{}
parsed := net.ParseIP(ipStr)
if parsed == nil || !ipNet.Contains(parsed) {
t.Fatalf("IP %s outside expected subnet %s", ipStr, cidr)
}
if strings.HasSuffix(ipStr, ".0") || strings.HasSuffix(ipStr, ".255") {
t.Fatalf("unusable host IP picked: %s", ipStr)
}
}
})
}
func TestRandomIPsFromCIDR_IPv6(t *testing.T) {
t.Run("single host /128", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("2001:db8::1/128", 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 1 || ips[0] != "2001:db8::1" {
t.Fatalf("RandomIPsFromCIDR(/128) = %v, want [2001:db8::1]", ips)
}
})
t.Run("standard /64 datacenter block", func(t *testing.T) {
cidr := "2a01:4f8:1c1b:729::/64"
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatalf("parse cidr: %v", err)
}
ips, err := RandomIPsFromCIDR(cidr, 10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 10 {
t.Fatalf("len(ips) = %d, want 10", len(ips))
}
seen := make(map[string]struct{}, len(ips))
for _, ipStr := range ips {
if _, dup := seen[ipStr]; dup {
t.Fatalf("duplicate IP emitted: %s", ipStr)
}
seen[ipStr] = struct{}{}
parsed := net.ParseIP(ipStr)
if parsed == nil || !ipNet.Contains(parsed) {
t.Fatalf("IP %s outside expected subnet %s", ipStr, cidr)
}
if ipStr == "2a01:4f8:1c1b:729::" {
t.Fatalf("subnet-router anycast address was emitted")
}
}
})
t.Run("bracketed cidr notation", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("[2a01:4f8:1c1b:729::/64]", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 3 {
t.Fatalf("len(ips) = %d, want 3", len(ips))
}
})
t.Run("bracketed host form /64", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("[2001:db8::]/64", 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != 3 {
t.Fatalf("len(ips) = %d, want 3", len(ips))
}
})
t.Run("unbounded count clamped to MaxRandomCIDRCount", func(t *testing.T) {
ips, err := RandomIPsFromCIDR("2001:db8::/64", 1000)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ips) != MaxRandomCIDRCount {
t.Fatalf("len(ips) = %d, want %d", len(ips), MaxRandomCIDRCount)
}
})
}
func TestRandomIPsFromCIDR_Invalid(t *testing.T) {
if _, err := RandomIPsFromCIDR("example.com", 1); err == nil {
t.Fatalf("RandomIPsFromCIDR(domain) expected error, got nil")
}
}
+23
View File
@@ -2,6 +2,7 @@ package sub
import (
"encoding/json"
"fmt"
"maps"
"slices"
@@ -34,6 +35,28 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
if slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
if IsCIDR(h.Address) {
count := h.RandomCount
if count <= 0 {
count = 1
}
ips, err := RandomIPsFromCIDR(h.Address, count)
if err == nil && len(ips) > 0 {
for i, ip := range ips {
hCopy := *h
hCopy.Address = ip
if len(ips) > 1 {
if h.Remark != "" {
hCopy.Remark = fmt.Sprintf("%s - %d", h.Remark, i+1)
} else {
hCopy.Remark = fmt.Sprintf("%d", i+1)
}
}
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, inbound.Port))
}
continue
}
}
eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port))
}
return eps
+111
View File
@@ -3,6 +3,7 @@ package sub
import (
"encoding/json"
"fmt"
"net"
"net/url"
"path/filepath"
"strings"
@@ -664,3 +665,113 @@ func TestSub_HostTLSVerificationJSONAtXrayLevel(t *testing.T) {
}
}
}
func TestSub_HostCIDR_RawLinks(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "cidr-raw", 4462, 1,
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"sni.example.com"}}`)
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 0,
Remark: "IPv6Node",
Address: "2a01:4f8:1c1b:729::/64",
Port: 8443,
Security: "tls",
RandomCount: 3,
})
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
parts := strings.Split(strings.Join(links, "\n"), "\n")
if len(parts) != 3 {
t.Fatalf("len(parts) = %d, want 3", len(parts))
}
_, ipNet, _ := net.ParseCIDR("2a01:4f8:1c1b:729::/64")
seenIPs := make(map[string]struct{}, len(parts))
for i, link := range parts {
wantRemark := fmt.Sprintf("IPv6Node%%20-%%20%d", i+1)
if !strings.Contains(link, wantRemark) {
t.Errorf("link[%d] does not contain expected remark %q: %s", i, wantRemark, link)
}
atIdx := strings.Index(link, "@")
colonIdx := strings.LastIndex(link, ":8443")
if atIdx == -1 || colonIdx == -1 || colonIdx <= atIdx {
t.Fatalf("malformed link host: %s", link)
}
rawAddr := strings.Trim(link[atIdx+1:colonIdx], "[]")
parsed := net.ParseIP(rawAddr)
if parsed == nil || !ipNet.Contains(parsed) {
t.Errorf("link[%d] IP %q not inside 2a01:4f8:1c1b:729::/64", i, rawAddr)
}
if _, dup := seenIPs[rawAddr]; dup {
t.Errorf("duplicate IP %q emitted across links", rawAddr)
}
seenIPs[rawAddr] = struct{}{}
}
}
func TestSub_HostCIDR_SingleCountKeepsBaseRemark(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "cidr-single", 4463, 1,
`{"network":"tcp","security":"none"}`)
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 0,
Remark: "SingleCIDR",
Address: "198.51.100.0/24",
Port: 8080,
Security: "none",
RandomCount: 1,
})
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err != nil {
t.Fatalf("GetSubs: %v", err)
}
parts := strings.Split(strings.Join(links, "\n"), "\n")
if len(parts) != 1 {
t.Fatalf("len(parts) = %d, want 1", len(parts))
}
if strings.Contains(parts[0], "SingleCIDR - 1") {
t.Errorf("count=1 must not append index suffix: %s", parts[0])
}
if !strings.Contains(parts[0], "SingleCIDR") {
t.Errorf("link does not contain base remark: %s", parts[0])
}
}
func TestSub_HostCIDR_ClashAndJson(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "cidr-clash", 4464, 1,
`{"network":"tcp","security":"none"}`)
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 0,
Remark: "V4Node",
Address: "203.0.113.0/24",
Port: 8080,
Security: "none",
RandomCount: 2,
})
clashYaml, _, err := NewSubClashService(false, "", 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)
}
}
+1
View File
@@ -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"`
}
+15
View File
@@ -61,6 +61,7 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
MihomoIpVersion: h.MihomoIpVersion,
MihomoX25519: h.MihomoX25519,
ShuffleHost: h.ShuffleHost,
RandomCount: h.RandomCount,
}
}
@@ -91,6 +92,9 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
if h.SortOrder < g.SortOrder {
g.SortOrder = h.SortOrder
}
if h.RandomCount > g.RandomCount {
g.RandomCount = h.RandomCount
}
}
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
@@ -150,6 +154,7 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
MihomoIpVersion: req.MihomoIpVersion,
MihomoX25519: req.MihomoX25519,
ShuffleHost: req.ShuffleHost,
RandomCount: clampRandomCount(req.RandomCount),
})
}
}
@@ -363,3 +368,13 @@ func parseHostAndPort(hostStr string, defaultPort int) (string, int) {
}
return addr, defaultPort
}
func clampRandomCount(n int) int {
if n <= 0 {
return 1
}
if n > 100 {
return 100
}
return n
}
+1
View File
@@ -2368,6 +2368,7 @@
"mihomoIpVersion": "إصدار IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "خلط المضيف",
"randomCount": "عدد عناوين IP العشوائية",
"tags": "وسوم",
"nodeGuids": "النودز",
"excludeFromSubTypes": "استبعاد من الصيغ",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "نسخه IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "درهم‌سازی میزبان",
"randomCount": "تعداد IP تصادفی",
"tags": "برچسب‌ها",
"nodeGuids": "نودها",
"excludeFromSubTypes": "حذف از فرمت‌ها",
+1
View File
@@ -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",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "IP バージョン",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "ホストをシャッフル",
"randomCount": "ランダム IP 数",
"tags": "タグ",
"nodeGuids": "ノード",
"excludeFromSubTypes": "形式から除外",
+1
View File
@@ -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",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "Версия IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "Перемешивать хост",
"randomCount": "Количество случайных IP",
"tags": "Теги",
"nodeGuids": "Узлы",
"excludeFromSubTypes": "Исключить из форматов",
+1
View File
@@ -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",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "Версія IP",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "Перемішувати host",
"randomCount": "Кількість випадкових IP",
"tags": "Теги",
"nodeGuids": "Вузли",
"excludeFromSubTypes": "Виключити з форматів",
+1
View File
@@ -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",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "IP 版本",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "随机打乱 Host",
"randomCount": "随机 IP 数量",
"tags": "标签",
"nodeGuids": "节点",
"excludeFromSubTypes": "从格式中排除",
+1
View File
@@ -2369,6 +2369,7 @@
"mihomoIpVersion": "IP 版本",
"mihomoX25519": "Mihomo X25519",
"shuffleHost": "隨機排序 Host",
"randomCount": "隨機 IP 數量",
"tags": "標籤",
"nodeGuids": "節點",
"excludeFromSubTypes": "從格式中排除",