feat(hosts): give a host a pool of SNIs, random per config or shared (#6923)

Rotating SNIs meant one host entry per domain. A host now takes a pool of
SNIs: with more than one, every generated config gets a random entry,
or, with the shared switch on, the whole host group uses one pick per
subscription fetch. Raw, JSON and Clash output, the panel's link preview
and node adoption all carry the pool.

sni stays the pool's first entry, so a rolled-back binary and API callers
that only know the single field keep working; an sni outside the pool
becomes its primary. A host with one SNI renders exactly as before.
This commit is contained in:
Pejman Yousefi
2026-10-24 22:35:42 +03:30
committed by GitHub
parent 14c9bdff89
commit 8da3b3c344
33 changed files with 675 additions and 13 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound)
if v.host == nil {
return nil
}
return hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port)
return s.hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port)
}
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
+52 -4
View File
@@ -1,9 +1,11 @@
package sub
import (
"crypto/rand"
"encoding/json"
"fmt"
"maps"
"math/big"
"slices"
"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -86,7 +88,7 @@ func (s *SubService) clientHostEndpoints(inbound *model.Inbound, client model.Cl
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
eps = append(eps, s.hostEndpointMaps(h, defaultDest, inbound.Port)...)
}
return eps, true
}
@@ -136,14 +138,15 @@ func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
eps = append(eps, s.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 {
func (s *SubService) hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[string]any {
shared := s.sharedHostSni(h)
if IsCIDR(h.Address) {
count := max(h.RandomCount, 1)
if ips, err := RandomIPsFromCIDR(h.Address, count); err == nil && len(ips) > 0 {
@@ -158,12 +161,57 @@ func hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[
hCopy.Remark = fmt.Sprintf("%d", i+1)
}
}
hCopy.Sni = endpointSni(h, shared)
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, defaultPort))
}
return eps
}
}
return []map[string]any{hostToExternalProxyMap(h, defaultDest, defaultPort)}
hCopy := *h
hCopy.Sni = endpointSni(h, shared)
return []map[string]any{hostToExternalProxyMap(&hCopy, defaultDest, defaultPort)}
}
// endpointSni is the SNI one endpoint of h advertises: the request's shared pick, a fresh random
// entry of a multi-SNI pool, or the single SNI; the address and blank overrides apply later.
func endpointSni(h *model.Host, shared string) string {
switch {
case shared != "":
return shared
case len(h.Snis) > 1:
return randomSni(h.Snis)
}
return h.Sni
}
// sharedHostSni picks one SNI of a shared pool per host group and request, so every endpoint of
// the group carries the same one; "" when h does not share.
func (s *SubService) sharedHostSni(h *model.Host) string {
if !h.SniSharedRandom || len(h.Snis) < 2 {
return ""
}
key := h.GroupId
if key == "" {
key = fmt.Sprintf("host-%d", h.Id)
}
if sni, ok := s.sharedGroupSni[key]; ok {
return sni
}
if s.sharedGroupSni == nil {
s.sharedGroupSni = make(map[string]string)
}
sni := randomSni(h.Snis)
s.sharedGroupSni[key] = sni
return sni
}
func randomSni(snis []string) string {
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(snis))))
if err != nil {
return snis[0]
}
return snis[idx.Int64()]
}
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
+170
View File
@@ -1707,3 +1707,173 @@ func TestSub_ClientHostRule_SurvivesGroupEdit(t *testing.T) {
t.Fatalf("clash must not contain the unbound host or old SNI:\n%s", yaml)
}
}
func TestSub_HostMultiSNI_RandomizePerConfig(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "sni-rand", 4465, 1,
`{"network":"tcp","security":"tls"}`)
snis := []string{"sni1.example.com", "sni2.example.com"}
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 0,
Remark: "MultiSNI-CIDR",
Address: "198.51.100.0/24",
Port: 8443,
Security: "tls",
RandomCount: 30,
Snis: snis,
})
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) != 30 {
t.Fatalf("len(parts) = %d, want 30", len(parts))
}
seenSNIs := make(map[string]int)
for _, l := range parts {
if strings.Contains(l, "sni=sni1.example.com") {
seenSNIs["sni1.example.com"]++
} else if strings.Contains(l, "sni=sni2.example.com") {
seenSNIs["sni2.example.com"]++
} else {
t.Fatalf("link carries unexpected SNI: %s", l)
}
}
if len(seenSNIs) < 2 {
t.Fatalf("expected both SNIs to be picked across 30 randomized configs, got: %v", seenSNIs)
}
}
func TestSub_HostMultiSNI_SharedRandom(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "sni-shared", 4466, 1,
`{"network":"tcp","security":"tls"}`)
snis := []string{"sniA.example.com", "sniB.example.com"}
groupId := "grp_multi_sni_shared"
seedHost(t, &model.Host{
GroupId: groupId,
InboundId: ib.Id,
SortOrder: 1,
Remark: "Host1",
Address: "h1.example.com",
Port: 8443,
Security: "tls",
Snis: snis,
SniSharedRandom: true,
})
seedHost(t, &model.Host{
GroupId: groupId,
InboundId: ib.Id,
SortOrder: 2,
Remark: "Host2",
Address: "h2.example.com",
Port: 8443,
Security: "tls",
Snis: snis,
SniSharedRandom: true,
})
seedHost(t, &model.Host{
GroupId: groupId,
InboundId: ib.Id,
SortOrder: 3,
Remark: "Host3",
Address: "h3.example.com",
Port: 8443,
Security: "tls",
Snis: snis,
SniSharedRandom: true,
})
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))
}
var firstSNI string
for i, l := range parts {
var sni string
if strings.Contains(l, "sni=sniA.example.com") {
sni = "sniA.example.com"
} else if strings.Contains(l, "sni=sniB.example.com") {
sni = "sniB.example.com"
} else {
t.Fatalf("link[%d] missing expected SNI: %s", i, l)
}
if firstSNI == "" {
firstSNI = sni
} else if sni != firstSNI {
t.Fatalf("shared random mode mismatch: link[%d] had %s, expected %s across all configs in same group", i, sni, firstSNI)
}
}
// Verify that across independent subscription fetches, both pool SNIs are selected
seenAcrossFetches := make(map[string]bool)
seenAcrossFetches[firstSNI] = true
for i := 0; i < 25; i++ {
l, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
if err == nil && len(l) > 0 {
if strings.Contains(l[0], "sni=sniA.example.com") {
seenAcrossFetches["sniA.example.com"] = true
} else if strings.Contains(l[0], "sni=sniB.example.com") {
seenAcrossFetches["sniB.example.com"] = true
}
}
}
if len(seenAcrossFetches) < 2 {
t.Fatalf("expected both SNIs to be selected across independent fetches, got: %v", seenAcrossFetches)
}
}
func TestSub_HostMultiSNI_Overrides(t *testing.T) {
seedSubDB(t)
ib := seedSubInbound(t, "s1", "sni-overrides", 4467, 1,
`{"network":"tcp","security":"tls"}`)
snis := []string{"sni1.example.com", "sni2.example.com"}
// When OverrideSniFromAddress is true, address is used as SNI.
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 1,
Remark: "AddrAsSni",
Address: "addr.example.com",
Port: 8443,
Security: "tls",
Snis: snis,
OverrideSniFromAddress: true,
})
// When KeepSniBlank is true, SNI is omitted.
seedHost(t, &model.Host{
InboundId: ib.Id,
SortOrder: 2,
Remark: "BlankSni",
Address: "blank.example.com",
Port: 8443,
Security: "tls",
Snis: snis,
KeepSniBlank: true,
})
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) != 2 {
t.Fatalf("len(parts) = %d, want 2", len(parts))
}
if !strings.Contains(parts[0], "sni=addr.example.com") {
t.Errorf("expected address as sni: %s", parts[0])
}
if strings.Contains(parts[1], "sni=") {
t.Errorf("expected blank sni: %s", parts[1])
}
}
+2
View File
@@ -80,6 +80,7 @@ type SubService struct {
hostsByInbound map[int][]*model.Host
hostGroupOrders map[string]hostEntryOrder
hostsFirst bool
sharedGroupSni map[string]string
}
// NewSubService creates a new subscription service with the given configuration.
@@ -117,6 +118,7 @@ func (s *SubService) PrepareForRequest(host string) {
s.settingsByInbound = map[int]map[string]any{}
s.hostsByInbound = nil
s.hostGroupOrders = nil
s.sharedGroupSni = nil
mode, _ := s.settingService.GetSubHostSortMode()
s.hostsFirst = mode == "hosts-first"
s.loadNodes()