Tuesday, June 5, 2018

HAProxy & Lua: Store information in signed cookies

In some cases it is convenient to store data in cookie. This data must be signed by server for preventing modification by an attacker or something like that.

In other way, HAProxy require Lua-5.3. In many cases, this requirement implies usage of a Lua library specifically packaged because current Linux distributions doesn't embed Lua-5.3. Sometimes, usefull Lua libraries are not available with your distro, and you don't want to use heavy solutions like luarocks. This article explain also compile some modules:

  • base64
  • crypto
  • cjson

Lua compilation

The first step is compiling Lua. This library is very easy to compile, it doesn't have a lot of dependencies nor a ./configure which add compilation difficulties on manny Linux distributions.

The Lua dependencies are "libreadline". Note that this dependency is required for the command line Lua interpretor. If you not have this library, the Lua library will be compiled but not the command line interpretor. This condition doesn't impact the embedding with HAProxy.

Just download le last Lua version (here 5.3.4), extract data from the tarball, enter the directory and type "make":


That's all ! The installation is not required.  We considers that the Lua directory is store in the variable "$LUA_DIR".

base64 compilation

This extension is very useful, and very easy to compile. We download the archive and compile the library with right path. The command are:


That's done ! Le library is available in the source directory and it is called base64.so.

Note that the compilation embed Lua test of this new library. On my screen, it displays:

$LUA_DIR/src/lua test.lua
base64 library for Lua 5.3 / Aug 2012

0 true  
1 true TA== L
2 true THU= Lu
3 true THVh Lua
4 true THVhLQ== Lua-
5 true THVhLXM= Lua-s
6 true THVhLXNj Lua-sc
7 true THVhLXNjcg== Lua-scr
8 true THVhLXNjcmk= Lua-scri
9 true THVhLXNjcmlw Lua-scrip

testing prefix 0
testing prefix 1
testing prefix 2
testing prefix 3

THVhLXNjcmlwdGluZy1sYW5ndWFnZQ== Lua-scripting-language 22
???h???jcmlwd?lu?y1s??5nd??n??== nil
THV?LXN??????G??Z?1?YW5??WF?ZQ== nil
===h===jcmlwd=lu=y1s==5nd==n====  0
THV=LXN======G==Z=1=YW5==WF=ZQ== Lu 2

Note that, the website whoch provides the base64 library provides also a lot of useful libraries: http://webserver2.tecgraf.puc-rio.br/~lhf/ftp/lua/index.html

lua-cjson compilation

This library is also easy to compile. It provides very useful JSON converters functions. There is the way to compile:


Some warning will be display, but you can ignore it. The Lua library is available in the source directory with the name "cjson.so".

This library doesn't provides easy-to-use tests. You can test with this  code written in the file "test.lua" stored in the source directory
local cjson = require("cjson")

local struct = {
 a = "a",
 b = {
  33, 34
 },
 c = "string"
}

enc = cjson.encode(struct)
print(enc)

dec = cjson.decode(enc)
print(dec['c'])

And executes the following command line:

user@host:~/build/lua-cjson-2.1.0$ $LUA_DIR/src/lua test.lua
{"b":[33,34],"c":"string","a":"a"}
string

crypto compilation

This library is very annoying to compile because it use all the compilation assistants: automake, pkg-config, autoconf, libtool, luarocks, and it very difficult to find the right option to compile with a Lua source installed in a non-standard directory.

In other way, look in the directory "src". You will find only one file ! All this crap is used for compiling only one file... :-(

One point for the library: It seems to be deprecated, But it provides required functions.

Default Lua build doesn't provide "*pkg*" file, the option LUA_CFLAGS seems to be ignored, ... After many time of research and try, I decide to use a non conventional way to compile this library. There is my solution:

First, we need to apply a little patch because this library is written for Lua version < 5.3.x. It easy: edit the file src/lcrypto.c and line 91, replace "luaL_checkint" by "luaL_checkinteger".

  • wget https://github.com/mkottman/luacrypto/archive/0.3.2.zip -O luacrypto-0.3.2.zip
  • unzip luacrypto-0.3.2.zip
  • cd luacrypto-0.3.2
  • gcc -I$LUA_DIR/src -fPIC -g -O2 -DLUA_COMPAT_APIINTCASTS -c -o src/lcrypto.o src/lcrypto.c
  • gcc -fPIC -shared -o crypto.so src/lcrypto.o -lcrypto

It works. About 25 various build files for an effective work of two fucking compilation lines. Great !

We will test the library, but only the load. We considers if the library is loaded, it will works perfectly.

local crypto = require("crypto")

And executes the Lua code:
user@host:~/build/luacrypto-0.3.2$ $LUA_DIR/src/lua test.lua 
table: 0x1823160
It works !

Now we have 3 .so files which contains basic functions for out main goal. Just copy these files in the same directory that the haproxy configuration and the Lua file. These library will be move in a conventional directory later.

Crypting cookies with HAProxy and Lua

The following Lua program shows how encoding Lua data and/or HAProxy variables in a signed cookie. The values will be serialised using json and them base64 encoded. The JSON string is signed  with an SHA-256 HMAC

We are just 4 functions:

  • cookie_encode: Get a Lua struct, encode it and generate signature, return cookie compatible string.
  • cookie_decode: decode the cookie and verify the signature, and return a Lua struct.
  • one HAProxy action for retrieving the cookie
  • one HAProxy action for generating the cookie
This is the code:
cjson  = require("cjson")
base64 = require("base64")
crypto = require("crypto")

-- serialize and sign this data
function cookie_encode(data, secret)
 local cookie_json = cjson.encode(cookie_data)
 local cookie_base64 = base64.encode(cookie_json)
 local sign_bin = crypto.hmac.digest("sha256", cookie_json, secret)
 local sign_base64 = base64.encode(sign_bin)
 return cookie_base64 .. "@" .. sign_base64
end

-- deserialize and check cookie
function cookie_decode(data, secret)
 local index = string.find(data, "@")
 if index == nil then return false, "bad-format" end
 local cookie_base64 = string.sub(data, 1, index - 1)
 local cookie_json = base64.decode(cookie_base64)
 if cookie_json == nil then return false, "bad-format" end
 local sign_base64 = string.sub(data, index + 1)
 local sign_bin = base64.decode(sign_base64)
 if cookie_json == nil then return false, "bad-format" end
 local sign_cmp = crypto.hmac.digest("sha256",cookie_json, secret)
 if sign_cmp ~= sign_bin then return false, "bad-sign" end
 local st, cookie_data = pcall(cjson.decode, cookie_json)
 if st == false then return false, "bad-format" end
 return true, cookie_data
end

-- Secret key
secret = "s3cr3t"

-- Create cookie and set it
core.register_action("set-cookie", {"http-res"}, function (txn)
 local cookie_data = {
  date = os.date("%Y-%m-%d %H:%M:%S"),
  var1 = txn:get_var("txn.var1"),
  var2 = txn:get_var("txn.var2")
 }
 -- Generate cookie
 local cookie = cookie_encode(cookie_data, secret)
 txn.http:req_add_header("Set-Cookie", "MYDATA="..cookie)
end)

-- Decode cookie ans restore vars
core.register_action("get-cookie", {"http-req"}, function(txn)
 local cookie = txn.sf:req_cook_val("MYDATA")
 local status, cookie_dec = cookie_decode(cookie, secret)
 if status == false then return end
 txn:set_var("txn.var1", cookie_dec.var1)
 txn:set_var("txn.var2", cookie_dec.var2)
end)

You must add tow lines in the HAProxy configuration:

  • http-request lua.get-cookie
  • http-response lua.set-cookie

The HAProxy transaction variables txn.var1 and txn.var2 will be encoded, signed and sent as cookie.

Do not hesitate to send feedback

Wednesday, February 14, 2018

Lua & JSON: libraries & performances

JSON is a great encoding language: It is very simple and really human readable. It embed basic type and complex type are made from these basics. It is embedded in many languages.

Compared to ASN.1/DER or some TLV encoding/decoding, JSON is a little bit CPU expansive, and the data  encoded uses more bytes. Compared to XML, it is quick and light :-). So it is a good compromise between all the following criteria : size, easy to encode/decode, human readable.

