Sindbad~EG File Manager

Current Path : /opt/nginxhttpd_/etc/openresty_config/lua/lib/
Upload File :
Current File : //opt/nginxhttpd_/etc/openresty_config/lua/lib/o2switch_counter.lua

--[[
    The file will contains the code that counts the requests / ip and requests / domain.

    Worry Points : 
    - Call to the get_keys() is blocking. We'll need to see if it's a problem later and if it scale with the nb of ipairs
    - Does it scale with the number of IPs in the memory_zone ? Maybe we should delete IP from here when TmpBanned
    - With a lot of workers, does the Nginx workers blocked themself because of the lock on the check() call ? 
      Maybe we should switch to a local lua counter in a table (L3) + regular sync to the L2 dict using a timer
      It may be a problem on an ipxtender server... 
    - TTL management of entries. I'm not 100% sure about the current implementation.  
--]]

local _M = {}

local timer = require("resty.timer")
local o2debug = require "lib/o2switch_debug"
local o2config = require "lib/o2switch_config"
local ipUtils = require "lib/o2switch_ip_utils"
local ngx = ngx
local domainCounter = ngx.shared.domain_req_counter_dict
local ipCounter = ngx.shared.ip_req_counter_dict
local miscDict = ngx.shared.misc_dict
-- local sha1_bin = ngx.sha1_bin 
local type = type
local ipairs = ipairs
local pairs = pairs
local config = o2config.autoMitigationConfig
local tonumber = tonumber
local math_abs = math.abs
local math_floor = math.floor 
local sub = string.sub


-- Increment the counter type for the given key
-- @counterType either 'd' for domain or 'i' for ip address
-- @key either the binary ip address from nginx, or the domain name (full, untruncated)
-- @increment the increment value, default to 1
-- @return {newValue|nil, errMsg|nil}
function _M.increment(counterType, key, increment)
    local timeframe = config[counterType][1] + 5
    if counterType == 'i' then
        local v, r = ipCounter:incr(
            key, 
            (increment or 1), 
            0, 
            timeframe
        )
        ipCounter:expire(key, timeframe)
        return v, r
    else
        local v, r = domainCounter:incr(
            sub(key, 1, 30), -- sha1_bin(key), -- sha1 is better but not usable if we want to export the data from memory
            (increment or 1), 
            0, 
            timeframe
        )
        domainCounter:expire(key, timeframe)
        return v, r
    end
    return nil, 'Unexpected error'
end


-- Check if we need a mitigation for the type&key
-- @counterType 'i' for ip and 'd' for domain
-- @key The raw key, so not encoded.So binary IP address or full untruncated domain name
-- @return nil or the mitigation to use, number or nil
function _M.check(counterType, key)
    -- --o2debug.debug('check called')
    local c = config[counterType]
    local value, err = _M.increment(counterType, key)
    local mitigation = nil
    for i = 1, #c[4] do
        if c[4][i][1] > value then
            return mitigation, value
        else
            mitigation = c[4][i][2]
        end
    end

    return mitigation, value
end

-- Decrement the counter type/key. Also delete the key from the dict if number < 0 and set a max value to number to avoid overflow
-- @counterType 'i' for ip or 'd' for domain
-- @key The already encoded key. So the binary IP address and for domain, the sub(domain, 1, 30)
-- @decrement The decrement value, default to 1
-- @allowNegative Set to true to allow setting a negative value on the counter. Useful for whitelisting for instance.
-- @return {newValue|nil, errMsg|nil}
function _M.decrement(counterType, key, decrement, allowNegative)
    local decrement = (decrement > 0) and decrement * -1 or decrement
    local dict = counterType == 'i' and ipCounter or domainCounter
    local leakagePeriod = config[counterType][2] + 5
    local allowNegative = allowNegative or false

    local value, err = dict:incr(key, (decrement or -1), 0, leakagePeriod)

    if(type(value) ~= 'number') then
        dict:delete(key)
        return nil, err or 'Not a number'
    end

    -- For IP (i), the negative value can be legitimate, for instance, when a challenge is passed
    if(not allowNegative and value < 0) then
        dict:delete(key)
        return nil, nil
    end

    -- Not sure about this one. Only keep negative (whitelist) value, to keep the "whiltelist" until it re-become > 0
    if(value < 0) then
        dict:expire(key, leakagePeriod * 2)
    end

    return value, err
end

-- Return all the keys in the shared dict used to store all the counting stuff
-- @counterType either 'i' or 'd'
-- @return table with the list for keys found
function _M.getKeys(counterType) 
    local dict = counterType == 'i' and ipCounter or domainCounter
    return dict:get_keys(0)
