白嫖阿里云函数计算实现 V2Ray、Clash 订阅转换
背景知识及目标
VMESS 订阅连接
作为目前使用较广的加密代理协议,V2Ray 是大部分“机场”的首选。大多数套模板(以及不套模板)的“机场”会为用户提供一个VMESS 协议的订阅链接,方便用户自动更新节点信息。
最初,VMESS 订阅链接不是由官方设计的,只是在部分工具中使用,但由于其非常方便,目前已经成为一个标准。订阅格式为 JSON 的节点配置信息通过 base64 编码为 vmess://{base64 json}
格式,每个节点一行,而后对所有节点再执行一次 base64 编码。
Clash 配置订阅
与 VMESS 订阅类似,Clash 作为一个广泛使用的跨平台跨协议代理工具,也提供了方便的配置订阅。用户可以自动更新节点信息(包括白名单、黑名单、节点组)。
通常,用户会使用 tindy2013/subconverter 实现自动订阅转换。通过简单的配置文件,可以自动抓取 VMESS 订阅(该工具还支持其他格式),经过处理后,生成符合 Clash 要求的配置文件。对于初次使用的用户,可以借助 可视化页面 完成配置文件的配置。
函数计算
传说中的函数即服务(FaaS),大题思路是将程序部署在 Docker 内,由函数计算服务商负责包括部署、版本回退、负载均衡、自动扩容……等各种运维任务。简单来说,开发者只需要写好代码,剩下就不需要考虑了,如果用户量小,就只跑一个容器。如果突然访问量爆发,就自动申请更多资源。
从设计上来说,函数计算适合于与其他功能低耦合的模块功能,刚好这里的需求符合
目标
通常,会同时使用多个 “机场” 以供备份,部分机场可能会由于各种原因无法提供服务。如果电脑、手机、路由器分别都配置了代理工具,那么每次修改都非常麻烦(而需要修改是经常性的)
因此,考虑使用函数计算,实现两点
- 聚合各个“机场”订阅链接
- 自建 subconverter 转换订阅(公有订阅存在泄露信息的风险)
理论上,这种自建服务只有自己使用,完全用不完每个月白嫖的免费额度。
具体操作
这里以 阿里云-函数计算 为例进行配置。简单介绍下阿里云函数计算的概念,对于每个用户,包含多个函数组,可以认为是一个大项目。每个函数组内包含多个函数,可以认为是项目内的各个功能模块。函数内可以是任意语言的代码或编译后的可执行文件(这里的函数与编程语言的函数不太一样,后续如果没有特殊说明,指函数计算的函数)。
集群选择
首先,切换到香港集群。由于对于不同的集群,资源基本不共享,因此后续所有操作都在香港集群执行
选择香港的原因有两个
- 即使“机场”被屏蔽,也可以确保功能可用
- 香港域名不要求域名在阿里云备案
函数配置
创建一个名为 proxy 的函数组,并建立两个需要的函数完成功能
订阅聚合
用于聚合订阅信息的函数名为 subscribe
,创建时选择 nodejs12.x
环境 的 HTTP 函数,同时记得在 “修改配置” 里,将 单实例并发度 设置为 100
接下来,进入代码编辑页面(由于内容很少,因此这里直接在线编辑即可)
这里,用到了 axios
用于发送 HTTP 请求,因此需要安装依赖,在网页在线编辑器调出终端,执行 npm install axios
即可(如果没有使用网页在线编辑,需要注意 node_modules
文件夹需要一起部署至函数计算)
修改 index.js
为下述内容
const axios = require('axios'); axios.defaults.timeout = 3000; const URLS = (process.env.URL || '') .split('|') .filter((item) => item.length > 0); const TOKENS = Object.assign( {}, ...(process.env.TOKEN || '') .split('|') .filter((item) => item.length > 0) .filter((item) => item.indexOf('=') !== -1) .map((item) => item.split('=')) .map((item) => ({ [item[1]]: item[0] })) ); const TESTSUBSCRIBE='dm1lc3M6Ly9ldzBLSUNBaWRpSTZJQ0l5SWl3TkNpQWdJbkJ6SWpvZ0l1YTFpK2l2bFNJc0RRb2dJQ0poWkdRaU9pQWlNVEkzTGpBdU1DNHhJaXdOQ2lBZ0luQnZjblFpT2lBaU1USXpORFVpTEEwS0lDQWlhV1FpT2lBaU16QXdaRGN6T1RZdE1tUXlPQzAwWmpKaUxUaG1PV1l0TXpjMU5UQTVZbVZpTVROaElpd05DaUFnSW1GcFpDSTZJQ0l3SWl3TkNpQWdJbk5qZVNJNklDSmhkWFJ2SWl3TkNpQWdJbTVsZENJNklDSjBZM0FpTEEwS0lDQWlkSGx3WlNJNklDSnViMjVsSWl3TkNpQWdJbWh2YzNRaU9pQWlJaXdOQ2lBZ0luQmhkR2dpT2lBaUlpd05DaUFnSW5Sc2N5STZJQ0lpTEEwS0lDQWljMjVwSWpvZ0lpSU5DbjA9DQo=' const fromBase64 = (s) => Buffer.from(s, 'base64').toString(); const toBase64 = (s) => Buffer.from(s).toString('base64'); const removePrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; const get = (url, resolve, reject) => axios .get(url) .then((resp) => resp.data) .then(resolve) .catch(reject); async function getData() { try { return toBase64( ( await Promise.all( URLS.map( (url) => new Promise((resolve, reject) => get(url, resolve, reject)) ) ) ) .map((encoded) => fromBase64(encoded) .split('\n') .filter((line) => line.length > 0) ) .reduce((pre, cur) => pre.concat(cur)) .join('\n') ); } catch (e) { return e.toString(); } } exports.handler = async (req, resp, context) => { console.log(req.path); if (removePrefix(req.path, "/subscribe") === '/test') { console.log(req.clientIP); resp.send(TESTSUBSCRIBE); return; } const token = req.queries['token']; console.log(URLS, TOKENS); console.log(req.clientIP, token, TOKENS[token]); const res = !!TOKENS[token] ? await getData() : ''; resp.send(res); };
功能很简单,从环境变量 URL
获取以 |
分割的订阅链接,分别请求这些地址并聚合到一起返回给用户。如果用户访问的路径是 /test
,则会返回一个测试 VMESS 订阅(用于后续的转换测试)(如果大量请求可能会被“机场”封掉)
除去基本的功能外,这里做了一个简单的基于环境变量的用户管理,配置环境变量 TOKEN
可以设置以 |
分割的用户信息对,用户信息格式为 <用户名>=<token>
,访问时需要访问 /subscribe?token=1234
的形式进行访问,只有存在于环境变量内的用户可以获得订阅信息。
如果已经绑定域名,则应该可以使用类似的域名访问
https://fc.ohyee.cc/subscribe?token=abcd
https://fc.ohyee.cc/subscribe/test
订阅转换
由于需要运行 subconverter,这里使用 custom runtime(实际上用 NodeJS 环境也可以)
同样选择 HTTP 函数,同时配置 单实例并发度
在代码编辑页面,修改 bootstrap
文件,该文件是 custom runtime 的启动文件,一般来说不需要修改
#!/bin/bash
node ./index.js
修改 index.js
为下述内容
const http = require('http'); const child = require('child_process'); const removePrefix = (s, p) => s.startsWith(p) ? s.slice(p.length) : s; const requestListener = function (req, res) { var { connection, host, ...originHeaders } = req.headers; var options = { "method": req.method, "hostname": "127.0.0.1", "port": 25500, "path": removePrefix(req.url, "/subconverter"), "headers": { originHeaders } } var p = new Promise((resolve, reject) => { let postbody = []; req.on("data", chunk => { postbody.push(chunk); }) req.on('end', () => { let postbodyBuffer = Buffer.concat(postbody); resolve(postbodyBuffer) }) }); p.then((postbodyBuffer) => { let responsebody = [] var request = http.request(options, (response) => { response.on('data', (chunk) => { responsebody.push(chunk) }) response.on("end", () => { responsebodyBuffer = Buffer.concat(responsebody) res.end(responsebodyBuffer); }) }) request.write(postbodyBuffer) request.end(); }) }; child.exec('./subconverter/subconverter', function(err, sto) { console.log(sto); }) const server = http.createServer(requestListener); server.listen(9000);
上述功能也很容易理解,该文件共完成两项任务
- 后台启动
./subconverter/subconverter
- 在
9000
端口启动 WEB 服务,将请求转发至25500
端口。并且修改访问的路径(去掉可能存在的/subconverter
)
函数计算默认会将请求转发至 9000
端口(可以修改),同时在使用自定义域名时,如果路由至根目录,则内部程序可能无法正确识别路径,需要去掉前缀
那么剩下的问题就是获取 subconverter
的代码了。使用在线编辑器的终端执行下述代码
wget https://github.com/tindy2013/subconverter/releases/download/v0.6.4/subconverter_linux64.tar.gz tar -xzvf subconverter_linux64.tar.gz rm subconverter_linux64.tar.gz
该部分会在 Github 下载 v0.6.4 版本的 subconverter
,将其解压至当前目录,并删除掉压缩包。
从可视化编辑器中,应该可以看到左侧文件列表多了一个 subconverter
文件夹,且里面包含一个 subconverter
可执行文件,对应 JS 代码中的 ./subconverter/subconverter
现在,访问服务应该有下述内容(将域名修改为自己的)
https://fc.ohyee.cc/subconverter
:File not found.
https://fc.ohyee.cc/subconverter/sub
:Invalid target!
自定义域名
函数计算默认会提供一个非常长的域名,这个域名很难记忆,而且为了确保用户不会恶意使用函数计算功能,因此对于未使用自定义域名的函数,在浏览器访问时会被自动转换为下载文件而非直接展示。
这里在功能上,我们不需要使用浏览器访问,因此实际上不进行自定义域名也没有任何影响,如果不使用自定义域名,可跳过该部分。
在自定义域名页面,可以看到自己账号在对应集群的域名。需要将自己的域名 CNAME 解析到该地址,然后等待几分钟填写。
需要特别注意的是,除去绑定域名外,还需要设置路由。这里的路由规则分为两种
- 精确匹配: 需要与路径完全相同
- 模糊匹配: 在结尾包含
*
,匹配所有前缀符合的路径
这里,希望在不携带结尾的 /
也能正确访问服务,因此使用精确匹配绑定了对应的函数。
对于使用模糊匹配的域名绑定,需要注意的是如果被访问的路径是 /abc/def
,绑定的是 /abc/*
,那么程序中看到的 path 是 /abc/def
,而不是 /def
。
该问题在 Nginx 下,也是一个常见的问题,解决方案有两种
- 转发前后 path 完全一致,也即直接绑定
/*
- 做一个转发的中间件,Nginx 或者自己写一个简单的脚本
使用
至此,我们已经完整部署了所需的所有功能,可以结合 Github 上的 Clash 配置文件,实现全自主可控的订阅转换了
如,可以使用我开源的 OhYee/subconverter-config 转换配置,使用类似下面的域名作为 Clash 订阅链接: https://fc.ohyee.cc/subconverter/sub?target=clash&new_name=true&url=http://fc.ohyee.cc/subscribe?token=${TOKEN}$&insert=false&config=https://raw.githubusercontent.com/OhYee/subconverter-config/master/config.ini