feat(clients): bind host rules to individual clients in subscriptions (#6620)

Host rules applied per inbound only, so every client of an inbound got the same
subscription endpoints (#6618). A client can now be bound to one or more host
rules, and its raw, JSON and Clash subscriptions render only those hosts, in
either subscription order; an unbound client is unchanged. A binding whose hosts
are deleted, disabled or excluded from the format fails closed: the client gets
no links there but stays in traffic accounting. Host edits keep row ids, so
bindings survive them; nil leaves a stored binding alone and an empty list
clears it.
This commit is contained in:
Nethmina Yasas
2026-10-24 16:03:02 +05:30
committed by GitHub
parent d103c0c012
commit bc1b750e00
47 changed files with 1940 additions and 168 deletions
+80
View File
@@ -11,6 +11,86 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// hostBinding is how a client's host binding applies to one inbound.
type hostBinding int
const (
hostBindingNone hostBinding = iota // nothing bound here: the inbound's own hosts apply
hostBindingClosed // bound, but no bound host survives: render nothing
hostBindingBound // render only the returned bound hosts of this inbound
)
// clientHostBinding resolves the client's bound host rules against one inbound. A binding whose
// every id is gone fails closed; a missing id beside a live one never silences other inbounds.
func (s *SubService) clientHostBinding(inbound *model.Inbound, client model.Client) (hostBinding, []*model.Host) {
ids := model.EffectiveClientHostRuleIds(client)
if len(ids) == 0 || database.GetDB() == nil {
return hostBindingNone, nil
}
var rows []model.Host
if err := database.GetDB().Where("id IN ?", ids).Find(&rows).Error; err != nil {
logger.Warning("SubService - clientHostBinding:", err)
return hostBindingClosed, nil
}
if len(rows) == 0 {
return hostBindingClosed, nil
}
byId := make(map[int]*model.Host, len(rows))
for i := range rows {
byId[rows[i].Id] = &rows[i]
}
var here []*model.Host
for _, id := range ids {
if h, ok := byId[id]; ok && h.InboundId == inbound.Id {
here = append(here, h)
}
}
if len(here) == 0 {
return hostBindingNone, nil
}
return hostBindingBound, here
}
// clientHostEndpoints returns the endpoints of the client's usable bound hosts on this inbound, or
// baseEps when nothing is bound here; (nil, true) fails closed, and the caller still accounts the client.
func (s *SubService) clientHostEndpoints(inbound *model.Inbound, client model.Client, format string, baseEps []map[string]any) ([]map[string]any, bool) {
state, hosts := s.clientHostBinding(inbound, client)
switch state {
case hostBindingNone:
return baseEps, false
case hostBindingClosed:
return nil, true
}
defaultDest := s.resolveInboundAddress(inbound)
var eps []map[string]any
for _, h := range hosts {
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
}
return eps, true
}
// clientHostVariants narrows the hosts-first variants of an inbound to the client's bound hosts;
// closed reports a binding that leaves this client nothing to render on the inbound.
func (s *SubService) clientHostVariants(inbound *model.Inbound, client model.Client, format string) (variants []subscriptionHostVariant, closed bool) {
all := s.subscriptionHostVariants(inbound, format)
state, hosts := s.clientHostBinding(inbound, client)
switch state {
case hostBindingNone:
return all, false
case hostBindingClosed:
return nil, true
}
for _, v := range all {
if v.host != nil && slices.ContainsFunc(hosts, func(h *model.Host) bool { return h.Id == v.host.Id }) {
variants = append(variants, v)
}
}
return variants, len(variants) == 0
}
// hostEndpoints loads an inbound's enabled hosts for the given subscription
// format ("raw"|"json"|"clash") and returns them as externalProxy-shaped maps so
// the existing per-format renderers can fan out one link/proxy per host. Returns