Files
op-packages/luci-app-substore/root/usr/share/substore/probe.lua
T

190 lines
7.0 KiB
Lua
Raw 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.
-- probe.lua — 节点网络探测(Ping / TCPing / URL 测试)(纯 Lua,无 luci.* 依赖)
-- luci-app-substore
-- 依赖 busybox 内置工具:ping、nc、wget、cut(OpenWrt / ImmortalWrt 均内置)
local util = require("substore.util")
local M = {}
M.TIMEOUT = 2 -- 每个目标的超时(秒)
-- 批量探测时同时存在的子进程上限。
-- 每个探测占 1 个进程 + 1 个管道 fd,节点数由订阅内容决定(可上千),
-- 不限并发会把路由器的 fd/进程额度打满,之后 io.popen 静默失败。
M.MAX_PARALLEL = 16
-- shell 引号:用单引号转义(util.shq),配合 safe_host 校验,双重防命令注入。
-- 不能用 string.format("%q"):它生成双引号字符串,sh 在双引号内仍会做 $() / ``
-- 命令替换,实测可注入。
local function q(s) return util.shq(s) end
-- 校验目标主机名/IP:仅允许 [A-Za-z0-9._-] 与冒号(IPv6),杜绝命令注入
local function safe_host(host)
if type(host) ~= "string" or host == "" then return false end
if not host:match("^[%w%.%-%:]+$") then return false end
-- 以 "-" 开头会被 busybox 的 getopt 当成**选项**而不是参数:
-- 命令是 `ping -c 1 -W 2 <host>`,host="--help" / "-c" 会改变 ping 的行为。
-- 引号(util.shq)挡不住这个 —— 引号由 shell 剥掉,getopt 看到的仍是 -x。
-- 合法主机名(RFC 1123 要求首字符为字母或数字)与 IP 都不会以 "-" 开头。
if host:sub(1, 1) == "-" then return false end
return true
end
-- 构造 http URL:IPv6 字面量必须写成 [addr]:port,否则 "2001:db8::1:443" 不是合法 URL,
-- curl 直接报 URL rejected(code 000),导致所有 IPv6 节点的 URL 测试恒为失败。
local function http_url(server, port)
if server:find(":", 1, true) then
return "http://[" .. server .. "]:" .. port .. "/"
end
return "http://" .. server .. ":" .. port .. "/"
end
local function safe_port(port)
port = tonumber(port)
if port and port >= 1 and port <= 65535 then return port end
return nil
end
-- 解析 ping 输出的 time=xx.x ms
function M.parse_ping(out)
out = out or ""
local t = out:match("time=([%d%.]+)")
return t and tonumber(t) or nil
end
-- 解析 shell 包装输出的 "起始uptime 结束uptime"(单位秒),返回毫秒延迟
function M.parse_delta(out)
out = util.trim(out or "")
local a, b = out:match("^(%d+%.?%d*)%s+(%d+%.?%d*)$")
if a and b then
local d = (tonumber(b) - tonumber(a)) * 1000
if d >= 0 then return d end
end
return nil
end
-- URL 测试命令与解析(优先 curl 输出 time_total,回退 wget + uptime 差值)
local url_tool
local function detect_url_tool()
if url_tool == nil then
local f = io.popen("command -v curl >/dev/null 2>&1 && echo curl || echo wget")
url_tool = util.trim(f and f:read("*a") or "wget")
if f then f:close() end
end
return url_tool
end
local function build_url_job(url)
if detect_url_tool() == "curl" then
local cmd = "curl -s -o /dev/null --connect-timeout " .. M.TIMEOUT .. " --max-time " .. M.TIMEOUT
.. " -w '%{http_code} %{time_total}' " .. q(url) .. " 2>/dev/null"
return cmd, function(out)
-- http_code=000 表示连接失败(拒绝/超时),其余状态码均说明对端可用 HTTP 通信
local code, t = util.trim(out or ""):match("^(%d+)%s+([%d%.]+)$")
t = tonumber(t)
if code and code ~= "000" and t and t > 0 then return t * 1000 end
return nil
end
end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if wget -q -T " .. M.TIMEOUT .. " -O /dev/null " .. q(url)
.. " 2>/dev/null; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
return cmd, M.parse_delta
end
-- 单节点探测(串行,便于单测)。返回延迟 ms(number)或 nil(失败/超时)
function M.ping(server)
server = util.trim(server or "")
if not safe_host(server) then return nil end
local f = io.popen("ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null")
local out = f and f:read("*a") or ""
if f then f:close() end
return M.parse_ping(out)
end
function M.tcping(server, port)
server = util.trim(server or "")
port = safe_port(port)
if not safe_host(server) or not port then return nil end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
local f = io.popen(cmd)
local out = f and f:read("*a") or ""
if f then f:close() end
return M.parse_delta(out)
end
function M.url_test(server, port)
server = util.trim(server or "")
port = safe_port(port)
if not safe_host(server) or not port then return nil end
local url = http_url(server, port)
local cmd, parse = build_url_job(url)
local f = io.popen(cmd)
local out = f and f:read("*a") or ""
if f then f:close() end
return parse(out)
end
-- 为单个节点构造探测任务:返回 cmd, parse 或 nil
local function build_job(mode, server, port)
server = util.trim(server or "")
if not safe_host(server) then return nil end
if mode == "ping" then
return "ping -c 1 -W " .. M.TIMEOUT .. " " .. q(server) .. " 2>/dev/null", M.parse_ping
elseif mode == "tcping" then
port = safe_port(port)
if not port then return nil end
local cmd = "a=$(cut -d' ' -f1 /proc/uptime); if nc -z -w " .. M.TIMEOUT .. " " .. q(server) .. " " .. port
.. " >/dev/null 2>&1; then b=$(cut -d' ' -f1 /proc/uptime); echo \"$a $b\"; fi"
return cmd, M.parse_delta
elseif mode == "url" then
port = safe_port(port)
if not port then return nil end
return build_url_job(http_url(server, port))
end
return nil
end
-- 批量探测:mode = "ping" | "tcping" | "url"
-- 并行启动所有子进程再统一读取,总耗时≈单节点超时,而非节点数×超时。
-- 返回结果数组 { 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
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
drain()
return out
end
return M