前言

咱们平时在使用 CFW 上网时,直接 global 并不是一种好的做法,因为这会导致许多没有必要绕过的流量也过了一遍代理,不仅浪费代理流量,也影响了速度。因此除非你有特殊的需求,日常使用通常来讲都是需要配置分流的。

本文会介绍 CFW 的分流机制、简单的分流配置,以及咱最后灵活性更高一些的方式,还有在探索这个方式之中的一些踩坑。

CFW 分流机制

Proxy

从服务提供商处获取 Profile(配置文件)后,就可以在 CFW 中使用 Profile 中定义的 Proxy。这些 Proxy 需要配合规则进行使用,由规则匹配流量,最终决定要使用哪个 Proxy 代理这些流量,这就是 CFW 的基本分流逻辑。

Proxy Groups

除了 Proxy 以外,规则也可以将流量指向一个 Proxy Group,用户可以在使用中手动改变这个 Proxy Group 的指向。Proxy Group 也可以指向另一个 Group,不过最终还是会到达 Proxy 或者 DIRECT、REJECT 这样的特殊终点。

这为用户手动指定是否代理某些流量以及使用哪些位置的节点提供了很大的灵活性。在定义了复杂的分流规则的时候也可以提供一定的分类作用。

上面展示了一个简单的带有 Proxy Group 的示例。GitHub、npm 的流量将被转发到一个高速节点、Netflix 的流量将被转发到一个解锁流媒体的节点,而 bing 的流量将不通过代理。

简单配置分流

瞅瞅 Profile 长啥样

要配置分流,咱们必须修改代理的 Profile,在 Profile 列表右键即可。

编辑配置文件

第一项是使用 CFW 内置编辑器打开,第二项是使用外部编辑器打开,不过不是很清楚 CFW 默认选择什么外部编辑器,而 CFW 内置的编辑器是 Monaco,相当于一个小型无插件 VS Code,感觉编辑 yml 文件还是够用了的。

在这里可以编辑 CFW 中各种重要配置选项,包括上面提到的 Proxy、Proxy Group和 Rule 等等。可以在这里查看服务商给提供的默认 Profile 是什么样的。

但是直接编辑 Profile 存在一个问题,就是每当从服务商更新新的文件时,CFW 会简单粗暴地直接把文件给覆盖了。这就导致需要每次更新都要重新修改配置选项。

Parser

由于上面一小节提到的原因,CFW 提供了一个 Parser 的功能(官方中文说法似乎是叫「配置文件预处理」),可以让用户也编辑服务商提供的配置文件,达到客制化的效果。

而 Parser 有好几种使用方法,对于简单分流,使用 Yaml Parser 就够了。

要定义 Parser,首先在 parsers 下添加一个项目,填入两个关键属性:regyamlreg 表示匹配配置项的正则表达式,只有符合这个正则的 Profile 链接才会被这个 Parser 处理。什么?你说不会写正则,Okii,也可以使用 url 代替 reg,这种方式会直接对链接进行字符串匹配。如果只有一个 Profile,或者想让所有的 Profile 都应用这个 Parser,可以填 .*

然后就是 yaml 属性,该属性是一个对象,可以填写的属性如下:

值类型 操作
append-rules 数组 数组合并至原配置rules数组后
prepend-rules 数组 数组合并至原配置rules数组前
append-proxies 数组 数组合并至原配置proxies数组后
prepend-proxies 数组 数组合并至原配置proxies数组前
append-proxy-groups 数组 数组合并至原配置proxy-groups数组后
prepend-proxy-groups 数组 数组合并至原配置proxy-groups数组前
mix-proxy-providers 对象 对象合并至原配置proxy-providers中
mix-rule-providers 对象 对象合并至原配置rule-providers中
mix-object 对象 对象合并至原配置最外层中
commands 数组 在上面操作完成后执行简单命令操作配置文件

后面那几个就不用管了,主要使用 rulesproxiesproxy-groups 这几项相关的即可。

appendprepend 的区别在于这里的项目会添加到原来的数组的开始还是末尾,除此之外没有任何区别。

而具体到 rulesproxiesproxy-groups 这里面的数组项目该填什么属性呢?这里咱也不太清楚是否有固定的属性写法,不过既然都到这里了,你肯定是有一个 Profile 了吧,总不会没有 Profile 在这里虚空钻研吧(?),那就直接打开你的服务商提供的 Profile 依葫芦画瓢吧!

比如你的服务商提供了一个这样的 proxy 配置:

1
2
3
4
5
6
7
proxies:
- name: 好耶
type: vmess
server: 114.51.41.91
port: 8888
uuid: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
udp: true

