diff options
| author | pwnnex <pwnnex@proton.me> | 2026-04-22 11:01:21 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-04-22 11:01:21 +0300 |
| commit | 9611c9def67ca8daa81f4b220ae7b9cec729ed21 (patch) | |
| tree | 4321b4497e891544c7a9ffb92094424b0650e4e0 | |
| parent | 292eb992f46830ef03c54d280b2031f03fe2eb4f (diff) | |
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 <eternxles@gmail.com>
| -rw-r--r-- | sub/subClashService.go | 86 | ||||
| -rw-r--r-- | sub/subService.go | 52 |
2 files changed, 135 insertions, 3 deletions
diff --git a/sub/subClashService.go b/sub/subClashService.go index f0a8ca8a..d0445aa4 100644 --- a/sub/subClashService.go +++ b/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": diff --git a/sub/subService.go b/sub/subService.go index bf9c029c..dd6bbcc9 100644 --- a/sub/subService.go +++ b/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 { |
