mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +08:00
255 lines
11 KiB
Lua
255 lines
11 KiB
Lua
-- output_uri.lua — 分享链接(URI)输出:Shadowrocket / V2Ray URI(纯 Lua)
|
||
-- luci-app-substore
|
||
-- 生成 vmess:// / vless:// / trojan:// / ss:// / hysteria2:// / tuic:// / ssr:// 分享链接
|
||
|
||
local util = require("substore.util")
|
||
|
||
local M = {}
|
||
|
||
-- URL 编码:保留字母数字 - . _ ~,其余转 %XX
|
||
local function url_encode(s)
|
||
s = tostring(s or "")
|
||
return (s:gsub("([^%w%-%.%_%~])", function(c)
|
||
return string.format("%%%02X", c:byte())
|
||
end))
|
||
end
|
||
|
||
-- alpn 归一成逗号分隔字符串。
|
||
-- alpn 可能是数组:Clash YAML 的 alpn 列表与 sing-box JSON 的 tls.alpn 导入后
|
||
-- 就是 table。直接交给 url_encode 会 tostring 成 "table: 0x...",链接里出现
|
||
-- `alpn=table%3A%200x...`(客户端解析失败)。
|
||
local function alpn_str(v)
|
||
if type(v) == "table" then return table.concat(v, ",") end
|
||
return v
|
||
end
|
||
|
||
-- 是否跳过证书校验。模型里的权威字段是 skip-cert-verify(见 parser.lua 中
|
||
-- hysteria2/hysteria URI 解析处的说明);insecure 只是 URI 参数名,且**只有**
|
||
-- URI 解析器会写它 —— Clash YAML / sing-box JSON / 表单导入的节点只有
|
||
-- skip-cert-verify。只读 insecure 会让这些节点在导出链接时丢掉该项,
|
||
-- 客户端按严格校验握手直接失败。
|
||
local function insecure_flag(n)
|
||
local v = n["skip-cert-verify"]
|
||
if v == nil then v = n.skip_cert_verify end
|
||
if v == nil then v = n.insecure end
|
||
if v == nil then return nil end
|
||
if v == false or v == "false" or v == 0 or v == "0" then return "0" end
|
||
return "1"
|
||
end
|
||
|
||
-- 生成单节点分享链接;无法生成时返回 nil
|
||
-- 分享链接里的 server 字面量。IPv6 必须加方括号:RFC 3986 的 authority 是
|
||
-- `host:port`,而裸 IPv6 本身就含冒号,`fd00::1:443` 无法判断哪一段是端口。
|
||
-- 本项目自己的解析器(util.split_hostport)就拆不开,重新导入得到 0 个节点;
|
||
-- 规范的客户端同样会拒绝或拆错。output_wireguard_conf 的 Endpoint 一直是这么做的。
|
||
local function host_literal(server)
|
||
server = tostring(server or "")
|
||
if server:find(":", 1, true) and server:sub(1, 1) ~= "[" then
|
||
return "[" .. server .. "]"
|
||
end
|
||
return server
|
||
end
|
||
|
||
function M.to_share_uri(n)
|
||
if type(n) ~= "table" or not n.server or not n.port then return nil end
|
||
local server = host_literal(n.server)
|
||
local port = tonumber(n.port) or 0
|
||
local name = url_encode(n.name or (server .. ":" .. tostring(port)))
|
||
local proto = (n.proto or ""):lower()
|
||
|
||
if proto == "ss" then proto = "shadowsocks" end
|
||
|
||
if proto == "shadowsocks" then
|
||
local method = n.method or n.cipher or "aes-256-gcm"
|
||
local password = n.password or ""
|
||
local userinfo = util.base64_encode(method .. ":" .. password)
|
||
-- SIP002:SS-URI = "ss://" userinfo "@" host ":" port [ "/" ] [ "?" plugin ] [ "#" tag ]
|
||
-- 插件参数整体做一次百分号编码(`;` 与 `=` 在 query 里必须转义)。
|
||
-- 不回写这一项时,带插件的节点导出后再导入就永久丢失插件配置。
|
||
local query = ""
|
||
if type(n.plugin) == "string" and n.plugin ~= "" then
|
||
query = "/?plugin=" .. url_encode(n.plugin)
|
||
end
|
||
return "ss://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
|
||
.. query .. "#" .. name
|
||
end
|
||
|
||
if proto == "vmess" then
|
||
local json = {
|
||
v = "2",
|
||
ps = n.name or (server .. ":" .. tostring(port)),
|
||
add = server,
|
||
port = tostring(port),
|
||
id = n.uuid or "",
|
||
aid = tostring(n.alterId or n.aid or 0),
|
||
-- scy 是 vmess 加密方式,取 cipher 而非 TLS 层
|
||
scy = n.cipher or "auto",
|
||
net = n.net or n.network or "tcp",
|
||
type = n.type or n.headerType or "none",
|
||
host = n.host or "",
|
||
path = n.path or "",
|
||
-- 经典 vmess JSON 的 tls 字段是 TLS 层("tls" 或 ""),
|
||
-- security 经 node.normalize 归一后是唯一权威来源
|
||
tls = (n.security and n.security ~= "none") and "tls" or "",
|
||
}
|
||
return "vmess://" .. util.base64_encode(util.json_encode(json))
|
||
end
|
||
|
||
if proto == "vless" then
|
||
local q = {}
|
||
q[#q + 1] = "encryption=none"
|
||
q[#q + 1] = "type=" .. url_encode(n.net or n.network or "tcp")
|
||
q[#q + 1] = "security=" .. url_encode(n.security or "none")
|
||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
|
||
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
|
||
if n.path then q[#q + 1] = "path=" .. url_encode(n.path) end
|
||
if n.host then q[#q + 1] = "host=" .. url_encode(n.host) end
|
||
if n.flow then q[#q + 1] = "flow=" .. url_encode(n.flow) end
|
||
-- Reality 参数:键名 pbk / sid / spx 来自 Xray 分享链接规范
|
||
-- (XTLS/Xray-core discussion #716),mihomo 与 v2rayN 都按它解析。
|
||
-- 不写出 pbk 的话,链接导入到任何客户端都得不到 Reality 配置,节点不可用。
|
||
if n["public-key"] then q[#q + 1] = "pbk=" .. url_encode(n["public-key"]) end
|
||
if n["short-id"] then q[#q + 1] = "sid=" .. url_encode(n["short-id"]) end
|
||
if n["spider-x"] then q[#q + 1] = "spx=" .. url_encode(n["spider-x"]) end
|
||
return "vless://" .. (n.uuid or "") .. "@" .. server .. ":" .. tostring(port)
|
||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||
end
|
||
|
||
if proto == "trojan" then
|
||
local q = {}
|
||
q[#q + 1] = "security=" .. url_encode(n.security or "tls")
|
||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
|
||
if n.fp then q[#q + 1] = "fp=" .. url_encode(n.fp) end
|
||
return "trojan://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||
.. "?" .. table.concat(q, "&") .. "#" .. name
|
||
end
|
||
|
||
if proto == "anytls" then
|
||
-- anytls:// 分享链接(由 anytls-go 定义):anytls://[auth@]host[:port]/?sni=..&insecure=..
|
||
-- 密码放在 userinfo(auth)位置,端口缺省 443。URI 规范只定义了 sni 与
|
||
-- insecure 两个查询参数,alpn 之类只存在于各家客户端的配置文件里 ——
|
||
-- 写进链接是非法参数(严格解析器会拒绝,宽松的会静默忽略)。
|
||
local q = {}
|
||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||
local ins = insecure_flag(n)
|
||
if ins ~= nil then q[#q + 1] = "insecure=" .. ins end
|
||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||
return "anytls://" .. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||
.. suffix .. "#" .. name
|
||
end
|
||
|
||
if proto == "hysteria2" or proto == "hysteria" then
|
||
local q = {}
|
||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||
local ins = insecure_flag(n)
|
||
if ins ~= nil then q[#q + 1] = "insecure=" .. ins end
|
||
-- 混淆:hysteria2 是 salamander,URI 参数为 obfs / obfs-password;
|
||
-- hysteria(v1) 的 obfs 只是普通字符串,**没有** obfs-password
|
||
-- (写到 v1 链接上是非法参数,且会误导下游客户端)
|
||
if n.obfs and n.obfs ~= "" and n.obfs ~= "plain" then
|
||
q[#q + 1] = "obfs=" .. url_encode(n.obfs)
|
||
if proto == "hysteria2" then
|
||
local opw = n["obfs-password"] or n.obfs_password
|
||
if opw and opw ~= "" then q[#q + 1] = "obfs-password=" .. url_encode(opw) end
|
||
end
|
||
end
|
||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||
return (proto == "hysteria2" and "hysteria2://" or "hysteria://")
|
||
.. url_encode(n.password or "") .. "@" .. server .. ":" .. tostring(port)
|
||
.. suffix .. "#" .. name
|
||
end
|
||
|
||
if proto == "tuic" then
|
||
local userinfo = (n.uuid or "") .. ":" .. url_encode(n.password or "")
|
||
local q = {}
|
||
if n.congestion_control then q[#q + 1] = "congestion_control=" .. url_encode(n.congestion_control) end
|
||
if n.alpn then q[#q + 1] = "alpn=" .. url_encode(alpn_str(n.alpn)) end
|
||
if n.sni then q[#q + 1] = "sni=" .. url_encode(n.sni) end
|
||
local suffix = #q > 0 and ("?" .. table.concat(q, "&")) or ""
|
||
return "tuic://" .. userinfo .. "@" .. server .. ":" .. tostring(port)
|
||
.. suffix .. "#" .. name
|
||
end
|
||
|
||
if proto == "socks5" or proto == "socks" then
|
||
local userinfo = ""
|
||
if n.username then userinfo = url_encode(n.username)
|
||
if n.password then userinfo = userinfo .. ":" .. url_encode(n.password) end
|
||
userinfo = userinfo .. "@"
|
||
end
|
||
return "socks5://" .. userinfo .. server .. ":" .. tostring(port) .. "#" .. name
|
||
end
|
||
|
||
if proto == "ssr" then
|
||
return M.to_ssr_uri(n)
|
||
end
|
||
|
||
if proto == "wireguard" then
|
||
-- wireguard://base64(json)#name —— 本项目自定义 scheme(wireguard 无统一 URI 标准)
|
||
local json = {
|
||
server = server,
|
||
port = tostring(port),
|
||
["private-key"] = n["private-key"] or n.private_key,
|
||
["public-key"] = n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key,
|
||
["peer-public-key"] = n["peer-public-key"] or n.peer_public_key,
|
||
["pre-shared-key"] = n["pre-shared-key"] or n.pre_shared_key or n["preshared-key"] or n.preshared_key,
|
||
["preshared-key"] = n["preshared-key"] or n.preshared_key,
|
||
ip = n.ip,
|
||
ipv6 = n.ipv6,
|
||
["allowed-ips"] = n["allowed-ips"],
|
||
reserved = n.reserved,
|
||
["persistent-keepalive"] = n["persistent-keepalive"],
|
||
["listen-port"] = n["listen-port"],
|
||
dns = n.dns,
|
||
["amnezia-wg-option"] = n["amnezia-wg-option"],
|
||
}
|
||
if n.mtu then json.mtu = tostring(n.mtu) end
|
||
if n.name then json.name = n.name end
|
||
return "wireguard://" .. util.base64_encode(util.json_encode(json)) .. "#" .. name
|
||
end
|
||
|
||
return nil
|
||
end
|
||
|
||
-- 生成 ssr:// 分享链接(外层与密码为标准 base64,参数为 base64url)
|
||
function M.to_ssr_uri(n)
|
||
if type(n) ~= "table" or not n.server then return nil end
|
||
local server = n.server
|
||
local port = tostring(tonumber(n.port) or 0)
|
||
local protocol = n.protocol or "origin"
|
||
local method = n.method or n.cipher or "aes-128-cfb"
|
||
local obfs = n.obfs or "plain"
|
||
local password = n.password or ""
|
||
local main = table.concat({ server, port, protocol, method, obfs, util.base64_encode(password) }, ":")
|
||
local q = {}
|
||
local op = n.obfs_param or n["obfs-param"]
|
||
local pp = n.protocol_param or n["protocol-param"]
|
||
if op and op ~= "" then q[#q + 1] = "obfsparam=" .. util.base64_url_encode(op) end
|
||
if pp and pp ~= "" then q[#q + 1] = "protoparam=" .. util.base64_url_encode(pp) end
|
||
q[#q + 1] = "remarks=" .. util.base64_url_encode(n.name or server)
|
||
if n.group and n.group ~= "" then q[#q + 1] = "group=" .. util.base64_url_encode(n.group) end
|
||
return "ssr://" .. util.base64_encode(main .. "/?" .. table.concat(q, "&"))
|
||
end
|
||
|
||
-- 生成 URI 列表(每行一条,丢弃无法生成 URI 的节点)
|
||
function M.to_uri_list(nodes)
|
||
local out = {}
|
||
for _, n in ipairs(nodes or {}) do
|
||
local u = M.to_share_uri(n)
|
||
if u then out[#out + 1] = u end
|
||
end
|
||
return table.concat(out, "\n")
|
||
end
|
||
|
||
-- Shadowrocket 订阅:base64 编码的 URI 列表
|
||
function M.to_shadowrocket(nodes)
|
||
return util.base64_encode(M.to_uri_list(nodes))
|
||
end
|
||
|
||
-- V2Ray URI 订阅:明文 URI 列表
|
||
function M.to_v2ray_uri(nodes)
|
||
return M.to_uri_list(nodes)
|
||
end
|
||
|
||
return M |