HAProxy: Use LUA to rewrite the body

Why there are not an easy way to rewrite body

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.

How to rewrite the body anyway

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.

With core.tcp()

how it works

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.

Lua code

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)

With core.httpclient()

how it works

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.

Lua code

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)

HAProxy configuration file

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