Files
op-packages/luci-app-substore/tests/dns_fallback_test.lua
T

340 lines
15 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.
-- 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 msg = require("substore.msg")
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 == "Target resolves only to private/reserved addresses")
-- 应答段是公网地址 → 放行
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 == "Target resolves only to private/reserved addresses")
-- ---------- 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 == "Cannot resolve the target hostname")
-- 空输出(nslookup 不存在 / 立刻失败)同样算解析不出来
stub_nslookup("")
local okB2, rB2 = http.check_public("nope.invalid")
restore()
check("B empty output is unresolvable", okB2 == false and rB2 == "Cannot resolve the target hostname")
-- ---------- 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 == "Cannot resolve the target hostname")
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 == "No local DNS resolution; the peer address will be verified at connect time")
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 == msg.compose("Target actually connects to a private/reserved address (", "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 == msg.compose("Target actually connects to a private/reserved address (", "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 == "Cannot verify the target address (no local DNS resolution and no peer IP obtained)")
-- 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 == "No local DNS resolution: the wget backend cannot verify the target address and refused the download (install curl and retry)")
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)