mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 19:25:05 +03:00
fix(sub): preserve external Hysteria2 Clash options (#6944)
An external hy2:// link turned into a bare Clash Hysteria2 node: obfs, obfs-password, the port-hopping range and the certificate pin were dropped, so Mihomo could not reach servers that require them. External and panel Hysteria2 nodes now share one converter.
This commit is contained in:
@@ -131,17 +131,9 @@ func clashHysteriaFromExternal(settings, stream map[string]any, name string) map
|
||||
"password": auth,
|
||||
"udp": true,
|
||||
}
|
||||
if tls, _ := stream["tlsSettings"].(map[string]any); tls != nil {
|
||||
if sni, _ := tls["serverName"].(string); sni != "" {
|
||||
proxy["sni"] = sni
|
||||
}
|
||||
if alpn := clashStringList(tls["alpn"]); len(alpn) > 0 {
|
||||
proxy["alpn"] = alpn
|
||||
}
|
||||
if fp, _ := tls["fingerprint"].(string); fp != "" {
|
||||
proxy["client-fingerprint"] = fp
|
||||
}
|
||||
}
|
||||
tls, _ := stream["tlsSettings"].(map[string]any)
|
||||
applyHysteriaTLS(proxy, tls, tls)
|
||||
applyHysteriaFinalMask(proxy, stream)
|
||||
return proxy
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestClashExternalHysteriaPreservesStreamOptions(t *testing.T) {
|
||||
const pin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
||||
const fingerprint = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "plain TLS retains SNI ALPN and client fingerprint",
|
||||
query: "sni=tls.example&alpn=h3,h2&fp=chrome",
|
||||
want: map[string]any{"sni": "tls.example", "alpn": []string{"h3", "h2"}, "client-fingerprint": "chrome"},
|
||||
},
|
||||
{
|
||||
name: "salamander password and hopping range survive conversion",
|
||||
query: "obfs=salamander&obfs-password=obfs%2B%26%3D%23&mport=20000-30000",
|
||||
want: map[string]any{"obfs": "salamander", "obfs-password": "obfs+&=#", "ports": "20000-30000"},
|
||||
},
|
||||
{
|
||||
name: "gecko packet sizes and multiple hopping ranges survive conversion",
|
||||
query: "obfs=gecko&obfs-password=pw&minPacketSize=512&maxPacketSize=1200&mport=20000-30000,40000-50000",
|
||||
want: map[string]any{
|
||||
"obfs": "gecko", "obfs-password": "pw", "obfs-min-packet-size": 512,
|
||||
"obfs-max-packet-size": 1200, "ports": "20000-30000,40000-50000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "certificate pin remains distinct from client fingerprint",
|
||||
query: "fp=firefox&pinSHA256=invalid," + pin,
|
||||
want: map[string]any{"client-fingerprint": "firefox", "fingerprint": fingerprint},
|
||||
},
|
||||
{
|
||||
name: "base64 certificate pin is normalized",
|
||||
query: "pinSHA256=AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8%3D",
|
||||
want: map[string]any{"fingerprint": fingerprint},
|
||||
},
|
||||
{
|
||||
name: "invalid packet sizes retain salamander password",
|
||||
query: "obfs=gecko&obfs-password=pw&minPacketSize=1200&maxPacketSize=512",
|
||||
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
|
||||
},
|
||||
{
|
||||
name: "incomplete packet size range retains salamander password",
|
||||
query: "obfs=gecko&obfs-password=pw&minPacketSize=512",
|
||||
want: map[string]any{"obfs": "salamander", "obfs-password": "pw"},
|
||||
},
|
||||
{
|
||||
name: "obfuscation without password leaves hopping intact",
|
||||
query: "obfs=salamander&mport=20000-30000",
|
||||
want: map[string]any{"ports": "20000-30000"},
|
||||
},
|
||||
{
|
||||
name: "unknown obfuscation and invalid pin are omitted",
|
||||
query: "obfs=unknown&obfs-password=pw&pinSHA256=not-a-pin",
|
||||
},
|
||||
{
|
||||
name: "finalmask obfuscation and hopping take precedence over URI options",
|
||||
query: "obfs=salamander&obfs-password=ignored&mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"salamander","settings":{"password":"mask-pw","packetSize":"600-1000"}},{"type":"udphop","settings":{"remotePorts":"30000-40000"}}]}`),
|
||||
want: map[string]any{
|
||||
"obfs": "gecko", "obfs-password": "mask-pw", "obfs-min-packet-size": 600,
|
||||
"obfs-max-packet-size": 1000, "ports": "30000-40000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "legacy hopping range survives conversion",
|
||||
query: "fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":" 20000-30000 "}}}`),
|
||||
want: map[string]any{"ports": "20000-30000"},
|
||||
},
|
||||
{
|
||||
name: "malformed masks are skipped before usable obfuscation and hopping",
|
||||
query: "fm=" + url.QueryEscape(`{"udp":[null,42,{"type":"salamander","settings":{"password":42}},{"type":"salamander","settings":{"password":"pw","packetSize":"512-4096"}},{"type":"udphop","settings":null},{"type":"udphop","settings":{"remotePorts":"20000-30000"}}]}`),
|
||||
want: map[string]any{"obfs": "salamander", "obfs-password": "pw", "ports": "20000-30000"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
want := map[string]any{
|
||||
"name": "external", "type": "hysteria2", "server": "192.0.2.1", "port": 443,
|
||||
"password": "auth+&=#", "udp": true, "alpn": []string{"h3"},
|
||||
}
|
||||
for key, value := range tt.want {
|
||||
want[key] = value
|
||||
}
|
||||
got := (&SubClashService{}).clashProxyFromExternal("hy2://auth%2B%26%3D%23@192.0.2.1:443?"+tt.query+"#provider", "external")
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("proxy = %#v, want %#v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClashExternalHysteriaRejectsInvalidLinks(t *testing.T) {
|
||||
for _, rawLink := range []string{
|
||||
"hysteria2://@192.0.2.1:443?obfs=salamander&obfs-password=pw",
|
||||
"hysteria2://pass@[::1",
|
||||
"hysteria2://pass%zz@192.0.2.1:443",
|
||||
} {
|
||||
t.Run(rawLink, func(t *testing.T) {
|
||||
if got := (&SubClashService{}).clashProxyFromExternal(rawLink, "external"); got != nil {
|
||||
t.Fatalf("invalid link produced proxy %#v, want nil", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClashHysteriaStreamOptionsMatchInbound(t *testing.T) {
|
||||
const pin = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"
|
||||
const fingerprint = "00:01:02:03:04:05:06:07:08:09:0A:0B:0C:0D:0E:0F:10:11:12:13:14:15:16:17:18:19:1A:1B:1C:1D:1E:1F"
|
||||
tests := []struct {
|
||||
name string
|
||||
mask string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "salamander with modern hopping preferred over legacy range",
|
||||
mask: `{"udp":[{"type":"salamander","settings":{"password":"pw"}},{"type":"udphop","settings":{"remotePorts":"20000-30000"}}],"quicParams":{"udpHop":{"ports":"1-2"}}}`,
|
||||
want: map[string]any{"obfs": "salamander", "obfs-password": "pw", "ports": "20000-30000"},
|
||||
},
|
||||
{
|
||||
name: "gecko with legacy hopping",
|
||||
mask: `{"udp":[{"type":"salamander","settings":{"password":"pw","packetSize":"512-1200"}}],"quicParams":{"udpHop":{"ports":"20000-30000"}}}`,
|
||||
want: map[string]any{
|
||||
"obfs": "gecko", "obfs-password": "pw", "obfs-min-packet-size": 512,
|
||||
"obfs-max-packet-size": 1200, "ports": "20000-30000",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed optional settings do not add proxy fields",
|
||||
mask: `{"udp":[null,{"type":"salamander","settings":null},{"type":"udphop","settings":{"remotePorts":42}}]}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
inbound := &model.Inbound{
|
||||
Protocol: model.Hysteria, Listen: "192.0.2.1", Port: 443, Settings: `{"version":2}`,
|
||||
StreamSettings: `{"tlsSettings":{"serverName":"tls.example","alpn":["h3"],"settings":{"fingerprint":"chrome","pinnedPeerCertSha256":["` + pin + `"]}},"finalmask":` + tt.mask + `}`,
|
||||
}
|
||||
svc := &SubClashService{}
|
||||
fromInbound := svc.buildHysteriaProxy(&SubService{}, inbound, model.Client{Email: "user", Auth: "secret"}, nil)
|
||||
fromExternal := svc.clashProxyFromExternal("hysteria2://secret@192.0.2.1:443?sni=tls.example&fp=chrome&pinSHA256="+pin+"&fm="+url.QueryEscape(tt.mask), "external")
|
||||
want := map[string]any{
|
||||
"type": "hysteria2", "server": "192.0.2.1", "port": 443, "password": "secret", "udp": true,
|
||||
"sni": "tls.example", "alpn": []string{"h3"}, "client-fingerprint": "chrome", "fingerprint": fingerprint,
|
||||
}
|
||||
for key, value := range tt.want {
|
||||
want[key] = value
|
||||
}
|
||||
for path, proxy := range map[string]map[string]any{"inbound": fromInbound, "external": fromExternal} {
|
||||
delete(proxy, "name")
|
||||
if !reflect.DeepEqual(proxy, want) {
|
||||
t.Errorf("%s proxy = %#v, want %#v", path, proxy, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClashHysteriaV1RetainsAuthAndInsecureOverride(t *testing.T) {
|
||||
inbound := &model.Inbound{
|
||||
Protocol: model.Hysteria, Listen: "192.0.2.1", Port: 443, Settings: `{"version":1}`,
|
||||
StreamSettings: `{"tlsSettings":{"serverName":"tls.example","alpn":["h3"],"settings":{"allowInsecure":true,"fingerprint":"chrome"}}}`,
|
||||
}
|
||||
svc := &SubClashService{}
|
||||
for _, ep := range []map[string]any{nil, {"allowInsecure": false}} {
|
||||
got := svc.buildHysteriaProxy(&SubService{}, inbound, model.Client{Email: "user", Auth: "secret"}, ep)
|
||||
delete(got, "name")
|
||||
want := map[string]any{
|
||||
"type": "hysteria", "server": "192.0.2.1", "port": 443, "auth-str": "secret", "udp": true,
|
||||
"sni": "tls.example", "alpn": []string{"h3"}, "skip-cert-verify": true, "client-fingerprint": "chrome",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("proxy with endpoint %#v = %#v, want %#v", ep, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -524,11 +524,8 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
|
||||
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.
|
||||
// buildHysteriaProxy reads the raw stream because streamData prunes
|
||||
// TLS pins and finalmask settings needed by Hysteria clients.
|
||||
func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.Inbound, client model.Client, ep map[string]any) map[string]any {
|
||||
inboundSettings := subReq.linkSettings(inbound)
|
||||
|
||||
@@ -551,34 +548,9 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
||||
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
|
||||
}
|
||||
if certFingerprint := mihomoCertFingerprint(inner["pinnedPeerCertSha256"]); certFingerprint != "" {
|
||||
proxy["fingerprint"] = certFingerprint
|
||||
}
|
||||
}
|
||||
}
|
||||
tlsSettings, _ := rawStream["tlsSettings"].(map[string]any)
|
||||
clientTLS, _ := tlsSettings["settings"].(map[string]any)
|
||||
applyHysteriaTLS(proxy, tlsSettings, clientTLS)
|
||||
if insecure, ok := ep["allowInsecure"].(bool); ok && insecure {
|
||||
proxy["skip-cert-verify"] = true
|
||||
}
|
||||
@@ -586,41 +558,56 @@ func (s *SubClashService) buildHysteriaProxy(subReq *SubService, inbound *model.
|
||||
proxy["fingerprint"] = certFingerprint
|
||||
}
|
||||
|
||||
// Salamander obfs (Hysteria2), read from the same finalmask.udp[salamander]
|
||||
// block as the link generator; a packetSize range is mihomo's gecko obfs.
|
||||
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
|
||||
packetSize, _ := settings["packetSize"].(string)
|
||||
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
|
||||
minStr, maxStr := splitHysteriaPacketSize(gecko)
|
||||
proxy["obfs"] = "gecko"
|
||||
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
|
||||
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UDP port hopping. mihomo reads the range from a dedicated `ports`
|
||||
// field (the base `port` stays as the redirect target).
|
||||
if hopPorts := hysteriaHopPorts(rawStream); hopPorts != "" {
|
||||
proxy["ports"] = hopPorts
|
||||
}
|
||||
applyHysteriaFinalMask(proxy, rawStream)
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
func applyHysteriaTLS(proxy, tlsSettings, clientTLS map[string]any) {
|
||||
if sni, _ := tlsSettings["serverName"].(string); sni != "" {
|
||||
proxy["sni"] = sni
|
||||
}
|
||||
if alpn := clashStringList(tlsSettings["alpn"]); len(alpn) > 0 {
|
||||
proxy["alpn"] = alpn
|
||||
}
|
||||
if insecure, _ := clientTLS["allowInsecure"].(bool); insecure {
|
||||
proxy["skip-cert-verify"] = true
|
||||
}
|
||||
if fp, _ := clientTLS["fingerprint"].(string); fp != "" {
|
||||
proxy["client-fingerprint"] = fp
|
||||
}
|
||||
if certFingerprint := mihomoCertFingerprint(clientTLS["pinnedPeerCertSha256"]); certFingerprint != "" {
|
||||
proxy["fingerprint"] = certFingerprint
|
||||
}
|
||||
}
|
||||
|
||||
func applyHysteriaFinalMask(proxy, stream map[string]any) {
|
||||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||||
udpMasks, _ := finalmask["udp"].([]any)
|
||||
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
|
||||
packetSize, _ := settings["packetSize"].(string)
|
||||
if gecko := parseHysteriaPacketSize(packetSize); gecko != "" {
|
||||
minStr, maxStr := splitHysteriaPacketSize(gecko)
|
||||
proxy["obfs"] = "gecko"
|
||||
proxy["obfs-min-packet-size"], _ = strconv.Atoi(minStr)
|
||||
proxy["obfs-max-packet-size"], _ = strconv.Atoi(maxStr)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if hopPorts := hysteriaHopPorts(stream); hopPorts != "" {
|
||||
proxy["ports"] = hopPorts
|
||||
}
|
||||
}
|
||||
|
||||
// Mihomo supports only one certificate fingerprint, so mihomoCertFingerprint
|
||||
// converts the first valid SHA-256 pin to its colon-separated TLS form.
|
||||
func mihomoCertFingerprint(value any) string {
|
||||
|
||||
Reference in New Issue
Block a user