diff --git a/internal/sub/host_order.go b/internal/sub/host_order.go index e8c2033f2..1eb254ebe 100644 --- a/internal/sub/host_order.go +++ b/internal/sub/host_order.go @@ -104,7 +104,7 @@ func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound) if v.host == nil { return nil } - return []map[string]any{hostToExternalProxyMap(v.host, s.resolveInboundAddress(inbound), inbound.Port)} + return hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port) } func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound { diff --git a/internal/sub/host_order_test.go b/internal/sub/host_order_test.go index 05ea4ccff..db889c341 100644 --- a/internal/sub/host_order_test.go +++ b/internal/sub/host_order_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "net/url" @@ -255,3 +256,24 @@ func TestSub_GlobalHostOrder(t *testing.T) { setHostSortMode(t, "inbound-first") assertAll([]string{"203.0.113.5", "shared1.example.com", "shared2.example.com", "a.example.com", "c.example.com", "b.example.com", "shared1.example.com"}) } + +// The hosts-first order renders each host on its own, and a CIDR host must still expand into its +// random addresses there, as it does in inbound order, on every format. +func TestSub_HostOrderExpandsCIDRHosts(t *testing.T) { + seedSubDB(t) + setHostSortMode(t, "hosts-first") + ib := seedSubInbound(t, "order-sub", "cidr", 46910, 0, wsTLSStream) + seedHost(t, &model.Host{GroupId: "cidr", InboundId: ib.Id, Remark: "R", Address: "198.51.100.0/24", RandomCount: 3}) + _, cidr, _ := net.ParseCIDR("198.51.100.0/24") + for _, format := range []string{"raw", "clash", "json"} { + got := renderedHostAddresses(t, format, NewSubService("")) + if len(got) != 3 { + t.Fatalf("%s addresses = %v, want 3 random addresses from the range", format, got) + } + for _, addr := range got { + if ip := net.ParseIP(addr); ip == nil || !cidr.Contains(ip) { + t.Fatalf("%s address %q is not inside 198.51.100.0/24", format, addr) + } + } + } +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 2721750c3..a3a5655e5 100644 --- a/internal/sub/host_sub.go +++ b/internal/sub/host_sub.go @@ -37,33 +37,36 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[ if h.IsDisabled || 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)) + eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...) } return eps } +// hostEndpointMaps is one host's externalProxy entries: a CIDR address expands into RandomCount +// random addresses from the range, any other address is a single entry. +func hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[string]any { + if IsCIDR(h.Address) { + count := max(h.RandomCount, 1) + if ips, err := RandomIPsFromCIDR(h.Address, count); err == nil && len(ips) > 0 { + eps := make([]map[string]any, 0, len(ips)) + 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, defaultPort)) + } + return eps + } + } + return []map[string]any{hostToExternalProxyMap(h, defaultDest, defaultPort)} +} + // hostToExternalProxyMap projects a Host onto the externalProxy entry shape the // raw/json/clash renderers already consume. Address/port fall back to the // inbound's own when the host leaves them blank (override-only host).