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 <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
mrchatam
2026-09-13 23:58:13 +03:30
committed by GitHub
parent 939c470698
commit 5ad9df69b9
4 changed files with 299 additions and 8 deletions
+74 -8
View File
@@ -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 {
@@ -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://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 =