Skip to content

Commit 0647782

Browse files
committed
fix: parse Shadowrocket VLESS TLS and ECH metadata
1 parent 6a3557e commit 0647782

5 files changed

Lines changed: 268 additions & 10 deletions

File tree

packages/core/src/mihomo/ech.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ describe("Mihomo ECH helpers", () => {
1414
enable: true,
1515
"query-server-name": "cloudflare-ech.com",
1616
});
17+
expect(
18+
buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://203.0.113.53/dns-query")
19+
).toEqual({
20+
enable: true,
21+
"query-server-name": "cloudflare-ech.com",
22+
});
23+
expect(buildMihomoEchOptsFromShareValue("ech.example.com+udp://203.0.113.53:53")).toEqual({
24+
enable: true,
25+
"query-server-name": "ech.example.com",
26+
});
1727
expect(buildMihomoEchOptsFromShareValue("not base64!")).toEqual({ enable: true });
1828
});
1929

@@ -27,4 +37,27 @@ describe("Mihomo ECH helpers", () => {
2737
expect(isMihomoEchQueryServerName(`bad_label.${"a".repeat(64)}.example.com`)).toBe(false);
2838
expect(isMihomoEchQueryServerName(`${"a".repeat(100_000)}.example.com`)).toBe(false);
2939
});
40+
41+
it("rejects malformed compound values without promoting their prefix", () => {
42+
const enableOnly = { enable: true };
43+
44+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+not-a-uri")).toEqual(enableOnly);
45+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://")).toEqual(enableOnly);
46+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https:///dns-query")).toEqual(enableOnly);
47+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://:")).toEqual(enableOnly);
48+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://@")).toEqual(enableOnly);
49+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://[bad")).toEqual(enableOnly);
50+
expect(buildMihomoEchOptsFromShareValue("cloudflare-ech.com+https://203.0.113.53/dns query")).toEqual(
51+
enableOnly
52+
);
53+
expect(buildMihomoEchOptsFromShareValue("single-label+https://203.0.113.53/dns-query")).toEqual(enableOnly);
54+
expect(buildMihomoEchOptsFromShareValue("203.0.113.53+https://203.0.113.53/dns-query")).toEqual(enableOnly);
55+
expect(buildMihomoEchOptsFromShareValue("bad_name.example.com+https://203.0.113.53/dns-query")).toEqual(
56+
enableOnly
57+
);
58+
expect(buildMihomoEchOptsFromShareValue("dGVzdA")).toEqual(enableOnly);
59+
expect(
60+
buildMihomoEchOptsFromShareValue(`cloudflare-ech.com+${"a".repeat(100_000)}`)
61+
).toEqual(enableOnly);
62+
});
3063
});

packages/core/src/mihomo/ech.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,55 @@ export function isMihomoEchQueryServerName(value: string): boolean {
2020
return hostname.split(".").every((label) => label.length <= 63 && DNS_LABEL_PATTERN.test(label));
2121
}
2222

