Haproxy — программирование и конфигурирование средствами Lua

в 18:37, , рубрики: api, devops, haproxy, jwt, Lua, Серверная оптимизация

Сервер Haproxy имеет встроенные средства для выполнения скриптов Lua.Язык программирования Lua для расширения возможностей различных серверов используется очень широко. Например, на Lua можно программировать для серверов Redis, Nginx (nginx-extras, openresty), Envoy. Это вполне закономерно, так как язык программирования Lua как раз и был разработан для удобства встраивания в приложения в качестве скриптового языка.В этом сообщении я рассмотрю варианты использования Lua для расширения возможностей Haproxy.Согласно документации, скрипты Lua на сервере Haproxy могут выполняться в шести контекстах:

  • body context (контекст времени загрузки конфигурации сервера Haproxy, когда выполняются скрипты, заданные директивой lua-load);
  • init context (контекст функций, которые вызываются сразу после загрузки конфигурации, и зарегистрированы системной функции core.register_init(function);
  • task context (контекст функций, выполняемых по расписанию и зарегистрированных системной функцией core.register_task(function));
  • action context (контекст функций, зарегистрированных системной функцией сore.register_action(function));
  • sample-fetch context (контекст функций, зарегистрированных системной функцией сore.register_fetches(function));
  • converter context (контекст функций, зарегистрированных системной функцией сore.register_converters(function)).

Фактически есть еще один контекст выполнения, который не указан в документации:

  • service context (контекст функций, зарегистрированных системной функцией сore.register_service(function));

Начнем с самой простой конфигурации сервера Haproxy. Конфигурация состоит из двух секций frontend — то есть то, к чему обращается клиент с запросом, и backend — то, куда проксируется запрос клиента через сервер Haproxy:

frontend jwt
        mode http
        bind *:80
        use_backend backend_app

backend backend_app
        mode http
        server app1 app:3000

Теперь все запросы, приходящие на порт 80 Haproxy будут перенаправлены на порт 3000 сервера app.

Services

Services — это функции, определенные в скриптах Lua, которые формируют ответ без обращения к бэкенду. Эти функции регистрируются вызовом системной функции сore.register_service(function)).Определим простейший Service в файле guarde.lua:

function _M.hello_world(applet)
  applet:set_status(200)
  local response = string.format([[<html><body>Hello World!</body></html>]], message);
  applet:add_header("content-type", "text/html");
  applet:add_header("content-length", string.len(response))
  applet:start_response()
  applet:send(response)
end

И зарегистрируем ее как Service в файле register.lua:

package.path = package.path  .. "./?.lua;/usr/local/etc/haproxy/?.lua"
local guard = require("guard")
core.register_service("hello-world", "http", guard.hello_world);

Параметр «http» является триггером, который допускает использование Service только в контексте http запроса (mode http).Дополним конфигурацию сервера Haproxy:

global
        lua-load /usr/local/etc/haproxy/register.lua

frontend jwt
        mode http
        bind *:80
        use_backend backend_app
        http-request use-service lua.hello-world   if { path /hello_world }

backend backend_app
        mode http
        server app1 app:3000

Теперь, обратившись к серверу Haproxy с запросом /hello_world, клиент получит не ответ с проксируемого сервера, а ответ сервиса lua.hello-world.В качестве параметра функции передается контекст запроса в параметре applet. Нет возможности передать дополнительные параметры файле конфигурации.

Actions

Actions — действия, выполняемые после получения запроса от клиента или после получения ответа от проксируемого сервера. Actions могут выполнять асинхронные операции (например запросы к базе данных) и не имеют возвращаемого значения. С сервером Actions общаются путем установки переменных контекста запроса. Контекст запроса предается в качестве параметра при вызове Action. Традиционно имя этого параметра txn. Передать дополнительные параметры из файла конфигурации Haproxy в Action нельзя. Создадим Action, который будет проверять наличие авторизации Bearer в запросе:

function _M.validate_token_action(txn)
  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")
  if auth_header[1] ~= "Bearer" or not auth_header[2] then
    return txn:set_var("txn.not_authorized", true);
  end
  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});
  if not claim then
    return txn:set_var("txn.not_authorized", true);
  end
  if claim.exp < os.time() then
    return txn:set_var("txn.authentication_timeout", true);
  end
  txn:set_var("txn.jwt_authorized", true);
end

Зарегистрируем этот Action:

core.register_action("validate-token", { "http-req" }, guard.validate_token_action);

Параметр { «http-req» } является триггером, который позволяет использовать этот Action только в контексте http и только на этапе запроса клиента (и запрещает использовать на этапе ответа проксируемого сервера).В конфигурации Haproxy, Action регистрируется в секции http-request:

