🍕 Sync 2026-10-01 13:33:56

This commit is contained in:
github-actions[bot] committed 2026-10-01 13:33:56 +08:00
1 parent 9057fa94b1
commit 1f3a4e8fb4
24 files changed
+731 -83

No files matched your search

@@ -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
-- ---------- 人性化格式 ----------