白嫖阿里云函数计算实现 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