Files

808 lines
32 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.
-- node.lua — 统一节点模型(纯 Lua)
-- luci-app-substore
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
local util = require("substore.util")
local msg = require("substore.msg")
local M = {}
M.PROTOS = {
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
-- anytls 追加在末尾:清单顺序即界面下拉/复选框顺序,插在中间会改动既有
-- 界面的排列(无功能影响,但会让每次 diff 都动到无关行)。
"anytls",
}
-- 每种协议在「表单导入 / 节点编辑」界面里渲染的字段集合,数组顺序即界面顺序。
--
-- 这里是**唯一**的字段清单:LuCI 页面把它渲染成 window.SUBSTORE_PROTO_FIELDS
-- 交给 nodeform.js 渲染表单,core.merge_form_node 用它决定「哪些字段可以被表单
-- 覆盖(含清空)」。两边共用一份数据不是洁癖,而是必须的 —— 表单没渲染的字段
-- 提交不上来,合并时若一并清空就等于用空值覆盖原值:
-- * vmess 的 TLS 层字段是 security,而表单原先渲染的是 tls,编辑一次就把
-- security 抹成 nil,normalize 再补成 "none",一个启用 TLS 的 vmess 节点
-- 静默变成明文;
-- * hysteria2 / tuic / hysteria 是 TLS-only 协议(parser.lua 补 security="tls"),
-- 表单不渲染 security,编辑一次就丢掉 TLS,sing-box 会因 C.ErrTLSRequired
-- 直接拒绝启动;
-- * WireGuard 的 dns、vmess 的 flow 同理。
-- 所以字段清单必须与表单渲染的字段严格一致,不能各维护一份。
M.PROTO_FIELDS = {
ssr = { "server", "port", "password", "cipher", "protocol", "obfs", "obfs-param", "protocol-param", "udp" },
-- vmess 的 TLS 开关是 security(与 vless 一致):节点模型里 TLS 层统一存于
-- security,vmess 的加密方式存于 cipher。表单渲染 tls 时读不到 security,
-- 保存即丢 TLS。
vmess = { "server", "port", "uuid", "alterId", "cipher", "net", "headerType", "path", "host", "sni", "security", "udp", "skip-cert-verify" },
vless = { "server", "port", "uuid", "security", "flow", "net", "headerType", "path", "host", "sni", "udp", "skip-cert-verify",
-- Reality(security=reality)参数。public-key 缺失时客户端根本连不上;
-- 表单不渲染这几项的话,用户在界面上编辑一次就把 Reality 参数清空
-- (core.merge_form_node 把「在清单里但没提交」的字段当作清空处理)。
"public-key", "short-id", "spider-x", "fp" },
trojan = { "server", "port", "password", "sni", "net", "headerType", "path", "host", "udp", "skip-cert-verify" },
-- plugin:SIP003 插件串(`obfs-local;obfs=http;obfs-host=x`)。不进这个清单
-- 会有两个后果:表单不渲染它,且 core.merge_form_node 会在保存时把原值清掉
-- —— 用户在界面上改一下带插件的 ss 节点,插件配置就永久消失。
shadowsocks = { "server", "port", "password", "method", "headerType", "udp", "plugin" },
hysteria2 = { "server", "port", "password", "sni", "obfs", "obfs-password", "skip-cert-verify" },
-- hysteria(v1):obfs 是普通字符串(不是 hysteria2 的 salamander),因此没有 obfs-password。
-- 字段集合以各输出模块实际消费的键为准(output_clash_meta / output_singbox / output_uri)。
hysteria = { "server", "port", "password", "sni", "obfs", "skip-cert-verify" },
tuic = { "server", "port", "uuid", "password", "sni", "udp", "skip-cert-verify" },
-- anytls:字段集合以各输出实际消费的键为准。mihomo 的 AnyTLSOption 里是
-- password / sni / alpn / skip-cert-verify(**注意是 sni,没有 servername**);
-- sing-box 是 password + tls{server_name, alpn, insecure};Surge 是 password
-- 加共享 TLS 参数。三者交集即下面这几项。
-- client-fingerprint 故意不列入:它只在 mihomo 侧有意义,而「不在清单里的
-- 字段」在表单保存时会被原样保留(只有清单内的字段才会被清空),
-- 所以不列入既不影响导入的节点,也少一个只对单一客户端生效的输入框。
--
-- Reality:AnyTLS 同样能跑在 Reality 上(Loon 的节点行有 AnyTLS 的 Reality
-- 示例、QX 1.6+ 的 anytls 行认 reality-base64-pubkey、sing-box 1.12+ 的 anytls
-- 出站 tls 里也能带 reality),public-key 缺失时客户端根本连不上。而
-- core.merge_form_node 会把「在清单里但没提交」的字段当作清空处理 ——
-- 不列入的话,用户在界面上编辑一次就把这两个凭据抹掉。
-- spider-x 不列入:没有任何 anytls 输出端消费它(Loon 只写 public-key /
-- short-id,QX 与 sing-box 的 Reality 也没有 spiderX 这个参数)。
anytls = { "server", "port", "password", "sni", "alpn", "skip-cert-verify",
"public-key", "short-id" },
wireguard = { "server", "port", "private-key", "public-key", "pre-shared-key", "ip", "ipv6", "allowed-ips", "reserved", "persistent-keepalive", "listen-port", "mtu", "amnezia-wg-option" },
socks = { "server", "port", "username", "password", "udp" },
}
-- 表单里与协议无关、始终渲染的字段(nodeform.js 的 nodeTemplate 固定输出这三个)
M.FORM_ALWAYS_FIELDS = { "name", "group" }
-- TLS-only 协议:协议本身要求 TLS 层,缺了客户端直接起不来
-- (sing-box 的 hysteria2/tuic 出站在 TLS 缺失或未启用时返回 C.ErrTLSRequired)。
-- 这是协议自身的约束,属于归一化该保证的不变量,不能依赖各解析器自己补:
-- URI 解析器会补 security="tls",但 Clash YAML / sing-box JSON 导入的同名节点
-- 没有这个字段,导出后是一份客户端起不来的配置。
--
-- 单独抽成一张表,是因为 DEFAULTS(下面)与测试(tests/view_injection_test.lua)
-- 都要用它。两边各写一份必然漂移:新增一个 TLS-only 协议时只改一处,
-- 另一处会静默失效 —— 测试从此不再覆盖新协议。
M.TLS_ONLY = {
trojan = true,
hysteria2 = true,
hysteria = true,
tuic = true,
-- anytls 同样建立在 TLS 之上,协议本身没有明文模式:mihomo 的 AnyTLSOption
-- 根本没有 tls 开关,sing-box 的 anytls 出站把 tls 标为 Required,Surge
-- 文档写明 "AnyTLS always encrypts traffic with TLS"。
-- 缺 security 时 output_singbox.build_tls 返回 nil,sni / insecure 全丢,
-- 客户端直接起不来 —— 与 hysteria2/tuic 是同一个不变量。
anytls = true,
}
-- 支持 Reality 的协议。Reality 的凭据是 public-key(对端公钥,Base64)+
-- short-id(十六进制),两者都从服务端配置里抄来。
--
-- 判据必须是「协议 + 有没有 public-key」两者,只看 public-key 会误伤 wireguard:
-- wireguard 的 public-key 是「对端公钥」,与 Reality 毫无关系,一旦被当成 Reality,
-- security 会被写成 "reality",output_singbox 就会去读 tls.reality,wireguard
-- 节点的 TLS 无关字段整批丢失。
--
-- 协议清单取自各客户端文档:Loon 的 Reality 示例覆盖 VLESS / VMess / Trojan /
-- AnyTLS(nsloon.app/docs/Node/),mihomo 的 reality-opts 也出现在这几类出站上。
M.REALITY_PROTOS = {
vless = true,
vmess = true,
trojan = true,
anytls = true,
}
-- 支持 uTLS 客户端指纹(mihomo 的 client-fingerprint)的协议。
--
-- 键名与语义都已对照上游源码确认(MetaCubeX/mihomo,adapter/outbound/*.go):
-- * vmess / vless / trojan / shadowsocks / anytls 的选项结构体里是
-- `ClientFingerprint string \`proxy:"client-fingerprint,omitempty"\``,即 uTLS 指纹;
-- * hysteria / hysteria2 / tuic 上叫 `fingerprint`,但那是**证书固定**
-- (SHA256 pin),与 uTLS 是两回事,把 fp 写过去是语义错误;
-- * 全仓库没有任何结构体声明 `proxy:"fp,..."`。旧输出写的 `fp:` 键在 mihomo
-- 里根本不存在,而它的 proxy 解码器对未知键是**静默忽略**的
-- (common/structure/structure.go:多余的键留在 dataValKeysUnused 里,没有
-- `,remain` 字段就再也不检查、不报错),于是这个错误不报错、不生效 ——
-- 用户拿到的配置看起来「有指纹」,实际握手用的是默认指纹。
--
-- 抽成一张表而不是在各输出模块里各写一遍 if 链:新增协议(如 anytls)时只改
-- 一处,遗漏由 tests/protocol_registry_test.lua 直接暴露。
M.CLIENT_FP_PROTOS = {
vmess = true,
vless = true,
trojan = true,
shadowsocks = true,
anytls = true,
}
-- 协议是否支持 uTLS 客户端指纹。兼容 ss 等别名写法(未归一化的节点直接进来
-- 时 proto 可能是 "ss"),归一化规则与 M.normalize 保持一致。
function M.supports_client_fp(proto)
if proto == "ss" then proto = "shadowsocks" end
return M.CLIENT_FP_PROTOS[proto] == true
end
-- 协议默认值,用于补全缺省字段
local DEFAULTS = {
vmess = { net = "tcp", security = "none" },
vless = { net = "tcp", security = "none" },
}
-- TLS-only 协议的默认 security 由 M.TLS_ONLY 推导,不在这里另列一遍
for proto in pairs(M.TLS_ONLY) do
DEFAULTS[proto] = { security = "tls" }
end
-- vmess 加密方式(cipher)白名单。sing-box 的 vmess.security、Xray 的
-- users[].security、Clash 的 vmess.cipher 取同一组值;写入非法值会让客户端
-- 拒绝整份配置,因此归一化时丢弃未知取值,由输出端回退到 "auto"。
M.VMESS_CIPHERS = {
auto = true, none = true, zero = true,
["aes-128-gcm"] = true, ["chacha20-poly1305"] = true,
}
-- 判断节点是否拥有某标签;兼容 tags 为数组({"fast"})或 set({fast=true})
local function has_tag(node, tag)
local t = node and node.tags
if type(t) ~= "table" then return false end
if t[tag] == true then return true end
for _, v in ipairs(t) do
if v == tag then return true end
end
return false
end
-- 归一化:协议别名统一、补默认值、保证 name 非空
function M.normalize(node)
if type(node) ~= "table" then return node end
if node.proto == "ss" then node.proto = "shadowsocks" end
-- socks5 与 socks 是同一个协议,只是各客户端写法不同(Clash 写 socks5、
-- sing-box 写 socks、分享链接写 socks5://)。统一成 socks:
-- 各输出模块本来就同时认这两种写法,但 node.filter / rules.proto_filter
-- 是精确比较,两种写法会互相看不见——节点页按协议筛选和规则过滤都会漏。
if node.proto == "socks5" then node.proto = "socks" end
local d = DEFAULTS[node.proto] or {}
for k, v in pairs(d) do
if node[k] == nil then node[k] = v end
end
-- tls 的空串 / "none" / "false" 在 Lua 里都是真值,会被下游的
-- `if node.tls then` 误判为「启用 TLS」(经典 vmess JSON 的 tls:"" 即此例),
-- 统一归一为 nil
if node.tls == "" or node.tls == "none" or node.tls == "false" then
node.tls = nil
elseif node.tls == "true" then
-- 简易 YAML 解析器把 tls: true 读成字符串 "true"
node.tls = true
end
-- tls 与 security 是同一语义(TLS 层)的两种写法,统一落到 security;
-- 已显式给出 security(含 DEFAULTS 补的 "none")时不覆盖
if node.security == nil or node.security == "none" then
if node.tls == true then
node.security = "tls"
elseif node.tls == "tls" or node.tls == "reality" then
node.security = node.tls
end
end
-- Reality:带 public-key 就是 Reality,与 security 是怎么写的无关 ——
-- 分享链接写 security=reality,Clash / Loon 的行则写 public-key 而 security
-- 仍是 tls 或 none(Loon 的 Reality 行就是 over-tls=true + public-key)。
-- 统一在这里推导,各解析器与各输出模块就不必各判一次,也不会出现
-- 「同一个节点在 clash 里出了 reality-opts、在 v2ray 里却被当成普通 TLS」
-- 这种按格式分叉的漏判。
-- 必须排在 TLS_ONLY 补的 security="tls" 之后,否则 anytls 会被改回 tls。
if node["public-key"] and M.REALITY_PROTOS[node.proto] then
node.security = "reality"
end
-- vmess 的加密方式存于 cipher,与 TLS 层(security)无关;
-- 非白名单取值(例如被误写成 "tls")直接丢弃,避免输出非法配置
if node.proto == "vmess" and node.cipher ~= nil and not M.VMESS_CIPHERS[node.cipher] then
node.cipher = nil
end
if node.name == nil or node.name == "" then
node.name = (node.server or "") .. ":" .. tostring(node.port or "")
end
-- tags 字符串自动拆分为数组(逗号分隔)
if type(node.tags) == "string" then
local t = {}
for tag in node.tags:gmatch("[^,]+") do
tag = tag:match("^%s*(.-)%s*$")
if tag ~= "" then t[#t + 1] = tag end
end
node.tags = t
end
-- 保持 group、remarks、template、url 字段原样
return node
end
-- 过滤:支持 proto、keyword(name/server)、server、port、group、tags
function M.filter(nodes, opts)
opts = opts or {}
local out = {}
for _, n in ipairs(nodes) do
if opts.proto and n.proto ~= opts.proto then
else
local ok = true
if opts.keyword and opts.keyword ~= "" then
local kw = opts.keyword:lower()
if not ( (n.name or ""):lower():find(kw, 1, true) or (n.server or ""):lower():find(kw, 1, true) ) then
ok = false
end
end
if ok and opts.server and n.server ~= opts.server then ok = false end
if ok and opts.port and tonumber(n.port) ~= tonumber(opts.port) then ok = false end
if ok and opts.group and n.group ~= opts.group then ok = false end
if ok and opts.tags then
local wanted = opts.tags
if type(wanted) == "string" then wanted = wanted:gsub("^%s*(.-)%s*$", "%1") end
if not has_tag(n, wanted) then ok = false end
end
if ok then out[#out + 1] = n end
end
end
return out
end
-- WireGuard peer 公钥。字段名以 parser 产出的 "public-key" 为准,
-- 其余为历史/导入别名(output_wireguard_conf 同样接受这几种写法)
local function wg_public_key(n)
return n["public-key"] or n.public_key or n["peer-public-key"] or n.peer_public_key
end
-- 去重键的「身份」部分:同一 server:port 上的**不同账号是不同节点**。
--
-- 旧键只有 proto|server|port,于是「同一入口的多账号」被当成重复,静默删掉
-- 其中一个 —— 那是数据丢失而不是去重(与下面 WireGuard 公钥的说明同理)。
-- 这里取各协议里真正代表账号身份的字段;未知协议不猜,退回空身份。
local function identity_key(n)
local proto = (n.proto or ""):lower()
if proto == "ss" then proto = "shadowsocks" end
if proto == "wg" then proto = "wireguard" end
if proto == "socks5" then proto = "socks" end
-- WireGuard 的身份是 peer 公钥(同一个 endpoint 上不同公钥是不同节点)
if proto == "wireguard" then
return tostring(wg_public_key(n) or "")
end
if proto == "shadowsocks" then
return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "")
end
if proto == "ssr" then
-- SSR 的加密方式、密码、协议插件、混淆方式是四段独立参数,任一不同
-- 都是不同的节点
return tostring(n.method or n.cipher or "") .. "\1" .. tostring(n.password or "")
.. "\1" .. tostring(n.protocol or "") .. "\1" .. tostring(n.obfs or "")
end
if proto == "vmess" or proto == "vless" then
return tostring(n.uuid or "")
end
if proto == "tuic" then
return tostring(n.uuid or "") .. "\1" .. tostring(n.password or "")
end
if proto == "socks" or proto == "http" then
return tostring(n.username or "") .. "\1" .. tostring(n.password or "")
end
if proto == "trojan" or proto == "hysteria" or proto == "hysteria2" then
return tostring(n.password or "")
end
return ""
end
-- 去重:按 proto+server+port+身份唯一。
-- proto 先归一:`wg` 与 `wireguard` 是同一协议,不归一的话同一条链路以两种
-- 写法出现时不会被去重。
function M.dedup(nodes)
local seen = {}
local out = {}
for _, n in ipairs(nodes) do
local proto = (n.proto or ""):lower()
if proto == "wg" then proto = "wireguard" end
local key = proto .. "|" .. (n.server or "") .. "|" .. tostring(n.port or "")
.. "|" .. identity_key(n)
if not seen[key] then
seen[key] = true
out[#out + 1] = n
end
end
return out
end
-- 排序:by = "name"|"server"|"proto"|"port"
function M.sort(nodes, by, desc)
by = by or "name"
desc = desc and true or false
table.sort(nodes, function(a, b)
local av, bv = a[by], b[by]
if by == "port" then
av, bv = tonumber(av) or 0, tonumber(bv) or 0
else
-- 字段值可能是数字(例如 Clash YAML 里 `- name: 123`),
-- 数字和字符串直接比较会抛 "attempt to compare number with string",
-- 整个节点列表页就崩了;统一转成字符串再比。
av = tostring(av == nil and "" or av)
bv = tostring(bv == nil and "" or bv)
end
if av == bv then return false end
if desc then return av > bv else return av < bv end
end)
return nodes
end
-- 重命名单个节点
function M.rename(node, new_name)
if type(node) == "table" and new_name then
node.name = new_name
end
return node
end
-- 逗号分隔列表 → 数组,逐项去空白。
-- 与 split_keywords 的区别:不做小写化(group / template 名是大小写敏感的)。
-- 原来各过滤器直接 gmatch("[^,]+") 不去空白,"vmess, vless" 会把 " vless"
-- (带前导空格)当成一个协议名,于是只剩 1 个节点且不报错。
local function split_list(s)
local out = {}
for item in (s or ""):gmatch("[^,]+") do
item = item:match("^%s*(.-)%s*$")
if item ~= "" then out[#out + 1] = item end
end
return out
end
-- 将关键词串拆分为数组(英文逗号/中文逗号/空白分隔),忽略空项,并统一小写
local function split_keywords(s)
local out = {}
for kw in (s or ""):gmatch("[^,%s,]+") do
kw = kw:lower()
if kw ~= "" then out[#out + 1] = kw end
end
return out
end
-- 判断节点 name/server 是否命中任一关键词
local function match_keywords(n, kws)
local name = (n.name or ""):lower()
local server = (n.server or ""):lower()
for _, kw in ipairs(kws) do
if name:find(kw, 1, true) or server:find(kw, 1, true) then
return true
end
end
return false
end
-- 应用规则集到节点列表
function M.apply_rules(nodes, rules)
rules = rules or {}
-- 协议过滤
if rules.proto_filter and rules.proto_filter ~= "" then
local set = {}
for _, p in ipairs(split_list(rules.proto_filter)) do set[p] = true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.proto] then out[#out+1]=n end
end
nodes = out
end
-- 分组过滤
if rules.group_filter and rules.group_filter ~= "" then
local set = {}
for _, g in ipairs(split_list(rules.group_filter)) do set[g]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.group] then out[#out+1]=n end
end
nodes = out
end
-- 标签包含
if rules.tags_include and rules.tags_include ~= "" then
local set = {}
for _, t in ipairs(split_list(rules.tags_include)) do set[t]=true end
local out = {}
for _,n in ipairs(nodes) do
local match = false
for tag in pairs(set) do
if has_tag(n, tag) then match = true; break end
end
if match then out[#out+1]=n end
end
nodes = out
end
-- 模板过滤
if rules.template_filter and rules.template_filter ~= "" then
local set = {}
for _, tmpl in ipairs(split_list(rules.template_filter)) do set[tmpl]=true end
local out = {}
for _,n in ipairs(nodes) do
if set[n.template] then out[#out+1]=n end
end
nodes = out
end
-- 关键词包含
if rules.keyword_include and rules.keyword_include ~= "" then
local kws = split_keywords(rules.keyword_include)
local out = {}
for _,n in ipairs(nodes) do
if match_keywords(n, kws) then out[#out+1]=n end
end
nodes = out
end
-- 关键词排除
if rules.keyword_exclude and rules.keyword_exclude ~= "" then
local kws = split_keywords(rules.keyword_exclude)
local out = {}
for _,n in ipairs(nodes) do
if not match_keywords(n, kws) then out[#out+1]=n end
end
nodes = out
end
-- 去重
if rules.dedup == "1" or rules.dedup == true then
nodes = M.dedup(nodes)
end
-- 重命名规则(精确匹配 / 正则 / 模板,统一走重命名引擎)
if rules.rename_map and rules.rename_map ~= "" then
nodes = M.rename_with_rules(nodes, M.parse_rename_rules(rules.rename_map))
end
-- 模板应用:若节点有 template,则生成 url(简单占位符替换)
if rules.template_apply == true or rules.template_apply == "1" then
for _,n in ipairs(nodes) do
if n.template and n.template ~= "" and not n.url then
-- 替换值按字面处理:gsub 替换串里的 `%` 有语义,裸 `%` 会被吞掉、
-- 结尾的 `%` 会注入 NUL 字节(与 build_replacement 同一类问题)。
local url = n.template
url = url:gsub("{server}", util.gsub_literal(n.server))
url = url:gsub("{port}", util.gsub_literal(n.port))
url = url:gsub("{uuid}", util.gsub_literal(n.uuid or n.password))
url = url:gsub("{name}", util.gsub_literal(n.name))
n.url = url
end
end
end
return nodes
end
-- ---------- 分组 ----------
-- 按字段分组:返回 { [字段值] = {节点...} }
function M.group_by(nodes, field)
local groups = {}
for _, n in ipairs(nodes) do
local key = tostring(n[field] or "")
if not groups[key] then groups[key] = {} end
groups[key][#groups[key] + 1] = n
end
return groups
end
-- 按自身 group 字段分组(缺省组 "")
function M.group_nodes(nodes)
return M.group_by(nodes, "group")
end
-- 按规则为节点设置 group:rules = { {field=..., prefix=...}, ... }
function M.add_group(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
for _, r in ipairs(rules) do
if type(r) == "table" and r.field then
n.group = (r.prefix or "") .. tostring(n[r.field] or "")
end
end
end
return nodes
end
-- 按组名过滤
function M.filter_by_group(nodes, group_name)
local out = {}
for _, n in ipairs(nodes) do
if n.group == group_name then out[#out + 1] = n end
end
return out
end
-- ---------- 标签 ----------
-- 为节点打标签。两种模式:
-- 数组模式:add_tags(nodes, {"vip"}) 追加字符串标签(tags 存为数组)
-- 规则模式:add_tags(nodes, {{type="keyword", value="HK", tag="hongkong"}}) 匹配后写 tag(tags 存为 set)
function M.add_tags(nodes, tags)
tags = tags or {}
if type(tags) == "string" then tags = { tags } end
if type(tags) ~= "table" then return nodes end
-- 规则模式:元素为 {type, value, tag}
if tags[1] and type(tags[1]) == "table" then
for _, n in ipairs(nodes) do
if not n.tags or type(n.tags) ~= "table" then n.tags = {} end
for _, rule in ipairs(tags) do
local matched = false
if rule.type == "keyword" then
local kw = rule.value or ""
if kw ~= "" and ((n.name or ""):find(kw, 1, true) or (n.server or ""):find(kw, 1, true)) then
matched = true
end
end
if matched and rule.tag then
n.tags[rule.tag] = true
end
end
end
return nodes
end
-- 数组模式:追加字符串标签(去重)
for _, n in ipairs(nodes) do
if not n.tags then n.tags = {} end
if type(n.tags) ~= "table" then n.tags = {} end
local has = {}
for _, t in ipairs(n.tags) do has[t] = true end
for _, t in ipairs(tags) do
if not has[t] then
n.tags[#n.tags + 1] = t
has[t] = true
end
end
end
return nodes
end
-- 按标签集合过滤:要求节点拥有 tags 中的所有标签
function M.filter_by_tags(nodes, tags)
local out = {}
for _, n in ipairs(nodes) do
local match = true
if type(tags) == "string" then tags = { tags } end
for _, t in ipairs(tags) do
if not has_tag(n, t) then match = false; break end
end
if match then out[#out + 1] = n end
end
return out
end
-- ---------- 重命名规则引擎 ----------
-- 解析重命名规则字符串为规则表列表
-- 支持格式(每行一条,支持 # 注释):
-- "旧名称=新名称"(精确匹配,type="exact")
-- "pattern -> replacement"(正则替换,type="regex")
-- "{server}_{port}_{proto}"(含 {var} 占位符,type="template")
-- line_no 记录规则在原始文本里的行号(从 1 起),仅用于报错时定位到用户写的那一行
function M.parse_rename_rules(rule_str)
rule_str = rule_str or ""
local rules = {}
local no = 0
for line in rule_str:gmatch("[^\r\n]+") do
no = no + 1
line = line:match("^%s*(.-)%s*$")
if line ~= "" and not line:match("^#") then
local pat, repl = line:match("^(.-)%s*%-%>%s*(.+)$")
if pat then
rules[#rules + 1] = { type = "regex", pattern = pat, replacement = repl, line_no = no }
elseif line:find("{", 1, true) then
rules[#rules + 1] = { type = "template", template = line, line_no = no }
else
local k, v = line:match("^([^=]+)=(.*)$")
if k and v then
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$"), line_no = no }
end
end
end
end
return rules
end
-- 展开模板:替换 {var} 占位符
-- gsub 的**替换串**里 % 有特殊含义(%1 反向引用、%% 转义),而节点数据是不可信输入:
-- 名字里带 "%" 时会被静默吞掉("50% OFF" → "50 OFF"),更糟的是能拼出 %0,
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
-- 所以替换前必须先把值里的 % 转义成 %%。
local function expand_template(template, n)
local esc = util.gsub_literal
local out = template
out = out:gsub("{server}", esc(n.server))
out = out:gsub("{port}", esc(n.port))
out = out:gsub("{proto}", esc(n.proto))
out = out:gsub("{name}", esc(n.name))
out = out:gsub("{uuid}", esc(n.uuid))
out = out:gsub("{password}", esc(n.password))
out = out:gsub("{group}", esc(n.group))
return out
end
-- 正则风格 → Lua pattern 的转义映射。
-- Lua pattern 里转义符是 %,所以 \d 之类要改写;\d \D \w \W \s \S 与 Lua 的
-- %d %D %w %W %s %S 一一对应。
-- \b \B(词边界)在 Lua pattern 里**没有**对应写法:原来一律把 \ 换成 %,
-- 于是 \b 变成了 %b —— 那是「成对匹配」(%bxy 匹配 x…y),语义完全不同,
-- 而且经常直接抛错、被下面的 pcall 吞掉。这里按「无操作」处理。
local REGEX_ESCAPE = {
d = "%d", D = "%D", w = "%w", W = "%W", s = "%s", S = "%S",
b = "", B = "",
}
-- 把「正则里是普通字符、Lua pattern 里却是元字符」的字符转义。
-- 最要命的是 `-`:正则里在字符类外就是普通字符("HK-01"、"Node-42"),
-- Lua pattern 里却是「懒惰量词」,于是 `Node-(\d+)` 被解释成 Nod + e- + 数字,
-- 永远匹配不上,而且不报错(pcall 也吞不掉,因为根本没抛错)。
-- 字符类 [...] 内的 - 是范围([a-z]),两边语义一致,保持原样。
-- `\x` 按 REGEX_ESCAPE 映射(\d→%d,\b→空),其余转义字符按 Lua 写法 %x。
local function regex_to_lua(pat)
local out, i, in_class = {}, 1, false
local n = #pat
while i <= n do
local c = pat:sub(i, i)
if c == "\\" and i < n then
local nx = pat:sub(i + 1, i + 1)
local m = REGEX_ESCAPE[nx]
out[#out + 1] = (m ~= nil) and m or ("%" .. nx)
i = i + 2
elseif c == "[" then
in_class = true; out[#out + 1] = c; i = i + 1
elseif c == "]" then
in_class = false; out[#out + 1] = c; i = i + 1
elseif c == "-" and not in_class then
out[#out + 1] = "%-"; i = i + 1
else
out[#out + 1] = c; i = i + 1
end
end
return table.concat(out)
end
-- 把 `a|b` 拆成多个候选(**仅顶层** |)。
-- 正则的「或」在 Lua pattern 里不存在,原来整个规则会静默不匹配。
-- 只拆括号深度为 0、且不在字符类 [] 里的 |:
-- * `[...]` 里的 | 是字面字符(Lua 的 [a|b] 匹配 a、| 或 b),拆开会破坏字符类
-- * `(...)` 里的 | 属于分组内部。Lua pattern 的 () 只是捕获,没有「或」语义,
-- 拆开只会得到 `^(HK` / `US` / `JP)%-(.*)$` 这种残缺模式——比不拆更糟:
-- 每一段都匹配不上,还静默无提示。分组内的「或」明确不支持,按字面处理。
local function split_alternatives(pat)
local alts, buf = {}, {}
local depth, in_class, i = 0, false, 1
local n = #pat
-- 必须是 while 而不是 for:下面遇到 % 转义序列要一次吃掉两个字符,
-- Lua 的数值 for 会在每轮重新赋值控制变量,循环体内改 i 不生效。
-- 也不用 goto(Lua 5.1 没有)。
while i <= n do
local c = pat:sub(i, i)
if c == "%" then
-- Lua 转义序列整体保留(regex_to_lua 已产出 %d / %- 这类两字符序列)
buf[#buf + 1] = c .. pat:sub(i + 1, i + 1)
i = i + 2
elseif c == "|" and depth == 0 and not in_class then
alts[#alts + 1] = table.concat(buf)
buf = {}
i = i + 1
else
if in_class then
if c == "]" then in_class = false end
elseif c == "[" then
in_class = true
elseif c == "(" then
depth = depth + 1
elseif c == ")" and depth > 0 then
depth = depth - 1
end
buf[#buf + 1] = c
i = i + 1
end
end
alts[#alts + 1] = table.concat(buf)
return alts
end
-- 按规则链式重命名节点(就地修改)
-- 把用户写的替换串翻译成 string.gsub 的替换串。
--
-- 两个坑:
-- * 捕获引用写的是 `$1`(正则风格),而 gsub 要的是 `%1`,必须转换。
-- * 其余字符必须按**字面**处理。用户写的 `%` 若原样传进 gsub,就落进了 gsub
-- 的替换串语义:`%` 后接非数字字符时被吞掉("50%off" → "50off"),
-- 结尾的 `%` 会注入一个 NUL 字节("100%" → "100\0")。这不是显示问题——
-- 节点名会写进节点文件并下发给所有客户端。
-- 原先只做了 `$` → `%` 的替换,没有转义字面 `%`,上述两种损坏都会发生。
local function build_replacement(rep)
rep = rep or ""
local out, i, n = {}, 1, #rep
while i <= n do
local c = rep:sub(i, i)
local nx = rep:sub(i + 1, i + 1)
if c == "$" and nx:match("%d") then
-- $1..$9 → %1..%9(gsub 的捕获引用)
out[#out + 1] = "%" .. nx
i = i + 2
elseif c == "%" then
-- 字面百分号:转义成 %%
out[#out + 1] = "%%"
i = i + 1
else
out[#out + 1] = c
i = i + 1
end
end
return table.concat(out)
end
-- 校验重命名规则串里的正则是否可用。返回 ok, err。
--
-- 用户写的 pattern 经 regex_to_lua 转换后交给 string.gsub;非法 pattern(未闭合的
-- `[`、结尾的 `%` 等)会让 gsub 抛错。rename_with_rules 里那个 pcall 会把它吞掉,
-- 结果是「规则明明写了却完全不生效,页面上没有任何提示」—— 用户只会觉得功能坏了。
-- 在**保存时**校验并回显,用户才知道错在哪一行。
function M.validate_rename_map(rule_str)
for _, r in ipairs(M.parse_rename_rules(rule_str)) do
if r.type == "regex" then
local pat = regex_to_lua(r.pattern or "")
local repl = build_replacement(r.replacement)
local ok = pcall(function()
-- 空串足以让 gsub 编译 pattern 与替换串;抛错即说明写法非法
for _, alt in ipairs(split_alternatives(pat)) do
(""):gsub(alt, repl)
end
end)
if not ok then
return false, msg.compose("Rename rule line ", r.line_no or 0,
": invalid regular expression (", r.pattern or "", ")")
end
end
end
return true
end
function M.rename_with_rules(nodes, rules)
rules = rules or {}
for _, n in ipairs(nodes) do
for _, r in ipairs(rules) do
if r.type == "exact" then
if n.name == r.old then n.name = r.new end
elseif r.type == "template" and r.template then
n.name = expand_template(r.template, n)
elseif r.type == "regex" then
local name = n.name or ""
-- 正则风格 → Lua pattern:\d -> %d、$1 -> %1、HK-01 里的 - 转义成 %-
local pat = regex_to_lua(r.pattern or "")
local repl = build_replacement(r.replacement)
local ok, result = pcall(function()
-- 顶层 | 拆成多个候选依次替换(Lua pattern 没有「或」)
local s = name
for _, alt in ipairs(split_alternatives(pat)) do
s = s:gsub(alt, repl)
end
return s
end)
if ok then
n.name = result
end
end
end
end
return nodes
end
return M