summaryrefslogtreecommitdiffstats
path: root/src/plugins/lua/mx_check.lua
diff options
context:
space:
mode:
Diffstat (limited to 'src/plugins/lua/mx_check.lua')
-rw-r--r--src/plugins/lua/mx_check.lua392
1 files changed, 392 insertions, 0 deletions
diff --git a/src/plugins/lua/mx_check.lua b/src/plugins/lua/mx_check.lua
new file mode 100644
index 0000000..71892b9
--- /dev/null
+++ b/src/plugins/lua/mx_check.lua
@@ -0,0 +1,392 @@
+--[[
+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
+
+-- MX check plugin
+local rspamd_logger = require "rspamd_logger"
+local rspamd_tcp = require "rspamd_tcp"
+local rspamd_util = require "rspamd_util"
+local lua_util = require "lua_util"
+local lua_redis = require "lua_redis"
+local N = "mx_check"
+local fun = require "fun"
+
+local settings = {
+ timeout = 1.0, -- connect timeout
+ symbol_bad_mx = 'MX_INVALID',
+ symbol_no_mx = 'MX_MISSING',
+ symbol_good_mx = 'MX_GOOD',
+ symbol_white_mx = 'MX_WHITE',
+ expire = 86400, -- 1 day by default
+ expire_novalid = 7200, -- 2 hours by default for no valid mxes
+ greylist_invalid = true, -- Greylist first message with invalid MX (require greylist plugin)
+ key_prefix = 'rmx',
+ max_mx_a_records = 5, -- Maximum number of A records to check per MX request
+ wait_for_greeting = false, -- Wait for SMTP greeting and emit `quit` command
+}
+local redis_params
+local exclude_domains
+
+local E = {}
+local CRLF = '\r\n'
+local mx_miss_cache_prefix = 'mx_miss:'
+
+local function mx_check(task)
+ local ip_addr = task:get_ip()
+ if task:get_user() or (ip_addr and ip_addr:is_local()) then
+ return
+ end
+
+ local from = task:get_from('smtp')
+ local mx_domain
+ if ((from or E)[1] or E).domain and not from[2] then
+ mx_domain = from[1]['domain']
+ else
+ mx_domain = task:get_helo()
+
+ if mx_domain then
+ mx_domain = rspamd_util.get_tld(mx_domain)
+ end
+ end
+
+ if not mx_domain then
+ return
+ end
+
+ if exclude_domains then
+ if exclude_domains:get_key(mx_domain) then
+ rspamd_logger.infox(task, 'skip mx check for %s, excluded', mx_domain)
+ task:insert_result(settings.symbol_white_mx, 1.0, mx_domain)
+ return
+ end
+ end
+
+ local valid = false
+
+ local function check_results(mxes)
+ if fun.all(function(_, elt)
+ return elt.checked
+ end, mxes) then
+ -- Save cache
+ local key = settings.key_prefix .. mx_domain
+ local function redis_cache_cb(err)
+ if err ~= nil then
+ rspamd_logger.errx(task, 'redis_cache_cb received error: %1', err)
+ return
+ end
+ end
+ if not valid then
+ -- Greylist message
+ if settings.greylist_invalid then
+ task:get_mempool():set_variable("grey_greylisted_required", "1")
+ lua_util.debugm(N, task, "advice to greylist a message")
+ task:insert_result(settings.symbol_bad_mx, 1.0, "greylisted")
+ else
+ task:insert_result(settings.symbol_bad_mx, 1.0)
+ end
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_cache_cb, --callback
+ 'SETEX', -- command
+ { key, tostring(settings.expire_novalid), '0' } -- arguments
+ )
+ lua_util.debugm(N, task, "set redis cache key: %s; invalid MX", key)
+ if not ret then
+ rspamd_logger.errx(task, 'got error connecting to redis')
+ end
+ else
+ local valid_mx = {}
+ fun.each(function(k)
+ table.insert(valid_mx, k)
+ end, fun.filter(function(_, elt)
+ return elt.working
+ end, mxes))
+ task:insert_result(settings.symbol_good_mx, 1.0, valid_mx)
+ local value = table.concat(valid_mx, ';')
+ if mxes[mx_domain] and type(mxes[mx_domain]) == 'table' and mxes[mx_domain].mx_missing then
+ value = mx_miss_cache_prefix .. value
+ end
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ true, -- is write
+ redis_cache_cb, --callback
+ 'SETEX', -- command
+ { key, tostring(settings.expire), value } -- arguments
+ )
+ lua_util.debugm(N, task, "set redis cache key: %s; %s", key, value)
+ if not ret then
+ rspamd_logger.errx(task, 'error connecting to redis')
+ end
+ end
+ end
+ end
+
+ local function gen_mx_a_callback(name, mxes)
+ return function(_, _, results, err)
+ lua_util.debugm(N, task, "got DNS results for %s: %s", name, results)
+ mxes[name].ips = results
+
+ local function io_cb(io_err, _, conn)
+ lua_util.debugm(N, task, "TCP IO callback for %s, error: %s", name, io_err)
+ if io_err then
+ mxes[name].checked = true
+ conn:close()
+ else
+ mxes[name].checked = true
+ mxes[name].working = true
+ valid = true
+ if settings.wait_for_greeting then
+ conn:add_write(function(_)
+ conn:close()
+ end, string.format('QUIT%s', CRLF))
+ end
+ end
+ check_results(mxes)
+ end
+ local function on_connect_cb(conn)
+ lua_util.debugm(N, task, "TCP connect callback for %s, error: %s", name, err)
+ if err then
+ mxes[name].checked = true
+ conn:close()
+ check_results(mxes)
+ else
+ mxes[name].checked = true
+ valid = true
+ mxes[name].working = true
+ end
+
+ -- Disconnect without SMTP dialog
+ if not settings.wait_for_greeting then
+ check_results(mxes)
+ conn:close()
+ end
+ end
+
+ if err or not results or #results == 0 then
+ mxes[name].checked = true
+ else
+ -- Try to open TCP connection to port 25 for a random IP address
+ -- see #3839 on GitHub
+ lua_util.shuffle(results)
+ local str_ip = results[1]:to_string()
+ lua_util.debugm(N, task, "trying to connect to IP %s", str_ip)
+ local t_ret = rspamd_tcp.new({
+ task = task,
+ host = str_ip,
+ callback = io_cb,
+ stop_pattern = CRLF,
+ on_connect = on_connect_cb,
+ timeout = settings.timeout,
+ port = 25
+ })
+
+ if not t_ret then
+ mxes[name].checked = true
+ end
+ end
+ check_results(mxes)
+ end
+ end
+
+ local function mx_callback(_, _, results, err)
+ local mxes = {}
+ if err or not results then
+ local r = task:get_resolver()
+ -- XXX: maybe add ipv6?
+ -- fallback to implicit mx
+ if not err and not results then
+ err = 'no MX records found'
+ end
+
+ lua_util.debugm(N, task, "cannot find MX record for %s: %s, use implicit fallback",
+ mx_domain, err)
+ mxes[mx_domain] = { checked = false, working = false, ips = {}, mx_missing = true }
+ r:resolve('a', {
+ name = mx_domain,
+ callback = gen_mx_a_callback(mx_domain, mxes),
+ task = task,
+ forced = true
+ })
+ task:insert_result(settings.symbol_no_mx, 1.0, err)
+ else
+ -- Inverse sort by priority
+ table.sort(results, function(r1, r2)
+ return r1['priority'] > r2['priority']
+ end)
+
+ local max_mx_to_resolve = math.min(#results, settings.max_mx_a_records)
+ lua_util.debugm(N, task, 'check %s MX records (%d actually returned)',
+ max_mx_to_resolve, #results)
+ for i = 1, max_mx_to_resolve do
+ local mx = results[i]
+ mxes[mx.name] = { checked = false, working = false, ips = {} }
+ local r = task:get_resolver()
+ -- XXX: maybe add ipv6?
+ r:resolve('a', {
+ name = mx.name,
+ callback = gen_mx_a_callback(mx.name, mxes),
+ task = task,
+ forced = true
+ })
+ end
+ check_results(mxes)
+ end
+ end
+
+ if not redis_params then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ else
+ local function redis_cache_get_cb(err, data)
+ if err or type(data) ~= 'string' then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ else
+ if data == '0' then
+ task:insert_result(settings.symbol_bad_mx, 1.0, 'cached')
+ else
+ if lua_util.str_startswith(data, mx_miss_cache_prefix) then
+ task:insert_result(settings.symbol_no_mx, 1.0, 'cached')
+ data = string.sub(data, #mx_miss_cache_prefix + 1)
+ end
+ local mxes = lua_util.str_split(data, ';')
+ task:insert_result(settings.symbol_good_mx, 1.0, 'cached: ' .. mxes[1])
+ end
+ end
+ end
+
+ local key = settings.key_prefix .. mx_domain
+ local ret = rspamd_redis_make_request(task,
+ redis_params, -- connect params
+ key, -- hash key
+ false, -- is write
+ redis_cache_get_cb, --callback
+ 'GET', -- command
+ { key } -- arguments
+ )
+
+ if not ret then
+ local r = task:get_resolver()
+ r:resolve('mx', {
+ name = mx_domain,
+ callback = mx_callback,
+ task = task,
+ forced = true
+ })
+ end
+ end
+end
+
+-- Module setup
+local opts = rspamd_config:get_all_opt('mx_check')
+if not (opts and type(opts) == 'table') then
+ rspamd_logger.infox(rspamd_config, 'module is unconfigured')
+ return
+end
+if opts then
+ redis_params = lua_redis.parse_redis_server('mx_check')
+ if not redis_params then
+ rspamd_logger.errx(rspamd_config, 'no redis servers are specified, disabling module')
+ lua_util.disable_module(N, "redis")
+ return
+ end
+
+ settings = lua_util.override_defaults(settings, opts)
+ lua_redis.register_prefix(settings.key_prefix .. '*', N,
+ 'MX check cache', {
+ type = 'string',
+ })
+
+ local id = rspamd_config:register_symbol({
+ name = settings.symbol_bad_mx,
+ type = 'normal',
+ callback = mx_check,
+ flags = 'empty',
+ augmentations = { string.format("timeout=%f", settings.timeout + rspamd_config:get_dns_timeout() or 0.0) },
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_no_mx,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_good_mx,
+ type = 'virtual',
+ parent = id
+ })
+ rspamd_config:register_symbol({
+ name = settings.symbol_white_mx,
+ type = 'virtual',
+ parent = id
+ })
+
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_bad_mx,
+ score = 0.5,
+ description = 'Domain has no working MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_good_mx,
+ score = -0.01,
+ description = 'Domain has working MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_white_mx,
+ score = 0.0,
+ description = 'Domain is whitelisted from MX check',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+ rspamd_config:set_metric_symbol({
+ name = settings.symbol_no_mx,
+ score = 3.5,
+ description = 'Domain has no resolvable MX',
+ group = 'MX',
+ one_shot = true,
+ one_param = true,
+ })
+
+ if settings.exclude_domains then
+ exclude_domains = rspamd_config:add_map {
+ type = 'set',
+ description = 'Exclude specific domains from MX checks',
+ url = settings.exclude_domains,
+ }
+ end
+end