mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-25 03:06:45 +08:00
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:
@@ -547,6 +547,8 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
|||||||
// Two link shapes coexist:
|
// Two link shapes coexist:
|
||||||
// modern: ss://base64(method:password)@host:port#remark
|
// modern: ss://base64(method:password)@host:port#remark
|
||||||
// legacy: 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.
|
// Try modern first; fall back to legacy decode of the whole userinfo+host.
|
||||||
let userInfo: string;
|
let userInfo: string;
|
||||||
let host: string;
|
let host: string;
|
||||||
@@ -562,6 +564,7 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const queryIndex = linkNoHash.indexOf('?');
|
const queryIndex = linkNoHash.indexOf('?');
|
||||||
|
const rawQuery = queryIndex >= 0 ? linkNoHash.slice(queryIndex + 1) : '';
|
||||||
const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
|
const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash;
|
||||||
const atIndex = core.indexOf('@');
|
const atIndex = core.indexOf('@');
|
||||||
if (atIndex >= 0) {
|
if (atIndex >= 0) {
|
||||||
@@ -581,7 +584,7 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
|||||||
userInfo = rawUserInfo;
|
userInfo = rawUserInfo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const hostPort = core.slice(atIndex + 1);
|
const hostPort = core.slice(atIndex + 1).replace(/\/+$/, '');
|
||||||
const colon = hostPort.lastIndexOf(':');
|
const colon = hostPort.lastIndexOf(':');
|
||||||
if (colon < 0) return null;
|
if (colon < 0) return null;
|
||||||
host = hostPort.slice(0, colon);
|
host = hostPort.slice(0, colon);
|
||||||
@@ -605,12 +608,20 @@ export function parseShadowsocksLink(link: string): Raw | null {
|
|||||||
const sep = userInfo.indexOf(':');
|
const sep = userInfo.indexOf(':');
|
||||||
const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
|
const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
|
||||||
const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
|
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 {
|
return {
|
||||||
protocol: 'shadowsocks',
|
protocol: 'shadowsocks',
|
||||||
tag: remark,
|
tag: remark,
|
||||||
settings: {
|
settings: {
|
||||||
servers: [{ address: host, port, password, method }],
|
servers: [{ address: host, port, password, method }],
|
||||||
},
|
},
|
||||||
|
streamSettings: stream,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -304,6 +304,32 @@ describe('parseShadowsocksLink', () => {
|
|||||||
expect(settings.servers[0].password).toBe('legacypw');
|
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)', () => {
|
it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
|
||||||
const method = 'aes-256-gcm';
|
const method = 'aes-256-gcm';
|
||||||
const password = '>>>';
|
const password = '>>>';
|
||||||
|
|||||||
@@ -337,16 +337,23 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
|||||||
// Two shapes:
|
// Two shapes:
|
||||||
// ss://base64(method:pass)@host:port#remark
|
// ss://base64(method:pass)@host:port#remark
|
||||||
// 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 := ""
|
remark := ""
|
||||||
if i := strings.Index(link, "#"); i >= 0 {
|
if i := strings.Index(link, "#"); i >= 0 {
|
||||||
remark, _ = url.QueryUnescape(link[i+1:])
|
remark, _ = url.QueryUnescape(link[i+1:])
|
||||||
link = link[:i]
|
link = link[:i]
|
||||||
}
|
}
|
||||||
|
rawQuery := ""
|
||||||
if i := strings.Index(link, "?"); i >= 0 {
|
if i := strings.Index(link, "?"); i >= 0 {
|
||||||
|
rawQuery = link[i+1:]
|
||||||
link = link[:i]
|
link = link[:i]
|
||||||
}
|
}
|
||||||
|
params, _ := url.ParseQuery(rawQuery)
|
||||||
core := strings.TrimPrefix(link, "ss://")
|
core := strings.TrimPrefix(link, "ss://")
|
||||||
at := strings.Index(core, "@")
|
at := strings.Index(core, "@")
|
||||||
|
var host, method, pass string
|
||||||
|
var port int
|
||||||
if at >= 0 {
|
if at >= 0 {
|
||||||
// modern
|
// modern
|
||||||
userB64 := core[:at]
|
userB64 := core[:at]
|
||||||
@@ -364,46 +371,48 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
|||||||
if colon < 0 {
|
if colon < 0 {
|
||||||
return nil, fmt.Errorf("bad ss host:port")
|
return nil, fmt.Errorf("bad ss host:port")
|
||||||
}
|
}
|
||||||
host := hp[:colon]
|
host = hp[:colon]
|
||||||
port, err := strconv.Atoi(hp[colon+1:])
|
port, err = strconv.Atoi(hp[colon+1:])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err)
|
return nil, fmt.Errorf("bad ss port %q: %w", hp[colon+1:], err)
|
||||||
}
|
}
|
||||||
method, pass := splitMethodPass(userInfo)
|
method, pass = splitMethodPass(userInfo)
|
||||||
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
} else {
|
||||||
ob := Outbound{
|
// legacy: whole thing b64
|
||||||
"protocol": "shadowsocks",
|
dec, err := base64DecodeFlexible(core)
|
||||||
"tag": remark,
|
if err != nil {
|
||||||
"settings": map[string]any{
|
return nil, err
|
||||||
"servers": []any{
|
|
||||||
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
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)
|
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{
|
ob := Outbound{
|
||||||
"protocol": "shadowsocks",
|
"protocol": "shadowsocks",
|
||||||
"tag": remark,
|
"tag": remark,
|
||||||
@@ -412,6 +421,7 @@ func parseShadowsocks(link string) (*ParseResult, error) {
|
|||||||
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"streamSettings": stream,
|
||||||
}
|
}
|
||||||
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestParseShadowsocksBadPort(t *testing.T) {
|
||||||
user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
user := base64.StdEncoding.EncodeToString([]byte("aes-256-gcm:secretpass"))
|
||||||
cases := map[string]string{
|
cases := map[string]string{
|
||||||
|
|||||||
Reference in New Issue
Block a user