diff options
author | Sergey Poznyakoff <gray@gnu.org> | 2021-03-05 16:11:15 +0200 |
---|---|---|
committer | Sergey Poznyakoff <gray@gnu.org> | 2021-03-07 16:21:54 +0200 |
commit | 865ff3a51306c4dd789364a1463000a182f19c97 (patch) | |
tree | 814661f35d9f3a378fd87b1556b6b694b7ade32c | |
download | haproxy-bulkredirect-865ff3a51306c4dd789364a1463000a182f19c97.tar.gz haproxy-bulkredirect-865ff3a51306c4dd789364a1463000a182f19c97.tar.bz2 |
Initial commit
-rw-r--r-- | bulkredirect.lua | 445 |
1 files changed, 445 insertions, 0 deletions
diff --git a/bulkredirect.lua b/bulkredirect.lua new file mode 100644 index 0000000..20666ca --- /dev/null +++ b/bulkredirect.lua @@ -0,0 +1,445 @@ +-- Bulk redirects for HAProxy +-- Copyright (C) 2021 Sergey Poznyakoff +-- +-- This program is free software; you can redistribute it and/or modify +-- it under the terms of the GNU General Public License as published by +-- the Free Software Foundation; either version 3, or (at your option) +-- any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU General Public License for more details. +-- +-- You should have received a copy of the GNU General Public License +-- along with this program. If not, see <http://www.gnu.org/licenses/>. + +local bulkredirect = {} +__ENV = bulkredirect + +--[[ + Iterator factory to successively strip off the trailing pathname + element from a path. + + On input, T is the pathname (optionally terminated by /). + Returns iterator function that, on each call, returns the pathname + with its trailing element removed. Trailing element here means the + everything past the last '/' character. +]] +local function prevsegm (t) + local s = t + if not s:match("/$") then s = s .. "/" end + return function () + s = s:match("(.*)/") + return s + end +end + +--[[ + Module global variables: + + rt + Redirection table. It is indexed by the request host name + (the value given by the HTTP Host header). The corresponding value + can be either a string or a table. + + A string value refers to another host name (an alias). + + A table supplies the actual mapping from an URI to another URL. + When a request to P?Q arrives (where P denotes the pathname part and + Q denotes query arguments), the P is looked up in the table. If the + entry rt[P] does not exist, the trailing pathname component is + removed from P and the resulting string P1 (unless it is empty) is + used as the look-up key. The process continues until either the entry + rt[P1] is found, or P1 is reduced to an empty string. The latter means + there is no redirect for the given P?Q combination. + + The lookup process is reduced to a single look up if the global + variable 'exact' is true (see below). + + If the entry rt[P1] is found and is a string, it gives the location + of the redirect. Prior to returning a 301 reply, this location is + augmented by pathname part removed from P during the look up process, + and the quiery part (if any). These modifications are controlled by + the global variables 'strippath' and 'stripquery', described below. + If 'strippath' is true, the removed pathname components are not added + back to the location. Similarly, if 'stripquery' is true, the query + part is not appended to the location. The 301 reply code is the + default. If the 'temporary' global variable is set to true, the code + 302 will be used instead. + + If rt[P1] is a table, it must contain a sequence of two elements. + The element rt[P1][1] supplies the new location if Q is not present. + The element rt[P1][2] is an associative table, indexed by possible + values of Q (including empty value). Both rt[P1][1] and rt[P1][2][X] + can contain either a string or a table value. The string value + supplies the new location as described above. The table value + supplies the new location in the element [1]. Rest of elements + (2 up to 5) are boolean values overriding the default global variables + for this entries. They are located in the following order: + + 2 - exact + 3 - strippath + 4 - stripquery + 5 - temporary + + www + If set to true, the rules for a hostname "X" apply also to + hostname "www.X" + + exact + If set to true, no path prefix search is done: the redirection reply + is returned only if the input path is present in the table. + + strippath + By default the path components stripped during the look up process + are added back to the returned location. If this variable is set + to true, these components are dropped instead. + + stripquery + By default the query part (if any) is appended to the new + location when returning the redirection reply. Setting this variable + to true disables this + + temporary + Whether to return temporary (302) or permanent (301) reply. +]] + +-- +-- Redirect the request if it matches one of the entries in the RT table. +-- +function bulkredirect.request (txn) + local headers = txn.http:req_get_headers() + local reply = txn:reply() + local path = txn.f:path():sub(2) + local host = headers["host"][0] + + -- Get the per-host redirection table + local rthost = rt[host] + + -- Resolve eventual alias chain. + while type(rthost) == 'string' + do + rthost = rt[rthost] + end + + -- If no corresponding host entry found, start from the default + -- entry. + if not rthost then + rthost = rt["*"] + end + + if rthost then + local location + + -- Successively strip the trailing element off the path and look up + -- the remaining part in the table. + for i in prevsegm(path) do + local exact, strippath, stripquery, temporary = exact, strippath, stripquery, temporary + + if rthost[i] then + -- If the entry is found, it is either a table or a string + if type(rthost[i]) == 'table' then + local dt = rthost[i] + local query = txn.f:query() + if query then + if dt[2] then + dt = dt[2][query] + else + dt = dt[1] + end + else + dt = dt[1] + end + + location = dt[1] + if dt[2] ~= nil then + exact = dt[2] + end + if dt[3] ~= nil then + strippath = dt[3] + end + if dt[4] ~= nil then + stripquery = dt[4] + end + if dt[5] ~= nil then + temporary = dt[5] + end + else + location = rthost[i] + end + end + + if location then + if not exact or i == path or i..'/' == path then + if not strippath then + location = location .. path:sub(i:len() + 1) + end + if not stripquery and txn.f:query() then + location = location .. '?' .. txn.f:query() + end + + core.Debug("REDIRECT " .. host .. txn.f:path() .. " to " .. location) + if temporary then + reply:set_status(302, "Moved Temporarily") + else + reply:set_status(301, "Moved Permanently") + end + reply:add_header("Location", location) + txn:done(reply) + break + end + end + end + end +end + +-- Populate the redirection table +--[[ + Syntax: + + input ::= statement | input statement + statement ::= option | domain | redirect + option ::= 'option' optlist [,] + domain ::= '[' HOSTNAME ']' + redirect ::= STRING STRING optlist [,] + optlist ::= OPTNAME | optilist ',' OPTNAME + OPTNAME ::= 'www' | 'exact' | 'strippath' | 'stripquery' | 'temporary' + <or any of these prefixed with 'no'> + HOSTNAME ::= <any valid hostname> +]] + +local function parseopt (s, t, loc) + local valid = { + ['www'] = true, + ['exact'] = true, + ['strippath'] = true, + ['stripquery'] = true, + ['temporary'] = true + } + + function options (str, loc) + local s = str + return function () + if s == nil or s == "" then + return nil + end + + local opt, rest + opt, rest = s:match("^(%w+)%s*,%s*(.*)") + if not opt then + opt = s:match("^%w+$") + if not opt then + error(loc .. ": bad option list syntax near " .. s, 0) + end + end + s = rest + return opt + end + end + + if not t then + t = _ENV + end + + for opt in options(s, loc) do + local neg = opt:match("^no(%w+)$") + local val = true + + if neg then + opt = neg + val = not val + end + if not valid[opt] then + error(loc .. ': invalid option ' .. opt, 0) + end + + t[opt] = val + end +end + +local function set_dst (dt, src, dst) + local path, query = src:match('^(.+)?(.+)') + if path then + if not dt[src] then + dt[src] = {nil, {}} + elseif type(dt[src]) == 'string' then + dt[src] = { { dt[src] }, {} } + end + dt[src][2][query] = dst + elseif type(dt[src]) == 'table' then + dt[src][1] = dst + elseif type(dst) == 'table' then + dt[src] = { dst } + else + dt[src] = dst + end +end + +local function clone (orig) + local copy + if type(orig) == 'table' then + copy = {} + for k,v in pairs(orig) do + copy[k] = clone(v) + end + setmetatable(copy, clone(getmetatable(orig))) + else + copy = orig + end + return copy +end + +local function www_complement (name) + local s = name:match('^www%.(.+)') + if not s then s = 'www.' .. name end + return s +end + +local function load_redirect_file (f, filename) + local domain + local ln = 1 + + local rt = {} + local domopt = {} + + local parsetab = { + { '^#', function () end }, + { '^%s*$', function () end }, + { '^option%s+(.*)', function (s) + local t + if domain then + t = domopt + else + t = _ENV + end + parseopt(s, t, filename .. ':' .. ln) + end }, + { '^%s*%[(.+)%]%s*$', function (s) + domain = s + if not rt[domain] then rt[domain] = {} end + end }, + { '^%s*([^%s]+)%s+([^%s]+)%s*(.*)$', function (src, dst, optlist) + if not domain then + error(filename .. ':' .. ln .. ': declare [domain] first', 0) + end + + if src:match('^/') then + src = src:sub(2) + end + + if dst:match('^/') then + dst = dst:sub(2) + end + + local optab = domopt + if optlist ~= '' then + optab = clone(domopt) + parseopt(optlist, optab, filename .. ':' .. ln) + end + + if optab['exact'] then + if type(dst) == 'string' then dst = { dst } end + dst[2] = optab['exact'] + end + if optab['strippath'] then + if type(dst) == 'string' then dst = { dst } end + dst[3] = optab['strippath'] + end + if optab['stripquery'] then + if type(dst) == 'string' then dst = { dst } end + dst[4] = optab['stripquery'] + end + if optab['temporary'] then + if type(dst) == 'string' then dst = { dst } end + dst[5] = optab['temporary'] + end + + if optab['www'] ~= nil then + if www == optab['www'] then + return + elseif www then + for d,t in pairs(rt) do + local compl = www_complement(d) + if rt[compl] then + for k,v in pairs(t) do + -- FIXME: Error message if rt[compl][k] exists + rt[compl][k] = clone(v) + end + else + rt[compl] = clone(t) + end + end + www = nil + end + end + + set_dst(rt[domain], src, dst) + + if optab['www'] then + local s = www_complement(domain) + if not rt[s] then rt[s] = {} end + set_dst(rt[s], src, dst) + end + end } + } + + for line in f:lines() do + ln = ln + 1 + for i = 1, #parsetab do + local t = {line:match(parsetab[i][1])} + if t[1] then + parsetab[i][2](table.unpack(t)) + goto continue + end + end + + error(filename .. ':' .. ln .. ': syntax error', 0) + ::continue:: + end + + if www then + for d,t in pairs(rt) do + local compl = www_complement(d) + if rt[compl] then + for k,v in pairs(t) do + -- FIXME: Error message if rt[compl][k] exists + rt[compl][k] = clone(v) + end + else + rt[compl] = d + end + end + www = nil + end + + _ENV['rt'] = rt +end + +local function load_redirect_table () + local name = os.getenv('HAPROXY_BULKREDIRECT') + if name == nil then + name = '/etc/haproxy/bulkredirect.lua' + end + if name:match('%.lua$') then + rt, www, exact, strippath, stripquery, temporary = dofile(name) + else + local file, err = io.open(name,"r") + if file ~= nil then + local status, err = pcall(load_redirect_file, file, name) + file:close() + if not status then + core.Alert(err) + end + else + core.Alert("can't open " .. name .. ": " .. err) + end + end +end + +-- Load redirects +load_redirect_table() + +-- Register the actions with HAProxy +core.register_action("bulkredirect", {"http-req"}, bulkredirect.request, 0) + |