mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-31 21:55:13 +08:00
🍕 Sync 2026-10-01 13:33:56
This commit is contained in:
1 parent
9057fa94b1
commit
1f3a4e8fb4
24 files changed
+731
-83
No files matched your search
@@ -90,10 +90,15 @@ local function read_cron_fields()
|
||||
return cron_enable, cron_time
|
||||
end
|
||||
|
||||
-- read subscription level rules from form, return rules table
|
||||
-- read subscription level rules from form, return rules table, err
|
||||
--
|
||||
-- 第二个返回值是**用户可见**的校验错误。目前只有重命名规则的正则需要校验:
|
||||
-- 非法 pattern 会被 node.rename_with_rules 里的 pcall 静默吞掉,用户看到的是
|
||||
-- 「规则写了但没生效」。在这里拦下来,调用方用 back_to_list(err) 回显。
|
||||
local function read_rules_fields()
|
||||
local http = require("luci.http")
|
||||
local core = require("substore.core")
|
||||
local node = require("substore.node")
|
||||
local proto_list = {}
|
||||
-- 协议清单来自 core.RULE_PROTOS,与表单模板共用一份(见该常量的说明)
|
||||
for _, p in ipairs(core.RULE_PROTOS) do
|
||||
@@ -101,13 +106,16 @@ local function read_rules_fields()
|
||||
end
|
||||
local rules_enable = fv(http, "rules_enable") or "0"
|
||||
if rules_enable ~= "1" then rules_enable = "0" end
|
||||
local rename_map = fv(http, "rename_map") or ""
|
||||
local vok, verr = node.validate_rename_map(rename_map)
|
||||
if not vok then return nil, verr end
|
||||
return {
|
||||
rules_enable = rules_enable,
|
||||
proto_filter = table.concat(proto_list, ","),
|
||||
keyword_include = fv(http, "keyword_include") or "",
|
||||
keyword_exclude = fv(http, "keyword_exclude") or "",
|
||||
dedup = fv(http, "dedup") or "0",
|
||||
rename_map = fv(http, "rename_map") or "",
|
||||
rename_map = rename_map,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -124,7 +132,9 @@ function action_create()
|
||||
return back_to_list("名称和 URL 不能为空")
|
||||
end
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local id, err = core.add(name, url, {
|
||||
proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
@@ -153,7 +163,9 @@ function action_save()
|
||||
return back_to_list("名称和 URL 不能为空")
|
||||
end
|
||||
local cron_enable, cron_time = read_cron_fields()
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local ok, err = core.save_meta(id, {
|
||||
name = name, url = url, proxy_enable = proxy_enable, proxy = proxy,
|
||||
cron_enable = cron_enable, cron_time = cron_time,
|
||||
@@ -174,7 +186,9 @@ function action_local_create()
|
||||
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = fv(http, "content") or ""
|
||||
local local_mode = fv(http, "local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
-- §19:空名称 / 空内容必须明确报错,不能无声创建无效订阅
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
if content == "" then return back_to_list("订阅内容不能为空") end
|
||||
@@ -198,7 +212,9 @@ function action_local_save()
|
||||
local name = (fv(http, "name") or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||
local content = fv(http, "content") or ""
|
||||
local local_mode = fv(http, "local_mode") or "text"
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
if id == "" then return back_to_list("缺少订阅 ID") end
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
if content == "" then return back_to_list("订阅内容不能为空") end
|
||||
@@ -370,7 +386,9 @@ function action_combo_save()
|
||||
-- §18:此前名称留空、或一个来源都没勾选时,这里整段跳过、直接跳回列表 ——
|
||||
-- 用户填了表单却什么都没发生,页面上也没有任何提示。
|
||||
if name == "" then return back_to_list("名称不能为空") end
|
||||
local rules = read_rules_fields()
|
||||
-- 规则校验失败(如非法正则)必须回显,否则用户看到的是「规则没生效」
|
||||
local rules, rules_err = read_rules_fields()
|
||||
if not rules then return back_to_list(rules_err or "规则无效") end
|
||||
local o = {
|
||||
rules_enable = rules.rules_enable, proto_filter = rules.proto_filter,
|
||||
keyword_include = rules.keyword_include, keyword_exclude = rules.keyword_exclude,
|
||||
|
||||
@@ -18,17 +18,18 @@
|
||||
local rm = (it and it.rename_map) or ""
|
||||
local local_mode = (it and it.local_mode) or "text"
|
||||
local content = (it and it.raw_content) or ""
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,转义 "</" 防止提前闭合 script
|
||||
-- 注入 JS 的初始内容:JSON 字符串字面量,把全部 "<" 转成 <,
|
||||
-- 同时挡住 "</script" 提前闭合与 "<!--" 吞掉其后整页(详见 nodes.htm 同名注释)
|
||||
local util = require("substore.util")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("</", "<\\/")
|
||||
local js_content = (util.json_encode(content) or '""'):gsub("<", "\\u003c")
|
||||
-- local_mode 是用户可提交的字段,直接拼进 JS 字符串会被闭合引号注入;
|
||||
-- 归一为白名单取值后用 json_encode 输出,保证是合法的 JS 字符串字面量
|
||||
if local_mode ~= "form" then local_mode = "text" end
|
||||
local js_mode = util.json_encode(local_mode) or '"text"'
|
||||
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
|
||||
local node_mod = require("substore.node")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
|
||||
%>
|
||||
<h2><%=is_edit and luci.i18n.translate("Edit local subscription") or luci.i18n.translate("Add local subscription")%></h2>
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
local meta = id ~= "" and core.get(id) or nil
|
||||
local nodes = id ~= "" and core.read_nodes(id) or {}
|
||||
local n = nodes[idx]
|
||||
-- 注入 JS 的节点对象:转义 "</" 防止提前闭合 script
|
||||
local js_node = n and ((util.json_encode(n) or "null"):gsub("</", "<\\/")) or "null"
|
||||
-- 注入 JS 的节点对象:把全部 "<" 转成 <,同时挡住 "</script" 提前闭合
|
||||
-- 与 "<!--" 让脚本块吞掉其后整页(详见 nodes.htm 同名注释)
|
||||
local js_node = n and ((util.json_encode(n) or "null"):gsub("<", "\\u003c")) or "null"
|
||||
-- 协议与字段清单:与 core.merge_form_node 共用 substore/node.lua 的同一份数据
|
||||
local node_mod = require("substore.node")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("</", "<\\/")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("</", "<\\/")
|
||||
local js_protos = (util.json_encode(node_mod.PROTOS) or "[]"):gsub("<", "\\u003c")
|
||||
local js_proto_fields = (util.json_encode(node_mod.PROTO_FIELDS) or "{}"):gsub("<", "\\u003c")
|
||||
local back_url = luci.dispatcher.build_url('admin/services/substore/nodes') .. "?id=" .. luci.util.urlencode(id)
|
||||
%>
|
||||
<h2><%:Edit node%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
|
||||
|
||||
@@ -18,8 +18,11 @@
|
||||
-- 但只有 JSON 编码还不够:json_encode 不转义 "<",id 直接来自查询串,
|
||||
-- 形如 "</script><script>alert(1)</script>" 的值会在 HTML 词法阶段就闭合
|
||||
-- script 元素(与 JS 字符串上下文无关),剩下的部分被当作 HTML 执行。
|
||||
-- 转义 "</" 即可:在 JS 字符串里 "\/" 就是 "/",而 HTML 不再看到 "</script"。
|
||||
local js_id = (util.json_encode(id) or '""'):gsub("</", "<\\/")
|
||||
-- 把**全部** "<" 转成 <:在 JS/JSON 字符串里这就是 "<",而 HTML 词法
|
||||
-- 阶段再也看不到 "<",于是 "</script"(提前闭合)与 "<!--"(进入 script
|
||||
-- data escaped 状态,后面的 </script> 不再结束脚本块,整页被吞进脚本)两个
|
||||
-- 坑一并堵上。只转 "</" 挡不住 "<!--"。
|
||||
local js_id = (util.json_encode(id) or '""'):gsub("<", "\\u003c")
|
||||
|
||||
-- 标注原始数组下标(filter 保留表引用、sort 原地排序,下标可穿透筛选排序)
|
||||
for i, nn in ipairs(nodes) do nn.__idx = i end
|
||||
@@ -47,7 +50,7 @@
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
|
||||
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
</div>
|
||||
<% end %>
|
||||
<h2><%:Nodes%> — <%=luci.util.pcdata(meta and meta.name or "")%></h2>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
if flash_err and flash_err ~= "" then
|
||||
%>
|
||||
<div class="cbi-section" style="border-left:4px solid #cc0000;padding:0.5em 0.75em;background:#fdf0f0;color:#cc0000">
|
||||
<strong><%:操作失败%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
<strong><%:Operation failed%>:</strong> <%=luci.util.pcdata(flash_err)%>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -103,7 +103,7 @@
|
||||
onclick="var e=document.getElementById('link_<%=it.id%>');e.select();document.execCommand('copy');"><%:Copy%></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value-description"><%:选择格式后生成订阅链接,格式可随时切换%></div>
|
||||
<div class="cbi-value-description"><%:Pick a format to generate the subscription link; you can switch formats at any time%></div>
|
||||
</td>
|
||||
<td>
|
||||
<% if it["local"] then %>
|
||||
|
||||
@@ -125,10 +125,12 @@ function M.convert_protocol(node, target_proto)
|
||||
new_node.port = node.port
|
||||
end
|
||||
|
||||
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码
|
||||
-- trojan -> vmess/vless 生成新 uuid,避免直接复用密码。
|
||||
-- 必须是真的 UUID:客户端的 uuid 字段会按 16 字节解析,格式不对时多数客户端
|
||||
-- 直接拒绝该节点。原来写的是 base64(seed) 截前 36 位 —— base64 只产出 24 个
|
||||
-- 字符(`sub(1,36)` 是空操作),里面还可能带 `+` `/` `=`,不是合法 UUID。
|
||||
if src == "trojan" and (tgt == "vmess" or tgt == "vless") then
|
||||
local seed = tostring(os.time()) .. tostring(math.random(100000, 999999))
|
||||
new_node.uuid = util.base64_encode(seed):sub(1, 36)
|
||||
new_node.uuid = util.uuid()
|
||||
end
|
||||
|
||||
-- 丢弃源协议专属字段(不带入转换后的协议)
|
||||
@@ -251,11 +253,14 @@ function M.apply_template(nodes, template)
|
||||
local cp = {}
|
||||
for k, v in pairs(n) do cp[k] = v end
|
||||
if cp.name and template ~= "" then
|
||||
-- 替换值一律按**字面**处理:gsub 的替换串里 `%` 有语义
|
||||
-- (`%1` 是捕获引用,`%%` 才是字面百分号,裸 `%` 会被吞掉或注入 NUL)。
|
||||
-- 节点名来自订阅内容,必须原样落地。
|
||||
local new_name = template
|
||||
new_name = new_name:gsub("{{name}}", cp.name or "")
|
||||
new_name = new_name:gsub("{{proto}}", cp.proto or "")
|
||||
new_name = new_name:gsub("{{server}}", cp.server or "")
|
||||
new_name = new_name:gsub("{{port}}", tostring(cp.port or ""))
|
||||
new_name = new_name:gsub("{{name}}", util.gsub_literal(cp.name))
|
||||
new_name = new_name:gsub("{{proto}}", util.gsub_literal(cp.proto))
|
||||
new_name = new_name:gsub("{{server}}", util.gsub_literal(cp.server))
|
||||
new_name = new_name:gsub("{{port}}", util.gsub_literal(cp.port))
|
||||
cp.name = new_name
|
||||
end
|
||||
out[#out + 1] = cp
|
||||
|
||||
@@ -7,7 +7,7 @@ local parser = require("substore.parser")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.version = "2.6.8"
|
||||
M.version = "2.6.9"
|
||||
M.DATA_DIR = "/etc/substore"
|
||||
M.LIST_FILE = M.DATA_DIR .. "/subscriptions.json"
|
||||
M.NODES_DIR = M.DATA_DIR .. "/nodes"
|
||||
@@ -28,9 +28,20 @@ local function id_is_valid(id)
|
||||
return type(id) == "string" and id ~= "" and id:match("^[A-Za-z0-9_%-]+$") ~= nil
|
||||
end
|
||||
|
||||
-- 目录权限只收紧一次(每进程)。load() 每次读列表都会调用 ensure_dirs(),
|
||||
-- 无条件 chmod 会让每次读取都多 fork 两个子进程。
|
||||
local dirs_secured = false
|
||||
function M.ensure_dirs()
|
||||
util.ensure_dir(M.DATA_DIR)
|
||||
util.ensure_dir(M.NODES_DIR)
|
||||
util.ensure_dir(M.DATA_DIR, "700")
|
||||
util.ensure_dir(M.NODES_DIR, "700")
|
||||
if not dirs_secured then
|
||||
dirs_secured = true
|
||||
-- mkdir -m 只对**本次新建**的目录生效;从旧版本升级上来的机器上目录已存在,
|
||||
-- 权限仍是当初按 umask 建的(通常 0755),所以这里显式再 chmod 一次。
|
||||
-- 目录里是订阅 URL、公开下载 token 与节点凭据,0700 只留 root。
|
||||
util.chmod(M.DATA_DIR, "700")
|
||||
util.chmod(M.NODES_DIR, "700")
|
||||
end
|
||||
end
|
||||
|
||||
-- 读取订阅列表,返回 seq, items, err。
|
||||
@@ -73,7 +84,8 @@ end
|
||||
|
||||
local function save(seq, items)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }))
|
||||
-- 0600:列表里有订阅 URL 与公开下载 token
|
||||
return util.atomic_write(M.LIST_FILE, util.json_encode({ _seq = seq, items = items }), "600")
|
||||
end
|
||||
|
||||
-- 返回 arr, err。err 非空表示列表文件已损坏。
|
||||
@@ -248,7 +260,8 @@ end
|
||||
|
||||
function M.write_nodes(id, nodes)
|
||||
M.ensure_dirs()
|
||||
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes))
|
||||
-- 0600:节点文件里有 uuid / 密码 / 私钥等全部凭据
|
||||
return util.atomic_write(M.nodes_file(id), util.json_encode(nodes), "600")
|
||||
end
|
||||
|
||||
function M.read_nodes(id)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
-- node.lua — 统一节点模型(纯 Lua)
|
||||
-- luci-app-substore
|
||||
|
||||
-- util 是叶子模块(自身不 require 任何 substore.*),所以这里不会形成循环依赖。
|
||||
local util = require("substore.util")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.PROTOS = {
|
||||
@@ -366,11 +369,13 @@ function M.apply_rules(nodes, rules)
|
||||
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}", n.server or "")
|
||||
url = url:gsub("{port}", tostring(n.port or ""))
|
||||
url = url:gsub("{uuid}", n.uuid or n.password or "")
|
||||
url = url:gsub("{name}", n.name or "")
|
||||
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
|
||||
@@ -485,21 +490,24 @@ 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 }
|
||||
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 }
|
||||
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*$") }
|
||||
rules[#rules + 1] = { type = "exact", old = k:match("^%s*(.-)%s*$"), new = v:match("^%s*(.-)%s*$"), line_no = no }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -513,7 +521,7 @@ end
|
||||
-- 在结果里产生 NUL 字节并一路写进节点名、写盘、下发到各订阅文件。
|
||||
-- 所以替换前必须先把值里的 % 转义成 %%。
|
||||
local function expand_template(template, n)
|
||||
local function esc(v) return (tostring(v == nil and "" or v):gsub("%%", "%%%%")) end
|
||||
local esc = util.gsub_literal
|
||||
local out = template
|
||||
out = out:gsub("{server}", esc(n.server))
|
||||
out = out:gsub("{port}", esc(n.port))
|
||||
@@ -639,6 +647,32 @@ local function build_replacement(rep)
|
||||
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
|
||||
|
||||
@@ -129,8 +129,13 @@ M.FORMAT_OPTIONS = {
|
||||
|
||||
-- 统一分发:nodes → 目标格式字符串
|
||||
function M.generate(nodes, format, options)
|
||||
format = (format or DEFAULT_FORMAT) or DEFAULT_FORMAT
|
||||
if type(format) ~= "string" then format = DEFAULT_FORMAT end
|
||||
-- 空串 / 纯空白等同于「未指定」。
|
||||
-- `?target=` 会传进来 "",而 "" 在 Lua 里是**真值**,所以 `format or DEFAULT_FORMAT`
|
||||
-- 兜不住它,会一路落到 "unsupported format: "(冒号后面什么都没有)——
|
||||
-- 用户拿到的是一个说不出原因的错误页。控制器只在 nil 时兜底,覆盖不到空串。
|
||||
if type(format) ~= "string" then format = "" end
|
||||
format = format:match("^%s*(.-)%s*$") or ""
|
||||
if format == "" then format = DEFAULT_FORMAT end
|
||||
local norm = M.FORMAT_ALIASES[format:lower():gsub("[-_%s]", "")]
|
||||
if not norm then norm = M.FORMAT_ALIASES[format:lower()] end
|
||||
if not norm then return nil, "unsupported format: " .. tostring(format) end
|
||||
|
||||
@@ -8,6 +8,11 @@ local M = {}
|
||||
|
||||
M.TIMEOUT = 2 -- 每个目标的超时(秒)
|
||||
|
||||
-- 批量探测时同时存在的子进程上限。
|
||||
-- 每个探测占 1 个进程 + 1 个管道 fd,节点数由订阅内容决定(可上千),
|
||||
-- 不限并发会把路由器的 fd/进程额度打满,之后 io.popen 静默失败。
|
||||
M.MAX_PARALLEL = 16
|
||||
|
||||
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
|
||||
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
|
||||
-- 命令替换,实测可注入。
|
||||
@@ -145,26 +150,39 @@ end
|
||||
-- 返回结果数组 { name=, server=, port=, latency=number|nil }
|
||||
function M.probe(nodes, mode)
|
||||
local jobs = {}
|
||||
-- 分批:一批最多 MAX_PARALLEL 个进程,读完并关闭这一批再起下一批。
|
||||
--
|
||||
-- 原来是「先把全部节点的 io.popen 起完,再统一读取」。并行度确实最高
|
||||
-- (总耗时≈单节点超时),但代价是无界的:每个节点占 1 个进程 + 1 个管道 fd,
|
||||
-- 而节点数由订阅内容决定(上千个很常见)。路由器上 fd 与进程数都是硬上限,
|
||||
-- 打满之后 io.popen 直接失败 —— 表现是一大片节点探测不出来,
|
||||
-- 而不是报错。分批后并发度封顶,总耗时仍是「批数 × 单节点超时」。
|
||||
local out = {}
|
||||
local batch = {}
|
||||
local function drain()
|
||||
for _, j in ipairs(batch) do
|
||||
local latency
|
||||
if j.f then
|
||||
local raw = j.f:read("*a") or ""
|
||||
j.f:close()
|
||||
latency = j.parse and j.parse(raw) or nil
|
||||
end
|
||||
out[#out + 1] = {
|
||||
name = j.name, server = j.server, port = j.port, latency = latency,
|
||||
}
|
||||
end
|
||||
batch = {}
|
||||
end
|
||||
for _, n in ipairs(nodes or {}) do
|
||||
local cmd, parse = build_job(mode, n.server, n.port)
|
||||
local f = cmd and io.popen(cmd) or nil
|
||||
jobs[#jobs + 1] = {
|
||||
batch[#batch + 1] = {
|
||||
f = f, parse = parse,
|
||||
name = n.name or "", server = n.server or "", port = n.port or "",
|
||||
}
|
||||
if #batch >= M.MAX_PARALLEL then drain() end
|
||||
end
|
||||
local out = {}
|
||||
for _, j in ipairs(jobs) do
|
||||
local latency
|
||||
if j.f then
|
||||
local raw = j.f:read("*a") or ""
|
||||
j.f:close()
|
||||
latency = j.parse and j.parse(raw) or nil
|
||||
end
|
||||
out[#out + 1] = {
|
||||
name = j.name, server = j.server, port = j.port, latency = latency,
|
||||
}
|
||||
end
|
||||
drain()
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
@@ -124,6 +124,33 @@ function M.rnd_hex(len)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- RFC 4122 version 4 UUID(8-4-4-4-12 十六进制)。
|
||||
--
|
||||
-- 客户端的 uuid 字段要的是**合法 UUID**,不是「一串随机字符」:
|
||||
-- vmess/vless 的 uuid 会被解析成 16 字节,格式不对时多数客户端直接拒绝该节点。
|
||||
-- 所以这里按规范置版本位(第 13 位十六进制 = 4)与变体位(第 17 位 ∈ 8/9/a/b),
|
||||
-- 而不是随便取 36 个字符。随机源复用 rnd_hex(优先 /dev/urandom)。
|
||||
-- 32 个十六进制字符按 8-4-4-4-12 切分;第 13 位固定 '4'(版本),
|
||||
-- 第 17 位取 8/9/a/b(变体,即高两位为二进制的 10)。
|
||||
function M.uuid()
|
||||
local h = M.rnd_hex(32)
|
||||
if #h < 32 then return nil end
|
||||
local variant = tonumber(h:sub(17, 17), 16) % 4 + 8 -- 8..11 → 8/9/a/b
|
||||
return string.format("%s-%s-4%s-%x%s-%s",
|
||||
h:sub(1, 8), h:sub(9, 12), h:sub(14, 16),
|
||||
variant, h:sub(18, 20),
|
||||
h:sub(21, 32))
|
||||
end
|
||||
|
||||
-- 把任意字符串转成 string.gsub 的**字面**替换串。
|
||||
--
|
||||
-- gsub 的替换串有自己的语义:`%` 后接数字是捕获引用,`%%` 才是字面百分号,
|
||||
-- 而 `%` 后接其它字符会被吞掉、结尾的 `%` 会注入 NUL 字节。
|
||||
-- 用户提供的替换值(节点名、模板变量)必须原样落地,所以先转义。
|
||||
function M.gsub_literal(s)
|
||||
return (tostring(s or ""):gsub("%%", "%%%%"))
|
||||
end
|
||||
|
||||
-- ---------- URL ----------
|
||||
function M.url_decode(s)
|
||||
if type(s) ~= "string" then return "" end
|
||||
@@ -411,6 +438,12 @@ function M.file_size(path)
|
||||
return #data
|
||||
end
|
||||
|
||||
-- 修改文件权限。Lua 5.1 标准库**没有** os.chmod(那是 nixio/posix 才有的),
|
||||
-- 所以走 busybox 的 chmod,路径经 shq 引用。mode 传八进制字符串(如 "600")。
|
||||
function M.chmod(path, mode)
|
||||
return os.execute("chmod " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1") == 0
|
||||
end
|
||||
|
||||
-- 原子写:先写临时文件再重命名,避免写一半损坏。
|
||||
--
|
||||
-- 两处必须做对,否则「原子」只是名义上的:
|
||||
@@ -419,7 +452,10 @@ end
|
||||
-- 2) write / close 的返回值必须检查:磁盘写满或写入被截断时 f:write 会失败,
|
||||
-- 但 os.rename 依然成功 —— 于是原子地换上一个残缺文件,调用方却以为成功,
|
||||
-- 下一次读取才发现数据没了。
|
||||
function M.atomic_write(path, content)
|
||||
--
|
||||
-- mode 可选:写盘后把文件权限收紧到该值。数据文件里是订阅 URL、公开下载 token、
|
||||
-- 节点凭据;io.open 按 umask 创建,默认通常是 0644 —— 同机任何用户都能读到。
|
||||
function M.atomic_write(path, content, mode)
|
||||
local tmp = string.format("%s.tmp.%s", path, M.rnd_hex(8))
|
||||
local f, e = io.open(tmp, "wb")
|
||||
if not f then return false, e end
|
||||
@@ -439,11 +475,21 @@ function M.atomic_write(path, content)
|
||||
os.remove(tmp)
|
||||
return false, rerr
|
||||
end
|
||||
-- rename 保留临时文件自身的权限,所以收紧必须在 rename **之后**做:
|
||||
-- 先 chmod 再 rename 的话,临时文件名是公开可猜的,中间窗口里别人仍能读到。
|
||||
if mode then M.chmod(path, mode) end
|
||||
return true
|
||||
end
|
||||
|
||||
function M.ensure_dir(path)
|
||||
os.execute("mkdir -p " .. M.shq(path))
|
||||
-- 建目录。mode 可选,仅在目录**由本次调用创建**时生效 ——
|
||||
-- 对已存在的目录 busybox mkdir 不会改权限(要改得显式 chmod),
|
||||
-- 调用方若需要「无论新建与否都收紧」,用 M.chmod 单独补一次。
|
||||
function M.ensure_dir(path, mode)
|
||||
if mode then
|
||||
os.execute("mkdir -p -m " .. mode .. " " .. M.shq(path) .. " >/dev/null 2>&1")
|
||||
else
|
||||
os.execute("mkdir -p " .. M.shq(path))
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------- 人性化格式 ----------
|
||||
|
||||
Reference in new issue
Block a user