| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- 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
- }
- }
- }
|