Переглянути джерело

fix(link): restore mKCP seed and headerType on share-link import (#6480)

* fix(link): restore mKCP seed and headerType on share-link import

applyTransport / applyTransportParams ignored kcp query params that
applyKcpShareParams emits, so re-imported outbounds lost seed and
header and could not talk to the inbound. Mirror those fields (plus
mtu/tti) into kcpSettings in both Go and TS importers.

Fixes #6476

* fix(link): restore mKCP header/seed via finalmask mkcp-legacy

* fix(link): split mKCP header and seed into separate masks on import

Both importers folded a share link's headerType and seed into one
mkcp-legacy mask {header, value}. xray-core's MkcpLegacy.Build ignores
value once header is set (and reads it as the fake DNS domain for
header=dns), so an imported outbound carried the header mask but no
AES-128-GCM seed while the emitting inbound has both, and could not
connect — the failure #6476 reports, now for every link carrying both
params. Emit one mask per field, seed first: the finalmask array's
first item is the innermost layer, which puts the header around the
cipher as legacy mKCP did.

Also bound mtu/tti to KCPConfig.Build's accepted ranges (mtu >= 21,
tti 10..1000, decimal digits only on both importers) so a pasted link
cannot fail the whole Xray config load, and look header types up as
own properties so a prototype key such as "constructor" is not mapped.

---------

Co-authored-by: mrchatam <[email protected]>
Co-authored-by: Sanaei <[email protected]>
mrchatam 2 днів тому
батько
коміт
5ad9df69b9

+ 74 - 8
frontend/src/lib/xray/outbound-link-parser.ts

@@ -219,6 +219,15 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void {
       applyXhttpStringFromParams(xhttp, params);
       break;
     }
+    case 'kcp': {
+      // mtu/tti on kcpSettings; header/seed via applyMkcpLegacyFromShare.
+      const kcp = stream.kcpSettings as Raw;
+      const mtu = kcpParamInRange(params.get('mtu'), KCP_MIN_MTU, KCP_MAX_MTU);
+      if (mtu !== null) kcp.mtu = mtu;
+      const tti = kcpParamInRange(params.get('tti'), KCP_MIN_TTI, KCP_MAX_TTI);
+      if (tti !== null) kcp.tti = tti;
+      break;
+    }
     case 'tcp':
       // vless/trojan TCP HTTP camouflage rides on header=http+host+path
       if (params.get('headerType') === 'http' || params.get('type') === 'http') {
@@ -236,21 +245,78 @@ function applyTransportParams(stream: Raw, params: URLSearchParams): void {
   }
 }
 
+// mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
+// the whole config load); mtu's ceiling is the int32 that fits its uint32 field.
+const KCP_MIN_MTU = 21;
+const KCP_MAX_MTU = 0x7fffffff;
+const KCP_MIN_TTI = 10;
+const KCP_MAX_TTI = 1000;
+
+// Decimal digits only, like the Go importer's strconv.Atoi; anything else keeps
+// buildStream's default.
+function kcpParamInRange(raw: string | null, min: number, max: number): number | null {
+  if (raw === null || !/^\d+$/.test(raw)) return null;
+  const n = Number(raw);
+  return Number.isSafeInteger(n) && n >= min && n <= max ? n : null;
+}
+
+const kcpHeaderTypeToMask: Record<string, string> = {
+  dns: 'dns',
+  dtls: 'dtls',
+  srtp: 'srtp',
+  utp: 'utp',
+  'wechat-video': 'wechat',
+  wireguard: 'wireguard',
+};
+
 // The inbound link emits the entire finalmask object as a JSON-encoded
 // `fm` query param. Decode and attach to streamSettings so udpHop /
 // quicParams / tcp+udp masks round-trip on outbound import.
 function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
   const fm = params.get('fm');
