Reorganise, move code to not be split between haproxy and src folder

This commit is contained in:
Thomas Lynch
2023-02-11 15:06:35 +11:00
parent 0d56079960
commit 08a966c121
23 changed files with 15 additions and 15 deletions

89
src/lua/libs/cookie.lua Normal file
View File

@ -0,0 +1,89 @@
-- Copyright (C) 2013-2016 Jiale Zhi (calio), CloudFlare Inc.
-- See RFC6265 http://tools.ietf.org/search/rfc6265
-- require "luacov"
local type = type
local byte = string.byte
local sub = string.sub
local EQUAL = byte("=")
local SEMICOLON = byte(";")
local SPACE = byte(" ")
local HTAB = byte("\t")
local MAX_LEN = 10 * 1024 -- in case you are a dumbass and set a high tune.maxrewrite
local MAX_COOKIES = 100
local _M = {}
_M._VERSION = '0.01'
function _M.get_cookie_table(text_cookie)
if type(text_cookie) ~= "string" then
return {}
end
local EXPECT_KEY = 1
local EXPECT_VALUE = 2
local EXPECT_SP = 3
local n = 0
local len = #text_cookie
if len > MAX_LEN then
return {}
end
for i=1, len do
if byte(text_cookie, i) == SEMICOLON then
n = n + 1
if n > MAX_COOKIES then
return {}
end
end
end
local cookie_table = {}
local state = EXPECT_SP
local i = 1
local j = 1
local key, value
while j <= len do
if state == EXPECT_KEY then
if byte(text_cookie, j) == EQUAL then
key = sub(text_cookie, i, j - 1)
state = EXPECT_VALUE
i = j + 1
end
elseif state == EXPECT_VALUE then
if byte(text_cookie, j) == SEMICOLON
or byte(text_cookie, j) == SPACE
or byte(text_cookie, j) == HTAB
then
value = sub(text_cookie, i, j - 1)
cookie_table[key] = value
key, value = nil, nil
state = EXPECT_SP
i = j + 1
end
elseif state == EXPECT_SP then
if byte(text_cookie, j) ~= SPACE
and byte(text_cookie, j) ~= HTAB
then
state = EXPECT_KEY
i = j
j = j - 1
end
end
j = j + 1
end
if key ~= nil and value == nil then
cookie_table[key] = sub(text_cookie, i)
end
return cookie_table
end
return _M

388
src/lua/libs/json.lua Normal file
View File

