Files
3x-ui/internal/sub/host_sub.go
T
Nethmina Yasas bc1b750e00 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.
2026-10-24 12:33:02 +02:00

505 lines
16 KiB
Go

package sub
import (
"encoding/json"
"fmt"
"maps"
"slices"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"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
// nil when the inbound has no applicable host — the caller then uses the legacy
// inbound/externalProxy path, preserving byte-identical output for zero-host
// inbounds.
func (s *SubService) hostEndpoints(inbound *model.Inbound, format string) []map[string]any {
var hosts []*model.Host
if s.hostsByInbound != nil {
hosts = s.hostsByInbound[inbound.Id]
} else if err := database.GetDB().
Where("inbound_id = ? AND is_disabled = ?", inbound.Id, false).
Order("sort_order asc, id asc").
Find(&hosts).Error; err != nil {
logger.Warning("SubService - hostEndpoints:", err)
return nil
}
if len(hosts) == 0 {
return nil
}
defaultDest := s.resolveInboundAddress(inbound)
eps := make([]map[string]any, 0, len(hosts))
for _, h := range hosts {
if h.IsDisabled || slices.Contains(h.ExcludeFromSubTypes, format) {
continue
}
eps = append(eps, hostEndpointMaps(h, defaultDest, inbound.Port)...)
}
return eps
}
// hostEndpointMaps is one host's externalProxy entries: a CIDR address expands into RandomCount
// random addresses from the range, any other address is a single entry.
func hostEndpointMaps(h *model.Host, defaultDest string, defaultPort int) []map[string]any {
if IsCIDR(h.Address) {
count := max(h.RandomCount, 1)
if ips, err := RandomIPsFromCIDR(h.Address, count); err == nil && len(ips) > 0 {
eps := make([]map[string]any, 0, len(ips))
for i, ip := range ips {
hCopy := *h
hCopy.Address = ip
if len(ips) > 1 {
if h.Remark != "" {
hCopy.Remark = fmt.Sprintf("%s - %d", h.Remark, i+1)
} else {
hCopy.Remark = fmt.Sprintf("%d", i+1)
}
}
eps = append(eps, hostToExternalProxyMap(&hCopy, defaultDest, defaultPort))
}
return eps
}
}
return []map[string]any{hostToExternalProxyMap(h, defaultDest, defaultPort)}
}
// hostToExternalProxyMap projects a Host onto the externalProxy entry shape the
// raw/json/clash renderers already consume. Address/port fall back to the
// inbound's own when the host leaves them blank (override-only host).
func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int) map[string]any {
dest := h.Address
if dest == "" {
dest = defaultDest
}
port := h.Port
if port == 0 {
port = defaultPort
}
ep := map[string]any{
"forceTls": hostSecurityToForceTls(h.Security),
"dest": dest,
"port": float64(port),
"remark": h.Remark,
// Marks this as a host (not a legacy externalProxy) entry so host-only
// behaviors (e.g. reality SNI/fp override) apply without touching the
// legacy externalProxy path. Not emitted into output.
"isHost": true,
}
sni := h.Sni
if h.OverrideSniFromAddress {
sni = dest
}
if !h.KeepSniBlank && sni != "" {
ep["sni"] = sni
}
if h.Fingerprint != "" {
ep["fingerprint"] = h.Fingerprint
}
if h.CipherSuites != "" {
ep["cipherSuites"] = h.CipherSuites
}
if len(h.Alpn) > 0 {
ep["alpn"] = stringsToAnySlice(h.Alpn)
}
if len(h.PinnedPeerCertSha256) > 0 {
ep["pinnedPeerCertSha256"] = stringsToAnySlice(h.PinnedPeerCertSha256)
}
if h.EchConfigList != "" {
ep["echConfigList"] = h.EchConfigList
}
if h.VerifyPeerCertByName != "" {
ep["verifyPeerCertByName"] = h.VerifyPeerCertByName
}
if h.AllowInsecure {
ep["allowInsecure"] = true
}
if h.HostHeader != "" {
ep["hostHeader"] = h.HostHeader
}
if h.Path != "" {
ep["path"] = h.Path
}
if h.MihomoIpVersion != "" {
ep["mihomoIpVersion"] = h.MihomoIpVersion
}
if h.MihomoX25519 {
ep["mihomoX25519"] = true
}
if h.SockoptParams != "" {
ep["sockoptParams"] = h.SockoptParams
}
if h.MuxParams != "" {
ep["muxParams"] = h.MuxParams
}
if h.FinalMask != "" {
ep["finalMask"] = h.FinalMask
}
if h.VlessRoute != "" {
ep["vlessRoute"] = h.VlessRoute
}
if h.ServerDescription != "" {
ep["serverDescription"] = h.ServerDescription
}
return ep
}
// hostMuxOverride returns a host's muxParams when it is valid JSON, else "".
// Used to override the JSON outbound's mux for that host.
func hostMuxOverride(ep map[string]any) string {
mp, ok := ep["muxParams"].(string)
if ok && mp != "" && json.Valid([]byte(mp)) {
return mp
}
return ""
}
// applyHostStreamOverrides injects a host's free-JSON stream overrides into the
// per-host stream the JSON/Clash renderers build: sockoptParams (re-added since
// the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
// entries (which never carry these keys), so existing output is unchanged.
func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
if hh, ok := ep["hostHeader"].(string); ok && hh != "" {
for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
if ts, ok := stream[key].(map[string]any); ok && ts != nil {
ts["host"] = hh
}
}
}
if p, ok := ep["path"].(string); ok && p != "" {
for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
if ts, ok := stream[key].(map[string]any); ok && ts != nil {
ts["path"] = p
}
}
}
if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
var sockopt map[string]any
if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
stream["sockopt"] = sockopt
}
}
// Host finalmask: merge the host's masks into the stream's finalmask (the
// JSON renderer consumes streamSettings["finalmask"]; clash ignores it).
if fm, ok := ep["finalMask"].(string); ok && fm != "" {
var masks map[string]any
if json.Unmarshal([]byte(fm), &masks) == nil && len(masks) > 0 {
merged := mergeFinalMask(stream["finalmask"], masks)
if len(merged) > 0 {
stream["finalmask"] = merged
}
}
}
// Reality SNI override (host only): the stream is already in client form, and xray
// refuses a reality client carrying the server-side serverNames list (#6690).
if isHostEndpoint(ep) {
if sec, _ := stream["security"].(string); sec == "reality" {
if rs, ok := stream["realitySettings"].(map[string]any); ok && rs != nil {
if sni, ok := externalProxySNI(ep); ok {
rs["serverName"] = sni
}
if fp, ok := ep["fingerprint"].(string); ok && fp != "" {
rs["fingerprint"] = fp
}
}
}
}
}
// hostSecurityToForceTls maps Host.Security onto the externalProxy forceTls
// vocabulary. "reality"/"same"/"" all keep the inbound's base security ("same")
// — reality parameters can only come from the inbound itself.
func hostSecurityToForceTls(security string) string {
switch security {
case "tls", "none":
return security
default:
return "same"
}
}
func stringsToAnySlice(in []string) []any {
out := make([]any, 0, len(in))
for _, s := range in {
if s != "" {
out = append(out, s)
}
}
return out
}
// injectExternalProxy rewrites the inbound's StreamSettings so its externalProxy
// array is exactly eps. Host endpoints win over any legacy externalProxy.
func injectExternalProxy(inbound *model.Inbound, eps []map[string]any) {
stream := unmarshalStreamSettings(inbound.StreamSettings)
if stream == nil {
stream = map[string]any{}
}
arr := make([]any, len(eps))
for i := range eps {
arr[i] = eps[i]
}
stream["externalProxy"] = arr
if b, err := json.Marshal(stream); err == nil {
inbound.StreamSettings = string(b)
}
}
// linkFromHosts renders a (possibly multi-line) raw link for one client using
// the given host endpoints. It renders ONLY the hosts: an empty eps yields ""
// (no legacy fallback) — the caller decides when to take the legacy path. That
// separation is what makes the zero-hosts fallback mutation-testable.
func (s *SubService) linkFromHosts(inbound *model.Inbound, client model.Client, eps []map[string]any) string {
if len(eps) == 0 {
return ""
}
stream := unmarshalStreamSettings(inbound.StreamSettings)
transport, _ := stream["network"].(string)
// Clone each ep before expanding its remark template: the eps slice is
// shared across all clients of this inbound, so the rendered (per-client)
// remark must not leak into the next client's links.
rendered := make([]map[string]any, len(eps))
for i, ep := range eps {
cp := maps.Clone(ep)
s.renderHostRemark(inbound, client, cp, transport)
rendered[i] = cp
}
clone := *inbound
injectExternalProxy(&clone, rendered)
return s.GetLink(&clone, client.Email)
}
// renderHostRemark expands a host endpoint's {{VAR}} remark template for one
// client in place and marks it final, so the downstream link/proxy/config
// renderers emit it verbatim (via endpointRemark) instead of re-composing it.
// No-op for non-host endpoints (legacy externalProxy / synthetic default), so
// their output stays byte-identical.
func (s *SubService) renderHostRemark(inbound *model.Inbound, client model.Client, ep map[string]any, transport string) {
if !isHostEndpoint(ep) {
return
}
tmpl, _ := ep["remark"].(string)
ep["remark"] = s.genHostRemark(inbound, client, tmpl, transport)
ep["remarkFinal"] = true
}
// endpointRemark returns the remark to stamp on an endpoint's link/proxy/config
// entry. A host endpoint whose template was pre-expanded by renderHostRemark
// carries remarkFinal and is used verbatim; every other entry flows through the
// standard genRemark composition unchanged.
func (s *SubService) endpointRemark(inbound *model.Inbound, email string, ep map[string]any, transport string) string {
if ep != nil {
if final, _ := ep["remarkFinal"].(bool); final {
r, _ := ep["remark"].(string)
return r
}
}
var extra string
if ep != nil {
extra, _ = ep["remark"].(string)
}
return s.genRemark(inbound, email, extra, transport)
}
// applyEndpointHostPath overrides the transport host header / path for a host
// endpoint. It is a no-op for legacy externalProxy entries (which never carry
// hostHeader/path) and only replaces keys the transport already emits, so it
// cannot add spurious params to e.g. a tcp link.
func applyEndpointHostPath(e ShareEndpoint, params map[string]string) {
if e.ep == nil {
return
}
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
if _, exists := params["host"]; exists {
params["host"] = h
}
}
if p, ok := e.ep["path"].(string); ok && p != "" {
if _, exists := params["path"]; exists {
params["path"] = p
}
}
}
// isHostEndpoint reports whether ep was synthesized from a Host (vs a legacy
// externalProxy entry), so host-only overrides stay off the legacy path.
func isHostEndpoint(ep map[string]any) bool {
v, _ := ep["isHost"].(bool)
return v
}
// applyEndpointRealityParams overrides a reality link's SNI + fingerprint from a
// host (reality's pbk/sid are inherited from the inbound, so they aren't touched).
// Host-only: legacy externalProxy reality links are unchanged.
func applyEndpointRealityParams(e ShareEndpoint, params map[string]string, security string) {
if security != "reality" || e.ep == nil || !isHostEndpoint(e.ep) {
return
}
if sni, ok := externalProxySNI(e.ep); ok {
params["sni"] = sni
}
if fp, ok := e.ep["fingerprint"].(string); ok && fp != "" {
params["fp"] = fp
}
if hostX25519Only(e.ep) {
delete(params, "support-x25519mlkem768")
}
}
// hostX25519Only reports whether a host asks Mihomo for a plain X25519 reality
// hello instead of the ML-KEM one Xray-core 26.9.8+ requires.
func hostX25519Only(ep map[string]any) bool {
v, _ := ep["mihomoX25519"].(bool)
return v
}
// applyEndpointAllowInsecure adds allowInsecure=1 to a TLS/Reality link when the
// host opts into skipping cert verification. No-op for legacy externalProxy
// entries (which never carry the key) and for plaintext (none) endpoints.
func applyEndpointAllowInsecure(e ShareEndpoint, params map[string]string, security string) {
if e.ep == nil || security == "none" {
return
}
if ai, ok := e.ep["allowInsecure"].(bool); ok && ai {
params["allowInsecure"] = "1"
}
}
// applyEndpointFinalMask merges a host's Final Mask into the raw link's fm
// param, mirroring the applyHostStreamOverrides merge on the JSON/Clash path.
func applyEndpointFinalMask(e ShareEndpoint, params map[string]string) {
if merged, ok := endpointFinalMask(e, params["fm"]); ok {
params["fm"] = merged
}
}
// applyEndpointFinalMaskObj is applyEndpointFinalMask for the VMess object form.
func applyEndpointFinalMaskObj(e ShareEndpoint, obj map[string]any) {
baseFm, _ := obj["fm"].(string)
if merged, ok := endpointFinalMask(e, baseFm); ok {
obj["fm"] = merged
}
}
func endpointFinalMask(e ShareEndpoint, baseFm string) (string, bool) {
if e.ep == nil {
return "", false
}
fm, ok := e.ep["finalMask"].(string)
if !ok || fm == "" {
return "", false
}
var masks map[string]any
if json.Unmarshal([]byte(fm), &masks) != nil || len(masks) == 0 {
return "", false
}
var base any
if baseFm != "" {
var baseMap map[string]any
if json.Unmarshal([]byte(baseFm), &baseMap) == nil {
base = baseMap
}
}
return marshalFinalMask(mergeFinalMask(base, masks))
}
// applyEndpointHostPathObj is applyEndpointHostPath for the VMess object form.
func applyEndpointHostPathObj(e ShareEndpoint, obj map[string]any) {
if e.ep == nil {
return
}
if h, ok := e.ep["hostHeader"].(string); ok && h != "" {
if _, exists := obj["host"]; exists {
obj["host"] = h
}
}
if p, ok := e.ep["path"].(string); ok && p != "" {
if _, exists := obj["path"]; exists {
obj["path"] = p
}
}
}