Files

210 lines
8.1 KiB
Lua
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- 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