mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-08 13:14:12 +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
@@ -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