Skip to content

Instantly share code, notes, and snippets.

@dch
Last active October 3, 2022 21:17
Show Gist options
  • Save dch/63dd70f626b4203c2769298c9c371958 to your computer and use it in GitHub Desktop.
Save dch/63dd70f626b4203c2769298c9c371958 to your computer and use it in GitHub Desktop.
GET haproxy sticky table data as json

HTTP JSON API for haproxy stick tables

intro

A quick sketch of haproxy config with lua to provide an HTTP API to query stick tables in JSON format. Probably has a few rough edges around output as kv_pairs() doesn't really understand its output, just building up strings. A proper table traversal function would be better, and verifying its actually valid JSON would be even better.

interfaces

  • http://localhost:8000/ is the front end
  • http://localhost:8001/ is the usual admin panel
  • port 8002 is used for peer syncing, the goal is to have the peer table queryable across nodes, but right now this doesn't work, the stick table needs to be attached to a backend to be visible to lua.
  • /tmp/haproxy.{state,sock,pid} are the usual things

run

$ git clone https://gist.github.com/dch/63dd70f626b4203c2769298c9c371958.git /tmp/haproxy
$ cd /tmp/haproxy
$ haproxy -L local -db -d -V -f haproxy.conf

output

$ wrk -c 5 -d 1 http://localhost:8000/
Running 1s test @ http://localhost:8000/
  2 threads and 5 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency     1.79ms    3.20ms  14.71ms   83.53%
    Req/Sec     8.52k     3.03k   17.79k    90.91%
  18693 requests in 1.10s, 1.79MB read
Requests/sec:  16946.99
Transfer/sec:      1.62MB
$ curl http://localhost:8000/
{
"::ffff:127.0.0.1": 18696
}

Making the table name, and threshold, configurable as parameters would be a nice addition.

# vim: filetype=config
global
daemon
pidfile /tmp/haproxy.pid
server-state-file /tmp/haproxy.state
stats socket /tmp/haproxy.sock level admin
log 127.0.0.1 len 65535 format rfc5424 daemon
log-send-hostname test
log-tag haproxy
lua-load stick.lua
defaults
log global
option log-health-checks
mode http
option httplog
option dontlognull
monitor-uri /_haproxy_health_check
# https://www.haproxy.com/documentation/hapee/latest/configuration/config-sections/peers
peers cluster
peer local 127.0.0.1:8002
# https://www.haproxy.com/blog/introduction-to-haproxy-stick-tables/
table rate_limiting type ipv6 size 1m expire 10s store http_req_rate(10s)
frontend admin
bind 127.0.0.1:8001
stats enable
stats uri /
stats refresh 30s
stats admin if TRUE
no log
backend rate_limiting
stick-table type ipv6 size 1m expire 10m store http_req_rate(10m)
frontend lua
bind :::8000 v4v6
http-request track-sc0 src table rate_limiting
# we don't see peer entries via lua
# http-request track-sc0 src table cluster/rate_limiting
http-request use-service lua.stick
## see https://www.haproxy.com/blog/use-haproxy-response-policies-to-stop-threats/ for alternative responses
-- Copyright 2016 Thierry Fournier
function color(index, str)
return "\x1b[" .. index .. "m" .. str .. "\x1b[00m"
end
function nocolor(index, str)
return str
end
function sp(count)
local spaces = ""
while count > 0 do
spaces = spaces .. " "
count = count - 1
end
return spaces
end
function escape(str)
local s = ""
for i = 1, #str do
local c = str:sub(i,i)
ascii = string.byte(c, 1)
if ascii > 126 or ascii < 20 then
s = s .. string.format("\\x%02x", ascii)
else
s = s .. c
end
end
return s
end
function print_rr(p, indent, c, wr, hist)
local i = 0
local nl = ""
if type(p) == "table" then
wr(c("33", "(table)") .. " " .. c("36", tostring(p)) .. " [")
for idx, value in ipairs(hist) do
if value == p then
wr(" " .. c("35", "/* recursion */") .. " ]")
return
end
end
hist[indent + 1] = p
mt = getmetatable(p)
if mt ~= nil then
wr("\n" .. sp(indent+1) .. c("31", "METATABLE") .. ": ")
print_rr(mt, indent+1, c, wr, hist)
end
for k,v in pairs(p) do
if i > 0 then
nl = "\n"
else
wr("\n")
end
wr(nl .. sp(indent+1))
if type(k) == "number" then
wr(c("32", tostring(k)))
else
wr("\"" .. c("32", escape(tostring(k))) .. "\"")
end
wr(": ")
print_rr(v, indent+1, c, wr, hist)
i = i + 1
end
if i == 0 then
wr(" " .. c("35", "/* empty */") .. " ]")
else
wr("\n" .. sp(indent) .. "]")
end
hist[indent + 1] = nil
elseif type(p) == "string" then
wr(c("33", "(string)") .. " \"" .. c("36", escape(p)) .. "\"")
else
wr(c("33", "(" .. type(p) .. ")") .. " " .. c("36", tostring(p)))
end
end
function print_r(p, col, wr)
if col == nil then col = true end
if wr == nil then wr = function(msg) io.stdout:write(msg) end end
local hist = {}
if col == true then
print_rr(p, 0, color, wr, hist)
else
print_rr(p, 0, nocolor, wr, hist)
end
wr("\n")
end
-- stick.lua
-- BSD 2 Clause license
core.Alert("lua: stick loaded");
local function list(applet)
require("print_r")
local st = core.backends.rate_limiting.stktable
local filter = {{"http_req_rate", "gt", 100}}
local dump = st:dump(filter)
local body = kv_pairs("http_req_rate", dump)
applet:set_status(200)
applet:add_header("content-length", string.len(body))
applet:add_header("content-type", "application/json")
applet:start_response()
applet:send(body)
end
core.register_service("stick", "http", list)
function kv_pairs(subkey, stick_table)
local j = core.concat()
j:add('{\n')
if type(stick_table) == 'table' then
-- table resembles { ["127.0.0.1"] = { ["http_req_rate"] = 104,} ,}
local first = true
for k,v in pairs(stick_table) do
if type(k) == 'string' then
-- add preceding , for all but first row
-- because JSON is annoying
if first then
j:add('"')
first = false
else
j:add(',"')
end
j:add(k)
j:add('": ')
-- append value if found in sub-table
v = v[subkey] or 'null'
j:add(v)
j:add('\n')
end
end
j:add('}\n')
return j:dump()
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment