--[[ Copyright (c) 2011-2015, Vsevolod Stakhov 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 rspamd_maps = require "lua_maps" local lua_squeeze = require "lua_squeeze_rules" 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 ucl = require "ucl" local fun = require "fun" local redis_params local settings = {} local N = "settings" local settings_ids = {} local settings_initialized = false local max_pri = 0 local selectors_cache = {} -- Used to speed up selectors in settings local function apply_settings(task, to_apply) task:set_settings(to_apply) task:cache_set('settings', to_apply) lua_squeeze.handle_settings(task, to_apply) 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 fun.each(function(val) task:insert_result(val, 1.0) end, fun.filter(function(elt) return type(elt) == 'string' end, to_apply.symbols)) else -- Object like symbols fun.each(function(k, val) task:insert_result(k, val.score or 1.0, val.options or {}) end, fun.filter(function(_, elt) return type(elt) == 'table' end, to_apply.symbols)) 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 end -- Checks for overridden settings within query params and returns 'true' if -- settings are overridden local function check_query_settings(task) -- Try 'settings' attribute local query_set = task:get_request_header('settings') if query_set then local parser = ucl.parser() local res,err = parser:parse_string(tostring(query_set)) if res then local settings_obj = parser:get_object() apply_settings(task, settings_obj) return true else rspamd_logger.errx(task, 'Parse error: %s', err) end end local query_maxscore = task:get_request_header('maxscore') if query_maxscore then -- We have score limits redefined by request local ms = tonumber(tostring(query_maxscore)) if ms then local nset = { default = { actions = { reject = ms } } } local query_softscore = task:get_request_header('softscore') if query_softscore then local ss = tonumber(tostring(query_softscore)) nset['default']['actions']['add header'] = ss end apply_settings(task, nset) return true end end local settings_id = task:get_request_header('settings-id') if settings_id and settings_initialized then -- settings_id is rspamd text, so need to convert it to string for lua local id_str = tostring(settings_id) local elt = settings_ids[id_str] if elt then if elt['whitelist'] then elt['apply'] = {whitelist = true} end if elt.apply then apply_settings(task, elt['apply']) rspamd_logger.infox(task, "applying settings id %s", id_str) return true end end end return false end -- Check limit for a task local function check_settings(task) local function check_addr_setting(rule, addr) local function check_specific_addr(elt) if rule['name'] then if rspamd_maps.rspamd_maybe_check_map(rule['name'], elt['addr']) then return true end end if rule['user'] then if rspamd_maps.rspamd_maybe_check_map(rule['user'], elt['user']) then return true end end if rule['domain'] and elt['domain'] then if rspamd_maps.rspamd_maybe_check_map(rule['domain'], elt['domain']) then return true end end if rule['regexp'] then if rule['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_ip_setting(rule, ip) if not rule[2] then if rspamd_maps.rspamd_maybe_check_map(rule[1], ip:to_string()) then return true end else if rule[2] ~= 0 then local nip = ip:apply_mask(rule[2]) if nip and nip:to_string() == rule[1]:to_string() then return true end elseif ip:to_string() == rule[1]:to_string() then return true end end return false end local function check_specific_setting(rule_name, rule, data, matched) local res = false local function ip_valid(ip) return ip:is_valid() end local function not_empty(s) return #s > 0 end local function generic_check(value, to_check, check_func, what, valid_func) if not to_check then return true end if type(value) == 'function' then value = value() end if value then if valid_func then if not valid_func(value) then return false end end if not check_func then check_func = function(a, b) return a == b end end local ret = fun.any(function(d) return check_func(d, value) end, to_check) if ret then res = true matched[#matched + 1] = what else return false end else return false end return true end if not generic_check(data.ip, rule.ip, check_ip_setting, 'ip', ip_valid) then return nil end if not generic_check(data.client_ip, rule.client_ip, check_ip_setting, 'client_ip', ip_valid) then return nil end if not generic_check(data.from, rule.from, check_addr_setting, 'from') then return nil end if not generic_check(data.from_mime, rule.from_mime, check_addr_setting, 'from_mime') then return nil end if not generic_check(data.rcpt, rule.rcpt, check_addr_setting, 'rcpt') then return nil end if not generic_check(data.rcpt_mime, rule.rcpt_mime, check_addr_setting, 'rcpt_mime') then return nil end if not generic_check(data.user, rule.user, check_addr_setting, 'user') then return nil end if not generic_check(data.hostname, rule.hostname, check_addr_setting, 'hostname', not_empty) then return nil end -- Non generic checks if rule.authenticated then if data.user[1] then res = true matched[#matched + 1] = 'authenticated' end if not res then return nil end end if rule['local'] then if not data.ip or not data.ip:is_valid() then return nil end if data.ip:is_local() then matched[#matched + 1] = 'local' res = true else return nil end end if rule.request_header then for hname, pattern in pairs(rule.request_header) do local hvalue = task:get_request_header(hname) res = (hvalue and pattern:match(hvalue)) if res then matched[#matched + 1] = 'req_header: ' .. hname break end end if not res then return nil end end if rule.header then for _,elt in ipairs(rule.header) do for hname,patterns in pairs(elt) do for _,pattern in ipairs(patterns) do local hvalue = task:get_header(hname) res = (hvalue and pattern:match(hvalue)) if res then matched[#matched + 1] = 'header: ' .. hname break end end if res then break end end if res then break end end if not res then return nil end end if rule.selector then res = fun.all(function(s) return s(task) end, rule.selector) if res then matched[#matched + 1] = 'selector' end end if res then if rule['whitelist'] then rule['apply'] = {whitelist = true} end return rule end return nil end -- Check if we have override as query argument if check_query_settings(task) then return end -- Do not waste resources if not settings_initialized then return end lua_util.debugm(N, task, "check for settings") local data = { ip = task:get_from_ip(), client_ip = task:get_client_ip(), from = task:get_from(1), from_mime = task:get_from(2), rcpt = task:get_recipients(1), rcpt_mime = task:get_recipients(2), hostname = task:get_hostname() or '', user = {} } local uname = task:get_user() if uname then data.user[1] = {} local localpart, domainpart = string.gmatch(uname, "(.+)@(.+)")() if localpart then data.user[1]["user"] = localpart data.user[1]["domain"] = domainpart data.user[1]["addr"] = uname else data.user[1]["user"] = uname data.user[1]["addr"] = uname end end -- Match rules according their order local applied = false for pri = max_pri,1,-1 do if not applied and settings[pri] then for _,s in ipairs(settings[pri]) do local matched = {} local result = check_specific_setting(s.name, s.rule, data, matched) -- Can use xor here but more complicated for reading if (result and not s.rule.inverse) or (not result and s.rule.inverse) then rspamd_logger.infox(task, "<%s> apply settings according to rule %s (%s matched)", task:get_message_id(), s.name, table.concat(matched, ',')) if s.rule['apply'] then apply_settings(task, s.rule['apply']) applied = true 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 end -- Process settings based on their priority local function process_settings_table(tbl) local get_priority = function(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 -- Check the setting element internal data local process_setting_elt = function(name, elt) -- Process IP address local function process_ip(ip) local out = {} if type(ip) == "table" then for _,v in ipairs(ip) do table.insert(out, process_ip(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 out[2] = 0 else -- It can still be a map out[1] = res 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 out[2] = mask else rspamd_logger.errx(rspamd_config, "bad IP address: " .. ip) return nil end end else return nil end return out end local function process_addr(addr) local out = {} if type(addr) == "table" then for _,v in ipairs(addr) do table.insert(out, process_addr(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 local check_table = function(chk_elt, out) if type(chk_elt) == 'string' then return {out} end return out end local out = {} if elt['ip'] then local ip = process_ip(elt['ip']) if ip then out['ip'] = check_table(elt['ip'], ip) end end if elt['client_ip'] then local ip = process_ip(elt['client_ip']) if ip then out['client_ip'] = check_table(elt['client_ip'], ip) end end if elt['from'] then local from = process_addr(elt['from']) if from then out['from'] = check_table(elt['from'], from) end end if elt['rcpt'] then local rcpt = process_addr(elt['rcpt']) if rcpt then out['rcpt'] = check_table(elt['rcpt'], rcpt) end end if elt['from_mime'] then local from_mime = process_addr(elt['from_mime']) if from_mime then out['from_mime'] = check_table(elt['from_mime'], from_mime) end end if elt['rcpt_mime'] then local rcpt_mime = process_addr(elt['rcpt_mime']) if rcpt_mime then out['rcpt_mime'] = check_table(elt['rcpt_mime'], rcpt_mime) end end if elt['user'] then local user = process_addr(elt['user']) if user then out['user'] = check_table(elt['user'], user) end end if elt['hostname'] then local hostname = process_addr(elt['hostname']) if hostname then out['hostname'] = check_table(elt['hostname'], hostname) end end if elt['authenticated'] then out['authenticated'] = true end if elt['local'] then out['local'] = true end if elt['inverse'] then out['inverse'] = true end if elt['request_header'] then local rho = {} for k, v in pairs(elt['request_header']) do local re = rspamd_regexp.create(v) if re then rho[k] = re end end out['request_header'] = rho end if elt['header'] then if not elt['header'][1] and next(elt['header']) then elt['header'] = {elt['header']} end for _, e in ipairs(elt['header']) do local rho = {} for k, v in pairs(e) do if type(v) ~= 'table' then v = {v} end for _, r in ipairs(v) do local re = rspamd_regexp.get_cached(r) if not re then re = rspamd_regexp.create_cached(r) end if re then if not out['header'] then out['header'] = {} end if rho[k] then table.insert(rho[k], re) else rho[k] = {re} end end end end if not out['header'] then out['header'] = {} end table.insert(out['header'], rho) end end if elt['selector'] then local sel = selectors_cache[name] if not sel then sel = lua_selectors.create_selector_closure(rspamd_config, elt.selector, elt.delimiter or "") if sel then selectors_cache[name] = sel end end if sel then if out.selector then table.insert(out['selector'], sel) else out['selector'] = {sel} end end end -- Now we must process actions if elt['symbols'] then out['symbols'] = elt['symbols'] end if elt['id'] then out['id'] = elt['id'] settings_ids[elt['id']] = out end 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 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 settings_ids = {} 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 priopities 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 rspamd_logger.infox(rspamd_config, 'loaded %1 elements of settings', nrules) return true end -- Parse settings map from the ucl line local function process_settings_map(string) local parser = ucl.parser() local res,err = parser:parse_string(string) if not res then rspamd_logger.warnx(rspamd_config, 'cannot parse settings map: ' .. err) else local obj = parser:get_object() if obj['settings'] then process_settings_table(obj['settings']) else process_settings_table(obj) 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_string(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) 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,nostat', callback = gen_redis_callback(h, id), priority = 10, flags = 'empty', }) end, redis_key_handlers) end 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 if not rspamd_config:add_map(set_section[1], "settings map", process_settings_map) then rspamd_logger.errx(rspamd_config, 'cannot load settings from %1', set_section) end elseif set_section and type(set_section) == "table" then process_settings_table(set_section) end rspamd_config:register_symbol({ name = 'SETTINGS_CHECK', type = 'prefilter,nostat', callback = check_settings, priority = 10, flags = 'empty', })