Skip to content

制作 V2Ray 订阅链接

订阅链接是什么

首先,真正让你能够代理的,是 V2Ray 的内核

而 V2Ray 的内核,是用的一个 JSON 文件做配置,包括客户端和服务器都需要配置(具体在 v2fly 文档上有)

所谓分享链接,只是各个基于内核的 GUI 框架,为了方便用户配置内核的配置,来辅助解析你的订阅链接并写入内核配置

所以,这里我们不关注内核如何配置,而是只关心订阅链接应该如何写

Clash 它也是一个内核项目(死死喵),然后 Clash for Windows(也死死喵了),喵帕斯都是 GUI 界面(喵帕斯用的好像叫 mihomo 内核)

Clash 的内核配置文件格式为 YAML,具体写法参考:https://github.com/Dreamacro/clash/wiki/configuration

订阅链接怎么做?

首先,一般机场的订阅链接都是一个网址,它指向一个字符串资源

就好比 raw.github 那种直接拿字符串的

重点:

机场的服务器,会根据 HTML 请求头中的 UA 来自动返回不同的配置文件(千辛万苦我才搞清楚这一点)

如果你是 浏览器,或者 v2Ray,则返回 v2Ray

如果是 clash 中发送的请求,则返回 clash 的配置

即:

shell
curl -v "https://sub.yafsub.net/s/xxxx"
shell
curl -v -H "User-Agent: Clash/1.0" "https://sub.yafsub.net/s/xxxx"

返回不同结果。

前者是 Base64 编码,后者是一个明文的 yaml 配置文件

所以,如果你不区分 UA,只做针对 V2RayN 的订阅,你该如何写?

V2RayN 的订阅

vmess://xxxxxxx 这种格式,是 V2RayN 这个客户端,为了方便分享而做的,后来因为使用量很大,一部分 Clash 客户端也可以解析这种格式,导入到 Clash 里

格式大致有以下区分:

(1):// 后面,跟的是 Base64(JSON) 还是 URL 参数,前者是更新的配置,后者是更古老的配置格式(似乎是为了模仿 Shadowsocks 风格)

(2)是否采取 Base64 编码

测试点:

(1)vmess://明文【明文为 url 参数,而非 json】

从剪切板上导入,v2ray 支持

vmess://1cf66be4-ffff-ffff-ffff-7e9e0b368cd0@1.2.3.4:10002?encryption=none&security=none&type=tcp&headerType=none#miaomiaomiao

(2)vmess://base64

从剪切板上导入,v2ray 不支持

让 v2ray 访问 URL 去拿,支持

vmess://MWNmNjZiZTQtZmZmZi1mZmZmLWZmZmYtN2U5ZTBiMzY4Y2QwQDEuMi4zLjQ6MTAwMDI/ZW5jcnlwdGlvbj1ub25lJnNlY3VyaXR5PW5vbmUmdHlwZT10Y3AmaGVhZGVyVHlwZT1ub25lI21pYW9taWFvbWlhbw==

(3)vmess://明文 base64

从剪切板上导入,v2ray 支持

dm1lc3M6Ly8xY2Y2NmJlNC1mZmZmLWZmZmYtZmZmZi03ZTllMGIzNjhjZDBAMS4yLjMuNDoxMDAwMj9lbmNyeXB0aW9uPW5vbmUmc2VjdXJpdHk9bm9uZSZ0eXBlPXRjcCZoZWFkZXJUeXBlPW5vbmUjbWlhb21pYW9taWFv

(4)vmess://base64 base64

从剪切板上导入,v2ray 不支持

让 v2ray 访问 URL 去拿,不支持

dm1lc3M6Ly9NV05tTmpaaVpUUXRabVptWmkxbVptWm1MV1ptWm1ZdE4yVTVaVEJpTXpZNFkyUXdRREV1TWk0ekxqUTZNVEF3TURJL1pXNWpjbmx3ZEdsdmJqMXViMjVsSm5ObFkzVnlhWFI1UFc1dmJtVW1kSGx3WlQxMFkzQW1hR1ZoWkdWeVZIbHdaVDF1YjI1bEkyMXBZVzl0YVdGdmJXbGhidz09

备注:

机场的格式其实和(4)是一样的,只不过解码后是 vmess://base64 但是 base64 是 json 编码的

(5)vmess://json

从剪切板上导入,v2ray 支持,但是导入了错误的内容

