...

понедельник, 10 марта 2014 г.

LUA в nginx: слегка интеллектуальный firewall



Данный пост является продолжением применения lua в nginx.

Там обсуждалось кеширование в памяти, а тут lua будет использоваться для фильтрации входящих запросов в качестве этакого фаервола на nginx-балансере. Нечто подобное было у 2GIS. У нас свой велосипед :) В котором разделяем динамику и статику, стараемся учесть NAT и белый список. И, конечно же, всегда можно навернуть еще специфичной логики, что не выйдет при использовании готовых модулей.

Данная схема сейчас спокойно и ненапряжно (практически не сказывается на использовании cpu) обрабатывает порядка 1200 запросов/сек. На предельные величины не тестировалось. Пожалуй, к счастью :)


Хочется обрабатывать все входящие запросы сразу по поступлению, а не по факту строчки в access_log (который еще небось и выключен для той же статики). Не вопрос, вешаем обработчик глобально на весь http:



http {
include lua/req.conf;
}

# содержимое lua/req.conf

# память под хранение счетчиков запросов (надо много, хотя вытеснение старых записей по LRU допустимо)
lua_shared_dict req_limit 1024m;
# память под хранение списка забаненных (список должен быть небольшой, но вытеснение крайне нежелательно)
lua_shared_dict ban_list 128m;

# белый список. проверки не выполняются, защитная кука не ставится
geo $lua_req_whitelist {
default 0;
12.34.56.78/24 1;
}

# настройка
init_by_lua '
-- секретная соль для защитной куки
lua_req_priv_key = "secretpassphrase"
-- имя защитной куки
lua_req_cookie_name = "reqcookiename"
-- путь до файла лога забаненных
lua_req_ban_log = "/path/to/log/file"

-- допустимые лимиты на запросы (в мин) -- числа исключительно для примера
lua_req_d_one = 42 -- динамика на один URI
lua_req_d_mul = 84 -- динамика на разные URI
lua_req_s_one = 100 -- статика на один URI
lua_req_s_mul = 200 -- статика на разные URI

lua_req_d_ip = 200 -- динамика с одного IP
lua_req_s_ip = 400 -- статика с одного IP

-- бан на 10 минут
lua_req_ban_ttl = 600

-- служебное
math.randomseed(math.floor(ngx.now()*1000))
';

# подключение основного скрипта, встраивающегося в access стадию обработки запросов
access_by_lua_file /path/to/nginx/lua/req.lua;




Теперь все запросы, приходящие в nginx, пройдут через наш скрипт req.lua.

При этом у нас есть две таблицы req_limit и ban_list для хранения истории запросов и списка уже забаненных соотвественно (подробнее ниже).

А для реализации whitelist по IP вместо велосипедов использован модуль geo nginx, проставляющий значение переменной lua_req_whitelist, которая используется примерно так:

if ngx.var.lua_req_whitelist ~= '1' then
-- IP не из белого списка, выполняем проверки
end


Для проверки статика/динамика (запрос за файлом на диске/backend серверу) делаем простую проверку по имени запрашиваемого файла (тут можно усложнять реализацию, подстраиваясь под свою бизнес логику):



function string.endswith(haystack, needle)
return (needle == '') or (needle == string.sub(haystack, -string.len(needle)))
end

local function path_is_static(path)
local exts = {'js', 'css', 'png', 'jpg', 'jpeg', 'gif', 'xml', 'ico', 'swf'}

path = path:lower()

for _,ext in ipairs(exts) do
if path:endswith(ext) then
return true
end
end
return false
end

local uri_path = ngx.var.request_uri
if ngx.var.is_args == '?' then
uri_path = uri_path:gsub('^([^?]+)\\?.*$', '%1')
end
local is_static = path_is_static(uri_path)


Для хоть какой-то обработки NAT, кроме IP клиентов так же учитывается их UserAgent и проставляется спец кука. Все три элемента в целом и составляют идентификатор пользователя. Если некий злодей долбит сервер, игнорируя передаваемую куку, то в худшем случае просто будет забанен его IP/подсеть. При этом те пользователи с этой подсети, кто уже получил ранее куку, будут спокойно работать дальше (кроме случая бана по IP). Решение не идеальное, но все же лучше, чем считать полстраны/мобильного оператора за одного пользователя.

Генерация и проверки куки:



local function gen_cookie_rand()
return tostring(math.random(2147483647))
end

local function gen_cookie(prefix, rnd)
return ngx.encode_base64(
-- для разделения двух клиентов с одного IP и с одинаковыми UserAgent, вмешиваем каждому случайное число
ngx.sha1_bin(ngx.today() .. prefix .. lua_req_priv_key .. rnd)
)
end

local uri = ngx.var.request_uri -- запрашиваемый URI
local host = ngx.var.http_host -- к какому домену пришел запрос (если у вас nginx обрабатывает несколько доменов)
local ip = ngx.var.remote_addr
local user_agent = ngx.var.http_user_agent or ''
if user_agent:len() > 0 then
user_agent = ngx.encode_base64(ngx.sha1_bin(user_agent))
end
local key_prefix = ip .. ':' .. user_agent