frontend jwt
        mode http
        bind *:80
        http-request use-service lua.hello-world   if { path /hello_world }
        http-request lua.validate-token                 if { path -m beg /api/ }

На основании значения переменных, установленных в Action, формируются ACL (Access Control Lists) — ключевой элемент в конфигурациях Haproxy:

        acl jwt_authorized  var(txn.jwt_authorized) -m bool
        use_backend app if jwt_authorized { path -m beg /api/ }

Полный листинг конфигурации сервера Haproxy для Action validate-token:

global
        lua-load /usr/local/etc/haproxy/register.lua

frontend jwt
        mode http
        bind *:80

        http-request use-service lua.hello-world   if { path /hello_world }
        http-request lua.validate-token            if { path -m beg /api }

        acl bad_request            var(txn.bad_request)               -m bool
        acl not_authorized         var(txn.not_authorized)            -m bool
        acl authentication_timeout var(txn.authentication_timeout)    -m bool
        acl too_many_request       var(txn.too_many_request)          -m bool
        acl jwt_authorized         var(txn.jwt_authorized)            -m bool

        http-request deny deny_status 400 if bad_request { path -m beg /api/ }
        http-request deny deny_status 401 if !jwt_authorized { path -m beg /api/ } || not_authorized { path -m beg /api/ }
        http-request return status 419 content-type text/html string "Authentication Timeout" if authentication_timeout { path -m beg /api/ }
        http-request deny deny_status 429 if too_many_request { path -m beg /api/  }
        http-request deny deny_status 429 if too_many_request { path -m beg /auth/  }

        use_backend app if { path /hello }
        use_backend app if { path /auth/login }
        use_backend app if jwt_authorized { path -m beg /api/ }

backend app
        mode http
        server app1 app:3000

Fetches

Fetches — это значения которые вычисляются в процессе запроса. Они могут быть только синхронными, и принимают параметры, заданные в конфигурации Haproxy. Например, та же самая проверка авторизации может быть выполнена как Fetch:

function _M.validate_token_fetch(txn)
  local auth_header = core.tokenize(txn.sf:hdr("Authorization"), " ")
  if auth_header[1] ~= "Bearer" or not auth_header[2] then
    return "not_authorized";
  end
  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});
  if not claim then
    return "not_authorized";
  end
  if claim.exp < os.time() then
    return "authentication_timeout";
  end
  return "jwt_authorized:" .. claim.jti;
end

core.register_fetches("validate-token", _M.validate_token_fetch);

Установка ACL по значениям из Fetches задается так:

       http-request set-var(txn.validate_token) lua.validate-token()
       acl bad_request var(txn.validate_token) == "bad_request" -m bool
       acl not_authorized var(txn.validate_token) == "not_authorized" -m bool
       acl authentication_timeout var(txn.validate_token) == "authentication_timeout" -m bool
       acl too_many_request var(txn.validate_token) == "too_many_request" -m bool
       acl jwt_authorized var(txn.validate_token) -m beg "jwt_authorized"

Converters

Converters в качестве параметра принимают строку и возвращают значение. Converters, также как и Fetches, могут быть только синхронными и принимают параметры, задаваемые в конфигурации Haproxy. В конфигурации Haproxy Converters отделяются от значения, к которому они применяются, запятой.Создадим Converter, который будет заголовок Authorization преобразовывать в строку:

function _M.validate_token_converter(auth_header_string)
  local auth_header = core.tokenize(auth_header_string, " ")
  if auth_header[1] ~= "Bearer" or not auth_header[2] then
    return "not_authorized";
  end
  local claim = jwt.decode(auth_header[2],{alg="RS256",keys={public=public_key}});
  if not claim then
    return "not_authorized";
  end
  if claim.exp < os.time() then
    return "authentication_timeout";
  end
  return "jwt_authorized";
end

core.register_converters("validate-token-converter",  _M.validate_token_converter);

В файле конфигурации использование конвертера задается следующим образом:

        http-request set-var(txn.validate_token) hdr(authorization),lua.validate-token-converter

К значениею заголовка Authorization, который извлекается системным Fetch hdr() применяется Converter lua.validate-token-converter.

Stick Table

Stick Table — это хранилище пар ключ-значение, которое оптимизировано для учета количества запросов в единицу времени, и служит, прежде всего, для защиты серверов от атак DDoS или брутфорса (напрмер перебора паролей или выкачки запросами REST больших объемов данных). В сочетании с такими средствами как Fetches и Converters, эти таблицы могут подсчитывать количество запросов, например, с определенным сессионным cookie или jti, не давая тем самым использовать одну авторизацию для организации распределенной атаки с сотен тысяч устройств. К положительным сторонам Stick Table относится скорость работы и простота конфигурирования. К отрицательным — ограниченное количество регистров для учета значений (всего восемь регистров), потребление памяти, потеря данных после перегрузки сервера Haproxy. Рассмотрим как задаются правила в Stick Table:

        stick-table  type string  size 100k  expire 30s store http_req_rate(10s)
        http-request track-sc1 lua.validate-token()
        http-request deny deny_status 429 if { sc_http_req_rate(1) gt 3 }