vmess://{"v":"2","ps":"name","add":"phony.vpn.test.com","port":"10000","id":"1cf66be4-ffff-ffff-ffff-7e9e0b368cd0","aid":"0","net":"ws","type":"ws","host":"","path":"","tls":""}

(5)vmess://base64(json)

从剪切板上导入,v2ray 支持

vmess://eyJ2IjoiMiIsInBzIjoibmFtZSIsImFkZCI6InBob255LnZwbi50ZXN0LmNvbSIsInBvcnQiOiIxMDAwMCIsImlkIjoiMWNmNjZiZTQtZmZmZi1mZmZmLWZmZmYtN2U5ZTBiMzY4Y2QwIiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6IndzIiwiaG9zdCI6IiIsInBhdGgiOiIiLCJ0bHMiOiIifQ==

这里也是比较长,实际上我们只需要关注第一个

能拼接好 ip,端口,加密方式,uuid 即可

解析 x-ui 数据库

x-ui 这个框架,把配置都写入了一个本地 SQLite3 数据库

解析它即可

Rust 实现:

rust
use rusqlite::{Connection, Result};

const XUI_DB_PATH: &str = "D:\\Workspaces\\learn-rust\\read_xui\\res\\x-ui.db";

fn main() -> Result<()> {
    let conn = Connection::open(XUI_DB_PATH)?;
    let mut stmt_tables = conn.prepare("SELECT name FROM sqlite_master WHERE type='table'")?;
    let tables_iter = stmt_tables.query_map([], |row| {
        let name: String = row.get(0)?;
        Ok(name)
    })?;
    let _tables: Vec<String> = tables_iter.collect::<Result<_>>()?;
    let mut stmt_inbounds = conn.prepare("SELECT * FROM inbounds")?;
    let inbounds_iter = stmt_inbounds.query_map([], |row| {
        let remark: Option<String> = row.get(5)?;
        Ok(remark)
    })?;
    for inbound in inbounds_iter {
        if let Ok(remark_option) = inbound {
            match remark_option {
                Some(remark) => println!("Remark: {}", remark),
                None => println!("Remark is empty (NULL)"),
            }
        }
    }
    Ok(())
}

Python 实现:

python
def read_db():
    IP = get_server_ip()
    # 连接数据库
    conn = sqlite3.connect(XUI_DB_PATH)
    cursor = conn.cursor()
    # 查看所有表
    cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
    tables = cursor.fetchall()
    # 读取 inbounds 表(通常存储用户配置)
    cursor.execute("SELECT * FROM inbounds;")
    inbounds = cursor.fetchall()
    # 解析表
    for inbound in inbounds:
        #print(f"id: {inbound[0]}")
        #print(f"user_id: {inbound[1]}")
        #print(f"up: {inbound[2]}")
        #print(f"down: {inbound[3]}")
        #print(f"total: {inbound[4]}")
        #print(f"ramark: {inbound[5]}")
        #print(f"enable: {inbound[6]}")
        #print(f"expiry_time: {inbound[7]}")
        #print(f"ip_alert: {inbound[8]}")
        #print(f"ip_limit: {inbound[9]}")
        #print(f"listen: {inbound[10]}")
        #print(f"port: {inbound[11]}")
        #print(f"protocol: {inbound[12]}")
        #print(f"settings: {inbound[13]}")
        #print(f"stream_settings: {inbound[14]}")
        #print(f"tag: {inbound[15]}")
        #print(f"sniffing: {inbound[16]}")
        remark = inbound[5]
        port = inbound[12]
        protocol = inbound[13]
        if inbound[14]:  # settings 字段
                try:
                    settings = json.loads(inbound[14])
                    if 'clients' in settings:
                        #print("用户列表:")
                        for client in settings['clients']:
                            UUID = client.get('id')
                            #print(f"  - UUID: {client.get('id')}")
                            #print(f"    email: {client.get('email')}")
                            #print(f"    flow: {client.get('totalGB')}")
                            #print(f"    fingerprint: {client.get('fingerprint', 'chrome')}")
                            #print(f"    total: {client.get('total', True)}")
                            #print(f"    expiryTime: {client.get('expiryTime', True)}")
                            with open("./result.txt", "a") as f:
                                one_url = f"{protocol}://{UUID}@{IP}:{port}?encryption=none&security=none&type=tcp&headerType=none#{remark}\n"
                                f.write(one_url)
                except json.JSONDecodeError as e:
                    print(f"解析配置错误:{e}")
        #print("-" * 50)