Skip to content

[Bug]: Shadowsocks TLS share links are incompatible with SIP002 and v2rayNG #6094

Description

@vladon

Describe the bug

For a Shadowsocks inbound using Xray-native transport TLS, the raw share/subscription link looks like:

ss://<base64url(method:password)>@example.com:443?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user

This link looks complete, but it is not interoperable with SIP002 clients such as v2rayNG.

SIP002 defines the optional query component for a SIP003 plugin; parameters such as security, sni, alpn, fp, and type are not a standard representation of Xray streamSettings.

Current v2rayNG parses the server, port, method, and password, and only handles the plugin query when it contains obfs=http. It ignores the Xray-native TLS parameters, so it creates a plain Shadowsocks outbound while the server expects TLS. The connection then fails during the handshake.

3x-ui's own Shadowsocks importer demonstrates the same mismatch: it truncates the link at ? before parsing and therefore discards all generated TLS parameters.

Relevant source:

  • 3x-ui generator adds TLS query parameters:
    security, _ := stream["security"].(string)
    if security == "tls" {
    applyShareTLSParams(stream, params)
    }
    // SIP002 clients (v2rayN) ignore the xray-native type/headerType/host/path
    // params and only read `plugin`. Re-encode a TCP http header as obfs-local so
    // they build a matching tcp/http outbound (v2rayN forces request path "/").
    if streamNetwork == "tcp" && params["headerType"] == "http" {
    host := params["host"]
    delete(params, "type")
    delete(params, "headerType")
    delete(params, "host")
    delete(params, "path")
    params["plugin"] = "obfs-local;obfs=http;obfs-host=" + host
    }
    // 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(client.Password))
    } else {
    userInfo = base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s:%s", method, client.Password))
    }
    externalProxies, _ := stream["externalProxy"].([]any)
    if len(externalProxies) > 0 {
    proxyParams := cloneStringMap(params)
    proxyParams["security"] = security
    return s.buildExternalProxyURLLinks(
    externalProxies,
    proxyParams,
    security,
    func(_ map[string]any, dest string, port int) string {
    return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
    },
    func(ep map[string]any) string {
    return s.endpointRemark(inbound, email, ep, streamNetwork)
    },
    )
    }
    link := fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(address, inbound.Port))
    return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork))
  • 3x-ui importer drops the entire query:
    func parseShadowsocks(link string) (*ParseResult, error) {
    // Two shapes:
    // ss://base64(method:pass)@host:port#remark
    // ss://base64(method:pass@host:port)#remark
    remark := ""
    if i := strings.Index(link, "#"); i >= 0 {
    remark, _ = url.QueryUnescape(link[i+1:])
    link = link[:i]
    }
    if i := strings.Index(link, "?"); i >= 0 {
    link = link[:i]
    }
    core := strings.TrimPrefix(link, "ss://")
    at := strings.Index(core, "@")
    if at >= 0 {
    // modern
    userB64 := core[:at]
    hp := strings.TrimRight(core[at+1:], "/")
    userInfo, err := base64DecodeFlexible(userB64)
    if err != nil {
    // 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 {
    return nil, fmt.Errorf("bad ss host:port")
    }
    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},
    },
    },
    }
    return &ParseResult{Outbound: ob, Identity: identity}, nil
  • v2rayNG only handles the SIP002 plugin query: https://github.com/2dust/v2rayNG/blob/master/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt#L29-L68
  • SIP002 grammar: https://github.com/shadowsocks/shadowsocks-org/wiki/SIP002-URI-Scheme

The same generator logic is still present on the current 3x-ui main branch.

How to reproduce the problem

  1. Create a Shadowsocks inbound:
    • method: chacha20-ietf-poly1305
    • network: TCP
    • transport security: TLS
    • valid certificate and SNI
  2. Add a client and fetch its normal raw subscription.
  3. Import the generated ss:// entry into v2rayNG.
  4. Try to connect. The profile fails because v2rayNG creates a plain Shadowsocks outbound.
  5. As a control, build an Xray outbound with the same address, port, method, and password plus:
    {
      "streamSettings": {
        "network": "tcp",
        "security": "tls",
        "tlsSettings": {
          "serverName": "example.com"
        }
      }
    }
    The control connection works.
  6. Remove native TLS from the server inbound and refresh the same subscription. The unmodified v2rayNG profile then works.

I reproduced this with an end-to-end HTTPS request through an independent Xray client:

  • generated SIP002 core fields without manually restoring TLS: failed
  • same credentials with TLS stream settings: HTTP 200
  • after changing the server inbound to standard Shadowsocks without TLS: HTTP 200

Expected behavior

3x-ui should not emit a misleading standard ss:// link that common SIP002 clients import without the server-required TLS layer.

Possible safe outcomes:

  1. Reject or warn about Shadowsocks + Xray-native TLS when raw share links/subscriptions are used.
  2. Omit the raw ss:// entry and provide a full JSON configuration for this non-SIP002 combination.
  3. Use a SIP003 plugin representation only when both the generated link and server configuration actually use the same supported plugin.

A regression test should cover either:

  • genShadowsocksLink -> ParseLink preserving all server-required transport settings, or
  • refusing/marking the combination as non-exportable when it cannot round-trip.

Actual behavior

genShadowsocksLink emits Xray-native transport fields as non-standard ss:// query parameters. v2rayNG and 3x-ui's own importer discard them, producing a plain Shadowsocks client configuration that cannot connect to the TLS-enabled inbound.

The existing characterization test currently locks this incompatible output in as expected:

// C3b — Shadowsocks 2022 (method[0]=='2'), TLS base, 1 externalProxy entry.
// Locks the ss-2022 triple-segment userinfo path through the shared builder.
func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
stream := `{
"network":"tcp","security":"tls",
"tcpSettings":{"header":{"type":"none"}},
"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}},
"externalProxy":[{"forceTls":"tls","dest":"ss.example.com","port":8443,"remark":"SS","sni":"ss.sni"}]
}`
in := &model.Inbound{
Listen: "203.0.113.1",
Port: 443,
Protocol: model.Shadowsocks,
Remark: "char",
Settings: `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`,
StreamSettings: stream,
}
s := &SubService{}
got := s.genShadowsocksLink(in, "user")
want := "ss://2022-blake3-aes-256-gcm:inboundpw:clientpw@ss.example.com:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
if got != want {
t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
}

Relevant logs

Generated link (credentials redacted):
ss://<redacted>@example.com:443?alpn=h2%2Chttp%2F1.1&fp=firefox&security=tls&sni=example.com&type=tcp#user

v2rayNG import result:
- Shadowsocks server/method/password imported
- no TLS stream settings created
- connection fails

Control Xray outbound with streamSettings.security=tls:
- HTTP 200

Screenshots

Not applicable.

3x-ui version

3.5.0 (tag commit 4e928a1ce0945a6e956aa63365034ec24d2b1387); verified that current main still contains the same logic.

Xray-core version

26.7.11

How did you install 3x-ui?

install.sh script

Operating system

Ubuntu 26.04 LTS

Which parts of the panel are affected?

  • Subscription (share links / Clash / JSON)
  • Backend share-link generation

Browser (only if it is a UI bug)

Not applicable.

Is the panel behind a reverse proxy or CDN?

No — direct access

Before submitting

  • I searched existing issues for Shadowsocks TLS, SIP002, native TLS, and v2rayNG; no duplicate was found.
  • I am running the latest released version of 3x-ui and verified the same code on current main.
  • This bug report is written in English.
  • All tokens, real domains, addresses, client identifiers, and credentials are redacted.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions