diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index bd42d6ed6..6e4da8ed5 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -595,6 +595,18 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string { applyExternalProxyTLSParams(externalProxy, params, security); } + // SIP002 clients (v2rayN) ignore type/headerType/host/path 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 ((stream.network ?? 'tcp') === 'tcp' && params.get('headerType') === 'http') { + const host = params.get('host') ?? ''; + params.delete('type'); + params.delete('headerType'); + params.delete('host'); + params.delete('path'); + params.set('plugin', `obfs-local;obfs=http;obfs-host=${host}`); + } + const isSS2022 = settings.method.substring(0, 4) === '2022'; const isSSMultiUser = settings.method !== '2022-blake3-chacha20-poly1305'; const passwords: string[] = []; diff --git a/internal/sub/characterization_test.go b/internal/sub/characterization_test.go index 551b27045..d63704776 100644 --- a/internal/sub/characterization_test.go +++ b/internal/sub/characterization_test.go @@ -174,6 +174,34 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) { } } +// A TCP http header on Shadowsocks must be emitted as a SIP002 obfs-local +// plugin (what v2rayN parses), not the xray-native type/headerType/host/path +// params (which SIP002 clients silently ignore). +func TestShadowsocksTcpHttpHeaderUsesObfsLocalPlugin(t *testing.T) { + stream := `{ + "network":"tcp","security":"none", + "tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["test"]}}}} + }` + in := &model.Inbound{ + Listen: "203.0.113.1", + Port: 38143, + Protocol: model.Shadowsocks, + Remark: "ss", + Settings: `{"method":"2022-blake3-aes-256-gcm","password":"inboundpw","clients":[{"password":"clientpw","email":"user"}]}`, + StreamSettings: stream, + } + s := &SubService{} + got := s.genShadowsocksLink(in, "user") + if !strings.Contains(got, "plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dtest") { + t.Fatalf("expected obfs-local plugin param, got: %q", got) + } + for _, leak := range []string{"headerType=", "type=tcp", "host=test", "path="} { + if strings.Contains(got, leak) { + t.Fatalf("xray-native param %q must not leak into SS link: %q", leak, got) + } + } +} + // C6 — Hysteria2, TLS, 1 externalProxy entry with a cert pin. Guards that the // Hysteria generator stays on its own path (hex pinSHA256, not pcs) and is NOT // folded into the unified builder. Pin hex is derived, so Contains is used. diff --git a/internal/sub/service.go b/internal/sub/service.go index d0b7a18dd..2c17ec554 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -704,6 +704,18 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st 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 + } + encPart := fmt.Sprintf("%s:%s", method, clients[clientIndex].Password) if method[0] == '2' { encPart = fmt.Sprintf("%s:%s:%s", method, inboundPassword, clients[clientIndex].Password)