mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-09 21:47:59 +08:00
210 lines
8.1 KiB
Lua
210 lines
8.1 KiB
Lua
-- output_v2ray.lua — V2Ray / Xray JSON 配置输出(纯 Lua)
|
||
-- luci-app-substore
|
||
|
||
local util = require("substore.util")
|
||
|
||
local M = {}
|
||
|
||
-- 构建 streamSettings
|
||
local function build_stream_settings(n)
|
||
local ss = {}
|
||
local net = n.net or n.network or "tcp"
|
||
ss.network = net
|
||
|
||
-- 传输层
|
||
-- 空表必须用 util.JSON_EMPTY_OBJECT(编码成 {})而不是裸 {}:json_encode 的
|
||
-- is_array 会把空表判成数组编成 [],而 Xray 用标准库 json.Unmarshal 解析配置,
|
||
-- wsSettings / grpcSettings / httpSettings 在它那边是结构体指针,把数组解进
|
||
-- 结构体会直接 UnmarshalTypeError,Xray 拒绝启动。触发条件很普通:grpc 节点
|
||
-- 没填服务名、ws 节点既没 path 也没 host。
|
||
if net == "ws" then
|
||
local ws = util.JSON_EMPTY_OBJECT
|
||
if n.path or n.host then
|
||
ws = {}
|
||
if n.path then ws.path = n.path end
|
||
if n.host then ws.headers = { Host = n.host } end
|
||
end
|
||
ss.wsSettings = ws
|
||
elseif net == "grpc" then
|
||
local g = util.JSON_EMPTY_OBJECT
|
||
if n.path then g = { serviceName = n.path } end
|
||
ss.grpcSettings = g
|
||
elseif net == "http" or net == "h2" then
|
||
local h = util.JSON_EMPTY_OBJECT
|
||
if n.host or n.path then
|
||
h = {}
|
||
if n.host then h.host = { n.host } end
|
||
if n.path then h.path = n.path end
|
||
end
|
||
ss.httpSettings = h
|
||
elseif net == "kcp" then
|
||
ss.kcpSettings = { header = { type = n.headerType or "none" } }
|
||
end
|
||
|
||
-- 安全层
|
||
local security = n.security
|
||
local sni = n.sni or n.servername
|
||
if security == "reality" and n["public-key"] then
|
||
-- Reality 必须写 realitySettings,**不是** tlsSettings。Xray 的 StreamConfig
|
||
-- 校验是 `case "reality": if c.REALITYSettings == nil { return nil,
|
||
-- errors.New(`REALITY: Empty "realitySettings".`) }` —— 写成 tlsSettings 的
|
||
-- reality 节点会被直接拒绝,一个节点废掉整份配置。
|
||
-- 客户端字段名取自 infra/conf/transport_security.go 的 REALITYConfig:
|
||
-- fingerprint / serverName / publicKey / shortId / spiderX(外加 show)。
|
||
-- 注意 publicKey 是**客户端**侧的键名(privateKey 只属于服务端),
|
||
-- spiderX 在 Xray 侧缺省为 "/"。
|
||
local rs = { show = false, fingerprint = n.fp or "chrome" }
|
||
if sni then rs.serverName = sni end
|
||
rs.publicKey = n["public-key"]
|
||
if n["short-id"] then rs.shortId = n["short-id"] end
|
||
if n["spider-x"] then rs.spiderX = n["spider-x"] end
|
||
ss.security = "reality"
|
||
ss.realitySettings = rs
|
||
elseif security and security ~= "none" then
|
||
-- security 声称 reality 却没有 public-key 时也走这里,但要把 security 降回
|
||
-- tls:REALITYConfig.Build() 在公钥为空时直接报错(客户端分支是
|
||
-- `empty "password"`,PublicKey 可由 Password 推导),而 StreamConfig 又要求
|
||
-- security == "reality" 必须配 realitySettings —— 照抄写出去会让 Xray
|
||
-- 拒绝加载**整份**配置。没有公钥就没有 Reality 可言,降级成普通 TLS
|
||
-- 至少配置能加载(与 sing-box / mihomo / QX 三个输出端的行为一致:
|
||
-- 它们同样只在有 public-key 时才写 Reality 配置)。
|
||
ss.security = (security == "reality") and "tls" or security
|
||
if sni then
|
||
ss.tlsSettings = { serverName = sni, allowInsecure = false }
|
||
end
|
||
elseif n.tls and n.tls ~= "none" and n.tls ~= false then
|
||
ss.security = "tls"
|
||
if sni then ss.tlsSettings = { serverName = sni } end
|
||
end
|
||
|
||
return ss
|
||
end
|
||
|
||
-- 该协议能否落到 V2Ray / Xray outbound。
|
||
-- 白名单而不是「排除 ssr」:原来的 `proto ~= "ssr"` 会把 hysteria2 / hysteria /
|
||
-- tuic / wireguard / snell 一并放行,而 to_outbound 里没有它们的分支,全部落进
|
||
-- 末尾的 else —— 于是生成 `"protocol": "hysteria2"` 这种 Xray 根本不认识的
|
||
-- outbound,凭据还被塞进无意义的 users 字段。Xray 解析到未知 protocol 会拒绝
|
||
-- 整份配置,一个节点废掉整个订阅(与 parser 侧丢弃未知协议是同一个理由)。
|
||
local V2RAY_PROTOS = {
|
||
vmess = true,
|
||
vless = true,
|
||
trojan = true,
|
||
shadowsocks = true,
|
||
ss = true,
|
||
socks = true,
|
||
socks5 = true,
|
||
http = true,
|
||
}
|
||
|
||
local function supported(proto)
|
||
return V2RAY_PROTOS[proto or "vmess"] == true
|
||
end
|
||
|
||
-- 单节点 → V2Ray outbound 表
|
||
-- tag 可选:完整配置里由 util.unique_tags 统一分配,避免节点重名导致 tag 冲突
|
||
function M.to_outbound(n, tag)
|
||
local proto = n.proto or "vmess"
|
||
if not supported(proto) then return nil end
|
||
local o = {
|
||
protocol = (proto == "ss" and "shadowsocks" or proto),
|
||
tag = tag or n.name or ((n.server or "") .. ":" .. tostring(n.port or "")),
|
||
settings = {},
|
||
streamSettings = build_stream_settings(n),
|
||
}
|
||
local server, port = n.server or "", tonumber(n.port) or 0
|
||
|
||
if proto == "vmess" then
|
||
o.settings.vnext = { {
|
||
address = server,
|
||
port = port,
|
||
users = { {
|
||
id = n.uuid or "",
|
||
alterId = tonumber(n.alterId or n.aid or 0),
|
||
-- users[].security 是 vmess 加密方式,取 cipher 而非 TLS 层
|
||
security = n.cipher or "auto",
|
||
} },
|
||
} }
|
||
elseif proto == "vless" then
|
||
local user = { id = n.uuid or "", encryption = "none" }
|
||
if n.flow then user.flow = n.flow end
|
||
o.settings.vnext = { { address = server, port = port, users = { user } } }
|
||
elseif proto == "trojan" then
|
||
o.settings.servers = { { address = server, port = port, password = n.password or "" } }
|
||
elseif proto == "shadowsocks" or proto == "ss" then
|
||
o.settings.servers = { {
|
||
address = server,
|
||
port = port,
|
||
method = n.method or n.cipher or "aes-256-gcm",
|
||
password = n.password or "",
|
||
} }
|
||
else
|
||
-- socks / http 等
|
||
o.settings.servers = { {
|
||
address = server,
|
||
port = port,
|
||
users = n.username and { { user = n.username, pass = n.password or "" } } or nil,
|
||
} }
|
||
end
|
||
|
||
return o
|
||
end
|
||
|
||
-- Xray 的 balancer / observatory selector 按「前缀」匹配 outbound tag。
|
||
-- 若某节点 tag 恰好是 "direct" / "block" / "auto" 的前缀(例如节点名就叫 "d"、"auto"),
|
||
-- selector 会把这些保留出站一并纳入负载均衡,导致流量被丢进 direct / block。
|
||
-- 因此把保留 tag 的所有真前缀也登记为冲突,交给 util.unique_tags 加后缀消解。
|
||
local function reserved_with_prefixes(names)
|
||
local r = {}
|
||
for _, s in ipairs(names) do
|
||
r[s] = true
|
||
for i = 1, #s - 1 do r[s:sub(1, i)] = true end
|
||
end
|
||
return r
|
||
end
|
||
|
||
-- 生成完整 V2Ray / Xray 配置(outbounds + observatory + routing)。
|
||
-- 不含 inbounds / dns:这两项会绑定本地监听端口、覆盖用户既有 DNS 设置,
|
||
-- 由用户在自己的配置里维护,本输出只负责节点与分流。
|
||
function M.generate(nodes, options)
|
||
local usable = {}
|
||
for _, n in ipairs(nodes or {}) do
|
||
if type(n) == "table" and supported(n.proto) then usable[#usable + 1] = n end
|
||
end
|
||
local tags = util.unique_tags(usable, reserved_with_prefixes({ "direct", "block", "auto" }))
|
||
|
||
local outbounds, proxy_tags = {}, {}
|
||
for i, n in ipairs(usable) do
|
||
outbounds[#outbounds + 1] = M.to_outbound(n, tags[i])
|
||
proxy_tags[#proxy_tags + 1] = tags[i]
|
||
end
|
||
|
||
-- direct / block 必须存在:路由规则要按 tag 引用它们
|
||
outbounds[#outbounds + 1] = { protocol = "freedom", tag = "direct", settings = util.JSON_EMPTY_OBJECT }
|
||
outbounds[#outbounds + 1] = { protocol = "blackhole", tag = "block", settings = { response = { type = "none" } } }
|
||
|
||
local routing = {
|
||
domainStrategy = "IPIfNonMatch",
|
||
rules = { { type = "field", ip = { "geoip:private" }, outboundTag = "direct" } },
|
||
}
|
||
local cfg = { log = { loglevel = "warning" }, outbounds = outbounds, routing = routing }
|
||
|
||
if #proxy_tags > 0 then
|
||
-- leastPing 依赖 observatory 的探测结果;不配 observatory 时该策略不生效
|
||
cfg.observatory = {
|
||
subjectSelector = proxy_tags,
|
||
probeUrl = "https://www.gstatic.com/generate_204",
|
||
probeInterval = "10s",
|
||
enableConcurrency = true,
|
||
}
|
||
routing.balancers = { { tag = "auto", selector = proxy_tags, strategy = { type = "leastPing" } } }
|
||
routing.rules[#routing.rules + 1] = { type = "field", network = "tcp,udp", balancerTag = "auto" }
|
||
else
|
||
-- 无可用节点时没有 balancer 可引用;首元素 direct 即默认出站
|
||
routing.rules[#routing.rules + 1] = { type = "field", network = "tcp,udp", outboundTag = "direct" }
|
||
end
|
||
|
||
return util.json_encode(cfg)
|
||
end
|
||
|
||
return M |