view mod_dnsbl/mod_dnsbl.lua @ 6319:04c3273cb81f

mod_auth_cyrus: Add empty 'profile' table to SASL handler objects This is for compatibility with Prosody's built-in util.sasl objects. A SASL profile table usually includes methods supported by the backend, which can be used by SASL mechanism handlers to perform operations (such as testing the password). It also optionally contains a 'cb' field with channel binding method handlers. The Cyrus backend doesn't support channel binding, and doesn't have the same concept of auth backend methods (it handles all that internally, and Prosody has no insight or control over it). Thus, we create an empty profile which informs Prosody that the SASL handler does not support any of the auth or channel binding methods. Some features will not work, but they didn't work anyway. This just makes it explicit. This fixes a traceback in mod_sasl2_fast, which expected SASL handlers to always contain a 'profile' field.
author Matthew Wild <mwild1@gmail.com>
date Thu, 04 Sep 2025 10:14:46 +0100
parents 99860e1b817d
children ec4a0e4d950e
line wrap: on
line source

local lfs = require "lfs";

local adns = require "net.adns";
local it = require "util.iterators";
local parse_cidr = require "util.ip".parse_cidr;
local parse_ip = require "util.ip".new_ip;
local promise = require "util.promise";
local set = require "util.set";
local st = require "util.stanza";

local render_message = require "util.interpolation".new("%b{}", function (s)
	return s;
end);

local trie = module:require("mod_anti_spam/trie");

local dnsbls_config_raw = module:get_option("dnsbls");
local default_dnsbl_flag = module:get_option_string("dnsbl_flag", "dnsbl_hit");
local default_dnsbl_message = module:get_option("dnsbl_message");

if not dnsbls_config_raw then
	module:log_status("error", "No 'dnsbls' in config file");
	return;
end

local dnsbls = set.new();
local dnsbls_config = {};

for k, v in ipairs(dnsbls_config_raw) do
	local dnsbl_name, dnsbl_config;
	if type(k) == "string" then
		dnsbl_name = k;
		dnsbl_config = v;
	else
		dnsbl_name = v;
		dnsbl_config = {};
	end
	dnsbls:add(dnsbl_name);
	dnsbls_config[dnsbl_name] = dnsbl_config;
end

local function read_dnsbl_file(filename)
	local t = trie.new();
	local f, err = io.open(filename);
	if not f then
		module:log("error", "Failed to read file: %s", err);
		return t;
	end

	local n_line, n_added = 0, 0;
	for line in f:lines() do
		n_line = n_line + 1;
		line = line:gsub("#.+$", ""):match("^%s*(.-)%s*$");
		if line == "" then -- luacheck: ignore 542
			-- Skip
		else
			local parsed_ip, parsed_bits = parse_cidr(line);
			if not parsed_ip then
				-- Skip
				module:log("warn", "Failed to parse IP/CIDR on %s:%d", filename, n_line);
			else
				if not parsed_bits then
					-- Default to full length of IP address
					parsed_bits = #parsed_ip.packed * 8;
				end
				t:add_subnet(parsed_ip, parsed_bits);
				n_added = n_added + 1;
			end
		end
	end

	module:log("info", "Loaded %d entries from %s", n_added, filename);

	return t;
end

local ipsets = {};
local ipsets_last_updated = {};

function reload_file_dnsbls()
	for dnsbl in dnsbls do
		if dnsbl:byte(1) == 64 then -- '@'
			local filename = dnsbl:sub(2);
			local file_last_updated = lfs.attributes(filename, "change");
			if (ipsets_last_updated[dnsbl] or 0) < file_last_updated then
				ipsets[dnsbl] = read_dnsbl_file(filename);
				ipsets_last_updated[dnsbl] = file_last_updated;
			end
		end
	end
end

module:hook_global("config-reloaded", reload_file_dnsbls);
reload_file_dnsbls();

local mod_flags = module:depends("flags");

local function reverse(ip, suffix)
	local a,b,c,d = ip:match("^(%d+).(%d+).(%d+).(%d+)$");
	if not a then return end
	return ("%d.%d.%d.%d.%s"):format(d,c,b,a, suffix);
end