-  if (!fm) return;
-  try {
-    const parsed = JSON.parse(fm) as Record<string, unknown>;
-    if (parsed && typeof parsed === 'object') {
-      sanitizeFinalMaskQuicParams(parsed);
-      stream.finalmask = parsed;
+  if (fm) {
+    try {
+      const parsed = JSON.parse(fm) as Record<string, unknown>;
+      if (parsed && typeof parsed === 'object') {
+        sanitizeFinalMaskQuicParams(parsed);
+        stream.finalmask = parsed;
+      }
+    } catch {
+      // malformed fm — leave streamSettings.finalmask absent
     }
-  } catch {
-    // malformed fm — leave streamSettings.finalmask absent
   }
+  applyMkcpLegacyFromShare(stream, params);
+}
+
+/** Restore headerType/seed into finalmask.udp mkcp-legacy; fm= mkcp-legacy wins. */
+function applyMkcpLegacyFromShare(stream: Raw, params: URLSearchParams): void {
+  let headerType = (params.get('headerType') ?? '').trim();
+  const seed = params.get('seed') ?? '';
+  if (headerType === 'none') headerType = '';
+  if (!headerType && !seed) return;
+  const network = stream.network;
+  if (typeof network === 'string' && network && network !== 'kcp') return;
+
+  let maskHeader = '';
+  if (headerType) {
+    if (!Object.hasOwn(kcpHeaderTypeToMask, headerType)) return;
+    maskHeader = kcpHeaderTypeToMask[headerType];
+  }
+
+  const finalmask = (stream.finalmask as Raw) ?? {};
+  const udp = Array.isArray(finalmask.udp) ? [...(finalmask.udp as unknown[])] : [];
+  if (udp.some((m) => (m as Raw)?.type === 'mkcp-legacy')) return;
+
+  // One mask per field, seed first: MkcpLegacy.Build ignores value once header is
+  // set, and the chain puts the last mask outermost on the wire (header around cipher).
+  if (seed) udp.push(mkcpLegacyMask('', seed));
+  if (maskHeader) udp.push(mkcpLegacyMask(maskHeader, ''));
+  finalmask.udp = udp;
+  stream.finalmask = finalmask;
+}
+
+function mkcpLegacyMask(header: string, value: string): Raw {
+  return { type: 'mkcp-legacy', settings: { header, value } };
 }
 
 function ensureFinalMask(stream: Raw): Raw {

+ 61 - 0
frontend/src/test/outbound-link-parser.test.ts

@@ -266,6 +266,67 @@ describe('parseVlessLink', () => {
   });
 });
 
