Jelajahi Sumber

fix(link): preserve Shadowsocks TLS query params on import (#6467)

* fix(link): preserve Shadowsocks TLS query params on import

Mirror trojan/vless stream parsing so Xray-native type/security/sni/alpn/fp
query params on ss:// links survive into streamSettings on both Go and TS importers.

Fixes #6094

* fix(link): drop extra blank line so oxfmt passes

---------

Co-authored-by: mrchatam <[email protected]>
mrchatam 3 jam lalu
induk
melakukan
503b5df4b9

+ 12 - 1
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,
   };
 }
 

+ 26 - 0
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<string, unknown>;
+    expect(stream.network).toBe('tcp');
+    expect(stream.security).toBe('tls');
+    const tls = stream.tlsSettings as Record<string, unknown>;
+    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 = '>>>';

+ 43 - 33
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
-	}
-	// 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")
+		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)
 	}
-	userInfo := dec[:at]
-	hp := dec[at+1:]
-	colon := strings.LastIndex(hp, ":")
-	if colon < 0 {
-		return nil, fmt.Errorf("bad legacy ss hp")
+	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+	network := params.Get("type")
+	if network == "" {
+		network = "tcp"
 	}
-	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)
+	security := params.Get("security")
+	if security == "" {
+		security = "none"
 	}
-	method, pass := splitMethodPass(userInfo)
-	identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
+	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
 }

+ 40 - 0
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{