Skip to content

Commit 7be333d

Browse files
committed
feat: 支持 VLESS ECH 配置在 URI 与 Mihomo 间互转(含 nested xhttp 与告警)
- 新增 `backend/src/core/proxy-utils/ech-utils.js`,统一提取与校验 ECH 配置逻辑:支持 `echConfigList` / `echForceQuery`,并新增 Xray 与 Mihomo `ech-opts` 的双向转换能力。 - `backend/src/core/proxy-utils/parsers/index.js`: - 在 VLESS TLS 字段解析中新增 `echConfigList`、`echForceQuery`、`echSockopt` 的兼容性校验; - 解析 URI `ech` 参数时映射为 `ech-opts` sidecar; - `downloadSettings.tlsSettings` 的导出改为复用统一 ECH 构建逻辑。 - `backend/src/core/proxy-utils/producers/uri.js`: - URI 输出时从 `ech-opts` 回填 `ech` 参数; - 支持 `ech-opts._dns` 与 `query-server-name` 的拼装(含默认 `https://dns.alidns.com/dns-query` 回退); - `nested xhttp` 下的 TLS ECH 参数(`_dns`/`_force-query`/`_sockopt`)同步到 `extra` 的 `tlsSettings`; - 增加默认 DNS 场景告警。 - `backend/src/core/proxy-utils/producers/clashmeta.js`: - 输出到 mihomo 时发现 `ech-opts` 内含 ECH DNS 时给出明确 warning,引导改为 `dns["nameserver-policy"]` 配置。 - 测试覆盖补齐(parser / structured / text):新增/更新多组用例,覆盖 ECH base config、ECH DNS、`_force-query`、`_sockopt`、默认 DNS 回退、以及 nested xhttp 下载场景的导入导出闭环。 - 文档与版本: - `scripts/demo.js` 注释更新说明规则与转换行为;
1 parent 491885b commit 7be333d

9 files changed