function check_dnsbl(ip_address, dnsbl, callback, ud)
	if dnsbl:byte(1) == 64 then -- '@'
		local parsed_ip = parse_ip(ip_address);
		if not parsed_ip then
			module:log("warn", "Failed to parse IP address: %s", ip_address);
			callback(ud, false, dnsbl);
			return;
		end
		callback(ud, not not ipsets[dnsbl]:contains_ip(parsed_ip), dnsbl);
		return;
	else
		if ip_address:sub(1,7):lower() == "::ffff:" then
			ip_address = ip_address:sub(8);
		end

		local rbl_ip = reverse(ip_address, dnsbl);
		if not rbl_ip then return; end

		module:log("debug", "Sending DNSBL lookup for %s", ip_address);
		adns.lookup(function (reply)
			local hit = not not (reply and reply[1]);
			module:log("debug", "Received DNSBL result for %s: %s", ip_address, hit and "present" or "absent");
			callback(ud, hit, dnsbl);
		end, rbl_ip);
	end
end

local function handle_dnsbl_register_result(registration_event, hit, dnsbl)
	if not hit then return; end

	if registration_event.dnsbl_match then return; end
	registration_event.dnsbl_match = true;

	local username = registration_event.username;
	local flag = dnsbls_config[dnsbl].flag or default_dnsbl_flag;

	module:log("info", "Flagging %s for user %s registered from %s matching %s", flag, username, registration_event.ip, dnsbl);

	mod_flags:add_flag(username, flag, "Matched "..dnsbl);

	local msg = dnsbls_config[dnsbl].message or default_dnsbl_message;

	if msg then
		module:log("debug", "Sending warning message to %s", username);
		local msg_stanza = st.message(
			{
				to = username.."@"..module.host;
				from = module.host;
			},
			render_message(msg, { registration = registration_event })
		);
		module:send(msg_stanza);
	end
end

module:hook("user-registered", function (event)
	local session = event.session;
	local ip = event.ip or (session and session.ip);
	if not ip then return; end

	if not event.ip then
		event.ip = ip;
	end

	for dnsbl in dnsbls do
		check_dnsbl(ip, dnsbl, handle_dnsbl_register_result, event);
	end
end);

module:add_item("account-trait", {
	name = "register-dnsbl-hit";
	prob_bad_true = 0.6;
	prob_bad_false = 0.4;
});

module:hook("get-account-traits", function (event)
	event.traits["register-dnsbl-hit"] = mod_flags.has_flag(event.username, default_dnsbl_flag);
end);

module:add_item("shell-command", {
	section = "dnsbl";
	section_desc = "Manage DNS blocklists";
	name = "lists";
	desc = "Show all lists currently in use on the specified host";
	args = {
		{ name = "host", type = "string" };
	};
	host_selector = "host";
	handler = function(self, host) --luacheck: ignore 212/self 212/host
		local count = 0;
		for list in dnsbls do
			count = count + 1;
			self.session.print(list);
		end
		return true, ("%d lists"):format(count);
	end;
});

module:add_item("shell-command", {
	section = "dnsbl";
	section_desc = "Manage DNS blocklists";
	name = "check";
	desc = "Check an IP against the configured block lists";
	args = {
		{ name = "host", type = "string" };
		{ name = "ip_address", type = "string" };
	};
	host_selector = "host";
	handler = function(self, host, ip_address) --luacheck: ignore 212/self 212/host
		local parsed_ip = parse_ip(ip_address);
		if not parsed_ip then
			return false, "Failed to parse IP address";
		end

		local matches, total = 0, 0;

		local promises = {};

		for dnsbl in dnsbls do
			total = total + 1;
			promises[dnsbl] = promise.new(function (resolve)
				check_dnsbl(parsed_ip, dnsbl, resolve, true);
			end);
		end

		return promise.all_settled(promises):next(function (results)
			for dnsbl, result in it.sorted_pairs(results) do
				local msg;
				if result.status == "fulfilled" then
					if result.value then
						msg = "[X]";
						matches = matches + 1;
					else
						msg = "[ ]";
					end
				else
					msg = "[?]";
				end

				print(msg, dnsbl);
			end
			return ("Found in %d of %d lists"):format(matches, total);
		end);
	end;
});