For most types of rewrite of the body, like removing absolute links, the full body is required in the memory. For performance reasons HAProxy works with limited size buffers. These buffers has limited size and cannot ensure sufficient room to store the full body. HAProxy transfer the body content chunk by chunk. It flush its buffers as soon as possible. This ensure a minimum of latence in responses.
So, the HAProxy design choices ensure a minimum of latence a a controlled amount of memory consumed. These choices are the basics of robust and reliable product. Unfortunately these choices cannot provide the right conditions to rewrite the body.
In other way, its important to understand that rewriting body is a bad practice. Its dangerous because only the producer of the content knows the structure, a rewrite could break this structure. On the other hand, the producer could change the content format. Inaddition the body rewriting consume a lot of resources. So, rewrite the body at your own risk.
Luckily the LUA extensions could help us to bufferize the full body. Its important to understand that this way introduce latency, because it wait for the full server response before sending content to the client. This way introduce also memory consomation because the full body is bufferized through Lua memory. So the memory consumed for an HTTP request cannot be predictible and the memory sizing according with the maxconn is no longer possible.
The concept is writing a proxy in Lua using "services". See the schema below.
The Lua rewrite service must forward request and parse response using core.tcp. Rewriting request doesn't present major difficulties, but parsing response is a little bit more complicated. With a real HTTP client, we must support keep-alive and tranfer-encoding chunked. To avoid these difficulties, we support only HTTTP/1.0 without keepalive. The version 1.0 doesn't include the transfer-encoding chunked.
HAProxy provide parsed HTTP request to the applet running the rewrite service. We must forward the request, changing some data : The HTTP version is forced to HTTP/1.0, the header Connection is set to the value "close". The header "Accept-Encoding" is discarded.
The answer is easy to parse because the body will be transfered at once, and the connection will be closed by HAProxy. Now we can rewrite the body. The received response must be forwarded as applet response. It will be safe to remove the header "Accept-Ranges" to avoid partial requests, the header connection because the connection will be negociated between HAProxy frontend and the client, and the Content-Length because it could be changed after rewriting the body.
core.register_service("rewrite", "http", function(applet)
-- ------------------------------------------------------
--
-- transcode request, force HTTP/1.0 and connection close
-- to avoid transfer-encoding chunked which is hard to parse.
--
-- ------------------------------------------------------
local req = core.concat()
local first
req:add(applet.method)
req:add(" ")
req:add(applet.path)
if applet.qs ~= nil and #applet.qs > 0 then
req:add("?")
req:add(applet.qs)
end
req:add(" HTTP/1.0\r\n")
for name, value in pairs(applet.headers) do
if string.lower(name) ~= "connection" and
string.lower(name) ~= "accept-encoding" then
req:add(name)
req:add(": ")
first = true
for index, part in pairs(value) do
if not first then
req:add(", ")
end
first = false
req:add(part)
end
req:add("\r\n")
end
end
req:add("Connection: close\r\n")
req:add("\r\n")
local tcp = core.tcp()
tcp:connect("unix@/tmp/rewrite.sock")
tcp:send(req:dump())
-- ------------------------------------------------------
--
-- bufferize and parse respoense
--
-- ------------------------------------------------------
local status_line = tcp:receive("*l")
local headers = {}
while true do
local hdr = tcp:receive("*l")
if hdr == nil or hdr == "" then break end
i, j = string.find(hdr, ": ")
local name = string.sub(hdr, 1, i - 1)
local value = string.sub(hdr, j + 1)
if string.lower(name) ~= "accept-ranges" and -- Can't sed ranges
string.lower(name) ~= "connection" and -- Connexion negociated by haproxy
string.lower(name) ~= "content-length" -- Content-Length recalculated
then
headers[name] = value
end
end
local body = tcp:receive("*a")
tcp:close()
-- ------------------------------------------------------
--
-- transform body
--
-- ------------------------------------------------------
body = string.gsub(body, "(>[^<]*)([lL][uU][aA])", "%1<b style=\"color: #ffffff; background: #ff0000;\">%2</b>")
-- ------------------------------------------------------
--
-- forward response
--
-- ------------------------------------------------------
applet:set_status(200)
for name, value in pairs(headers) do
applet:add_header(name, value)
end
applet:add_header("Content-Length", tostring(#body))
applet:start_response()
applet:send(body)
end)
The Lua rewrite service must forward request and parse response using core.httpclient. Unlike core.tcp() method, core.httpclient provides decoded HTTP body, so it is not necessary to transform the request in order to force a response format.
However, header connection must be removed because HAProxy manage the connection, and this not the problem of hte Lua code. The header accept-encoding must be removed to avoid compression. The core.httpclient function doesn't support compression.
core.register_service("rewrite", "http", function(applet)
-- --------------------------------------------
--
-- bufferize request
--
-- --------------------------------------------
local method = applet.method
local path = applet.path
local qs = applet.qs
if qs ~= nil and qs ~= "" then
path = path .. "?" .. qs
end
-- --------------------------------------------
--
-- forward request
--
-- --------------------------------------------
local httpclient = core.httpclient()
-- select function according with method
local cli_req = nil
if method == "GET" then
cli_req = httpclient.get
elseif method == "POST" then
cli_req = httpclient.post
elseif method == "PUT" then
cli_req = httpclient.put
elseif method == "HEAD" then
cli_req = httpclient.head
elseif method == "DELETE" then
cli_req = httpclient.delete
end
-- copy and filter headers
headers = applet.headers
headers["connection"] = nil -- let haproxy manage connection
headers["accept-encoding"] = nil -- remove this header to avoid compression
local host = headers["host"]
if host ~= nil then
host = host[0]
else
host = "www"
end
-- execute requests
local response = cli_req(httpclient, {
url = "http://" .. host .. path,
headers = applet.headers,
body = applet:receive(),
dst = "unix@/tmp/proxy.sock"
})
-- extract body
local body = response.body
-- --------------------------------------------
--
-- Trandform body
--
-- --------------------------------------------
body = string.gsub(body, "(>[^<]*)([lL][uU][aA])", "%1<b style=\"color: #ffffff; background: #ff0000;\">%2</b>")
-- --------------------------------------------
--
-- forward response
--
-- --------------------------------------------
applet:set_status(response.status)
local name
local value
for name, value in pairs(response.headers) do
local part
local index
if string.lower(name) ~= "content-length" and
string.lower(name) ~= "accept-ranges" and
string.lower(name) ~= "transfer-encoding" then
for index, part in pairs(value) do
applet:add_header(name, part)
end
end
end
applet:start_response()
applet:send(body)
end)
With the two previous Lua examples core.tcp() and core.httpclient()
global lua-load-per-thread rewrite.lua defaults mode tcp timeout connect 10s timeout client 1m timeout server 1m frontend main_frontend bind *:5678 mode http acl is_static path -m end .jpg .js .css .png http-request use-service lua.rewrite if !is_static use_backend application_backend frontend local_frontend mode http bind unix@/tmp/rewrite.sock use_backend application_backend backend application_backend mode http server srv3 51.15.182.151:443 maxconn 10 ssl verify none