@ -0,0 +1,388 @@
--
-- json.lua
--
-- Copyright (c) 2020 rxi
--
-- Permission is hereby granted, free of charge, to any person obtaining a copy of
-- this software and associated documentation files (the "Software"), to deal in
-- the Software without restriction, including without limitation the rights to
-- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
-- of the Software, and to permit persons to whom the Software is furnished to do
-- so, subject to the following conditions:
--
-- The above copyright notice and this permission notice shall be included in all
-- copies or substantial portions of the Software.
--
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-- SOFTWARE.
--
local json = { _version = "0.1.2" }
-------------------------------------------------------------------------------
-- Encode
-------------------------------------------------------------------------------
local encode
local escape_char_map = {
[ "\\" ] = "\\",
[ "\"" ] = "\"",
[ "\b" ] = "b",
[ "\f" ] = "f",
[ "\n" ] = "n",
[ "\r" ] = "r",
[ "\t" ] = "t",
}
local escape_char_map_inv = { [ "/" ] = "/" }
for k, v in pairs(escape_char_map) do
escape_char_map_inv[v] = k
end
local function escape_char(c)
return "\\" .. (escape_char_map[c] or string.format("u%04x", c:byte()))
end
local function encode_nil(val)
return "null"
end
local function encode_table(val, stack)
local res = {}
stack = stack or {}
-- Circular reference?
if stack[val] then error("circular reference") end
stack[val] = true
if rawget(val, 1) ~= nil or next(val) == nil then
-- Treat as array -- check keys are valid and it is not sparse
local n = 0
for k in pairs(val) do
if type(k) ~= "number" then
error("invalid table: mixed or invalid key types")
end
n = n + 1
end
if n ~= #val then
error("invalid table: sparse array")
end
-- Encode
for i, v in ipairs(val) do
table.insert(res, encode(v, stack))
end
stack[val] = nil
return "[" .. table.concat(res, ",") .. "]"
else
-- Treat as an object
for k, v in pairs(val) do
if type(k) ~= "string" then
error("invalid table: mixed or invalid key types")
end
table.insert(res, encode(k, stack) .. ":" .. encode(v, stack))
end
stack[val] = nil
return "{" .. table.concat(res, ",") .. "}"
end
end
local function encode_string(val)
return '"' .. val:gsub('[%z\1-\31\\"]', escape_char) .. '"'
end
local function encode_number(val)
-- Check for NaN, -inf and inf
if val ~= val or val <= -math.huge or val >= math.huge then
error("unexpected number value '" .. tostring(val) .. "'")
end
return string.format("%.14g", val)
end
local type_func_map = {
[ "nil" ] = encode_nil,
[ "table" ] = encode_table,
[ "string" ] = encode_string,
[ "number" ] = encode_number,
[ "boolean" ] = tostring,
}
encode = function(val, stack)
local t = type(val)
local f = type_func_map[t]
if f then
return f(val, stack)
end
error("unexpected type '" .. t .. "'")
end
function json.encode(val)
return ( encode(val) )
end
-------------------------------------------------------------------------------
-- Decode
-------------------------------------------------------------------------------
local parse
local function create_set(...)
local res = {}
for i = 1, select("#", ...) do
res[ select(i, ...) ] = true
end
return res
end
local space_chars = create_set(" ", "\t", "\r", "\n")
local delim_chars = create_set(" ", "\t", "\r", "\n", "]", "}", ",")
local escape_chars = create_set("\\", "/", '"', "b", "f", "n", "r", "t", "u")
local literals = create_set("true", "false", "null")
local literal_map = {
[ "true" ] = true,
[ "false" ] = false,
[ "null" ] = nil,
}
local function next_char(str, idx, set, negate)
for i = idx, #str do
if set[str:sub(i, i)] ~= negate then
return i
end
end
return #str + 1
end
local function decode_error(str, idx, msg)
local line_count = 1
local col_count = 1
for i = 1, idx - 1 do
col_count = col_count + 1
if str:sub(i, i) == "\n" then
line_count = line_count + 1
col_count = 1
end
end
error( string.format("%s at line %d col %d", msg, line_count, col_count) )
end
local function codepoint_to_utf8(n)
-- http://scripts.sil.org/cms/scripts/page.php?site_id=nrsi&id=iws-appendixa
local f = math.floor
if n <= 0x7f then
return string.char(n)
elseif n <= 0x7ff then
return string.char(f(n / 64) + 192, n % 64 + 128)
elseif n <= 0xffff then
return string.char(f(n / 4096) + 224, f(n % 4096 / 64) + 128, n % 64 + 128)
elseif n <= 0x10ffff then
return string.char(f(n / 262144) + 240, f(n % 262144 / 4096) + 128,
f(n % 4096 / 64) + 128, n % 64 + 128)
end
error( string.format("invalid unicode codepoint '%x'", n) )
end
local function parse_unicode_escape(s)
local n1 = tonumber( s:sub(1, 4), 16 )
local n2 = tonumber( s:sub(7, 10), 16 )
-- Surrogate pair?
if n2 then
return codepoint_to_utf8((n1 - 0xd800) * 0x400 + (n2 - 0xdc00) + 0x10000)
else
return codepoint_to_utf8(n1)
end
end
local function parse_string(str, i)
local res = ""
local j = i + 1
local k = j
while j <= #str do
local x = str:byte(j)
if x < 32 then
decode_error(str, j, "control character in string")
elseif x == 92 then -- `\`: Escape
res = res .. str:sub(k, j - 1)
j = j + 1
local c = str:sub(j, j)
if c == "u" then
local hex = str:match("^[dD][89aAbB]%x%x\\u%x%x%x%x", j + 1)
or str:match("^%x%x%x%x", j + 1)
or decode_error(str, j - 1, "invalid unicode escape in string")
res = res .. parse_unicode_escape(hex)
j = j + #hex
else
if not escape_chars[c] then
decode_error(str, j - 1, "invalid escape char '" .. c .. "' in string")
end
res = res .. escape_char_map_inv[c]
end
k = j + 1
elseif x == 34 then -- `"`: End of string
res = res .. str:sub(k, j - 1)
return res, j + 1
end
j = j + 1
end
decode_error(str, i, "expected closing quote for string")
end
local function parse_number(str, i)
local x = next_char(str, i, delim_chars)
local s = str:sub(i, x - 1)
local n = tonumber(s)
if not n then
decode_error(str, i, "invalid number '" .. s .. "'")
end
return n, x
end
local function parse_literal(str, i)
local x = next_char(str, i, delim_chars)
local word = str:sub(i, x - 1)
if not literals[word] then
decode_error(str, i, "invalid literal '" .. word .. "'")
end
return literal_map[word], x
end
local function parse_array(str, i)
local res = {}
local n = 1
i = i + 1
while 1 do
local x
i = next_char(str, i, space_chars, true)
-- Empty / end of array?
if str:sub(i, i) == "]" then
i = i + 1
break
end
-- Read token
x, i = parse(str, i)
res[n] = x
n = n + 1
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "]" then break end
if chr ~= "," then decode_error(str, i, "expected ']' or ','") end
end
return res, i
end
local function parse_object(str, i)
local res = {}
i = i + 1
while 1 do
local key, val
i = next_char(str, i, space_chars, true)
-- Empty / end of object?
if str:sub(i, i) == "}" then
i = i + 1
break
end
-- Read key
if str:sub(i, i) ~= '"' then
decode_error(str, i, "expected string for key")
end
key, i = parse(str, i)
-- Read ':' delimiter
i = next_char(str, i, space_chars, true)
if str:sub(i, i) ~= ":" then
decode_error(str, i, "expected ':' after key")
end
i = next_char(str, i + 1, space_chars, true)
-- Read value
val, i = parse(str, i)
-- Set
res[key] = val
-- Next token
i = next_char(str, i, space_chars, true)
local chr = str:sub(i, i)
i = i + 1
if chr == "}" then break end
if chr ~= "," then decode_error(str, i, "expected '}' or ','") end
end
return res, i
end
local char_func_map = {
[ '"' ] = parse_string,
[ "0" ] = parse_number,
[ "1" ] = parse_number,
[ "2" ] = parse_number,
[ "3" ] = parse_number,
[ "4" ] = parse_number,
[ "5" ] = parse_number,
[ "6" ] = parse_number,
[ "7" ] = parse_number,
[ "8" ] = parse_number,
[ "9" ] = parse_number,
[ "-" ] = parse_number,
[ "t" ] = parse_literal,
[ "f" ] = parse_literal,
[ "n" ] = parse_literal,
[ "[" ] = parse_array,
[ "{" ] = parse_object,
}
parse = function(str, idx)
local chr = str:sub(idx, idx)
local f = char_func_map[chr]
if f then
return f(str, idx)
end
decode_error(str, idx, "unexpected character '" .. chr .. "'")
end
function json.decode(str)
if type(str) ~= "string" then
error("expected argument of type string, got " .. type(str))
end
local res, idx = parse(str, next_char(str, 1, space_chars, true))
idx = next_char(str, idx, space_chars, true)
if idx <= #str then
decode_error(str, idx, "trailing garbage")
end
return res
end
return json

96
src/lua/libs/print_r.lua Normal file
View File

