mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-27 04:42:51 +08:00
339 lines
15 KiB
Lua
339 lines
15 KiB
Lua
-- dns_fallback_test.lua — [2.6.12-r1] DNS 解析回退与连接时对端校验回归测试
|
||
-- 用法:lua5.1 tests/dns_fallback_test.lua
|
||
--
|
||
-- 背景(用户实测的回归):[2.6.8-r1] 把 check_public 里「解析不出 IP」一律
|
||
-- fail-closed 之后,在**缺少 luci-lib-nixio** 的设备上 resolve() 恒返回 nil,
|
||
-- 于是所有域名订阅都报「无法解析目标主机名」——而 curl 自带 libc 解析器,
|
||
-- 2.6.7-r1 上同样的订阅是能正常下载的。
|
||
--
|
||
-- 根因:resolve() 只有 nixio 一条路,「本机没有解析手段」和「这个域名解析不出来」
|
||
-- 被合并成了同一个 nil,而两者的处置完全相反。
|
||
--
|
||
-- 覆盖:
|
||
-- A nslookup 回退:nixio 不可用时改用 busybox nslookup 解析
|
||
-- B nslookup 输出的 Server/Address 段**不能**被当成解析结果
|
||
-- C 有解析手段但解析失败(NXDOMAIN)→ 仍然 fail-closed
|
||
-- D 本机完全无解析手段 → 放行,但标记 unverified
|
||
-- E 连接时对端校验:curl 回报的 %{remote_ip} 为内网 → 拒绝并清理临时文件
|
||
-- F unverified 且拿不到对端 IP → 拒绝(「无法校验」不等于「放行」)
|
||
-- G 走代理时不校验对端(%{remote_ip} 是代理地址,多半就在内网)
|
||
-- H unverified + wget 后端 → 拒绝,且不发起下载(wget 拿不到对端地址)
|
||
-- H2 对照:有解析能力时 wget 后端照常工作
|
||
--
|
||
-- 全部用例不触网:nixio / nslookup / curl 一律用替身。
|
||
|
||
package.path = "./root/usr/share/?.lua;" .. package.path
|
||
|
||
local util = require("substore.util")
|
||
local http = require("substore.http")
|
||
|
||
local passed, failed = 0, 0
|
||
local function check(name, cond)
|
||
if cond then passed = passed + 1; print("PASS " .. name)
|
||
else failed = failed + 1; print("FAIL " .. name) end
|
||
end
|
||
|
||
local real_popen, real_execute = io.popen, os.execute
|
||
local real_try = util.try_require
|
||
local real_rnd = util.rnd_hex
|
||
|
||
local function restore()
|
||
io.popen, os.execute, util.try_require, util.rnd_hex =
|
||
real_popen, real_execute, real_try, real_rnd
|
||
end
|
||
|
||
-- 让 resolve() 走 nslookup 分支:nixio 取不到、nslookup 命令存在、输出固定。
|
||
local spawned
|
||
local function stub_nslookup(output)
|
||
spawned = {}
|
||
util.try_require = function() return nil end
|
||
os.execute = function() return 0 end -- have("nslookup") 恒真
|
||
io.popen = function(cmd)
|
||
spawned[#spawned + 1] = cmd
|
||
return { read = function() return output end, close = function() end }
|
||
end
|
||
end
|
||
|
||
-- ---------- A:nslookup 回退 ----------
|
||
-- Server 段故意写 8.8.8.8(公网)、应答段写内网地址:
|
||
-- 若实现误把 Server 段的 Address 也当解析结果,就会因为「有一个公网 IP」
|
||
-- 而放行 —— 这条断言因此能真正区分对错,不是恒真。
|
||
local NS_MIXED = "Server:\t\t8.8.8.8\nAddress:\t8.8.8.8:53\n\n" ..
|
||
"Name:\tintranet.example\nAddress: 192.168.1.10\n"
|
||
|
||
stub_nslookup(NS_MIXED)
|
||
local okA, rA = http.check_public("intranet.example")
|
||
restore()
|
||
check("A nslookup fallback used", #spawned == 1)
|
||
check("A nslookup command quotes host", spawned[1] == "nslookup 'intranet.example' 2>/dev/null")
|
||
check("A domain resolving to private rejected", okA == false)
|
||
check("A reason is private-address", rA == "目标仅解析到内网/保留地址")
|
||
|
||
-- 应答段是公网地址 → 放行
|
||
local NS_PUBLIC = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" ..
|
||
"Name:\texample.com\nAddress: 93.184.216.34\n"
|
||
stub_nslookup(NS_PUBLIC)
|
||
local okA2 = http.check_public("example.com")
|
||
restore()
|
||
check("A public domain allowed via nslookup", okA2 == true)
|
||
|
||
-- 多 A 记录:只要有一个公网地址就放行(与 nixio 分支同语义)
|
||
local NS_MULTI = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" ..
|
||
"Name:\tmulti.example\nAddress: 10.0.0.5\nAddress: 93.184.216.34\n"
|
||
stub_nslookup(NS_MULTI)
|
||
local okA3 = http.check_public("multi.example")
|
||
restore()
|
||
check("A multi-A with one public allowed", okA3 == true)
|
||
|
||
-- IPv6 应答:%zone 后缀必须去掉,否则 fe80::1%eth0 匹配不上 link-local 前缀
|
||
local NS_V6 = "Server:\t\t192.168.1.1\nAddress:\t192.168.1.1:53\n\n" ..
|
||
"Name:\tv6.example\nAddress: fe80::1%eth0\n"
|
||
stub_nslookup(NS_V6)
|
||
local okA4, rA4 = http.check_public("v6.example")
|
||
restore()
|
||
check("A ipv6 zone suffix stripped", okA4 == false and rA4 == "目标仅解析到内网/保留地址")
|
||
|
||
-- ---------- B:Server 段的 Address 不是解析结果 ----------
|
||
-- 解析失败时 busybox 只把错误写到 stderr,stdout 里只有 Server/Address 段。
|
||
-- 应答段为空 → 必须判为「解析不出来」,而不是把 8.8.8.8(DNS 服务器自己)当成结果。
|
||
local NS_NXDOMAIN = "Server:\t\t8.8.8.8\nAddress:\t8.8.8.8:53\n\n" ..
|
||
"** server can't find nope.invalid: NXDOMAIN\n"
|
||
stub_nslookup(NS_NXDOMAIN)
|
||
local okB, rB = http.check_public("nope.invalid")
|
||
restore()
|
||
check("B server-block address not used as answer", okB == false)
|
||
check("B empty answer is unresolvable", rB == "无法解析目标主机名")
|
||
|
||
-- 空输出(nslookup 不存在 / 立刻失败)同样算解析不出来
|
||
stub_nslookup("")
|
||
local okB2, rB2 = http.check_public("nope.invalid")
|
||
restore()
|
||
check("B empty output is unresolvable", okB2 == false and rB2 == "无法解析目标主机名")
|
||
|
||
-- ---------- C:有解析手段但解析失败 → fail-closed ----------
|
||
-- 这是 [2.6.8-r1] 引入的行为,必须保留:127.0.0.1.nip.io 这类
|
||
-- 「公网可解析到内网」的域名,只要本机解析不出来就能绕过全部检查,
|
||
-- 而 curl 自己仍会把它解析到 127.0.0.1 并连上去。
|
||
stub_nslookup(NS_NXDOMAIN)
|
||
local okC, rC, unC = http.check_public("127.0.0.1.nip.io")
|
||
restore()
|
||
check("C unresolvable with resolver rejected", okC == false)
|
||
check("C unresolvable reason", rC == "无法解析目标主机名")
|
||
check("C not marked unverified", unC == nil)
|
||
|
||
-- ---------- D:本机完全无解析手段 → 放行 + unverified ----------
|
||
-- os.execute 恒返回 1 → command -v 一律「找不到」→ nixio 与 nslookup 都没有。
|
||
util.try_require = function() return nil end
|
||
os.execute = function() return 1 end
|
||
spawned = {}
|
||
io.popen = function(cmd) spawned[#spawned + 1] = cmd; return nil end
|
||
local okD, rD, unD = http.check_public("example.com")
|
||
local spawnedD = #spawned
|
||
restore()
|
||
check("D no resolver allows", okD == true)
|
||
check("D reason explains fallback", rD == "本机无 DNS 解析能力,改由连接时校验对端地址")
|
||
check("D marked unverified", unD == true)
|
||
check("D spawns no resolver process", spawnedD == 0)
|
||
|
||
-- 无解析手段也不能放过 IP 字面量与 localhost(这些分支不经过 DNS)
|
||
util.try_require = function() return nil end
|
||
os.execute = function() return 1 end
|
||
io.popen = function() return nil end
|
||
local dLit = http.check_public("127.0.0.1")
|
||
local dLit6 = http.check_public("::1")
|
||
local dName = http.check_public("localhost")
|
||
local dPub = http.check_public("1.1.1.1")
|
||
restore()
|
||
check("D loopback literal still rejected", dLit == false)
|
||
check("D ipv6 loopback still rejected", dLit6 == false)
|
||
check("D localhost name still rejected", dName == false)
|
||
check("D public literal still allowed", dPub == true)
|
||
|
||
-- ---------- E/F/G:连接时对端校验 ----------
|
||
-- fetch_curl 通过 -w 取 %{http_code} 与 %{remote_ip},据此复核真正连上的地址。
|
||
-- 替身按命令行里 -o/-D/2> 的目标把文件建出来,让「失败后 /tmp 无残留」
|
||
-- 成为真断言(文件确实被创建过)。
|
||
util.rnd_hex = function() return "DNSTAG" end
|
||
local TMP = "/tmp/substore_dl_DNSTAG_0"
|
||
local HDR = TMP .. ".tmp.hdr"
|
||
local ERRF = TMP .. ".tmp.err"
|
||
|
||
local function touch(path, data)
|
||
local f = assert(io.open(path, "wb"))
|
||
f:write(data or "")
|
||
f:close()
|
||
end
|
||
local function exists(path)
|
||
local f = io.open(path, "rb")
|
||
if f then f:close(); return true end
|
||
return false
|
||
end
|
||
|
||
-- curl 替身:code 是 -w 的完整输出(形如 "200 93.184.216.34")
|
||
local function spy_curl(code)
|
||
os.execute = function() return 0 end -- have("curl") 恒真 → 选 curl 后端
|
||
io.popen = function(cmd)
|
||
local o = cmd:match("%-o '([^']+)'")
|
||
local d = cmd:match("%-D '([^']+)'")
|
||
local e = cmd:match("2>'([^']+)'")
|
||
if o then touch(o, "body") end
|
||
if d then touch(d, "HTTP/1.1 200 OK\r\n") end
|
||
if e then touch(e, "") end
|
||
return { read = function() return code end, close = function() end }
|
||
end
|
||
end
|
||
|
||
local function clear_tmp()
|
||
os.remove(TMP .. ".tmp"); os.remove(HDR); os.remove(ERRF)
|
||
end
|
||
|
||
-- E1:公网对端 → 正常返回
|
||
clear_tmp()
|
||
spy_curl("200 93.184.216.34")
|
||
local bodyE, _, errE = http.download("http://1.1.1.1/sub")
|
||
restore()
|
||
check("E1 public peer accepted", bodyE == "body" and errE == nil)
|
||
|
||
-- E2:对端是内网 → 拒绝,且临时文件全部清理
|
||
clear_tmp()
|
||
spy_curl("200 127.0.0.1")
|
||
local bodyE2, _, errE2 = http.download("http://1.1.1.1/sub")
|
||
restore()
|
||
check("E2 private peer rejected", bodyE2 == nil)
|
||
check("E2 reason names the peer ip", errE2 == "目标实际连接到内网/保留地址 (127.0.0.1)")
|
||
check("E2 removes .tmp", not exists(TMP .. ".tmp"))
|
||
check("E2 removes .hdr", not exists(HDR))
|
||
check("E2 removes .err", not exists(ERRF))
|
||
|
||
-- E3:3xx 的第一跳就连到内网 → 同样拒绝
|
||
-- (Location 检查只能拦第二跳,拦不住第一跳本身)
|
||
clear_tmp()
|
||
spy_curl("302 192.168.1.1")
|
||
local bodyE3, _, errE3 = http.download("http://1.1.1.1/sub")
|
||
restore()
|
||
check("E3 redirect hop private rejected", bodyE3 == nil)
|
||
check("E3 reason names the peer ip", errE3 == "目标实际连接到内网/保留地址 (192.168.1.1)")
|
||
|
||
-- E4:link-local 对端同样拦
|
||
clear_tmp()
|
||
spy_curl("200 fe80::1")
|
||
local bodyE4, _, errE4 = http.download("http://1.1.1.1/sub")
|
||
restore()
|
||
check("E4 link-local peer rejected", bodyE4 == nil and errE4 ~= nil)
|
||
|
||
-- E5:对照 —— 替身确实落盘,所以上面的「无残留」不是空断言
|
||
clear_tmp()
|
||
touch(TMP .. ".tmp", "x")
|
||
check("E5 control: temp files are observable", exists(TMP .. ".tmp"))
|
||
clear_tmp()
|
||
|
||
-- F:预检无解析能力(unverified)且拿不到对端 IP → 拒绝。
|
||
-- 「无法校验」必须等于「拒绝」,否则这条新的放行路径就是个洞。
|
||
util.rnd_hex = function() return "DNSTAG" end
|
||
util.try_require = function() return nil end
|
||
spy_curl("200") -- 只有状态码,没有对端 IP
|
||
os.execute = function(cmd)
|
||
-- have("curl") 为真、have("nslookup") 为假 → 无解析能力 + 用 curl 下载
|
||
if cmd:find("curl", 1, true) then return 0 end
|
||
return 1
|
||
end
|
||
clear_tmp()
|
||
-- 这里必须用**域名**:只有走 resolve() 分支才会得到 unverified=true。
|
||
local bodyF, _, errF = http.download("http://example.com/sub")
|
||
restore()
|
||
check("F unverified without peer ip rejected", bodyF == nil)
|
||
check("F reason explains missing peer ip",
|
||
errF == "无法校验目标地址(本机无 DNS 解析能力,且未取得对端 IP)")
|
||
|
||
-- F2:有解析能力时拿不到对端 IP → 不额外拒绝(预检已经查过一轮)
|
||
clear_tmp()
|
||
spy_curl("200")
|
||
local bodyF2, _, errF2 = http.download("http://1.1.1.1/sub")
|
||
restore()
|
||
check("F2 resolved target without peer ip accepted", bodyF2 == "body" and errF2 == nil)
|
||
|
||
-- G:走代理时不校验对端(%{remote_ip} 是代理地址,多半就在内网)
|
||
clear_tmp()
|
||
spy_curl("200 192.168.1.1")
|
||
local bodyG, _, errG = http.download("http://1.1.1.1/sub", { proxy = "http://192.168.1.1:1080" })
|
||
restore()
|
||
check("G proxied private peer accepted", bodyG == "body" and errG == nil)
|
||
|
||
-- G2:代理参数确实进了 curl 命令行(否则 G 可能是假阳性)
|
||
local saw_proxy
|
||
os.execute = function() return 0 end
|
||
io.popen = function(cmd)
|
||
saw_proxy = cmd:find("%-x 'http://192.168.1.1:1080'", 1) ~= nil
|
||
local o = cmd:match("%-o '([^']+)'")
|
||
local d = cmd:match("%-D '([^']+)'")
|
||
local e = cmd:match("2>'([^']+)'")
|
||
if o then touch(o, "body") end
|
||
if d then touch(d, "HTTP/1.1 200 OK\r\n") end
|
||
if e then touch(e, "") end
|
||
return { read = function() return "200 192.168.1.1" end, close = function() end }
|
||
end
|
||
clear_tmp()
|
||
http.download("http://1.1.1.1/sub", { proxy = "http://192.168.1.1:1080" })
|
||
restore()
|
||
check("G2 proxy flag passed to curl", saw_proxy == true)
|
||
|
||
-- ---------- H:unverified + wget 后端 → 拒绝,且不发起下载 ----------
|
||
-- curl 后端靠 %{remote_ip} 在连接后复核对端地址;wget 拿不到对端地址,
|
||
-- busybox wget 也没有可用的重定向拦截。预检 fail-open 放行之后这条路径上
|
||
-- 再无任何校验 —— 与 verify_peer_ip 同处置:校验不了就拒绝,而不是放行。
|
||
-- 拒绝还必须发生在 wget **之前**:否则请求已经发出去,拒绝就只是事后补救。
|
||
local WGET_TMP = "/tmp/substore_dl_wget_DNSTAGW.tmp"
|
||
local execsH = {}
|
||
util.rnd_hex = function() return "DNSTAGW" end
|
||
util.try_require = function() return nil end
|
||
os.execute = function(cmd)
|
||
execsH[#execsH + 1] = cmd
|
||
if cmd:find("command %-v wget") then return 0 end -- 有 wget
|
||
if cmd:find("command %-v") then return 1 end -- 没有 curl / nslookup → 无解析能力
|
||
-- 真正的下载命令:**照常落盘**,模拟一个成功的下载。
|
||
-- 不落盘的话,旧实现会因为「内容为空」而返回 nil,
|
||
-- 断言就在错误的理由上通过,测不出「本该拒绝却把内容交付了」。
|
||
local o = cmd:match("%-O '([^']+)'")
|
||
if o then touch(o, "evilbody") end
|
||
return 0
|
||
end
|
||
io.popen = function() return nil end
|
||
os.remove(WGET_TMP)
|
||
local bodyH, _, errH = http.download("http://example.com/sub")
|
||
restore()
|
||
check("H unverified wget rejected", bodyH == nil)
|
||
check("H reason names the wget backend",
|
||
errH == "本机无 DNS 解析能力,wget 后端无法校验目标地址,已拒绝下载(安装 curl 后重试)")
|
||
local ran_wget = false
|
||
for _, c in ipairs(execsH) do
|
||
if c:find("%-O ", 1) then ran_wget = true end
|
||
end
|
||
check("H wget never invoked", ran_wget == false)
|
||
check("H no temp file left", not exists(WGET_TMP))
|
||
|
||
-- H2:对照 —— 有解析能力(unverified 为 nil)时 wget 后端照常下载,
|
||
-- 证明 H 拒绝的是「无法校验」,不是把 wget 后端整个废掉。
|
||
local WGET_TMP2 = "/tmp/substore_dl_wget_DNSTAGW2.tmp"
|
||
local ran_wget2 = false
|
||
util.rnd_hex = function() return "DNSTAGW2" end
|
||
util.try_require = function() return nil end
|
||
os.execute = function(cmd)
|
||
if cmd:find("command %-v curl") then return 1 end -- 没有 curl
|
||
if cmd:find("command %-v") then return 0 end -- 有 wget / nslookup
|
||
local o = cmd:match("%-O '([^']+)'")
|
||
if o then ran_wget2 = true; touch(o, "wgetbody") end
|
||
return 0
|
||
end
|
||
io.popen = function() return { read = function() return NS_PUBLIC end, close = function() end } end
|
||
os.remove(WGET_TMP2)
|
||
local bodyH2, _, errH2 = http.download("http://example.com/sub")
|
||
restore()
|
||
check("H2 resolved target via wget accepted", bodyH2 == "wgetbody" and errH2 == nil)
|
||
check("H2 wget really ran", ran_wget2 == true)
|
||
check("H2 no temp file left", not exists(WGET_TMP2))
|
||
|
||
clear_tmp()
|
||
print("")
|
||
print(passed .. " passed, " .. failed .. " failed")
|
||
os.exit(failed == 0 and 0 or 1)
|