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
- Create a Shadowsocks inbound:
- method:
chacha20-ietf-poly1305
- network: TCP
- transport security: TLS
- valid certificate and SNI
- Add a client and fetch its normal raw subscription.
- Import the generated
ss:// entry into v2rayNG.
- Try to connect. The profile fails because v2rayNG creates a plain Shadowsocks outbound.
- 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.
- 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:
- Reject or warn about Shadowsocks + Xray-native TLS when raw share links/subscriptions are used.
- Omit the raw
ss:// entry and provide a full JSON configuration for this non-SIP002 combination.
- 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
Describe the bug
For a Shadowsocks inbound using Xray-native transport TLS, the raw share/subscription link looks like:
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 assecurity,sni,alpn,fp, andtypeare not a standard representation of XraystreamSettings.Current v2rayNG parses the server, port, method, and password, and only handles the
pluginquery when it containsobfs=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/internal/sub/service.go
Lines 927 to 975 in 4e928a1
3x-ui/internal/util/link/outbound.go
Lines 336 to 383 in 4e928a1
pluginquery: https://github.com/2dust/v2rayNG/blob/master/V2rayNG/app/src/main/java/com/v2ray/ang/fmt/ShadowsocksFmt.kt#L29-L68The same generator logic is still present on the current 3x-ui
mainbranch.How to reproduce the problem
chacha20-ietf-poly1305ss://entry into v2rayNG.{ "streamSettings": { "network": "tcp", "security": "tls", "tlsSettings": { "serverName": "example.com" } } }I reproduced this with an end-to-end HTTPS request through an independent Xray client:
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:
ss://entry and provide a full JSON configuration for this non-SIP002 combination.A regression test should cover either:
genShadowsocksLink -> ParseLinkpreserving all server-required transport settings, orActual behavior
genShadowsocksLinkemits Xray-native transport fields as non-standardss://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:
3x-ui/internal/sub/characterization_test.go
Lines 152 to 174 in 4e928a1
Relevant logs
Screenshots
Not applicable.
3x-ui version
3.5.0 (tag commit
4e928a1ce0945a6e956aa63365034ec24d2b1387); verified that currentmainstill 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?
Browser (only if it is a UI bug)
Not applicable.
Is the panel behind a reverse proxy or CDN?
No — direct access
Before submitting
main.