Files
3x-ui/internal/util/link/outbound_test.go
T
MHSanaei 73a028f52d feat(xray): update xray-core to v26.10.10 and adapt panel
Move the three Xray binary pins (DockerInit.sh, both release.yml
fetches) to v26.10.10, in lockstep with the xtls/xray-core module that
82c77111 already bumped to 701af60772cd.

xdns finalmask (#7090): a domain now takes a "names" list instead of a
single "name", and a resolver is an "addrs" list of udp:// or tcp://
URLs instead of {type, settings:{addr}}. The loader ignores the old
keys rather than failing, so every mask the 26.9.30 seeder wrote would
load with no domain and no resolver and the tunnel would carry nothing.
maskcompat.UpgradeLegacyXdns now lifts both the pre-26.9.30 string lists
and the 26.9.30 objects straight to names/addrs, dropping entries the
old loader refused. A new seeder (XdnsFinalmaskNamesAddrsFix) reruns it
over every stored finalmask, and the save path, GetXrayConfig, both
link importers and the mask editor's mount lift go through it. Template
outbounds now get the build-time heal too: a 26.9.30 template pasted
after the seeder passes the save check, since the core loads that shape
without error. The editor edits names and addrs as tag lists and no
longer requires record types, which the core now defaults (TXT on a
client, A/CNAME/TXT/AAAA on a server). The wire format did not change,
but clients on an older core cannot read the new config shape.

Lua scripts (#6823): dns.script, routing.script and dns servers[].id
are new. The xray page parsed the dns block with a strict schema, so
loading it dropped script and id and the next save deleted them; the
schema now keeps both and the DNS server modal carries the id through
an edit. The hot routing apply built routing in-process, and that build
now resolves the script file from the panel's working directory, so a
script beside the xray binary failed every rule change into a full
restart and broke the node bridge. RoutingService cannot change the
script anyway, so the panel no longer resolves it there.

TLS client roots (#7105): the core now verifies against its bundled
Mozilla roots instead of the OS store unless tlsSettings.useSystemCA is
set. The outbound TLS form gets a switch for it, so an outbound to a
server signed by a privately installed CA can opt back in.
2026-10-11 07:55:12 +02:00

580 lines
19 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 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 no longer suppresses mport",
query: "mport=1-2&fm=" + url.QueryEscape(`{"quicParams":{"udpHop":{"ports":"30000-40000","interval":"7-9"}}}`),
wantPorts: "1-2",
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 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)
}
})
}
}