Lines changed: 791 additions & 26 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "sub-store",
3-
"version": "2.22.22",
3+
"version": "2.22.23",
44
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
55
"main": "src/main.js",
66
"scripts": {
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { isNotBlank, isPlainObject } from '@/utils';
2+
3+
export const ECH_DNS_FIELD = '_dns';
4+
export const ECH_FORCE_QUERY_FIELD = '_force-query';
5+
export const ECH_SOCKOPT_FIELD = '_sockopt';
6+
export const DEFAULT_XRAY_ECH_DNS = 'https://dns.alidns.com/dns-query';
7+
8+
export function parseXrayEchConfigList(echConfigList) {
9+
if (!isNotBlank(echConfigList)) {
10+
return undefined;
11+
}
12+
13+
if (!echConfigList.includes('://')) {
14+
return {
15+
type: 'config',
16+
config: echConfigList,
17+
};
18+
}
19+
20+
const parts = echConfigList.split('+');
21+
if (parts.length === 1 && isNotBlank(parts[0])) {
22+
return {
23+
type: 'dns',
24+
dns: parts[0],
25+
};
26+
}
27+
28+
if (parts.length === 2 && isNotBlank(parts[0]) && isNotBlank(parts[1])) {
29+
return {
30+
type: 'dns',
31+
queryServerName: parts[0],
32+
dns: parts[1],
33+
};
34+
}
35+
36+
return undefined;
37+
}
38+
39+
export function isSupportedXrayEchConfigList(echConfigList) {
40+
return parseXrayEchConfigList(echConfigList) != null;
41+
}
42+
43+
export function isSupportedXrayEchForceQuery(forceQuery) {
44+
return ['none', 'half', 'full'].includes(forceQuery);
45+
}
46+
47+
function isMihomoEchEnabled(value) {
48+
// Match mihomo's current `ech-opts.enable` handling:
49+
// - adapter/parser.go decodes proxies with WeaklyTypedInput=true.
50+
// - common/structure/structure.go decodeBool accepts bool directly and
51+
// converts int/uint to `value != 0`, but does not convert strings.
52+
// - adapter/outbound/ech.go then gates ECH with `if !o.Enable`.
53+
if (typeof value === 'boolean') {
54+
return value;
55+
}
56+
57+
return typeof value === 'number' && Number.isInteger(value) && value !== 0;
58+
}
59+
60+
export function buildMihomoEchOptsFromXrayFields({
61+
echConfigList,
62+
echForceQuery,
63+
echSockopt,
64+
} = {}) {
65+
const parsedEchConfigList = parseXrayEchConfigList(echConfigList);
66+
if (!parsedEchConfigList) {
67+
return undefined;
68+
}
69+
70+
const echOpts = {
71+
enable: true,
72+
};
73+
if (parsedEchConfigList.type === 'config') {
74+
echOpts.config = parsedEchConfigList.config;
75+
} else {
76+
echOpts[ECH_DNS_FIELD] = parsedEchConfigList.dns;
77+
if (parsedEchConfigList.queryServerName) {
78+
echOpts['query-server-name'] = parsedEchConfigList.queryServerName;
79+
}
80+
}
81+
82+
if (isSupportedXrayEchForceQuery(echForceQuery)) {
83+
echOpts[ECH_FORCE_QUERY_FIELD] = echForceQuery;
84+
}
85+
86+
if (isPlainObject(echSockopt)) {
87+
echOpts[ECH_SOCKOPT_FIELD] = echSockopt;
88+
}
89+
90+
return echOpts;
91+
}
92+
93+
export function buildXrayEchFieldsFromMihomo(
94+
echOpts,
95+
fallbackEchConfigList,
96+
{ dnsFieldPath = 'ech-opts._dns', warnDefaultDns } = {},
97+
) {
98+
const fields = {};
99+
100+
if (isPlainObject(echOpts)) {
101+
if (!isMihomoEchEnabled(echOpts.enable)) {
102+
return fields;
103+
}
104+
105+
const queryServerName = echOpts['query-server-name'];
106+
if (isNotBlank(echOpts.config)) {
107+
fields.echConfigList = echOpts.config;
108+
} else if (isNotBlank(echOpts[ECH_DNS_FIELD])) {
109+
fields.echConfigList = isNotBlank(queryServerName)
110+
? `${queryServerName}+${echOpts[ECH_DNS_FIELD]}`
111+
: echOpts[ECH_DNS_FIELD];
112+
} else if (isNotBlank(queryServerName)) {
113+
fields.echConfigList = `${queryServerName}+${DEFAULT_XRAY_ECH_DNS}`;
114+
warnDefaultDns?.({
115+
defaultDns: DEFAULT_XRAY_ECH_DNS,
116+
dnsFieldPath,
117+
queryServerName,
118+
});
119+
}
120+
121+
if (
122+
fields.echConfigList &&
123+
isSupportedXrayEchForceQuery(echOpts[ECH_FORCE_QUERY_FIELD])
124+
) {
125+
fields.echForceQuery = echOpts[ECH_FORCE_QUERY_FIELD];
126+
}
127+
128+
if (fields.echConfigList && isPlainObject(echOpts[ECH_SOCKOPT_FIELD])) {
129+
fields.echSockopt = echOpts[ECH_SOCKOPT_FIELD];
130+
}
131+
132+
return fields;
133+
}
134+
135+
if (isNotBlank(fallbackEchConfigList)) {
136+
fields.echConfigList = fallbackEchConfigList;
137+
}
138+
139+
return fields;
140+
}
141+
142+
export function buildXrayEchConfigListFromMihomo(
143+
echOpts,
144+
fallbackEchConfigList,
145+
options,
146+
) {
147+
return buildXrayEchFieldsFromMihomo(echOpts, fallbackEchConfigList, options)
148+
.echConfigList;
149+
}

backend/src/core/proxy-utils/parsers/index.js

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ import {
2525
normalizeXhttpScalarUpperBound,
2626
} from '../xhttp-utils';
2727
import { extractPathQueryParam, getPathQueryParam } from '../transport-path';
28+
import {
29+
buildMihomoEchOptsFromXrayFields,
30+
isSupportedXrayEchConfigList,
31+
isSupportedXrayEchForceQuery,
32+
} from '../ech-utils';
2833

2934
function surge_port_hopping(raw) {
3035
const [parts, port_hopping] =
@@ -1235,13 +1240,14 @@ function URI_VLESS() {
12351240
}
12361241

12371242
const unsupportedTlsSettings = {};
1243+
const hasSupportedEchConfigList =
1244+
isSupportedXrayEchConfigList(value.echConfigList);
12381245
for (const [tlsKey, tlsValue] of Object.entries(
12391246
value,
12401247
)) {
12411248
switch (tlsKey) {
12421249
case 'serverName':
12431250
case 'fingerprint':
1244-
case 'echConfigList':
12451251
if (!isNotBlank(tlsValue)) {
12461252
setUnsupportedXhttpField(
12471253
unsupportedTlsSettings,
@@ -1250,6 +1256,41 @@ function URI_VLESS() {
12501256
);
12511257
}
12521258
break;
1259+
case 'echConfigList':
1260+
if (
1261+
!isSupportedXrayEchConfigList(tlsValue)
1262+
) {
1263+
setUnsupportedXhttpField(
1264+
unsupportedTlsSettings,
1265+
tlsKey,
1266+
tlsValue,
1267+
);
1268+
}
1269+
break;
1270+
case 'echForceQuery':
1271+
if (
1272+
!hasSupportedEchConfigList ||
1273+
!isSupportedXrayEchForceQuery(tlsValue)
1274+
) {
1275+
setUnsupportedXhttpField(
1276+
unsupportedTlsSettings,
1277+
tlsKey,
1278+
tlsValue,
1279+
);
1280+
}
1281+
break;
1282+
case 'echSockopt':
1283+
if (
1284+
!hasSupportedEchConfigList ||
1285+
!isPlainObject(tlsValue)
1286+
) {
1287+
setUnsupportedXhttpField(
1288+
unsupportedTlsSettings,
1289+
tlsKey,
1290+
tlsValue,
1291+
);
1292+
}
1293+
break;
12531294
case 'alpn':
12541295
if (
12551296
!(
@@ -1575,11 +1616,13 @@ function URI_VLESS() {
15751616
if (downloadSettings.tlsSettings.allowInsecure === true) {
15761617
parsedDownloadSettings['skip-cert-verify'] = true;
15771618
}
1578-
if (isNotBlank(downloadSettings.tlsSettings.echConfigList)) {
1579-
parsedDownloadSettings['ech-opts'] = {
1580-
enable: true,
1581-
config: downloadSettings.tlsSettings.echConfigList,
1582-
};
1619+
const echOpts = buildMihomoEchOptsFromXrayFields({
1620+
echConfigList: downloadSettings.tlsSettings.echConfigList,
1621+
echForceQuery: downloadSettings.tlsSettings.echForceQuery,
1622+
echSockopt: downloadSettings.tlsSettings.echSockopt,
1623+
});
1624+
if (echOpts) {
1625+
parsedDownloadSettings['ech-opts'] = echOpts;
15831626
}
15841627
}
15851628

@@ -1711,6 +1754,12 @@ function URI_VLESS() {
17111754
proxy.alpn = params.alpn ? params.alpn.split(',') : undefined;
17121755
proxy['skip-cert-verify'] = /(TRUE)|1/i.test(params.allowInsecure);
17131756
proxy._echConfigList = getIfPresent(params.ech);
1757+
const echOpts = buildMihomoEchOptsFromXrayFields({
1758+
echConfigList: params.ech,
1759+
});
1760+
if (echOpts) {
1761+
proxy['ech-opts'] = echOpts;
1762+
}
17141763
proxy['tls-fingerprint'] = getIfPresent(params.pcs);
17151764
proxy._h2 = /(TRUE)|1/i.test(params.h2);
17161765

backend/src/core/proxy-utils/producers/clashmeta.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import {
44
produceProxyListOutput,
55
supportsShadowsocksV2rayPluginMode,
66
} from '@/core/proxy-utils/producers/utils';
7+
import { isNotBlank, isPlainObject } from '@/utils';
78
import {
89
deleteHttpUpgradeEarlyDataMetadata,
910
normalizeWebSocketEarlyDataPath,
1011
} from '../transport-path';
12+
import { ECH_DNS_FIELD } from '../ech-utils';
1113
import $ from '@/core/app';
1214

1315
const ipVersions = {
@@ -18,6 +20,32 @@ const ipVersions = {
1820
'prefer-v6': 'ipv6-prefer',
1921
};
2022

23+
function warnMihomoUnsupportedEchDns(proxy, echOpts, echOptsPath) {
24+
if (!isPlainObject(echOpts) || !isNotBlank(echOpts[ECH_DNS_FIELD])) {
25+
return;
26+
}
27+
28+
const queryServerName = isNotBlank(echOpts['query-server-name'])
29+
? echOpts['query-server-name']
30+
: '这里是 query-server-name';
31+
$.warn(
32+
`mihomo 不支持在 ech-opts 中配置 ECH DNS. 如需跟节点 ECH 配置一致, 请在 mihomo 配置文件里设置 dns["nameserver-policy"]["${queryServerName}"] = ["${echOpts[ECH_DNS_FIELD]}"].`,
33+
);
34+
}
35+
36+
function warnMihomoUnsupportedEchDnsFields(proxy, type) {
37+
if (type === 'internal') {
38+
return;
39+
}
40+
41+
warnMihomoUnsupportedEchDns(proxy, proxy['ech-opts'], 'ech-opts');
42+
warnMihomoUnsupportedEchDns(
43+
proxy,
44+
proxy['xhttp-opts']?.['download-settings']?.['ech-opts'],
45+
'xhttp-opts.download-settings.ech-opts',
46+
);
47+
}
48+
2149
export default function ClashMeta_Producer() {
2250
const type = 'ALL';
2351
const produce = (proxies, type, opts = {}) => {
@@ -90,6 +118,8 @@ export default function ClashMeta_Producer() {
90118
return true;
91119
})
92120
.map((proxy) => {
121+
warnMihomoUnsupportedEchDnsFields(proxy, type);
122+
93123
if (proxy['reality-opts'] && !proxy['client-fingerprint']) {
94124
proxy['client-fingerprint'] = 'chrome';
95125
}

0 commit comments

Comments
 (0)