Переглянути джерело

Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053) (#4073)

* Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053)

Two related gaps on the Hysteria side of the subscription layer:

1) `genHysteriaLink` ignored `externalProxy` entirely, so an admin who
   pointed a Hysteria inbound at an alternate endpoint (e.g. a CDN
   hostname forwarding UDP back to the node) still got a link with the
   original server address. Mirror what `genVlessLink` / `genTrojanLink`
   already do: fan out one link per entry, substituting `dest` / `port`
   and picking up the entry's remark suffix. As a bonus, the salamander
   obfs password is now copied into the URL too — the panel-side link
   generator already did this, so the subscription output was lagging
   behind it.

2) `buildProxy` in `subClashService.go` had a protocol switch with cases
   for VMESS / VLESS / Trojan / Shadowsocks and a `default: return nil`.
   Hysteria inbounds fell into the default branch and silently vanished
   from the Clash YAML. Route Hysteria to a dedicated
   `buildHysteriaProxy` helper before the transport/security helpers run
   (applyTransport / applySecurity model xray streams, which Hysteria
   doesn't use).

   `buildHysteriaProxy` reads `inbound.StreamSettings` directly instead
   of going through `streamData` / `tlsData`, because those prune
   fields (`allowInsecure`, the salamander `finalmask.udp` block) that
   the mihomo Hysteria proxy wants preserved. Output shape matches
   mihomo's expectations:

     type: hysteria2                  # or "hysteria" for v1
     password / auth-str: <client auth>
     sni, alpn, skip-cert-verify, client-fingerprint
     obfs: salamander
     obfs-password: <finalmask.udp[salamander].settings.password>

The existing `getProxies` fanout over `externalProxy` already plugs in
for Clash, so with Hysteria now recognised, External Proxy entries
also flow through to the Clash output for Hysteria inbounds.

Closes #4053

* gofmt: align map keys in buildHysteriaProxy

---------

Co-authored-by: pwnnex <[email protected]>
pwnnex 3 днів тому
батько
коміт
9611c9def6
2 змінених файлів з 135 додано та 3 видалено
  1. 86 0
      sub/subClashService.go
  2. 49 3
      sub/subService.go

+ 86 - 0
sub/subClashService.go

@@ -159,6 +159,16 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
 }
 
 func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
+	// Hysteria (v1 / v2) doesn't ride an xray `streamSettings.network`
+	// transport and the TLS story is handled inside hysteria itself, so
+	// applyTransport / applySecurity below don't model it. Build the
+	// proxy directly. Without this, hysteria inbounds fell into the
+	// `default: return nil` branch and silently vanished from the
+	// generated Clash config.
+	if inbound.Protocol == model.Hysteria {
+		return s.buildHysteriaProxy(inbound, client, extraRemark)
+	}
+
 	proxy := map[string]any{
 		"name":   s.SubService.genRemark(inbound, client.Email, extraRemark),
 		"server": inbound.Listen,
@@ -222,6 +232,82 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
 	return proxy
 }
 
+// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
+// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
+// directly instead of going through streamData/tlsData, because those
+// helpers prune fields (like `allowInsecure` / the salamander obfs
+// block) that the hysteria proxy wants preserved.
+func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
+	var inboundSettings map[string]any
+	_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
+
+	proxyType := "hysteria2"
+	authKey := "password"
+	if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
+		proxyType = "hysteria"
+		authKey = "auth-str"
+	}
+
+	proxy := map[string]any{
+		"name":   s.SubService.genRemark(inbound, client.Email, extraRemark),
+		"type":   proxyType,
+		"server": inbound.Listen,
+		"port":   inbound.Port,
+		"udp":    true,
+		authKey:  client.Auth,
+	}
+
+	var rawStream map[string]any
+	_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
+
+	// TLS details — hysteria always uses TLS.
+	if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
+		if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
+			proxy["sni"] = serverName
+		}
+		if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
+			out := make([]string, 0, len(alpnList))
+			for _, a := range alpnList {
+				if s, ok := a.(string); ok && s != "" {
+					out = append(out, s)
+				}
+			}
+			if len(out) > 0 {
+				proxy["alpn"] = out
+			}
+		}
+		if inner, ok := tlsSettings["settings"].(map[string]any); ok {
+			if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
+				proxy["skip-cert-verify"] = true
+			}
+			if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
+				proxy["client-fingerprint"] = fp
+			}
+		}
+	}
+
+	// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
+	// block the subscription link generator uses.
+	if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
+		if udpMasks, ok := finalmask["udp"].([]any); ok {
+			for _, m := range udpMasks {
+				mask, _ := m.(map[string]any)
+				if mask == nil || mask["type"] != "salamander" {
+					continue
+				}
+				settings, _ := mask["settings"].(map[string]any)
+				if pw, ok := settings["password"].(string); ok && pw != "" {
+					proxy["obfs"] = "salamander"
+					proxy["obfs-password"] = pw
+					break
+				}
+			}
+		}
+	}
+
+	return proxy
+}
+
 func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
 	switch network {
 	case "", "tcp":

+ 49 - 3
sub/subService.go

@@ -906,7 +906,6 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 }
 
 func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
-	address := s.address
 	if inbound.Protocol != model.Hysteria {
 		return ""
 	}
@@ -921,7 +920,6 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 	}
 	auth := clients[clientIndex].Auth
-	port := inbound.Port
 	params := make(map[string]string)
 
 	params["security"] = "tls"
@@ -950,6 +948,26 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		}
 	}
 
+	// salamander obfs (Hysteria2). The panel-side link generator already
+	// emits these; keep the subscription output in sync so a client has
+	// the obfs password to match the server.
+	if finalmask, ok := stream["finalmask"].(map[string]interface{}); ok {
+		if udpMasks, ok := finalmask["udp"].([]interface{}); ok {
+			for _, m := range udpMasks {
+				mask, _ := m.(map[string]interface{})
+				if mask == nil || mask["type"] != "salamander" {
+					continue
+				}
+				settings, _ := mask["settings"].(map[string]interface{})
+				if pw, ok := settings["password"].(string); ok && pw != "" {
+					params["obfs"] = "salamander"
+					params["obfs-password"] = pw
+					break
+				}
+			}
+		}
+	}
+
 	var settings map[string]interface{}
 	json.Unmarshal([]byte(inbound.Settings), &settings)
 	version, _ := settings["version"].(float64)
@@ -958,7 +976,35 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 		protocol = "hysteria"
 	}
 
-	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, address, port)
+	// Fan out one link per External Proxy entry if any. Previously this
+	// generator ignored `externalProxy` entirely, so the link kept the
+	// server's own IP/port even when the admin configured an alternate
+	// endpoint (e.g. a CDN hostname + port that forwards to the node).
+	// Matches the behaviour of genVlessLink / genTrojanLink / ….
+	externalProxies, _ := stream["externalProxy"].([]interface{})
+	if len(externalProxies) > 0 {
+		links := make([]string, 0, len(externalProxies))
+		for _, externalProxy := range externalProxies {
+			ep, _ := externalProxy.(map[string]interface{})
+			dest, _ := ep["dest"].(string)
+			epPort := int(ep["port"].(float64))
+			epRemark, _ := ep["remark"].(string)
+
+			link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, epPort)
+			u, _ := url.Parse(link)
+			q := u.Query()
+			for k, v := range params {
+				q.Add(k, v)
+			}
+			u.RawQuery = q.Encode()
+			u.Fragment = s.genRemark(inbound, email, epRemark)
+			links = append(links, u.String())
+		}
+		return strings.Join(links, "\n")
+	}
+
+	// No external proxy configured — fall back to the request host.
+	link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
 	url, _ := url.Parse(link)
 	q := url.Query()
 	for k, v := range params {