mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 11:45:04 +03:00
14f2bb5e2c
parseVmess tried standard base64, then unpadded URL-safe base64, so a URL-safe body that kept its = padding failed both and the server silently dropped out of an imported outbound subscription. It now uses base64DecodeFlexible, the helper the shadowsocks parser already uses.
705 lines
25 KiB
Go
705 lines
25 KiB
Go
package link
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"net/url"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestParseVmessLink(t *testing.T) {
|
||
// vmess:// + base64 of:
|
||
// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
|
||
link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
|
||
res, err := ParseLink(link)
|
||
if err != nil {
|
||
t.Fatalf("parse vmess: %v", err)
|
||
}
|
||
if res.Outbound["protocol"] != "vmess" {
|
||
t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
|
||
}
|
||
if res.Outbound["tag"] != "test" {
|
||
t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
|
||
}
|
||
}
|
||
|
||
func TestParseVmessLinkBase64Alphabets(t *testing.T) {
|
||
raw := []byte(`{"v":"2","ps":"a?>","add":"1.2.3.4","port":443,"id":"uuid","net":"ws","path":"/?","tls":"tls"}`)
|
||
if padded := base64.URLEncoding.EncodeToString(raw); !strings.ContainsAny(padded, "-_") || !strings.HasSuffix(padded, "=") {
|
||
t.Fatalf("test object must encode to URL-safe characters with padding, got %s", padded)
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
b64 string
|
||
}{
|
||
{"standard", base64.StdEncoding.EncodeToString(raw)},
|
||
{"standard unpadded", base64.RawStdEncoding.EncodeToString(raw)},
|
||
{"url-safe", base64.URLEncoding.EncodeToString(raw)},
|
||
{"url-safe unpadded", base64.RawURLEncoding.EncodeToString(raw)},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
res, err := ParseLink("vmess://" + c.b64)
|
||
if err != nil {
|
||
t.Fatalf("parse %s: %v", c.b64, err)
|
||
}
|
||
if res.Outbound["tag"] != "a?>" {
|
||
t.Errorf("tag = %v, want %q", res.Outbound["tag"], "a?>")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestLinkIdentityKeepsTLSServerName(t *testing.T) {
|
||
a, errA := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=a.example.com#node")
|
||
b, errB := ParseLink("vless://uuid@1.2.3.4:443?type=ws&security=tls&sni=b.example.com#node")
|
||
if errA != nil || errB != nil {
|
||
t.Fatalf("parse vless: %v, %v", errA, errB)
|
||
}
|
||
if a.Identity == b.Identity {
|
||
t.Fatalf("TLS links for different SNIs share identity %q", a.Identity)
|
||
}
|
||
}
|
||
|
||
func TestParseVlessLink(t *testing.T) {
|
||
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
||
res, err := ParseLink(link)
|
||
if err != nil {
|
||
t.Fatalf("parse vless: %v", err)
|
||
}
|
||
if res.Outbound["protocol"] != "vless" {
|
||
t.Fatalf("bad protocol")
|
||
}
|
||
if res.Outbound["tag"] != "node1" {
|
||
t.Errorf("tag mismatch: %v", res.Outbound["tag"])
|
||
}
|
||
}
|
||
|
||
func TestParseVlessLink_FinalMaskQuicParamsSanitized(t *testing.T) {
|
||
fm := url.QueryEscape(`{"mask":"dtls","quicParams":{"keepAlivePeriod":"10s","maxIdleTimeout":"30","initStreamReceiveWindow":524288,"maxIncomingStreams":true,"brutalUp":"100 mbps"}}`)
|
||
res, err := ParseLink("vless://uuid@1.2.3.4:443?type=tcp&security=none&fm=" + fm + "#node1")
|
||
if err != nil {
|
||
t.Fatalf("parse vless with fm: %v", err)
|
||
}
|
||
stream, ok := res.Outbound["streamSettings"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing streamSettings: %v", res.Outbound)
|
||
}
|
||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing finalmask: %v", stream)
|
||
}
|
||
if finalmask["mask"] != "dtls" {
|
||
t.Errorf("mask changed: %v", finalmask["mask"])
|
||
}
|
||
qp, ok := finalmask["quicParams"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing quicParams: %v", finalmask)
|
||
}
|
||
if got := qp["keepAlivePeriod"]; got != int64(10) {
|
||
t.Errorf("keepAlivePeriod: expected 10, got %v (%T)", got, got)
|
||
}
|
||
if got := qp["maxIdleTimeout"]; got != int64(30) {
|
||
t.Errorf("maxIdleTimeout: expected 30, got %v (%T)", got, got)
|
||
}
|
||
if got := qp["initStreamReceiveWindow"]; got != int64(524288) {
|
||
t.Errorf("initStreamReceiveWindow: expected 524288, got %v (%T)", got, got)
|
||
}
|
||
if _, exists := qp["maxIncomingStreams"]; exists {
|
||
t.Errorf("maxIncomingStreams should be dropped, got %v", qp["maxIncomingStreams"])
|
||
}
|
||
if got := qp["brutalUp"]; got != "100 mbps" {
|
||
t.Errorf("brutalUp should stay a string, got %v (%T)", got, got)
|
||
}
|
||
}
|
||
|
||
// A panel on xray-core 26.9.30 shares xdns objects whose "name" and typed resolvers
|
||
// 26.10.10 ignores; imported verbatim, the outbound would dial with no domain.
|
||
func TestParseLink_UpgradesLegacyXdnsFinalMask(t *testing.T) {
|
||
fm := url.QueryEscape(`{"udp":[{"type":"xdns","settings":{"domains":[{"name":"t.example.com","types":[16],"edns0":1232}],"resolvers":[{"type":"udp","settings":{"addr":"8.8.8.8:53"}}]}}]}`)
|
||
res, err := ParseLink("vless://uuid@1.2.3.4:53?type=kcp&security=none&fm=" + fm + "#dns")
|
||
if err != nil {
|
||
t.Fatalf("parse vless with fm: %v", err)
|
||
}
|
||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||
got, err := json.Marshal(stream["finalmask"])
|
||
if err != nil {
|
||
t.Fatalf("marshal finalmask: %v", err)
|
||
}
|
||
want := `{"udp":[{"settings":{"domains":[{"edns0":1232,"names":["t.example.com"],"types":[16]}],"resolvers":[{"addrs":["udp://8.8.8.8:53"]}]},"type":"xdns"}]}`
|
||
if string(got) != want {
|
||
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
|
||
}
|
||
}
|
||
|
||
func TestSanitizeFinalMaskQuicParams_ClampsAndRejects(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
key string
|
||
in any
|
||
want any
|
||
}{
|
||
{"infinite string dropped", "keepAlivePeriod", "inf", nil},
|
||
{"nan string dropped", "keepAlivePeriod", "NaN", nil},
|
||
{"negative dropped", "maxStreamReceiveWindow", float64(-5), nil},
|
||
{"negative duration dropped", "keepAlivePeriod", "-10s", nil},
|
||
{"absurd magnitude dropped", "initConnectionReceiveWindow", float64(1e30), nil},
|
||
{"keepAlive clamped up", "keepAlivePeriod", "1s", int64(2)},
|
||
{"keepAlive clamped down", "keepAlivePeriod", "90s", int64(60)},
|
||
{"idle clamped up", "maxIdleTimeout", float64(1), int64(4)},
|
||
{"idle clamped down", "maxIdleTimeout", "10m", int64(120)},
|
||
{"streams clamped up", "maxIncomingStreams", float64(4), int64(8)},
|
||
{"zero means unset and survives", "maxIdleTimeout", float64(0), int64(0)},
|
||
{"window passes through", "initStreamReceiveWindow", float64(524288), int64(524288)},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
parsed := map[string]any{"quicParams": map[string]any{c.key: c.in}}
|
||
sanitizeFinalMaskQuicParams(parsed)
|
||
qp := parsed["quicParams"].(map[string]any)
|
||
got, exists := qp[c.key]
|
||
if c.want == nil {
|
||
if exists {
|
||
t.Fatalf("%s: expected key dropped, got %v (%T)", c.key, got, got)
|
||
}
|
||
return
|
||
}
|
||
if !exists || got != c.want {
|
||
t.Fatalf("%s: expected %v, got %v (%T)", c.key, c.want, got, got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func salamanderPassword(t *testing.T, res *ParseResult) (string, bool) {
|
||
t.Helper()
|
||
stream, ok := res.Outbound["streamSettings"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing streamSettings: %v", res.Outbound)
|
||
}
|
||
finalmask, ok := stream["finalmask"].(map[string]any)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
udp, ok := finalmask["udp"].([]any)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
for _, m := range udp {
|
||
mask, _ := m.(map[string]any)
|
||
if mask == nil || mask["type"] != "salamander" {
|
||
continue
|
||
}
|
||
settings, _ := mask["settings"].(map[string]any)
|
||
pw, _ := settings["password"].(string)
|
||
return pw, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func finalmaskUDP(t *testing.T, res *ParseResult) []any {
|
||
t.Helper()
|
||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||
udp, _ := finalmask["udp"].([]any)
|
||
return udp
|
||
}
|
||
|
||
func hopMask(t *testing.T, res *ParseResult) (map[string]any, bool) {
|
||
t.Helper()
|
||
for _, rawMask := range finalmaskUDP(t, res) {
|
||
mask, _ := rawMask.(map[string]any)
|
||
if maskType, _ := mask["type"].(string); maskType == "udphop" {
|
||
settings, _ := mask["settings"].(map[string]any)
|
||
return settings, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func hopPorts(t *testing.T, res *ParseResult) (string, bool) {
|
||
t.Helper()
|
||
settings, ok := hopMask(t, res)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
ports, _ := settings["remotePorts"].(string)
|
||
return ports, true
|
||
}
|
||
|
||
func TestParseHysteria2_Obfs(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
query string
|
||
wantPw string
|
||
wantSet bool
|
||
}{
|
||
{"standard", "obfs=salamander&obfs-password=s3cr3t", "s3cr3t", true},
|
||
{"snake-case alias", "obfs=salamander&obfs_password=aliaspw", "aliaspw", true},
|
||
{"camel-case alias", "obfs=salamander&obfsPassword=camelpw", "camelpw", true},
|
||
{"case-insensitive type", "obfs=Salamander&obfs-password=mixed", "mixed", true},
|
||
{"no obfs", "sni=ex.com", "", false},
|
||
{"obfs without password", "obfs=salamander", "", false},
|
||
{"unknown obfs type", "obfs=random&obfs-password=x", "", false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&" + c.query + "#node")
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2: %v", err)
|
||
}
|
||
if res.Outbound["protocol"] != "hysteria" {
|
||
t.Fatalf("bad protocol: %v", res.Outbound["protocol"])
|
||
}
|
||
pw, ok := salamanderPassword(t, res)
|
||
if ok != c.wantSet {
|
||
t.Fatalf("salamander mask present = %v, want %v (stream: %v)", ok, c.wantSet, res.Outbound["streamSettings"])
|
||
}
|
||
if pw != c.wantPw {
|
||
t.Errorf("salamander password: got %q, want %q", pw, c.wantPw)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseHysteria2_ObfsFinalMaskPrecedence(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
fm string
|
||
obfsPw string
|
||
wantPw string
|
||
wantUDPLen int
|
||
}{
|
||
{
|
||
name: "fm password wins over obfs",
|
||
fm: `{"udp":[{"type":"salamander","settings":{"password":"fromfm"}}]}`,
|
||
obfsPw: "fromobfs",
|
||
wantPw: "fromfm",
|
||
wantUDPLen: 1,
|
||
},
|
||
{
|
||
name: "obfs fills password-less fm mask",
|
||
fm: `{"udp":[{"type":"salamander","settings":{}}]}`,
|
||
obfsPw: "fromobfs",
|
||
wantPw: "fromobfs",
|
||
wantUDPLen: 1,
|
||
},
|
||
{
|
||
name: "obfs appends alongside a non-salamander mask",
|
||
fm: `{"udp":[{"type":"mkcp-legacy","settings":{"header":"srtp"}}]}`,
|
||
obfsPw: "fromobfs",
|
||
wantPw: "fromobfs",
|
||
wantUDPLen: 2,
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
link := "hysteria2://auth@1.2.3.4:443?security=tls&fm=" + url.QueryEscape(c.fm) +
|
||
"&obfs=salamander&obfs-password=" + c.obfsPw + "#node"
|
||
res, err := ParseLink(link)
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2: %v", err)
|
||
}
|
||
pw, ok := salamanderPassword(t, res)
|
||
if !ok {
|
||
t.Fatalf("salamander mask missing: %v", res.Outbound["streamSettings"])
|
||
}
|
||
if pw != c.wantPw {
|
||
t.Errorf("salamander password: got %q, want %q", pw, c.wantPw)
|
||
}
|
||
if udp := finalmaskUDP(t, res); len(udp) != c.wantUDPLen {
|
||
t.Errorf("udp mask count: got %d, want %d (%v)", len(udp), c.wantUDPLen, udp)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseHysteria2_Mport(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
query string
|
||
wantPorts string
|
||
wantHop bool
|
||
}{
|
||
{"standard mport", "mport=20000-50000", "20000-50000", true},
|
||
{"no mport", "sni=ex.com", "", false},
|
||
{
|
||
name: "fm udphop mask wins over mport",
|
||
query: "mport=1-2&fm=" + url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalremote","interval":"7-9","remotePorts":"30000-40000"}}]}`),
|
||
wantPorts: "30000-40000",
|
||
wantHop: true,
|
||
},
|
||
{
|
||
name: "legacy fm quicParams.udpHop is upgraded and wins over mport like the mask",
|
||
query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
|
||
wantPorts: "30000-40000",
|
||
wantHop: true,
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&" + c.query + "#node")
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2: %v", err)
|
||
}
|
||
ports, ok := hopPorts(t, res)
|
||
if ok != c.wantHop {
|
||
t.Fatalf("udpHop present = %v, want %v (stream: %v)", ok, c.wantHop, res.Outbound["streamSettings"])
|
||
}
|
||
if ports != c.wantPorts {
|
||
t.Errorf("hop ports: got %q, want %q", ports, c.wantPorts)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// xray-core 26.9.9 rejects a udphop mask whose mode is empty or unknown, so
|
||
// the mport importer must emit a mode the core's UDPHop.Build() accepts.
|
||
func TestParseHysteria2_MportEmitsCoreAcceptedMask(t *testing.T) {
|
||
res, err := ParseLink("hysteria2://auth@1.2.3.4:443?security=tls&mport=20000-50000#node")
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2: %v", err)
|
||
}
|
||
settings, ok := hopMask(t, res)
|
||
if !ok {
|
||
t.Fatalf("no udphop mask (stream: %v)", res.Outbound["streamSettings"])
|
||
}
|
||
if got, _ := settings["mode"].(string); got != "intervalremote" {
|
||
t.Errorf("mode = %q, want %q", got, "intervalremote")
|
||
}
|
||
if got, _ := settings["interval"].(string); got != "5-10" {
|
||
t.Errorf("interval = %q, want %q", got, "5-10")
|
||
}
|
||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||
finalmask, _ := stream["finalmask"].(map[string]any)
|
||
if quicParams, ok := finalmask["quicParams"].(map[string]any); ok {
|
||
if _, dead := quicParams["udpHop"]; dead {
|
||
t.Error("importer still writes the quicParams.udpHop key the core ignores")
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestParseShadowsocks(t *testing.T) {
|
||
modernUser := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||
legacyBody := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:8388"))
|
||
cases := []struct {
|
||
name string
|
||
link string
|
||
host string
|
||
port int
|
||
method string
|
||
pass string
|
||
}{
|
||
{
|
||
name: "modern",
|
||
link: "ss://" + modernUser + "@1.2.3.4:8388#node",
|
||
host: "1.2.3.4",
|
||
port: 8388,
|
||
method: "aes-256-gcm",
|
||
pass: "secretpass",
|
||
},
|
||
{
|
||
name: "modern with plugin query",
|
||
link: "ss://" + modernUser + "@1.2.3.4:8388?plugin=v2ray-plugin#node",
|
||
host: "1.2.3.4",
|
||
port: 8388,
|
||
method: "aes-256-gcm",
|
||
pass: "secretpass",
|
||
},
|
||
{
|
||
name: "modern sip002 slash query",
|
||
link: "ss://" + modernUser + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
|
||
host: "1.2.3.4",
|
||
port: 8388,
|
||
method: "aes-256-gcm",
|
||
pass: "secretpass",
|
||
},
|
||
{
|
||
name: "legacy",
|
||
link: "ss://" + legacyBody + "#node",
|
||
host: "1.2.3.4",
|
||
port: 8388,
|
||
method: "aes-256-gcm",
|
||
pass: "secretpass",
|
||
},
|
||
{
|
||
name: "base64url userinfo with plugin and trailing slash",
|
||
link: "ss://" + base64.RawURLEncoding.EncodeToString([]byte("aes-128-gcm:pa+ss/word")) + "@1.2.3.4:8388/?plugin=obfs-local%3Bobfs%3Dhttp#node",
|
||
host: "1.2.3.4",
|
||
port: 8388,
|
||
method: "aes-128-gcm",
|
||
pass: "pa+ss/word",
|
||
},
|
||
{
|
||
name: "sip022 percent-encoded userinfo",
|
||
link: "ss://2022-blake3-aes-256-gcm:YctPZ6U7xPPcU%2Bgp3u%2B0tx%2FtRizJN9K8y%2BuKlW2qjlI%3D@example.com:8888#Example3",
|
||
host: "example.com",
|
||
port: 8888,
|
||
method: "2022-blake3-aes-256-gcm",
|
||
pass: "YctPZ6U7xPPcU+gp3u+0tx/tRizJN9K8y+uKlW2qjlI=",
|
||
},
|
||
{
|
||
name: "sip022 dual-key password with type query preserves inner colon",
|
||
link: "ss://2022-blake3-aes-256-gcm:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB%3D@1.2.3.4:9999?type=tcp#node",
|
||
host: "1.2.3.4",
|
||
port: 9999,
|
||
method: "2022-blake3-aes-256-gcm",
|
||
pass: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=",
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
res, err := ParseLink(c.link)
|
||
if err != nil {
|
||
t.Fatalf("parse ss: %v", err)
|
||
}
|
||
if res.Outbound["protocol"] != "shadowsocks" {
|
||
t.Fatalf("protocol = %v, want shadowsocks", res.Outbound["protocol"])
|
||
}
|
||
srv := res.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||
if srv["address"] != c.host {
|
||
t.Errorf("address = %v, want %v", srv["address"], c.host)
|
||
}
|
||
if srv["port"] != c.port {
|
||
t.Errorf("port = %v, want %v", srv["port"], c.port)
|
||
}
|
||
if srv["method"] != c.method {
|
||
t.Errorf("method = %v, want %v", srv["method"], c.method)
|
||
}
|
||
if srv["password"] != c.pass {
|
||
t.Errorf("password = %v, want %v", srv["password"], c.pass)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseShadowsocksTLSQueryRoundTrip(t *testing.T) {
|
||
user := base64.RawURLEncoding.EncodeToString([]byte("chacha20-ietf-poly1305:secretpass"))
|
||
link := "ss://" + user + "@example.com:443?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user"
|
||
res, err := ParseLink(link)
|
||
if err != nil {
|
||
t.Fatalf("parse ss tls: %v", err)
|
||
}
|
||
srv := res.Outbound["settings"].(map[string]any)["servers"].([]any)[0].(map[string]any)
|
||
if srv["address"] != "example.com" || srv["port"] != 443 {
|
||
t.Fatalf("server = %v", srv)
|
||
}
|
||
if srv["method"] != "chacha20-ietf-poly1305" || srv["password"] != "secretpass" {
|
||
t.Fatalf("creds = %v", srv)
|
||
}
|
||
stream, ok := res.Outbound["streamSettings"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing streamSettings: %v", res.Outbound)
|
||
}
|
||
if stream["network"] != "tcp" {
|
||
t.Errorf("network = %v, want tcp", stream["network"])
|
||
}
|
||
if stream["security"] != "tls" {
|
||
t.Errorf("security = %v, want tls", stream["security"])
|
||
}
|
||
tls, ok := stream["tlsSettings"].(map[string]any)
|
||
if !ok {
|
||
t.Fatalf("missing tlsSettings: %v", stream)
|
||
}
|
||
if tls["serverName"] != "example.com" {
|
||
t.Errorf("sni = %v, want example.com", tls["serverName"])
|
||
}
|
||
if tls["fingerprint"] != "firefox" {
|
||
t.Errorf("fp = %v, want firefox", tls["fingerprint"])
|
||
}
|
||
alpn, _ := tls["alpn"].([]string)
|
||
if len(alpn) != 2 || alpn[0] != "h2" || alpn[1] != "http/1.1" {
|
||
t.Errorf("alpn = %v, want [h2 http/1.1]", alpn)
|
||
}
|
||
}
|
||
|
||
func TestParseShadowsocksBadPort(t *testing.T) {
|
||
user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||
cases := map[string]string{
|
||
"modern": "ss://" + user + "@1.2.3.4:notaport#node",
|
||
"legacy": "ss://" + base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass@1.2.3.4:notaport")) + "#node",
|
||
}
|
||
for name, link := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
if _, err := ParseLink(link); err == nil {
|
||
t.Errorf("expected parse error for non-numeric port, got nil")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseSubscriptionBody_Base64(t *testing.T) {
|
||
// base64 of the two joined links:
|
||
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
|
||
b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
|
||
obs, ids, err := ParseSubscriptionBody([]byte(b64))
|
||
if err != nil {
|
||
t.Fatalf("parse sub body: %v", err)
|
||
}
|
||
if len(obs) != 2 {
|
||
t.Fatalf("expected 2 outbounds, got %d", len(obs))
|
||
}
|
||
if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
|
||
t.Errorf("bad identities: %v", ids)
|
||
}
|
||
}
|
||
|
||
func TestSlugAndSuggest(t *testing.T) {
|
||
if SlugRemark("Hello World!") != "hello-world" {
|
||
t.Errorf("slug failed")
|
||
}
|
||
tag := SuggestTag("hk-", " SG 01 !! ", 0)
|
||
if tag != "hk-sg-01" {
|
||
t.Errorf("suggest tag got %q", tag)
|
||
}
|
||
// Non-ASCII letters/digits are preserved rather than stripped.
|
||
if got := SlugRemark("Москва 🇷🇺 01"); got != "москва-01" {
|
||
t.Errorf("unicode slug got %q", got)
|
||
}
|
||
if got := SuggestTag("ru-", "Сервер 2", 0); got != "ru-сервер-2" {
|
||
t.Errorf("unicode suggest tag got %q", got)
|
||
}
|
||
// The tag names Xray counters and an outbound_traffics row; a provider picks the remark.
|
||
if got := SlugRemark(strings.Repeat("Ab ", 1<<20)); got != strings.Repeat("ab-", 21)+"a" {
|
||
t.Errorf("megabyte remark slug = %.80q (%d bytes), want the first 64 runes", got, len(got))
|
||
}
|
||
}
|
||
|
||
// The obfs-local plugin the panel exports carries the only description of
|
||
// shadowsocks tcp/http obfuscation, so it has to become that header.
|
||
func TestParseShadowsocksObfsLocalPlugin(t *testing.T) {
|
||
user := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||
const httpObfs = "obfs-local;obfs=http;obfs-host=obfs.example.com"
|
||
for _, tc := range []struct {
|
||
name, query, wantHeader, wantHost string
|
||
}{
|
||
{"http obfs becomes the tcp header", "plugin=" + url.QueryEscape(httpObfs), "http", "obfs.example.com"},
|
||
{"unencoded separators map the same way", "plugin=" + httpObfs, "http", "obfs.example.com"},
|
||
{"tls obfs has no xray header", "plugin=" + url.QueryEscape("obfs-local;obfs=tls"), "none", ""},
|
||
{"an unrelated plugin is left alone", "plugin=v2ray-plugin", "none", ""},
|
||
} {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
res, err := ParseLink("ss://" + user + "@1.2.3.4:8388/?" + tc.query + "#node")
|
||
if err != nil {
|
||
t.Fatalf("parse ss: %v", err)
|
||
}
|
||
raw, err := json.Marshal(res.Outbound["streamSettings"])
|
||
if err != nil {
|
||
t.Fatalf("marshal stream: %v", err)
|
||
}
|
||
var stream map[string]any
|
||
_ = json.Unmarshal(raw, &stream)
|
||
tcp, _ := stream["tcpSettings"].(map[string]any)
|
||
header, _ := tcp["header"].(map[string]any)
|
||
if header == nil || header["type"] != tc.wantHeader {
|
||
t.Fatalf("header = %v, want type %q", header, tc.wantHeader)
|
||
}
|
||
request, _ := header["request"].(map[string]any)
|
||
headers, _ := request["headers"].(map[string]any)
|
||
hosts, _ := headers["Host"].([]any)
|
||
got := ""
|
||
if len(hosts) > 0 {
|
||
got, _ = hosts[0].(string)
|
||
}
|
||
if got != tc.wantHost {
|
||
t.Errorf("host = %q, want %q", got, tc.wantHost)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// A panel older than the 26.9.9 core shares its hop range as quicParams.udpHop inside fm=,
|
||
// which the core ignores; imported verbatim, the outbound would never hop.
|
||
func TestParseLink_UpgradesLegacyUDPHopFinalMask(t *testing.T) {
|
||
fm := url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"20000-30000","interval":"5-10"}}}`)
|
||
res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy")
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2 with fm: %v", err)
|
||
}
|
||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||
got, err := json.Marshal(stream["finalmask"])
|
||
if err != nil {
|
||
t.Fatalf("marshal finalmask: %v", err)
|
||
}
|
||
want := `{"udp":[{"settings":{"interval":"5-10","mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
|
||
if string(got) != want {
|
||
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
|
||
}
|
||
}
|
||
|
||
// A link from a panel that listed udphop before another UDP mask would leave an outbound
|
||
// whose every dial the core refuses ("incorrect index"); the import puts the hop last.
|
||
func TestParseLink_PutsUDPHopMaskLast(t *testing.T) {
|
||
fm := url.QueryEscape(`{"udp":[{"type":"udphop","settings":{"mode":"intervalRemote","remotePorts":"20000-30000"}},{"type":"salamander","settings":{"password":"s3cr3t"}}]}`)
|
||
res, err := ParseLink("hysteria2://auth@hy.example.com:443?sni=hy.example.com&fm=" + fm + "#hy")
|
||
if err != nil {
|
||
t.Fatalf("parse hysteria2 with fm: %v", err)
|
||
}
|
||
stream, _ := res.Outbound["streamSettings"].(map[string]any)
|
||
got, _ := json.Marshal(stream["finalmask"])
|
||
want := `{"udp":[{"settings":{"password":"s3cr3t"},"type":"salamander"},{"settings":{"mode":"intervalRemote","remotePorts":"20000-30000"},"type":"udphop"}]}`
|
||
if string(got) != want {
|
||
t.Fatalf("imported finalmask\n got: %s\nwant: %s", got, want)
|
||
}
|
||
}
|
||
|
||
// A share link describes a remote server; keys that redirect the dial or name files on
|
||
// the importing host must not survive, however the core's case-folding decoder spells them.
|
||
func TestParseLinkDropsHostLocalKeys(t *testing.T) {
|
||
extra := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp",` +
|
||
`"sockopt":{"dialerProxy":"api","tcpFastOpen":true},` +
|
||
`"tlsSettings":{"serverName":"cdn.example.com","CERTIFICATES":[{"certificateFile":"/dev/zero","keyFile":"/etc/shadow"}],"maſterKeyLog":"/root/keys"}}}`)
|
||
fm := url.QueryEscape(`{"tcp":[{"type":"header-custom","settings":{"DialerProxy":"api"}}]}`)
|
||
res, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&sni=cdn.example.com&extra=" + extra + "&fm=" + fm + "#node")
|
||
if err != nil {
|
||
t.Fatalf("parse vless: %v", err)
|
||
}
|
||
raw, err := json.Marshal(res.Outbound)
|
||
if err != nil {
|
||
t.Fatalf("marshal: %v", err)
|
||
}
|
||
var hostLocal []string
|
||
var walk func(v any)
|
||
walk = func(v any) {
|
||
switch v := v.(type) {
|
||
case map[string]any:
|
||
for k, child := range v {
|
||
for _, banned := range []string{"dialerProxy", "certificateFile", "keyFile", "masterKeyLog"} {
|
||
if strings.EqualFold(k, banned) {
|
||
hostLocal = append(hostLocal, k)
|
||
}
|
||
}
|
||
walk(child)
|
||
}
|
||
case []any:
|
||
for _, child := range v {
|
||
walk(child)
|
||
}
|
||
}
|
||
}
|
||
var decoded any
|
||
_ = json.Unmarshal(raw, &decoded)
|
||
walk(decoded)
|
||
if len(hostLocal) > 0 {
|
||
t.Fatalf("outbound keeps host-local keys %v: %s", hostLocal, raw)
|
||
}
|
||
if !strings.Contains(string(raw), `"tcpFastOpen":true`) || !strings.Contains(string(raw), `"serverName":"cdn.example.com"`) {
|
||
t.Fatalf("remote-server settings were dropped too: %s", raw)
|
||
}
|
||
}
|
||
|
||
// Pretty-printing costs the square of the nesting depth, and extra=, fm= and the vmess
|
||
// JSON copy provider JSON into the outbound; a link may not nest past real shapes.
|
||
func TestParseLinkRejectsDeeplyNestedJSON(t *testing.T) {
|
||
deep := strings.Repeat(`{"a":`, 40) + `1` + strings.Repeat(`}`, 40)
|
||
if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + url.QueryEscape(`{"xmux":`+deep+`}`) + "#n"); err == nil {
|
||
t.Fatal("a link nesting JSON 40 levels deep was accepted")
|
||
}
|
||
real := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","xhttpSettings":{"headers":{"a":"b"}},` +
|
||
`"finalmask":{"udp":[{"type":"noise","settings":{"noise":[{"type":"rand","packet":"10-20"}]}}]}}}`)
|
||
if _, err := ParseLink("vless://uuid@8.8.8.8:443?type=xhttp&security=tls&extra=" + real + "#n"); err != nil {
|
||
t.Fatalf("a real download stream with a finalmask was refused: %v", err)
|
||
}
|
||
}
|