--[[ Copyright (c) 2011-2017, Vsevolod Stakhov Copyright (c) 2016-2017, Andrew Lewis Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ]]-- if confighelp then return end local rspamd_logger = require "rspamd_logger" local rspamd_util = require "rspamd_util" local rspamd_lua_utils = require "lua_util" local lua_redis = require "lua_redis" local fun = require "fun" local lua_maps = require "lua_maps" local lua_util = require "lua_util" local lua_verdict = require "lua_verdict" local rspamd_hash = require "rspamd_cryptobox_hash" local lua_selectors = require "lua_selectors" local ts = require("tableshape").types -- A plugin that implements ratelimits using redis local E = {} local N = 'ratelimit' local redis_params -- Senders that are considered as bounce local settings = { bounce_senders = { 'postmaster', 'mailer-daemon', '', 'null', 'fetchmail-daemon', 'mdaemon' }, -- Do not check ratelimits for these recipients whitelisted_rcpts = { 'postmaster', 'mailer-daemon' }, prefix = 'RL', ham_factor_rate = 1.01, spam_factor_rate = 0.99, ham_factor_burst = 1.02, spam_factor_burst = 0.98, max_rate_mult = 5, max_bucket_mult = 10, expire = 60 * 60 * 24 * 2, -- 2 days by default limits = {}, allow_local = false, prefilter = true, } -- Checks bucket, updating it if needed -- KEYS[1] - prefix to update, e.g. RL__ -- KEYS[2] - current time in milliseconds -- KEYS[3] - bucket leak rate (messages per millisecond) -- KEYS[4] - bucket burst -- KEYS[5] - expire for a bucket -- KEYS[6] - number of recipients -- return 1 if message should be ratelimited and 0 if not -- Redis keys used: -- l - last hit -- b - current burst -- dr - current dynamic rate multiplier (*10000) -- db - current dynamic burst multiplier (*10000) local bucket_check_script = [[ local last = redis.call('HGET', KEYS[1], 'l') local now = tonumber(KEYS[2]) local dynr, dynb, leaked = 0, 0, 0 if not last then -- New bucket redis.call('HSET', KEYS[1], 'l', KEYS[2]) redis.call('HSET', KEYS[1], 'b', '0') redis.call('HSET', KEYS[1], 'dr', '10000') redis.call('HSET', KEYS[1], 'db', '10000') redis.call('EXPIRE', KEYS[1], KEYS[5]) return {0, '0', '1', '1', '0'} end last = tonumber(last) local burst = tonumber(redis.call('HGET', KEYS[1], 'b')) -- Perform leak if burst > 0 then if last < tonumber(KEYS[2]) then local rate = tonumber(KEYS[3]) dynr = tonumber(redis.call('HGET', KEYS[1], 'dr')) / 10000.0 if dynr == 0 then dynr = 0.0001 end rate = rate * dynr leaked = ((now - last) * rate) if leaked > burst then leaked = burst end burst = burst - leaked redis.call('HINCRBYFLOAT', KEYS[1], 'b', -(leaked)) redis.call('HSET', KEYS[1], 'l', KEYS[2]) end dynb = tonumber(redis.call('HGET', KEYS[1], 'db')) / 10000.0 if dynb == 0 then dynb = 0.0001 end if burst > 0 and (burst + tonumber(KEYS[6])) > tonumber(KEYS[4]) * dynb then return {1, tostring(burst), tostring(dynr), tostring(dynb), tostring(leaked)} end else burst = 0 redis.call('HSET', KEYS[1], 'b', '0') end return {0, tostring(burst), tostring(dynr), tostring(dynb), tostring(leaked)} ]] local bucket_check_id -- Updates a bucket -- KEYS[1] - prefix to update, e.g. RL__ -- KEYS[2] - current time in milliseconds -- KEYS[3] - dynamic rate multiplier -- KEYS[4] - dynamic burst multiplier -- KEYS[5] - max dyn rate (min: 1/x) -- KEYS[6] - max burst rate (min: 1/x) -- KEYS[7] - expire for a bucket -- KEYS[8] - number of recipients (or increase rate) -- Redis keys used: -- l - last hit -- b - current burst -- dr - current dynamic rate multiplier -- db - current dynamic burst multiplier local bucket_update_script = [[ local last = redis.call('HGET', KEYS[1], 'l') local now = tonumber(KEYS[2]) if not last then -- New bucket redis.call('HSET', KEYS[1], 'l', KEYS[2]) redis.call('HSET', KEYS[1], 'b', '1') redis.call('HSET', KEYS[1], 'dr', '10000') redis.call('HSET', KEYS[1], 'db', '10000') redis.call('EXPIRE', KEYS[1], KEYS[7]) return {1, 1, 1} end local dr, db = 1.0, 1.0 if tonumber(KEYS[5]) > 1 then local rate_mult = tonumber(KEYS[3]) local rate_limit = tonumber(KEYS[5]) dr = tonumber(redis.call('HGET', KEYS[1], 'dr')) / 10000 if rate_mult > 1.0 and dr < rate_limit then dr = dr * rate_mult if dr > 0.0001 then redis.call('HSET', KEYS[1], 'dr', tostring(math.floor(dr * 10000))) else redis.call('HSET', KEYS[1], 'dr', '1') end elseif rate_mult < 1.0 and dr > (1.0 / rate_limit) then dr = dr * rate_mult if dr > 0.0001 then redis.call('HSET', KEYS[1], 'dr', tostring(math.floor(dr * 10000))) else redis.call('HSET', KEYS[1], 'dr', '1') end end end if tonumber(KEYS[6]) > 1 then local rate_mult = tonumber(KEYS[4]) local rate_limit = tonumber(KEYS[6]) db = tonumber(redis.call('HGET', KEYS[1], 'db')) / 10000 if rate_mult > 1.0 and db < rate_limit then db = db * rate_mult if db > 0.0001 then redis.call('HSET', KEYS[1], 'db', tostring(math.floor(db * 10000))) else redis.call('HSET', KEYS[1], 'db', '1') end elseif rate_mult < 1.0 and db > (1.0 / rate_limit) then db = db * rate_mult if db > 0.0001 then redis.call('HSET', KEYS[1], 'db', tostring(math.floor(db * 10000))) else redis.call('HSET', KEYS[1], 'db', '1') end end end local burst = tonumber(redis.call('HGET', KEYS[1], 'b')) if burst < 0 then burst = 0 end redis.call('HINCRBYFLOAT', KEYS[1], 'b', tonumber(KEYS[8])) redis.call('HSET', KEYS[1], 'l', KEYS[2]) redis.call('EXPIRE', KEYS[1], KEYS[7]) return {tostring(burst), tostring(dr), tostring(db)} ]] local bucket_update_id -- message_func(task, limit_type, prefix, bucket, limit_key) local message_func = function(_, limit_type, _, _, _) return string.format('Ratelimit "%s" exceeded', limit_type) end local function load_scripts(cfg, ev_base) bucket_check_id = lua_redis.add_redis_script(bucket_check_script, redis_params) bucket_update_id = lua_redis.add_redis_script(bucket_update_script, redis_params) end local limit_parser local function parse_string_limit(lim, no_error) local function parse_time_suffix(s) if s == 's' then return 1 elseif s == 'm' then return 60 elseif s == 'h' then return 3600 elseif s == 'd' then return 86400 end end local function parse_num_suffix(s) if s == '' then return 1 elseif s == 'k' then return 1000 elseif s == 'm' then return 1000000 elseif s == 'g' then return 1000000000 end end local lpeg = require "lpeg" if not limit_parser then local digit = lpeg.R("09") limit_parser = {} limit_parser.integer = (lpeg.S("+-") ^ -1) * (digit ^ 1) limit_parser.fractional = (lpeg.P(".") ) * (digit ^ 1) limit_parser.number = (limit_parser.integer * (limit_parser.fractional ^ -1)) + (lpeg.S("+-") * limit_parser.fractional) limit_parser.time = lpeg.Cf(lpeg.Cc(1) * (limit_parser.number / tonumber) * ((lpeg.S("smhd") / parse_time_suffix) ^ -1), function (acc, val) return acc * val end) limit_parser.suffixed_number = lpeg.Cf(lpeg.Cc(1) * (limit_parser.number / tonumber) * ((lpeg.S("kmg") / parse_num_suffix) ^ -1), function (acc, val) return acc * val end) limit_parser.limit = lpeg.Ct(limit_parser.suffixed_number * (lpeg.S(" ") ^ 0) * lpeg.S("/") * (lpeg.S(" ") ^ 0) * limit_parser.time) end local t = lpeg.match(limit_parser.limit, lim) if t and t[1] and t[2] and t[2] ~= 0 then return t[2], t[1] end if not no_error then rspamd_logger.errx(rspamd_config, 'bad limit: %s', lim) end return nil end local function str_to_rate(str) local divider,divisor = parse_string_limit(str, false) if not divisor then rspamd_logger.errx(rspamd_config, 'bad rate string: %s', str) return nil end return divisor / divider end local bucket_schema = ts.shape{ burst = ts.number + ts.string / lua_util.dehumanize_number, rate = ts.number + ts.string / str_to_rate, skip_recipients = ts.boolean:is_optional(), } local function parse_limit(name, data) if type(data) == 'table' then -- 2 cases here: -- * old limit in format [burst, rate] -- * vector of strings in Andrew's string format (removed from 1.8.2) -- * proper bucket table if #data == 2 and tonumber(data[1]) and tonumber(data[2]) then -- Old style ratelimit rspamd_logger.warnx(rspamd_config, 'old style ratelimit for %s', name) if tonumber(data[1]) > 0 and tonumber(data[2]) > 0 then return { burst = data[1], rate = data[2] } elseif data[1] ~= 0 then rspamd_logger.warnx(rspamd_config, 'invalid numbers for %s', name) else rspamd_logger.infox(rspamd_config, 'disable limit %s, burst is zero', name) end return nil else local parsed_bucket,err = bucket_schema:transform(data) if not parsed_bucket or err then rspamd_logger.errx(rspamd_config, 'cannot parse bucket for %s: %s; original value: %s', name, err, data) else return parsed_bucket end end elseif type(data) == 'string' then local rep_rate, burst = parse_string_limit(data) rspamd_logger.warnx(rspamd_config, 'old style rate bucket config detected for %s: %s', name, data) if rep_rate and burst then return { burst = burst, rate = burst / rep_rate -- reciprocal } end end return nil end --- Check whether this addr is bounce local function check_bounce(from) return fun.any(function(b) return b == from end, settings.bounce_senders) end local keywords = { ['ip'] = { ['get_value'] = function(task) local ip = task:get_ip() if ip and ip:is_valid() then return tostring(ip) end return nil end, }, ['rip'] = { ['get_value'] = function(task) local ip = task:get_ip() if ip and ip:is_valid() and not ip:is_local() then return tostring(ip) end return nil end, }, ['from'] = { ['get_value'] = function(task) local from = task:get_from(0) if ((from or E)[1] or E).addr then return string.lower(from[1]['addr']) end return nil end, }, ['bounce'] = { ['get_value'] = function(task) local from = task:get_from(0) if not ((from or E)[1] or E).user then return '_' end if check_bounce(from[1]['user']) then return '_' else return nil end end, }, ['asn'] = { ['get_value'] = function(task) local asn = task:get_mempool():get_variable('asn') if not asn then return nil else return asn end end, }, ['user'] = { ['get_value'] = function(task) local auser = task:get_user() if not auser then return nil else return auser end end, }, ['to'] = { ['get_value'] = function(task) return task:get_principal_recipient() end, }, ['digest'] = { ['get_value'] = function(task) return task:get_digest() end, }, ['attachments'] = { ['get_value'] = function(task) local parts = task:get_parts() or E local digests = {} for _,p in ipairs(parts) do if p:get_filename() then table.insert(digests, p:get_digest()) end end if #digests > 0 then return table.concat(digests, '') end return nil end, }, ['files'] = { ['get_value'] = function(task) local parts = task:get_parts() or E local files = {} for _,p in ipairs(parts) do local fname = p:get_filename() if fname then table.insert(files, fname) end end if #files > 0 then return table.concat(files, ':') end return nil end, }, } local function gen_rate_key(task, rtype, bucket) local key_t = {tostring(lua_util.round(100000.0 / bucket.burst))} local key_keywords = lua_util.str_split(rtype, '_') local have_user = false for _, v in ipairs(key_keywords) do local ret if keywords[v] and type(keywords[v]['get_value']) == 'function' then ret = keywords[v]['get_value'](task) end if not ret then return nil end if v == 'user' then have_user = true end if type(ret) ~= 'string' then ret = tostring(ret) end table.insert(key_t, ret) end if have_user and not task:get_user() then return nil end return table.concat(key_t, ":") end local function make_prefix(redis_key, name, bucket) local hash_len = 24 if hash_len > #redis_key then hash_len = #redis_key end local hash = settings.prefix .. string.sub(rspamd_hash.create(redis_key):base32(), 1, hash_len) -- Fill defaults if not bucket.spam_factor_rate then bucket.spam_factor_rate = settings.spam_factor_rate end if not bucket.ham_factor_rate then bucket.ham_factor_rate = settings.ham_factor_rate end if not bucket.spam_factor_burst then bucket.spam_factor_burst = settings.spam_factor_burst end if not bucket.ham_factor_burst then bucket.ham_factor_burst = settings.ham_factor_burst end return { bucket = bucket, name = name, hash = hash } end local function limit_to_prefixes(task, k, v, prefixes) local n = 0 for _,bucket in ipairs(v.buckets) do if v.selector then local selectors = lua_selectors.process_selectors(task, v.selector) if selectors then local combined = lua_selectors.combine_selectors(task, selectors, ':') if type(combined) == 'string' then prefixes[combined] = make_prefix(combined, k, bucket) n = n + 1 else fun.each(function(p) prefixes[p] = make_prefix(p, k, bucket) n = n + 1 end, combined) end end else local prefix = gen_rate_key(task, k, bucket) if prefix then if type(prefix) == 'string' then prefixes[prefix] = make_prefix(prefix, k, bucket) n = n + 1 else fun.each(function(p) prefixes[p] = make_prefix(p, k, bucket) n = n + 1 end, prefix) end end end end return n end local function ratelimit_cb(task) if not settings.allow_local and rspamd_lua_utils.is_rspamc_or_controller(task) then return end -- Get initial task data local ip = task:get_from_ip() if ip and ip:is_valid() and settings.whitelisted_ip then if settings.whitelisted_ip:get_key(ip) then -- Do not check whitelisted ip rspamd_logger.infox(task, 'skip ratelimit for whitelisted IP') return end end -- Parse all rcpts local rcpts = task:get_recipients() local rcpts_user = {} if rcpts then fun.each(function(r) fun.each(function(type) table.insert(rcpts_user, r[type]) end, {'user', 'addr'}) end, rcpts) if fun.any(function(r) return settings.whitelisted_rcpts:get_key(r) end, rcpts_user) then rspamd_logger.infox(task, 'skip ratelimit for whitelisted recipient') return end end -- Get user (authuser) if settings.whitelisted_user then local auser = task:get_user() if settings.whitelisted_user:get_key(auser) then rspamd_logger.infox(task, 'skip ratelimit for whitelisted user') return end end -- Now create all ratelimit prefixes local prefixes = {} local nprefixes = 0 for k,v in pairs(settings.limits) do nprefixes = nprefixes + limit_to_prefixes(task, k, v, prefixes) end for k, hdl in pairs(settings.custom_keywords or E) do local ret, redis_key, bd = pcall(hdl, task) if ret then local bucket = parse_limit(k, bd) if bucket then prefixes[redis_key] = make_prefix(redis_key, k, bucket) end nprefixes = nprefixes + 1 else rspamd_logger.errx(task, 'cannot call handler for %s: %s', k, redis_key) end end local function gen_check_cb(prefix, bucket, lim_name, lim_key) return function(err, data) if err then rspamd_logger.errx('cannot check limit %s: %s %s', prefix, err, data) elseif type(data) == 'table' and data[1] then lua_util.debugm(N, task, "got reply for limit %s (%s / %s); %s burst, %s:%s dyn, %s leaked", prefix, bucket.burst, bucket.rate, data[2], data[3], data[4], data[5]) if data[1] == 1 then -- set symbol only and do NOT soft reject if settings.symbol then task:insert_result(settings.symbol, 1.0, string.format('%s(%s)', lim_name, lim_key)) rspamd_logger.infox(task, 'set_symbol_only: ratelimit "%s(%s)" exceeded, (%s / %s): %s (%s:%s dyn); redis key: %s', lim_name, prefix, bucket.burst, bucket.rate, data[2], data[3], data[4], lim_key) return -- set INFO symbol and soft reject elseif settings.info_symbol then task:insert_result(settings.info_symbol, 1.0, string.format('%s(%s)', lim_name, lim_key)) end rspamd_logger.infox(task, 'ratelimit "%s(%s)" exceeded, (%s / %s): %s (%s:%s dyn); redis key: %s', lim_name, prefix, bucket.burst, bucket.rate, data[2], data[3], data[4], lim_key) task:set_pre_result('soft reject', message_func(task, lim_name, prefix, bucket, lim_key), N) end end end end -- Don't do anything if pre-result has been already set if task:has_pre_result() then return end local _,nrcpt = task:has_recipients('smtp') if not nrcpt or nrcpt <= 0 then nrcpt = 1 end if nprefixes > 0 then -- Save prefixes to the cache to allow update task:cache_set('ratelimit_prefixes', prefixes) local now = rspamd_util.get_time() now = lua_util.round(now * 1000.0) -- Get milliseconds -- Now call check script for all defined prefixes for pr,value in pairs(prefixes) do local bucket = value.bucket local rate = (bucket.rate) / 1000.0 -- Leak rate in messages/ms local bincr = nrcpt if bucket.skip_recipients then bincr = 1 end lua_util.debugm(N, task, "check limit %s:%s -> %s (%s/%s)", value.name, pr, value.hash, bucket.burst, bucket.rate) lua_redis.exec_redis_script(bucket_check_id, {key = value.hash, task = task, is_write = true}, gen_check_cb(pr, bucket, value.name, value.hash), {value.hash, tostring(now), tostring(rate), tostring(bucket.burst), tostring(settings.expire), tostring(bincr)}) end end end local function ratelimit_update_cb(task) if task:has_flag('skip') then return end if not settings.allow_local and lua_util.is_rspamc_or_controller(task) then return end local prefixes = task:cache_get('ratelimit_prefixes') if prefixes then if task:has_pre_result() then -- Already rate limited/greylisted, do nothing lua_util.debugm(N, task, 'pre-action has been set, do not update') return end local verdict = lua_verdict.get_specific_verdict(N, task) local _,nrcpt = task:has_recipients('smtp') if not nrcpt or nrcpt <= 0 then nrcpt = 1 end -- Update each bucket for k, v in pairs(prefixes) do local bucket = v.bucket local function update_bucket_cb(err, data) if err then rspamd_logger.errx(task, 'cannot update rate bucket %s: %s', k, err) else lua_util.debugm(N, task, "updated limit %s:%s -> %s (%s/%s), burst: %s, dyn_rate: %s, dyn_burst: %s", v.name, k, v.hash, bucket.burst, bucket.rate, data[1], data[2], data[3]) end end local now = rspamd_util.get_time() now = lua_util.round(now * 1000.0) -- Get milliseconds local mult_burst = 1.0 local mult_rate = 1.0 if verdict == 'spam' or verdict == 'junk' then mult_burst = bucket.spam_factor_burst or 1.0 mult_rate = bucket.spam_factor_rate or 1.0 elseif verdict == 'ham' then mult_burst = bucket.ham_factor_burst or 1.0 mult_rate = bucket.ham_factor_rate or 1.0 end local bincr = nrcpt if bucket.skip_recipients then bincr = 1 end lua_redis.exec_redis_script(bucket_update_id, {key = v.hash, task = task, is_write = true}, update_bucket_cb, {v.hash, tostring(now), tostring(mult_rate), tostring(mult_burst), tostring(settings.max_rate_mult), tostring(settings.max_bucket_mult), tostring(settings.expire), tostring(bincr)}) end end end local opts = rspamd_config:get_all_opt(N) if opts then settings = lua_util.override_defaults(settings, opts) if opts['limit'] then rspamd_logger.errx(rspamd_config, 'Legacy ratelimit config format no longer supported') end if opts['rates'] and type(opts['rates']) == 'table' then -- new way of setting limits fun.each(function(t, lim) local buckets = {} if type(lim) == 'table' and lim.bucket then if lim.bucket[1] then for _,bucket in ipairs(lim.bucket) do local b = parse_limit(t, bucket) if not b then rspamd_logger.errx(rspamd_config, 'bad ratelimit bucket for %s: "%s"', t, b) return end table.insert(buckets, b) end else local bucket = parse_limit(t, lim.bucket) if not bucket then rspamd_logger.errx(rspamd_config, 'bad ratelimit bucket for %s: "%s"', t, lim.bucket) return end buckets = {bucket} end settings.limits[t] = { buckets = buckets } if lim.selector then local selector = lua_selectors.parse_selector(rspamd_config, lim.selector) if not selector then rspamd_logger.errx(rspamd_config, 'bad ratelimit selector for %s: "%s"', t, lim.selector) settings.limits[t] = nil return end settings.limits[t].selector = selector end else rspamd_logger.warnx(rspamd_config, 'old syntax for ratelimits: %s', lim) buckets = parse_limit(t, lim) if buckets then settings.limits[t] = { buckets = {buckets} } end end end, opts['rates']) end -- Display what's enabled fun.each(function(s) rspamd_logger.infox(rspamd_config, 'enabled ratelimit: %s', s) end, fun.map(function(n,d) return string.format('%s [%s]', n, table.concat(fun.totable(fun.map(function(v) return string.format('%s msgs burst, %s msgs/sec rate', v.burst, v.rate) end, d.buckets)), '; ') ) end, settings.limits)) -- Ret, ret, ret: stupid legacy stuff: -- If we have a string with commas then load it as as static map -- otherwise, apply normal logic of Rspamd maps local wrcpts = opts['whitelisted_rcpts'] if type(wrcpts) == 'string' then if string.find(wrcpts, ',') then settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl( lua_util.rspamd_str_split(wrcpts, ','), 'set', 'Ratelimit whitelisted rcpts') else settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(wrcpts, 'set', 'Ratelimit whitelisted rcpts') end elseif type(opts['whitelisted_rcpts']) == 'table' then settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl(wrcpts, 'set', 'Ratelimit whitelisted rcpts') else -- Stupid default... settings.whitelisted_rcpts = lua_maps.rspamd_map_add_from_ucl( settings.whitelisted_rcpts, 'set', 'Ratelimit whitelisted rcpts') end if opts['whitelisted_ip'] then settings.whitelisted_ip = lua_maps.rspamd_map_add('ratelimit', 'whitelisted_ip', 'radix', 'Ratelimit whitelist ip map') end if opts['whitelisted_user'] then settings.whitelisted_user = lua_maps.rspamd_map_add('ratelimit', 'whitelisted_user', 'set', 'Ratelimit whitelist user map') end settings.custom_keywords = {} if opts['custom_keywords'] then local ret, res_or_err = pcall(loadfile(opts['custom_keywords'])) if ret then opts['custom_keywords'] = {} if type(res_or_err) == 'table' then for k,hdl in pairs(res_or_err) do settings['custom_keywords'][k] = hdl end elseif type(res_or_err) == 'function' then settings['custom_keywords']['custom'] = res_or_err end else rspamd_logger.errx(rspamd_config, 'cannot execute %s: %s', opts['custom_keywords'], res_or_err) settings['custom_keywords'] = {} end end if opts['message_func'] then message_func = assert(load(opts['message_func']))() end redis_params = lua_redis.parse_redis_server('ratelimit') if not redis_params then rspamd_logger.infox(rspamd_config, 'no servers are specified, disabling module') lua_util.disable_module(N, "redis") else local s = { type = settings.prefilter and 'prefilter' or 'callback', name = 'RATELIMIT_CHECK', priority = 7, callback = ratelimit_cb, flags = 'empty,nostat', } local id = rspamd_config:register_symbol(s) if settings.info_symbol then rspamd_config:register_symbol{ type = 'virtual', name = settings.info_symbol, score = 0.0, parent = id } end if settings.symbol then rspamd_config:register_symbol{ type = 'virtual', name = settings.symbol, score = 0.0, -- Might be overridden if needed parent = id } end rspamd_config:register_symbol { type = 'idempotent', name = 'RATELIMIT_UPDATE', callback = ratelimit_update_cb, } end end rspamd_config:add_on_load(function(cfg, ev_base, worker) load_scripts(cfg, ev_base) end)