mirror of
https://github.com/0rangebananaspy/authelia.git
synced 2024-09-14 22:47:21 +07:00
[FEATURE] Support updated haproxy-auth-request (#1310)
* [FEATURE] Support updated haproxy-auth-request This version removes the dependency of lua-socket which seemed to result in many unsupported and broken BSD/Pfsense deployments. * Fix docs indentation * Add haproxy-lua-http to TLS enabled configuration
This commit is contained in:
parent
8dfe5c7d70
commit
771c220d38
|
@ -14,17 +14,12 @@ nav_order: 1
|
||||||
|
|
||||||
You need the following to run Authelia with HAProxy:
|
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
|
* `USE_LUA=1` set at compile time
|
||||||
* [haproxy-auth-request](https://github.com/TimWolla/haproxy-auth-request/blob/master/auth-request.lua)
|
* [haproxy-lua-http](https://github.com/haproxytech/haproxy-lua-http) must be available within the Lua path
|
||||||
* 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`)
|
* A `json` library within the Lua path (dependency of haproxy-lua-http, usually found as OS package `lua-json`)
|
||||||
* `lua-socket` from Debian Stretch works
|
* With HAProxy 2.1.3+ you can use the [`lua-prepend-path`] configuration option to specify the search path.
|
||||||
* `lua-socket` from Ubuntu Xenial works
|
* [haproxy-auth-request](https://github.com/TimWolla/haproxy-auth-request/blob/master/auth-request.lua)
|
||||||
* `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
|
|
||||||
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
@ -75,6 +70,8 @@ to TLS verification as well as header rewriting. An example of this configuratio
|
||||||
##### haproxy.cfg
|
##### haproxy.cfg
|
||||||
```
|
```
|
||||||
global
|
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
|
# Path to haproxy-auth-request
|
||||||
lua-load /usr/local/etc/haproxy/auth-request.lua
|
lua-load /usr/local/etc/haproxy/auth-request.lua
|
||||||
log stdout format raw local0 debug
|
log stdout format raw local0 debug
|
||||||
|
@ -118,12 +115,20 @@ backend be_authelia
|
||||||
server authelia authelia:9091
|
server authelia authelia:9091
|
||||||
|
|
||||||
backend be_nextcloud
|
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
|
server nextcloud nextcloud:443 ssl verify none
|
||||||
```
|
```
|
||||||
|
|
||||||
##### haproxy.cfg (TLS enabled Authelia)
|
##### haproxy.cfg (TLS enabled Authelia)
|
||||||
```
|
```
|
||||||
global
|
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
|
# Path to haproxy-auth-request
|
||||||
lua-load /usr/local/etc/haproxy/auth-request.lua
|
lua-load /usr/local/etc/haproxy/auth-request.lua
|
||||||
log stdout format raw local0 debug
|
log stdout format raw local0 debug
|
||||||
|
@ -176,6 +181,12 @@ listen authelia_proxy
|
||||||
server authelia authelia:9091 ssl verify none
|
server authelia authelia:9091 ssl verify none
|
||||||
|
|
||||||
backend be_nextcloud
|
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
|
server nextcloud nextcloud:443 ssl verify none
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ FROM haproxy:2.2.2-alpine
|
||||||
RUN \
|
RUN \
|
||||||
apk add --no-cache \
|
apk add --no-cache \
|
||||||
curl \
|
curl \
|
||||||
lua5.3-socket \
|
lua-json4 \
|
||||||
openssl && \
|
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 && \
|
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
|
cat haproxy.key haproxy.crt > /usr/local/etc/haproxy/haproxy.pem
|
|
@ -20,15 +20,38 @@
|
||||||
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
-- SOFTWARE.
|
-- 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)
|
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.
|
-- Check whether the given backend exists.
|
||||||
if core.backends[be] == nil then
|
if core.backends[be] == nil then
|
||||||
txn:Alert("Unknown auth-request backend '" .. be .. "'")
|
txn:Alert("Unknown auth-request backend '" .. be .. "'")
|
||||||
txn:set_var("txn.auth_response_code", 500)
|
set_var(txn, "txn.auth_response_code", 500)
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -44,7 +67,7 @@ core.register_action("auth-request", { "http-req" }, function(txn, be, path)
|
||||||
end
|
end
|
||||||
if addr == nil then
|
if addr == nil then
|
||||||
txn:Warning("No servers available for auth-request backend: '" .. be .. "'")
|
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
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,40 +87,33 @@ core.register_action("auth-request", { "http-req" }, function(txn, be, path)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Make request to backend.
|
-- Make request to backend.
|
||||||
local b, c, h = http.request {
|
local response, err = http.head {
|
||||||
url = "http://" .. addr .. path,
|
url = "http://" .. addr .. path,
|
||||||
headers = headers,
|
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.
|
-- Check whether we received a valid HTTP response.
|
||||||
if b == nil then
|
if response == nil then
|
||||||
txn:Warning("Failure in auth-request backend '" .. be .. "': " .. c)
|
txn:Warning("Failure in auth-request backend '" .. be .. "': " .. err)
|
||||||
txn:set_var("txn.auth_response_code", 500)
|
set_var(txn, "txn.auth_response_code", 500)
|
||||||
return
|
return
|
||||||
end
|
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.
|
-- 2xx: Allow request.
|
||||||
if 200 <= c and c < 300 then
|
if 200 <= response.status_code and response.status_code < 300 then
|
||||||
if h["remote-user"] then
|
set_var(txn, "txn.auth_response_successful", true)
|
||||||
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)
|
|
||||||
-- Don't allow other codes.
|
-- Don't allow other codes.
|
||||||
-- Codes with Location: Passthrough location at redirect.
|
-- Codes with Location: Passthrough location at redirect.
|
||||||
elseif c == 301 or c == 302 or c == 303 or c == 307 or c == 308 then
|
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
|
||||||
txn:set_var("txn.auth_response_location", h["location"])
|
set_var(txn, "txn.auth_response_location", response:get_header("location", "last"))
|
||||||
-- 401 / 403: Do nothing, everything else: log.
|
-- 401 / 403: Do nothing, everything else: log.
|
||||||
elseif c ~= 401 and c ~= 403 then
|
elseif response.status_code ~= 401 and response.status_code ~= 403 then
|
||||||
txn:Warning("Invalid status code in auth-request backend '" .. be .. "': " .. c)
|
txn:Warning("Invalid status code in auth-request backend '" .. be .. "': " .. response.status_code)
|
||||||
end
|
end
|
||||||
end, 2)
|
end, 2)
|
||||||
|
|
|
@ -4,6 +4,7 @@ services:
|
||||||
build: ./example/compose/haproxy/
|
build: ./example/compose/haproxy/
|
||||||
volumes:
|
volumes:
|
||||||
- ./example/compose/haproxy/haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro
|
- ./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
|
- ./example/compose/haproxy/auth-request.lua:/usr/local/etc/haproxy/auth-request.lua
|
||||||
networks:
|
networks:
|
||||||
authelianet:
|
authelianet:
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
global
|
global
|
||||||
|
lua-prepend-path /usr/local/etc/haproxy/?/http.lua
|
||||||
lua-load /usr/local/etc/haproxy/auth-request.lua
|
lua-load /usr/local/etc/haproxy/auth-request.lua
|
||||||
log stdout format raw local0 debug
|
log stdout format raw local0 debug
|
||||||
|
|
||||||
|
@ -68,9 +69,9 @@ backend be_protected
|
||||||
server nginx-backend nginx-backend:80
|
server nginx-backend nginx-backend:80
|
||||||
|
|
||||||
backend be_httpbin
|
backend be_httpbin
|
||||||
acl remote_user_exist var(txn.auth_user) -m found
|
acl remote_user_exist var(req.auth_response_header.remote_user) -m found
|
||||||
acl remote_groups_exist var(txn.auth_groups) -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
|
server httpbin-backend httpbin:8000
|
791
internal/suites/example/compose/haproxy/http.lua
Normal file
791
internal/suites/example/compose/haproxy/http.lua
Normal file
|
@ -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ć <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
|
||||||
|
|
||||||
|
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 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
|
Loading…
Reference in New Issue
Block a user