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
+25 -6
View File
@@ -132,6 +132,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
var header string
var hasInactiveExternal bool
var hasBoundClosed bool
var hasEnabledClient bool
seenEmails := make(map[string]struct{})
@@ -150,9 +151,14 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
continue
}
subReq.projectThroughFallbackMaster(inbound)
var baseEps []map[string]any
baseStream := inbound.StreamSettings
// getConfig rewrites inbound.Listen/Port per endpoint while rendering a client, so bound
// endpoints resolve against this pre-loop snapshot.
baseInbound := *inbound
if !subReq.hostsFirst {
if hostEps := subReq.hostEndpoints(inbound, "json"); len(hostEps) > 0 {
injectExternalProxy(inbound, hostEps)
if baseEps = subReq.hostEndpoints(inbound, "json"); len(baseEps) > 0 {
injectExternalProxy(inbound, baseEps)
}
}
@@ -163,16 +169,29 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
}
seenEmails[client.Email] = struct{}{}
if subReq.hostsFirst {
for _, variant := range subReq.subscriptionHostVariants(inbound, "json") {
variants, closed := subReq.clientHostVariants(&baseInbound, client, "json")
hasBoundClosed = hasBoundClosed || closed
for _, variant := range variants {
render := func() subConfigEntry {
configs := s.getConfig(subReq, variant.inbound(subReq, inbound), client, host)
return subConfigEntry{sortIndex: inbound.SubSortIndex, id: inbound.Id, configs: configs, hostOrder: variant.order}
}
ordered = append(ordered, orderedSubEntry[func() subConfigEntry]{variant.order, render})
}
} else {
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, inbound, client, host)...)
continue
}
effective := inbound
if effectiveEps, bound := subReq.clientHostEndpoints(&baseInbound, client, "json", baseEps); bound {
if len(effectiveEps) == 0 {
hasBoundClosed = true
continue
}
clone := baseInbound
clone.StreamSettings = baseStream
injectExternalProxy(&clone, effectiveEps)
effective = &clone
}
inboundConfigs = append(inboundConfigs, s.getConfig(subReq, effective, client, host)...)
}
if len(inboundConfigs) > 0 {
entries = append(entries, subConfigEntry{
@@ -254,7 +273,7 @@ func (s *SubJsonService) GetJson(subId string, host string, alwaysReturnArray bo
}
}
if len(configArray) == 0 && !hasInactiveExternal {
if len(configArray) == 0 && !hasInactiveExternal && !hasBoundClosed {
return "", "", nil
}