mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 03:12:52 +08:00
709 lines
26 KiB
Lua
709 lines
26 KiB
Lua
-- node.lua — 统一节点模型(纯 Lua)
|
||
-- luci-app-substore
|
||
|
||
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
|
||
local util = require("substore.util")
|
||
|
||
local M = {}
|
||
|
||
M.PROTOS = {
|
||
"vmess", "vless", "trojan", "shadowsocks", "ssr", "hysteria2", "tuic", "hysteria", "wireguard", "socks",
|
||
}
|
||
|
||
-- 每种协议在「表单导入 / 节点编辑」界面里渲染的字段集合,数组顺序即界面顺序。
|
||
--
|
||
-- 这里是**唯一**的字段清单: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" },
|
||
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" },
|
||
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" }
|
||
|
||
-- 协议默认值,用于补全缺省字段
|
||
local DEFAULTS = {
|
||
vmess = { net = "tcp", security = "none" },
|
||
vless = { net = "tcp", security = "none" },
|
||
trojan = { security = "tls" },
|
||
-- hysteria2 / hysteria / tuic 在 sing-box 与 mihomo 里都是 TLS-only:
|
||
-- sing-box 的 hysteria2/tuic 出站在 TLS 缺失或未启用时直接返回 C.ErrTLSRequired
|
||
-- 拒绝启动。URI 解析器(parser.lua)本来就会补 security="tls",但 Clash YAML
|
||
-- 与 sing-box JSON 导入的 hysteria2/tuic 节点没有这个字段,导出后是一份客户端
|
||
-- 起不来的配置。TLS-only 是协议本身的约束,属于归一化该保证的不变量,
|
||
-- 与 trojan 同理。
|
||
hysteria2 = { security = "tls" },
|
||
hysteria = { security = "tls" },
|
||
tuic = { security = "tls" },
|
||
}
|
||
|
||
-- 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
|
||
|
||
-- 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, string.format("重命名规则第 %d 行:正则表达式无效(%s)",
|
||
r.line_no or 0, 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 |