-- проверка контрольной куки
local user_cookie = ngx.unescape_uri(ngx.var['cookie_' .. lua_req_cookie_name]) or ''
local rnd = gen_cookie_rand()

local p = user_cookie:find('_')
if p then
rnd = user_cookie:sub(p+1)
user_cookie = user_cookie:sub(1, p-1)
end

local control_cookie = gen_cookie(key_prefix, rnd)

if user_cookie ~= control_cookie then
user_cookie = ''
rnd = gen_cookie_rand()
control_cookie = gen_cookie(key_prefix, rnd)
end

key_prefix = key_prefix .. ':' .. user_cookie
ngx.header['Set-Cookie'] = string.format('%s=%s; path=/; expires=%s',
lua_req_cookie_name,
ngx.escape_uri(control_cookie .. '_' .. rnd),
ngx.cookie_time(ngx.time()+24*3600)
)




Теперь в key_prefix содержится идентификатор клиента, чей запрос мы обрабатываем. Если данный клиент уже забанен, то дальнейшая обработка не нужна:

local ban_key = key_prefix..':ban'
if ban_list:get(ban_key) or ban_list:get(ip..':ban') then -- проверка ключа и проверка бана вообще в целом по IP
return ngx.exit(ngx.HTTP_FORBIDDEN)
end




Ключ получили, бан проверили, теперь можно посчитать, не превышает ли данный запрос какой из лимитов:

-- проверка обоих вариантов: на один URI и на разные URI
local limits = {
[false] = {
[false] = lua_req_d_mul, -- динамика на разные URI
[true] = lua_req_d_one, -- динамика на один URI
},
[true] = {
[false] = lua_req_s_mul, -- статика на разные URI
[true] = lua_req_s_one, -- статика на один URI
}
}

for _,one_path in ipairs({true, false}) do
local limit = limits[is_static][one_path]
local key = {key_prefix}

-- разделение статики и динамики в имени ключа
if is_static then
table.insert(key, 'S')
else
table.insert(key, 'D')
end

-- для проверки запросов к одному и тому же пути (для всяких API может не подойти)
if one_path then
table.insert(key, host..uri)
end

-- получаем ключ вида "12.34.56.78:useragentsha1base64:cookiesha1base64:S:http://ift.tt/1dHMrNp"
key = table.concat(key, ':')

local exaust = check_limit_exaust(key, limit, ban_ttl)
if exaust then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
end




Проверяем 4 варианта счетчиков: статика/динамика, по одному пути/по разным. Непосредственные проверки выполняются в check_limit_exaust():

local function check_limit_exaust(key, limit, cnt_ttl)
local key_ts = key..':ts'

local cnt, _ = req_limit:incr(key, 1)

-- если ключа нет, то это первый запрос
-- добавляем счетчик и отметку с текущим временем
if cnt == nil then
if req_limit:add(key, 1, cnt_ttl) then
req_limit:set(key_ts, ngx.now(), cnt_ttl)
end
return false
end

-- если не превысили лимит (пока даже без учета интервалов)
if cnt <= limit then
return false
end

-- если есть превышение лимита (без учета интервалов),
-- то нужно получить последнюю отметку интервала и проверить лимит уже с учетом интервала

local key_lock = key..':lock'
local key_lock_ttl = 0.5
local ts

local try_until = ngx.now() + key_lock_ttl
local locked

while true do
locked = req_limit:add(key_lock, 1, key_lock_ttl)
cnt = req_limit:get(key)
ts = req_limit:get(key_ts)
if locked or (try_until < ngx.now()) then
break
end
ngx.sleep(0.01)
end

-- если не удалось получить актуальные данные и получить лок на обновление - крики, паника, запрещаем запрос.
-- при этом не добавляем данный IP в blacklist
-- у вас может быть иная логика
if (not locked) and ((not cnt) or (not ts)) then
return true, 'lock_failed'
end

-- за сколько времени (в сек) накоплен счетчик
local ts_diff = math.max(0.001, ngx.now() - ts)
-- нормализация счетчика на секундный интервал
local cnt_norm = math.floor(cnt / ts_diff)

-- если нормализованное количество запросов не превысило лимит
if cnt_norm <= limit then
-- корректировка ts и cnt (если что в этих set'ах поломается - просто потом еще раз попадем в эту ветку)
req_limit:set(key, cnt_norm, cnt_ttl)
req_limit:set(key_ts, ngx.now() - 1, cnt_ttl)

-- лок снимаем; в blacklist не добавляем; запрос не блокируем
if locked then
req_limit:delete(key_lock)
end
return false
end

-- превысили лимит. баним, запрос блокируем, пишем в лог
req_limit:delete(key)
req_limit:delete(key_ts)

if locked then
req_limit:delete(key_lock)
end

return true, cnt_norm
end




Кроме непосредственного бана на lua_req_ban_ttl секунд, можно реализовать постоянное хранение, а заодно прикрутить логгирование и проброс забаненных по IP в iptables/аналоги. Это уже вне темы поста.

Все это, само собой, лишь пример, а не серебряная пуля-копипаста. Тем более приведенные числа лимитов указаны с потолка.


Изображение в шапке взято отсюда.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий