| -- |
| -- HTTP 1.1 library for HAProxy Lua modules |
| -- |
| -- The library is loosely modeled after Python's Requests Library |
| -- using the same field names and very similar calling conventions for |
| -- "HTTP verb" methods (where we use Lua specific named parameter support) |
| -- |
| -- In addition to client side, the library also supports server side request |
| -- parsing, where we utilize HAProxy Lua API for all heavy lifting. |
| -- |
| -- |
| -- Copyright (c) 2017-2020. Adis Nezirović <anezirovic@haproxy.com> |
| -- Copyright (c) 2017-2020. HAProxy Technologies, LLC. |
| -- |
| -- Licensed under the Apache License, Version 2.0 (the "License"); |
| -- you may not use this file except in compliance with the License. |
| -- You may obtain a copy of the License at |
| -- |
| -- http://www.apache.org/licenses/LICENSE-2.0 |
| -- |
| -- Unless required by applicable law or agreed to in writing, software |
| -- distributed under the License is distributed on an "AS IS" BASIS, |
| -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| -- See the License for the specific language governing permissions and |
| -- limitations under the License. |
| -- |
| -- SPDX-License-Identifier: Apache-2.0 |
| |
| -- Downloaded from: https://github.com/haproxytech/haproxy-lua-http |
| |
| local _author = "Adis Nezirovic <anezirovic@haproxy.com>" |
| local _copyright = "Copyright 2017-2020. HAProxy Technologies, LLC." |
| local _version = "1.0.0" |
| |
| local json = require "json" |
| |
| -- Utility functions |
| |
| -- HTTP headers fetch helper |
| -- |
| -- Returns a header value(s) according to strategy (fold by default): |
| -- - single/string value for "fold", "first" and "last" strategies |
| -- - table for "all" strategy (for single value, a table with single element) |
| -- |
| -- @param hdrs table Headers table as received by http.get and friends |
| -- @param name string Header name |
| -- @param strategy string "multiple header values" handling strategy |
| -- @return header value (string or table) or nil |
| local function get_header(hdrs, name, strategy) |
| if hdrs == nil or name == nil then return nil end |
| |
| local v = hdrs[name:lower()] |
| if type(v) ~= "table" and strategy ~= "all" then return v end |
| |
| if strategy == nil or strategy == "fold" then |
| return table.concat(v, ",") |
| elseif strategy == "first" then |
| return v[1] |
| elseif strategy == "last" then |
| return v[#v] |
| elseif strategy == "all" then |
| if type(v) ~= "table" then |
| return {v} |
| else |
| return v |
| end |
| end |
| end |
| |
| |
| |
| -- HTTP headers iterator helper |
| -- |
| -- Returns key/value pairs for all header, making sure that returned values |
| -- are always of string type (if necessary, it folds multiple headers with |
| -- the same name) |
| -- |
| -- @param hdrs table Headers table as received by http.get and friends |
| -- @return header name/value iterator (suitable for use in "for" loops) |
| local function get_headers_folded(hdrs) |
| if hdrs == nil then |
| return function() end |
| end |
| |
| local function iter(t, k) |
| local v |
| k, v = next(t, k) |
| |
| if v ~= nil then |
| if type(v) ~= "table" then |
| return k, v |
| else |
| return k, table.concat(v, ",") |
| end |
| end |
| end |
| |
| return iter, hdrs, nil |
| end |
| |
| -- HTTP headers iterator |
| -- |
| -- Returns key/value pairs for all headers, for multiple headers with same name |
| -- it will return every name/value pair |
| -- (i.e. you can safely use it to process responses with 'Set-Cookie' header) |
| -- |
| -- @param hdrs table Headers table as received by http.get and friends |
| -- @return header name/value iterator (suitable for use in "for" loops) |
| local function get_headers_flattened(hdrs) |
| if hdrs == nil then |
| return function() end |
| end |
| |
| local k -- top level key (string) |
| local k_sub = 0 -- sub table key (integer), 0 if item not a table, |
| -- nil after last sub table iteration |
| local v_sub -- sub table |
| |
| return function () |
| local v |
| if k_sub == 0 then |
| k, v = next(hdrs, k) |
| if k == nil then return end |
| else |
| k_sub, v = next(v_sub, k_sub) |
| |
| if k_sub == nil then |
| k_sub = 0 |
| k, v = next(hdrs, k) |
| end |
| end |
| |
| if k == nil then return end |
| |
| if type(v) ~= "table" then |
| return k, v |
| else |
| v_sub = v |
| k_sub = k_sub + 1 |
| return k, v[k_sub] |
| end |
| end |
| end |
| |
| |
| --- Parse key/value pairs from a string |
| -- |
| -- @param s Lua string with (multiple) key/value pairs (separated by 'sep') |
| -- |
| -- @return Table with parsed keys and values or nil |
| local function parse_kv(s, sep) |
| if s == nil then return nil end |
| idx = 1 |
| result = {} |
| |
| while idx < s:len() do |
| i, j = s:find(sep, idx) |
| |
| if i == nil then |
| k, v = string.match(s:sub(idx), "^(.-)=(.*)$") |
| if k then result[k] = v end |
| break |
| end |
| |
| k, v = string.match(s:sub(idx, i-1), "^(.-)=(.*)$") |
| if k then result[k] = v end |
| idx = j + 1 |
| end |
| |
| if next(result) == nil then |
| return nil |
| else |
| return result |
| end |
| end |
| |
| |
| --- Make deep copy of table and it's values |
| -- |
| -- Use only for simple tables (it handles nested table values), but not for |
| -- Lua objects or similar, or very big tables (this uses recursion). |
| -- |
| -- @param t Cloned Lua table or nil |
| -- |
| -- @return Cloned table or nil |
| local function copyTable(t) |
| if type(t) ~= "table" then |
| return nil |
| end |
| |
| local r = {} |
| |
| for k, v in pairs(t) do |
| if type(v) == "table" then |
| r[k] = copyTable(v) |
| else |
| r[k] = v |
| end |
| end |
| |
| return r |
| end |
| |
| |
| --- Namespace object which hosts HTTP verb methods and request/response classes |
| local M = {} |
| |
| |
| --- HTTP response class |
| M.response = {} |
| M.response.__index = M.response |
| |
| local _reason = { |
| [200] = "OK", |
| [201] = "Created", |
| [204] = "No Content", |
| [301] = "Moved Permanently", |
| [302] = "Found", |
| [400] = "Bad Request", |
| [403] = "Forbidden", |
| [404] = "Not Found", |
| [405] = "Method Not Allowed", |
| [408] = "Request Timeout", |
| [413] = "Payload Too Large", |
| [429] = "Too many requests", |
| [500] = "Internal Server Error", |
| [501] = "Not Implemented", |
| [502] = "Bad Gateway", |
| [503] = "Service Unavailable", |
| [504] = "Gateway Timeout" |
| } |
| |
| --- Creates HTTP response from scratch |
| -- |
| -- @param status_code HTTP status code |
| -- @param reason HTTP status code text (e.g. "OK" for 200 response) |
| -- @param headers HTTP response headers |
| -- @param request The HTTP request which triggered the response |
| -- @param encoding Default encoding for response or conversions |
| -- |
| -- @return response object |
| function M.response.create(t) |
| local self = setmetatable({}, M.response) |
| |
| if not t then |
| t = {} |
| end |
| |
| self.status_code = t.status_code or nil |
| self.reason = t.reason or _reason[self.status_code] or "" |
| self.headers = copyTable(t.headers) or {} |
| self.content = t.content or "" |
| self.request = t.request or nil |
| self.encoding = t.encoding or "utf-8" |
| |
| return self |
| end |
| |
| function M.response.send(self, applet) |
| applet:set_status(tonumber(self.status_code), self.reason) |
| |
| for k, v in pairs(self.headers) do |
| if type(v) == "table" then |
| for _, hdr_val in pairs(v) do |
| applet:add_header(k, hdr_val) |
| end |
| else |
| applet:add_header(k, v) |
| end |
| end |
| |
| if not self.headers["content-type"] then |
| if type(self.content) == "table" then |
| applet:add_header("content-type", "application/json; charset=" .. |
| self.encoding) |
| if next(self.content) == nil then |
| -- Return empty JSON object for empty Lua tables |
| -- (that makes more sense then returning []) |
| self.content = "{}" |
| else |
| self.content = json.encode(self.content) |
| end |
| else |
| applet:add_header("content-type", "text/plain; charset=" .. |
| self.encoding) |
| end |
| end |
| |
| if not self.headers["content-length"] then |
| applet:add_header("content-length", #tostring(self.content)) |
| end |
| |
| applet:start_response() |
| applet:send(tostring(self.content)) |
| end |
| |
| --- Convert response content to JSON |
| -- |
| -- @return Lua table (decoded json) |
| function M.response.json(self) |
| return json.decode(self.content) |
| end |
| |
| -- Response headers getter |
| -- |
| -- Returns a header value(s) according to strategy (fold by default): |
| -- - single/string value for "fold", "first" and "last" strategies |
| -- - table for "all" strategy (for single value, a table with single element) |
| -- |
| -- @param name string Header name |
| -- @param strategy string "multiple header values" handling strategy |
| -- @return header value (string or table) or nil |
| function M.response.get_header(self, name, strategy) |
| return get_header(self.headers, name, strategy) |
| end |
| |
| -- Response headers iterator |
| -- |
| -- Yields key/value pairs for all headers, making sure that returned values |
| -- are always of string type |
| -- |
| -- @param folded boolean Specifies whether to fold headers with same name |
| -- @return header name/value iterator (suitable for use in "for" loops) |
| function M.response.get_headers(self, folded) |
| if folded == true then |
| return get_headers_folded(self.headers) |
| else |
| return get_headers_flattened(self.headers) |
| end |
| end |
| |
| |
| --- HTTP request class (client or server side, depending on the constructor) |
| M.request = {} |
| M.request.__index = M.request |
| |
| --- HTTP request constructor |
| -- |
| -- Parses client HTTP request (as forwarded by HAProxy) |
| -- |
| -- @param applet HAProxy AppletHTTP Lua object |
| -- |
| -- @return Request object |
| function M.request.parse(applet) |
| local self = setmetatable({}, M.request) |
| self.method = applet.method |
| |
| if (applet.method == "POST" or applet.method == "PUT") and |
| applet.length > 0 then |
| self.data = applet:receive() |
| if self.data == "" then self.data = nil end |
| end |
| |
| self.headers = {} |
| for k, v in pairs(applet.headers) do |
| if (v[1]) then -- (non folded header with multiple values) |
| self.headers[k] = {} |
| for _, val in pairs(v) do |
| table.insert(self.headers[k], val) |
| end |
| else |
| self.headers[k] = v[0] |
| end |
| end |
| |
| if not self.headers["host"] then |
| return nil, "Bad request, no Host header specified" |
| end |
| |
| self.cookies = parse_kv(self.headers["cookie"], "; ") |
| |
| -- TODO: Patch ApletHTTP and add schema of request |
| local schema = applet.schema or "http" |
| local url = {schema, "://", self.headers["host"], applet.path} |
| |
| self.params = {} |
| if applet.qs:len() > 0 then |
| for _, arg in ipairs(core.tokenize(applet.qs, "&", true)) do |
| kv = core.tokenize(arg, "=", true) |
| self.params[kv[1]] = kv[2] |
| end |
| url[#url+1] = "?" |
| url[#url+1] = applet.qs |
| end |
| |
| self.url = table.concat(url) |
| |
| return self |
| end |
| |
| --- Escape Lua pattern chars in HTTP multipart boundary |
| -- |
| -- This function escapes only minimal number of characters, which can be |
| -- observed in multipart boundaries, namely: -, +, ? and . |
| -- |
| -- @param s string Data to escape |
| -- |
| -- @return escaped data (string) |
| local function escape_pattern(s) |
| return s:gsub("%-", "%%-"):gsub("%+", "%%+"):gsub("%?", "%%?"):gsub("%.", "%%.") |
| end |
| |
| --- Parse HTTP POST data |
| -- |
| -- @return Table with submitted form data |
| function M.request.parse_multipart(self) |
| local ct = self.headers['content-type'] |
| if ct == nil then |
| return nil, 'Content-Type header not present' |
| end |
| |
| if self.data == nil then |
| return nil, 'Empty body' |
| end |
| local body = self.data |
| local result ={} |
| |
| if ct:find('^multipart/form[-]data;') then |
| local boundary = ct:match('^multipart/form[-]data; boundary=["]?(.+)["]?$') |
| if boundary == nil then |
| return nil, 'Could not parse boundary from Content-Type' |
| end |
| |
| -- per RFC2046, CLRF is treated as a part of boundary |
| -- but first one does not have it, so we're going pretend |
| -- it is part of the content and ignore it there (in the pattern) |
| boundary = string.format('%%-%%-%s.-\r\n', escape_pattern(boundary)) |
| |
| local i = 1 |
| local j |
| local old_i |
| |
| while true do |
| i, j = body:find(boundary, i) |
| |
| if i == nil then break end |
| |
| if old_i then |
| local part = body:sub(old_i, i - 1) |
| local k, fn, t, v = part:match('^[cC]ontent[-][dD]isposition: form[-]data; name[=]"(.+)"; filename="(.+)"\r\n[cC]ontent[-][tT]ype: (.+)\r\n\r\n(.+)\r\n$') |
| |
| if k then |
| result[k] = { |
| filename = fn, |
| content_type = t, |
| data = v |
| } |
| else |
| k, v = part:match('^[cC]ontent[-][dD]isposition: form[-]data; name[=]"(.+)"\r\n\r\n(.+)\r\n$') |
| |
| if k then |
| result[k] = v |
| end |
| end |
| |
| end |
| |
| i = j + 1 |
| old_i = i |
| end |
| elseif ct == 'application/x-www-form-urlencoded' then |
| result = parse_kv(body, '&') |
| else |
| return nil, 'Unsupported Content-Type: ' .. ct |
| end |
| |
| if result == nil or not next(result) then |
| return nil, 'Could not parse form data' |
| end |
| |
| return result |
| end |
| |
| --- Reads (all) chunks from a HTTP response |
| -- |
| -- @param socket socket object (with already established tcp connection) |
| -- @param get_all boolean (true by default), collect all chunks at once |
| -- or yield every chunk separately. |
| -- |
| -- @return Full response payload or nil and an error message |
| function M.receive_chunked(socket, get_all) |
| if socket == nil then |
| return nil, "http.receive_chunked: Socket is nil" |
| end |
| local data = {} |
| |
| while true do |
| local chunk, err = socket:receive("*l") |
| |
| if chunk == nil then |
| return nil, "http.receive_chunked(): Receive error (chunk length): " .. tostring(err) |
| end |
| |
| local chunk_len = tonumber(chunk, 16) |
| if chunk_len == nil then |
| return nil, "http.receive_chunked(): Could not parse chunk length" |
| end |
| |
| if chunk_len == 0 then |
| -- TODO: support trailers |
| break |
| end |
| |
| -- Consume next chunk (including the \r\n) |
| chunk, err = socket:receive(chunk_len+2) |
| if chunk == nil then |
| return nil, "http.receive_chunked(): Receive error (chunk data): " .. tostring(err) |
| end |
| |
| -- Strip the \r\n before collection |
| local chunk_data = string.sub(chunk, 1, -3) |
| |
| if get_all == false then |
| return chunk_data |
| end |
| |
| table.insert(data, chunk_data) |
| end |
| |
| return table.concat(data) |
| end |
| |
| |
| -- Request headers getter |
| -- |
| -- Returns a header value(s) according to strategy (fold by default): |
| -- - single/string value for "fold", "first" and "last" strategies |
| -- - table for "all" strategy (for single value, a table with single element) |
| -- |
| -- @param name string Header name |
| -- @param strategy string "multiple header values" handling strategy |
| -- @return header value (string or table) or nil |
| function M.request.get_header(self, name, strategy) |
| return get_header(self.headers, name, strategy) |
| end |
| |
| -- Request headers iterator |
| -- |
| -- Yields key/value pairs for all headers, making sure that returned values |
| -- are always of string type |
| -- |
| -- @param hdrs table Headers table as received by http.get and friends |
| -- @param folded boolean Specifies whether to fold headers with same name |
| -- @return header name/value iterator (suitable for use in "for" loops) |
| function M.request.get_headers(self, folded) |
| if folded == true then |
| return get_headers_folded(self.headers) |
| else |
| return get_headers_flattened(self.headers) |
| end |
| end |
| |
| --- Creates HTTP request from scratch |
| -- |
| -- @param method HTTP method |
| -- @param url Valid HTTP url |
| -- @param headers Lua table with request headers |
| -- @param data Request content |
| -- @param params Lua table with request url arguments |
| -- @param auth (username, password) tuple for HTTP auth |
| -- |
| -- @return request object |
| function M.request.create(t) |
| local self = setmetatable({}, M.request) |
| |
| if t.method then |
| self.method = t.method:lower() |
| else |
| self.method = "get" |
| end |
| self.url = t.url or nil |
| self.headers = copyTable(t.headers) or {} |
| self.data = t.data or nil |
| self.params = copyTable(t.params) or {} |
| self.auth = copyTable(t.auth) or {} |
| |
| return self |
| end |
| |
| --- HTTP HEAD request |
| function M.head(t) |
| return M.send("HEAD", t) |
| end |
| |
| --- HTTP GET request |
| function M.get(t) |
| return M.send("GET", t) |
| end |
| |
| --- HTTP PUT request |
| function M.put(t) |
| return M.send("PUT", t) |
| end |
| |
| --- HTTP POST request |
| function M.post(t) |
| return M.send("POST", t) |
| end |
| |
| --- HTTP DELETE request |
| function M.delete(t) |
| return M.send("DELETE", t) |
| end |
| |
| |
| --- Send HTTP request |
| -- |
| -- @param method HTTP method |
| -- @param url Valid HTTP url (mandatory) |
| -- @param headers Lua table with request headers |
| -- @param data Request content |
| -- @param params Lua table with request url arguments |
| -- @param auth (username, password) tuple for HTTP auth |
| -- @param timeout Optional timeout for socket operations (5s by default) |
| -- |
| -- @return Response object or tuple (nil, msg) on errors |
| |
| -- Note that the prefered way to call this method is via Lua |
| -- "keyword arguments" convention, e.g. |
| -- http.get{uri="http://example.net"} |
| function M.send(method, t) |
| if type(t) ~= "table" then |
| return nil, "http." .. method:lower() .. |
| ": expecting Request object for named parameters" |
| end |
| |
| if type(t.url) ~= "string" then |
| return nil, "http." .. method:lower() .. ": 'url' parameter missing" |
| end |
| |
| local socket = core.tcp() |
| socket:settimeout(t.timeout or 5) |
| local connect |
| if t.url:sub(1, 7) ~= "http://" and t.url:sub(1, 8) ~= "https://" then |
| t.url = "http://" .. t.url |
| end |
| local schema, host, req_uri = t.url:match("^(.*)://(.-)(/.*)$") |
| |
| if not schema then |
| -- maybe path (request uri) is missing |
| schema, host = t.url:match("^(.*)://(.-)$") |
| if not schema then |
| return nil, "http." .. method:lower() .. ": Could not parse URL: " .. t.url |
| end |
| req_uri = "/" |
| end |
| |
| local addr, port = host:match("(.*):(%d+)") |
| |
| if schema == "http" then |
| connect = socket.connect |
| if not port then |
| addr = host |
| port = 80 |
| end |
| elseif schema == "https" then |
| connect = socket.connect_ssl |
| if not port then |
| addr = host |
| port = 443 |
| end |
| else |
| return nil, "http." .. method:lower() .. ": Invalid URL schema " .. tostring(schema) |
| end |
| |
| local c, err = connect(socket, addr, port) |
| |
| if c then |
| local req = {} |
| local hdr_tbl = {} |
| |
| if t.headers then |
| for k, v in pairs(t.headers) do |
| if type(v) == "table" then |
| table.insert(hdr_tbl, k .. ": " .. table.concat(v, ",")) |
| else |
| table.insert(hdr_tbl, k .. ": " .. tostring(v)) |
| end |
| end |
| else |
| t.headers = {} -- dummy table |
| end |
| |
| if not t.headers.host then |
| -- 'Host' header must be provided for HTTP/1.1 |
| table.insert(hdr_tbl, "host: " .. host) |
| end |
| |
| if not t.headers["accept"] then |
| table.insert(hdr_tbl, "accept: */*") |
| end |
| |
| if not t.headers["user-agent"] then |
| table.insert(hdr_tbl, "user-agent: haproxy-lua-http/1.0") |
| end |
| |
| if not t.headers.connection then |
| table.insert(hdr_tbl, "connection: close") |
| end |
| |
| if t.data then |
| req[4] = t.data |
| if not t.headers or not t.headers["content-length"] then |
| table.insert(hdr_tbl, "content-length: " .. tostring(#t.data)) |
| end |
| end |
| |
| req[1] = method .. " " .. req_uri .. " HTTP/1.1\r\n" |
| req[2] = table.concat(hdr_tbl, "\r\n") |
| req[3] = "\r\n\r\n" |
| |
| local r, e = socket:send(table.concat(req)) |
| |
| if not r then |
| socket:close() |
| return nil, "http." .. method:lower() .. ": " .. tostring(e) |
| end |
| |
| local line |
| r = M.response.create() |
| |
| while true do |
| line, err = socket:receive("*l") |
| |
| if not line then |
| socket:close() |
| return nil, "http." .. method:lower() .. |
| ": Receive error (headers): " .. err |
| end |
| |
| if line == "" then break end |
| |
| if not r.status_code then |
| _, r.status_code, r.reason = |
| line:match("(HTTP/1.[01]) (%d%d%d)(.*)") |
| if not _ then |
| socket:close() |
| return nil, "http." .. method:lower() .. |
| ": Could not parse request line" |
| end |
| r.status_code = tonumber(r.status_code) |
| else |
| local sep = line:find(":") |
| local hdr_name = line:sub(1, sep-1):lower() |
| local hdr_val = line:sub(sep+1):match("^%s*(.*%S)%s*$") or "" |
| |
| if r.headers[hdr_name] == nil then |
| r.headers[hdr_name] = hdr_val |
| elseif type(r.headers[hdr_name]) == "table" then |
| table.insert(r.headers[hdr_name], hdr_val) |
| else |
| r.headers[hdr_name] = { |
| r.headers[hdr_name], |
| hdr_val |
| } |
| end |
| end |
| end |
| |
| if method:lower() == "head" then |
| r.content = nil |
| socket:close() |
| return r |
| end |
| |
| if r.headers["content-length"] and tonumber(r.headers["content-length"]) > 0 then |
| r.content, err = socket:receive("*a") |
| |
| if not r.content then |
| socket:close() |
| return nil, "http." .. method:lower() .. |
| ": Receive error (content): " .. err |
| end |
| end |
| |
| if r.headers["transfer-encoding"] and r.headers["transfer-encoding"] == "chunked" then |
| r.content, err = M.receive_chunked(socket) |
| if r.content == nil then |
| socket:close() |
| return nil, err |
| end |
| end |
| |
| socket:close() |
| return r |
| else |
| return nil, "http." .. method:lower() .. ": Connection error: " .. tostring(err) |
| end |
| end |
| |
| M.base64 = {} |
| |
| --- URL safe base64 encoder |
| -- |
| -- Padding ('=') is omited, as permited per RFC |
| -- https://tools.ietf.org/html/rfc4648 |
| -- in order to follow JSON Web Signature RFC |
| -- https://tools.ietf.org/html/rfc7515 |
| -- |
| -- @param s String (can be binary data) to encode |
| -- @param enc Function which implements base64 encoder (e.g. HAProxy base64 fetch) |
| -- @return Encoded string |
| function M.base64.encode(s, enc) |
| if not s then return nil end |
| local u = enc(s) |
| |
| if not u then |
| return nil |
| end |
| |
| local pad_len = 2 - ((#s-1) % 3) |
| |
| if pad_len > 0 then |
| return u:sub(1, - pad_len - 1):gsub('[+]', '-'):gsub('[/]', '_') |
| else |
| return u:gsub('[+]', '-'):gsub('[/]', '_') |
| end |
| end |
| |
| --- URLsafe base64 decoder |
| -- |
| -- @param s Base64 string to decode |
| -- @param dec Function which implements base64 decoder (e.g. HAProxy b64dec fetch) |
| -- @return Decoded string (can be binary data) |
| function M.base64.decode(s, dec) |
| if not s then return nil end |
| |
| local e = s:gsub('[-]', '+'):gsub('[_]', '/') |
| return dec(e .. string.rep('=', 3 - ((#s - 1) % 4))) |
| end |
| |
| return M |