diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index bf67c4a3b..347b72369 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/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 = { + 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; - if (parsed && typeof parsed === 'object') { - sanitizeFinalMaskQuicParams(parsed); - stream.finalmask = parsed; + if (fm) { + try { + const parsed = JSON.parse(fm) as Record; + 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 { diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 0a21986a5..f14a5b6c1 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/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; + 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://11111111-2222-4333-8444-555555555555@h.com: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://pw@h.com:443?type=kcp&headerType=srtp&security=none#kcp-tj', + { mtu: 1350, tti: 20 }, + [['mkcp-legacy', 'srtp', '']], + ], + [ + 'seed only adds no header mask', + 'vless://uuid@h.com: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://uuid@h.com:443?type=kcp&mtu=10&tti=5000&security=none', + { mtu: 1350, tti: 20 }, + [], + ], + [ + 'non-decimal mtu keeps the default like the Go importer', + 'vless://uuid@h.com:443?type=kcp&mtu=1.5&tti=1e2&security=none', + { mtu: 1350, tti: 20 }, + [], + ], + [ + 'a prototype key is not a header type', + 'vless://uuid@h.com: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 = diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index b347f4ca5..21bd04209 100644 --- a/internal/util/link/outbound.go +++ b/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. diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go index d0b659da6..787f81c0c 100644 --- a/internal/util/link/outbound_helpers_test.go +++ b/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://uuid@h.com: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://pw@h.com: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://uuid@h.com: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://uuid@h.com: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) + } + }) + } +}