-- 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="--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