如果你要添加同样的 proxy 在代理列表的前头,应该怎么写呢?

1
2
3
4
5
6
7
8
9
10
parsers:
- reg: .*
yaml:
prepend-proxies:
- name: 好耶
type: vmess
server: 114.51.41.91
port: 8888
uuid: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
udp: true

没错,所有的属性都和 Profile 中的一模一样,只需要把这些配置放到 prepend-proxies 中去就好。ruleproxy-group 都是同理的,可以去看看你的服务商提供了什么样的配置~

Proxy 添加无效

如果你尝试跟着上面的栗子添加一个 Proxy,你会发现根本用不了这个 Proxy。因为在你的 Proxy Group 里根本没有它!而你也没有添加指向这个 Proxy 的规则。

因此你还需要在 Proxy Group 的 proxies 里添加上你的这个 Proxy,或者再依葫芦画瓢添加一条规则。

规则列表是下面这样形式的字符串数组:

1
2
rules:
- DOMAIN-SUFFIX,github.com,Proxied

使用两个逗号分开成三段,第一段是规则类型,第二段是规则匹配字符串,第三段是规则指向的 Proxy Group 或者 Proxy。比如上面这个栗子就表示匹配域名前缀(DOMAIN-SUFFIX),指向 Proxied 这个 Group,那么之后所有在 github.com 这个域名下的流量都会遵循 Proxied 的设定。

那如果要修改现有的 Proxy Group,在其 proxies 里面添加上咱们自己的 Proxy 要怎么办呢?

很遗憾,目前这种方法并不能实现。 你可以尝试一下官方文档中的 commands 选项,这是一个新功能,可以实现修改现有的选项,其实到这里就差不多够用了。(如果早点出的话兴许咱就不会去琢磨更高级的用法了)另外 commands 还在 beta 当中,可能会有一些 bug,因此还是建议用下面提到的方法应对更加复杂的需求 qwq。

脚本预处理

除了使用 yaml 的方式预处理,咱们还可以使用 JavaScript 的方式来对 Profile 进行操作,这种方式更加灵活和强大,甚至可以调用 npm 包,也不需要你去额外学习 yaml 预处理的写法,如果有 JavaScript 经验的话,这种方式兴许会更简单一点。

内嵌 code

一种简单的方式是在 yaml 文件中直接嵌入 JavaScript 代码,官方给出的示例写法如下:

1
2
3
4
5
6
7
8
parsers:
- reg: .*
code: |
module.exports.parse = async (raw, { axios, yaml, notify, console }, { name, url, interval, selected }) => {
const obj = yaml.parse(raw)
// your parse code here
return yaml.stringify(obj)
}

可以看到可以直接以 CommonJS 的写法在这里编写代码,并且官方代码中还有 async 关键字,这意味着你哪怕在这里异步处理也是没问题的。直接在 yaml 中编写 JavaScript 的话是没法使用 npm 包的,因此第二个参数和第三个参数还提供了一些常用的工具类来满足简单的需求。

这里的 raw 就是来自服务器的 yaml 文件字符串,而 yaml.parse(raw) 则是把 yaml 给解析成了 JavaScript Object,因此可以按照 JavaScript 访问 Object 的所有语法访问这个对象里的所有内容,你问对象的结构是什么样子?去看服务商返回给你的 Profile 呀。

外部 JS

内嵌 code 其实能做的已经很多了,因为可以编程,灵活性一下子相比于 yaml parser 提升了不少,但内嵌 code 也有一些问题,比如编写代码的时候没有提示、没法使用 npm 包和构建复杂的架构等。

外部 JS 的使用方法如下:

1
2
3
parsers:
- reg: .*
file: "C:/Ayaka/cfw/parser.js"

然后 CFW 会把这个文件给读取进来,效果和上面的内嵌 JavaScript 是一模一样的,里面写的代码也是一样的,没什么不同。

调试问题

因为咱打算彻底抛弃 yaml 预处理,全面转移到可以自己定义配置如何写、如何处理的 JavaScript Parser 来,因此这个 parse 函数里就不简简单单是几句逻辑了,而是会有许多封装过的,便于自己使用、满足自己需求的函数,这种情况下就需要更多的模块来组织代码了。

但咱发现了一个问题:parse 函数中提供的 console 是 CFW 暴露的日志工具,在 Windows 上,它会被输出到 ~/AppData/Local/Temp/cfw-parser.log 里,但如果在 parse 函数所在以外的模块使用它,不管是什么方式使用,都无法打印日志,也无法操作文件(因此也没法自己把日志打到别的地方去),代码出错会直接略过,CFW 中看不到预处理错误信息,debugability 基本为 0,甚至会出现 unshift 把元素添加到数组末尾的离奇现象,但把使用 console 的代码移到 parse 函数所在的模块内,就一切恢复正常了。

