diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 13c342bc1..bf67c4a3b 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -388,6 +388,27 @@ function sanitizeFinalMaskQuicParams(parsed: Record): void { } } +// The panel exports tcp/http obfuscation as the SIP002 obfs-local plugin only, +// so the header it stands for has to be rebuilt before the transport is applied. +function applyObfsLocalPluginParams(params: URLSearchParams): void { + if (params.get('headerType') || params.get('type') === 'http') return; + const parts = (params.get('plugin') ?? '').split(';'); + if (parts[0] !== 'obfs-local') return; + let obfs = ''; + let host = ''; + for (const part of parts.slice(1)) { + const eq = part.indexOf('='); + if (eq < 0) continue; + const key = part.slice(0, eq); + if (key === 'obfs') obfs = part.slice(eq + 1); + else if (key === 'obfs-host') host = part.slice(eq + 1); + } + if (obfs !== 'http') return; + params.set('type', 'tcp'); + params.set('headerType', 'http'); + if (host) params.set('host', host); +} + function applySecurityParams(stream: Raw, params: URLSearchParams): void { if (stream.security === 'tls') { const tls = stream.tlsSettings as Raw; @@ -614,6 +635,7 @@ export function parseShadowsocksLink(link: string): Raw | null { const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep); const password = sep < 0 ? userInfo : userInfo.slice(sep + 1); const params = new URLSearchParams(rawQuery); + applyObfsLocalPluginParams(params); const network = params.get('type') ?? 'tcp'; const security = (params.get('security') ?? 'none') as string; const stream = buildStream(network, security); diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 66c0e29b6..0a21986a5 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -358,6 +358,27 @@ describe('parseShadowsocksLink', () => { expect(tls.alpn).toEqual(['h2', 'http/1.1']); }); + // The panel exports tcp/http obfuscation as the SIP002 plugin only, so the + // importer has to rebuild the header it stands for. + it('rebuilds the tcp/http header from the obfs-local plugin', () => { + const userinfo = Base64.encode('aes-256-gcm:secretpass', true); + const plugin = encodeURIComponent('obfs-local;obfs=http;obfs-host=obfs.example.com'); + const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`; + const stream = parseShadowsocksLink(link)?.streamSettings as Record; + expect((stream.tcpSettings as Record).header).toMatchObject({ + type: 'http', + request: { headers: { Host: ['obfs.example.com'] } }, + }); + }); + + it('leaves a plugin without an xray header alone', () => { + const userinfo = Base64.encode('aes-256-gcm:secretpass', true); + const plugin = encodeURIComponent('obfs-local;obfs=tls'); + const link = `ss://${userinfo}@example.com:8388?plugin=${plugin}#user`; + const stream = parseShadowsocksLink(link)?.streamSettings as Record; + expect((stream.tcpSettings as Record).header).toMatchObject({ type: 'none' }); + }); + it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => { const method = 'aes-256-gcm'; const password = '>>>'; diff --git a/internal/sub/shadowsocks_plugin_import_test.go b/internal/sub/shadowsocks_plugin_import_test.go new file mode 100644 index 000000000..c628fa278 --- /dev/null +++ b/internal/sub/shadowsocks_plugin_import_test.go @@ -0,0 +1,49 @@ +package sub + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/util/link" +) + +// The panel exports shadowsocks tcp/http obfuscation as the SIP002 plugin, so +// importing that same link has to rebuild the header it stands for. +func TestShadowsocksHTTPObfsSurvivesExportImport(t *testing.T) { + in := &model.Inbound{ + Id: 940001, Listen: "203.0.113.1", Port: 8388, Protocol: model.Shadowsocks, + Settings: `{"method":"aes-256-gcm","password":"serverpass","clients":[{"email":"user","password":"clientpass"}]}`, + StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"http","request":{"path":["/"],"headers":{"Host":["obfs.example.com"]}}}}}`, + } + exported := (&SubService{}).genShadowsocksLink(in, "user") + if !strings.Contains(exported, "plugin=obfs-local%3Bobfs%3Dhttp") { + t.Fatalf("export did not use the SIP002 plugin form: %q", exported) + } + + parsed, err := link.ParseLink(exported) + if err != nil { + t.Fatalf("ParseLink(%q): %v", exported, err) + } + streamJSON, err := json.Marshal(parsed.Outbound["streamSettings"]) + if err != nil { + t.Fatalf("marshal stream: %v", err) + } + var stream map[string]any + if err := json.Unmarshal(streamJSON, &stream); err != nil { + t.Fatalf("stream json: %v", err) + } + + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + if header == nil || header["type"] != "http" { + t.Fatalf("import dropped the tcp/http obfuscation: %s", streamJSON) + } + request, _ := header["request"].(map[string]any) + headers, _ := request["headers"].(map[string]any) + hosts, _ := headers["Host"].([]any) + if len(hosts) == 0 || hosts[0] != "obfs.example.com" { + t.Fatalf("import dropped the obfs host: %s", streamJSON) + } +} diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 7c5d004b6..b347f4ca5 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -406,6 +406,9 @@ func parseShadowsocks(link string) (*ParseResult, error) { method, pass = splitMethodPass(userInfo) } identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port) + // The panel and v2rayN express shadowsocks tcp/http obfuscation only as the + // SIP002 plugin, so it has to become the header it stands for. + applyObfsLocalPlugin(params, rawQuery) network := params.Get("type") if network == "" { network = "tcp" @@ -439,6 +442,54 @@ func splitMethodPass(userInfo string) (string, string) { return before, after } +// applyObfsLocalPlugin maps a SIP002 obfs-local=http plugin onto the tcp/http +// response header it stands for; the other plugin values have no Xray header. +func applyObfsLocalPlugin(p url.Values, rawQuery string) { + if p.Get("headerType") != "" || p.Get("type") == "http" { + return + } + plugin := p.Get("plugin") + if plugin == "" { + plugin = rawQueryPlugin(rawQuery) + } + parts := strings.Split(plugin, ";") + if len(parts) == 0 || parts[0] != "obfs-local" { + return + } + obfs, host := "", "" + for _, part := range parts[1:] { + if k, v, ok := strings.Cut(part, "="); ok { + switch k { + case "obfs": + obfs = v + case "obfs-host": + host = v + } + } + } + if obfs != "http" { + return + } + p.Set("type", "tcp") + p.Set("headerType", "http") + if host != "" { + p.Set("host", host) + } +} + +// rawQueryPlugin reads the plugin parameter straight out of the query string for +// the pair stdlib discards: a value holding an unencoded semicolon never parses. +func rawQueryPlugin(rawQuery string) string { + for _, segment := range strings.Split(rawQuery, "&") { + if key, value, ok := strings.Cut(segment, "="); ok && key == "plugin" { + if decoded, err := url.QueryUnescape(value); err == nil { + return decoded + } + } + } + return "" +} + // --- hysteria2 --- func parseHysteria2(link string) (*ParseResult, error) { diff --git a/internal/util/link/outbound_test.go b/internal/util/link/outbound_test.go index b60cca0ec..a6fb3c496 100644 --- a/internal/util/link/outbound_test.go +++ b/internal/util/link/outbound_test.go @@ -2,6 +2,7 @@ package link import ( "encoding/base64" + "encoding/json" "net/url" "strings" "testing" @@ -503,3 +504,46 @@ func TestSlugAndSuggest(t *testing.T) { t.Errorf("unicode suggest tag got %q", got) } } + +// The obfs-local plugin the panel exports carries the only description of +// shadowsocks tcp/http obfuscation, so it has to become that header. +func TestParseShadowsocksObfsLocalPlugin(t *testing.T) { + user := base64.RawURLEncoding.EncodeToString([]byte("aes-256-gcm:secretpass")) + const httpObfs = "obfs-local;obfs=http;obfs-host=obfs.example.com" + for _, tc := range []struct { + name, query, wantHeader, wantHost string + }{ + {"http obfs becomes the tcp header", "plugin=" + url.QueryEscape(httpObfs), "http", "obfs.example.com"}, + {"unencoded separators map the same way", "plugin=" + httpObfs, "http", "obfs.example.com"}, + {"tls obfs has no xray header", "plugin=" + url.QueryEscape("obfs-local;obfs=tls"), "none", ""}, + {"an unrelated plugin is left alone", "plugin=v2ray-plugin", "none", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + res, err := ParseLink("ss://" + user + "@1.2.3.4:8388/?" + tc.query + "#node") + if err != nil { + t.Fatalf("parse ss: %v", err) + } + raw, err := json.Marshal(res.Outbound["streamSettings"]) + if err != nil { + t.Fatalf("marshal stream: %v", err) + } + var stream map[string]any + _ = json.Unmarshal(raw, &stream) + tcp, _ := stream["tcpSettings"].(map[string]any) + header, _ := tcp["header"].(map[string]any) + if header == nil || header["type"] != tc.wantHeader { + t.Fatalf("header = %v, want type %q", header, tc.wantHeader) + } + request, _ := header["request"].(map[string]any) + headers, _ := request["headers"].(map[string]any) + hosts, _ := headers["Host"].([]any) + got := "" + if len(hosts) > 0 { + got, _ = hosts[0].(string) + } + if got != tc.wantHost { + t.Errorf("host = %q, want %q", got, tc.wantHost) + } + }) + } +}