mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
34a762f7c8
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.
187 lines
7.8 KiB
Go
187 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|