-- user_agent_test.lua — 订阅客户端类型(User-Agent)支持 -- 用法:lua5.1 tests/user_agent_test.lua -- -- 背景:部分机场(如 Allblue 加速器)按 User-Agent 区分客户端 —— 同一个订单链接, -- 只有用**该订单绑定的客户端**的 UA 去请求才返回真实节点,其它 UA(含不传 UA 时 -- curl 自带的 curl/x.y.z)拿到的是「与您使用客户端不兼容」的占位内容。 -- 占位内容本身是**合法**的 ss 节点(7 个指向 127.0.0.1:1080 的假节点), -- 所以解析不会报错,症状是「订阅更新成功但只有 7 个用不了的节点」。 -- -- 本文件覆盖: -- - http.validate_user_agent 的取值校验(控制字符 / 长度 / 去空白) -- - UA 确实以 -A(curl)/ -U(wget)进入命令行,且经过 shell 引用 -- - 重定向的**每一跳**都带 UA -- - core.resolve_user_agent 的预设解析与错误回显 -- - core.add / core.sync 的字段存储与透传;非法 UA 必须明确失败(禁止 silent fallback) -- -- 全部用例不触网:目标一律用**公网 IP 字面量**(不经过 DNS,结论与本机解析器无关), -- 子进程一律用 io.popen / os.execute 替身。 package.path = "./root/usr/share/?.lua;" .. package.path local http = require("substore.http") local core = require("substore.core") local util = require("substore.util") 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 spawned -- 强制 detect_tool 选中指定后端:have() 用 os.execute("command -v X") == 0 判定。 -- 其余命令(wget 本体、logger)一律返回 0(成功)。 local function spy_execute_for(tool) os.execute = function(cmd) if cmd:find("command %-v ") then return cmd:find("command %-v " .. tool .. " ") and 0 or 1 end return 0 end end -- curl 替身:按顺序返回预设响应,并把响应体 / 响应头写进命令行里 -o / -D 指向的文件 -- (fetch_curl 会去读这两个文件,不写就永远走「读取响应失败」分支)。 local function spy_curl(responses) spawned = {} local n = 0 io.popen = function(cmd) n = n + 1 spawned[#spawned + 1] = cmd local r = responses[n] or responses[#responses] local out = cmd:match("%-o '([^']+)'") local hdr = cmd:match("%-D '([^']+)'") if out and r.body then local f = io.open(out, "w"); if f then f:write(r.body); f:close() end end if hdr and r.location then local f = io.open(hdr, "w") if f then f:write("HTTP/1.1 " .. r.code .. "\r\nLocation: " .. r.location .. "\r\n\r\n") f:close() end end local text = r.code .. " " .. (r.peer or "") return { read = function() return text end, close = function() end } end end -- wget 替身:把响应体写进 -O 指向的文件 local function spy_wget(body) spawned = {} os.execute = function(cmd) if cmd:find("command %-v ") then return cmd:find("command %-v wget ") and 0 or 1 end spawned[#spawned + 1] = cmd local out = cmd:match("%-O '([^']+)'") if out and body then local f = io.open(out, "w"); if f then f:write(body); f:close() end end return 0 end end local function restore() io.popen = real_popen os.execute = real_execute end -- ---------- 1. validate_user_agent ---------- check("ua nil -> empty", http.validate_user_agent(nil) == "") check("ua empty -> empty", http.validate_user_agent("") == "") check("ua blank -> empty", http.validate_user_agent(" ") == "") check("ua trimmed", http.validate_user_agent(" clash-verge/v2.5.0 ") == "clash-verge/v2.5.0") check("ua with spaces kept inside", http.validate_user_agent("mihomo.party/v2.0.0 (clash.meta)") == "mihomo.party/v2.0.0 (clash.meta)") -- 控制字符必须拒绝:UA 最终进入 `-A `,util.shq 的单引号挡得住注入, -- 但挡不住换行 —— curl 会把它当成额外的请求头拼进去(header injection), -- 日志里也会被换行截断、伪造出额外行。 check("ua newline rejected", http.validate_user_agent("a\nb") == nil) check("ua cr rejected", http.validate_user_agent("a\rb") == nil) check("ua tab rejected", http.validate_user_agent("a\tb") == nil) check("ua nul rejected", http.validate_user_agent("a\0b") == nil) local _, uaerr = http.validate_user_agent("a\nb") check("ua reject reason is string", type(uaerr) == "string" and #uaerr > 0) check("ua 256 chars accepted", http.validate_user_agent(string.rep("x", 256)) ~= nil) check("ua 257 chars rejected", http.validate_user_agent(string.rep("x", 257)) == nil) -- ---------- 2. UA 进入 curl 命令行 ---------- local URL = "http://1.1.1.1/sub" -- 公网字面量,不走 DNS spy_execute_for("curl") spy_curl({ { code = "200", peer = "1.1.1.1", body = "trojan://pw@1.1.1.1:443#N" } }) local body = http.download(URL, { user_agent = "clash-verge/v2.5.0" }) check("curl download ok with ua", body ~= nil) check("curl cmd has -A", spawned[1] and spawned[1]:find("%-A 'clash%-verge/v2%.5%.0'") ~= nil) restore() -- 不设 UA 时不能凭空多出 -A(否则等于把「默认」也钉死成某个客户端) spy_execute_for("curl") spy_curl({ { code = "200", peer = "1.1.1.1", body = "x" } }) http.download(URL, {}) check("curl cmd has no -A when unset", spawned[1] and spawned[1]:find("%-A ") == nil) restore() -- 空字符串与 nil 等价 spy_execute_for("curl") spy_curl({ { code = "200", peer = "1.1.1.1", body = "x" } }) http.download(URL, { user_agent = "" }) check("curl cmd has no -A when empty", spawned[1] and spawned[1]:find("%-A ") == nil) restore() -- ---------- 3. UA 进入 wget 命令行 ---------- -- busybox wget 用 -U(已核对 busybox 1.37 的 --help:`-U STR Use STR for User-Agent header`) spy_wget("trojan://pw@1.1.1.1:443#N") local wbody = http.download(URL, { user_agent = "v2rayN/7.22.0" }) check("wget download ok with ua", wbody ~= nil) check("wget cmd has -U", spawned[1] and spawned[1]:find("%-U 'v2rayN/7%.22%.0'") ~= nil) restore() spy_wget("x") http.download(URL, {}) check("wget cmd has no -U when unset", spawned[1] and spawned[1]:find("%-U ") == nil) restore() -- ---------- 4. shell 引用:单引号不能逃逸 ---------- -- UA 来自表单,属不可信输入。util.shq 把 ' 变成 '\'',整串仍是一个参数。 spy_execute_for("curl") spy_curl({ { code = "200", peer = "1.1.1.1", body = "x" } }) http.download(URL, { user_agent = "evil'; rm -rf /; echo '" }) local c4 = spawned[1] or "" check("quote ua is shq-escaped", c4:find("'evil'\\''; rm %-rf /; echo '\\'''") ~= nil) check("quote ua has no raw injection", c4:find("'; rm %-rf /; echo ' ") == nil) restore() -- ---------- 5. 重定向的每一跳都带 UA ---------- -- 机场常用 302 跳到 CDN;只在第一跳带 UA 的话,第二跳拿回的仍是占位内容。 spy_execute_for("curl") spy_curl({ { code = "302", peer = "1.1.1.1", location = "http://8.8.8.8/next" }, { code = "200", peer = "8.8.8.8", body = "trojan://pw@8.8.8.8:443#N" }, }) local rbody = http.download(URL, { user_agent = "mihomo.party/v2.0.0 (clash.meta)" }) check("redirect download ok", rbody ~= nil) check("redirect followed (2 hops)", #spawned == 2) check("hop1 carries ua", spawned[1] and spawned[1]:find("%-A 'mihomo%.party/v2%.0%.0 %(clash%.meta%)'") ~= nil) check("hop2 carries ua", spawned[2] and spawned[2]:find("%-A 'mihomo%.party/v2%.0%.0 %(clash%.meta%)'") ~= nil) restore() -- ---------- 6. download 对非法 UA 必须拒绝,且一个进程都不起 ---------- -- 与代理一样:非法值静默忽略 = silent fallback(§12)。用户会以为 UA 已生效, -- 实际拿到的还是占位节点。 spy_execute_for("curl") spy_curl({ { code = "200", peer = "1.1.1.1", body = "x" } }) local db, _, derr = http.download(URL, { user_agent = "bad\nua" }) check("download rejects bad ua", db == nil) check("download bad ua reason", type(derr) == "string" and derr:find("User%-Agent") ~= nil) check("download bad ua spawns nothing", #spawned == 0) restore() -- ---------- 7. core.resolve_user_agent ---------- check("resolve empty preset -> empty", core.resolve_user_agent("", "") == "") check("resolve nil preset -> empty", core.resolve_user_agent(nil, nil) == "") -- 「自定义」留空 = 明确要求不设置 UA,不是错误 check("resolve custom empty -> empty", core.resolve_user_agent("custom", " ") == "") check("resolve custom value", core.resolve_user_agent("custom", " MyUA/1.0 ") == "MyUA/1.0") check("resolve custom rejects control chars", core.resolve_user_agent("custom", "a\nb") == nil) local _, rerr = core.resolve_user_agent("no-such-client", "") check("resolve unknown preset -> nil", core.resolve_user_agent("no-such-client", "") == nil) check("resolve unknown preset reason", type(rerr) == "string" and rerr:find("no%-such%-client") ~= nil) -- 四个预设必须都能解析出非空且互不相同的 UA local seen_ua = {} local presets_ok = true for _, p in ipairs(core.UA_PRESETS) do local ua = core.resolve_user_agent(p.key, "") if not ua or ua == "" or seen_ua[ua] then presets_ok = false end seen_ua[ua] = true end check("all presets resolve to distinct non-empty ua", presets_ok) check("four presets defined", #core.UA_PRESETS == 4) -- 锁定取自客户端源码的**确切** UA 形状(改错一个字就会退回占位内容) check("clash-verge preset", core.resolve_user_agent("clash-verge", "") == "clash-verge/v2.5.0") check("v2rayn preset", core.resolve_user_agent("v2rayn", "") == "v2rayN/7.22.0") check("clash-party preset keeps (clash.meta) suffix", core.resolve_user_agent("clash-party", "") == "mihomo.party/v2.0.0 (clash.meta)") local flc = core.resolve_user_agent("flclash", "") or "" check("flclash preset has 3 space-joined tokens", select(2, flc:gsub(" ", "")) == 2) check("flclash preset has clash-verge token", flc:find("clash%-verge", 1, false) ~= nil) check("flclash preset has Platform token", flc:find("Platform/", 1, true) ~= nil) -- ---------- 8. core.ua_preset_of 回显 ---------- check("preset_of empty", core.ua_preset_of("") == "") check("preset_of nil", core.ua_preset_of(nil) == "") check("preset_of known", core.ua_preset_of("clash-verge/v2.5.0") == "clash-verge") check("preset_of unknown -> custom", core.ua_preset_of("SomeOtherClient/9.9") == "custom") -- 往返:预设 -> UA -> 预设 local roundtrip_ok = true for _, p in ipairs(core.UA_PRESETS) do if core.ua_preset_of(p.ua) ~= p.key then roundtrip_ok = false end end check("preset roundtrip", roundtrip_ok) -- ---------- 9. core.add / core.sync 透传 ---------- local tmp = os.tmpname() .. "_substore_ua" os.execute("mkdir -p " .. string.format("%q", tmp .. "/nodes")) core.DATA_DIR = tmp core.LIST_FILE = tmp .. "/subscriptions.json" core.NODES_DIR = tmp .. "/nodes" core.CRON_FILE = tmp .. "/cron.d.substore" local id = core.add("UA 订阅", "http://1.1.1.1/sub", { user_agent = "clash-verge/v2.5.0" }) local m = core.get(id) check("add stores user_agent", m.user_agent == "clash-verge/v2.5.0") local id2 = core.add("无 UA 订阅", "http://1.1.1.1/sub2", {}) check("add defaults user_agent to empty", core.get(id2).user_agent == "") -- sync 必须把 meta.user_agent 透传给 http.download local seen_opts local real_download = http.download http.download = function(_, opts) seen_opts = opts; return "trojan://pw@1.1.1.1:443#N", {}, nil end spy_execute_for("curl") local cnt, serr = core.sync(id) check("sync ok with ua", cnt ~= nil) check("sync passes user_agent to download", seen_opts and seen_opts.user_agent == "clash-verge/v2.5.0") restore() -- 非法 UA:必须明确失败,且**不能**发起下载 seen_opts = nil core.save_meta(id, { user_agent = "bad\nua" }) spy_execute_for("curl") local cnt2, serr2 = core.sync(id) check("sync fails on invalid ua", cnt2 == nil) check("sync invalid ua reason", type(serr2) == "string" and serr2:find("User%-Agent") ~= nil) check("sync invalid ua does not download", seen_opts == nil) check("sync invalid ua recorded in meta", (core.get(id).error or ""):find("User%-Agent") ~= nil) restore() http.download = real_download -- ---------- 10. 控制器接线:表单 → core.add / core.save_meta ---------- -- 上面各节证明了 http 会发 UA、core 会透传 UA。但整条链路上还有一段没被覆盖: -- 表单提交的是 ua_preset / ua_custom,控制器必须把它解析成 user_agent 再存。 -- 这一段断了的话,用户在界面上选了客户端类型,保存后却什么都没发生 —— -- 正是最难发现的「设置无效」类缺陷。 -- -- 这里用**真实的 core**(数据目录已在第 9 节重定向到临时目录)+ 最小 luci stub, -- 走完整条 create / save 路径。 local FORM, LAST_REDIRECT = {}, nil _G.luci = { http = { formvalue = function(k) return FORM[k] end, redirect = function(url) LAST_REDIRECT = url end, }, dispatcher = { build_url = function(...) return "/" .. table.concat({ ... }, "/") end, }, util = { urlencode = function(s) return (tostring(s or ""):gsub("[^%w%-%._~]", function(c) return string.format("%%%02X", string.byte(c)) end)) end, pcdata = function(s) return tostring(s or "") end, }, i18n = { translate = function(s) return s end }, } _G.luci.controller = { admin = {} } package.loaded["luci.http"] = _G.luci.http package.loaded["luci.dispatcher"] = _G.luci.dispatcher package.loaded["luci.util"] = _G.luci.util package.loaded["luci.i18n"] = _G.luci.i18n local cok, cerr = pcall(dofile, "root/usr/lib/lua/luci/controller/admin/substore.lua") check("controller loads under stub", cok) if not cok then print(" load error: " .. tostring(cerr)) end local ctl = package.loaded["luci.controller.admin.substore"] or {} -- create:选预设 Clash Verge FORM = { token = "t", name = "机场A", url = "http://1.1.1.1/a", ua_preset = "clash-verge" } LAST_REDIRECT = nil ctl.action_create() local created for _, it in pairs(require("substore.core").list()) do if it.name == "机场A" then created = it end end check("create stores preset ua", created and created.user_agent == "clash-verge/v2.5.0") -- create:自定义 UA FORM = { token = "t", name = "机场B", url = "http://1.1.1.1/b", ua_preset = "custom", ua_custom = "MyClient/3.1" } LAST_REDIRECT = nil ctl.action_create() local created_b for _, it in pairs(require("substore.core").list()) do if it.name == "机场B" then created_b = it end end check("create stores custom ua", created_b and created_b.user_agent == "MyClient/3.1") -- create:不选 = 空 FORM = { token = "t", name = "机场C", url = "http://1.1.1.1/c" } LAST_REDIRECT = nil ctl.action_create() local created_c for _, it in pairs(require("substore.core").list()) do if it.name == "机场C" then created_c = it end end check("create defaults ua empty", created_c and created_c.user_agent == "") -- create:非法自定义 UA 必须回显错误,且**不创建**订阅 FORM = { token = "t", name = "机场D", url = "http://1.1.1.1/d", ua_preset = "custom", ua_custom = "bad\nua" } LAST_REDIRECT = nil ctl.action_create() local made_d = false for _, it in pairs(require("substore.core").list()) do if it.name == "机场D" then made_d = true end end check("create invalid ua rejected", made_d == false) check("create invalid ua redirects with err", LAST_REDIRECT ~= nil and LAST_REDIRECT:find("err=") ~= nil) -- save:改预设必须落盘 FORM = { token = "t", id = created.id, name = "机场A", url = "http://1.1.1.1/a", ua_preset = "flclash" } LAST_REDIRECT = nil ctl.action_save() local saved = require("substore.core").get(created.id) check("save updates ua", saved and saved.user_agent == "FlClash/v0.8.93 clash-verge Platform/linux") -- save:清回默认 FORM = { token = "t", id = created.id, name = "机场A", url = "http://1.1.1.1/a", ua_preset = "" } LAST_REDIRECT = nil ctl.action_save() check("save clears ua", require("substore.core").get(created.id).user_agent == "") -- ---------- 11. form.htm 真实渲染 ---------- -- .htm 是运行时才编译的模板,luac -p 检查不到它 —— 模板里的 Lua 语法错误 -- 只会在用户打开页面时 500。这里按 LuCI 的方式把模板重建成 Lua chunk 真渲染一遍, -- 同时验证「下拉回显」这段分支逻辑。 local VIEW = "root/usr/lib/lua/luci/view/substore/form.htm" local function build_chunk(src) local out = { "local __w = __substore_w" } local i, n = 1, #src while i <= n do local s = src:find("<%", i, true) if not s then out[#out + 1] = "__w(" .. string.format("%q", src:sub(i)) .. ")" break end if s > i then out[#out + 1] = "__w(" .. string.format("%q", src:sub(i, s - 1)) .. ")" end local e = src:find("%>", s + 2, true) if not e then return nil, "unclosed <% at " .. s end local tag = src:sub(s + 2, e - 1) local kind = tag:sub(1, 1) local body = tag:sub(2) if kind == "=" then out[#out + 1] = "__w(" .. body .. ")" elseif kind == ":" then out[#out + 1] = "__w(" .. string.format("%q", body) .. ")" elseif kind == "+" then out[#out + 1] = "__w()" else out[#out + 1] = tag end out[#out + 1] = "\n" i = e + 2 end return table.concat(out, "\n") end local FORM_ID = nil local view_http = { formvalue = function(k) if k == "id" then return FORM_ID end return nil end } -- pcdata 按 LuCI 真实语义转义:模板若把 UA 原样吐出(没走 pcdata), -- 输出里就会留下裸的 < > " &,下面的断言据此判定。 local function view_pcdata(s) return (tostring(s or ""):gsub('[&<>"]', function(c) return ({ ["&"] = "&", ["<"] = "<", [">"] = ">", ['"'] = """ })[c] end)) end local view_luci = { util = { pcdata = view_pcdata, urlencode = function(s) return tostring(s or "") end }, i18n = { translate = function(s) return tostring(s or "") end }, dispatcher = { build_url = function(...) return "/" .. table.concat({ ... }, "/") end }, } local real_require2 = require local function fake_require(name) if name == "luci.http" then return view_http end if name == "luci.util" then return view_luci.util end if name == "luci.i18n" then return view_luci.i18n end return real_require2(name) end local function render_form(id) FORM_ID = id local f = assert(io.open(VIEW, "rb")) local src = f:read("*a"); f:close() local chunk, cerr = build_chunk(src) if not chunk then return nil, cerr end local fn, lerr = loadstring(chunk, "form.htm") if not fn then return nil, lerr end local buf = {} local env = setmetatable({ __substore_w = function(s) if s ~= nil then buf[#buf + 1] = tostring(s) end end, require = fake_require, luci = view_luci, token = "TESTTOKEN", }, { __index = _G }) setfenv(fn, env) fn() return table.concat(buf) end local html_add, aerr = render_form(nil) check("form.htm renders (add)", html_add ~= nil) if not html_add then print(" render error: " .. tostring(aerr)) end check("form has ua_preset select", html_add and html_add:find('name="ua_preset"', 1, true) ~= nil) check("form has ua_custom input", html_add and html_add:find('name="ua_custom"', 1, true) ~= nil) -- 4 个预设 + 「默认」+「自定义」。注意只能数 ua_preset 这一个 ", s, true) return html:sub(s, e or #html) end local ua_sel = ua_select_html(html_add or "") local nopt = 0 for _ in (ua_sel or ""):gmatch("