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
+2
View File
@@ -428,6 +428,8 @@ type HostGroup struct {
Port int `json:"port" validate:"gte=0,lte=65535"`
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
Sni string `json:"sni"`
Snis []string `json:"snis"`
SniSharedRandom bool `json:"sniSharedRandom"`
HostHeader string `json:"hostHeader"`
Path string `json:"path"`
Alpn []string `json:"alpn"`
+47 -1
View File
@@ -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
}
+76
View File
@@ -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")
}
}
}
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "الإجراءات",
"security": "الأمان",
"sni": "SNI",
"sniSharedRandom": "نفس الـ SNI العشوائي لجميع التكوينات",
"overrideSniFromAddress": "استخدم العنوان كـ SNI",
"keepSniBlank": "اترك SNI فارغاً",
"hostHeader": "ترويسة Host",
+1
View File
@@ -1122,6 +1122,7 @@
"actions": "Actions",
"security": "Security",
"sni": "SNI",
"sniSharedRandom": "Same random SNI for all configs",
"overrideSniFromAddress": "Use address as SNI",
"keepSniBlank": "Keep SNI blank",
"hostHeader": "Host header",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Acciones",
"security": "Seguridad",
"sni": "SNI",
"sniSharedRandom": "Mismo SNI aleatorio para todas las configuraciones",
"overrideSniFromAddress": "Usar la dirección como SNI",
"keepSniBlank": "Dejar el SNI en blanco",
"hostHeader": "Cabecera Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "عملیات",
"security": "امنیت",
"sni": "SNI",
"sniSharedRandom": "یک SNI تصادفی یکسان برای تمام کانفیگ‌ها",
"overrideSniFromAddress": "استفاده از آدرس به‌عنوان SNI",
"keepSniBlank": "SNI خالی بماند",
"hostHeader": "هدر Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Aksi",
"security": "Keamanan",
"sni": "SNI",
"sniSharedRandom": "SNI acak yang sama untuk semua konfigurasi",
"overrideSniFromAddress": "Gunakan alamat sebagai SNI",
"keepSniBlank": "Biarkan SNI kosong",
"hostHeader": "Header Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "操作",
"security": "セキュリティ",
"sni": "SNI",
"sniSharedRandom": "すべての設定で同じランダムな SNI",
"overrideSniFromAddress": "アドレスを SNI として使用",
"keepSniBlank": "SNI を空のままにする",
"hostHeader": "Host ヘッダー",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Ações",
"security": "Segurança",
"sni": "SNI",
"sniSharedRandom": "Mesmo SNI aleatório para todas as configurações",
"overrideSniFromAddress": "Usar endereço como SNI",
"keepSniBlank": "Manter SNI em branco",
"hostHeader": "Cabeçalho Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Действия",
"security": "Безопасность",
"sni": "SNI",
"sniSharedRandom": "Один случайный SNI для всех конфигураций",
"overrideSniFromAddress": "Использовать адрес как SNI",
"keepSniBlank": "Оставить SNI пустым",
"hostHeader": "Заголовок Host",
+1
View File
@@ -1122,6 +1122,7 @@
"actions": "Akcie",
"security": "Bezpečnosť",
"sni": "SNI",
"sniSharedRandom": "Rovnaké náhodné SNI pre všetky konfigurácie",
"overrideSniFromAddress": "Použiť adresu ako SNI",
"keepSniBlank": "Ponechať SNI prázdne",
"hostHeader": "Hlavička Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "İşlemler",
"security": "Güvenlik",
"sni": "Toplumsal ve Ulusal Kimlik",
"sniSharedRandom": "Tüm yapılandırmalar için aynı rastgele SNI",
"overrideSniFromAddress": "Adresi SNI olarak kullan",
"keepSniBlank": "SNI'yi boş bırak",
"hostHeader": "Host başlığı",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Дії",
"security": "Безпека",
"sni": "SNI",
"sniSharedRandom": "Один випадковий SNI для всіх конфігурацій",
"overrideSniFromAddress": "Використовувати адресу як SNI",
"keepSniBlank": "Залишити SNI порожнім",
"hostHeader": "Заголовок Host",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "Hành động",
"security": "Bảo mật",
"sni": "SNI",
"sniSharedRandom": "Cùng một SNI ngẫu nhiên cho tất cả cấu hình",
"overrideSniFromAddress": "Dùng địa chỉ làm SNI",
"keepSniBlank": "Để trống SNI",
"hostHeader": "Host header",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "操作",
"security": "安全",
"sni": "SNI",
"sniSharedRandom": "所有配置使用相同的随机 SNI",
"overrideSniFromAddress": "使用地址作为 SNI",
"keepSniBlank": "保持 SNI 为空",
"hostHeader": "Host 头",
+1
View File
@@ -2394,6 +2394,7 @@
"actions": "操作",
"security": "安全",
"sni": "SNI",
"sniSharedRandom": "所有設定使用相同的隨機 SNI",
"overrideSniFromAddress": "使用地址作為 SNI",
"keepSniBlank": "保持 SNI 留空",
"hostHeader": "Host 標頭",