diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 1f539591c..937a8ec69 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -547,6 +547,8 @@ export function parseShadowsocksLink(link: string): Raw | null { // Two link shapes coexist: // modern: ss://base64(method:password)@host:port#remark // legacy: ss://base64(method:password@host:port)#remark + // Query may carry Xray-native stream params (type/security/sni/alpn/fp) + // emitted by the SS share-link generator — preserve them like trojan/vless. // Try modern first; fall back to legacy decode of the whole userinfo+host. let userInfo: string; let host: string; @@ -562,6 +564,7 @@ export function parseShadowsocksLink(link: string): Raw | null { } } const queryIndex = linkNoHash.indexOf('?'); + const rawQuery = queryIndex >= 0 ? linkNoHash.slice(queryIndex + 1) : ''; const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash; const atIndex = core.indexOf('@'); if (atIndex >= 0) { @@ -581,7 +584,7 @@ export function parseShadowsocksLink(link: string): Raw | null { userInfo = rawUserInfo; } } - const hostPort = core.slice(atIndex + 1); + const hostPort = core.slice(atIndex + 1).replace(/\/+$/, ''); const colon = hostPort.lastIndexOf(':'); if (colon < 0) return null; host = hostPort.slice(0, colon); @@ -605,12 +608,20 @@ export function parseShadowsocksLink(link: string): Raw | null { const sep = userInfo.indexOf(':'); const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep); const password = sep < 0 ? userInfo : userInfo.slice(sep + 1); + const params = new URLSearchParams(rawQuery); + const network = params.get('type') ?? 'tcp'; + const security = (params.get('security') ?? 'none') as string; + const stream = buildStream(network, security); + applyTransportParams(stream, params); + applySecurityParams(stream, params); + applyFinalMaskParam(stream, params); return { protocol: 'shadowsocks', tag: remark, settings: { servers: [{ address: host, port, password, method }], }, + streamSettings: stream, }; } diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 9991a3294..72ef0626f 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -304,6 +304,32 @@ describe('parseShadowsocksLink', () => { expect(settings.servers[0].password).toBe('legacypw'); }); + it('preserves Xray TLS query params on import (round-trip)', () => { + const userinfo = Base64.encode('chacha20-ietf-poly1305:secretpass', true); + const link = + `ss://${userinfo}@example.com:443` + + '?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user'; + const out = parseShadowsocksLink(link); + expect(out?.protocol).toBe('shadowsocks'); + expect(out?.tag).toBe('user'); + const settings = out?.settings as { + servers: Array<{ address: string; port: number; method: string; password: string }>; + }; + expect(settings.servers[0]).toMatchObject({ + address: 'example.com', + port: 443, + method: 'chacha20-ietf-poly1305', + password: 'secretpass', + }); + const stream = out?.streamSettings as Record; + expect(stream.network).toBe('tcp'); + expect(stream.security).toBe('tls'); + const tls = stream.tlsSettings as Record; + expect(tls.serverName).toBe('example.com'); + expect(tls.fingerprint).toBe('firefox'); + expect(tls.alpn).toEqual(['h2', 'http/1.1']); + }); + it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => { const method = 'aes-256-gcm'; const password = '>>>'; diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 1b762030f..f149e619e 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -337,16 +337,23 @@ func parseShadowsocks(link string) (*ParseResult, error) { // Two shapes: // ss://base64(method:pass)@host:port#remark // ss://base64(method:pass@host:port)#remark + // Query may carry Xray-native stream params (type/security/sni/alpn/fp) + // emitted by genShadowsocksLink — preserve them like trojan/vless. remark := "" if i := strings.Index(link, "#"); i >= 0 { remark, _ = url.QueryUnescape(link[i+1:]) link = link[:i] } + rawQuery := "" if i := strings.Index(link, "?"); i >= 0 { + rawQuery = link[i+1:] link = link[:i] } + params, _ := url.ParseQuery(rawQuery) core := strings.TrimPrefix(link, "ss://") at := strings.Index(core, "@") + var host, method, pass string + var port int if at >= 0 { // modern userB64 := core[:at] @@ -364,46 +371,48 @@ func parseShadowsocks(link string) (*ParseResult, error) { if colon < 0 { return nil, fmt.Errorf("bad ss host:port") } - host := hp[:colon] - port, err := strconv.Atoi(hp[colon+1:]) + host = hp[:colon] + port, err = strconv.Atoi(hp[colon+1:]) if err != nil { return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err) } - method, pass := splitMethodPass(userInfo) - identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port) - ob := Outbound{ - "protocol": "shadowsocks", - "tag": remark, - "settings": map[string]any{ - "servers": []any{ - map[string]any{"address": host, "port": port, "password": pass, "method": method}, - }, - }, + method, pass = splitMethodPass(userInfo) + } else { + // legacy: whole thing b64 + dec, err := base64DecodeFlexible(core) + if err != nil { + return nil, err } - return &ParseResult{Outbound: ob, Identity: identity}, nil + at = strings.Index(dec, "@") + if at < 0 { + return nil, fmt.Errorf("bad legacy ss") + } + userInfo := dec[:at] + hp := dec[at+1:] + colon := strings.LastIndex(hp, ":") + if colon < 0 { + return nil, fmt.Errorf("bad legacy ss hp") + } + host = hp[:colon] + port, err = strconv.Atoi(hp[colon+1:]) + if err != nil { + return nil, fmt.Errorf("bad legacy ss port %q: %w", hp[colon+1:], err) + } + method, pass = splitMethodPass(userInfo) } - // legacy: whole thing b64 - dec, err := base64DecodeFlexible(core) - if err != nil { - return nil, err - } - at = strings.Index(dec, "@") - if at < 0 { - return nil, fmt.Errorf("bad legacy ss") - } - userInfo := dec[:at] - hp := dec[at+1:] - colon := strings.LastIndex(hp, ":") - if colon < 0 { - return nil, fmt.Errorf("bad legacy ss hp") - } - host := hp[:colon] - port, err := strconv.Atoi(hp[colon+1:]) - if err != nil { - return nil, fmt.Errorf("bad legacy ss port %q: %w", hp[colon+1:], err) - } - method, pass := splitMethodPass(userInfo) identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port) + network := params.Get("type") + if network == "" { + network = "tcp" + } + security := params.Get("security") + if security == "" { + security = "none" + } + stream := buildStream(network, security) + applyTransport(stream, params) + applySecurity(stream, params) + applyFinalMask(stream, params) ob := Outbound{ "protocol": "shadowsocks", "tag": remark, @@ -412,6 +421,7 @@ func parseShadowsocks(link string) (*ParseResult, error) { map[string]any{"address": host, "port": port, "password": pass, "method": method}, }, }, + "streamSettings": stream, } return &ParseResult{Outbound: ob, Identity: identity}, nil } diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index acbd0b095..b60cca0ec 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -416,6 +416,46 @@ func TestParseShadowsocks(t *testing.T) { } } +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{