既然在模块外部使用会出现奇怪的现象,索性就把代码全塞在这一个模块里咯,你说要组织架构?那就上打包工具吧!

Parser 项目搭建

上一节末尾提到的分模块出现的奇怪 bug,以及咱的 Parser 日渐复杂,咱打起了 TypeScript 的主意…(JSDOC 写起来太难受了)于是干脆打算为这个 Parser 构建成一个项目得了。如果你也有同样的问题或者需求,也可以参考一下这里如何搭建。

初始化 TypeScript 项目以及 eslint 这类东西就略过了。咱们要做的主要就是用 Rollup 编译 TypeScript 并且打包成单个 CommonJS 模块的文件。

首先安装 Rollup 需要的依赖:

1
pnpm add -D rollup rollup-plugin-typescript2 tslib typescript @rollup/plugin-commonjs @rollup/plugin-terser

另外需要安装 tslib,虽然平时使用 tsc 的时候不需要安装,但在这里是需要安装的。

在项目根目录创建 rollup.config.js 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import typescript from 'rollup-plugin-typescript2'
import terser from '@rollup/plugin-terser'

export default [
{
input: 'src/index.ts',
plugins: [
typescript(),
terser(),
],
output: [
{ file: 'dist/index.js', format: 'cjs' },
],
},
]

大概的意思就是 src/index.ts 作为入口文件,先交给 Rollup 的 TypeScript 插件处理,再交给 terser 压缩一下,生成一个 dist/index.js 文件,格式是 CommonJS。

然后运行 rollup -c rollup.config.js 打包代码,再去 CFW 里引入这里的 dist/index.js 就可以辣。

迁移到服务端

仍然存在的问题

做到上一节中的外部 Parser 搭建后,其实还会有一个很难受的问题:每次更新自己的规则,就要重新构建项目,并在 CFW 中点击 update。这个问题可以通过将规则从代码中转移出去,代码从外部文件读入规则,并动态添加到 CFW 提供的 JavaScript Object 中来解决,但前文中提到了在这里面的编写与调试有很多的局限性。于是咱想到能不能在 update 之前自动执行一段构建脚本,这样就解决了需要跑去目录里执行的问题了,但是研究了一番似乎没法实现,如果你知道怎么实现可以告诉咱()

而且咱同时还在使用 ClashForAndroid,但这个软件并不支持 Parser。之后就琢磨干脆把 Parser 迁移到自己的服务器上,然后 CFW 从咱的服务器请求配置文件,因为 parse 函数本质上就是一个对 JavaScript Object 操作的函数,配置文件也不过是一个用 yml 表示的 JavaScript Object 罢了,所以迁移应该是很简单的事情。

迁移

咱们直接把 parse 函数以及用到的其他文件移动过去,把 parse 函数的签名修改一下: async (raw: string)

然后简单用 express 起一个 HTTP 服务,从自己的提供商请求到配置文件,然后丢给 parse 函数,再将 parse 过后的对象使用 yaml 包转换成 yml 格式的字符串响应给客户端即可,这里贴一下核心代码,可以根据自己的需求更改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
dotenv.config()

const app = express()
const port = process.env.PORT ?? 3000

app.get('/link', async (req, res) => {
try {
const link = `${Your subscribe link}`
const { data, headers } = await axios.get(link)
const parsed = await parse(data)
const userInfo = headers['subscription-userinfo'] as string
if (userInfo) {
res.setHeader('subscription-userinfo', userInfo)
}
res.setHeader('content-disposition', 'attachment; filename="config.yml"')
res.setHeader('content-type', 'application/yaml')
res.send(parsed)
} catch (error) {
if (error instanceof Error) {
res.statusCode = 400
res.send(error.message)
}
}
})

app.listen(port, () => {
console.log(`Listening on port ${port}`)
})

需要注意的是订阅信息并不在配置文件中,而是在响应头中,所以需要手动设置一下。

在调试的时候就在本地跑,改完了更新到服务器上构建,然后在本地点击 update 就可以了。你说这不还是需要手动构建嘛,可以像咱一样整一个简单的持续部署啦,改完直接 git push 等个十几二十秒再去点 update 就可以辣,而且这种方式也能够变相支持一些不能用 Parser 的软件。

参考

  1. 配置文件预处理 | Clash for Windows
  2. Quantumult/extra-subscription-feature.md