Files
3x-ui/internal/sub/host_order.go
T
Pejman Yousefi 8da3b3c344 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.
2026-10-24 21:05:42 +02:00

117 lines
3.3 KiB
Go

package sub
import (
"cmp"
"fmt"
"slices"
"sort"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
type hostEntryOrder struct {
sortOrder, inboundSort, inboundID, hostID int
remark, groupID string
}
type orderedSubEntry[T any] struct {
order *hostEntryOrder
value T
}
// Group ties match the Hosts page, then inbound order and host row ID.
// Stable sorting preserves emission order inside each host.
func hostOrderLess(a, b *hostEntryOrder) bool {
if a == nil || b == nil {
return a != nil && b == nil
}
for _, c := range []int{
cmp.Compare(a.sortOrder, b.sortOrder), cmp.Compare(a.remark, b.remark), cmp.Compare(a.groupID, b.groupID),
cmp.Compare(a.inboundSort, b.inboundSort), cmp.Compare(a.inboundID, b.inboundID), cmp.Compare(a.hostID, b.hostID),
} {
if c != 0 {
return c < 0
}
}
return false
}
func orderedSubValues[T any](entries []orderedSubEntry[T]) []T {
sort.SliceStable(entries, func(i, j int) bool { return hostOrderLess(entries[i].order, entries[j].order) })
var out []T
for _, entry := range entries {
out = append(out, entry.value)
}
return out
}
func (s *SubService) loadSubscriptionHosts(inbounds []*model.Inbound) error {
s.hostsByInbound = make(map[int][]*model.Host, len(inbounds))
s.hostGroupOrders = make(map[string]hostEntryOrder)
ids := make([]int, len(inbounds))
for i, inbound := range inbounds {
ids[i] = inbound.Id
}
for lo := 0; lo < len(ids); lo += 400 {
var rows []*model.Host
if err := database.GetDB().Where("inbound_id IN ?", ids[lo:min(lo+400, len(ids))]).Order("inbound_id ASC, sort_order ASC, id ASC").Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
s.hostsByInbound[row.InboundId] = append(s.hostsByInbound[row.InboundId], row)
key := hostOrderGroupID(row)
if order, exists := s.hostGroupOrders[key]; exists {
order.sortOrder = min(order.sortOrder, row.SortOrder)
s.hostGroupOrders[key] = order
} else {
s.hostGroupOrders[key] = hostEntryOrder{sortOrder: row.SortOrder, remark: row.Remark, groupID: key}
}
}
}
return nil
}
type subscriptionHostVariant struct {
host *model.Host
order *hostEntryOrder
}
func hostOrderGroupID(h *model.Host) string {
if h.GroupId != "" {
return h.GroupId
}
return fmt.Sprintf("fallback_%d", h.Id)
}
func (s *SubService) subscriptionHostVariants(inbound *model.Inbound, format string) []subscriptionHostVariant {
var variants []subscriptionHostVariant
for _, h := range s.hostsByInbound[inbound.Id] {
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
order := s.hostGroupOrders[hostOrderGroupID(h)]
order.inboundSort, order.inboundID, order.hostID = inbound.SubSortIndex, inbound.Id, h.Id
variants = append(variants, subscriptionHostVariant{h, &order})
}
if len(variants) == 0 {
return []subscriptionHostVariant{{}}
}
return variants
}
func (v subscriptionHostVariant) endpoint(s *SubService, inbound *model.Inbound) []map[string]any {
if v.host == nil {
return nil
}
return s.hostEndpointMaps(v.host, s.resolveInboundAddress(inbound), inbound.Port)
}
func (v subscriptionHostVariant) inbound(s *SubService, inbound *model.Inbound) *model.Inbound {
clone := *inbound
if ep := v.endpoint(s, inbound); len(ep) > 0 {
injectExternalProxy(&clone, ep)
}
return &clone
}