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

539 lines
22 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.
-- http.lua — 订阅下载(SSRF 防护、超时、大小限制)(纯 Lua)
-- luci-app-substore
local util = require("substore.util")
local M = {}
M.DEFAULT_MAX_SIZE = 10 * 1024 * 1024 -- 10MB
M.DEFAULT_TIMEOUT = 20
M.MAX_REDIRECTS = 4
-- ---------- 私网 / 保留地址判断 ----------
-- 单段数值:十进制 / 八进制(前导 0)/ 十六进制(0x 前缀)
-- 返回数值;不是数值写法返回 nil
local function numeral(seg)
if seg:match("^0[xX]%x+$") then return tonumber(seg:sub(3), 16) end
if seg:match("^0%d+$") then return tonumber(seg:sub(2), 8) end
if seg:match("^%d+$") then return tonumber(seg, 10) end
return nil
end
-- 把 inet_aton 接受的各种数值型 IPv4 写法归一为点分十进制。
-- curl / wget 都按 inet_aton 语义解析主机名,因此 SSRF 检查必须用同一套语义,
-- 否则 "0177.0.0.1"(八进制 127.0.0.1)、"2130706433"(十进制)、"0x7f000001"(十六进制)、
-- "127.1"(短写)会被 is_private_ipv4 当成公网地址而放行,实测这几种写法都能连到 127.0.0.1。
-- 段数与点号数必须一致(拒绝 "1..2" 这类畸形写法)。
-- 返回 "a.b.c.d";不是数值型 IPv4 时返回 nil。
local function normalize_ipv4(host)
local dots = select(2, host:gsub("%.", ""))
local parts = {}
for seg in host:gmatch("[^%.]+") do parts[#parts + 1] = seg end
if #parts ~= dots + 1 or #parts > 4 then return nil end
local vals = {}
for i, seg in ipairs(parts) do
local v = numeral(seg)
if not v then return nil end
vals[i] = v
end
-- inet_aton:前 k-1 段各 8 位,最后一段吃掉剩余位宽
local k = #vals
for i = 1, k - 1 do
if vals[i] > 255 then return nil end
end
if vals[k] >= 2 ^ (32 - 8 * (k - 1)) then return nil end
local n = 0
for i, v in ipairs(vals) do
local shift = (i < k) and (32 - 8 * i) or 0
n = n + v * 2 ^ shift
end
local o1 = math.floor(n / 2 ^ 24) % 256
local o2 = math.floor(n / 2 ^ 16) % 256
local o3 = math.floor(n / 2 ^ 8) % 256
local o4 = n % 256
return string.format("%d.%d.%d.%d", o1, o2, o3, o4)
end
local function is_private_ipv4(ip)
local a, b, c, d = ip:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
if not a then return false end
a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
if not (a and b and c and d) then return true end
if a == 0 then return true end
if a == 10 then return true end
if a == 127 then return true end
if a == 100 and b >= 64 and b <= 127 then return true end -- 100.64/10 CGNAT
if a == 169 and b == 254 then return true end -- link-local
if a == 172 and b >= 16 and b <= 31 then return true end -- 172.16/12
if a == 192 and b == 168 then return true end -- 192.168/16
if a >= 224 then return true end -- multicast + reserved
return false
end
local function is_private_ipv6(ip)
if ip == "::" or ip == "::1" then return true end
local l = ip:lower()
if l:match("^0*:") and not l:match("^0*::?0*$") and l ~= "0" then return true end
if l:sub(1, 2) == "fc" or l:sub(1, 2) == "fd" then return true end -- ULA fc00/7
local ll = l:sub(1, 4)
if ll == "fe80" or ll == "fe81" or ll == "fe82" or ll == "fe83"
or ll == "fe84" or ll == "fe85" or ll == "fe86" or ll == "fe87"
or ll == "fe88" or ll == "fe89" or ll == "fe8a" or ll == "fe8b"
or ll == "fe8c" or ll == "fe8d" or ll == "fe8e" or ll == "fe8f"
or ll == "fe90" or ll == "fe91" or ll == "fe92" or ll == "fe93"
or ll == "fe94" or ll == "fe95" or ll == "fe96" or ll == "fe97"
or ll == "fe98" or ll == "fe99" or ll == "fe9a" or ll == "fe9b"
or ll == "fe9c" or ll == "fe9d" or ll == "fe9e" or ll == "fe9f"
or ll == "fea0" or ll == "fea1" or ll == "fea2" or ll == "fea3"
or ll == "fea4" or ll == "fea5" or ll == "fea6" or ll == "fea7"
or ll == "fea8" or ll == "fea9" or ll == "feaa" or ll == "feab"
or ll == "feac" or ll == "fead" or ll == "feae" or ll == "feaf"
or ll == "feb0" or ll == "feb1" or ll == "feb2" or ll == "feb3"
or ll == "feb4" or ll == "feb5" or ll == "feb6" or ll == "feb7"
or ll == "feb8" or ll == "feb9" or ll == "feba" or ll == "febb"
or ll == "febc" or ll == "febd" or ll == "febe" or ll == "febf" then
return true -- link-local fe80::/10
end
if l:sub(1, 2) == "ff" then return true end -- multicast
return false
end
local function is_private(ip)
ip = ip or ""
if ip:find(":", 1, true) then return is_private_ipv6(ip) end
return is_private_ipv4(ip)
end
-- 粗校验:像不像一个 IPv6 字面量。
-- 带 ":" 的主机并不都是 IPv6 —— parse_url 对 "127.0.0.1:" 这类「有冒号但端口为空」
-- 的写法取不到端口,会把整个 "127.0.0.1:" 当主机名返回;它既不是合法域名,
-- is_private_ipv6 也识别不出,于是被当成公网放行。实测 curl 会把它连到 127.0.0.1:80。
-- 因此:含 ":" 但不像 IPv6 的一律拒绝(fail-closed)。
local function looks_like_ipv6(h)
if not h:match("^[%x:%.]+$") then return false end
if select(2, h:gsub(":", "")) < 2 then return false end
if h:find("::.*::") then return false end -- "::" 最多出现一次
return true
end
-- ---------- URL 解析 ----------
function M.parse_url(url)
url = util.trim(url or "")
local scheme, rest = url:match("^([%w%+%-%.]+)://(.*)$")
if not scheme then return nil, "无效 URL" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" then return nil, "仅支持 http/https" end
-- 主机部分到第一个 / ? # 为止(RFC 3986)。只剥 "/" 是不够的:
-- "http://127.0.0.1?a=1" 会剩下 "127.0.0.1?a=1",它既不是合法主机名也不是
-- 数值型 IPv4,DNS 解析必然失败,而解析失败是放行的(fail-open),
-- 于是绕过 SSRF 检查;curl 实际连的是 127.0.0.1(路由器上的 LuCI 就是 :80)。
local hostport = rest:match("^[^/%?#]*") or ""
-- 剥掉 userinfo(user:pass@):RFC 3986 里 userinfo 到**最后一个** @ 为止,
-- @ 之后才是主机。不剥的话 "http://evil@127.0.0.1/" 会把 "evil@127.0.0.1"
-- 当成主机名交给 check_public,DNS 解析失败即放行(fail-open),
-- 而 curl 实际连的是 127.0.0.1 —— 实测可复现。
local at = hostport:find("@", 1, true)
while at do
hostport = hostport:sub(at + 1)
at = hostport:find("@", 1, true)
end
local host, port
if hostport:sub(1, 1) == "[" then
local close = hostport:find("]", 1, true)
if not close then return nil, "无效主机名" end
host = hostport:sub(2, close - 1)
port = hostport:sub(close + 1):match("^:(%d+)$") or (scheme == "https" and "443" or "80")
else
local h, p = hostport:match("^([^:]+):(%d+)$")
if h then
host, port = h, p
else
host, port = hostport, (scheme == "https" and "443" or "80")
end
end
if not host or host == "" then return nil, "无效主机名" end
-- 端口范围校验:`(%d+)` 只保证是数字,`http://host:99999/` 与 `:0` 都能通过,
-- 而这两个端口连不出去 —— 下载必然失败,却会先经过一轮 DNS/SSRF 检查,
-- 报出来的是「连接失败」这种指错方向的原因。parse_proxy 早已做了同样的校验,
-- 这里对齐。
local pn = tonumber(port)
if not pn or pn < 1 or pn > 65535 then return nil, "端口无效" end
return { scheme = scheme, host = host, port = tostring(pn) }
end
-- 校验并规范化代理地址:支持 http/https/socks4/socks5/socks5h,可含 user:pass@
-- 空串 → ""(未使用);合法 → 规范化地址;非法 → nil, err
function M.parse_proxy(p)
local s = util.trim(p or "")
if s == "" then return "" end
local scheme, rest = s:match("^(%a[%w]*)://(.*)$")
if not scheme then return nil, "代理格式无效" end
scheme = scheme:lower()
if scheme ~= "http" and scheme ~= "https" and scheme ~= "socks4"
and scheme ~= "socks5" and scheme ~= "socks5h" then
return nil, "不支持的代理协议: " .. scheme
end
local userinfo, hostport = "", rest:gsub("/.*$", "")
local at = hostport:find("@", 1, true)
if at then
userinfo = hostport:sub(1, at - 1)
hostport = hostport:sub(at + 1)
end
if userinfo ~= "" and not userinfo:match("^[%w%.%-_:]+$") then
return nil, "代理用户名/密码含非法字符"
end
local host, port
local bracketed = hostport:sub(1, 1) == "["
if bracketed then
host = hostport:match("^%[([%w%.%-%:]+)%]")
port = hostport:match("^%[.*%]%:(%d+)$")
else
local h, prt = hostport:match("^([^:]+):(%d+)$")
if h then host, port = h, prt else host = hostport end
end
if not host or host == "" or not host:match("^[%w%.%-%:]+$") then
return nil, "代理主机无效"
end
-- 未加方括号的 IPv6 字面量:RFC 3986 要求 IPv6 主机必须写成 [addr]。
-- 没有方括号时 `::1:1080` 既可能是「地址 ::1 + 端口 1080」,也可能就是
-- 地址 `::1:1080` —— 无法判定。与其猜一个再拼出一条 curl -x 解析不了的
-- 代理串(静默失效),不如明确报错让用户补上方括号。
if not bracketed and select(2, host:gsub(":", "")) > 1 then
return nil, "IPv6 代理地址必须加方括号,例如 http://[::1]:1080"
end
if port then
port = tonumber(port)
if not port or port < 1 or port > 65535 then return nil, "代理端口无效" end
end
-- IPv6 字面量必须把方括号拼回去:上面为做主机校验把 [::1] 拆成了 ::1,
-- 直接拼会得到 `http://::1:1080` —— 冒号歧义,curl -x 与 http_proxy= 都
-- 解析不了(curl 会把 `::1:1080` 整个当成主机名),代理静默失效。
-- 单个冒号才是 host:port 的分隔符。
if host:find(":", 1, true) then host = "[" .. host .. "]" end
local r = scheme .. "://"
if userinfo ~= "" then r = r .. userinfo .. "@" end
r = r .. host
if port then r = r .. ":" .. port end
return r
end
-- 解析主机 → IP 列表;无法解析时返回 nil
local function resolve(host)
local nixio = util.try_require("nixio")
if not nixio then return nil end
local ok, addrs = pcall(function() return nixio.getaddrinfo(host) end)
if not ok or type(addrs) ~= "table" then return nil end
local ips = {}
for _, a in ipairs(addrs) do
if a and a.addr then ips[#ips + 1] = a.addr end
end
if #ips == 0 then return nil end
return ips
end
-- 回环别名(/etc/hosts 常见写法),比较前会先去掉尾部点
local LOOPBACK_NAMES = {
["localhost"] = true,
["localhost.localdomain"] = true,
["localhost4"] = true,
["localhost4.localdomain4"] = true,
["localhost6"] = true,
["localhost6.localdomain6"] = true,
["ip6-localhost"] = true,
["ip6-loopback"] = true,
["ip6-localnet"] = true,
}
-- SSRF 预检:拒绝 localhost / 私网 / 保留地址。返回 ok, reason
function M.check_public(host)
host = util.trim(host or ""):lower()
if host == "" then return false, "空主机名" end
-- userinfo 应已被 parse_url 剥掉;这里再挡一次。
-- "evil@127.0.0.1" 不是合法主机名,DNS 解析必然失败,而解析失败是放行的
-- (fail-open),于是会绕过检查 —— 实测该写法确实能连到 127.0.0.1。
if host:find("@", 1, true) then return false, "主机名含非法字符 @" end
-- 空白 / 百分号编码:主机名里都不合法("127.0.0.1%00.example.com" 这类
-- 截断写法当前版本的 curl 会直接判 URL 非法,但不同版本行为不一,直接拒绝更稳)
if host:find("[%s%%]") then return false, "主机名含非法字符" end
-- 去掉尾部点:"localhost." 与 "localhost" 指向同一台机器,
-- 不归一会被当成普通域名交给 DNS,解析失败时即放行。
host = host:gsub("%.$", "")
if host == "" then return false, "空主机名" end
if host:find(":", 1, true) then
if not looks_like_ipv6(host) then return false, "无效主机名" end
if is_private_ipv6(host) then return false, "目标为内网/保留地址" end
return true
end
-- 数值型 IPv4(十进制 / 八进制 / 十六进制 / 短写)按 inet_aton 语义归一后再判私网
local norm = normalize_ipv4(host)
if norm then
if is_private_ipv4(norm) then return false, "目标为内网/保留地址" end
return true
end
if LOOPBACK_NAMES[host] then return false, "目标为 localhost" end
local ips = resolve(host)
if not ips then
-- 解析不出 IP 一律拒绝(fail-closed)。
--
-- 此前这里放行,理由是「无 DNS 解析能力时尽力而为」—— 但那是一个
-- SSRF 绕过口:`127.0.0.1.nip.io` 这类**公网可解析到内网**的域名,
-- 只要本机这一刻解析不出来(nixio 缺失、解析器临时故障、超时),
-- 就绕过上面全部检查被放行,而 curl 自己仍会把它解析到 127.0.0.1
-- 并连上去。检查的强度不能低于被检查者。
--
-- 代价为零:本包依赖 luci-lua-runtime,后者硬依赖 luci-lib-nixio,
-- 所以 resolve() 返回 nil 只意味着「解析真的失败了」—— 那种情况下
-- curl 同样解析不了,下载本来就会失败,只是错误信息会变成
-- 「连接失败」这种指错方向的说法。
return false, "无法解析目标主机名"
end
for _, ip in ipairs(ips) do
if not is_private(ip) then return true end
end
return false, "目标仅解析到内网/保留地址"
end
-- ---------- 下载 ----------
local function have(cmd)
return os.execute("command -v " .. cmd .. " >/dev/null 2>&1") == 0
end
local function detect_tool()
if have("curl") then return "curl" end
if have("wget") then return "wget" end
return nil
end
local function location_from_headers(path)
local raw = util.read_file(path)
if not raw then return nil end
for line in raw:gmatch("[^\r\n]+") do
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
if v then return util.trim(v) end
end
return nil
end
-- 读取响应头(curl -D 输出),返回小写键 → 值的表;重复键取最后一个
local function read_headers(path)
local h = {}
local raw = util.read_file(path)
if not raw then return h end
for line in raw:gmatch("[^\r\n]+") do
local k, v = line:match("^%s*([^:]+):%s*(.*)$")
if k then h[k:lower()] = v end
end
return h
end
local function resolve_url(base, loc)
if loc:find("://", 1, true) then return loc end
local scheme, host = base:match("^([%w]+)://([^/]+)")
if not scheme then return loc end
-- 协议相对地址(//host/path):沿用 scheme,但主机取自 Location 本身,
-- 否则会被误当成同主机的路径而漏掉跨主机跳转
if loc:sub(1, 2) == "//" then return scheme .. ":" .. loc end
if loc:sub(1, 1) == "/" then return scheme .. "://" .. host .. loc end
local base_path = base:match("^[%w]+://[^/]+(.*)$") or "/"
local dir = base_path:match("^(.*)/[^/]*$") or ""
return scheme .. "://" .. host .. dir .. "/" .. loc
end
-- 从 wget -S 日志中按出现顺序提取重定向目标(Location)
local function locations_from_log(raw)
local locs = {}
for line in (raw or ""):gmatch("[^\r\n]+") do
local v = line:match("^%s*[Ll]ocation:%s*(.+)$")
if v then locs[#locs + 1] = util.trim(v) end
end
return locs
end
-- 复检重定向链:每一跳都必须通过 check_public。
-- busybox wget 没有 --max-redirect,无法在发出请求前拦住重定向,只能在 -S 日志里
-- 逐跳校验;任一跳不安全即整体失败并丢弃响应体(§14/§16)。
-- 纯函数,不触网,便于离线测试。返回 ok, err
function M.validate_redirect_chain(base_url, log)
for _, loc in ipairs(locations_from_log(log)) do
local next_url = resolve_url(base_url, loc)
local np = M.parse_url(next_url)
if not np then return false, "重定向目标无效: " .. tostring(loc) end
local ok, re = M.check_public(np.host)
if not ok then return false, "重定向目标不安全: " .. (re or "") end
end
return true
end
-- 代理地址去凭据,用于日志(§39:不记录代理密码)
function M.redact_proxy(p)
local scheme, rest = tostring(p or ""):match("^(%a[%w]*)://(.*)$")
if not scheme then return "***" end
return scheme .. "://" .. (rest:gsub("^[^@]*@", ""))
end
-- 抹掉文本中的 URL 凭据(//user:pass@),用于日志与错误信息(§39)
function M.scrub_credentials(text)
return (tostring(text or ""):gsub("//([^%s/@:]*)%:([^%s/@]*)@", "//***@"))
end
local function fetch_curl(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_arg = ""
if opts.proxy and opts.proxy ~= "" then
proxy_arg = " -x " .. util.shq(opts.proxy)
end
-- 临时文件名带随机标记:固定路径会让两个并发下载(如两个订阅的 cron 同时触发,
-- 或手动更新撞上 cron)互相覆盖,A 订阅存下 B 的内容且都不报错。
local tag = util.rnd_hex(8)
local cur = url
for redirect = 0, M.MAX_REDIRECTS do
local tmp = "/tmp/substore_dl_" .. tag .. "_" .. redirect .. ".tmp"
local hdr = tmp .. ".hdr"
local errf = tmp .. ".err"
-- 清理必须在**每一条**退出路径上执行。
-- 此前只在「本轮开始」和「2xx 成功」两处 os.remove:失败路径(curl 报错、
-- 超限、重定向无 Location / 目标不安全、HTTP 4xx-5xx、重定向次数过多)
-- 都把文件留在 /tmp。而 OpenWrt 的 /tmp 是 tmpfs —— 占的是内存;
-- 订阅更新失败(cron 定时重试)会一轮轮往内存里堆 .tmp/.hdr/.err,
-- 其中 .tmp 可能是部分下载的响应体,最大到 max_size。
local function cleanup()
os.remove(tmp); os.remove(hdr); os.remove(errf)
end
cleanup()
local cmd = string.format(
"curl -sS -o %s --max-time %d --connect-timeout %d --max-redirs 0 --max-filesize %d -D %s -w \"%%{http_code}\"%s %s 2>%s",
util.shq(tmp), t, math.min(t, 10), max, util.shq(hdr), proxy_arg, util.shq(cur), util.shq(errf))
local p = io.popen(cmd)
local code = p and p:read("*a") or ""
if p then p:close() end
code = util.trim(code)
if code == "" or code == "000" then
local msg = util.trim(util.read_file(errf) or "下载失败")
cleanup()
return nil, msg
end
-- 只把 2xx 当成功。写成 [23] 会把 3xx 也当成功,于是重定向响应体(通常是空的
-- 或一段 HTML)被当成订阅内容存下去:已存的节点被清空、node_count 归 0,
-- 而 error 仍是空字符串,列表页看不出任何异常。下面的重定向分支也因此永远不会执行。
if code:match("^2%d%d$") then
local size = util.file_size(tmp)
if size > max then
cleanup()
return nil, "响应超过大小限制 (" .. max .. " 字节)"
end
local content = util.read_file(tmp)
local headers = read_headers(hdr)
cleanup()
if not content then return nil, "读取响应失败" end
return content, headers
end
if code:match("^3%d%d$") then
local loc = location_from_headers(hdr)
if not loc then
cleanup()
return nil, "重定向无 Location"
end
local next_url = resolve_url(cur, loc)
local np = M.parse_url(next_url)
if not np then
cleanup()
return nil, "重定向目标无效"
end
local ok, re = M.check_public(np.host)
if not ok then
cleanup()
return nil, "重定向目标不安全: " .. (re or "")
end
cleanup()
cur = next_url
else
cleanup()
return nil, "HTTP 错误 " .. code
end
end
return nil, "重定向次数过多"
end
-- wget 后端的代理环境变量。busybox wget 只能通过 http_proxy/https_proxy 环境变量
-- 使用 http(s) 代理;遇到它不支持的协议(socks*)必须明确报错,
-- 不能丢掉代理静默直连(§12)。返回 env 或 nil, err
function M.wget_proxy_env(proxy)
if proxy == nil or proxy == "" then return "" end
local scheme = tostring(proxy):match("^(%a[%w]*)://")
scheme = scheme and scheme:lower() or ""
if scheme ~= "http" and scheme ~= "https" then
return nil, "当前下载后端 (wget) 不支持 " .. string.upper(scheme) ..
" 代理,请安装 curl 或改用 http 代理"
end
return "http_proxy=" .. util.shq(proxy) .. " https_proxy=" .. util.shq(proxy) .. " "
end
local function fetch_wget(url, parsed, opts)
local max, t = opts.max_size, opts.timeout
local proxy_env, perr = M.wget_proxy_env(opts.proxy)
if not proxy_env then return nil, perr end
local tag = util.rnd_hex(8)
local tmp = "/tmp/substore_dl_wget_" .. tag .. ".tmp"
local log = tmp .. ".log"
os.remove(tmp); os.remove(log)
-- -S 打印响应头(含整条重定向链),据此复检 SSRF;
-- 日志与响应体分流:体写 tmp,链写 log
local cmd = proxy_env .. string.format("wget -S -q -T %d -O %s %s >%s 2>&1",
t, util.shq(tmp), util.shq(url), util.shq(log))
local rc = os.execute(cmd)
-- 退出码必须看:wget 失败时可能已经写下半截响应体,只判断 size == 0
-- 会把残缺内容当成下载成功。
-- Lua 5.1 的 os.execute 返回数字退出码,5.2+ 返回 true/nil, "exit", code,两者都兼容。
local exit_ok = (rc == true) or (type(rc) == "number" and rc == 0)
local logtext = util.read_file(log) or ""
os.remove(log)
local safe, reason = M.validate_redirect_chain(url, logtext)
if not safe then
os.remove(tmp)
return nil, reason
end
local size = util.file_size(tmp)
if size > max then os.remove(tmp); return nil, "响应超过大小限制 (" .. max .. " 字节)" end
if not exit_ok then
os.remove(tmp)
return nil, "下载失败(wget 退出码非 0)"
end
if size == 0 then os.remove(tmp); return nil, "下载失败或内容为空" end
local content = util.read_file(tmp)
os.remove(tmp)
if not content then return nil, "读取响应失败" end
return content, {} -- wget 不捕获响应头(无 subscription-userinfo)
end
local function fetch(tool, url, parsed, opts)
if tool == "curl" then return fetch_curl(url, parsed, opts) end
return fetch_wget(url, parsed, opts)
end
-- 下载订阅内容。成功返回 body, headers, nil;失败返回 nil, nil, err
-- opts.proxy 为可选代理地址(scheme://host:port),应由调用方用 parse_proxy 校验
function M.download(url, opts)
opts = opts or {}
local max_size = opts.max_size or M.DEFAULT_MAX_SIZE
local timeout = opts.timeout or M.DEFAULT_TIMEOUT
local parsed = M.parse_url(url)
if not parsed then return nil, nil, "无效 URL" end
local ok, reason = M.check_public(parsed.host)
if not ok then return nil, nil, reason end
local tool = detect_tool()
if not tool then return nil, nil, "无可用下载工具 (curl/wget)" end
local body, headers = fetch(tool, url, parsed, { max_size = max_size, timeout = timeout, proxy = opts.proxy })
if not body then return nil, nil, headers end
return body, headers or {}, nil
end
return M