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 <287639636+mrchatam@users.noreply.github.com>
This commit is contained in:
mrchatam
2026-09-12 13:22:38 +03:30
committed by GitHub
parent bdd351bd15
commit 503b5df4b9
4 changed files with 122 additions and 35 deletions
+12 -1
View File
@@ -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,
};
}
@@ -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 = '>>>';