diff options
Diffstat (limited to 'src/plugins/lua/settings.lua')
-rw-r--r-- | src/plugins/lua/settings.lua | 1437 |
1 files changed, 1437 insertions, 0 deletions
diff --git a/src/plugins/lua/settings.lua b/src/plugins/lua/settings.lua new file mode 100644 index 0000000..69d31d3 --- /dev/null +++ b/src/plugins/lua/settings.lua @@ -0,0 +1,1437 @@ +--[[ +Copyright (c) 2022, Vsevolod Stakhov <vsevolod@rspamd.com> + +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 + +-- This plugin implements user dynamic settings +-- Settings documentation can be found here: +-- https://rspamd.com/doc/configuration/settings.html + +local rspamd_logger = require "rspamd_logger" +local lua_maps = require "lua_maps" +local lua_util = require "lua_util" +local rspamd_ip = require "rspamd_ip" +local rspamd_regexp = require "rspamd_regexp" +local lua_selectors = require "lua_selectors" +local lua_settings = require "lua_settings" +local ucl = require "ucl" +local fun = require "fun" +local rspamd_mempool = require "rspamd_mempool" + +local redis_params + +local settings = {} +local N = "settings" +local settings_initialized = false +local max_pri = 0 +local module_sym_id -- Main module symbol + +local function apply_settings(task, to_apply, id, name) + local cached_name = task:cache_get('settings_name') + if cached_name then + local cached_settings = task:cache_get('settings') + rspamd_logger.warnx(task, "cannot apply settings rule %s (id=%s):" .. + " settings has been already applied by rule %s (id=%s)", + name, id, cached_name, cached_settings.id) + return false + end + + task:set_settings(to_apply) + task:cache_set('settings', to_apply) + task:cache_set('settings_name', name or 'unknown') + + if id then + task:set_settings_id(id) + end + + if to_apply['add_headers'] or to_apply['remove_headers'] then + local rep = { + add_headers = to_apply['add_headers'] or {}, + remove_headers = to_apply['remove_headers'] or {}, + } + task:set_rmilter_reply(rep) + end + + if to_apply.flags and type(to_apply.flags) == 'table' then + for _, fl in ipairs(to_apply.flags) do + task:set_flag(fl) + end + end + + if to_apply.symbols then + -- Add symbols, specified in the settings + if #to_apply.symbols > 0 then + -- Array like symbols + for _, val in ipairs(to_apply.symbols) do + task:insert_result(val, 1.0) + end + else + -- Object like symbols + for k, v in pairs(to_apply.symbols) do + if type(v) == 'table' then + task:insert_result(k, v.score or 1.0, v.options or {}) + elseif tonumber(v) then + task:insert_result(k, tonumber(v)) + end + end + end + end + + if to_apply.subject then + task:set_metric_subject(to_apply.subject) + end + + -- E.g. + -- messages = { smtp_message = "5.3.1 Go away" } + if to_apply.messages and type(to_apply.messages) == 'table' then + fun.each(function(category, message) + task:append_message(message, category) + end, to_apply.messages) + end + + return true +end + +-- Checks for overridden settings within query params and returns 3 values: +-- * Apply element +-- * Settings ID element if found +-- * Priority of the settings according to the place where it is found +-- +-- If no override has been found, it returns `false` +local function check_query_settings(task) + -- Try 'settings' attribute + local settings_id = task:get_settings_id() + local query_set = task:get_request_header('settings') + if query_set then + + local parser = ucl.parser() + local res, err = parser:parse_text(query_set) + if res then + if settings_id then + rspamd_logger.warnx(task, "both settings-id '%s' and settings headers are presented, ignore settings-id; ", + tostring(settings_id)) + end + local settings_obj = parser:get_object() + + -- Treat as low priority + return settings_obj, nil, 1 + else + rspamd_logger.errx(task, 'Parse error: %s', err) + end + end + + local query_maxscore = task:get_request_header('maxscore') + local nset + + if query_maxscore then + if settings_id then + rspamd_logger.infox(task, "both settings id '%s' and maxscore '%s' headers are presented, merge them; " .. + "settings id has priority", + tostring(settings_id), tostring(query_maxscore)) + end + -- We have score limits redefined by request + local ms = tonumber(tostring(query_maxscore)) + if ms then + nset = { + actions = { + reject = ms + } + } + + local query_softscore = task:get_request_header('softscore') + if query_softscore then + local ss = tonumber(tostring(query_softscore)) + nset.actions['add header'] = ss + end + + if not settings_id then + rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions) + -- Maxscore is low priority + return nset, nil, 1 + end + end + end + + if settings_id and settings_initialized then + local cached = lua_settings.settings_by_id(settings_id) + lua_util.debugm(N, task, "check settings id for %s", settings_id) + + if cached then + local elt = cached.settings + if elt['whitelist'] then + elt['apply'] = { whitelist = true } + end + + if elt.apply then + if nset then + elt.apply = lua_util.override_defaults(nset, elt.apply) + end + end + return elt.apply, cached, cached.priority or 1 + else + rspamd_logger.warnx(task, 'no settings id "%s" has been found', settings_id) + if nset then + rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions) + return nset, nil, 1 + end + end + else + if nset then + rspamd_logger.infox(task, 'apply maxscore = %s', nset.actions) + return nset, nil, 1 + end + end + + return false +end + +local function check_addr_setting(expected, addr) + local function check_specific_addr(elt) + if expected.name then + if lua_maps.rspamd_maybe_check_map(expected.name, elt.addr) then + return true + end + end + if expected.user then + if lua_maps.rspamd_maybe_check_map(expected.user, elt.user) then + return true + end + end + if expected.domain and elt.domain then + if lua_maps.rspamd_maybe_check_map(expected.domain, elt.domain) then + return true + end + end + if expected.regexp then + if expected.regexp:match(elt.addr) then + return true + end + end + return false + end + + for _, e in ipairs(addr) do + if check_specific_addr(e) then + return true + end + end + + return false +end + +local function check_string_setting(expected, str) + if expected.regexp then + if expected.regexp:match(str) then + return true + end + elseif expected.check then + if lua_maps.rspamd_maybe_check_map(expected.check, str) then + return true + end + end + return false +end + +local function check_ip_setting(expected, ip) + if not expected[2] then + if lua_maps.rspamd_maybe_check_map(expected[1], ip:to_string()) then + return true + end + else + if expected[2] ~= 0 then + local nip = ip:apply_mask(expected[2]) + if nip and nip:to_string() == expected[1] then + return true + end + elseif ip:to_string() == expected[1] then + return true + end + end + + return false +end + +local function check_map_setting(map, input) + return map:get_key(input) +end + +local function priority_to_string(pri) + if pri then + if pri >= 3 then + return "high" + elseif pri >= 2 then + return "medium" + end + end + + return "low" +end + +-- Check limit for a task +local function check_settings(task) + local function check_specific_setting(rule, matched) + local function process_atom(atom) + local elt = rule.checks[atom] + + if elt then + local input = elt.extract(task) + if not input then + return false + end + + if elt.check(input) then + matched[#matched + 1] = atom + return 1.0 + end + else + rspamd_logger.errx(task, 'error in settings: check %s is not defined!', atom) + end + + return 0 + end + + local res = rule.expression and rule.expression:process(process_atom) or rule.implicit + + if res and res > 0 then + if rule['whitelist'] then + rule['apply'] = { whitelist = true } + end + + return rule + end + + return nil + end + + -- Check if we have override as query argument + local query_apply, id_elt, priority = check_query_settings(task) + + local function maybe_apply_query_settings() + if query_apply then + if id_elt then + apply_settings(task, query_apply, id_elt.id, id_elt.name) + rspamd_logger.infox(task, "applied settings id %s(%s); priority %s", + id_elt.name, id_elt.id, priority_to_string(priority)) + else + apply_settings(task, query_apply, nil, 'HTTP query') + rspamd_logger.infox(task, "applied settings from query; priority %s", + priority_to_string(priority)) + end + end + end + + local min_pri = 1 + if query_apply then + if priority >= min_pri then + -- Do not check lower or equal priorities + min_pri = priority + 1 + end + + if priority > max_pri then + -- Our internal priorities are lower then a priority from query, so no need to check + maybe_apply_query_settings() + + return + end + elseif id_elt and type(id_elt.settings) == 'table' and id_elt.settings.external_map then + local external_map = id_elt.settings.external_map + local selector_result = external_map.selector(task) + + if selector_result then + external_map.map:get_key(selector_result, nil, task) + -- No more selection logic + return + else + rspamd_logger.infox("cannot query selector to make external map request") + end + end + + -- Do not waste resources + if not settings_initialized then + maybe_apply_query_settings() + return + end + + -- Match rules according their order + local applied = false + + for pri = max_pri, min_pri, -1 do + if not applied and settings[pri] then + for _, s in ipairs(settings[pri]) do + local matched = {} + + local result = check_specific_setting(s.rule, matched) + lua_util.debugm(N, task, "check for settings element %s; result = %s", + s.name, result) + -- Can use xor here but more complicated for reading + if result then + if s.rule.apply then + if s.rule.id then + -- Extract static settings + local cached = lua_settings.settings_by_id(s.rule.id) + + if not cached or not cached.settings or not cached.settings.apply then + rspamd_logger.errx(task, 'unregistered settings id found: %s!', s.rule.id) + else + rspamd_logger.infox(task, "<%s> apply static settings %s (id = %s); %s matched; priority %s", + task:get_message_id(), + cached.name, s.rule.id, + table.concat(matched, ','), + priority_to_string(pri)) + apply_settings(task, cached.settings.apply, s.rule.id, s.name) + end + + else + -- Dynamic settings + rspamd_logger.infox(task, "<%s> apply settings according to rule %s (%s matched)", + task:get_message_id(), s.name, table.concat(matched, ',')) + apply_settings(task, s.rule.apply, nil, s.name) + end + + applied = true + elseif s.rule.external_map then + local external_map = s.rule.external_map + local selector_result = external_map.selector(task) + + if selector_result then + external_map.map:get_key(selector_result, nil, task) + -- No more selection logic + return + else + rspamd_logger.infox("cannot query selector to make external map request") + end + end + if s.rule['symbols'] then + -- Add symbols, specified in the settings + fun.each(function(val) + task:insert_result(val, 1.0) + end, s.rule['symbols']) + end + end + end + end + end + + if not applied then + maybe_apply_query_settings() + end + +end + +local function convert_to_table(chk_elt, out) + if type(chk_elt) == 'string' then + return { out } + end + + return out +end + +local function gen_settings_external_cb(name) + return function(result, err_or_data, code, task) + if result then + local parser = ucl.parser() + + local res, ucl_err = parser:parse_text(err_or_data) + if not res then + rspamd_logger.warnx(task, 'cannot parse settings from the external map %s: %s', + name, ucl_err) + else + local obj = parser:get_object() + rspamd_logger.infox(task, "<%s> apply settings according to the external map %s", + name, task:get_message_id()) + apply_settings(task, obj, nil, 'external_map') + end + else + rspamd_logger.infox(task, "<%s> no settings returned from the external map %s: %s (code = %s)", + task:get_message_id(), name, err_or_data, code) + end + end +end + +-- Process IP address: converted to a table {ip, mask} +local function process_ip_condition(ip) + local out = {} + + if type(ip) == "table" then + for _, v in ipairs(ip) do + table.insert(out, process_ip_condition(v)) + end + elseif type(ip) == "string" then + local slash = string.find(ip, '/') + + if not slash then + -- Just a plain IP address + local res = rspamd_ip.from_string(ip) + + if res:is_valid() then + out[1] = res:to_string() + out[2] = 0 + else + -- It can still be a map + out[1] = ip + end + else + local res = rspamd_ip.from_string(string.sub(ip, 1, slash - 1)) + local mask = tonumber(string.sub(ip, slash + 1)) + + if res:is_valid() then + out[1] = res:to_string() + out[2] = mask + else + rspamd_logger.errx(rspamd_config, "bad IP address: " .. ip) + return nil + end + end + else + return nil + end + + return out +end + +-- Process email like condition, converted to a table with fields: +-- name - full email (surprise!) +-- user - user part +-- domain - domain part +-- regexp - full email regexp (yes, it sucks) +local function process_email_condition(addr) + local out = {} + if type(addr) == "table" then + for _, v in ipairs(addr) do + table.insert(out, process_email_condition(v)) + end + elseif type(addr) == "string" then + if string.sub(addr, 1, 4) == "map:" then + -- It is map, don't apply any extra logic + out['name'] = addr + else + local start = string.sub(addr, 1, 1) + if start == '/' then + -- It is a regexp + local re = rspamd_regexp.create(addr) + if re then + out['regexp'] = re + else + rspamd_logger.errx(rspamd_config, "bad regexp: " .. addr) + return nil + end + + elseif start == '@' then + -- It is a domain if form @domain + out['domain'] = string.sub(addr, 2) + else + -- Check user@domain parts + local at = string.find(addr, '@') + if at then + -- It is full address + out['name'] = addr + else + -- It is a user + out['user'] = addr + end + end + end + else + return nil + end + + return out +end + +-- Convert a plain string condition to a table: +-- check - string to match +-- regexp - regexp to match +local function process_string_condition(addr) + local out = {} + if type(addr) == "table" then + for _, v in ipairs(addr) do + table.insert(out, process_string_condition(v)) + end + elseif type(addr) == "string" then + if string.sub(addr, 1, 4) == "map:" then + -- It is map, don't apply any extra logic + out['check'] = addr + else + local start = string.sub(addr, 1, 1) + if start == '/' then + -- It is a regexp + local re = rspamd_regexp.create(addr) + if re then + out['regexp'] = re + else + rspamd_logger.errx(rspamd_config, "bad regexp: " .. addr) + return nil + end + + else + out['check'] = addr + end + end + else + return nil + end + + return out +end + +local function get_priority (elt) + local pri_tonum = function(p) + if p then + if type(p) == "number" then + return tonumber(p) + elseif type(p) == "string" then + if p == "high" then + return 3 + elseif p == "medium" then + return 2 + end + + end + + end + + return 1 + end + + return pri_tonum(elt['priority']) +end + +-- Used to create a checking closure: if value matches expected somehow, return true +local function gen_check_closure(expected, check_func) + return function(value) + if not value then + return false + end + + if type(value) == 'function' then + value = value() + end + + if value then + + if not check_func then + check_func = function(a, b) + return a == b + end + end + + local ret + if type(expected) == 'table' then + ret = fun.any(function(d) + return check_func(d, value) + end, expected) + else + ret = check_func(expected, value) + end + if ret then + return true + end + end + + return false + end +end + +-- Process settings based on their priority +local function process_settings_table(tbl, allow_ids, mempool, is_static) + + -- Check the setting element internal data + local process_setting_elt = function(name, elt) + + lua_util.debugm(N, rspamd_config, 'process settings "%s"', name) + + local out = {} + + local checks = {} + if elt.ip then + local ips_table = process_ip_condition(elt['ip']) + + if ips_table then + lua_util.debugm(N, rspamd_config, 'added ip condition to "%s": %s', + name, ips_table) + checks.ip = { + check = gen_check_closure(convert_to_table(elt.ip, ips_table), check_ip_setting), + extract = function(task) + local ip = task:get_from_ip() + if ip and ip:is_valid() then + return ip + end + return nil + end, + } + end + end + if elt.ip_map then + local ips_map = lua_maps.map_add_from_ucl(elt.ip_map, 'radix', + 'settings ip map for ' .. name) + + if ips_map then + lua_util.debugm(N, rspamd_config, 'added ip_map condition to "%s"', + name) + checks.ip_map = { + check = gen_check_closure(ips_map, check_map_setting), + extract = function(task) + local ip = task:get_from_ip() + if ip and ip:is_valid() then + return ip + end + return nil + end, + } + end + end + + if elt.client_ip then + local client_ips_table = process_ip_condition(elt.client_ip) + + if client_ips_table then + lua_util.debugm(N, rspamd_config, 'added client_ip condition to "%s": %s', + name, client_ips_table) + checks.client_ip = { + check = gen_check_closure(convert_to_table(elt.client_ip, client_ips_table), + check_ip_setting), + extract = function(task) + local ip = task:get_client_ip() + if ip:is_valid() then + return ip + end + return nil + end, + } + end + end + if elt.client_ip_map then + local ips_map = lua_maps.map_add_from_ucl(elt.ip_map, 'radix', + 'settings client ip map for ' .. name) + + if ips_map then + lua_util.debugm(N, rspamd_config, 'added client ip_map condition to "%s"', + name) + checks.client_ip_map = { + check = gen_check_closure(ips_map, check_map_setting), + extract = function(task) + local ip = task:get_client_ip() + if ip and ip:is_valid() then + return ip + end + return nil + end, + } + end + end + + if elt.from then + local from_condition = process_email_condition(elt.from) + + if from_condition then + lua_util.debugm(N, rspamd_config, 'added from condition to "%s": %s', + name, from_condition) + checks.from = { + check = gen_check_closure(convert_to_table(elt.from, from_condition), + check_addr_setting), + extract = function(task) + return task:get_from(1) + end, + } + end + end + + if elt.rcpt then + local rcpt_condition = process_email_condition(elt.rcpt) + if rcpt_condition then + lua_util.debugm(N, rspamd_config, 'added rcpt condition to "%s": %s', + name, rcpt_condition) + checks.rcpt = { + check = gen_check_closure(convert_to_table(elt.rcpt, rcpt_condition), + check_addr_setting), + extract = function(task) + return task:get_recipients(1) + end, + } + end + end + + if elt.from_mime then + local from_mime_condition = process_email_condition(elt.from_mime) + + if from_mime_condition then + lua_util.debugm(N, rspamd_config, 'added from_mime condition to "%s": %s', + name, from_mime_condition) + checks.from_mime = { + check = gen_check_closure(convert_to_table(elt.from_mime, from_mime_condition), + check_addr_setting), + extract = function(task) + return task:get_from(2) + end, + } + end + end + + if elt.rcpt_mime then + local rcpt_mime_condition = process_email_condition(elt.rcpt_mime) + if rcpt_mime_condition then + lua_util.debugm(N, rspamd_config, 'added rcpt mime condition to "%s": %s', + name, rcpt_mime_condition) + checks.rcpt_mime = { + check = gen_check_closure(convert_to_table(elt.rcpt_mime, rcpt_mime_condition), + check_addr_setting), + extract = function(task) + return task:get_recipients(2) + end, + } + end + end + + if elt.user then + local user_condition = process_email_condition(elt.user) + if user_condition then + lua_util.debugm(N, rspamd_config, 'added user condition to "%s": %s', + name, user_condition) + checks.user = { + check = gen_check_closure(convert_to_table(elt.user, user_condition), + check_addr_setting), + extract = function(task) + local uname = task:get_user() + local user = {} + if uname then + user[1] = {} + local localpart, domainpart = string.gmatch(uname, "(.+)@(.+)")() + if localpart then + user[1]["user"] = localpart + user[1]["domain"] = domainpart + user[1]["addr"] = uname + else + user[1]["user"] = uname + user[1]["addr"] = uname + end + + return user + end + + return nil + end, + } + end + end + + if elt.hostname then + local hostname_condition = process_string_condition(elt.hostname) + if hostname_condition then + lua_util.debugm(N, rspamd_config, 'added hostname condition to "%s": %s', + name, hostname_condition) + checks.hostname = { + check = gen_check_closure(convert_to_table(elt.hostname, hostname_condition), + check_string_setting), + extract = function(task) + return task:get_hostname() or '' + end, + } + end + end + + if elt.authenticated then + lua_util.debugm(N, rspamd_config, 'added authenticated condition to "%s"', + name) + checks.authenticated = { + check = function(value) + if value then + return true + end + return false + end, + extract = function(task) + return task:get_user() + end + } + end + + if elt['local'] then + lua_util.debugm(N, rspamd_config, 'added local condition to "%s"', + name) + checks['local'] = { + check = function(value) + if value then + return true + end + return false + end, + extract = function(task) + local ip = task:get_from_ip() + if not ip or not ip:is_valid() then + return nil + end + + if ip:is_local() then + return true + else + return nil + end + end + } + end + + local aliases = {} + -- This function is used to convert compound condition with + -- generic type and specific part (e.g. `header`, `Content-Transfer-Encoding`) + -- to a set of usable check elements: + -- `generic:specific` - most common part + -- `generic:<order>` - e.g. `header:1` for the first header + -- `generic:safe` - replace unsafe stuff with safe + lowercase + -- also aliases entry is set to avoid implicit expression + local function process_compound_condition(cond, generic, specific) + local full_key = generic .. ':' .. specific + checks[full_key] = cond + + -- Try numeric key + for i = 1, 1000 do + local num_key = generic .. ':' .. tostring(i) + if not checks[num_key] then + checks[num_key] = cond + aliases[num_key] = true + break + end + end + + local safe_key = generic .. ':' .. + specific:gsub('[:%-+&|><]', '_') + :gsub('%(', '[') + :gsub('%)', ']') + :lower() + + if not checks[safe_key] then + checks[safe_key] = cond + aliases[full_key] = true + end + + return safe_key + end + -- Headers are tricky: + -- We create an closure with extraction function depending on header name + -- We also inserts it into `checks` table as an atom in form header:<hname> + -- Check function depends on the input: + -- * for something that looks like `header = "/bar/"` we create a regexp + -- * for something that looks like `header = true` we just check the existence + local function process_header_elt(table_element, extractor_func) + if elt[table_element] then + for k, v in pairs(elt[table_element]) do + if type(v) == 'string' then + local re = rspamd_regexp.create(v) + if re then + local cond = { + check = function(values) + return fun.any(function(c) + return re:match(c) + end, values) + end, + extract = extractor_func(k), + } + local skey = process_compound_condition(cond, table_element, + k) + lua_util.debugm(N, rspamd_config, 'added %s condition to "%s": %s =~ %s', + skey, name, k, v) + end + elseif type(v) == 'boolean' then + local cond = { + check = function(values) + if #values == 0 then + return (not v) + end + return v + end, + extract = extractor_func(k), + } + + local skey = process_compound_condition(cond, table_element, + k) + lua_util.debugm(N, rspamd_config, 'added %s condition to "%s": %s == %s', + skey, name, k, v) + else + rspamd_logger.errx(rspamd_config, 'invalid %s %s = %s', table_element, k, v) + end + end + end + end + + process_header_elt('request_header', function(hname) + return function(task) + local rh = task:get_request_header(hname) + if rh then + return { rh } + end + return {} + end + end) + process_header_elt('header', function(hname) + return function(task) + local rh = task:get_header_full(hname) + if rh then + return fun.totable(fun.map(function(h) + return h.decoded + end, rh)) + end + return {} + end + end) + + if elt.selector then + local sel = lua_selectors.create_selector_closure(rspamd_config, elt.selector, + elt.delimiter or "") + + if sel then + local cond = { + check = function(values) + return fun.any(function(c) + return c + end, values) + end, + extract = sel, + } + local skey = process_compound_condition(cond, 'selector', elt.selector) + lua_util.debugm(N, rspamd_config, 'added selector condition to "%s": %s', + name, skey) + end + + end + + -- Special, special case! + local inverse = false + if elt.inverse then + lua_util.debugm(N, rspamd_config, 'added inverse condition to "%s"', + name) + inverse = true + end + + -- Count checks and create Rspamd expression from a set of rules + local nchecks = 0 + for k, _ in pairs(checks) do + if not aliases[k] then + nchecks = nchecks + 1 + end + end + + if nchecks > 0 then + -- Now we can deal with the expression! + if not elt.expression then + -- Artificial & expression to deal with the legacy parts + -- Here we get all keys and concatenate them with '&&' + local s = ' && ' + -- By De Morgan laws + if inverse then + s = ' || ' + end + -- Exclude aliases and join all checks by key + local expr_str = table.concat(lua_util.keys(fun.filter( + function(k, _) + return not aliases[k] + end, + checks)), s) + + if inverse then + expr_str = string.format('!(%s)', expr_str) + end + + elt.expression = expr_str + lua_util.debugm(N, rspamd_config, 'added implicit settings expression for %s: %s', + name, expr_str) + end + + -- Parse expression's sanity + local function parse_atom(str) + local atom = table.concat(fun.totable(fun.take_while(function(c) + if string.find(', \t()><+!|&\n', c, 1, true) then + return false + end + return true + end, fun.iter(str))), '') + + if checks[atom] then + return atom + end + + rspamd_logger.errx(rspamd_config, + 'use of undefined element "%s" when parsing settings expression, known checks: %s', + atom, table.concat(fun.totable(fun.map(function(k, _) + return k + end, checks)), ',')) + + return nil + end + + local rspamd_expression = require "rspamd_expression" + out.expression = rspamd_expression.create(elt.expression, parse_atom, + mempool) + out.checks = checks + + if not out.expression then + rspamd_logger.errx(rspamd_config, 'cannot parse expression %s for %s', + elt.expression, name) + else + lua_util.debugm(N, rspamd_config, 'registered settings %s with %s checks', + name, nchecks) + end + else + if not elt.disabled and elt.external_map then + lua_util.debugm(N, rspamd_config, 'registered settings %s with no checks, assume it as implicit', + name) + out.implicit = 1 + end + end + + -- Process symbols part/apply part + if elt['symbols'] then + lua_util.debugm(N, rspamd_config, 'added symbols condition to "%s": %s', + name, elt.symbols) + out['symbols'] = elt['symbols'] + end + + --[[ + external_map = { + map = { ... }; + selector = "..."; + } + --]] + if type(elt.external_map) == 'table' + and elt.external_map.map and elt.external_map.selector then + local maybe_external_map = {} + maybe_external_map.map = lua_maps.map_add_from_ucl(elt.external_map.map, "", + string.format("External map for settings element %s", name), + gen_settings_external_cb(name)) + maybe_external_map.selector = lua_selectors.create_selector_closure_fn(rspamd_config, + rspamd_config, elt.external_map.selector, ";", lua_selectors.kv_table_from_pairs) + + if maybe_external_map.map and maybe_external_map.selector then + rspamd_logger.infox(rspamd_config, "added external map for user's settings %s", name) + out.external_map = maybe_external_map + else + local incorrect_element + if not maybe_external_map.map then + incorrect_element = "map definition" + else + incorrect_element = "selector definition" + end + rspamd_logger.warnx(rspamd_config, "cannot add external map for user's settings; incorrect element: %s", + incorrect_element) + out.external_map = nil + end + end + + if not elt.external_map then + if elt['apply'] then + -- Just insert all metric results to the action key + out['apply'] = elt['apply'] + elseif elt['whitelist'] or elt['want_spam'] then + out['whitelist'] = true + else + rspamd_logger.errx(rspamd_config, "no actions in settings: " .. name) + return nil + end + end + + if allow_ids then + if not elt.id then + elt.id = name + end + + if elt['id'] then + -- We are here from a postload script + out.id = lua_settings.register_settings_id(elt.id, out, true) + lua_util.debugm(N, rspamd_config, + 'added settings id to "%s": %s -> %s', + name, elt.id, out.id) + end + + if not is_static then + -- If we apply that from map + -- In fact, it is useless and evil but who cares... + if elt.apply and elt.apply.symbols then + -- Register virtual symbols + for k, v in pairs(elt.apply.symbols) do + local rtb = { + type = 'virtual', + parent = module_sym_id, + } + if type(k) == 'number' and type(v) == 'string' then + rtb.name = v + elseif type(k) == 'string' then + rtb.name = k + end + if out.id then + rtb.allowed_ids = tostring(elt.id) + end + rspamd_config:register_symbol(rtb) + end + end + end + else + if elt['id'] then + rspamd_logger.errx(rspamd_config, + 'cannot set static IDs from dynamic settings, please read the docs') + end + end + + return out + end + + settings_initialized = false + -- filter trash in the input + local ft = fun.filter( + function(_, elt) + if type(elt) == "table" then + return true + end + return false + end, tbl) + + -- clear all settings + max_pri = 0 + local nrules = 0 + for k in pairs(settings) do + settings[k] = {} + end + -- fill new settings by priority + fun.for_each(function(k, v) + local pri = get_priority(v) + if pri > max_pri then + max_pri = pri + end + if not settings[pri] then + settings[pri] = {} + end + local s = process_setting_elt(k, v) + if s then + table.insert(settings[pri], { name = k, rule = s }) + nrules = nrules + 1 + end + end, ft) + -- sort settings with equal priorities in alphabetical order + for pri, _ in pairs(settings) do + table.sort(settings[pri], function(a, b) + return a.name < b.name + end) + end + + settings_initialized = true + lua_settings.load_all_settings(true) + rspamd_logger.infox(rspamd_config, 'loaded %s elements of settings', nrules) + + return true +end + +-- Parse settings map from the ucl line +local settings_map_pool + +local function process_settings_map(map_text) + local parser = ucl.parser() + local res, err = parser:parse_text(map_text) + + if not res then + rspamd_logger.warnx(rspamd_config, 'cannot parse settings map: ' .. err) + else + if settings_map_pool then + settings_map_pool:destroy() + end + + settings_map_pool = rspamd_mempool.create() + local obj = parser:get_object() + if obj['settings'] then + process_settings_table(obj['settings'], false, + settings_map_pool, false) + else + process_settings_table(obj, false, settings_map_pool, + false) + end + end + + return res +end + +local function gen_redis_callback(handler, id) + return function(task) + local key = handler(task) + + local function redis_settings_cb(err, data) + if not err and type(data) == 'table' then + for _, d in ipairs(data) do + if type(d) == 'string' then + local parser = ucl.parser() + local res, ucl_err = parser:parse_text(d) + if not res then + rspamd_logger.warnx(rspamd_config, 'cannot parse settings from redis: %s', + ucl_err) + else + local obj = parser:get_object() + rspamd_logger.infox(task, "<%1> apply settings according to redis rule %2", + task:get_message_id(), id) + apply_settings(task, obj, nil, 'redis') + break + end + end + end + elseif err then + rspamd_logger.errx(task, 'Redis error: %1', err) + end + end + + if not key then + lua_util.debugm(N, task, 'handler number %s returned nil', id) + return + end + + local keys + if type(key) == 'table' then + keys = key + else + keys = { key } + end + key = keys[1] + + local ret, _, _ = rspamd_redis_make_request(task, + redis_params, -- connect params + key, -- hash key + false, -- is write + redis_settings_cb, --callback + 'MGET', -- command + keys -- arguments + ) + if not ret then + rspamd_logger.errx(task, 'Redis MGET failed: %s', ret) + end + end +end + +local redis_section = rspamd_config:get_all_opt("settings_redis") +local redis_key_handlers = {} + +if redis_section then + redis_params = rspamd_parse_redis_server('settings_redis') + if redis_params then + local handlers = redis_section.handlers + + for id, h in pairs(handlers) do + local chunk, err = load(h) + + if not chunk then + rspamd_logger.errx(rspamd_config, 'Cannot load handler from string: %s', + tostring(err)) + else + local res, func = pcall(chunk) + if not res then + rspamd_logger.errx(rspamd_config, 'Cannot add handler from string: %s', + tostring(func)) + else + redis_key_handlers[id] = func + end + end + end + end + + fun.each(function(id, h) + rspamd_config:register_symbol({ + name = 'REDIS_SETTINGS' .. tostring(id), + type = 'prefilter', + callback = gen_redis_callback(h, id), + priority = lua_util.symbols_priorities.top, + flags = 'empty,nostat', + augmentations = { string.format("timeout=%f", redis_params.timeout or 0.0) }, + }) + end, redis_key_handlers) +end + +module_sym_id = rspamd_config:register_symbol({ + name = 'SETTINGS_CHECK', + type = 'prefilter', + callback = check_settings, + priority = lua_util.symbols_priorities.top, + flags = 'empty,nostat,explicit_disable,ignore_passthrough', +}) + +local set_section = rspamd_config:get_all_opt("settings") + +if set_section and set_section[1] and type(set_section[1]) == "string" then + -- Just a map of ucl + local map_attrs = { + url = set_section[1], + description = "settings map", + callback = process_settings_map, + opaque_data = true + } + if not rspamd_config:add_map(map_attrs) then + rspamd_logger.errx(rspamd_config, 'cannot load settings from %1', set_section) + end +elseif set_section and type(set_section) == "table" then + settings_map_pool = rspamd_mempool.create() + -- We need to check this table and register static symbols first + -- Postponed settings init is needed to ensure that all symbols have been + -- registered BEFORE settings plugin. Otherwise, we can have inconsistent settings expressions + fun.each(function(_, elt) + if elt.register_symbols then + for k, v in pairs(elt.register_symbols) do + local rtb = { + type = 'virtual', + parent = module_sym_id, + } + if type(k) == 'number' and type(v) == 'string' then + rtb.name = v + elseif type(k) == 'string' then + rtb.name = k + if type(v) == 'table' then + for kk, vv in pairs(v) do + -- Enrich table wih extra values + rtb[kk] = vv + end + end + end + rspamd_config:register_symbol(rtb) + end + end + if elt.apply and elt.apply.symbols then + -- Register virtual symbols + for k, v in pairs(elt.apply.symbols) do + local rtb = { + type = 'virtual', + parent = module_sym_id, + } + if type(k) == 'number' and type(v) == 'string' then + rtb.name = v + elseif type(k) == 'string' then + rtb.name = k + end + rspamd_config:register_symbol(rtb) + end + end + end, + -- Include only settings, exclude all maps + fun.filter( + function(_, elt) + if type(elt) == "table" then + return true + end + return false + end, set_section) + ) + + rspamd_config:add_post_init(function() + process_settings_table(set_section, true, settings_map_pool, true) + end, 100) +end + +rspamd_config:add_config_unload(function() + if settings_map_pool then + settings_map_pool:destroy() + end +end) |