mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
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:
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user