Use Lua to add fetches, converters, actions, services and tasks to HAProxy.
Did you know that HAProxy embeds the Lua scripting language, which you can use to add new functionality?
HAProxy features an extremely powerful and flexible configuration language and gives you the building blocks you need to handle many complex use cases. However, at certain times, you may want to extend HAProxy to meet a unique scenario that isn’t addressed. While HAProxy itself is written in the C programming language, and you can extend it with C—contributions are appreciated—for many of us, starting with a scripting language is a much easier proposition.
Lua is a lightweight programming language that was created by a team of researchers at the Pontifical Catholic University in Rio de Janeiro, Brazil. The meaning of its name is Moon in Portuguese. It’s implemented in C, making it portable and embeddable and you’ll often find it used for scripted add-ons and applications, especially for game development. It’s perfect for extending HAProxy! The syntax is also short and concise, which makes it a joy to use. The official Lua site links to many resources that will help you get up to speed on its use.
The purpose of this blog post is to show you how to use Lua to extend HAProxy. We’ll explain what’s possible to do, and what’s not, or at least maybe not recommended. Sometimes you might even prototype new HAProxy functionality with Lua and then decide whether you want to reimplement it in C for speed—although Lua is pretty fast for a scripting language!
We’ll demonstrate some code examples, but see our Lua code repository for more context. Before trying the examples, make sure that your HAProxy binary was compiled with Lua support. Lua first appeared in HAProxy 1.6, but we recommend that you upgrade to the latest version if possible. Use the following command to check whether Lua support is included:
$ haproxy -vv | grep Lua | |
Built with Lua version : Lua 5.3.5 |
Lua Says: Hello HAProxy
To respect the tradition, we’ll start with the simplest possible script. This will give us an opportunity to show you how to load your scripts so that HAProxy can use them. Open your favorite text editor and create a file named hello.lua. Add the following line:
core.Debug("Hello HAProxy!\n") |
Then, edit your HAProxy configuration, adding a lua-load
directive in the global
section:
global | |
lua-load /path/to/hello.lua |
As you no doubt guessed, lua-load
loads a Lua file when HAProxy starts or is reloaded. Try it out by invoking HAProxy from the command line, like this:
$ haproxy -d -f /etc/haproxy/haproxy.cfg |
You should see Hello HAProxy! printed to the screen. You can have multiple lua-load
directives. They will be executed sequentially. You can also use the require
function to load other Lua files from within your script, like you would for any standard Lua project.
The example above uses the Debug
function from the core class. When writing Lua scripts that read input or write output, it’s usually a good idea to use the classes provided by HAProxy, which are compatible with HAProxy’s non-blocking architecture, rather than the standard Lua input/output functions. So, we’ve used the core Lua class to write something to the HAProxy log.
It is safe, however, to read and write to files using the standard Lua functions during the script’s initialization phase. For example, you could add a register_init
function that does this, as we’ve done in the JWT library for HAProxy.
In addition to lua-load
, there are a few tuning directives that can be added in the global
section. Search for the keyword tune.lua in the HAProxy configuration manual.
HAProxy Extension Points
When you use Lua with HAProxy, your scripts get access to a library of classes specific to the task of extending HAProxy. This allows you to add any of the following:
Fetches
Converters
Actions
Services
Tasks
You can use these to hook into different parts of the processing pipeline. We’ll cover all of the above, describing related helper classes.
Fetches
A fetch is a piece of information about a connection, request, or some internal state provided by HAProxy. For example, the src
fetch method returns a client’s source IP address. Fetches are valuable when making decisions, such as where to route a request, whether to deny a client, or whether to process the message in a special way such as by compressing it. We’ve covered using fetches in our blog post Introduction to HAProxy ACLs.
With Lua, you can create your own. Generally speaking, you can’t return totally new information with a Lua-defined fetch, but you can combine existing methods and fetches to create something new and useful. The syntax for creating a fetch method in Lua is the following:
local function foo(txn, [param1], [param2], [etc.]) | |
-- perform logic here | |
end | |
core.register_fetches("foo_fetch", foo) |
You’d use the fetch in your HAProxy configuration as lua.foo_fetch. The foo function is passed a txn object, followed by optional input parameters that you can pass to the fetch. You can use txn to call other fetches or converters, use channels, and so on, from inside your function. The txn class exposes almost all standard HAProxy fetches as:
txn.f:NAME_OF_FETCH() |
The txn class exposes many other useful objects and methods, either for the current request/response or for more generic HAProxy functionality. The following table lists some that are particularly interesting:
Function or object | What it does |
txn.req and txn.res | Get the request and response channels. |
txn.f and txn.c | Returns collections of existing fetches and converters. |
txn.http | A class that’s exposed in proxies using HTTP mode. This helper class can be used to manipulate request/response attributes, such as the request line, method, path, URI, etc. This saves you from having to implement HTTP parsing yourself. |
txn.get_var(name) and txn.set_var(name, value) | Get or set a variable in HAProxy. |
txn.set_priv(data) and txn.get_priv() | Gets or sets data that you want to pass to other parts of your Lua code during a single transaction. It’s similar to setting a variable with transaction scope. |
Use the register_fetches
function to add your new fetch to HAProxy. Its first parameter defines what the fetch will be called in the configuration and the second maps to the function that will be invoked.
To give an example of a custom fetch method, let’s create one that compares two variables. Create a file called greater_than.lua and add the following code:
core.register_fetches("greater_than", function(txn, var1, var2) | |
local number1 = tonumber(txn:get_var(var1)) | |
local number2 = tonumber(txn:get_var(var2)) | |
if number1 > number2 then return true | |
else return false end | |
end) |
This code gets the value of two HAProxy variables, whose names are passed in as var1 and var2, by using the txn.get_var
function. It then checks whether the first holds a number that’s greater than the second’s and, if so, returns true. Otherwise, it returns false. Also, notice how you can define the function inline with the core.register_fetches
function. You might use this to compare a client’s connection rate with a threshold when both are stored as variables.
global | |
lua-load /path/to/greater_than.lua | |
frontend fe_main | |
bind :80 | |
# Store the threshold in a variable | |
http-request set-var(txn.connrate_threshold) int(100) | |
stick-table type ip size 1m expire 10s store conn_rate(10s) | |
http-request track-sc0 src | |
# Store the connection rate in a variable | |
http-request set-var(txn.conn_rate) src_conn_rate | |
# Deny if rate is greater than threshold | |
http-request deny if { lua.greater_than(txn.conn_rate,txn.connrate_threshold) -m bool } | |
default_backend be_servers |
Note that you can also compare two variables in a less obvious way by using an ACL statement like this, without Lua:
if { var(txn.connrate_threshold),sub(txn.conn_rate) -m int lt 0 } |
Next is a more advanced example that makes uses both the txn.f class to fetch a frontend name and the server class’ get_stats function to get statistics. This custom fetch tells you which backend has the fewest active sessions. Create a file called least_sessions.lua and add the following code:
local function backend_with_least_sessions(txn) | |
-- Get the frontend that was used | |
local fe_name = txn.f:fe_name() | |
local least_sessions_backend = "" | |
local least_sessions = 99999999999 | |
-- Loop through all the backends. You could change this | |
-- so that the backend names are passed into the function too. | |
for _, backend in pairs(core.backends) do | |
-- Look at only backends that have names that start with | |
-- the name of the frontend, e.g. "www_" prefix for "www" frontend. | |
if backend and backend.name:sub(1, #fe_name + 1) == fe_name .. '_' then | |
local total_sessions = 0 | |
-- Using the backend, loop through each of its servers | |
for _, server in pairs(backend.servers) do | |
-- Get server's stats | |
local stats = server:get_stats() | |
-- Get the backend's total number of current sessions | |
if stats['status'] == 'UP' then | |
total_sessions = total_sessions + stats['scur'] | |
core.Debug(backend.name .. ": " .. total_sessions) | |
end | |
end | |
if least_sessions > total_sessions then | |
least_sessions = total_sessions | |
least_sessions_backend = backend.name | |
end | |
end | |
end | |
-- Return the name of the backend that has the fewest sessions | |
core.Debug("Returning: " .. least_sessions_backend) | |
return least_sessions_backend | |
end | |
core.register_fetches('leastsess_backend', backend_with_least_sessions) |
This code will loop through all of the backends that start with the same letters as the current frontend, for example finding the backends www_dc1 and www_dc2 for the frontend www. It will then find the backend that currently has the fewest sessions and return its name.
Use a lua-load
directive to load the file into HAProxy. Then, add a use_backend
line to your frontend to route traffic to the backend that has the fewest, active sessions.
global | |
lua-load /path/to/least_sessions.lua | |
frontend www | |
bind :80 | |
use_backend %[lua.leastsess_backend] | |
backend www_dc1 | |
balance roundrobin | |
server server1 192.168.10.5:8080 check maxconn 30 | |
backend www_dc2 | |
balance roundrobin | |
server server1 192.168.11.5:8080 check maxconn 30 |
Converters
Sometimes, when a fetch returns a piece of information, you need it transformed before you can use it in a practical way. If the existing HAProxy converters are not enough, you can use Lua for that transformation. The syntax for creating a converter is the following:
local function foo(value) | |
-- perform logic here | |
end | |
core.register_converters("foo_conv", foo) |
Use the register_converters
function to inform HAProxy that your converter exists. Its first argument is the name you’d like to use in your HAProxy configuration and the second is the function to invoke.
For the next example, create a new file called urlencode.lua. Within it, add the following code, which defines a converter that URL encodes a passed-in value:
local char_to_hex = function(c) | |
return string.format("%%%02X", string.byte(c)) | |
end | |
local function urlencode(url) | |
if url == nil then | |
return | |
end | |
url = url:gsub("\n", "\r\n") | |
url = url:gsub("([^%w ])", char_to_hex) | |
url = url:gsub(" ", "+") | |
return url | |
end | |
core.register_converters("urlencode", urlencode) |
You might use this to encode a URL before redirecting a client to it. The converter can be referenced in your configuration as lua.urlencode. Update your configuration so that it contains the following:
global | |
lua-load /path/to/urlencode.lua | |
frontend fe_main | |
bind :80 | |
# URL encode the company name and store it in variable. | |
# In practice, you could get a company ID from a cookie | |
# or URL parameter and then find the name via a map file. | |
http-request set-var(req.company) str("Vinyl & Rare Music"),lua.urlencode | |
# Redirect to new URL | |
http-request redirect prefix http://%[req.hdr(Host)]/%[var(req.company)] if { var(req.company) -m found } { path / } | |
default_backend be_servers |
For this example, we use a hardcoded string, Vinyl & Rare Music, as the company name, but you could also get it from a map file. After encoding the name, it’s set as the URL path and the user is redirected to http://192.168.50.20/Vinyl+%26+Rare+Music.
Actions
Actions give you a way to modify L4 and L7 messages. With actions you can accept or reject TCP connections, add HTTP headers with dynamic values (such as the Access-Control-Allow-Origin and Access-Control-Allow-Methods headers needed for CORS), and rewrite the request or response’s URL path, query parameters, or HTTP status. You’d use the following directives to invoke your custom action in your HAProxy configuration:
tcp-request connection <action>
tcp-request content <action>
tcp-response content <action>
http-request <action>
http-response <action>
We touched upon Lua actions in our previous blog post Using HAProxy as an API Gateway, Part 2, so make sure you check it out. Another nice example is haproxy-auth-request from Tim Düsterhus. The Lua syntax for defining an action is:
local function foo(txn) | |
-- perform logic here | |
end | |
core.register_action("foo_action", { 'tcp-req', 'tcp-res', 'http-req', 'http-res' }, foo, 0) |
Use register_action
to add your new action to HAProxy. Its first parameter is the name of the action. The second is a list of applicable directives where this action can be used. Typically, you would limit your action to apply to either TCP or HTTP and also to either a request or a response. The third parameter identifies the function to invoke, which, in this case, is foo. The final parameter is the number of additional arguments that foo accepts. In this case, it’s zero. You would invoke the action like this:
http-request lua.foo |
If you need to return a value to HAProxy after the action is invoked, you can set a variable. Then, you can reference that variable in ACL statements.
In contrast to converters and fetches, your Lua actions can and will often use socket functions, which allow them to communicate with external services. The Socket class is a replacement for the standard Lua Socket class and is compatible with HAProxy’s non-blocking nature. When you want to use socket functions in your actions, you must use this class.
An instance of the Socket class is retrieved by calling core.tcp()
. Then you can use the following methods:
Function | What it does |
Socket.connect(address, [port]) | Connects to the specified address and port. |
Socket.connect_ssl(address, port) | Connects to the specified address and port using TLS. |
Socket.close() | Closes the open socket. |
Socket.settimeout(value, [mode]) | Sets the socket timeout. This should be lower than the Lua session and service timeouts. |
Socket.send(data, [start], [end]) | Sends data over the socket connection. |
Socket.receive([pattern], [prefix]) | Receives data over the socket connection. |
To give you an example, let’s say that you wanted to extend HAProxy by checking the client’s source IP against a registry of banned IPs. Our action will make a remote call to an IP Checker service and then set a variable, req.blocked, to true if the client should be denied access. Note that you can also define lists of whitelisted or blacklisted IP addresses by using HAProxy ACL files, but this example allows us to demonstrate the socket functions.
The IP Checker service will be a Python script that returns, randomly, either allow or deny. Remember, these examples are available in our code repository. First, install Python and Flask:
$ sudo apt install -y python3 python3-flask |
Add a file named ipchecker.py with the following code:
import random | |
from flask import Flask | |
app = Flask(__name__) | |
@app.route("/<address>") | |
def check(address=None): | |
myrandom = random.randint(0, 1) | |
if myrandom > 0: | |
return 'allow' | |
else: | |
return 'deny' |
Then run the application by using the flask run command:
$ export FLASK_APP=ipchecker.py | |
$ flask run | |
* Serving Flask app "ipchecker" | |
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit) |
In another window, try making a request to the IP Checker service using curl. Append an IP address as the path. It should return either allow or deny.
$ curl http://127.0.0.1:5000/192.168.50.1 |
Next, let’s add the Lua code that will call this service. Create a file called ipchecker.lua and add the following code:
local function check_ip(txn, addr, port) | |
if not addr then addr = '127.0.0.1' end | |
if not port then port = 5000 end | |
-- Set up a request to the service | |
local hdrs = { | |
[1] = string.format('host: %s:%s', addr, port), | |
[2] = 'accept: */*', | |
[3] = 'connection: close' | |
} | |
local req = { | |
[1] = string.format('GET /%s HTTP/1.1', tostring(txn.f:src())), | |
[2] = table.concat(hdrs, '\r\n'), | |
[3] = '\r\n' | |
} | |
req = table.concat(req, '\r\n') | |
-- Use core.tcp to get an instance of the Socket class | |
local socket = core.tcp() | |
socket:settimeout(5) | |
-- Connect to the service and send the request | |
if socket:connect(addr, port) then | |
if socket:send(req) then | |
-- Skip response headers | |
while true do | |
local line, _ = socket:receive('*l') | |
if not line then break end | |
if line == '' then break end | |
end | |
-- Get response body, if any | |
local content = socket:receive('*a') | |
-- Check if this request should be allowed | |
if content and content == 'allow' then | |
txn:set_var('req.blocked', false) | |
return | |
end | |
else | |
core.Alert('Could not connect to IP Checker server (send)') | |
end | |
socket:close() | |
else | |
core.Alert('Could not connect to IP Checker server (connect)') | |
end | |
-- The request should be blocked | |
txn:set_var('req.blocked', true) | |
end | |
core.register_action('checkip', {'http-req'}, check_ip, 2) |
Notice that we’re using the function core.tcp()
to get an instance of the Socket class. We can use it to connect to the Python service and send requests. Once we have the response, we set a variable named req.blocked to true or false, depending on whether the content of the response was allow. You are also able to access fetch methods, such as txn.f:src()
to get the client’s source IP address. Next, update your HAProxy configuration so that it loads the Lua file and uses our new action.
global | |
lua-load /path/to/ipchecker.lua | |
frontend fe_main | |
bind :80 | |
http-request lua.checkip 127.0.0.1 5000 | |
http-request deny if { var(req.blocked) -m bool } | |
default_backend be_servers |
Requests will now be randomly denied. Notice that we’re passing two parameters to lua.checkip: the IP address and port of the Python Flask service. When we added the action using core.register_action
, we set the last parameter, which declares the numbers of parameters that the action expects, to two.
Services
With services, also known as applets, you can ask HAProxy to deliver a requests to your Lua script, which will then generate the response. No backend servers will be contacted. This is handy if you want to add some application logic to your proxy. Or, maybe you long for the old days of Apache + mod_php scripts. Who knows!
Like actions, services are free to use socket functions (subject to HAProxy timeouts). The syntax for creating services with Lua is the following:
local function foo(applet) | |
-- perform logic here | |
end | |
core.register_service("foo_name", "[mode]", foo) |
A service can be invoked from your HAProxy configuration by passing its name to http-request use-service
, as shown:
frontend fe_proxy | |
http-request use-service lua.foo_name |
The mode passed to core.register_service
can be either http or tcp. Depending on that, the function, in this case foo, will receive an AppletHTTP or AppletTCP object, respectively. The applet objects provide you with enough functionality to create mini services with Lua.
The AppletHTTP class has attributes and methods useful for working with an HTTP request and response. The following table outlines them:
Function or object | What it does |
AppletHTTP.method | Gets the request method. |
AppletHTTP.path | Gets the request path. |
AppletHTTP.qs | Gets the query string. |
AppletHTTP.length | Gets the request body length. |
AppletHTTP.headers | Gets a table of request headers (zero-based table). |
AppletHTTP.receive([size]) | Reads the data from the request body. |
AppletHTTP.add_header(name, value) | Adds a header to the response. |
AppletHTTP.set_status(code, [reason]) | Sets the response status. |
AppletHTTP.start_response() | Starts the response (send the headers). |
AppletHTTP.send(message) | Sends the response body. |
The AppletTCP class is simpler than AppletHTTP. The following table outlines its available functions:
Function | What it does |
AppletTCP.receive([size]) | Reads data from a TCP stream. |
AppletTCP.getline() | Reads a single line from a TCP stream. |
AppletTCP.send(message) | Sends data on a TCP stream. |
To demonstrate a custom service, we’ll create one that returns Magic 8-ball answers directly from HAProxy. Create a file named magic8ball.lua and add the following code to it:
local function magic8ball(applet) | |
-- If client is POSTing request, receive body | |
-- local request = applet:receive() | |
local responses = {"Reply hazy", "Yes - definitely", "Don't count on it", "Outlook good", "Very doubtful"} | |
local myrandom = math.random(1, #responses) | |
local response = string.format([[ | |
<html> | |
<body>%s</body> | |
</html> | |
]], responses[myrandom]) | |
applet:set_status(200) | |
applet:add_header("content-length", string.len(response)) | |
applet:add_header("content-type", "text/html") | |
applet:start_response() | |
applet:send(response) | |
end | |
core.register_service("magic8ball", "http", magic8ball) |
In your HAProxy configuration, use http-request use-service
to invoke the service if the URL path is /magic. Otherwise, route traffic normally.
global | |
lua-load /path/to/magic8ball.lua | |
frontend fe_main | |
bind :80 | |
http-request use-service lua.magic8ball if { path /magic } | |
default_backend be_servers |
Now, when a client makes a request to /magic, they’ll get a random Magic 8-ball response. It’s interesting to see that, unlike a request to a backend, which records the backend name in the access log, when the service is invoked the log shows <lua.magic8ball> as the backend name.
fe_main fe_main/<lua.magic8ball> 0/0/0/0/0 200 125 - - ---- 1/1/0/0/0 0/0 "GET /magic HTTP/1.1" |
Tasks
Last but certainly not least, you can add functions that run in the background, in the spirit of cron jobs. The syntax is very simple:
local function foo() | |
-- perform logic here | |
end | |
core.register_task(foo) |
You might utilize tasks to write complex health checks, for example. However, as a simple example, this will write Doing some task work! to the HAProxy log every 10 seconds:
local function log_work() | |
while true do | |
core.Debug("Doing some task work!\n") | |
core.msleep(10000) | |
end | |
end | |
core.register_task(log_work) |
Save this as log_work.lua and then use lua-load
to add it to HAProxy. You don’t need to add anything other than a lua-load
directive to register and start a task. From your task, you can use any variables that were declared in the script’s global scope. You can also reference HAProxy variables and use socket functions to connect to external services.
Conclusion
In this blog post, we demonstrated how to expand HAProxy’s functionality by writing custom Lua code. Methods exist for creating your own fetches, converters, actions, services, and tasks. This means that you can extract new combinations of data from requests, transform that data, communicate with external services, respond directly from HAProxy, and create long-running, background tasks. Opportunities are plentiful for finding new and interesting ways to extend your load balancer.
Want to keep up to date on similar topics? Subscribe to this blog! You can also follow us on Twitter and join the conversation on Slack. HAProxy Enterprise includes a robust and cutting-edge codebase, an enterprise suite of add-ons, expert support, and professional services. It also includes modules that have been developed to extend HAProxy in complex and powerful ways. Want to learn more? Contact us today and sign up for a free trial.
Subscribe to our blog. Get the latest release updates, tutorials, and deep-dives from HAProxy experts.