Skip to content

Commit 6bb899c

Browse files
committed
fix(auto-routing): 修正 Telegram 规则分组与规则类型检测
- 支持 Telegram 规则使用 CIDR 格式并保持 no-resolve 选项 - 新增规则类型支持集合以校验并正确附加分组到规则 - 修正 attachGroupToRule 函数,提升规则拼接准确性 - 调整自动检测规则类型函数,去除域名前缀并支持多种通配符格式 - 修改规则插入时机,避免向数组插入规则错误位置 - 重构规则加载函数,拆分并规范处理域名和 CIDR 规则 - 优化规则转换逻辑,修正 no-resolve 书写错误 - 为 Telegram CIDR 规则添加集成测试覆盖,确保保留 no-resolve 并正确分流 - 更新编译时规则获取逻辑,分别获取国外媒体域名规则和 Telegram CIDR 规则 - 在集成测试中增加对 GFW、Apple 和 Telegram 非媒体规则的保护验证 - 在 README 中新增双 Clash 架构原理与配置说明,指导用户合理搭建双实例分流环境
1 parent d093f20 commit 6bb899c

9 files changed

Lines changed: 298 additions & 81 deletions

File tree

CompileTimeScripts/fetch-rules/index.ts

Lines changed: 99 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ export const fetchRules = async () => {
1515
fetchWithRetry('CN_bilibili', () => fetchGithubRules(CN_bilibili)),
1616
fetchWithRetry('INTL_bilibili', () => fetchGithubRules(INTL_bilibili)),
1717
fetchWithRetry('Blocked_By_GFW', () => fetchGithubRules(Blocked_By_GFW)),
18-
fetchWithRetry('Loyalsoldier_GFW', () => fetchLoyalsoldierRules(Loyalsoldier_GFW)),
19-
fetchWithRetry('Loyalsoldier_Proxy', () => fetchLoyalsoldierRules(Loyalsoldier_Proxy)),
20-
fetchWithRetry('Loyalsoldier_Telegram', () => fetchLoyalsoldierRules(Loyalsoldier_Telegram)),
21-
fetchWithRetry('Loyalsoldier_Apple', () => fetchLoyalsoldierRules(Loyalsoldier_Apple)),
18+
fetchWithRetry('Loyalsoldier_GFW', () => fetchLoyalsoldierDomainRules(Loyalsoldier_GFW)),
19+
fetchWithRetry('Loyalsoldier_Proxy', () => fetchForeignMediaRules(Loyalsoldier_Proxy)),
20+
fetchWithRetry('Loyalsoldier_Telegram', () => fetchTelegramCidrRules(Loyalsoldier_Telegram)),
21+
fetchWithRetry('Loyalsoldier_Apple', () => fetchLoyalsoldierDomainRules(Loyalsoldier_Apple)),
2222
fetchWithRetry('Loyalsoldier_Direct', () => fetchLoyalsoldierDirect(Loyalsoldier_Direct_Filtered)),
2323
fetchWithRetry('Microsoft', () => fetchMicrosoftRules(Microsoft_Rules)),
2424
fetchWithRetry('AI', () => fetchAIRules()),
@@ -59,11 +59,9 @@ then( ( text ) => {
5959
} );
6060

6161
/**
62-
* 获取Loyalsoldier/clash-rules规则
63-
* 直接从raw.githubusercontent.com获取YAML文件
64-
* 只保留真正的国外媒体服务域名(过滤掉 Microsoft/Apple/Google/开发工具/CDN 等)
62+
* 获取 Loyalsoldier/clash-rules 的通用域名规则
6563
*/
66-
export const fetchLoyalsoldierRules = (url: string) => fetch(url, {
64+
export const fetchLoyalsoldierDomainRules = (url: string) => fetch(url, {
6765
headers: {
6866
"User-Agent" : "Clash-Parser/1.0"
6967
},
@@ -76,30 +74,42 @@ export const fetchLoyalsoldierRules = (url: string) => fetch(url, {
7674
return res.text();
7775
})
7876
.then(text => {
79-
// 解析YAML格式的规则文件
80-
const yaml: ResContents = parse(text);
81-
if (!yaml.payload || !Array.isArray(yaml.payload)) return [];
82-
83-
// 过滤并提取域名
84-
return yaml.payload
85-
.filter(rule => {
86-
// 过滤掉 IP-CIDR 和 IP-CIDR6 规则
87-
return !rule.startsWith('IP-CIDR,') && !rule.startsWith('IP-CIDR6,');
88-
})
89-
.map(rule => {
90-
// 处理 '+.domain.com' 格式(表示 DOMAIN-SUFFIX)
91-
if (rule.startsWith('+.')) {
92-
return rule.substring(2); // 去掉 '+.' 前缀
93-
}
94-
// 处理 'DOMAIN,xxx' 或 'DOMAIN-SUFFIX,xxx' 格式
95-
if (rule.includes(',')) {
96-
const parts = rule.split(',');
97-
return parts[1];
98-
}
99-
// 其他情况直接返回
100-
return rule;
101-
})
102-
.filter(domain => filterForeignMediaDomains(domain));
77+
return parsePayload(text)
78+
.map(normalizeDomainPayloadRule)
79+
.filter((domain): domain is string => Boolean(domain));
80+
});
81+
82+
/**
83+
* 获取国外媒体域名规则
84+
* 只保留真正的国外媒体服务域名(过滤掉 Microsoft/Apple/Google/开发工具/CDN 等)
85+
*/
86+
export const fetchForeignMediaRules = (url: string) => fetchLoyalsoldierDomainRules(url)
87+
.then(domains => domains.filter(domain => filterForeignMediaDomains(domain)));
88+
89+
/**
90+
* 向后兼容旧函数名。新代码应按用途调用 fetchLoyalsoldierDomainRules / fetchForeignMediaRules。
91+
*/
92+
export const fetchLoyalsoldierRules = fetchForeignMediaRules;
93+
94+
/**
95+
* 获取 Telegram CIDR 规则
96+
*/
97+
export const fetchTelegramCidrRules = (url: string) => fetch(url, {
98+
headers: {
99+
"User-Agent" : "Clash-Parser/1.0"
100+
},
101+
signal: AbortSignal.timeout(30000)
102+
})
103+
.then(res => {
104+
if (!res.ok) {
105+
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
106+
}
107+
return res.text();
108+
})
109+
.then(text => {
110+
return parsePayload(text)
111+
.map(normalizeCidrPayloadRule)
112+
.filter((rule): rule is string => Boolean(rule));
103113
});
104114

105115
/**
@@ -123,28 +133,9 @@ export const fetchLoyalsoldierDirect = (url: string) => fetch(url, {
123133
return res.text();
124134
})
125135
.then(text => {
126-
const yaml: ResContents = parse(text);
127-
if (!yaml.payload || !Array.isArray(yaml.payload)) return [];
128-
129-
// 过滤并提取域名
130-
const domains = yaml.payload
131-
.filter(rule => {
132-
// 只保留 DOMAIN 相关规则
133-
return !rule.startsWith('IP-CIDR,') && !rule.startsWith('IP-CIDR6,');
134-
})
135-
.map(rule => {
136-
// 处理 '+.domain.com' 格式
137-
if (rule.startsWith('+.')) {
138-
return rule.substring(2);
139-
}
140-
// 处理 'DOMAIN,xxx' 或 'DOMAIN-SUFFIX,xxx' 格式
141-
if (rule.includes(',')) {
142-
const parts = rule.split(',');
143-
return parts[1];
144-
}
145-
return rule;
146-
})
147-
.filter(domain => domain && domain.length > 0);
136+
const domains = parsePayload(text)
137+
.map(normalizeDomainPayloadRule)
138+
.filter((domain): domain is string => Boolean(domain));
148139

149140
// 智能筛选策略
150141
return smartFilterDirectDomains(domains);
@@ -313,6 +304,60 @@ function filterForeignMediaDomains(domain: string): boolean {
313304
return isMediaDomain;
314305
}
315306

307+
function parsePayload(text: string): string[] {
308+
const yaml: ResContents = parse(text);
309+
if (!yaml.payload || !Array.isArray(yaml.payload)) return [];
310+
return yaml.payload.map(rule => String(rule).trim()).filter(Boolean);
311+
}
312+
313+
function normalizeDomainPayloadRule(rule: string): string | null {
314+
const cleanedRule = rule.startsWith('- ') ? rule.substring(2).trim() : rule.trim();
315+
316+
if (!cleanedRule || cleanedRule.startsWith('IP-CIDR,') || cleanedRule.startsWith('IP-CIDR6,')) {
317+
return null;
318+
}
319+
320+
if (cleanedRule.includes('/')) {
321+
return null;
322+
}
323+
324+
if (cleanedRule.includes(',')) {
325+
const [type, value] = cleanedRule.split(',');
326+
if (!['DOMAIN', 'DOMAIN-SUFFIX', 'DOMAIN-KEYWORD', 'DOMAIN-WILDCARD', 'DOMAIN-REGEX'].includes(type)) {
327+
return null;
328+
}
329+
return normalizeDomainValue(value);
330+
}
331+
332+
return normalizeDomainValue(cleanedRule);
333+
}
334+
335+
function normalizeDomainValue(value: string): string {
336+
return value
337+
.trim()
338+
.replace(/^\+\.\*\./, '')
339+
.replace(/^\+\./, '')
340+
.replace(/^\*\./, '');
341+
}
342+
343+
function normalizeCidrPayloadRule(rule: string): string | null {
344+
const cleanedRule = rule.startsWith('- ') ? rule.substring(2).trim() : rule.trim();
345+
if (!cleanedRule) return null;
346+
347+
if (cleanedRule.startsWith('IP-CIDR,') || cleanedRule.startsWith('IP-CIDR6,')) {
348+
const [type, value] = cleanedRule.split(',');
349+
if (!value) return null;
350+
return `${type},${value},no-resolve`;
351+
}
352+
353+
if (!cleanedRule.includes('/')) {
354+
return null;
355+
}
356+
357+
const type = cleanedRule.includes(':') ? 'IP-CIDR6' : 'IP-CIDR';
358+
return `${type},${cleanedRule},no-resolve`;
359+
}
360+
316361
/**
317362
* 智能筛选直连域名
318363
* 保留高频国内站点,去除长尾域名

README.md

Lines changed: 114 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,120 @@
3131
于是Dual Clash Arch应运而生:
3232
- Clash A:开启Tun模式,使用auto-routing自动分流模式接管系统所有流量,,但bypass 127.0.0.1:xxxx的流量
3333
- Clash B:不开启Tun和系统代理,使用global-proxy全局分流模式,仅设置并后台运行127.0.0.1:xxxx的服务器
34-
详细配置见
34+
35+
### 架构原理
36+
**为什么需要双Clash架构?**
37+
38+
1. **安全兜底**:单客户端自动分流存在漏网之鱼的风险,一旦敏感域名被错误直连可能造成严重后果。双架构提供"全局代理"作为安全兜底
39+
2. **应用兼容性**:某些应用不走系统代理或 hardcoded 直连,需要通过独立代理实例处理
40+
3. **职责分离**
41+
- Clash A 负责智能分流(国内直连、国外代理)
42+
- Clash B 负责全局代理(为特定应用提供固定代理入口)
43+
44+
### 配置步骤
45+
46+
#### 1. 准备 Clash B(全局代理实例)
47+
48+
```bash
49+
# 构建全局代理模式配置
50+
npm run build:cvr:global # 或其他客户端
51+
```
52+
53+
**Clash B 配置要点:**
54+
-**不开启 TUN 模式**
55+
-**不设置系统代理**
56+
- ✅ 监听本地端口(如 `127.0.0.1:7890`
57+
- ✅ 使用 `global-proxy` 模式(仅 GEOIP,CN 直连,其余全部代理)
58+
- ✅ 后台静默运行
59+
60+
```yaml
61+
# Clash B 配置示例
62+
mixed-port: 7890
63+
allow-lan: false
64+
mode: Rule
65+
log-level: info
66+
67+
tun:
68+
enable: false # 关键:不开启TUN
69+
70+
proxy-groups:
71+
- name: "🫧 Global Proxy 🫧"
72+
type: select
73+
proxies:
74+
- 你的代理节点1
75+
- 你的代理节点2
76+
77+
rules:
78+
- GEOIP,CN,❄️ China Geo-IP ❄️
79+
- MATCH,🫧 Global Proxy 🫧
80+
```
81+
82+
#### 2. 准备 Clash A(智能分流实例)
83+
84+
```bash
85+
# 构建自动路由模式配置
86+
npm run build:cvr:auto # 或其他客户端
87+
```
88+
89+
**Clash A 配置要点:**
90+
-**开启 TUN 模式**(接管系统所有流量)
91+
-**设置系统代理**
92+
- ✅ 使用 `auto-routing` 模式(12+ 智能分组)
93+
-**Bypass Clash B 地址**(避免递归代理)
94+
95+
```yaml
96+
# Clash A 配置示例
97+
mixed-port: 7891 # 使用不同端口
98+
allow-lan: true
99+
mode: Rule
100+
log-level: info
101+
102+
tun:
103+
enable: true # 关键:开启TUN
104+
stack: mixed
105+
dns-hijack:
106+
- any:53
107+
108+
# 关键:添加绕过规则
109+
rules:
110+
# 最高优先级:绕过 Clash B 的地址
111+
- IP-CIDR,127.0.0.1/32,DIRECT
112+
- IP-CIDR,127.0.0.0/8,DIRECT
113+
114+
# 其他自动分流规则...
115+
- DOMAIN-SUFFIX,google.com,🫧 Proxy A 🫧
116+
- GEOIP,CN,🟢 China Direct
117+
- MATCH,🐟 Final
118+
```
119+
120+
#### 3. 应用配置
121+
122+
在需要全局代理的应用中(如 Claude Desktop、Telegram 等):
123+
- 设置代理服务器为 `http://127.0.0.1:7890`(Clash B 的监听地址)
124+
- 这些应用的流量将强制走全局代理,不受 Clash A 分流规则影响
125+
126+
### 流量路径示意
127+
128+
```
129+
[应用程序]
130+
131+
├─> 应用内置代理 → 127.0.0.1:7890 (Clash B) → 全局代理 → 互联网
132+
133+
└─> 系统代理 → TUN (Clash A) → 智能分流
134+
├─ 国内流量 → DIRECT
135+
├─ GFW站点 → Proxy A
136+
├─ 国外媒体 → Foreign Media
137+
└─ 漏网之鱼 → Final
138+
```
139+
140+
### 注意事项
141+
142+
1. **端口冲突**:Clash A 和 Clash B 必须使用不同端口
143+
2. **Bypass 规则**:Clash A 必须绕过 Clash B 的监听地址,否则会形成递归代理
144+
3. **性能开销**:双实例会占用更多内存(约 2x),但现代设备通常无压力
145+
4. **维护成本**:需要同时管理两个配置文件和更新订阅
146+
147+
详细配置示例见:[proxifier-to-mihomo.yaml](proxifier-to-mihomo.yaml)
35148
## ✨ 功能特性
36149
37150
- 🎯 **多种客户端支持**: Clash for Windows、Clash Verge、Clash Party (Mihomo Party)
@@ -150,15 +263,3 @@ MIT
150263
- 构建产物位于 `dist/` 目录,可直接导入 Clash 客户端使用
151264
- 自动路由模式首次构建可能需要更长时间(需下载规则数据)
152265
- 自定义规则支持热更新,修改后重新构建即可生效
153-
154-
155-
156-
### Dual Clash Arch Config
157-
158-
- ClashA Conf
159-
- 开启Tun模式
160-
- 使用auto-routing分流模式(js脚本或server)
161-
- bypass ClashB代理地址
162-
-
163-
- 根据需要添加自定义分流
164-
-

src/AutoRoutingConfig.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,27 @@
44
*/
55

66
// ESM Imports (按重要程度排序: 业务模块 > 类型)
7-
import { ClashConfig, RuleType } from './types/clash';
7+
import type { ClashConfig, RuleType } from './types/clash';
88
import { SelectorSymbols, converters } from './RuleConverters';
99
import { Clash, Group } from './ClashConfigBuilder';
1010
import type { YAML } from './types/client';
1111
import type { UserCustomRulesConfig } from './types/user-rules';
1212
import { convert3DRulesToMihomoRules, validate3DRules } from './config/UserCustomRulesConverter';
1313

14+
const SUPPORTED_RULE_TYPES = new Set([
15+
'DOMAIN-SUFFIX',
16+
'DOMAIN',
17+
'DOMAIN-KEYWORD',
18+
'IP-CIDR',
19+
'IP-CIDR6',
20+
'SRC-IP-CIDR',
21+
'GEOIP',
22+
'PROCESS-NAME',
23+
'DST-PORT',
24+
'SRC-PORT',
25+
'MATCH',
26+
]);
27+
1428
export class AutoRoutingGroup extends Clash {
1529
presetGroups = {
1630
[SelectorSymbols.Direct] : 'DIRECT' ,
@@ -290,7 +304,7 @@ export class AutoRoutingGroup extends Clash {
290304

291305
// 8. Telegram规则 -> Telegram分组
292306
const telegramRules = __CompileTime_Rules__.Loyalsoldier_Telegram.map(
293-
(domain) => `DOMAIN-SUFFIX,${domain},${this.presetGroups['telegram']}`
307+
(rule) => this.attachGroupToRule(rule, this.presetGroups['telegram'], 'DOMAIN-SUFFIX')
294308
);
295309
rules.push(...telegramRules);
296310

@@ -392,6 +406,17 @@ export class AutoRoutingGroup extends Clash {
392406
return [rule];
393407
});
394408
}
409+
410+
attachGroupToRule(rule: string, group: string, fallbackType: keyof typeof RuleType): string {
411+
const parts = rule.split(',');
412+
const [type, value, ...options] = parts;
413+
414+
if (parts.length >= 2 && SUPPORTED_RULE_TYPES.has(type)) {
415+
return [type, value, group, ...options].join(',');
416+
}
417+
418+
return `${fallbackType},${rule},${group}`;
419+
}
395420

396421
/**
397422
* 去重rules(保留首次出现的规则)

src/ClashConfigBuilder.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ export class Clash {
154154
rules.push([type, groupName] as unknown as RuleArray);
155155
} else if (tail) {
156156
if (this.checkExistMatchRule(rules)) {
157-
rules.splice(rules.length - 2, 0, [type, value, groupName] as unknown as RuleArray);
157+
rules.splice(rules.length - 1, 0, [type, value, groupName] as unknown as RuleArray);
158158
}
159159
} else {
160160
rules.unshift([type, value, groupName] as unknown as RuleArray);

0 commit comments

Comments
 (0)