@ -0,0 +1,96 @@
-- Copyright 2016 Thierry Fournier
function color(index, str)
return "\x1b[" .. index .. "m" .. str .. "\x1b[00m"
end
function nocolor(index, str)
return str
end
function sp(count)
local spaces = ""
while count > 0 do
spaces = spaces .. " "
count = count - 1
end
return spaces
end
function escape(str)
local s = ""
for i = 1, #str do
local c = str:sub(i,i)
ascii = string.byte(c, 1)
if ascii > 126 or ascii < 20 then
s = s .. string.format("\\x%02x", ascii)
else
s = s .. c
end
end
return s
end
function print_rr(p, indent, c, wr, hist)
local i = 0
local nl = ""
if type(p) == "table" then
wr(c("33", "(table)") .. " " .. c("36", tostring(p)) .. " [")
for idx, value in ipairs(hist) do
if value == p then
wr(" " .. c("35", "/* recursion */") .. " ]")
return
end
end
hist[indent + 1] = p
mt = getmetatable(p)
if mt ~= nil then
wr("\n" .. sp(indent+1) .. c("31", "METATABLE") .. ": ")
print_rr(mt, indent+1, c, wr, hist)
end
for k,v in pairs(p) do
if i > 0 then
nl = "\n"
else
wr("\n")
end
wr(nl .. sp(indent+1))
if type(k) == "number" then
wr(c("32", tostring(k)))
else
wr("\"" .. c("32", escape(tostring(k))) .. "\"")
end
wr(": ")
print_rr(v, indent+1, c, wr, hist)
i = i + 1
end
if i == 0 then
wr(" " .. c("35", "/* empty */") .. " ]")
else
wr("\n" .. sp(indent) .. "]")
end
hist[indent + 1] = nil
elseif type(p) == "string" then
wr(c("33", "(string)") .. " \"" .. c("36", escape(p)) .. "\"")
else
wr(c("33", "(" .. type(p) .. ")") .. " " .. c("36", tostring(p)))
end
end
function print_r(p, col, wr)
if col == nil then col = true end
if wr == nil then wr = function(msg) io.stdout:write(msg) end end
local hist = {}
if col == true then
print_rr(p, 0, color, wr, hist)
else
print_rr(p, 0, nocolor, wr, hist)
end
wr("\n")
end

105
src/lua/libs/randbytes.lua Normal file
View File

@ -0,0 +1,105 @@
-- randbytes.lua
-- Colin 'Oka' Hall-Coates
-- MIT, 2015
-- https://github.com/okabsd/randbytes
local defaults = setmetatable ({
bytes = 4,
mask = 256,
file = 'urandom',
filetable = {'urandom', 'random'}
}, { __newindex = function () return false end })
local files = {
urandom = false,
random = false
}
local utils = {}
function utils:gettable (...)
local t, r = {...}, defaults.filetable
if #t > 0 then r = t end
return r
end
function utils:open (...)
for _, f in next, self:gettable (...) do
for k, _ in next, files do
if k == f then
files[f] = assert (io.open ('/dev/'..f, 'rb'))
end
end
end
end
function utils:close (...)
for _, f in next, self:gettable (...) do
for k, _ in next, files do
if files[f] and k == f then
files[f] = not assert (files[f]:close ())
end
end
end
end
function utils:reader (f, b)
if f then
return f:read (b or defaults.bytes)
end
end
local randbytes = {
generate = function (f, ...)
if f then
local n, m = 0, select (2, ...) or defaults.mask
local s = utils:reader (f, select (1, ...))
for i = 1, s:len () do
n = m * n + s:byte (i)
end
return n
end
end
}
function randbytes:open (...)
utils:open (...)
return self
end
function randbytes:close (...)
utils:close (...)
return self
end
function randbytes:uread (...)
return utils:reader (files.urandom, ...)
end
function randbytes:read (...)
return utils:reader (files.random, ...)
end
function randbytes:urandom (...)
return self.generate (files.urandom, ...)
end
function randbytes:random (...)
return self.generate (files.random, ...)
end
function randbytes:setdefault (k, v)
defaults[k] = v or defaults[k]
return defaults[k]
end
utils:open ()
return setmetatable (randbytes, {
__call = function (t, ...)
return utils:reader (files[defaults.file], ...)
end,
__metatable = false,
__newindex = function () return false end
})

5675
src/lua/libs/sha.lua Normal file

File diff suppressed because it is too large Load Diff

536
src/lua/libs/url.lua Normal file
View File