Строка 1. Создается таблица. В качестве ключа используется значение типа строка. Максимальный размер таблицы 100k. Срок хранения ключа 30 секунд. В качестве значения будут накапливаться количество запросов за последние 10 секунд с одинаковым значением ключа типа строка. Строка 2. Задается, что значение ключа определяется из Fetch lua.validate-token(), и будет использоваться регистр 1, в котором будут накапливаться значения (track-sc1)Строка 3. Если количество запросов с ключом, заданными в строке 2, накопленных в регистре 1 (sc_http_req_rate(1)) превышает 3 — сервер отдает ответ со статусом 429.

Асинхронные Actions

Если есть необходимость использовать асинхронный код (например запросы в базу данных) — то Actions это единственный выбор. Любого разработчика будет волновать вопрос, насколько асинхронные запросы будут снижать призводительность работы сервера. Если сравнить в этом аспекте Haproxy c основными конкуретнтами Nginx/Openresty и Envoy, то расклад будет такой. Envoy разрешает выполнение асинхронного кода, но оно будет фактически блокировать работу сервера на время выполнения этого запроса, и поэтому не рекомендуется. Openresty, напротив, поощряет использование асинхронного кода, но только если используемые библиотеки были специально разработаны для Openresty. В этих библиотеках на время выполнение асинхроных операций происходит высвобождение ресурсов, примерно как это делает Nodejs, реализующий свою главную фичу — NIO (не блокирующий ввод-вывод). Именно из-за особенностей архитектуры этого решения, Openresty работет на версии Lua 5.1 и не переходит на более высокие версии Lua 5.2 или 5.3. Haproxy, в отличие от Openresty, позволяет использовать библиотеки общего назначения Lua без ограничений по версиям. Но, в отличие от Envoy, асинхронные вызовы не блокируют работу сервера. По моим выборочным замерам призводиетельности некоторых запросов они, близки к тому что выдает на асинхронных запросах Openresty — хотя я, не претендую в этой оценке на полную объективность.Сейчас мы рассмотрим обращение к серверу Redis. В предыдущем разделе мы рассмтривали посчет количества запросов с определенным ключом при помощи Stick Table. Они работают очень быстро, но не без недостатков. Судя по «поведению» реального севрера, срок хранения ключа «продлевается» при каждом новом запросе. Это приводит к тому, что стчетчик не «сбрасывается» по окончании заданного срока и запросы начинают отвергаться все на 100%. Для того чтобы трафик возобновился, нужно чтобы на время действия ключа полностью прекратились запросы с этим ключом. Обычно это не то поведение, которое ожидается. Реализуем счетичики запросов на Redis, и будем отвергать запросы после превышения заданного лимита за заданный период времени:

function _M.validate_body(txn, keys, ttl, count, ip)
  local body = txn.f:req_body();
  status, data = pcall(json.decode, body);
  if not (status and type(data) == "table") then
    return txn:set_var("txn.bad_request", true);
  end
  local redis_key = "validate:body"
  for i, name in pairs(keys) do
    if data[name] == nil or data[name] == "" then
      return txn:set_var("txn.bad_request", true);
    end
    redis_key = redis_key .. ":" .. name .. ":" .. data[name]
  end
  if (ip) then
    redis_key = redis_key .. ":ip:" .. ip
  end
  local test = _M.redis_incr(txn, redis_key, ttl, count);
end

function _M.redis_incr(txn, key, ttl, count)
  local prefixed_key = "mobile:guard:" .. key
  local tcp = core.tcp();
  if tcp == nil then
    return false;
  end
  tcp:settimeout(1);
  if tcp:connect(redis_ip, redis_port) == nil then
    return false;
  end
  local client = redis.connect({socket=tcp});
  local status, result = pcall(client.set, client, prefixed_key, "0", "EX", ttl, "NX");
  status, result = pcall(client.incrby, client, prefixed_key, 1);
  tcp:close();
  if tonumber(result) > count + 0.1 then
    txn:set_var("txn.too_many_request", true)
    return false;
  else
    return true;
  end
end

core.register_action("validate-body", { "http-req" }, function(txn)
  _M.validate_body(txn, {"name"}, 10, 2);
end);

Код, использованный в данном сообщении доступен в репозитарии. В частности, там есть файл docker-compose.yml, который поможет поднять необходимую для работы среду.apapacy@gmail.com5 декабря 2020 года

Автор: apapacy

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js