+describe('mKCP share params', () => {
+  // The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
+  // mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
+  type Mask = { type: string; settings: { header: string; value: string } };
+  const parse = (link: string) => {
+    const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link);
+    expect(out).not.toBeNull();
+    const stream = out!.streamSettings as Record<string, unknown>;
+    const kcp = stream.kcpSettings as { mtu: number; tti: number };
+    const udp = (stream.finalmask as { udp?: Mask[] } | undefined)?.udp ?? [];
+    return {
+      kcp: { mtu: kcp.mtu, tti: kcp.tti },
+      masks: udp.map((m) => [m.type, m.settings.header, m.settings.value]),
+    };
+  };
+
+  it.each([
+    [
+      'vless header and seed become two masks, seed first',
+      'vless://[email protected]:443?type=kcp&headerType=wechat-video&seed=secret-seed&mtu=1400&tti=50&security=none#kcp1',
+      { mtu: 1400, tti: 50 },
+      [
+        ['mkcp-legacy', '', 'secret-seed'],
+        ['mkcp-legacy', 'wechat', ''],
+      ],
+    ],
+    [
+      'trojan header only adds no seed mask',
+      'trojan://[email protected]:443?type=kcp&headerType=srtp&security=none#kcp-tj',
+      { mtu: 1350, tti: 20 },
+      [['mkcp-legacy', 'srtp', '']],
+    ],
+    [
+      'seed only adds no header mask',
+      'vless://[email protected]:443?type=kcp&headerType=none&seed=abc123&security=none',
+      { mtu: 1350, tti: 20 },
+      [['mkcp-legacy', '', 'abc123']],
+    ],
+    [
+      'mtu/tti outside KCPConfig.Build bounds keep the defaults',
+      'vless://[email protected]:443?type=kcp&mtu=10&tti=5000&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+    [
+      'non-decimal mtu keeps the default like the Go importer',
+      'vless://[email protected]:443?type=kcp&mtu=1.5&tti=1e2&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+    [
+      'a prototype key is not a header type',
+      'vless://[email protected]:443?type=kcp&headerType=constructor&seed=abc&security=none',
+      { mtu: 1350, tti: 20 },
+      [],
+    ],
+  ])('%s', (_name, link, kcp, masks) => {
+    expect(parse(link)).toEqual({ kcp, masks });
+  });
+});
+
 describe('parseTrojanLink', () => {
   it('parses a trojan:// link with ws + tls', () => {
     const link =

+ 90 - 0
internal/util/link/outbound.go

@@ -704,6 +704,15 @@ func applyTransport(stream map[string]any, p url.Values) {
 				xh[k] = v
 			}
 		}
+	case "kcp":
+		// mtu/tti live on kcpSettings; header/seed are finalmask mkcp-legacy (see applyMkcpLegacyFromShare).
+		kcp := stream["kcpSettings"].(map[string]any)
+		if n, ok := kcpParamInRange(p.Get("mtu"), kcpMinMTU, kcpMaxMTU); ok {
+			kcp["mtu"] = n
+		}
+		if n, ok := kcpParamInRange(p.Get("tti"), kcpMinTTI, kcpMaxTTI); ok {
+			kcp["tti"] = n
+		}
 	case "tcp":
 		if p.Get("headerType") == "http" || p.Get("type") == "http" {
 			stream["tcpSettings"] = map[string]any{
@@ -753,6 +762,87 @@ func applyFinalMask(stream map[string]any, p url.Values) {
 			stream["finalmask"] = parsed
 		}
 	}
+	applyMkcpLegacyFromShare(stream, p)
+}
+
+// mKCP bounds mirror xray-core's KCPConfig.Build checks (a value outside them fails
+// the whole config load); mtu's ceiling is the int32 that fits its uint32 field everywhere.
+const (
+	kcpMinMTU = 21
+	kcpMaxMTU = math.MaxInt32
+	kcpMinTTI = 10
+	kcpMaxTTI = 1000
+)
+
+// kcpParamInRange rejects an out-of-range or malformed link value so buildStream's default stays.
+func kcpParamInRange(s string, minVal, maxVal int) (int, bool) {
+	n, err := strconv.Atoi(s)
+	return n, err == nil && n >= minVal && n <= maxVal
+}
+
+// kcpHeaderTypeToMask maps share-link headerType to mkcp-legacy settings.header
+// (inverse of sub.kcpMaskToHeaderType).
+var kcpHeaderTypeToMask = map[string]string{
+	"dns":          "dns",
+	"dtls":         "dtls",
+	"srtp":         "srtp",
+	"utp":          "utp",
+	"wechat-video": "wechat",
+	"wireguard":    "wireguard",
+}
+
+// applyMkcpLegacyFromShare restores headerType/seed into finalmask.udp mkcp-legacy,
+// matching the shape InboundFormModal / FinalMaskForm emit. fm= mkcp-legacy wins.
+func applyMkcpLegacyFromShare(stream map[string]any, p url.Values) {
+	headerType := strings.TrimSpace(p.Get("headerType"))
+	seed := p.Get("seed")
+	if headerType == "" || headerType == "none" {
+		headerType = ""
+	}
+	if headerType == "" && seed == "" {
+		return
+	}
+	if network, _ := stream["network"].(string); network != "" && network != "kcp" {
+		return
+	}
+	maskHeader := ""
+	if headerType != "" {
+		mapped, ok := kcpHeaderTypeToMask[headerType]
+		if !ok {
+			return
+		}
+		maskHeader = mapped
+	}
+	finalmask, _ := stream["finalmask"].(map[string]any)
+	if finalmask == nil {
+		finalmask = map[string]any{}
+	}
+	udp, _ := finalmask["udp"].([]any)
+	for _, raw := range udp {
+		m, _ := raw.(map[string]any)
+		if m != nil {
+			if t, _ := m["type"].(string); t == "mkcp-legacy" {
+				return // fm= (or prior) already carries the live mask
+			}
+		}
+	}
+	// One mask per field, seed first: MkcpLegacy.Build ignores value once header is set,
+	// and the chain puts the last mask outermost on the wire (header around the cipher).
+	if seed != "" {
+		udp = append(udp, mkcpLegacyMask("", seed))
+	}
+	if maskHeader != "" {
+		udp = append(udp, mkcpLegacyMask(maskHeader, ""))
+	}
+	finalmask["udp"] = udp
+	stream["finalmask"] = finalmask
+}
+
+func mkcpLegacyMask(header, value string) map[string]any {
+	return map[string]any{
+		"type":     "mkcp-legacy",
+		"settings": map[string]any{"header": header, "value": value},
+	}
 }
 
 // gecko packetSize bounds mirror xray-core's salamander buffer cap.

+ 74 - 0
internal/util/link/outbound_helpers_test.go

@@ -4,6 +4,7 @@ import (
 	"encoding/base64"
 	"net/url"
 	"reflect"
+	"slices"
 	"testing"
 )
 
@@ -244,3 +245,76 @@ func TestParseTrojanAndSS_CoreFields(t *testing.T) {
 		t.Errorf("ss server = %#v", ssrv)
 	}
 }
+
+type mkcpMask struct{ header, value string }
+
+func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask {
+	t.Helper()
+	var out []mkcpMask
+	for _, raw := range finalmaskUDP(t, res) {
+		mask, _ := raw.(map[string]any)
+		if mask["type"] != "mkcp-legacy" {
+			t.Fatalf("unexpected udp mask %#v", mask)
+		}
+		settings, _ := mask["settings"].(map[string]any)
+		header, _ := settings["header"].(string)
+		value, _ := settings["value"].(string)
+		out = append(out, mkcpMask{header, value})
+	}
+	return out
+}
+
+func TestParse_KcpShareParams(t *testing.T) {
+	// The emitter flattens one mkcp-legacy mask per field into headerType/seed; a merged
+	// mask drops the seed in xray-core (MkcpLegacy.Build), so import rebuilds them separately.
+	cases := []struct {
+		name      string
+		link      string
+		wantMTU   int
+		wantTTI   int
+		wantMasks []mkcpMask
+	}{
+		{
+			name:      "vless header and seed become two masks, seed first",
+			link:      "vless://[email protected]:443?type=kcp&headerType=wechat-video&seed=secret-seed&mtu=1400&tti=50&security=none#kcp1",
+			wantMTU:   1400,
+			wantTTI:   50,
+			wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}},
+		},
+		{
+			name:      "trojan header only adds no seed mask",
+			link:      "trojan://[email protected]:443?type=kcp&headerType=srtp&security=none#kcp-tj",
+			wantMTU:   1350,
+			wantTTI:   20,
+			wantMasks: []mkcpMask{{"srtp", ""}},
+		},
+		{
+			name:      "seed only adds no header mask",
+			link:      "vless://[email protected]:443?type=kcp&headerType=none&seed=abc123&security=none",
+			wantMTU:   1350,
+			wantTTI:   20,
+			wantMasks: []mkcpMask{{"", "abc123"}},
+		},
+		{
+			name:    "mtu/tti outside KCPConfig.Build bounds keep the defaults",
+			link:    "vless://[email protected]:443?type=kcp&mtu=10&tti=5000&security=none",
+			wantMTU: 1350,
+			wantTTI: 20,
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			res, err := ParseLink(c.link)
+			if err != nil {
+				t.Fatalf("parse: %v", err)
+			}
+			kcp := streamSub(t, res, "kcpSettings")
+			if kcp["mtu"] != c.wantMTU || kcp["tti"] != c.wantTTI {
+				t.Fatalf("kcpSettings mtu/tti = %v/%v, want %d/%d", kcp["mtu"], kcp["tti"], c.wantMTU, c.wantTTI)
+			}
+			if got := mkcpLegacyMasks(t, res); !slices.Equal(got, c.wantMasks) {
+				t.Fatalf("mkcp-legacy masks = %v, want %v", got, c.wantMasks)
+			}
+		})
+	}
+}