Here the encoding specifications: http://json.org/

This encoding is very famous and each language support it. Why an article for Lua ? Lua provides many implementations. Some implementation are very CPU expansive, and I will provides some tests, and a way to use JSON with Lua in HAProxy without sacrificing the response times.

Here the list of Lua JSON libraries: http://lua-users.org/wiki/JsonModules. There is a lot of libraries. I can't test each one. I cheat testing two library I already tested.


DKJSON is a pure Lua implementation. Its main advantage is to be compilation less and all the libary is embedded in one file. CJSON is a C bindings for Lua, its main advantage is a quick encoding/decoding.

Compiling CJSON


You can find this library already compiled on your distro, or maybe using LuaRocks (I never this system, and I'm afraid to discover it). It is very simple to compile it yourself. The package doesn't contains "./configure" or complicated things. Just a Makefile with some path options.

Why compiling the package yourself ? HAProxy uses Lua 5.3, and Lua 5.3 is not yet available on all distro.
$ wget https://www.kyne.com.au/~mark/software/download/lua-cjson-2.1.0.tar.gz
$ tar xf lua-cjson-2.1.0.tar.gz
$ cd lua-cjson-2.1.0
$ make LUA_INCLUDE_DIR=/home/thierry/temp/lua-5.3.4/src/ 
cc -c -O3 -Wall -pedantic -DNDEBUG  -I/home/thierry/temp/lua-5.3.4/src/ -fpic -o lua_cjson.o lua_cjson.c
In file included from lua_cjson.c:43:0:
/home/thierry/temp/lua-5.3.4/src/lua.h:93:9: warning: ISO C90 does not support 'long long' [-Wlong-long]
/home/thierry/temp/lua-5.3.4/src/lua.h:96:9: warning: ISO C90 does not support 'long long' [-Wlong-long]
cc -c -O3 -Wall -pedantic -DNDEBUG  -I/home/thierry/temp/lua-5.3.4/src/ -fpic -o strbuf.o strbuf.c
cc -c -O3 -Wall -pedantic -DNDEBUG  -I/home/thierry/temp/lua-5.3.4/src/ -fpic -o fpconv.o fpconv.c
That's all ! Note that we don't care about warning. The .so library is available

Compare the to libraries


I present a comparative protocol. The way is encoding Lua struct as JSON message in a loop. We perform this encoding about 500 000 times.

Why 500 000 ? because I do some tests: high value gives more precision for the calculus of one encode() or one decode() function. Value highest than 500 000 requires a long test (more than one minute) and I'm not patient :-). 500 000 is a good intermediate value.

There is the test code:
package.cpath = package.cpath .. ";lua-cjson-2.1.0/?.so"

require('print_r')
cjson = require('cjson')
dkjson = require('dkjson')

lua = {}
lua['entry1'] = 123
lua['entry2'] = 456.789
lua['entry3'] = "string"
lua['entry4'] = {'a', 'b', 'c', 'd', 'e', '...'}
lua['entry5'] = {}
lua['entry5']['entry1'] = 1123
lua['entry5']['entry2'] = 1456.789
lua['entry5']['entry3'] = "Another string"
lua['entry5']['entry4'] = {'a', 'b', 'c', 'd', 'e', '...'}

enc = "{\"entry4\":[\"a\",\"b\",\"c\",\"d\",\"e\",\"...\"],\"entry2\":456.789,\"entry5\":{\"entry3\":\"Another string\",\"entry4\":[\"a\",\"b\",\"c\",\"d\",\"e\",\"...\"],\"entry2\":1456.789,\"entry1\":1123},\"entry1\":123,\"entry3\":\"string\"}"

-- Validate encoding:
--print(cjson.encode(lua))
--print(dkjson.encode(lua))

-- Validate decoding
--print_r(cjson.decode(enc))
--print_r(dkjson.decode(enc))

for i = 0, 500000 do
        -- Test CJSON
        --local j = cjson.encode(lua)

        -- Test DKJSON
        --local j = dkjson.encode(lua)
end
Note the first line. We modify the require.cpath variable for including the cjson library directory in the search path.

First we validate the right encoding for the two libraries. We uncomment the lines starting by print(..). The script return two independent JSON messages. Note the flags "-s" with jq. It read independent JSON message on its input and concatenate it an array.
$ ./lua json_test.lua | jq -s .
[
  {
    "entry4": [
      "a",
      "b",
      "c",
      "d",
      "e",
      "..."
    ],
    "entry5": {
      "entry4": [
        "a",
        "b",
        "c",
        "d",
        "e",
        "..."
      ],
      "entry1": 1123,
      "entry2": 1456.789,
      "entry3": "Another string"
    },
    "entry3": "string",
    "entry2": 456.789,
    "entry1": 123
  },
  {
    "entry4": [
      "a",
      "b",
      "c",
      "d",
      "e",
      "..."
    ],
    "entry5": {
      "entry4": [
        "a",
        "b",
        "c",
        "d",
        "e",
        "..."
      ],
      "entry1": 1123,
      "entry2": 1456.789,
      "entry3": "Another string"
    },
    "entry3": "string",
    "entry2": 456.789,
    "entry1": 123
  }
]
Enable decoding test uncommenting print_r(...) lines.
$ ./lua json_test.lua
(table) table: 0x25b65e0 [
    "entry2": (number) 456.789
    "entry5": (table) table: 0x25b66b0 [
        "entry1": (number) 1123.0
        "entry2": (number) 1456.789
        "entry4": (table) table: 0x25b23c0 [
            1: (string) "a"
            2: (string) "b"
            3: (string) "c"
            4: (string) "d"
            5: (string) "e"
            6: (string) "..."
        ]
        "entry3": (string) "Another string"
    ]
    "entry3": (string) "string"
    "entry1": (number) 123.0
    "entry4": (table) table: 0x25b6620 [
        1: (string) "a"
        2: (string) "b"
        3: (string) "c"
        4: (string) "d"
        5: (string) "e"
        6: (string) "..."
    ]
]
(table) table: 0x25caf80 [
    METATABLE: (table) table: 0x25cae70 [
        "__jsontype": (string) "object"
    ]
    "entry2": (number) 456.789
    "entry5": (table) table: 0x25cb5f0 [
        METATABLE: (table) table: 0x25cae70 [
            "__jsontype": (string) "object"
        ]
        "entry1": (number) 1123
        "entry2": (number) 1456.789
        "entry4": (table) table: 0x25cb780 [
            METATABLE: (table) table: 0x25caee0 [
                "__jsontype": (string) "array"
            ]
            1: (string) "a"
            2: (string) "b"
            3: (string) "c"
            4: (string) "d"
            5: (string) "e"
            6: (string) "..."
        ]
        "entry3": (string) "Another string"
    ]
    "entry3": (string) "string"
    "entry1": (number) 123
    "entry4": (table) table: 0x25cb090 [
        METATABLE: (table) table: 0x25caee0 [
            "__jsontype": (string) "array"
        ]
        1: (string) "a"
        2: (string) "b"
        3: (string) "c"
        4: (string) "d"
        5: (string) "e"
        6: (string) "..."
    ]
]
We can observe that the DKJSON result provide some metadata about the type of tables.

For comparing the two libraries, we need to comment/uncomment the encode() lines. There is the results:
$ # DKJSON
$ time ./lua json_test.lua 

real    0m18.273s
user    0m18.260s
sys     0m0.000s

$ # CJSON
$ time ./lua json_test.lua 

real    0m2.038s
user    0m2.036s
sys     0m0.000s
And now, compare the decoding performances uncommenting the decode() lines. There is the results:
$ # DKJSON
$ time ./lua json_test.lua 

real    0m26.461s
user    0m26.436s
sys     0m0.004s

$ # CJSON
$ time ./lua json_test.lua 

real    0m1.895s
user    0m1.892s
sys     0m0.000s
Compare the results:
DKJSON CJSONComment
Encoding time measured18.273s2.038sCJSON is 9 times quicly than DKJSON
One encoding (÷ 500000)36.546µs4.072µs
Decoding per second27 363245 339
Decoding time measured26.461s1.895CJSON is 14 times quicly than DKJSON
One encoding (÷ 500000)52.922µs3.79µs
Encoding per second18 896263 852
I thinked that the gap between performances of these two library was higher than these results.

So, Is the speed is not a criteria for your application, you can use DKJSON because it is easier to embbed. If the speed is important, use CJSON. Note that the compilation of CJSON is a one-time action, once is compiled, the usage of the two libraries is the same.

You can test the other JSON libraries with this protocol and share the result in the comments. I will update the array. Note that the result will be not comparable because the test must be performed with the same CPU.

Tuesday, February 13, 2018

HAProxy, Lua & Redis: Connection pool

In the previous post, we see how making simple connection to a Redis database. The performances are a little bit low. Now we will see how making a connection pool with Lua.

The advantages are:

  • Redis initialisation only one time
  • TCP connection  established one time.

This solution is more fast than the previous.

The first step is creating a redis wrapper with initialise and manage the Redis connection pool. This wrapper use the FIFO described here.

One of the big trap of this code is the increment of the number of connection established which is done before the effective establishment. The reason is a little bit tricky. This variable (r.nb_cur) is global (because the object is global) and it is shared by all process trying to establishing connection. When the command tcp:connect() is performed, the Lua give back the hand to HAProxy, and it may execute another Lua process which try to establish another connection.

If the connection accounting is not set before connection, other processes try to establish connection because the accounting is not yet incremented. The result is that the total amount of connections is upper than the maximum value of pool.

There is the code of the redis-wrapper file:
-- redis-wrapper : Add connection pool to the Lua Redis library
-- 
-- Copyright 2018 Thierry Fournier 
--
-- This lib is compliant with HAProxy cosockets
--
package.path  = package.path  .. ";redis-lua/src/?.lua"
require("fifo")

redis_wrapper = {}
redis_wrapper.redis = require("redis")

redis_wrapper.new = function(host, port, pool_sz)
        local r = {}
        r.host = host
        r.port = port
        r.pool = Fifo.new()
        r.nb_max = pool_sz
        r.nb_cur = 0
        r.nb_avail = 0
        r.nb_err = 0
        setmetatable(r, redis_wrapper.meta);
        return r
end

redis_wrapper.meta = {}
redis_wrapper.meta.__index = {}
redis_wrapper.meta.__index.new_conn = function(r)

        -- Limit the max number of connections
        if r.nb_cur >= r.nb_max then
                return nil
        end

        -- Increment the number of connexions before the real
        -- connexion, because the connection does a yield and
        -- another connexion may be started. If the creation
        -- fails decrement the counter.
        r.nb_cur = r.nb_cur + 1

        -- Redis session
        local sess = {}

        -- create and connect new tcp socket
        sess.tcp = core.tcp();
        sess.tcp:settimeout(1);
        if sess.tcp:connect(r.host, r.port) == nil then
                r.nb_cur = r.nb_cur - 1
                return nil
        end

        -- use the lib_redis library with this new socket
        sess.client = redis_wrapper.redis.connect({socket=sess.tcp});

        -- One more session created
        r.nb_avail = r.nb_avail + 1
        return sess

end

redis_wrapper.meta.__index.get = function(r, wait)
        local tspent = 0
        local conn
        while true do

                -- Get entry from pool
                conn = r.pool:pop()
                if conn ~= nil then
                        r.nb_avail = r.nb_avail - 1
                        return conn
                end

                -- Slot available: create new connection
                if r.nb_cur < r.nb_max then
                        conn = r:new_conn()
                        if conn ~= nil then
                                r.nb_avail = r.nb_avail - 1
                                return conn
                        end
                end

                -- no slot available wait a while
                if tspent >= wait then
                        return nil
                end
                core.msleep(50)
                tspent = tspent + 50
        end
end

redis_wrapper.meta.__index.release = function(r, conn)
        r.nb_avail = r.nb_avail + 1
        r.pool:push(conn)
end

redis_wrapper.meta.__index.renew = function(r, conn)
        if conn ~= nil then
                conn.tcp:close()
        end
        r.nb_cur = r.nb_cur - 1
        conn = r:new_conn()
        if conn ~= nil then
                r:release(conn)
        end
end
Now the usage of this code in  the main HAroxy Lua file. Note the function pcall. Is is explain int the previous post.
r_wrap = redis_wrapper.new("127.0.0.1", 6379, 20)

core.register_action("redis-accounting-v2", { "http-req", "http-res", "tcp-req", "tcp-res" }, function(txn)
        local conn
        local ip

        -- Get client information
        ip = txn.sf:src()

        -- Get lib_redis connection. If no connection avalaible, wait a bit.
        conn = r_wrap:get(1000)
        if conn ~= nil then
                local ret = pcall(conn.client.incrby, conn.client, ip, 1)
                if ret == false then
                        r_wrap:renew()
                else
                        r_wrap:release(conn)
                end
        end
end)
The HAProxy configuration file sample
global
        lua-load samples.lua
        stats socket /tmp/haproxy.sock mode 644 level admin
        tune.ssl.default-dh-param 2048

defaults
        timeout client 1m
        timeout server 1m

listen sample6
        mode http
        bind *:10060
        http-request lua.redis-accounting-v2
        http-request redirect location /ok

Benchmark

I bench this solution on my laptop. It have a i7-4600U CPU @ 2.10GHz. 2 core, 4 threads. I reserve one core for haproxy, one thread for the injector, and one thread fr redis. The setup is:
A reference test: With the same HAProxy configuration without the Lua process (# http-request lua.redis-accounting-v2), we reach about 70 000 HTTP request per second with an approximate ratio of CPU consummation 25% user and 75% system. Note that the test is limited by the injector who reach 100% CPU.

The result are quite better than the tests without the connection pool. We reach 33 500 HTTP requests per seconds (8x better).

We show a huge consummation of the CPU "user". The ratio is 66% user for 33% system. The reference is 25% user. I deduct that the gap from 25% to 66% is done by the Lua execution.



I try a last test without the lib Redis. I write himself the Redis protocol without the library. I reuse the connection pool. I alway initialize the Redis library, but I don't use it. Here the new code:
core.register_action("redis-accounting-v2-1", { "http-req", "http-res", "tcp-req", "tcp-res" }, function(txn)
        local conn
        local ip

        -- Get client information
        ip = txn.sf:src()

        -- Get lib_redis connection. If no connection avalaible, wait a bit.
        conn = r_wrap:get(1000)
        if conn ~= nil then
                if conn.tcp:send("*3\r\n$6\r\nINCRBY\r\n$9\r\n127.0.0.1\r\n$1\r\n1\r\n") == nil then
                        conn.tcp:close()
                        r_wrap:renew()
                elseif conn.tcp:receive() == nil then
                        conn.tcp:close()
                        r_wrap:renew()
                end
                r_wrap:release(conn)
        end
end)
The result is 15% better (38k vs 33k). 15% is a good gap, but writing Redis protocol from scratch is complicated. I conclude that the best way for "production" is using Redis library and pools.


Benchmark summary

Method Result Note
Calibration test: without Lua 70 000 req/s 25% user / 75% system, limited by injector
Basic usage of Redis 4 300 req/s 98% user / 2% system
Redis with pools 33 000 req/s 66% user / 33% system
Redis with pool and without Redis library 38 000 req/s 50% user / 50% system

Monday, February 12, 2018

HAProxy, Lua & Redis: Basic usage

This article shows a basic usage of Redis with HAProxy and Lua. This way is absolutely expensive because a network connection is open for each request.

I suppose that a local Redis server is installed.

First step is getting the Redis project. Checkout this project in the directory containing HAProxy Lua scripts. The lin to the libaray and the doc are here: https://github.com/nrk/redis-lua.
$ git clone https://github.com/nrk/redis-lua.git
Cloning into 'redis-lua'...
remote: Counting objects: 1439, done.
remote: Total 1439 (delta 0), reused 0 (delta 0), pack-reused 1439
Receiving objects: 100% (1439/1439), 337.30 KiB | 0 bytes/s, done.
Resolving deltas: 100% (647/647), done.
Checking connectivity... done.
For using this library in Lua script, we must adapt the search Lua library path, and obviously load the package.
package.path  = package.path  .. ";redis-lua/src/?.lua"

redis = require("redis")
Now we want to use redis for doing something. Two attention points:
  • First, we must use HAProxy cosocket in place of LuaSocket package. This is done in the configuration of the connexion. Look for {socket=tcp}
  • Second, the Lua call must be protected. This Redis library doesn't return error: it directly fail :-(. We must catch the fail for a clean termination of the process. Look for pcall(client.incrby, client, ip, 1)
  • Third, never use the quit() functions. This function call the LuaSocket function shutdown() which is not implemented in HAProxy.
We register an action which perform accounting on the IP source.
core.register_action("redis-accounting", { "http-req", "http-res", "tcp-req", "tcp-res" }, function(txn)

        -- create and connect new tcp socket
        local tcp = core.tcp();
        if tcp == nil then
                return
        end
        tcp:settimeout(1);
        if tcp:connect("127.0.0.1", 6379) == nil then
                return
        end

        -- use the redis library with this new socket
        local client = redis.connect({socket=tcp});

        -- Send redis accouting command
        local ip = txn.sf:src()
        pcall(client.incrby, client, ip, 1);

        -- Close connection
        tcp:close()
end)
And the HAProxy configuration
global
        lua-load samples.lua
        stats socket /tmp/haproxy.sock mode 644 level admin
        tune.ssl.default-dh-param 2048

defaults
        timeout client 1m
        timeout server 1m

listen sample5
        mode http
        bind *:10050
        http-request lua.redis-accounting
        http-request redirect location /ok
Now you can test:

$ redis-cli get '127.0.0.1'
(nil)
$ curl -s http://127.0.0.1:10050/
$ redis-cli get '127.0.0.1'
"1"
$ curl -s http://127.0.0.1:10050/
$ redis-cli get '127.0.0.1'
"2"
$ curl -s http://127.0.0.1:10050/
$ redis-cli get '127.0.0.1'
"3"

benchmark

I bench this solution on my laptop. It have a i7-4600U CPU @ 2.10GHz. 2 core, 4 threads. I reserve one core for haproxy, one thread for the injector, and one thread fr redis. The setp is:
A reference test: With the same HAProxy configuration without the Lua process (# http-request lua.redis-accounting), we reach about 70 000 HTTP request per second with an approximate ratio of CPU consummation 25% user and 75% system. Note that the test is limited by the injector who reach 100% CPU.
The results are not surprising:

We are limited by the HAproxy CPU. The consomation is about 98% user for the HAProxy process. Redis and the injector does nothing: about 15% cpu fr redis and 10% for the injector.

HAProxy process 4300 requests / second. HAProxy is very slow because the lib Redis is initialized to each request, the initialization takes a lot of CPU. In other way, the TCP connection is also initialized for each connection.



HAProxy & Lua: Fifo and asynchronous actions

In some cases, it will be great to process asynchronously some task. Maybe our email server is very slow and you dont want to add this server processing time to the response time of the HTTP request.

The solution is provided in two parts:

  • A Lua FIFO
  • An asychronous action action using tasks and the FIFO.

The FIFO

The fifo is a simple script inspired from the "PIL" manual. There is the library:
-- This library provides fifo functions
--
-- Usage:
--
--   fifo = Fifo.new()
--   fifo:push(data)
--   daa = fifo:pop()

Fifo = {}
Fifo.meta = {}
Fifo.meta.__index = {}
Fifo.new = function()
        local fifo = {}
        fifo.first = 1 -- Always the first data
        fifo.last = 1 -- Alway the last available + 1
        fifo.data = {}
        setmetatable(fifo, Fifo.meta)
        return fifo
end
Fifo.meta.__index.push = function(fifo, data)
        fifo.data[fifo.last] = data
        fifo.last = fifo.last + 1
end
Fifo.meta.__index.pop = function(fifo, data)
        if fifo.first == fifo.last then
                return nil
        end
        local data = fifo.data[fifo.first]
        fifo.data[fifo.first] = nil
        fifo.first = fifo.first + 1
        if fifo.first == fifo.last then
                fifo.first = 1
                fifo.last = 1
        end
        return data
end
The usage of this library is easy. Create new Fifo object. Push and pop elements. Like this
require("fifo")
fifo = Fifo.new()
fifo:push("a")
fifo:push("b")
fifo:push("c")
print(fifo:pop())
print(fifo:pop())
print(fifo:pop())
print(fifo:pop())
This Little sample displays:
$ lua ./test.lua
a
b
c
nil

Asynchronous tasks with Lua & HAProxy

Now, we will use this library to stack task executed asynchronously. We use:

  • A FIFO for storing tasks
  • An HAProxy Lua task for executing it
  • The email library to send email
Fisrt step is initializing the FIFO. The FIFO is initialized in the main part of the Lua file. It it just:
require("fifo")

fifo_email = Fifo.new()
The second step is creation an action which send emails. This action put data where sent to the user. In this example, data is a copy of received request.
core.register_action("async_send_email", { "tcp-req", "http-req", "tcp-res", "http-res" }, function(txn)
   fifo_email:push("Request received:\n" .. txn.req:dup())
end)
And now, the task function which effectively send the data. This function pop the FIFO each seconds looking for jobs.
core.register_task(function()
   local ret
   local reason
   local server = "127.0.0.1"
   local port = 25
   local domain = "arpalert.org"
   local from = "haproxy@arpalert.org"
   local to = "admin@arpalert.org"

   while true do
      -- Process queue
      local data = fifo_email:pop()
      if data == nil then
         core.sleep(1)
      else
         -- Execute action
         local msg = "From: " .. from .. "\r\n" ..
                "To: " .. to .. "\r\n" ..
                "Subject: test - " .. os.date() .. "\r\n" ..
                "\r\n" ..
                data .. "\r\n"
         ret, reason = smtp_send_email(server, port, domain, from, to, msg);
         if ret == false then
            txn:Warning("Can't send email: " .. reason)
         end
      end
   end
end)
Finaly a little bit of haproxy configuration:
global
   lua-load samples.lua
   stats socket /tmp/haproxy.sock mode 644 level admin

defaults
   timeout client 1m
   timeout server 1m

listen sample4
   mode http
   bind *:10040
   http-request lua.async_send_email
   http-request redirect location /ok
That's all

Sunday, February 11, 2018

HAProxy & Lua: How to send email

Sometimes, its useful to send emails where HAProxy events occurs. Sadly, existing libraries are a sendmail wrapper or uses LuaSocket package which is incompatible with HAProxy. The SMTP protocol is very easy to understand. Note that the "S" of SMTP means "Simple" ;-).

This article proposes a simple function which use HAProxy cosocket for sending email throught SMTP.

First the main function. This function works only when it is executed inside HAProxy because it uses HAProxy cosocket. The implementation of the SMTP protocol is very limited, this function just send emails. It not support SSL, Authentication, StartTLS nor other SMTP functions. It is designed for communicating with a local or trusted SMTP server.
-- smtp : send SMTP message
-- 
-- Copyright 2018 Thierry Fournier 
--
-- This lib is compliant with HAProxy cosockets
--
function smtp_send_email(server, port, domain, from, to, data)

        local ret
        local reason
        local tcp = core.tcp()
        local smtp_wait_code = function(tcp, code)
                local ret
                -- Read headers until we reac a 2.. code.
                while true do
                        -- read line
                        ret = tcp:receive("*l")
                        if ret == nil then
                                return false, "Connection unexpectly closed"
                        end
                        -- expected code
                        if string.match(ret, code) ~= nil then
                                return true, nil
                        end
                        -- other code
                        if string.match(ret, '^%d%d%d ') ~= nil then
                                return false, ret
                        end
                        -- other informational message, wait.
                end
        end

        if tcp:connect(server, port) == nil then
                return false, "Can't connect to \""..server..":"..port.."\""
        end

        ret, reason = smtp_wait_code(tcp, '^220 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send("EHLO " .. domain .. "\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^250 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send("MAIL FROM: <" .. from .. ">\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^250 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send("RCPT TO: <" .. to .. ">\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^250 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send("DATA\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^354 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send(data .. "\r\n.\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^250 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        if tcp:send("QUIT\r\n") == nil then
                tcp:close()
                return false, "Connection unexpectly closed"
        end

        ret, reason = smtp_wait_code(tcp, '^221 ')
        if ret == false then
                tcp:close()
                return false, reason
        end

        tcp:close()
        return true, nil
end
And now an example of the usage. The use case is simple: we want to send email on HAProxy event throught an action. If the sending fails, send a log. We register a new Lua action.
core.register_action("send_email", { "tcp-req", "http-req", "tcp-res", "http-res" }, function(txn)
        local ret
        local reason
        local server = "127.0.0.1"
        local port = 25
        local domain = "arpalert.org"
        local from = "haproxy-node1@arpalert.org"
        local to = "admin@arpalert.org"
        local msg = "From: " .. from .. "\r\n" ..
                    "To: " .. to .. "\r\n" ..
                    "Subject: test - " .. os.date() .. "\r\n" ..
                    "\r\n" ..
                    "test - " .. os.date() .. "\r\n"
        ret, reason = smtp_send_email(server, port, domain, from, to, msg);
        if ret == false then
                txn:Warning("Can't send email: " .. reason)
        end
end)
And the asoiated HAProxy configuration example:
global
   lua-load samples.lua
   stats socket /tmp/haproxy.sock mode 644 level admin
   tune.ssl.default-dh-param 2048

defaults
   timeout client 1m
   timeout server 1m

listen sample3
   mode http
   bind *:10030
   http-request lua.send_email
   http-request redirect location /ok

HAProxy & Lua: How to debug configurations

Because HAProxy configuration complexity increase using advanced things like stick-tables, we need some debugging tools.

This article shows 3 way for debug HAProxy configuration:
  • Enable some debug functions at compilation time
  • Debug Lua advanced structs
  • Use Lua function for debugging HAProxy

Compile HAProxy with debug functions

This compilation option is not famous because it is designed for developers. These debug option allow to get information about internal memory pool, SPOE protocol, and something like that. Anyway, one of these function is designed for debugging configuration.
Just enable a debug directive in the compilation options:
$ make TARGET=linux DEBUG=-DDEBUG_EXPR
This compilation directive simply add a new converter which display information about his input value on the standard output and return it unchanged. The debug directive is used to watch type and content of saple fetches. This is a simple HAProxy configuration:
global
   stats socket /tmp/haproxy.sock mode 644 level admin

defaults
   timeout client 1m
   timeout server 1m

listen sample1
   mode http
   bind *:10010
   acl is_redir path,debug -m beg /old-stuff
   http-request redirect location /redir if is_redir
   http-request redirect location /main
Start haproxy like below. It is absolutely necessary to start HAProxy in debug mode, otherwise the debug information can't be displayed.
$ ./haproxy -d -f haproxy-debug.conf
And use Curl command for activating debug:
$ curl "http://127.0.0.1:10010/test"
The result is:
$ ./haproxy -d -f haproxy-debug.conf 
Note: setting global.maxconn to 2000.
Available polling systems :
       poll : pref=200,  test result OK
     select : pref=150,  test result FAILED
Total: 2 (1 usable), will use poll.

Available filters :
        [COMP] compression
        [TRACE] trace
        [SPOE] spoe
Using poll() as the polling mechanism.
00000000:sample1.accept(0004)=0005 from [127.0.0.1:41392]
00000000:sample1.clireq[0005:ffffffff]: GET /test HTTP/1.1
00000000:sample1.clihdr[0005:ffffffff]: User-Agent: curl/7.26.0
00000000:sample1.clihdr[0005:ffffffff]: Host: 127.0.0.1:10010
00000000:sample1.clihdr[0005:ffffffff]: Accept: */*
[debug converter] type: str </test>
00000001:sample1.clicls[0005:ffffffff]
00000001:sample1.closed[0005:ffffffff]

Lua print_r() function

Modern script languages like PHP or Python provides a debug function which dumps variables values and their metadata. There are respectively var_dump() and pprint(). These kind of function are useful for debugging scripts.
I do not found this kind of fucntion with Lua, so the following is mine.

You can embed this tool with the Lua line require("print_r"). For displaying a variable, you can write print_r(my_var). The variable will be displayed on stdout with ANSI colors. The prototype is:
print_r(<var> [, <color (boolean)> [, <display (function)>]])
  • <var> is the displayed variable
  • <color> if true, use ANSI color. if false, displays flat text.
  • <display> The prototype of this function is function(msg).
This function is called each time that we want. The following call example can be included in the first Lua file loaded by HAProxy and displays the provided lua structure.
require("print_r")
print_r(core)

Debug HAProxy configuration using Lua

This method is approximatively the same than the "debug" converter. The way is writing a lua converter which dumps its input and return it. This method works only with string, any input is converted as string. The main advantage is using HAProxy without recompile it.
Just write this simple function:
require("print_r");

core.register_converters("debug", function(data, name)
   print_r("["..name.."] :<"..data..">")
end)
And used it like this in the haproxy configuraiton file:
global
   lua-load debug.lua
   stats socket /tmp/haproxy.sock mode 644 level admin

defaults
   timeout client 1m
   timeout server 1m

listen sample1
   mode http
   bind *:10010
   acl is_redir path,lua.debug(PATH) -m beg /old-stuff
   http-request redirect location /redir if is_redir
   http-request redirect location /main
$ ./haproxy -d -f haproxy-debug.conf
And use Curl command for activating debug:
$ curl "http://127.0.0.1:10010/test"
The result is:
$ ./haproxy -d -f haproxy-debug.conf 
Note: setting global.maxconn to 2000.
Available polling systems :
       poll : pref=200,  test result OK
     select : pref=150,  test result FAILED
Total: 2 (1 usable), will use poll.

Available filters :
        [COMP] compression
        [TRACE] trace
        [SPOE] spoe
Using poll() as the polling mechanism.
00000000:sample1.accept(0004)=0005 from [127.0.0.1:41667]
00000000:sample1.clireq[0005:ffffffff]: GET /test HTTP/1.1
00000000:sample1.clihdr[0005:ffffffff]: User-Agent: curl/7.26.0
00000000:sample1.clihdr[0005:ffffffff]: Host: 127.0.0.1:10010
00000000:sample1.clihdr[0005:ffffffff]: Accept: */*
(string) "[PATH] :</test>"
00000001:sample1.clicls[0005:ffffffff]
00000001:sample1.closed[0005:ffffffff]

HAProxy & Lua: How to use sample-fetches

It's not easy to understand usage of Lua in HAProxy. The best way is a cpy paste of samples. This page will collect some samples of basic usages.
Note that this tuto is not:
  • HAProxy configuration tutorial. The reader must known HAProxy basics
  • Lua programation tutorial. The reader must known Lua basics

Useful links

Before start

In the following examples, I use the function print_r(). This function is usefull for displaying complex Lua structures. It is used for debugging. You can found this function in the page Lua scripts. The samples.lua file starts with an include (require()) of the print_r script.

require("print_r")

For testing the following samples, we always use HAProxy in debug mode. The global section of the configuration file is like the following. This is not a production ready configuration !

global
   lua-load samples.lua
   stats socket /tmp/haproxy.sock mode 644 level admin
   tune.ssl.default-dh-param 2048

defaults
   timeout client 1m
   timeout server 1m

The configuration file is named haproxy.cfg, all the Lua samples which could be copy, need to be pasted in the file samples.lua. The command line for starting sample is like the following. We assume a start from the directory containing the two configuration file (for default path reasons).
haproxy -d -f haproxy.cfg
Most of tests are performed whith Curl.

The documentation links points to the HAProxy version 1.7 documentation.

Note about sample-fetches wrappers and Lua Notation: txn.f is an array of wrapper functions. The object like members of an array doesn't supports the "." which is a reserved character. So all the sample fetches are replaced by a "_".

With Lua, a function is a variable. For convenience, the executed function can be declared inline as anonymous functions or referenced as named function. So the two sample below have the same behavior.
-- Named function
function a(arg)
   print("hello")
end
register_callback(a)

-- Inline function
register_callback(function(arg)
   print("hello")
end)
I will use the inline notation. Bellow the first sample commented. Note that all the Lua registered function are mapped in the HAProxy configuration with a prefix "lua.".

Using HAProxy sample fetches inside Lua

This first example is absolutely useless. It just shows how to create a Lua sample-fetch which return the same content that an embedded sample-fetch. By the way, it shows how to use embedded sample fetches in Lua.
First, HAProxy provides some functions and class. Some of these ones are availaible from the start of HAProxy. Where the lua file is read, it is executed, so the Lua file is executed during the start of HAProxy.
New all the functions which will be call by HAProxy (called "hook") needs to be registered. For declaring a Lua sample-fetch we will use the function core.register_fetches()
  • core.register_fetches(): Function (doc): Register Lua sample-fetch. register_fetches takes a function as argument. This function will be called according with the HAProxy
  • txn: Class TXN (doc): Provides functions available with the transaction.
  • txn.f: Class Fetches (doc): Provides an access to all the haproxy sample fetches. There are called sample-fetches wrappers.

-- This register new sample-fetch called "lua.sample1"
core.register_fetches("sample1", function(txn)

   -- By default, all varibles are global. specify the local
   -- keyword to avoid bug. The following line store the 
   -- content of the sample-fetch "path" in the variable
   -- "path".
   local path = txn.f:path()

   -- Print out the content of path variable. The data
   -- id display on the HAProxy standard output.
   print_r(path)

   -- Return the value of the path as the sample-fetch
   -- result.
   return txn.f:path()
end)
We can use this new sample-fetch like the embedded sample-fetch"path":
listen sample1
   mode http
   bind *:10010
   acl use-lua lua.sample1 -m beg /use-lua
   acl use-php lua.sample1 -m beg /use-php
   http-request redirect location /lua if use-lua
   http-request redirect location /php if use-php
   http-request redirect location /other
Start HAProxy and test with curl:
$ curl -s -i http://127.0.0.1:10010/use-lua | grep Location
Location: /lua

$ curl -s -i http://127.0.0.1:10010/use-php | grep Location
Location: /php

$ curl -s -i http://127.0.0.1:10010/use-other | grep Location
Location: /other

Simplify complex conditions

In some cases, configurations have complex conditions based on sample-fetches. These kind of conditions are not easy to maintain because there are not easily understandable. We are quickly lost with a lot of ACL. A prod likely example is choosing redirect to https or no according with some conditions. The conditions are prod or preprod, already ssl or no, protected page or no. We want to force https for the payment page, except for the preprod, except if the request is already SSL and except if the DEBUG cookie is set.
We want to redirect to http if the request is ssl and if the preprod is required or if not a payment page is required and if the exception cookie is not set.
These condition are a little bit complex with classic ACL. The Lua can help us. The foolowing table resule input name, their definition and the testing method:
Condition name Description Test with Curl
is_payment_page The payment page is requested curl http://127.0.0.1:10020/payment/
is_ssl The request is performed using SSL curl -k https://127.0.0.1:10021/
is_preprod The preprod is required curl http://127.0.0.1:10020/ -H 'Host: preprod.test.com'
is_cookie_exception A DEBUG cookie is set curl http://127.0.0.1:10020/ -H 'Cookie: DEBUG=1'
Below the truth table resuming actions:
is_paymentis_sslis_preprodis_debugaction
0000forward
0001forward
0010forward
0011forward
0100HTTP redirect
0101HTTP redirect
0110HTTP redirect
0111HTTP redirect
1000HTTPS redirect
1001forward
1010forward
1011forward
1100forward
1101HTTP redirect
1110HTTP redirect
1111HTTP redirect
Below the Lua code performing conditions. The code is split in two parts. The first part extract inputs, and the second part perform conditions based on inputs.
-- This function returns all condition in an array.
function get_variables(txn)

   -- This array will contains conditions
   local cond = {}

   -- True if the path starts with "/payment/"
   cond['is_payment_page'] = string.match(txn.sf:path(), '^/payment/') ~= nil

   -- True if the front connection is SSL
   cond['is_ssl'] = txn.f:ssl_fc() == 1

   -- True if the domain name asked is preprod
   cond['is_preprod'] = txn.f:req_fhdr('host') == 'preprod.test.com'

   -- True if the cookie 'DEBUG' is set
   cond['is_cookie_exception'] = txn.f:req_cook_cnt('DEBUG') >= 1

   -- Display extracted conditions
   -- print_r(cond)
   return cond
end

-- This sample fetch return 1 if we need HTTPS redirect
core.register_fetches("sample2_1", function(txn)

   -- Get input conditions
   local cond = get_variables(txn)

   -- Return result according with conditions value and policy.
   if cond['is_ssl']              then return 0 end
   if cond['is_cookie_exception'] then return 0 end
   if cond['is_preprod']          then return 0 end
   if cond['is_payment_page']     then return 1 end
   return 0
end)

-- This sample fetch returns 1 if we need HTTP redirect
core.register_fetches("sample2_2", function(txn)

   -- Get input conditions
   local cond = get_variables(txn)

   -- Return result according with conditions value and policy.
   if not cond['is_ssl']          then return 0 end
   if cond['is_cookie_exception'] then return 1 end
   if cond['is_preprod']          then return 1 end
   if not cond['is_payment_page'] then return 1 end
   return 0
end)
And the HAProxy corresponding code:
listen sample2
   mode http
   bind *:10020
   bind *:10021 ssl crt www.test.com.crt crt preprod.test.com.crt
   http-request redirect location /to-https if { lua.sample2_1 1 }
   http-request redirect location /to-http  if { lua.sample2_2 1 }
   http-request redirect location /forward