package sub import ( "encoding/json" "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" ) // 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 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 slices.Contains(h.ExcludeFromSubTypes, format) { continue } eps = append(eps, hostToExternalProxyMap(h, defaultDest, inbound.Port)) } return eps } // 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 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.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.SockoptParams != "" { ep["sockoptParams"] = h.SockoptParams } if h.MuxParams != "" { ep["muxParams"] = h.MuxParams } if h.FinalMask != "" { ep["finalMask"] = h.FinalMask } 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 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): JSON realityData reads serverNames and // clash reads serverName, so set both forms. 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 rs["serverNames"] = []any{sni} } } } } } // 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 "" } // 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) 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) { if !isHostEndpoint(ep) { return } tmpl, _ := ep["remark"].(string) ep["remark"] = s.genHostRemark(inbound, client, tmpl) 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) 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) } // 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 } } // 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" } } // 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 } } }