diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 4312aa8d3..678dbfeac 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -617,6 +617,19 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string { if (isSS2022) passwords.push(settings.password); if (isSSMultiUser) passwords.push(clientPassword); + if (isSS2022) { + // SIP022 (2022-blake3-*) forbids base64 userinfo: method and each key are + // percent-encoded, joined by literal ':' separators. Built by hand because + // `new URL` would re-encode the inner key separator to %3A. + const userinfo = [settings.method, ...passwords].map(encodeURIComponent).join(':'); + let link = `ss://${userinfo}@${formatUrlHost(address)}:${port}`; + const query = params.toString(); + if (query) link += `?${query}`; + link += `#${encodeURIComponent(remark)}`; + return link; + } + + // SIP002 userinfo is base64(method:pw). const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true); const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index bd31c23b6..698321538 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -372,8 +372,15 @@ export function parseShadowsocksLink(link: string): Raw | null { const core = queryIndex >= 0 ? linkNoHash.slice(0, queryIndex) : linkNoHash; const atIndex = core.indexOf('@'); if (atIndex >= 0) { - try { userInfo = Base64.decode(core.slice('ss://'.length, atIndex)); } - catch { userInfo = core.slice('ss://'.length, atIndex); } + const rawUserInfo = core.slice('ss://'.length, atIndex); + if (rawUserInfo.includes(':')) { + // SIP022 (2022-blake3-*) userinfo is percent-encoded, never base64 + // (a literal ':' can't appear in a base64/base64url string). + try { userInfo = decodeURIComponent(rawUserInfo); } catch { userInfo = rawUserInfo; } + } else { + try { userInfo = Base64.decode(rawUserInfo); } + catch { userInfo = rawUserInfo; } + } const hostPort = core.slice(atIndex + 1); const colon = hostPort.lastIndexOf(':'); if (colon < 0) return null; diff --git a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap index a5189d7e4..d2b00e41f 100644 --- a/frontend/src/test/__snapshots__/inbound-link.test.ts.snap +++ b/frontend/src/test/__snapshots__/inbound-link.test.ts.snap @@ -4,7 +4,7 @@ exports[`genHysteriaLink > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst- exports[`genInboundLinks orchestrator > hysteria-v1-tls: byte-stable 1`] = `"hysteria://hyst-v1-auth-XYZ@override.test:36715?security=tls&fp=chrome&alpn=h3&sni=hysteria.example.test#parity-test"`; -exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@override.test:8388?type=tcp#parity-test"`; +exports[`genInboundLinks orchestrator > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@override.test:8388?type=tcp#parity-test"`; exports[`genInboundLinks orchestrator > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@override.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; @@ -32,7 +32,7 @@ PersistentKeepalive = 25 " `; -exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206Wm1GclpTMXpaWEoyWlhJdGNHRnpjM2R2Y21RdE1EQXdNUT09OmRHVnpkQzFqYkdsbGJuUXRjR0Z6YzNkdmNtUXRNUT09@example.test:8388?type=tcp#parity-test"`; +exports[`genShadowsocksLink > shadowsocks-tcp-2022: byte-stable 1`] = `"ss://2022-blake3-aes-256-gcm:ZmFrZS1zZXJ2ZXItcGFzc3dvcmQtMDAwMQ%3D%3D:dGVzdC1jbGllbnQtcGFzc3dvcmQtMQ%3D%3D@example.test:8388?type=tcp#parity-test"`; exports[`genTrojanLink > trojan-ws-tls: byte-stable 1`] = `"trojan://trojan-test-pw-XYZ@example.test:443?type=ws&path=%2Ftrojan&host=trojan.example.test&security=tls&fp=chrome&alpn=h2%2Chttp%2F1.1&sni=trojan.example.test#parity-test"`; diff --git a/internal/sub/characterization_test.go b/internal/sub/characterization_test.go index d63704776..acccb567c 100644 --- a/internal/sub/characterization_test.go +++ b/internal/sub/characterization_test.go @@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) { } s := &SubService{} got := s.genShadowsocksLink(in, "user") - want := "ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206aW5ib3VuZHB3OmNsaWVudHB3@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS" + want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS" if got != want { t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want) } diff --git a/internal/sub/service.go b/internal/sub/service.go index 85c1ed687..67010d111 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -738,9 +738,16 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host } - encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password) - if method[0] == '2' { - encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password) + // SIP002 userinfo is base64(method:password). For SIP022 (2022-blake3-*) the + // userinfo MUST NOT be base64-encoded; method and password are percent-encoded. + var userInfo string + if strings.HasPrefix(method, "2022") { + userInfo = fmt.Sprintf("%s:%s:%s", + url.QueryEscape(method), + url.QueryEscape(inboundPassword), + url.QueryEscape(clients[clientIndex].Password)) + } else { + userInfo = base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", method, clients[clientIndex].Password))) } externalProxies, _ := stream["externalProxy"].([]any) @@ -753,7 +760,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st proxyParams, security, func(dest string, port int) string { - return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port)) + return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.endpointRemark(inbound, email, ep) @@ -761,7 +768,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st ) } - link := fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(address, inbound.Port)) + link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index b8100e946..8d941ac16 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -344,7 +344,12 @@ func parseShadowsocks(link string) (*ParseResult, error) { hp := core[at+1:] userInfo, err := base64DecodeFlexible(userB64) if err != nil { - userInfo = userB64 // not b64, rare + // SIP022 (2022-blake3-*) userinfo is percent-encoded, not base64. + if dec, uerr := url.QueryUnescape(userB64); uerr == nil { + userInfo = dec + } else { + userInfo = userB64 // not b64, rare + } } colon := strings.LastIndex(hp, ":") if colon < 0 {