Initial commit
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
local socket = require("socket")
|
||||
local urlmod = require("socket.url")
|
||||
|
||||
local M = {}
|
||||
|
||||
local function parse_target(target)
|
||||
local ok, parsed = pcall(urlmod.parse, target)
|
||||
if not ok or type(parsed) ~= "table" then
|
||||
return nil, "invalid URL"
|
||||
end
|
||||
if parsed.scheme ~= "http" then
|
||||
return nil, "direct transport only supports http:// URLs"
|
||||
end
|
||||
if not parsed.host or parsed.host == "" then
|
||||
return nil, "URL has no host"
|
||||
end
|
||||
local port = tonumber(parsed.port) or 80
|
||||
if port < 1 or port > 65535 then
|
||||
return nil, "invalid port"
|
||||
end
|
||||
local path = parsed.path
|
||||
if not path or path == "" then path = "/" end
|
||||
if parsed.params and parsed.params ~= "" then path = path .. ";" .. parsed.params end
|
||||
if parsed.query and parsed.query ~= "" then path = path .. "?" .. parsed.query end
|
||||
local host_header = parsed.host
|
||||
if parsed.host:find(":", 1, true) and parsed.host:sub(1, 1) ~= "[" then
|
||||
host_header = "[" .. parsed.host .. "]"
|
||||
end
|
||||
if port ~= 80 then host_header = host_header .. ":" .. tostring(port) end
|
||||
return {
|
||||
host = parsed.host,
|
||||
port = port,
|
||||
path = path,
|
||||
host_header = host_header,
|
||||
}
|
||||
end
|
||||
|
||||
local function close_quietly(tcp)
|
||||
if tcp then pcall(function() tcp:close() end) end
|
||||
end
|
||||
|
||||
function M.probe(target, timeout)
|
||||
local parsed, parse_err = parse_target(target)
|
||||
if not parsed then
|
||||
return false, { stage = "url", detail = parse_err }
|
||||
end
|
||||
local tcp, socket_err = socket.tcp()
|
||||
if not tcp then
|
||||
return false, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
|
||||
end
|
||||
tcp:settimeout(timeout or 6)
|
||||
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
|
||||
close_quietly(tcp)
|
||||
if not connected then
|
||||
return false, {
|
||||
stage = "tcp",
|
||||
detail = tostring(connect_err or "connection failed"),
|
||||
host = parsed.host,
|
||||
port = parsed.port,
|
||||
}
|
||||
end
|
||||
return true, { host = parsed.host, port = parsed.port }
|
||||
end
|
||||
|
||||
local function decode_chunked(body)
|
||||
local out = {}
|
||||
local pos = 1
|
||||
while true do
|
||||
local line_end = body:find("\r\n", pos, true)
|
||||
if not line_end then return nil, "invalid chunked response" end
|
||||
local size_line = body:sub(pos, line_end - 1):match("^%s*([^;]+)")
|
||||
local size = tonumber(size_line, 16)
|
||||
if not size then return nil, "invalid chunk size" end
|
||||
pos = line_end + 2
|
||||
if size == 0 then return table.concat(out) end
|
||||
local chunk_end = pos + size - 1
|
||||
if chunk_end > #body then return nil, "truncated chunked response" end
|
||||
out[#out + 1] = body:sub(pos, chunk_end)
|
||||
pos = chunk_end + 1
|
||||
if body:sub(pos, pos + 1) ~= "\r\n" then return nil, "invalid chunk separator" end
|
||||
pos = pos + 2
|
||||
end
|
||||
end
|
||||
|
||||
function M.request(method, target, headers, body, timeout)
|
||||
local parsed, parse_err = parse_target(target)
|
||||
if not parsed then
|
||||
return nil, nil, nil, nil, { stage = "url", detail = parse_err }
|
||||
end
|
||||
|
||||
local tcp, socket_err = socket.tcp()
|
||||
if not tcp then
|
||||
return nil, nil, nil, nil, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") }
|
||||
end
|
||||
tcp:settimeout(timeout or 12)
|
||||
local connected, connect_err = tcp:connect(parsed.host, parsed.port)
|
||||
if not connected then
|
||||
close_quietly(tcp)
|
||||
return nil, nil, nil, nil, {
|
||||
stage = "tcp",
|
||||
detail = tostring(connect_err or "connection failed"),
|
||||
host = parsed.host,
|
||||
port = parsed.port,
|
||||
}
|
||||
end
|
||||
|
||||
headers = headers or {}
|
||||
local has_host, has_connection, has_length = false, false, false
|
||||
local lines = { tostring(method or "GET") .. " " .. parsed.path .. " HTTP/1.1" }
|
||||
for k, v in pairs(headers) do
|
||||
local lower = tostring(k):lower()
|
||||
if lower == "host" then has_host = true end
|
||||
if lower == "connection" then has_connection = true end
|
||||
if lower == "content-length" then has_length = true end
|
||||
lines[#lines + 1] = tostring(k) .. ": " .. tostring(v)
|
||||
end
|
||||
if not has_host then lines[#lines + 1] = "Host: " .. parsed.host_header end
|
||||
if not has_connection then lines[#lines + 1] = "Connection: close" end
|
||||
if body ~= nil and not has_length then lines[#lines + 1] = "Content-Length: " .. tostring(#body) end
|
||||
lines[#lines + 1] = ""
|
||||
lines[#lines + 1] = ""
|
||||
local payload = table.concat(lines, "\r\n") .. (body or "")
|
||||
|
||||
local sent, send_err, sent_partial = tcp:send(payload)
|
||||
if not sent then
|
||||
close_quietly(tcp)
|
||||
return nil, nil, nil, nil, {
|
||||
stage = "send",
|
||||
detail = tostring(send_err or "send failed") .. (sent_partial and (" after " .. tostring(sent_partial) .. " bytes") or ""),
|
||||
}
|
||||
end
|
||||
|
||||
local chunks = {}
|
||||
while true do
|
||||
local chunk, receive_err, partial = tcp:receive(4096)
|
||||
if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk end
|
||||
if partial and #partial > 0 then chunks[#chunks + 1] = partial end
|
||||
if receive_err then
|
||||
if receive_err ~= "closed" then
|
||||
close_quietly(tcp)
|
||||
return nil, nil, nil, nil, { stage = "receive", detail = tostring(receive_err) }
|
||||
end
|
||||
break
|
||||
end
|
||||
end
|
||||
close_quietly(tcp)
|
||||
|
||||
local raw = table.concat(chunks)
|
||||
local header_block, response_body = raw:match("^(.-)\r\n\r\n(.*)$")
|
||||
if not header_block then
|
||||
return nil, nil, nil, nil, { stage = "http", detail = "malformed HTTP response" }
|
||||
end
|
||||
local status_line, rest = header_block:match("^([^\r\n]+)\r\n(.*)$")
|
||||
if not status_line then status_line, rest = header_block, "" end
|
||||
local code = tonumber(status_line:match("^HTTP/%d+%.%d+%s+(%d%d%d)"))
|
||||
if not code then
|
||||
return nil, nil, nil, nil, { stage = "http", detail = "invalid status line: " .. tostring(status_line) }
|
||||
end
|
||||
|
||||
local response_headers = {}
|
||||
for line in (rest .. "\r\n"):gmatch("(.-)\r\n") do
|
||||
local key, value = line:match("^([^:]+):%s*(.*)$")
|
||||
if key then response_headers[key:lower()] = value end
|
||||
end
|
||||
if response_headers["transfer-encoding"] and response_headers["transfer-encoding"]:lower():find("chunked", 1, true) then
|
||||
local decoded, chunk_err = decode_chunked(response_body)
|
||||
if not decoded then
|
||||
return nil, nil, nil, nil, { stage = "http", detail = chunk_err }
|
||||
end
|
||||
response_body = decoded
|
||||
end
|
||||
|
||||
return code, response_headers, status_line, response_body, nil
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user