diff --git a/frontend/src/lib/xray/outbound-link-parser.ts b/frontend/src/lib/xray/outbound-link-parser.ts index 36aa975fc..30659462e 100644 --- a/frontend/src/lib/xray/outbound-link-parser.ts +++ b/frontend/src/lib/xray/outbound-link-parser.ts @@ -543,7 +543,20 @@ export function parseVmessLink(link: string): Raw | null { xhttp.path = json.path ?? '/'; if (json.mode) xhttp.mode = json.mode; applyXhttpStringFromJson(xhttp, json); + } else if (network === 'kcp') { + applyTransportParams( + stream, + new URLSearchParams({ mtu: String(json.mtu ?? ''), tti: String(json.tti ?? '') }), + ); } + // The vmess object carries fm as-is and the mKCP header and seed as type and path. + const maskParams = new URLSearchParams(); + if (typeof json.fm === 'string') maskParams.set('fm', json.fm); + if (network === 'kcp') { + if (typeof json.type === 'string') maskParams.set('headerType', json.type); + if (typeof json.path === 'string') maskParams.set('seed', json.path); + } + applyFinalMaskParam(stream, maskParams); if (security === 'tls') { const tls = stream.tlsSettings as Raw; tls.serverName = json.sni ?? ''; diff --git a/frontend/src/test/outbound-link-parser.test.ts b/frontend/src/test/outbound-link-parser.test.ts index 3c7aeb0f9..907615035 100644 --- a/frontend/src/test/outbound-link-parser.test.ts +++ b/frontend/src/test/outbound-link-parser.test.ts @@ -305,8 +305,25 @@ 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 vmessKcp = (fields: Record) => + 'vmess://' + + Base64.encode( + JSON.stringify({ + v: '2', + add: 'h.com', + port: 443, + id: '11111111-2222-4333-8444-555555555555', + net: 'kcp', + tls: 'none', + ...fields, + }), + ); const parse = (link: string) => { - const out = link.startsWith('trojan://') ? parseTrojanLink(link) : parseVlessLink(link); + const out = link.startsWith('trojan://') + ? parseTrojanLink(link) + : link.startsWith('vmess://') + ? parseVmessLink(link) + : parseVlessLink(link); expect(out).not.toBeNull(); const stream = out!.streamSettings as Record; const kcp = stream.kcpSettings as { mtu: number; tti: number }; @@ -357,9 +374,42 @@ describe('mKCP share params', () => { { mtu: 1350, tti: 20 }, [], ], + [ + 'vmess header and seed from type and path', + vmessKcp({ type: 'wechat-video', path: 'secret-seed', mtu: 1400, tti: 50 }), + { mtu: 1400, tti: 50 }, + [ + ['mkcp-legacy', '', 'secret-seed'], + ['mkcp-legacy', 'wechat', ''], + ], + ], + [ + 'vmess fm mkcp-legacy mask wins over path', + vmessKcp({ + type: 'none', + path: 's3cr3tSeed', + mtu: 1350, + tti: 20, + fm: JSON.stringify({ + udp: [{ settings: { header: '', value: 's3cr3tSeed' }, type: 'mkcp-legacy' }], + }), + }), + { mtu: 1350, tti: 20 }, + [['mkcp-legacy', '', 's3cr3tSeed']], + ], ])('%s', (_name, link, kcp, masks) => { expect(parse(link)).toEqual({ kcp, masks }); }); + + it('keeps a vmess fm udp mask', () => { + const fm = JSON.stringify({ + udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }], + }); + const out = parseVmessLink(vmessKcp({ type: 'none', fm })); + expect((out!.streamSettings as Record).finalmask).toEqual({ + udp: [{ type: 'salamander', settings: { password: 'obfspw123' } }], + }); + }); }); describe('parseTrojanLink', () => { diff --git a/internal/util/link/outbound.go b/internal/util/link/outbound.go index 45b34f4f0..9620a28aa 100644 --- a/internal/util/link/outbound.go +++ b/internal/util/link/outbound.go @@ -205,8 +205,18 @@ func parseVmess(link string) (*ParseResult, error) { }, } } + case "kcp": + applyTransport(stream, url.Values{"mtu": {strconv.Itoa(num(j["mtu"]))}, "tti": {strconv.Itoa(num(j["tti"]))}}) } + // The vmess object carries fm as-is and the mKCP header and seed as type and path. + maskParams := url.Values{"fm": {getString(j, "fm", "")}} + if network == "kcp" { + maskParams.Set("headerType", getString(j, "type", "")) + maskParams.Set("seed", getString(j, "path", "")) + } + applyFinalMask(stream, maskParams) + if security == "tls" { tls := stream["tlsSettings"].(map[string]any) tls["serverName"] = getString(j, "sni", "") diff --git a/internal/util/link/outbound_helpers_test.go b/internal/util/link/outbound_helpers_test.go index a9d7f7f64..bc9ca7fbd 100644 --- a/internal/util/link/outbound_helpers_test.go +++ b/internal/util/link/outbound_helpers_test.go @@ -286,6 +286,11 @@ func mkcpLegacyMasks(t *testing.T, res *ParseResult) []mkcpMask { return out } +func vmessKcpLink(fields string) string { + inner := `{"v":"2","add":"h.com","port":443,"id":"11111111-2222-4333-8444-555555555555","net":"kcp","tls":"none",` + fields + `}` + return "vmess://" + base64.StdEncoding.EncodeToString([]byte(inner)) +} + 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. @@ -323,6 +328,20 @@ func TestParse_KcpShareParams(t *testing.T) { wantMTU: 1350, wantTTI: 20, }, + { + name: "vmess header and seed from type and path", + link: vmessKcpLink(`"type":"wechat-video","path":"secret-seed","mtu":1400,"tti":50`), + wantMTU: 1400, + wantTTI: 50, + wantMasks: []mkcpMask{{"", "secret-seed"}, {"wechat", ""}}, + }, + { + name: "vmess fm mkcp-legacy mask wins over path", + link: vmessKcpLink(`"type":"none","path":"s3cr3tSeed","mtu":1350,"tti":20,"fm":"{\"udp\":[{\"settings\":{\"header\":\"\",\"value\":\"s3cr3tSeed\"},\"type\":\"mkcp-legacy\"}]}"`), + wantMTU: 1350, + wantTTI: 20, + wantMasks: []mkcpMask{{"", "s3cr3tSeed"}}, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -340,3 +359,15 @@ func TestParse_KcpShareParams(t *testing.T) { }) } } + +func TestParse_VmessFinalMask(t *testing.T) { + res, err := ParseLink(vmessKcpLink(`"type":"none","fm":"{\"udp\":[{\"settings\":{\"password\":\"obfspw123\"},\"type\":\"salamander\"}]}"`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + udp := finalmaskUDP(t, res) + want := []any{map[string]any{"type": "salamander", "settings": map[string]any{"password": "obfspw123"}}} + if !reflect.DeepEqual(udp, want) { + t.Fatalf("finalmask.udp = %#v, want %#v", udp, want) + } +}