Procházet zdrojové kódy

fix(link): rebuild shadowsocks tcp/http obfuscation on import (#6505)

* fix(link): rebuild shadowsocks tcp/http obfuscation on import

genShadowsocksLink encodes tcp/http obfuscation only as the SIP002
plugin=obfs-local;obfs=http;obfs-host=... parameter, deleting type, headerType,
path and host in the process, because SIP002 clients ignore those and read
`plugin` alone. ParseLink read none of them, so importing a link the panel had
just exported produced a plain tcp outbound with header.type none: the
obfuscation the inbound requires was gone, and the client could not connect to
the very inbound the link came from.

The plugin is now mapped back onto the header it stands for. Credentials and
every other parameter are untouched, and other plugin values are left as they
were because Xray has no equivalent for them.

* fix(link): map the SIP002 plugin in both importers

The panel parses share links twice: link.ParseLink in Go, which the external
subscriptions use, and parseShadowsocksLink in outbound-link-parser.ts, which
the Add Outbound button calls. Mapping the plugin in Go alone left the UI path
still saving header.type none for a link the panel had exported itself, so one
panel answered the same link with two different outbounds.

The unencoded plugin=obfs-local;obfs=http;... form maps as well now: stdlib
drops any query pair whose value holds a literal semicolon, and that is the
shape clients which skip percent-encoding emit, so the raw query is read as a
fallback when the parsed parameter is missing.
BlindMaster24 před 7 hodinami
rodič
revize
8fc4fc0bf8

+ 22 - 0
frontend/src/lib/xray/outbound-link-parser.ts

@@ -388,6 +388,27 @@ function sanitizeFinalMaskQuicParams(parsed: Record<string, unknown>): 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);

+ 21 - 0
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<string, unknown>;
+    expect((stream.tcpSettings as Record<string, unknown>).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<string, unknown>;
+    expect((stream.tcpSettings as Record<string, unknown>).header).toMatchObject({ type: 'none' });
+  });
+
   it('decodes URL-safe base64 userinfo (as the emitter writes it)', () => {
     const method = 'aes-256-gcm';
     const password = '>>>';

+ 49 - 0
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)
+	}
+}

+ 51 - 0
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) {

+ 44 - 0
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)
+			}
+		})
+	}
+}