制作 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 的配置
即:
curl -v "https://sub.yafsub.net/s/xxxx"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 实现:
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 实现:
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)