end

-- Leak the 'counterType' counter with the right amount (based on the configuration). Also flush expired counter.
-- @counterType either 'i' or 'd'
-- @return bool, nb of keys in counter|nil, errMsg|nil
function _M.leak(counterType) 
    --o2debug.debug('Leak called for : ' .. counterType)
    --ngx.update_time()
    --local begin = ngx.now()

    local dict = counterType == 'i' and ipCounter or domainCounter
    local c = config[counterType]
    if (type(c) ~= 'table') then
        return false, nil, "Cant find the configuration"
    end
    
    local leakageCount = miscDict:get(counterType) or 0
    local leakageAmount = c[3]
    local leakageInterval = c[2]
    local reset = false
    if(type(leakageAmount) ~= 'number') then
        return false, nil, 'No leakage amount'
    end

    -- For every cycle (every timeframe), we reset the value of the count to a max value, 
    -- to avoid having a domain / ip blocked too long, if a attack has stopped.
    if((leakageCount or 0) > math_floor(c[1] / leakageInterval)) then
        reset = true
        --o2debug.debug('Reset loop this time')
        if(leakageCount > 900000) then
            miscDict:set(counterType, 0)
        end
    end

    local getResetValue = function(currentVal)
        local prev = 0
        local val = 0
        for i = 1, #c[4] do
            if currentVal > c[4][i][1] and currentVal > prev then
                val = c[4][i][1]
            else
                return (val - leakageAmount) > 0 and (val - leakageAmount) or val 
            end
            prev = c[4][i][1]
        end
        return (val - leakageAmount) > 0 and (val - leakageAmount) or val  
    end

    dict:flush_expired(0)

    local count = 1
    for _, key in ipairs(dict:get_keys(0)) do
        -- Peak value first. Not great (performance wise)
        local val, err = dict:get(key)
        if err then
            dict:delete(key)
            goto zcontinue
        end

        -- Dont touch negative value (because it came from a captcha/js challenge probably)
        if val < 0 then
            dict:expire(key, 86400) -- We'll need to see if it's a problem having a huge TTL and the IP piling up in the memory
            goto zcontinue
        end

        -- Reset : each timeframe circle, we reset the value
        if reset then
            local newValue = getResetValue(val)
            if newValue <= 0 then
                dict:delete(key)
            else
                dict:set(key, newValue)
            end
            goto zcontinue
        end

        -- Normal cycle
        _M.decrement(counterType, key, leakageAmount)

        ::zcontinue::
        count = count + 1
    end

    --ngx.update_time()
    ----o2debug.debug(tostring(leakageCount) .. ' : leakage elapsed seconds for "' .. counterType .. '" ' .. tostring(count) .. ' keys : ' .. tostring(ngx.now() - begin))
    miscDict:incr(counterType, 1, 0)

    return true, count, nil
end

-- This will registrer all the timers for the counter management. The timers will be launched only in one worker (node-wide).
-- @return bool
function _M.registerTimers() 
    --o2debug.debug('o2switch_counter.registerTimers() called')
    for i, v in pairs(o2config.autoMitigationConfig) do
        -- https://kong.github.io/lua-resty-timer/topics/README.md.html
        timer({
            interval = v[2],  
            recurring = true, 
            immediate = false,
            detached = true, 
            jitter = 0.1,
            shm_name = "timer_shm", -- shm to use for node-wide timers
            key_name = 'counter-' .. i .. '-leakage', -- key-name to use for node-wide timers
            expire = function(counterType) 
                local o2counter = require "lib/o2switch_counter"
                o2counter.leak(counterType)
            end
        }, i)        
    end
    return true
end

-- Export data from a counter
--  @return list|false, errorMsg|nil
function _M.exportDataFromCounter(counterType, filterFunction)
    if filterFunction == nil then
        -- Default filter function (Not really meant to be used, it's more of an example/documentation)
        filterFunction = function(count) 
            return count > 1000
        end
    end

    if type(filterFunction) ~= 'function' then
        return false, 'The filterFunction argument must be LUA Function'
    end

    local dict = counterType == 'i' and ipCounter or domainCounter
    local keys = _M.getKeys(counterType)
    local result = {}

    for _, k in ipairs(keys) do
        local value = dict:get(k)
        if counterType == 'i' and type(value) ~= 'number' then
            dict:delete(k)
            value = -1
        end
        if filterFunction(value) then
            local kk = counterType == 'i' and ipUtils.binaryToStringIp(k) or k
            result[kk] = value
        end
    end

    return result, nil
end

return _M

Sindbad File Manager Version 1.0, Coded By Sindbad EG ~ The Terrorists