@ -0,0 +1,536 @@
-- net/url.lua - a robust url parser and builder
--
-- Bertrand Mansion, 2011-2021; License MIT
-- @module net.url
-- @alias M
local M = {}
M.version = "1.1.0"
--- url options
-- - `separator` is set to `&` by default but could be anything like `&amp;amp;` or `;`
-- - `cumulative_parameters` is false by default. If true, query parameters with the same name will be stored in a table.
-- - `legal_in_path` is a table of characters that will not be url encoded in path components
-- - `legal_in_query` is a table of characters that will not be url encoded in query values. Query parameters only support a small set of legal characters (-_.).
-- - `query_plus_is_space` is true by default, so a plus sign in a query value will be converted to %20 (space), not %2B (plus)
-- @todo Add option to limit the size of the argument table
-- @todo Add option to limit the depth of the argument table
-- @todo Add option to process dots in parameter names, ie. `param.filter=1`
M.options = {
separator = '&',
cumulative_parameters = false,
square_bracket_key = false,
max_query_parse_keys = 50,
max_query_parse_length = 32 * 1024,
legal_in_path = {
[":"] = true, ["-"] = true, ["_"] = true, ["."] = true,
["!"] = true, ["~"] = true, ["*"] = true, ["'"] = true,
["("] = true, [")"] = true, ["@"] = true, ["&"] = true,
["="] = true, ["$"] = true, [","] = true,
[";"] = true
},
legal_in_query = {
[":"] = true, ["-"] = true, ["_"] = true, ["."] = true,
[","] = true, ["!"] = true, ["~"] = true, ["*"] = true,
["'"] = true, [";"] = true, ["("] = true, [")"] = true,
["@"] = true, ["$"] = true,
},
query_plus_is_space = true
}
--- list of known and common scheme ports
-- as documented in <a href="http://www.iana.org/assignments/uri-schemes.html">IANA URI scheme list</a>
M.services = {
acap = 674,
cap = 1026,
dict = 2628,
ftp = 21,
gopher = 70,
http = 80,
https = 443,
iax = 4569,
icap = 1344,
imap = 143,
ipp = 631,
ldap = 389,
mtqp = 1038,
mupdate = 3905,
news = 2009,
nfs = 2049,
nntp = 119,
rtsp = 554,
sip = 5060,
snmp = 161,
telnet = 23,
tftp = 69,
vemmi = 575,
afs = 1483,
jms = 5673,
rsync = 873,
prospero = 191,
videotex = 516
}
local function decode(str)
return (str:gsub("%%(%x%x)", function(c)
return string.char(tonumber(c, 16))
end))
end
local function encode(str, legal)
return (str:gsub("([^%w])", function(v)
if legal[v] then
return v
end
return string.upper(string.format("%%%02x", string.byte(v)))
end))
end
-- for query values, + can mean space if configured as such
local function decodeValue(str)
if M.options.query_plus_is_space then
str = str:gsub('+', ' ')
end
return decode(str)
end
local function concat(a, b)
if type(a) == 'table' then
return a:build() .. b
else
return a .. b:build()
end
end
function M:addSegment(path)
if type(path) == 'string' then
self.path = self.path .. '/' .. encode(path:gsub("^/+", ""), M.options.legal_in_path)
end
return self
end
--- builds the url
-- @return a string representing the built url
function M:build()
local url = ''
if self.path then
local path = self.path
url = url .. tostring(path)
end
if self.query then
local qstring = tostring(self.query)
if qstring ~= "" then
url = url .. '?' .. qstring
end
end
if self.host then
local authority = self.host
if self.port and self.scheme and M.services[self.scheme] ~= self.port then
authority = authority .. ':' .. self.port
end
local userinfo
if self.user and self.user ~= "" then
userinfo = self.user
if self.password then
userinfo = userinfo .. ':' .. self.password
end
end
if userinfo and userinfo ~= "" then
authority = userinfo .. '@' .. authority
end
if authority then
if url ~= "" then
url = '//' .. authority .. '/' .. url:gsub('^/+', '')
else
url = '//' .. authority
end
end
end
if self.scheme then
url = self.scheme .. ':' .. url
end
if self.fragment then
url = url .. '#' .. self.fragment
end
return url
end
--- builds the querystring
-- @param tab The key/value parameters
-- @param sep The separator to use (optional)
-- @param key The parent key if the value is multi-dimensional (optional)
-- @return a string representing the built querystring
function M.buildQuery(tab, sep, key)
local query = {}
if not sep then
sep = M.options.separator or '&'
end
local keys = {}
for k in pairs(tab) do
keys[#keys+1] = k
end
table.sort(keys, function (a, b)
local function padnum(n, rest) return ("%03d"..rest):format(tonumber(n)) end
return tostring(a):gsub("(%d+)(%.)",padnum) < tostring(b):gsub("(%d+)(%.)",padnum)
end)
for _,name in ipairs(keys) do
local value = tab[name]
name = encode(tostring(name), {["-"] = true, ["_"] = true, ["."] = true})
if key then
if M.options.cumulative_parameters and string.find(name, '^%d+$') then
name = tostring(key)
else
name = string.format('%s[%s]', tostring(key), tostring(name))
end
end
if type(value) == 'table' then
query[#query+1] = M.buildQuery(value, sep, name)
else
local value = encode(tostring(value), M.options.legal_in_query)
if value ~= "" then
query[#query+1] = string.format('%s=%s', name, value)
else
query[#query+1] = name
end
end
end
return table.concat(query, sep)
end
--- Parses the querystring to a table
-- This function can parse multidimensional pairs and is mostly compatible
-- with PHP usage of brackets in key names like ?param[key]=value
-- @param str The querystring to parse
-- @param sep The separator between key/value pairs, defaults to `&`
-- @todo limit the max number of parameters with M.options.max_parameters
-- @return a table representing the query key/value pairs
function M.parseQuery(str, sep)
if #str > M.options.max_query_parse_length then
return
end
if not sep then
sep = M.options.separator or '&'
end
local values = {}
local parts = 0
for key,val in str:gmatch(string.format('([^%q=]+)(=*[^%q=]*)', sep, sep)) do
if parts > M.options.max_query_parse_keys then
break
end
local key = decodeValue(key)
local keys = {}
if M.options.square_bracket_key then
key = key:gsub('%[([^%]]*)%]', function(v)
-- extract keys between balanced brackets
if string.find(v, "^-?%d+$") then
v = tonumber(v)
else
v = decodeValue(v)
end
table.insert(keys, v)
return "="
end)
end
key = key:gsub('=+.*$', "")
key = key:gsub('%s', "_") -- remove spaces in parameter name
val = val:gsub('^=+', "")
if not values[key] then
values[key] = {}
end
if #keys > 0 and type(values[key]) ~= 'table' then
values[key] = {}
elseif #keys == 0 and type(values[key]) == 'table' then
values[key] = decodeValue(val)
elseif M.options.cumulative_parameters
and type(values[key]) == 'string' then
values[key] = { values[key] }
table.insert(values[key], decodeValue(val))
end
local t = values[key]
for i,k in ipairs(keys) do
if type(t) ~= 'table' then
t = {}
end
if k == "" then
k = #t+1
end
if not t[k] then
t[k] = {}
end
if i == #keys then
t[k] = val
end
t = t[k]
end
parts = parts + 1
end
setmetatable(values, { __tostring = M.buildQuery })
return values
end
--- set the url query
-- @param query Can be a string to parse or a table of key/value pairs
-- @return a table representing the query key/value pairs
function M:setQuery(query)
local query = query
if type(query) == 'table' then
query = M.buildQuery(query)
end
self.query = M.parseQuery(query)
return query
end
--- set the authority part of the url
-- The authority is parsed to find the user, password, port and host if available.
-- @param authority The string representing the authority
-- @return a string with what remains after the authority was parsed
function M:setAuthority(authority)
self.authority = authority
self.port = nil
self.host = nil
self.userinfo = nil
self.user = nil
self.password = nil
authority = authority:gsub('^([^@]*)@', function(v)
self.userinfo = v
return ''
end)
authority = authority:gsub(':(%d+)$', function(v)
self.port = tonumber(v)
return ''
end)
local function getIP(str)
-- ipv4
local chunks = { str:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") }
if #chunks == 4 then
for _, v in pairs(chunks) do
if tonumber(v) > 255 then
return false
end
end
return str
end
-- ipv6
local chunks = { str:match("^%["..(("([a-fA-F0-9]*):"):rep(8):gsub(":$","%%]$"))) }
if #chunks == 8 or #chunks < 8 and
str:match('::') and not str:gsub("::", "", 1):match('::') then
for _,v in pairs(chunks) do
if #v > 0 and tonumber(v, 16) > 65535 then
return false
end
end
return str
end
return nil
end
local ip = getIP(authority)
if ip then
self.host = ip
elseif type(ip) == 'nil' then
-- domain
if authority ~= '' and not self.host then
local host = authority:lower()
if string.match(host, '^[%d%a%-%.]+$') ~= nil and
string.sub(host, 0, 1) ~= '.' and
string.sub(host, -1) ~= '.' and
string.find(host, '%.%.') == nil then
self.host = host
end
end
end
if self.userinfo then
local userinfo = self.userinfo
userinfo = userinfo:gsub(':([^:]*)$', function(v)
self.password = v
return ''
end)
if string.find(userinfo, "^[%w%+%.]+$") then
self.user = userinfo
else
-- incorrect userinfo
self.userinfo = nil
self.user = nil
self.password = nil
end
end
return authority
end
--- Parse the url into the designated parts.
-- Depending on the url, the following parts can be available:
-- scheme, userinfo, user, password, authority, host, port, path,
-- query, fragment
-- @param url Url string
-- @return a table with the different parts and a few other functions
function M.parse(url)
local comp = {}
M.setAuthority(comp, "")
M.setQuery(comp, "")
local url = tostring(url or '')
url = url:gsub('#(.*)$', function(v)
comp.fragment = v
return ''
end)
url =url:gsub('^([%w][%w%+%-%.]*)%:', function(v)
comp.scheme = v:lower()
return ''
end)
url = url:gsub('%?(.*)', function(v)
M.setQuery(comp, v)
return ''
end)
url = url:gsub('^//([^/]*)', function(v)
M.setAuthority(comp, v)
return ''
end)
comp.path = url:gsub("([^/]+)", function (s) return encode(decode(s), M.options.legal_in_path) end)
setmetatable(comp, {
__index = M,
__tostring = M.build,
__concat = concat,
__div = M.addSegment
})
return comp
end
--- removes dots and slashes in urls when possible
-- This function will also remove multiple slashes
-- @param path The string representing the path to clean
-- @return a string of the path without unnecessary dots and segments
function M.removeDotSegments(path)
local fields = {}
if string.len(path) == 0 then
return ""
end
local startslash = false
local endslash = false
if string.sub(path, 1, 1) == "/" then
startslash = true
end
if (string.len(path) > 1 or startslash == false) and string.sub(path, -1) == "/" then
endslash = true
end
path:gsub('[^/]+', function(c) table.insert(fields, c) end)
local new = {}
local j = 0
for i,c in ipairs(fields) do
if c == '..' then
if j > 0 then
j = j - 1
end
elseif c ~= "." then
j = j + 1
new[j] = c
end
end
local ret = ""
if #new > 0 and j > 0 then
ret = table.concat(new, '/', 1, j)
else
ret = ""
end
if startslash then
ret = '/'..ret
end
if endslash then
ret = ret..'/'
end
return ret
end
local function reducePath(base_path, relative_path)
if string.sub(relative_path, 1, 1) == "/" then
return '/' .. string.gsub(relative_path, '^[%./]+', '')
end
local path = base_path
local startslash = string.sub(path, 1, 1) ~= "/";
if relative_path ~= "" then
path = (startslash and '' or '/') .. path:gsub("[^/]*$", "")
end
path = path .. relative_path
path = path:gsub("([^/]*%./)", function (s)
if s ~= "./" then return s else return "" end
end)
path = string.gsub(path, "/%.$", "/")
local reduced
while reduced ~= path do
reduced = path
path = string.gsub(reduced, "([^/]*/%.%./)", function (s)
if s ~= "../../" then return "" else return s end
end)
end
path = string.gsub(path, "([^/]*/%.%.?)$", function (s)
if s ~= "../.." then return "" else return s end
end)
local reduced
while reduced ~= path do
reduced = path
path = string.gsub(reduced, '^/?%.%./', '')
end
return (startslash and '' or '/') .. path
end
--- builds a new url by using the one given as parameter and resolving paths
-- @param other A string or a table representing a url
-- @return a new url table
function M:resolve(other)
if type(self) == "string" then
self = M.parse(self)
end
if type(other) == "string" then
other = M.parse(other)
end
if other.scheme then
return other
else
other.scheme = self.scheme
if not other.authority or other.authority == "" then
other:setAuthority(self.authority)
if not other.path or other.path == "" then
other.path = self.path
local query = other.query
if not query or not next(query) then
other.query = self.query
end
else
other.path = reducePath(self.path, other.path)
end
end
return other
end
end
--- normalize a url path following some common normalization rules
-- described on <a href="http://en.wikipedia.org/wiki/URL_normalization">The URL normalization page of Wikipedia</a>
-- @return the normalized path
function M:normalize()
if type(self) == 'string' then
self = M.parse(self)
end
if self.path then
local path = self.path
path = reducePath(path, "")
-- normalize multiple slashes
path = string.gsub(path, "//+", "/")
self.path = path
end
return self
end
return M

68
src/lua/libs/utils.lua Normal file
View File

@ -0,0 +1,68 @@
local _M = {}
local sha = require("sha")
local secret_bucket_duration = tonumber(os.getenv("BUCKET_DURATION"))
local challenge_includes_ip = os.getenv("CHALLENGE_INCLUDES_IP")
local tor_control_port_password = os.getenv("TOR_CONTROL_PORT_PASSWORD")
-- generate the challenge hash/user hash
function _M.generate_secret(context, salt, user_key, is_applet)
-- time bucket for expiry
local start_sec = core.now()['sec']
local bucket = start_sec - (start_sec % secret_bucket_duration)
-- optional IP to lock challenges/user_keys to IP (for clearnet or single-onion aka 99% of cases)
local ip = ""
if challenge_includes_ip == "1" then
ip = context.sf:src()
end
-- user agent to counter very dumb spammers
local user_agent = ""
if is_applet == true then
user_agent = context.headers['user-agent'] or {}
user_agent = user_agent[0] or ""
else
--note req_fhdr not req_hdr otherwise commas in useragent become a delimiter
user_agent = context.sf:req_fhdr('user-agent') or ""
end
return sha.sha3_256(salt .. bucket .. ip .. user_key .. user_agent)
end
-- split string by delimiter
function _M.split(inputstr, sep)
local t = {}
for str in string.gmatch(inputstr, "([^"..sep.."]*)") do
table.insert(t, str)
end
return t
end
-- return true if hash passes difficulty
function _M.checkdiff(hash, diff)
local i = 1
for j = 0, (diff-8), 8 do
if hash:sub(i, i) ~= "0" then
return false
end
i = i + 1
end
local lnm = tonumber(hash:sub(i, i), 16)
local msk = 0xff >> ((i*8)-diff)
return (lnm & msk) == 0
end
-- connect to the tor control port and instruct it to close a circuit
function _M.send_tor_control_port(circuit_identifier)
local tcp = core.tcp();
tcp:settimeout(1);
tcp:connect("127.0.0.1", 9051);
-- not buffered, so we are better off sending it all at once
tcp:send('AUTHENTICATE "' .. tor_control_port_password .. '"\nCLOSECIRCUIT ' .. circuit_identifier ..'\n')
tcp:close()
end
return _M

View File

@ -0,0 +1,358 @@
_M = {}
-- Testing only
-- require("socket")
-- require("print_r")
-- main libs
local url = require("url")
local utils = require("utils")
local cookie = require("cookie")
local json = require("json")
local sha = require("sha")
local randbytes = require("randbytes")
local templates = require("templates")
-- POW
local pow_difficulty = tonumber(os.getenv("POW_DIFFICULTY") or 18)
-- argon2
local argon2 = require("argon2")
local argon_kb = tonumber(os.getenv("ARGON_KB") or 6000)
local argon_time = tonumber(os.getenv("ARGON_TIME") or 1)
argon2.t_cost(argon_time)
argon2.m_cost(argon_kb)
argon2.parallelism(1)
argon2.hash_len(32)
argon2.variant(argon2.variants.argon2_id)
-- sha2
-- TODO
-- environment variables
local captcha_secret = os.getenv("HCAPTCHA_SECRET") or os.getenv("RECAPTCHA_SECRET")
local captcha_sitekey = os.getenv("HCAPTCHA_SITEKEY") or os.getenv("RECAPTCHA_SITEKEY")
local captcha_cookie_secret = os.getenv("CAPTCHA_COOKIE_SECRET")
local pow_cookie_secret = os.getenv("POW_COOKIE_SECRET")
local hmac_cookie_secret = os.getenv("HMAC_COOKIE_SECRET")
local ray_id = os.getenv("RAY_ID")
-- load captcha map and set hcaptcha/recaptch based off env vars
local captcha_map = Map.new("/etc/haproxy/ddos.map", Map._str);
local captcha_provider_domain = ""
local captcha_classname = ""
local captcha_script_src = ""
local captcha_siteverify_path = ""
local captcha_backend_name = ""
if os.getenv("HCAPTCHA_SITEKEY") then
captcha_provider_domain = "hcaptcha.com"
captcha_classname = "h-captcha"
captcha_script_src = "https://hcaptcha.com/1/api.js"
captcha_siteverify_path = "/siteverify"
captcha_backend_name = "hcaptcha"
else
captcha_provider_domain = "www.google.com"
captcha_classname = "g-recaptcha"
captcha_script_src = "https://www.google.com/recaptcha/api.js"
captcha_siteverify_path = "/recaptcha/api/siteverify"
captcha_backend_name = "recaptcha"
end
-- setup initial server backends based on hosts.map into backends.map
function _M.setup_servers()
if pow_difficulty < 8 then
error("POW_DIFFICULTY must be > 8. Around 16-32 is better")
end
local backend_name = os.getenv("BACKEND_NAME")
local server_prefix = os.getenv("SERVER_PREFIX")
if backend_name == nil or server_prefix == nil then
return;
end
local hosts_map = Map.new("/etc/haproxy/hosts.map", Map._str);
local handle = io.open("/etc/haproxy/hosts.map", "r")
local line = handle:read("*line")
local counter = 1
while line do
local domain, backend_host = line:match("([^%s]+)%s+([^%s]+)")
local port_index = backend_host:match'^.*():'
local backend_hostname = backend_host:sub(0, port_index-1)
local backend_port = backend_host:sub(port_index + 1)
core.set_map("/etc/haproxy/backends.map", domain, server_prefix..counter)
local proxy = core.proxies[backend_name].servers[server_prefix..counter]
proxy:set_addr(backend_hostname, backend_port)
proxy:set_ready()
line = handle:read("*line")
counter = counter + 1
end
handle:close()
end
-- kill a tor circuit
function _M.kill_tor_circuit(txn)
local ip = txn.sf:src()
if ip:sub(1,19) ~= "fc00:dead:beef:4dad" then
return -- not a tor circuit id/ip. we shouldn't get here, but just in case.
end
-- split the IP, take the last 2 sections
local split_ip = utils.split(ip, ":")
local aa_bb = split_ip[5] or "0000"
local cc_dd = split_ip[6] or "0000"
aa_bb = string.rep("0", 4 - #aa_bb) .. aa_bb
cc_dd = string.rep("0", 4 - #cc_dd) .. cc_dd
-- convert the last 2 sections to a number from hex, which makes the circuit ID
local circuit_identifier = tonumber(aa_bb..cc_dd, 16)
print('Closing Tor circuit ID: '..circuit_identifier..', "IP": '..ip)
utils.send_tor_control_port(circuit_identifier)
end
function _M.view(applet)
-- set response body and declare status code
local response_body = ""
local response_status_code
-- if request is GET, serve the challenge page
if applet.method == "GET" then
-- get the user_key#challenge#sig
local user_key = sha.bin_to_hex(randbytes(16))
local challenge_hash = utils.generate_secret(applet, pow_cookie_secret, user_key, true)
local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. challenge_hash)
local combined_challenge = user_key .. "#" .. challenge_hash .. "#" .. signature
-- define body sections
local site_name_body = ""
local captcha_body = ""
local pow_body = ""
local noscript_extra_body = ""
-- check if captcha is enabled, path+domain priority, then just domain, and 0 otherwise
local captcha_enabled = false
local host = applet.headers['host'][0]
local path = applet.qs; --because on /.basedflare/bot-check?/whatever, .qs (query string) holds the "path"
local captcha_map_lookup = captcha_map:lookup(host..path) or captcha_map:lookup(host) or 0
captcha_map_lookup = tonumber(captcha_map_lookup)
if captcha_map_lookup == 2 then
captcha_enabled = true
end
-- pow at least is always enabled when reaching bot-check page
site_name_body = string.format(templates.site_name_section, host)
if captcha_enabled then
captcha_body = string.format(templates.captcha_section, captcha_classname,
captcha_sitekey, captcha_script_src)
else
pow_body = templates.pow_section
noscript_extra_body = string.format(templates.noscript_extra, user_key, challenge_hash, signature,
math.ceil(pow_difficulty/8), argon_time, argon_kb)
end
-- sub in the body sections
response_body = string.format(templates.body, combined_challenge,
pow_difficulty, argon_time, argon_kb,
site_name_body, pow_body, captcha_body, noscript_extra_body, ray_id)
response_status_code = 403
-- if request is POST, check the answer to the pow/cookie
elseif applet.method == "POST" then
-- if they fail, set a var for use in ACLs later
local valid_submission = false
-- parsed POST body
local parsed_body = url.parseQuery(applet.receive(applet))
-- whether to set cookies sent as secure or not
local secure_cookie_flag = " Secure=true;"
if applet.sf:ssl_fc() == "0" then
secure_cookie_flag = ""
end
-- handle setting the POW cookie
local user_pow_response = parsed_body["pow_response"]
if user_pow_response then
-- split the response up (makes the nojs submission easier because it can be a single field)
local split_response = utils.split(user_pow_response, "#")
if #split_response == 4 then
local given_user_key = split_response[1]
local given_challenge_hash = split_response[2]
local given_signature = split_response[3]
local given_answer = split_response[4]
-- regenerate the challenge and compare it
local generated_challenge_hash = utils.generate_secret(applet, pow_cookie_secret, given_user_key, true)
if given_challenge_hash == generated_challenge_hash then
-- regenerate the signature and compare it
local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash)
if given_signature == generated_signature then
-- do the work with their given answer
local full_hash = argon2.hash_encoded(given_challenge_hash .. given_answer, given_user_key)
-- check the output is correct
local hash_output = utils.split(full_hash, '$')[6]:sub(0, 43) -- https://github.com/thibaultcha/lua-argon2/issues/37
local hex_hash_output = sha.bin_to_hex(sha.base64_to_bin(hash_output));
if utils.checkdiff(hex_hash_output, pow_difficulty) then
-- the answer was good, give them a cookie
local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer)
local combined_cookie = given_user_key .. "#" .. given_challenge_hash .. "#" .. given_answer .. "#" .. signature
applet:add_header(
"set-cookie",
string.format(
"z_ddos_pow=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s",
combined_cookie,
applet.headers['host'][0],
secure_cookie_flag
)
)
valid_submission = true
end
end
end
end
end
-- handle setting the captcha cookie
local user_captcha_response = parsed_body["h-captcha-response"] or parsed_body["g-recaptcha-response"]
if valid_submission and user_captcha_response then -- only check captcha if POW is already correct
-- format the url for verifying the captcha response
local captcha_url = string.format(
"https://%s%s",
core.backends[captcha_backend_name].servers[captcha_backend_name]:get_addr(),
captcha_siteverify_path
)
-- construct the captcha body to send to the captcha url
local captcha_body = url.buildQuery({
secret=captcha_secret,
response=user_captcha_response
})
-- instantiate an http client and make the request
local httpclient = core.httpclient()
local res = httpclient:post{
url=captcha_url,
body=captcha_body,
headers={
[ "host" ] = { captcha_provider_domain },
[ "content-type" ] = { "application/x-www-form-urlencoded" }
}
}
-- try parsing the response as json
local status, api_response = pcall(json.decode, res.body)
if not status then
api_response = {}
end
-- the response was good i.e the captcha provider says they passed, give them a cookie
if api_response.success == true then
local user_key = sha.bin_to_hex(randbytes(16))
local user_hash = utils.generate_secret(applet, captcha_cookie_secret, user_key, true)
local signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, user_key .. user_hash)
local combined_cookie = user_key .. "#" .. user_hash .. "#" .. signature
applet:add_header(
"set-cookie",
string.format(
"z_ddos_captcha=%s; Expires=Thu, 31-Dec-37 23:55:55 GMT; Path=/; Domain=.%s; SameSite=Strict;%s",
combined_cookie,
applet.headers['host'][0],
secure_cookie_flag
)
)
valid_submission = valid_submission and true
end
end
if not valid_submission then
_M.kill_tor_circuit(applet)
end
-- redirect them to their desired page in applet.qs (query string)
-- if they didn't get the appropriate cookies they will be sent back to the challenge page
response_status_code = 302
applet:add_header("location", applet.qs)
-- else if its another http method, just 403 them
else
response_status_code = 403
end
-- finish sending the response
applet:set_status(response_status_code)
applet:add_header("content-type", "text/html; charset=utf-8")
applet:add_header("content-length", string.len(response_body))
applet:start_response()
applet:send(response_body)
end
-- check if captcha is enabled, path+domain priority, then just domain, and 0 otherwise
function _M.decide_checks_necessary(txn)
local host = txn.sf:hdr("Host")
local path = txn.sf:path();
local captcha_map_lookup = captcha_map:lookup(host..path) or captcha_map:lookup(host) or 0
captcha_map_lookup = tonumber(captcha_map_lookup)
if captcha_map_lookup == 1 then
txn:set_var("txn.validate_pow", true)
elseif captcha_map_lookup == 2 then
txn:set_var("txn.validate_captcha", true)
txn:set_var("txn.validate_pow", true)
end
-- otherwise, domain+path was set to 0 (whitelist) or there is no entry in the map
end
-- check if captcha cookie is valid, separate secret from POW
function _M.check_captcha_status(txn)
local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie"))
local received_captcha_cookie = parsed_request_cookies["z_ddos_captcha"] or ""
-- split the cookie up
local split_cookie = utils.split(received_captcha_cookie, "#")
if #split_cookie ~= 3 then
return
end
local given_user_key = split_cookie[1]
local given_user_hash = split_cookie[2]
local given_signature = split_cookie[3]
-- regenerate the user hash and compare it
local generated_user_hash = utils.generate_secret(txn, captcha_cookie_secret, given_user_key, false)
if generated_user_hash ~= given_user_hash then
return
end
-- regenerate the signature and compare it
local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_user_hash)
if given_signature == generated_signature then
return txn:set_var("txn.captcha_passed", true)
end
end
-- check if pow cookie is valid
function _M.check_pow_status(txn)
local parsed_request_cookies = cookie.get_cookie_table(txn.sf:hdr("Cookie"))
local received_pow_cookie = parsed_request_cookies["z_ddos_pow"] or ""
-- split the cookie up
local split_cookie = utils.split(received_pow_cookie, "#")
if #split_cookie ~= 4 then
return
end
local given_user_key = split_cookie[1]
local given_challenge_hash = split_cookie[2]
local given_answer = split_cookie[3]
local given_signature = split_cookie[4]
-- regenerate the challenge and compare it
local generated_challenge_hash = utils.generate_secret(txn, pow_cookie_secret, given_user_key, false)
if given_challenge_hash ~= generated_challenge_hash then
return
end
-- regenerate the signature and compare it
local generated_signature = sha.hmac(sha.sha3_256, hmac_cookie_secret, given_user_key .. given_challenge_hash .. given_answer)
if given_signature == generated_signature then
return txn:set_var("txn.pow_passed", true)
end
end
return _M

View File

@ -0,0 +1,10 @@
package.path = package.path .. "./?.lua;/etc/haproxy/scripts/?.lua;/etc/haproxy/libs/?.lua"
local bot_check = require("bot-check")
core.register_service("bot-check", "http", bot_check.view)
core.register_action("captcha-check", { 'http-req', }, bot_check.check_captcha_status)
core.register_action("pow-check", { 'http-req', }, bot_check.check_pow_status)
core.register_action("decide-checks-necessary", { 'http-req', }, bot_check.decide_checks_necessary)
core.register_action("kill-tor-circuit", { 'http-req', }, bot_check.kill_tor_circuit)
core.register_init(bot_check.setup_servers)

View File

@ -0,0 +1,100 @@
local _M = {}
-- main page template
_M.body = [[
<!DOCTYPE html>
<html>
<head>
<meta name='viewport' content='width=device-width initial-scale=1'>
<title>Hold on...</title>
<style>
:root{--text-color:#c5c8c6;--bg-color:#1d1f21}
@media (prefers-color-scheme:light){:root{--text-color:#333;--bg-color:#EEE}}
.h-captcha,.g-recaptcha{min-height:85px;display:block}
.red{color:red;font-weight:bold}
.powstatus{color:green;font-weight:bold}
a,a:visited{color:var(--text-color)}
body,html{height:100%%}
body{display:flex;flex-direction:column;background-color:var(--bg-color);color:var(--text-color);font-family:Helvetica,Arial,sans-serif;max-width:1200px;margin:0 auto;padding: 0 20px}
details{transition: border-left-color 0.5s;max-width:1200px;text-align:left;border-left: 2px solid var(--text-color);padding:10px}
code{background-color:#dfdfdf30;border-radius:3px;padding:0 3px;}
img,h3,p{margin:0 0 5px 0}
footer{font-size:x-small;margin-top:auto;margin-bottom:20px;text-align:center}
img{display:inline}
.pt{padding-top:15vh;display:flex;align-items:center;word-break:break-all}
.pt img{margin-right:10px}
details[open]{border-left-color: #1400ff}
.lds-ring{display:inline-block;position:relative;width:80px;height:80px}.lds-ring div{box-sizing:border-box;display:block;position:absolute;width:32px;height:32px;margin:10px;border:5px solid var(--text-color);border-radius:50%%;animation:lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;border-color:var(--text-color) transparent transparent transparent}.lds-ring div:nth-child(1){animation-delay:-0.45s}.lds-ring div:nth-child(2){animation-delay:-0.3s}.lds-ring div:nth-child(3){animation-delay:-0.15s}@keyframes lds-ring{0%%{transform:rotate(0deg)}100%%{transform:rotate(360deg)}}
</style>
<noscript>
<style>.jsonly{display:none}</style>
</noscript>
<script src="/.basedflare/js/argon2.js"></script>
<script src="/.basedflare/js/challenge.js"></script>
</head>
<body data-pow="%s" data-diff="%s" data-time="%s" data-kb="%s">
%s
%s
%s
<noscript>
<br>
<p class="red">JavaScript is required on this page.</p>
%s
</noscript>
<div class="powstatus"></div>
<footer>
<p>Security and Performance by <a href="https://gitgud.io/fatchan/haproxy-protection/">haproxy-protection</a></p>
<p>Node: <code>%s</code></p>
</footer>
</body>
</html>
]]
_M.noscript_extra = [[
<details>
<summary>No JavaScript?</summary>
<ol>
<li>
<p>Run this in a linux terminal (requires <code>argon2</code> package installed):</p>
<code style="word-break: break-all;">
echo "Q0g9IiQyIjtCPSQocHJpbnRmICcwJS4wcycgJChzZXEgMSAkNCkpO2VjaG8gIldvcmtpbmcuLi4iO0k9MDt3aGlsZSB0cnVlOyBkbyBIPSQoZWNobyAtbiAkQ0gkSSB8IGFyZ29uMiAkMSAtaWQgLXQgJDUgLWsgJDYgLXAgMSAtbCAzMiAtcik7RT0ke0g6MDokNH07W1sgJEUgPT0gJEIgXV0gJiYgZWNobyAiT3V0cHV0OiIgJiYgZWNobyAkMSMkMiMkMyMkSSAmJiBleGl0IDA7KChJKyspKTtkb25lOwo=" | base64 -d | bash -s %s %s %s %s %s %s
</code>
<li>Paste the script output into the box and submit:
<form method="post">
<textarea name="pow_response" placeholder="script output" required></textarea>
<div><input type="submit" value="submit" /></div>
</form>
</ol>
</details>
]]
-- title with favicon and hostname
_M.site_name_section = [[
<h3 class="pt">
<img src="/favicon.ico" width="32" height="32" alt="icon">
%s
</h3>
]]
-- spinner animation for proof of work
_M.pow_section = [[
<h3>
Checking your browser for robots 🤖
</h3>
<div class="jsonly">
<div class="lds-ring"><div></div><div></div><div></div><div></div></div>
</div>
]]
-- message, captcha form and submit button
_M.captcha_section = [[
<h3>
Please solve the captcha to continue.
</h3>
<div id="captcha" class="jsonly">
<div class="%s" data-sitekey="%s" data-callback="onCaptchaSubmit"></div>
<script src="%s" async defer></script>
</div>
]]
return _M