23+
function hasAbsoluteResolverUri(value: string): boolean {
24+
const schemeEnd = value.indexOf("://");
25+
if (schemeEnd <= 0) return false;
26+
27+
const firstCode = value.charCodeAt(0);
28+
const firstIsAlpha =
29+
(firstCode >= 65 && firstCode <= 90) || (firstCode >= 97 && firstCode <= 122);
30+
if (!firstIsAlpha) return false;
31+
32+
for (let index = 1; index < schemeEnd; index += 1) {
33+
const code = value.charCodeAt(index);
34+
const isAlpha = (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
35+
const isDigit = code >= 48 && code <= 57;
36+
if (!isAlpha && !isDigit && code !== 43 && code !== 45 && code !== 46) return false;
37+
}
38+
39+
const authorityStart = schemeEnd + 3;
40+
if (authorityStart >= value.length) return false;
41+
let authorityEnd = value.length;
42+
for (let index = authorityStart; index < value.length; index += 1) {
43+
const code = value.charCodeAt(index);
44+
if (code <= 32 || code === 127) return false;
45+
if (authorityEnd === value.length && (code === 47 || code === 63 || code === 35)) {
46+
authorityEnd = index;
47+
}
48+
}
49+
if (authorityEnd <= authorityStart) return false;
50+
51+
try {
52+
return Boolean(new URL(`https://${value.slice(authorityStart)}`).hostname);
53+
} catch {
54+
return false;
55+
}
56+
}
57+
2358
export function buildMihomoEchOptsFromShareValue(value: unknown): MihomoEchOpts {
2459
const normalized = typeof value === "string" ? value.trim() : "";
2560
if (!normalized) return { enable: true };
2661
if (isStandardBase64String(normalized)) return { enable: true, config: normalized };
2762
if (isMihomoEchQueryServerName(normalized)) {
2863
return { enable: true, "query-server-name": normalized };
2964
}
65+
const separatorIndex = normalized.indexOf("+");
66+
if (separatorIndex > 0) {
67+
const queryServerName = normalized.slice(0, separatorIndex).trim();
68+
const resolverUri = normalized.slice(separatorIndex + 1).trim();
69+
if (isMihomoEchQueryServerName(queryServerName) && hasAbsoluteResolverUri(resolverUri)) {
70+
return { enable: true, "query-server-name": queryServerName };
71+
}
72+
}
3073
return { enable: true };
3174
}

packages/core/src/parser/protocols/ech-protocols.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const protocolCases: Array<[string, (value: string) => ParsedNode]> = [
4545
];
4646

4747
describe("ECH share-link protocol contracts", () => {
48-
it.each(protocolCases)("classifies %s ECH values and preserves the DNS name in generated YAML", (_protocol, parse) => {
48+
it.each(protocolCases)("classifies %s ECH values and preserves query names in generated YAML", (_protocol, parse) => {
4949
const domainNode = parse("cloudflare-ech.com");
5050
expect(domainNode["ech-opts"]).toEqual({
5151
enable: true,
@@ -60,6 +60,20 @@ describe("ECH share-link protocol contracts", () => {
6060
} as unknown as ClashConfig);
6161
expect(generated).toContain("ech-opts: {enable: true, query-server-name: cloudflare-ech.com}");
6262

63+
const compoundNode = parse("cloudflare-ech.com+https://203.0.113.53/dns-query");
64+
expect(compoundNode["ech-opts"]).toEqual({
65+
enable: true,
66+
"query-server-name": "cloudflare-ech.com",
67+
});
68+
const compoundGenerated = configToYaml({
69+
proxies: [compoundNode],
70+
"proxy-groups": [],
71+
"rule-providers": {},
72+
rules: [],
73+
} as unknown as ClashConfig);
74+
expect(compoundGenerated).toContain("ech-opts: {enable: true, query-server-name: cloudflare-ech.com}");
75+
expect(compoundGenerated).not.toContain("config: cloudflare-ech.com+");
76+
6377
expect(parse("+w==")["ech-opts"]).toEqual({ enable: true, config: "+w==" });
6478
expect(parse("")["ech-opts"]).toEqual({ enable: true });
6579
expect(parse("not base64!")["ech-opts"]).toEqual({ enable: true });

packages/core/src/parser/protocols/vless.test.ts

Lines changed: 128 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { describe, expect, it } from "vitest";
2+
import { configToYaml } from "../../generator/yaml";
3+
import type { ClashConfig } from "../../types/config";
24
import { parseVLESS } from "./vless";
35

46
const UUID = "11111111-1111-4111-8111-111111111111";
@@ -95,7 +97,7 @@ describe("parseVLESS", () => {
9597
});
9698
});
9799

98-
it("parses Shadowrocket-style encoded authority and Reality defaults", () => {
100+
it("parses Shadowrocket-style encoded authority and ordinary TLS aliases", () => {
99101
const encoded = Buffer.from(`prefix:${UUID}@shadowrocket.example.com:443`).toString("base64url");
100102
const node = parseVLESS(
101103
`vless://${encoded}?obfs=websocket&tls=1&xtls=2&obfsParam=cdn.example.com&path=/ws%3Fed%3D512#Shadowrocket`
@@ -108,7 +110,6 @@ describe("parseVLESS", () => {
108110
uuid: UUID,
109111
tls: true,
110112
flow: "xtls-rprx-vision",
111-
"client-fingerprint": "chrome",
112113
network: "ws",
113114
"ws-opts": {
114115
path: "/ws",
@@ -117,6 +118,8 @@ describe("parseVLESS", () => {
117118
"max-early-data": 512,
118119
},
119120
});
121+
expect(node).not.toHaveProperty("reality-opts");
122+
expect(node).not.toHaveProperty("client-fingerprint");
120123

121124
const encodedWithoutPrefix = Buffer.from(`${UUID}@shadow-simple.example.com:443`).toString("base64url");
122125
expect(parseVLESS(`vless://${encodedWithoutPrefix}?obfs=websocket&tls=1&xtls=1&tls-verification=false&remark=SimpleSR`)).toMatchObject({
@@ -129,6 +132,129 @@ describe("parseVLESS", () => {
129132
});
130133
});
131134

135+
it("parses Shadowrocket ordinary TLS with compound ECH through generated YAML", () => {
136+
const encoded = Buffer.from(`:${UUID}@vless-ech.example.com:443`).toString("base64url");
137+
const ech = encodeURIComponent("cloudflare-ech.com+https://203.0.113.53/dns-query");
138+
const obfsParam = encodeURIComponent(
139+
JSON.stringify({
140+
"": {
141+
xPaddingHeader: "X-Request-Signature",
142+
xPaddingPlacement: "queryInHeader",
143+
xPaddingObfsMode: true,
144+
xPaddingKey: "_tk",
145+
xPaddingMethod: "tokenish",
146+
},
147+
hOsT: "cdn.example.com",
148+
"X-Test": "yes",
149+
Numeric: 1,
150+
Enabled: true,
151+
})
152+
);
153+
const node = parseVLESS(
154+
`vless://${encoded}?obfs=xhttp&tls=1&peer=front.example.com&path=%2Fxhttp&obfsParam=${obfsParam}&ech=${ech}#ShadowrocketECH`
155+
);
156+
157+
expect(node).toMatchObject({
158+
name: "ShadowrocketECH",
159+
type: "vless",
160+
server: "vless-ech.example.com",
161+
port: 443,
162+
uuid: UUID,
163+
tls: true,
164+
servername: "front.example.com",
165+
network: "xhttp",
166+
"xhttp-opts": {
167+
path: "/xhttp",
168+
host: "cdn.example.com",
169+
headers: {
170+
"X-Test": "yes",
171+
},
172+
},
173+
"ech-opts": {
174+
enable: true,
175+
"query-server-name": "cloudflare-ech.com",
176+
},
177+
});
178+
expect(node).not.toHaveProperty("reality-opts");
179+
expect(Object.prototype.hasOwnProperty.call(node["xhttp-opts"] || {}, "")).toBe(false);
180+
expect(node["xhttp-opts"]?.headers).not.toHaveProperty("Numeric");
181+
expect(node["xhttp-opts"]?.headers).not.toHaveProperty("Enabled");
182+
183+
const generated = configToYaml({
184+
proxies: [node],
185+
"proxy-groups": [],
186+
"rule-providers": {},
187+
rules: [],
188+
} as unknown as ClashConfig);
189+
expect(generated).toContain("ech-opts: {enable: true, query-server-name: cloudflare-ech.com}");
190+
expect(generated).not.toContain("config: cloudflare-ech.com+");
191+
expect(generated).not.toContain("reality-opts");
192+
expect(generated).not.toContain("xPaddingHeader");
193+
expect(generated).not.toContain("Numeric");
194+
});
195+
196+
it("keeps explicit xHTTP host and headers ahead of Shadowrocket obfsParam fallbacks", () => {
197+
const encoded = Buffer.from(`${UUID}@xhttp-precedence.example.com:443`).toString("base64url");
198+
const obfsParam = encodeURIComponent(
199+
JSON.stringify({ Host: "fallback.example.com", "X-Test": "fallback", "X-Fallback": "yes" })
200+
);
201+
const explicitHeaders = encodeURIComponent(JSON.stringify({ "X-Test": "explicit", "X-Explicit": "yes" }));
202+
203+
expect(
204+
parseVLESS(
205+
`vless://${encoded}?obfs=xhttp&tls=1&xhttpHost=explicit.example.com&xhttpHeaders=${explicitHeaders}&obfsParam=${obfsParam}#XHTTPPrecedence`
206+
)
207+
).toMatchObject({
208+
"xhttp-opts": {
209+
host: "explicit.example.com",
210+
headers: {
211+
"X-Test": "explicit",
212+
"X-Fallback": "yes",
213+
"X-Explicit": "yes",
214+
},
215+
},
216+
});
217+
218+
expect(
219+
parseVLESS(`vless://${encoded}?obfs=xhttp&tls=1&obfsParam=legacy.example.com#XHTTPLegacy`)
220+
).toMatchObject({
221+
"xhttp-opts": {
222+
host: "legacy.example.com",
223+
},
224+
});
225+
});
226+
227+
it("infers implicit Shadowrocket Reality only from a public key", () => {
228+
const encoded = Buffer.from(`${UUID}@shadow-reality.example.com:443`).toString("base64url");
229+
const publicKey = "A".repeat(43);
230+
231+
expect(parseVLESS(`vless://${encoded}?tls=1&pbk=${publicKey}#ImplicitReality`)).toMatchObject({
232+
name: "ImplicitReality",
233+
tls: true,
234+
"client-fingerprint": "chrome",
235+
"reality-opts": {
236+
"public-key": publicKey,
237+
},
238+
});
239+
expect(() =>
240+
parseVLESS(
241+
`vless://${encoded}?tls=1&pbk=${publicKey}&ech=${encodeURIComponent("cloudflare-ech.com")}#BadRealityECH`
242+
)
243+
).toThrow("VLESS 启用 ECH 需要 security=tls");
244+
245+
const explicitTls = parseVLESS(
246+
`vless://${encoded}?security=tls&tls=1&pbk=${publicKey}&ech=${encodeURIComponent("cloudflare-ech.com")}#ExplicitTLS`
247+
);
248+
expect(explicitTls).toMatchObject({
249+
tls: true,
250+
"ech-opts": {
251+
enable: true,
252+
"query-server-name": "cloudflare-ech.com",
253+
},
254+
});
255+
expect(explicitTls).not.toHaveProperty("reality-opts");
256+
});
257+
132258
it("parses TCP, H2, HTTP Upgrade, and Reality detail variants", () => {
133259
const publicKey = "A".repeat(43);
134260

packages/core/src/parser/protocols/vless.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,50 @@ function parseHeaderMap(raw: string): Record<string, string> | undefined {
6262
return Object.keys(out).length > 0 ? out : undefined;
6363
}
6464

65+
function parseShadowrocketXhttpObfsParam(raw: string): {
66+
host?: string;
67+
headers?: Record<string, string>;
68+
} {
69+
const value = raw.trim();
70+
if (!value) return {};
71+
72+
const parsed = parseJsonObject(value);
73+
if (!parsed) return { host: value };
74+
75+
let host: string | undefined;
76+
const headers: Record<string, string> = {};
77+
for (const [rawKey, rawValue] of Object.entries(parsed)) {
78+
const key = rawKey.trim();
79+
if (!key || typeof rawValue !== "string") continue;
80+
if (key.toLowerCase() === "host") {
81+
const candidate = rawValue.trim();
82+
if (!host && candidate) host = candidate;
83+
continue;
84+
}
85+
headers[key] = rawValue;
86+
}
87+
88+
return {
89+
...(host ? { host } : {}),
90+
...(Object.keys(headers).length > 0 ? { headers } : {}),
91+
};
92+
}
93+
6594
function buildXhttpOptsFromQuery(params: URLSearchParams, fallbackPath: string, fallbackHost: string): XHttpOpts {
6695
const path = pickQueryValue(params, ["xhttp-path", "xhttp_path", "xhttpPath", "path"]) || fallbackPath || "/";
67-
const host = pickQueryValue(params, ["xhttp-host", "xhttp_host", "xhttpHost", "host"]) || fallbackHost;
96+
const obfsParam = parseShadowrocketXhttpObfsParam(params.get("obfsParam") || "");
97+
const host =
98+
pickQueryValue(params, ["xhttp-host", "xhttp_host", "xhttpHost", "host"]) ||
99+
obfsParam.host ||
100+
fallbackHost;
68101
const mode = pickQueryValue(params, ["xhttp-mode", "xhttp_mode", "xhttpMode", "mode"]);
69-
const headers = parseHeaderMap(
102+
const explicitHeaders = parseHeaderMap(
70103
pickQueryValue(params, ["xhttp-headers", "xhttp_headers", "xhttpHeaders", "headers"])
71104
);
105+
const headers =
106+
obfsParam.headers || explicitHeaders
107+
? { ...(obfsParam.headers || {}), ...(explicitHeaders || {}) }
108+
: undefined;
72109
const noGrpcHeader = parseBoolish(
73110
pickQueryValue(params, ["no-grpc-header", "no_grpc_header", "noGrpcHeader"])
74111
);
@@ -225,7 +262,11 @@ export function parseVLESS(uri: string): VLESSNode {
225262
if (raw === "tcp" && headerType === "http") return "http";
226263
return raw;
227264
})();
228-
const security = (params.get("security") || (normalized.isShadowrocket && parseBoolish(params.get("tls")) ? "reality" : "none")).trim().toLowerCase();
265+
const pbk = pickQueryValue(params, ["pbk", "public-key", "public_key", "publicKey"]);
266+
const explicitSecurity = (params.get("security") || "").trim().toLowerCase();
267+
const security =
268+
explicitSecurity ||
269+
(pbk ? "reality" : normalized.isShadowrocket && parseBoolish(params.get("tls")) ? "tls" : "none");
229270
const flow = (() => {
230271
const direct = params.get("flow") || "";
231272
if (direct.trim()) return direct.trim();
@@ -242,7 +283,6 @@ export function parseVLESS(uri: string): VLESSNode {
242283
const encryption = (params.get("encryption") || params.get("flow-encryption") || "").trim();
243284
const packetEncoding =
244285
(params.get("packet-encoding") || params.get("packet_encoding") || params.get("packetEncoding") || "").trim();
245-
const pbk = pickQueryValue(params, ["pbk", "public-key", "public_key", "publicKey"]);
246286
const sid = pickQueryValue(params, ["sid", "short-id", "short_id", "shortId"]);
247287
const spiderX = params.get("spx") || "";
248288
const echRaw = params.get("ech");
@@ -365,7 +405,11 @@ export function parseVLESS(uri: string): VLESSNode {
365405
break;
366406
case "xhttp":
367407
node.network = "xhttp";
368-
node["xhttp-opts"] = buildXhttpOptsFromQuery(params, path || "/", host);
408+
node["xhttp-opts"] = buildXhttpOptsFromQuery(
409+
params,
410+
path || "/",
411+
params.get("obfs-host") || ""
412+
);
369413
break;
370414
case "tcp":
371415
default:
@@ -375,5 +419,3 @@ export function parseVLESS(uri: string): VLESSNode {
375419

376420
return node;
377421
}
378-
379-

0 commit comments

Comments
 (0)