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:site.com/path/to/file"
        key = table.concat(key, ':')
    
        local exhaust = check_limit_exhaust(key, limit, ban_ttl)
        if exhaust then
            return ngx.exit(ngx.HTTP_FORBIDDEN)
        end
    end
    

    Проверяем 4 варианта счетчиков: статика/динамика, по одному пути/по разным. Непосредственные проверки выполняются в check_limit_exhaust():
    local function check_limit_exhaust(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/аналоги. Это уже вне темы поста.

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

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

    Similar posts

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 16

      +6
      Отлично. Рад, что я не одинок в своём горе :)
      Одно замечание: в 2гисе мы опытным путём установили, что скармливать регулярки nginx'у получается быстрее, чем обрабатывать их родным gsub'ом в Lua. Разница под нагрузкой — примерно 300%.
        +2
        Использование lua в nginx нам самим очень нравится.

        В посте приведен не окончательный вариант кода. В итоге регулярок у нас вообще не будет. Та же реализация path_is_static выполнена именно так, как приведено, а не через регулярку, осознанно. По скорости не тестили, просто в данном случае пока можно обойтись и без них.
        0
        Nginx + Lua очень приятная связка, пока на ней реализовал учет статистики загрузок в GA, при этом файл лежит статически, ни через какие дополнительные скрипты не проходит. Завтра чтоб не потерялось данное решение, может кому и пригодится, оформлю в виде статьи.
        0
        А чем не устроил testcookie-nginx-module?
          0
          Кука — это лишь средство решения проблемы NAT. Так-то и модули userid и req_limit есть. Но только с lua можно произвольно расширять и уточнять логику, не ограничивая себя рамками подобных модулей.
            0
            Выкладывайте на GitHub:)
              0
              Да, собственно, все основные моменты есть в посте. Что тут выкладывать-то :)
                +1
                Я к тому, что может стоит оформить как OpenSource проект. Развивать, допиливать. После сделать морду. И люди подключатся. Сама по себе тема очень интересна. К примеру из подобного есть CloudFlare. Он не только баны вешать и от ддоса, но еще и как cdn.
                  0
                  CloudFlare — это авторы данного модуля, кстати. Идею я понял, подумаю.
                    0
                    Напишите потом сюда адрес репозитария, если надумаете ;)
                      0
                      Можно подписаться на мой github аккаунт) Я туда редко что выкладываю, но если что и будет по lua ценного — сразу можно будет узнать.
          0
          Подскажите, а lua скрипт интерпретируется каждый раз при вызове или один раз делается байт код, который и используется при обработке запросов.
            0
            Если говорить о luajit, то разово генерится сразу машинный код. Потому оно так шустро и работает.
            0
            Посмотрел еще раз документацию lua-nginx-module. github.com/openresty/lua-nginx-module#lualuajit-bytecode-support. /path/to/luajit/bin/luajit -b /path/to/input_file.lua /path/to/output_file.luac ---генерирует байт код. После чего появляется возможность подгружать именно бинарный файл в nginx. Интересно какова разница в производительности в вашем случае.

            Only users with full accounts can post comments. Log in, please.