├── README.rst ├── LICENSE └── http.lua /README.rst: -------------------------------------------------------------------------------- 1 | Lua HTTP client library for HAProxy 2 | =================================== 3 | 4 | This is a pure Lua HTTP 1.1 library for HAProxy. It should work on any modern 5 | HAProxy version (1.7+) 6 | 7 | The library is loosely modeled after Python's Requests Library using the same 8 | attribute names and similar calling conventions for "HTTP verb" methods (where 9 | we use Lua specific named parameter support) 10 | 11 | In addition to client side, the library also supports server side request 12 | parsing, where we utilize HAProxy Lua API for all heavy lifting (i.e. HAProxy 13 | handles client side connections, parses the headers and gives us access to 14 | request body). 15 | 16 | Usage 17 | ----- 18 | 19 | After downloading this library, you will need to move it into your Lua package 20 | package path, to be able to use it from your script. In later HAProxy versions, 21 | there is a ``lua-prepend-path`` directive which can make your life easier. 22 | 23 | Basic usage for parsing client requests, constructing responses, or sending 24 | custom requests to external servers is demonstrated bellow. You can use this in 25 | your own Lua actions or services: 26 | 27 | .. code-block:: lua 28 | 29 | local http = require('http') 30 | 31 | local function main(applet) 32 | 33 | -- 1) Parse client side request and print received headers 34 | 35 | local req = http.request.parse(applet) 36 | for k, v in req:get_headers() do 37 | core.Debug(k .. ": " .. v) 38 | end 39 | 40 | -- You can also parse submitted form data 41 | local form, err = req:parse_multipart() 42 | 43 | -- 2) Send request to external server (please note there is no DNS 44 | -- support for Lua on HAProxy 45 | 46 | local res, err = http.get{url="http://1.2.3.4", 47 | headers={host="example.net", ["x-test"]={"a", "b"}} 48 | } 49 | 50 | if res then 51 | for k, v in res:get_headers() do 52 | core.Debug(k .. ": " .. v) 53 | end 54 | -- We can access the response body in content attribute 55 | core.Debug(res.content) 56 | else 57 | core.Debug(err) 58 | end 59 | 60 | 61 | -- 3) Send response to client side 62 | http.response.create{status_code=200, content="Hello World"}:send(applet) 63 | 64 | end 65 | 66 | core.register_service("test", "http", main) 67 | 68 | Naturally, you need to add your script to main haproxy configuration:: 69 | 70 | global 71 | ... 72 | lua-load test.lua 73 | 74 | frontend test 75 | ... 76 | http-request use-service lua.test 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /http.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- HTTP 1.1 library for HAProxy Lua modules 3 | -- 4 | -- The library is loosely modeled after Python's Requests Library 5 | -- using the same field names and very similar calling conventions for 6 | -- "HTTP verb" methods (where we use Lua specific named parameter support) 7 | -- 8 | -- In addition to client side, the library also supports server side request 9 | -- parsing, where we utilize HAProxy Lua API for all heavy lifting. 10 | -- 11 | -- 12 | -- Copyright (c) 2017-2020. Adis Nezirović 13 | -- Copyright (c) 2017-2020. HAProxy Technologies, LLC. 14 | -- 15 | -- Licensed under the Apache License, Version 2.0 (the "License"); 16 | -- you may not use this file except in compliance with the License. 17 | -- You may obtain a copy of the License at 18 | -- 19 | -- http://www.apache.org/licenses/LICENSE-2.0 20 | -- 21 | -- Unless required by applicable law or agreed to in writing, software 22 | -- distributed under the License is distributed on an "AS IS" BASIS, 23 | -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 24 | -- See the License for the specific language governing permissions and 25 | -- limitations under the License. 26 | -- 27 | -- SPDX-License-Identifier: Apache-2.0 28 | 29 | local _author = "Adis Nezirovic " 30 | local _copyright = "Copyright 2017-2020. HAProxy Technologies, LLC." 31 | local _version = "1.0.0" 32 | 33 | local json = require "json" 34 | 35 | -- Utility functions 36 | 37 | -- HTTP headers fetch helper 38 | -- 39 | -- Returns a header value(s) according to strategy (fold by default): 40 | -- - single/string value for "fold", "first" and "last" strategies 41 | -- - table for "all" strategy (for single value, a table with single element) 42 | -- 43 | -- @param hdrs table Headers table as received by http.get and friends 44 | -- @param name string Header name 45 | -- @param strategy string "multiple header values" handling strategy 46 | -- @return header value (string or table) or nil 47 | local function get_header(hdrs, name, strategy) 48 | if hdrs == nil or name == nil then return nil end 49 | 50 | local v = hdrs[name:lower()] 51 | if type(v) ~= "table" and strategy ~= "all" then return v end 52 | 53 | if strategy == nil or strategy == "fold" then 54 | return table.concat(v, ",") 55 | elseif strategy == "first" then 56 | return v[1] 57 | elseif strategy == "last" then 58 | return v[#v] 59 | elseif strategy == "all" then 60 | if type(v) ~= "table" then 61 | return {v} 62 | else 63 | return v 64 | end 65 | end 66 | end 67 | 68 | 69 | 70 | -- HTTP headers iterator helper 71 | -- 72 | -- Returns key/value pairs for all header, making sure that returned values 73 | -- are always of string type (if necessary, it folds multiple headers with 74 | -- the same name) 75 | -- 76 | -- @param hdrs table Headers table as received by http.get and friends 77 | -- @return header name/value iterator (suitable for use in "for" loops) 78 | local function get_headers_folded(hdrs) 79 | if hdrs == nil then 80 | return function() end 81 | end 82 | 83 | local function iter(t, k) 84 | local v 85 | k, v = next(t, k) 86 | 87 | if v ~= nil then 88 | if type(v) ~= "table" then 89 | return k, v 90 | else 91 | return k, table.concat(v, ",") 92 | end 93 | end 94 | end 95 | 96 | return iter, hdrs, nil 97 | end 98 | 99 | -- HTTP headers iterator 100 | -- 101 | -- Returns key/value pairs for all headers, for multiple headers with same name 102 | -- it will return every name/value pair 103 | -- (i.e. you can safely use it to process responses with 'Set-Cookie' header) 104 | -- 105 | -- @param hdrs table Headers table as received by http.get and friends 106 | -- @return header name/value iterator (suitable for use in "for" loops) 107 | local function get_headers_flattened(hdrs) 108 | if hdrs == nil then 109 | return function() end 110 | end 111 | 112 | local k -- top level key (string) 113 | local k_sub = 0 -- sub table key (integer), 0 if item not a table, 114 | -- nil after last sub table iteration 115 | local v_sub -- sub table 116 | 117 | return function () 118 | local v 119 | if k_sub == 0 then 120 | k, v = next(hdrs, k) 121 | if k == nil then return end 122 | else 123 | k_sub, v = next(v_sub, k_sub) 124 | 125 | if k_sub == nil then 126 | k_sub = 0 127 | k, v = next(hdrs, k) 128 | end 129 | end 130 | 131 | if k == nil then return end 132 | 133 | if type(v) ~= "table" then 134 | return k, v 135 | else 136 | v_sub = v 137 | k_sub = k_sub + 1 138 | return k, v[k_sub] 139 | end 140 | end 141 | end 142 | 143 | 144 | --- Parse key/value pairs from a string 145 | -- 146 | -- @param s Lua string with (multiple) key/value pairs (separated by 'sep') 147 | -- 148 | -- @return Table with parsed keys and values or nil 149 | local function parse_kv(s, sep) 150 | if s == nil then return nil end 151 | idx = 1 152 | result = {} 153 | 154 | while idx < s:len() do 155 | i, j = s:find(sep, idx) 156 | 157 | if i == nil then 158 | k, v = string.match(s:sub(idx), "^(.-)=(.*)$") 159 | if k then result[k] = v end 160 | break 161 | end 162 | 163 | k, v = string.match(s:sub(idx, i-1), "^(.-)=(.*)$") 164 | if k then result[k] = v end 165 | idx = j + 1 166 | end 167 | 168 | if next(result) == nil then 169 | return nil 170 | else 171 | return result 172 | end 173 | end 174 | 175 | 176 | --- Make deep copy of table and it's values 177 | -- 178 | -- Use only for simple tables (it handles nested table values), but not for 179 | -- Lua objects or similar, or very big tables (this uses recursion). 180 | -- 181 | -- @param t Cloned Lua table or nil 182 | -- 183 | -- @return Cloned table or nil 184 | local function copyTable(t) 185 | if type(t) ~= "table" then 186 | return nil 187 | end 188 | 189 | local r = {} 190 | 191 | for k, v in pairs(t) do 192 | if type(v) == "table" then 193 | r[k] = copyTable(v) 194 | else 195 | r[k] = v 196 | end 197 | end 198 | 199 | return r 200 | end 201 | 202 | 203 | --- Namespace object which hosts HTTP verb methods and request/response classes 204 | local M = {} 205 | 206 | 207 | --- HTTP response class 208 | M.response = {} 209 | M.response.__index = M.response 210 | 211 | local _reason = { 212 | [200] = "OK", 213 | [201] = "Created", 214 | [204] = "No Content", 215 | [301] = "Moved Permanently", 216 | [302] = "Found", 217 | [400] = "Bad Request", 218 | [403] = "Forbidden", 219 | [404] = "Not Found", 220 | [405] = "Method Not Allowed", 221 | [408] = "Request Timeout", 222 | [413] = "Payload Too Large", 223 | [429] = "Too many requests", 224 | [500] = "Internal Server Error", 225 | [501] = "Not Implemented", 226 | [502] = "Bad Gateway", 227 | [503] = "Service Unavailable", 228 | [504] = "Gateway Timeout" 229 | } 230 | 231 | --- Creates HTTP response from scratch 232 | -- 233 | -- @param status_code HTTP status code 234 | -- @param reason HTTP status code text (e.g. "OK" for 200 response) 235 | -- @param headers HTTP response headers 236 | -- @param request The HTTP request which triggered the response 237 | -- @param encoding Default encoding for response or conversions 238 | -- 239 | -- @return response object 240 | function M.response.create(t) 241 | local self = setmetatable({}, M.response) 242 | 243 | if not t then 244 | t = {} 245 | end 246 | 247 | self.status_code = t.status_code or nil 248 | self.reason = t.reason or _reason[self.status_code] or "" 249 | self.headers = copyTable(t.headers) or {} 250 | self.content = t.content or "" 251 | self.request = t.request or nil 252 | self.encoding = t.encoding or "utf-8" 253 | 254 | return self 255 | end 256 | 257 | function M.response.send(self, applet) 258 | applet:set_status(tonumber(self.status_code), self.reason) 259 | 260 | for k, v in pairs(self.headers) do 261 | if type(v) == "table" then 262 | for _, hdr_val in pairs(v) do 263 | applet:add_header(k, hdr_val) 264 | end 265 | else 266 | applet:add_header(k, v) 267 | end 268 | end 269 | 270 | if not self.headers["content-type"] then 271 | if type(self.content) == "table" then 272 | applet:add_header("content-type", "application/json; charset=" .. 273 | self.encoding) 274 | if next(self.content) == nil then 275 | -- Return empty JSON object for empty Lua tables 276 | -- (that makes more sense then returning []) 277 | self.content = "{}" 278 | else 279 | self.content = json.encode(self.content) 280 | end 281 | else 282 | applet:add_header("content-type", "text/plain; charset=" .. 283 | self.encoding) 284 | end 285 | end 286 | 287 | if not self.headers["content-length"] then 288 | applet:add_header("content-length", #tostring(self.content)) 289 | end 290 | 291 | applet:start_response() 292 | applet:send(tostring(self.content)) 293 | end 294 | 295 | --- Convert response content to JSON 296 | -- 297 | -- @return Lua table (decoded json) 298 | function M.response.json(self) 299 | return json.decode(self.content) 300 | end 301 | 302 | -- Response headers getter 303 | -- 304 | -- Returns a header value(s) according to strategy (fold by default): 305 | -- - single/string value for "fold", "first" and "last" strategies 306 | -- - table for "all" strategy (for single value, a table with single element) 307 | -- 308 | -- @param name string Header name 309 | -- @param strategy string "multiple header values" handling strategy 310 | -- @return header value (string or table) or nil 311 | function M.response.get_header(self, name, strategy) 312 | return get_header(self.headers, name, strategy) 313 | end 314 | 315 | -- Response headers iterator 316 | -- 317 | -- Yields key/value pairs for all headers, making sure that returned values 318 | -- are always of string type 319 | -- 320 | -- @param folded boolean Specifies whether to fold headers with same name 321 | -- @return header name/value iterator (suitable for use in "for" loops) 322 | function M.response.get_headers(self, folded) 323 | if folded == true then 324 | return get_headers_folded(self.headers) 325 | else 326 | return get_headers_flattened(self.headers) 327 | end 328 | end 329 | 330 | 331 | --- HTTP request class (client or server side, depending on the constructor) 332 | M.request = {} 333 | M.request.__index = M.request 334 | 335 | --- HTTP request constructor 336 | -- 337 | -- Parses client HTTP request (as forwarded by HAProxy) 338 | -- 339 | -- @param applet HAProxy AppletHTTP Lua object 340 | -- 341 | -- @return Request object 342 | function M.request.parse(applet) 343 | local self = setmetatable({}, M.request) 344 | self.method = applet.method 345 | 346 | if (applet.method == "POST" or applet.method == "PUT") and 347 | applet.length > 0 then 348 | self.data = applet:receive() 349 | if self.data == "" then self.data = nil end 350 | end 351 | 352 | self.headers = {} 353 | for k, v in pairs(applet.headers) do 354 | if (v[1]) then -- (non folded header with multiple values) 355 | self.headers[k] = {} 356 | for _, val in pairs(v) do 357 | table.insert(self.headers[k], val) 358 | end 359 | else 360 | self.headers[k] = v[0] 361 | end 362 | end 363 | 364 | if not self.headers["host"] then 365 | return nil, "Bad request, no Host header specified" 366 | end 367 | 368 | self.cookies = parse_kv(self.headers["cookie"], "; ") 369 | 370 | -- TODO: Patch ApletHTTP and add schema of request 371 | local schema = applet.schema or "http" 372 | local url = {schema, "://", self.headers["host"], applet.path} 373 | 374 | self.params = {} 375 | if applet.qs:len() > 0 then 376 | for _, arg in ipairs(core.tokenize(applet.qs, "&", true)) do 377 | kv = core.tokenize(arg, "=", true) 378 | self.params[kv[1]] = kv[2] 379 | end 380 | url[#url+1] = "?" 381 | url[#url+1] = applet.qs 382 | end 383 | 384 | self.url = table.concat(url) 385 | 386 | return self 387 | end 388 | 389 | --- Escape Lua pattern chars in HTTP multipart boundary 390 | -- 391 | -- This function escapes only minimal number of characters, which can be 392 | -- observed in multipart boundaries, namely: -, +, ? and . 393 | -- 394 | -- @param s string Data to escape 395 | -- 396 | -- @return escaped data (string) 397 | local function escape_pattern(s) 398 | return s:gsub("%-", "%%-"):gsub("%+", "%%+"):gsub("%?", "%%?"):gsub("%.", "%%.") 399 | end 400 | 401 | --- Parse HTTP POST data 402 | -- 403 | -- @return Table with submitted form data 404 | function M.request.parse_multipart(self) 405 | local ct = self.headers['content-type'] 406 | if ct == nil then 407 | return nil, 'Content-Type header not present' 408 | end 409 | 410 | if self.data == nil then 411 | return nil, 'Empty body' 412 | end 413 | local body = self.data 414 | local result ={} 415 | 416 | if ct:find('^multipart/form[-]data;') then 417 | local boundary = ct:match('^multipart/form[-]data; boundary=["]?(.+)["]?$') 418 | if boundary == nil then 419 | return nil, 'Could not parse boundary from Content-Type' 420 | end 421 | 422 | -- per RFC2046, CLRF is treated as a part of boundary 423 | -- but first one does not have it, so we're going pretend 424 | -- it is part of the content and ignore it there (in the pattern) 425 | boundary = string.format('%%-%%-%s.-\r\n', escape_pattern(boundary)) 426 | 427 | local i = 1 428 | local j 429 | local old_i 430 | 431 | while true do 432 | i, j = body:find(boundary, i) 433 | 434 | if i == nil then break end 435 | 436 | if old_i then 437 | local part = body:sub(old_i, i - 1) 438 | 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$') 439 | 440 | if k then 441 | result[k] = { 442 | filename = fn, 443 | content_type = t, 444 | data = v 445 | } 446 | else 447 | k, v = part:match('^[cC]ontent[-][dD]isposition: form[-]data; name[=]"(.+)"\r\n\r\n(.+)\r\n$') 448 | 449 | if k then 450 | result[k] = v 451 | end 452 | end 453 | 454 | end 455 | 456 | i = j + 1 457 | old_i = i 458 | end 459 | elseif ct == 'application/x-www-form-urlencoded' then 460 | result = parse_kv(body, '&') 461 | else 462 | return nil, 'Unsupported Content-Type: ' .. ct 463 | end 464 | 465 | if result == nil or not next(result) then 466 | return nil, 'Could not parse form data' 467 | end 468 | 469 | return result 470 | end 471 | 472 | --- Reads (all) chunks from a HTTP response 473 | -- 474 | -- @param socket socket object (with already established tcp connection) 475 | -- @param get_all boolean (true by default), collect all chunks at once 476 | -- or yield every chunk separately. 477 | -- 478 | -- @return Full response payload or nil and an error message 479 | function M.receive_chunked(socket, get_all) 480 | if socket == nil then 481 | return nil, "http.receive_chunked: Socket is nil" 482 | end 483 | local data = {} 484 | 485 | while true do 486 | local chunk, err = socket:receive("*l") 487 | 488 | if chunk == nil then 489 | return nil, "http.receive_chunked(): Receive error (chunk length): " .. tostring(err) 490 | end 491 | 492 | local chunk_len = tonumber(chunk, 16) 493 | if chunk_len == nil then 494 | return nil, "http.receive_chunked(): Could not parse chunk length" 495 | end 496 | 497 | if chunk_len == 0 then 498 | -- TODO: support trailers 499 | break 500 | end 501 | 502 | -- Consume next chunk (including the \r\n) 503 | chunk, err = socket:receive(chunk_len+2) 504 | if chunk == nil then 505 | return nil, "http.receive_chunked(): Receive error (chunk data): " .. tostring(err) 506 | end 507 | 508 | -- Strip the \r\n before collection 509 | local chunk_data = string.sub(chunk, 1, -3) 510 | 511 | if get_all == false then 512 | return chunk_data 513 | end 514 | 515 | table.insert(data, chunk_data) 516 | end 517 | 518 | return table.concat(data) 519 | end 520 | 521 | 522 | -- Request headers getter 523 | -- 524 | -- Returns a header value(s) according to strategy (fold by default): 525 | -- - single/string value for "fold", "first" and "last" strategies 526 | -- - table for "all" strategy (for single value, a table with single element) 527 | -- 528 | -- @param name string Header name 529 | -- @param strategy string "multiple header values" handling strategy 530 | -- @return header value (string or table) or nil 531 | function M.request.get_header(self, name, strategy) 532 | return get_header(self.headers, name, strategy) 533 | end 534 | 535 | -- Request headers iterator 536 | -- 537 | -- Yields key/value pairs for all headers, making sure that returned values 538 | -- are always of string type 539 | -- 540 | -- @param hdrs table Headers table as received by http.get and friends 541 | -- @param folded boolean Specifies whether to fold headers with same name 542 | -- @return header name/value iterator (suitable for use in "for" loops) 543 | function M.request.get_headers(self, folded) 544 | if folded == true then 545 | return get_headers_folded(self.headers) 546 | else 547 | return get_headers_flattened(self.headers) 548 | end 549 | end 550 | 551 | --- Creates HTTP request from scratch 552 | -- 553 | -- @param method HTTP method 554 | -- @param url Valid HTTP url 555 | -- @param headers Lua table with request headers 556 | -- @param data Request content 557 | -- @param params Lua table with request url arguments 558 | -- @param auth (username, password) tuple for HTTP auth 559 | -- 560 | -- @return request object 561 | function M.request.create(t) 562 | local self = setmetatable({}, M.request) 563 | 564 | if t.method then 565 | self.method = t.method:lower() 566 | else 567 | self.method = "get" 568 | end 569 | self.url = t.url or nil 570 | self.headers = copyTable(t.headers) or {} 571 | self.data = t.data or nil 572 | self.params = copyTable(t.params) or {} 573 | self.auth = copyTable(t.auth) or {} 574 | 575 | return self 576 | end 577 | 578 | --- HTTP HEAD request 579 | function M.head(t) 580 | return M.send("HEAD", t) 581 | end 582 | 583 | --- HTTP GET request 584 | function M.get(t) 585 | return M.send("GET", t) 586 | end 587 | 588 | --- HTTP PUT request 589 | function M.put(t) 590 | return M.send("PUT", t) 591 | end 592 | 593 | --- HTTP POST request 594 | function M.post(t) 595 | return M.send("POST", t) 596 | end 597 | 598 | --- HTTP DELETE request 599 | function M.delete(t) 600 | return M.send("DELETE", t) 601 | end 602 | 603 | 604 | --- Send HTTP request 605 | -- 606 | -- @param method HTTP method 607 | -- @param url Valid HTTP url (mandatory) 608 | -- @param headers Lua table with request headers 609 | -- @param data Request content 610 | -- @param params Lua table with request url arguments 611 | -- @param auth (username, password) tuple for HTTP auth 612 | -- @param timeout Optional timeout for socket operations (5s by default) 613 | -- 614 | -- @return Response object or tuple (nil, msg) on errors 615 | 616 | -- Note that the prefered way to call this method is via Lua 617 | -- "keyword arguments" convention, e.g. 618 | -- http.get{uri="http://example.net"} 619 | function M.send(method, t) 620 | if type(t) ~= "table" then 621 | return nil, "http." .. method:lower() .. 622 | ": expecting Request object for named parameters" 623 | end 624 | 625 | if type(t.url) ~= "string" then 626 | return nil, "http." .. method:lower() .. ": 'url' parameter missing" 627 | end 628 | 629 | local socket = core.tcp() 630 | socket:settimeout(t.timeout or 5) 631 | local connect 632 | if t.url:sub(1, 7) ~= "http://" and t.url:sub(1, 8) ~= "https://" then 633 | t.url = "http://" .. t.url 634 | end 635 | local schema, host, req_uri = t.url:match("^(.*)://(.-)(/.*)$") 636 | 637 | if not schema then 638 | -- maybe path (request uri) is missing 639 | schema, host = t.url:match("^(.*)://(.-)$") 640 | if not schema then 641 | return nil, "http." .. method:lower() .. ": Could not parse URL: " .. t.url 642 | end 643 | req_uri = "/" 644 | end 645 | 646 | local addr, port = host:match("(.*):(%d+)") 647 | 648 | if schema == "http" then 649 | connect = socket.connect 650 | if not port then 651 | addr = host 652 | port = 80 653 | end 654 | elseif schema == "https" then 655 | connect = socket.connect_ssl 656 | if not port then 657 | addr = host 658 | port = 443 659 | end 660 | else 661 | return nil, "http." .. method:lower() .. ": Invalid URL schema " .. tostring(schema) 662 | end 663 | 664 | local c, err = connect(socket, addr, port) 665 | 666 | if c then 667 | local req = {} 668 | local hdr_tbl = {} 669 | 670 | if t.headers then 671 | for k, v in pairs(t.headers) do 672 | if type(v) == "table" then 673 | table.insert(hdr_tbl, k .. ": " .. table.concat(v, ",")) 674 | else 675 | table.insert(hdr_tbl, k .. ": " .. tostring(v)) 676 | end 677 | end 678 | else 679 | t.headers = {} -- dummy table 680 | end 681 | 682 | if not t.headers.host then 683 | -- 'Host' header must be provided for HTTP/1.1 684 | table.insert(hdr_tbl, "host: " .. host) 685 | end 686 | 687 | if not t.headers["accept"] then 688 | table.insert(hdr_tbl, "accept: */*") 689 | end 690 | 691 | if not t.headers["user-agent"] then 692 | table.insert(hdr_tbl, "user-agent: haproxy-lua-http/1.0") 693 | end 694 | 695 | if not t.headers.connection then 696 | table.insert(hdr_tbl, "connection: close") 697 | end 698 | 699 | if t.data then 700 | req[4] = t.data 701 | if not t.headers or not t.headers["content-length"] then 702 | table.insert(hdr_tbl, "content-length: " .. tostring(#t.data)) 703 | end 704 | end 705 | 706 | req[1] = method .. " " .. req_uri .. " HTTP/1.1\r\n" 707 | req[2] = table.concat(hdr_tbl, "\r\n") 708 | req[3] = "\r\n\r\n" 709 | 710 | local r, e = socket:send(table.concat(req)) 711 | 712 | if not r then 713 | socket:close() 714 | return nil, "http." .. method:lower() .. ": " .. tostring(e) 715 | end 716 | 717 | local line 718 | r = M.response.create() 719 | 720 | while true do 721 | line, err = socket:receive("*l") 722 | 723 | if not line then 724 | socket:close() 725 | return nil, "http." .. method:lower() .. 726 | ": Receive error (headers): " .. err 727 | end 728 | 729 | if line == "" then break end 730 | 731 | if not r.status_code then 732 | _, r.status_code, r.reason = 733 | line:match("(HTTP/1.[01]) (%d%d%d)(.*)") 734 | if not _ then 735 | socket:close() 736 | return nil, "http." .. method:lower() .. 737 | ": Could not parse request line" 738 | end 739 | r.status_code = tonumber(r.status_code) 740 | else 741 | local sep = line:find(":") 742 | local hdr_name = line:sub(1, sep-1):lower() 743 | local hdr_val = line:sub(sep+1):match("^%s*(.*%S)%s*$") or "" 744 | 745 | if r.headers[hdr_name] == nil then 746 | r.headers[hdr_name] = hdr_val 747 | elseif type(r.headers[hdr_name]) == "table" then 748 | table.insert(r.headers[hdr_name], hdr_val) 749 | else 750 | r.headers[hdr_name] = { 751 | r.headers[hdr_name], 752 | hdr_val 753 | } 754 | end 755 | end 756 | end 757 | 758 | if method:lower() == "head" then 759 | r.content = nil 760 | socket:close() 761 | return r 762 | end 763 | 764 | if r.headers["content-length"] and tonumber(r.headers["content-length"]) > 0 then 765 | r.content, err = socket:receive("*a") 766 | 767 | if not r.content then 768 | socket:close() 769 | return nil, "http." .. method:lower() .. 770 | ": Receive error (content): " .. err 771 | end 772 | end 773 | 774 | if r.headers["transfer-encoding"] and r.headers["transfer-encoding"] == "chunked" then 775 | r.content, err = M.receive_chunked(socket) 776 | if r.content == nil then 777 | socket:close() 778 | return nil, err 779 | end 780 | end 781 | 782 | socket:close() 783 | return r 784 | else 785 | return nil, "http." .. method:lower() .. ": Connection error: " .. tostring(err) 786 | end 787 | end 788 | 789 | M.base64 = {} 790 | 791 | --- URL safe base64 encoder 792 | -- 793 | -- Padding ('=') is omited, as permited per RFC 794 | -- https://tools.ietf.org/html/rfc4648 795 | -- in order to follow JSON Web Signature RFC 796 | -- https://tools.ietf.org/html/rfc7515 797 | -- 798 | -- @param s String (can be binary data) to encode 799 | -- @param enc Function which implements base64 encoder (e.g. HAProxy base64 fetch) 800 | -- @return Encoded string 801 | function M.base64.encode(s, enc) 802 | if not s then return nil end 803 | local u = enc(s) 804 | 805 | if not u then 806 | return nil 807 | end 808 | 809 | local pad_len = 2 - ((#s-1) % 3) 810 | 811 | if pad_len > 0 then 812 | return u:sub(1, - pad_len - 1):gsub('[+]', '-'):gsub('[/]', '_') 813 | else 814 | return u:gsub('[+]', '-'):gsub('[/]', '_') 815 | end 816 | end 817 | 818 | --- URLsafe base64 decoder 819 | -- 820 | -- @param s Base64 string to decode 821 | -- @param dec Function which implements base64 decoder (e.g. HAProxy b64dec fetch) 822 | -- @return Decoded string (can be binary data) 823 | function M.base64.decode(s, dec) 824 | if not s then return nil end 825 | 826 | local e = s:gsub('[-]', '+'):gsub('[_]', '/') 827 | return dec(e .. string.rep('=', 3 - ((#s - 1) % 4))) 828 | end 829 | 830 | return M 831 | --------------------------------------------------------------------------------