diff --git a/docs/deployment/supported-proxies/haproxy.md b/docs/deployment/supported-proxies/haproxy.md index 0f40f6a7..52cab97c 100644 --- a/docs/deployment/supported-proxies/haproxy.md +++ b/docs/deployment/supported-proxies/haproxy.md @@ -14,17 +14,12 @@ nav_order: 1 You need the following to run Authelia with HAProxy: -* HAProxy 1.8.4+ +* HAProxy 1.8.4+ (2.2.0+ recommended) * `USE_LUA=1` set at compile time -* [haproxy-auth-request](https://github.com/TimWolla/haproxy-auth-request/blob/master/auth-request.lua) -* LuaSocket with commit [0b03eec16b](https://github.com/diegonehab/luasocket/commit/0b03eec16be0b3a5efe71bcb8887719d1ea87d60) (that is: newer than 2014-11-10) in your Lua library path (`LUA_PATH`) - * `lua-socket` from Debian Stretch works - * `lua-socket` from Ubuntu Xenial works - * `lua-socket` from Ubuntu Bionic works - * `lua5.3-socket` from Alpine 3.8 works - * `luasocket` from luarocks *does not* work - * `lua-socket` v3.0.0.17.rc1 from EPEL *does not* work - * `lua-socket` from Fedora 28 *does not* work + * [haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http) must be available within the Lua path + * A `json` library within the Lua path (dependency of haproxy-lua-http, usually found as OS package `lua-json`) + * With HAProxy 2.1.3+ you can use the [`lua-prepend-path`] configuration option to specify the search path. + * [haproxy-auth-request](https://github.com/TimWolla/haproxy-auth-request/blob/master/auth-request.lua) ## Configuration @@ -75,6 +70,8 @@ to TLS verification as well as header rewriting. An example of this configuratio ##### haproxy.cfg ``` global + # Path to haproxy-lua-http, below example assumes /usr/local/etc/haproxy/haproxy-lua-http/http.lua + lua-prepend-path /usr/local/etc/haproxy/?/http.lua # Path to haproxy-auth-request lua-load /usr/local/etc/haproxy/auth-request.lua log stdout format raw local0 debug @@ -118,12 +115,20 @@ backend be_authelia server authelia authelia:9091 backend be_nextcloud + # Pass Remote-User and Remote-Groups headers + acl remote_user_exist var(req.auth_response_header.remote_user) -m found + acl remote_groups_exist var(req.auth_response_header.remote_groups) -m found + http-request set-header Remote-User %[var(req.auth_response_header.remote_user)] if remote_user_exist + http-request set-header Remote-Groups %[var(req.auth_response_header.remote_groups)] if remote_groups_exist + server nextcloud nextcloud:443 ssl verify none ``` ##### haproxy.cfg (TLS enabled Authelia) ``` global + # Path to haproxy-lua-http, below example assumes /usr/local/etc/haproxy/haproxy-lua-http/http.lua + lua-prepend-path /usr/local/etc/haproxy/?/http.lua # Path to haproxy-auth-request lua-load /usr/local/etc/haproxy/auth-request.lua log stdout format raw local0 debug @@ -176,6 +181,12 @@ listen authelia_proxy server authelia authelia:9091 ssl verify none backend be_nextcloud + # Pass Remote-User and Remote-Groups headers + acl remote_user_exist var(req.auth_response_header.remote_user) -m found + acl remote_groups_exist var(req.auth_response_header.remote_groups) -m found + http-request set-header Remote-User %[var(req.auth_response_header.remote_user)] if remote_user_exist + http-request set-header Remote-Groups %[var(req.auth_response_header.remote_groups)] if remote_groups_exist + server nextcloud nextcloud:443 ssl verify none ``` diff --git a/internal/suites/example/compose/haproxy/Dockerfile b/internal/suites/example/compose/haproxy/Dockerfile index a66b491c..0fe8ad39 100644 --- a/internal/suites/example/compose/haproxy/Dockerfile +++ b/internal/suites/example/compose/haproxy/Dockerfile @@ -3,7 +3,7 @@ FROM haproxy:2.2.2-alpine RUN \ apk add --no-cache \ curl \ - lua5.3-socket \ + lua-json4 \ openssl && \ openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=AU/ST=Victoria/L=Melbourne/O=Authelia/CN=*.example.com" -keyout haproxy.key -out haproxy.crt && \ cat haproxy.key haproxy.crt > /usr/local/etc/haproxy/haproxy.pem \ No newline at end of file diff --git a/internal/suites/example/compose/haproxy/auth-request.lua b/internal/suites/example/compose/haproxy/auth-request.lua index debb8195..37b75f16 100644 --- a/internal/suites/example/compose/haproxy/auth-request.lua +++ b/internal/suites/example/compose/haproxy/auth-request.lua @@ -20,15 +20,38 @@ -- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -- SOFTWARE. -local http = require("socket.http") +local http = require("haproxy-lua-http") + +function set_var_pre_2_2(txn, var, value) + return txn:set_var(var, value) +end +function set_var_post_2_2(txn, var, value) + return txn:set_var(var, value, true) +end + +set_var = function(txn, var, value) + local success = pcall(set_var_post_2_2, txn, var, value) + if success then + set_var = set_var_post_2_2 + else + set_var = set_var_pre_2_2 + end + + return set_var(txn, var, value) +end + +function sanitize_header_for_variable(header) + return header:gsub("[^a-zA-Z0-9]", "_") +end + core.register_action("auth-request", { "http-req" }, function(txn, be, path) - txn:set_var("txn.auth_response_successful", false) + set_var(txn, "txn.auth_response_successful", false) -- Check whether the given backend exists. if core.backends[be] == nil then txn:Alert("Unknown auth-request backend '" .. be .. "'") - txn:set_var("txn.auth_response_code", 500) + set_var(txn, "txn.auth_response_code", 500) return end @@ -44,7 +67,7 @@ core.register_action("auth-request", { "http-req" }, function(txn, be, path) end if addr == nil then txn:Warning("No servers available for auth-request backend: '" .. be .. "'") - txn:set_var("txn.auth_response_code", 500) + set_var(txn, "txn.auth_response_code", 500) return end @@ -64,40 +87,33 @@ core.register_action("auth-request", { "http-req" }, function(txn, be, path) end -- Make request to backend. - local b, c, h = http.request { + local response, err = http.head { url = "http://" .. addr .. path, headers = headers, - create = core.tcp, - -- Disable redirects, because DNS does not work here. - redirect = false, - -- We do not check body, so HEAD - method = "HEAD", } -- Check whether we received a valid HTTP response. - if b == nil then - txn:Warning("Failure in auth-request backend '" .. be .. "': " .. c) - txn:set_var("txn.auth_response_code", 500) + if response == nil then + txn:Warning("Failure in auth-request backend '" .. be .. "': " .. err) + set_var(txn, "txn.auth_response_code", 500) return end - txn:set_var("txn.auth_response_code", c) + set_var(txn, "txn.auth_response_code", response.status_code) + + for header, value in response:get_headers(true) do + set_var(txn, "req.auth_response_header." .. sanitize_header_for_variable(header), value) + end -- 2xx: Allow request. - if 200 <= c and c < 300 then - if h["remote-user"] then - txn:set_var("txn.auth_user", h["remote-user"]) - end - if h["remote-groups"] then - txn:set_var("txn.auth_groups", h["remote-groups"]) - end - txn:set_var("txn.auth_response_successful", true) + if 200 <= response.status_code and response.status_code < 300 then + set_var(txn, "txn.auth_response_successful", true) -- Don't allow other codes. -- Codes with Location: Passthrough location at redirect. - elseif c == 301 or c == 302 or c == 303 or c == 307 or c == 308 then - txn:set_var("txn.auth_response_location", h["location"]) + elseif response.status_code == 301 or response.status_code == 302 or response.status_code == 303 or response.status_code == 307 or response.status_code == 308 then + set_var(txn, "txn.auth_response_location", response:get_header("location", "last")) -- 401 / 403: Do nothing, everything else: log. - elseif c ~= 401 and c ~= 403 then - txn:Warning("Invalid status code in auth-request backend '" .. be .. "': " .. c) + elseif response.status_code ~= 401 and response.status_code ~= 403 then + txn:Warning("Invalid status code in auth-request backend '" .. be .. "': " .. response.status_code) end -end, 2) \ No newline at end of file +end, 2) diff --git a/internal/suites/example/compose/haproxy/docker-compose.yml b/internal/suites/example/compose/haproxy/docker-compose.yml index 452bd094..e0f81520 100644 --- a/internal/suites/example/compose/haproxy/docker-compose.yml +++ b/internal/suites/example/compose/haproxy/docker-compose.yml @@ -4,6 +4,7 @@ services: build: ./example/compose/haproxy/ volumes: - ./example/compose/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro + - ./example/compose/haproxy/http.lua:/usr/local/etc/haproxy/haproxy-lua-http/http.lua - ./example/compose/haproxy/auth-request.lua:/usr/local/etc/haproxy/auth-request.lua networks: authelianet: diff --git a/internal/suites/example/compose/haproxy/haproxy.cfg b/internal/suites/example/compose/haproxy/haproxy.cfg index ef18ac11..872ec577 100644 --- a/internal/suites/example/compose/haproxy/haproxy.cfg +++ b/internal/suites/example/compose/haproxy/haproxy.cfg @@ -1,4 +1,5 @@ global + lua-prepend-path /usr/local/etc/haproxy/?/http.lua lua-load /usr/local/etc/haproxy/auth-request.lua log stdout format raw local0 debug @@ -68,9 +69,9 @@ backend be_protected server nginx-backend nginx-backend:80 backend be_httpbin - acl remote_user_exist var(txn.auth_user) -m found - acl remote_groups_exist var(txn.auth_groups) -m found + acl remote_user_exist var(req.auth_response_header.remote_user) -m found + acl remote_groups_exist var(req.auth_response_header.remote_groups) -m found + http-request set-header Remote-User %[var(req.auth_response_header.remote_user)] if remote_user_exist + http-request set-header Remote-Groups %[var(req.auth_response_header.remote_groups)] if remote_groups_exist - http-request set-header Remote-User %[var(txn.auth_user)] if remote_user_exist - http-request set-header Remote-Groups %[var(txn.auth_groups)] if remote_groups_exist server httpbin-backend httpbin:8000 \ No newline at end of file diff --git a/internal/suites/example/compose/haproxy/http.lua b/internal/suites/example/compose/haproxy/http.lua new file mode 100644 index 00000000..44809c8e --- /dev/null +++ b/internal/suites/example/compose/haproxy/http.lua @@ -0,0 +1,791 @@ +-- +-- 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ć +-- 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 + +local _author = "Adis Nezirovic " +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 request cookies from string +-- +-- @param s Lua string with value of cookie header (can be nil) +-- +-- @return Table with parsed cookies or nil +local function parse_request_cookies(s) + if s == nil then return nil end + idx = 1 + cookies = {} + + while idx < s:len() do + i, j = s:find("; ", idx) + + if i == nil then + k, v = string.match(s:sub(idx), "^(.-)=(.*)$") + if k then cookies[k] = v end + break + end + + k, v = string.match(s:sub(idx, i-1), "^(.-)=(.*)$") + if k then cookies[k] = v end + idx = j + 1 + end + + if next(cookies) == nil then + return nil + else + return cookies + end +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 = 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_request_cookies(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 + +--- Parse HTTP POST data +-- +-- @return Table with submitted form data +function M.request.parse_multipart(self) + local result ={} + local ct = self.headers['content-type'] + local body = self.data + + if ct:match('^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 + + 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('^\r\n[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('^\r\n[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 + local i = 1 + local j + while true do + j = body:find('&', i) + if j == nil then break end + + local part = body:sub(i, j-1) + local k, v = part:match('^(.+)=(.+)$') + if k then + result[k] = v + end + i = j + 1 + end + else + return nil, 'Unsupported Content-Type: ' .. ct + end + + if 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 = t.headers or {} + self.data = t.data or nil + self.params = t.params or {} + self.auth = 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