mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
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:
@@ -42,6 +42,8 @@ func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
|
||||
Port: h.Port,
|
||||
Security: h.Security,
|
||||
Sni: h.Sni,
|
||||
Snis: h.Snis,
|
||||
SniSharedRandom: h.SniSharedRandom,
|
||||
HostHeader: h.HostHeader,
|
||||
Path: h.Path,
|
||||
Alpn: h.Alpn,
|
||||
@@ -96,6 +98,12 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
||||
if h.RandomCount > g.RandomCount {
|
||||
g.RandomCount = h.RandomCount
|
||||
}
|
||||
if len(h.Snis) > 0 && len(g.Snis) == 0 {
|
||||
g.Snis = h.Snis
|
||||
}
|
||||
if h.SniSharedRandom {
|
||||
g.SniSharedRandom = true
|
||||
}
|
||||
}
|
||||
|
||||
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
|
||||
@@ -117,6 +125,13 @@ func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
||||
}
|
||||
|
||||
func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
||||
snis := hostSniPool(req.Sni, req.Snis)
|
||||
primarySni := ""
|
||||
if len(snis) > 0 {
|
||||
primarySni = snis[0]
|
||||
}
|
||||
shared := req.SniSharedRandom && len(snis) > 1
|
||||
|
||||
hostsToProcess := req.Hosts
|
||||
if len(hostsToProcess) == 0 {
|
||||
hostsToProcess = []string{""}
|
||||
@@ -137,7 +152,9 @@ func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
||||
Address: addr,
|
||||
Port: port,
|
||||
Security: req.Security,
|
||||
Sni: req.Sni,
|
||||
Sni: primarySni,
|
||||
Snis: snis,
|
||||
SniSharedRandom: shared,
|
||||
HostHeader: req.HostHeader,
|
||||
Path: req.Path,
|
||||
Alpn: req.Alpn,
|
||||
@@ -483,3 +500,32 @@ func clampRandomCount(n int) int {
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// hostSniPool is the group's SNI pool, primary first: an sni outside the pool (a caller that only
|
||||
// knows the single field) becomes the primary, replacing a one-entry pool.
|
||||
func hostSniPool(sni string, pool []string) []string {
|
||||
snis := cleanSnis(pool)
|
||||
sni = strings.TrimSpace(sni)
|
||||
switch {
|
||||
case sni == "" || (len(snis) > 0 && snis[0] == sni):
|
||||
return snis
|
||||
case len(snis) <= 1:
|
||||
return []string{sni}
|
||||
}
|
||||
return append([]string{sni}, slices.DeleteFunc(snis, func(s string) bool { return s == sni })...)
|
||||
}
|
||||
|
||||
func cleanSnis(snis []string) []string {
|
||||
var out []string
|
||||
seen := make(map[string]struct{})
|
||||
for _, s := range snis {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
if trimmed != "" {
|
||||
if _, exists := seen[trimmed]; !exists {
|
||||
seen[trimmed] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -525,3 +525,79 @@ func TestHostGroup_CipherSuitesRoundTrip(t *testing.T) {
|
||||
t.Fatalf("CipherSuites = %q, want %q", g.CipherSuites, suites)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostGroup_MultiSNIRoundTrip(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
snis := []string{"sni1.example.com", "sni2.example.com"}
|
||||
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||
InboundIds: []int{ib.Id},
|
||||
Remark: "multi-sni",
|
||||
Hosts: []string{"h1.example.com", "h2.example.com"},
|
||||
Security: "tls",
|
||||
Snis: snis,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddHostGroup: %v", err)
|
||||
}
|
||||
if len(created) != 2 {
|
||||
t.Fatalf("created %d rows, want 2", len(created))
|
||||
}
|
||||
for i, row := range created {
|
||||
if !reflect.DeepEqual(row.Snis, snis) {
|
||||
t.Fatalf("row[%d].Snis = %v, want %v", i, row.Snis, snis)
|
||||
}
|
||||
if row.Sni != snis[0] {
|
||||
t.Fatalf("row[%d].Sni = %q, want primary %q", i, row.Sni, snis[0])
|
||||
}
|
||||
if row.SniSharedRandom {
|
||||
t.Fatalf("row[%d] shares its SNI by default", i)
|
||||
}
|
||||
}
|
||||
|
||||
g, err := svc.GetHostGroup(created[0].GroupId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostGroup: %v", err)
|
||||
}
|
||||
if g.Security != "tls" {
|
||||
t.Fatalf("g.Security = %q, want %q", g.Security, "tls")
|
||||
}
|
||||
if !reflect.DeepEqual(g.Snis, snis) {
|
||||
t.Fatalf("g.Snis = %v, want %v", g.Snis, snis)
|
||||
}
|
||||
if g.SniSharedRandom {
|
||||
t.Fatalf("g shares its SNI by default")
|
||||
}
|
||||
|
||||
// Now update to SniSharedRandom and verify Security and mode preservation
|
||||
g.SniSharedRandom = true
|
||||
updated, err := svc.UpdateHostGroup(g.GroupId, g)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateHostGroup: %v", err)
|
||||
}
|
||||
for i, row := range updated {
|
||||
if row.Security != "tls" {
|
||||
t.Fatalf("updated row[%d].Security = %q, want %q", i, row.Security, "tls")
|
||||
}
|
||||
if !row.SniSharedRandom {
|
||||
t.Fatalf("updated row[%d] lost SniSharedRandom", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify REST script updating req.Sni independently is honored and synchronized
|
||||
g.Sni = "rest-updated.example.com"
|
||||
updatedSni, err := svc.UpdateHostGroup(g.GroupId, g)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateHostGroup with updated Sni: %v", err)
|
||||
}
|
||||
for i, row := range updatedSni {
|
||||
if row.Sni != "rest-updated.example.com" {
|
||||
t.Fatalf("row[%d].Sni = %q, want %q", i, row.Sni, "rest-updated.example.com")
|
||||
}
|
||||
if len(row.Snis) == 0 || row.Snis[0] != "rest-updated.example.com" {
|
||||
t.Fatalf("row[%d].Snis[0] = %v, want %q", i, row.Snis, "rest-updated.example.com")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user