Mercurial > prosody-modules
comparison mod_dnsbl/mod_dnsbl.lua @ 6133:99860e1b817d
mod_dnsbl: Flag accounts registered by IPs matching blocklists
| author | Matthew Wild <mwild1@gmail.com> |
|---|---|
| date | Wed, 22 Jan 2025 18:04:26 +0000 |
| parents | |
| children | ec4a0e4d950e |
comparison
equal
deleted
inserted
replaced
| 6132:4887f68130c0 | 6133:99860e1b817d |
|---|---|
| 1 local lfs = require "lfs"; | |
| 2 | |
| 3 local adns = require "net.adns"; | |
| 4 local it = require "util.iterators"; | |
| 5 local parse_cidr = require "util.ip".parse_cidr; | |
| 6 local parse_ip = require "util.ip".new_ip; | |
| 7 local promise = require "util.promise"; | |
| 8 local set = require "util.set"; | |
| 9 local st = require "util.stanza"; | |
| 10 | |
| 11 local render_message = require "util.interpolation".new("%b{}", function (s) | |
| 12 return s; | |
| 13 end); | |
| 14 | |
| 15 local trie = module:require("mod_anti_spam/trie"); | |
| 16 | |
| 17 local dnsbls_config_raw = module:get_option("dnsbls"); | |
| 18 local default_dnsbl_flag = module:get_option_string("dnsbl_flag", "dnsbl_hit"); | |
| 19 local default_dnsbl_message = module:get_option("dnsbl_message"); | |
| 20 | |
| 21 if not dnsbls_config_raw then | |
| 22 module:log_status("error", "No 'dnsbls' in config file"); | |
| 23 return; | |
| 24 end | |
| 25 | |
| 26 local dnsbls = set.new(); | |
| 27 local dnsbls_config = {}; | |
| 28 | |
| 29 for k, v in ipairs(dnsbls_config_raw) do | |
| 30 local dnsbl_name, dnsbl_config; | |
| 31 if type(k) == "string" then | |
| 32 dnsbl_name = k; | |
| 33 dnsbl_config = v; | |
| 34 else | |
| 35 dnsbl_name = v; | |
| 36 dnsbl_config = {}; | |
| 37 end | |
| 38 dnsbls:add(dnsbl_name); | |
| 39 dnsbls_config[dnsbl_name] = dnsbl_config; | |
| 40 end | |
| 41 | |
| 42 local function read_dnsbl_file(filename) | |
| 43 local t = trie.new(); | |
| 44 local f, err = io.open(filename); | |
| 45 if not f then | |
| 46 module:log("error", "Failed to read file: %s", err); | |
| 47 return t; | |
| 48 end | |
| 49 | |
| 50 local n_line, n_added = 0, 0; | |
| 51 for line in f:lines() do | |
| 52 n_line = n_line + 1; | |
| 53 line = line:gsub("#.+$", ""):match("^%s*(.-)%s*$"); | |
| 54 if line == "" then -- luacheck: ignore 542 | |
| 55 -- Skip | |
| 56 else | |
| 57 local parsed_ip, parsed_bits = parse_cidr(line); | |
| 58 if not parsed_ip then | |
| 59 -- Skip | |
| 60 module:log("warn", "Failed to parse IP/CIDR on %s:%d", filename, n_line); | |
| 61 else | |
| 62 if not parsed_bits then | |
| 63 -- Default to full length of IP address | |
| 64 parsed_bits = #parsed_ip.packed * 8; | |
| 65 end | |
| 66 t:add_subnet(parsed_ip, parsed_bits); | |
| 67 n_added = n_added + 1; | |
| 68 end | |
| 69 end | |
| 70 end | |
| 71 | |
| 72 module:log("info", "Loaded %d entries from %s", n_added, filename); | |
| 73 | |
| 74 return t; | |
| 75 end | |
| 76 | |
| 77 local ipsets = {}; | |
| 78 local ipsets_last_updated = {}; | |
| 79 | |
| 80 function reload_file_dnsbls() | |
| 81 for dnsbl in dnsbls do | |
| 82 if dnsbl:byte(1) == 64 then -- '@' | |
| 83 local filename = dnsbl:sub(2); | |
| 84 local file_last_updated = lfs.attributes(filename, "change"); | |
| 85 if (ipsets_last_updated[dnsbl] or 0) < file_last_updated then | |
| 86 ipsets[dnsbl] = read_dnsbl_file(filename); | |
| 87 ipsets_last_updated[dnsbl] = file_last_updated; | |
| 88 end | |
| 89 end | |
| 90 end | |
| 91 end | |
| 92 | |
| 93 module:hook_global("config-reloaded", reload_file_dnsbls); | |
| 94 reload_file_dnsbls(); | |
| 95 | |
| 96 local mod_flags = module:depends("flags"); | |
| 97 | |
| 98 local function reverse(ip, suffix) | |
| 99 local a,b,c,d = ip:match("^(%d+).(%d+).(%d+).(%d+)$"); | |
| 100 if not a then return end | |
| 101 return ("%d.%d.%d.%d.%s"):format(d,c,b,a, suffix); | |
| 102 end | |
| 103 | |
| 104 function check_dnsbl(ip_address, dnsbl, callback, ud) | |
| 105 if dnsbl:byte(1) == 64 then -- '@' | |
| 106 local parsed_ip = parse_ip(ip_address); | |
| 107 if not parsed_ip then | |
| 108 module:log("warn", "Failed to parse IP address: %s", ip_address); | |
| 109 callback(ud, false, dnsbl); | |
| 110 return; | |
| 111 end | |
| 112 callback(ud, not not ipsets[dnsbl]:contains_ip(parsed_ip), dnsbl); | |
| 113 return; | |
| 114 else | |
| 115 if ip_address:sub(1,7):lower() == "::ffff:" then | |
| 116 ip_address = ip_address:sub(8); | |
| 117 end | |
| 118 | |
| 119 local rbl_ip = reverse(ip_address, dnsbl); | |
| 120 if not rbl_ip then return; end | |
| 121 | |
| 122 module:log("debug", "Sending DNSBL lookup for %s", ip_address); | |
| 123 adns.lookup(function (reply) | |
| 124 local hit = not not (reply and reply[1]); | |
| 125 module:log("debug", "Received DNSBL result for %s: %s", ip_address, hit and "present" or "absent"); | |
| 126 callback(ud, hit, dnsbl); | |
| 127 end, rbl_ip); | |
| 128 end | |
| 129 end | |
| 130 | |
| 131 local function handle_dnsbl_register_result(registration_event, hit, dnsbl) | |
| 132 if not hit then return; end | |
| 133 | |
| 134 if registration_event.dnsbl_match then return; end | |
| 135 registration_event.dnsbl_match = true; | |
| 136 | |
| 137 local username = registration_event.username; | |
| 138 local flag = dnsbls_config[dnsbl].flag or default_dnsbl_flag; | |
| 139 | |
| 140 module:log("info", "Flagging %s for user %s registered from %s matching %s", flag, username, registration_event.ip, dnsbl); | |
| 141 | |
| 142 mod_flags:add_flag(username, flag, "Matched "..dnsbl); | |
| 143 | |
| 144 local msg = dnsbls_config[dnsbl].message or default_dnsbl_message; | |
| 145 | |
| 146 if msg then | |
| 147 module:log("debug", "Sending warning message to %s", username); | |
| 148 local msg_stanza = st.message( | |
| 149 { | |
| 150 to = username.."@"..module.host; | |
| 151 from = module.host; | |
| 152 }, | |
| 153 render_message(msg, { registration = registration_event }) | |
| 154 ); | |
| 155 module:send(msg_stanza); | |
| 156 end | |
| 157 end | |
| 158 | |
| 159 module:hook("user-registered", function (event) | |
| 160 local session = event.session; | |
| 161 local ip = event.ip or (session and session.ip); | |
| 162 if not ip then return; end | |
| 163 | |
| 164 if not event.ip then | |
| 165 event.ip = ip; | |
| 166 end | |
| 167 | |
| 168 for dnsbl in dnsbls do | |
| 169 check_dnsbl(ip, dnsbl, handle_dnsbl_register_result, event); | |
| 170 end | |
| 171 end); | |
| 172 | |
| 173 module:add_item("account-trait", { | |
| 174 name = "register-dnsbl-hit"; | |
| 175 prob_bad_true = 0.6; | |
| 176 prob_bad_false = 0.4; | |
| 177 }); | |
| 178 | |
| 179 module:hook("get-account-traits", function (event) | |
| 180 event.traits["register-dnsbl-hit"] = mod_flags.has_flag(event.username, default_dnsbl_flag); | |
| 181 end); | |
| 182 | |
| 183 module:add_item("shell-command", { | |
| 184 section = "dnsbl"; | |
| 185 section_desc = "Manage DNS blocklists"; | |
| 186 name = "lists"; | |
| 187 desc = "Show all lists currently in use on the specified host"; | |
| 188 args = { | |
| 189 { name = "host", type = "string" }; | |
| 190 }; | |
| 191 host_selector = "host"; | |
| 192 handler = function(self, host) --luacheck: ignore 212/self 212/host | |
| 193 local count = 0; | |
| 194 for list in dnsbls do | |
| 195 count = count + 1; | |
| 196 self.session.print(list); | |
| 197 end | |
| 198 return true, ("%d lists"):format(count); | |
| 199 end; | |
| 200 }); | |
| 201 | |
| 202 module:add_item("shell-command", { | |
| 203 section = "dnsbl"; | |
| 204 section_desc = "Manage DNS blocklists"; | |
| 205 name = "check"; | |
| 206 desc = "Check an IP against the configured block lists"; | |
| 207 args = { | |
| 208 { name = "host", type = "string" }; | |
| 209 { name = "ip_address", type = "string" }; | |
| 210 }; | |
| 211 host_selector = "host"; | |
| 212 handler = function(self, host, ip_address) --luacheck: ignore 212/self 212/host | |
| 213 local parsed_ip = parse_ip(ip_address); | |
| 214 if not parsed_ip then | |
| 215 return false, "Failed to parse IP address"; | |
| 216 end | |
| 217 | |
| 218 local matches, total = 0, 0; | |
| 219 | |
| 220 local promises = {}; | |
| 221 | |
| 222 for dnsbl in dnsbls do | |
| 223 total = total + 1; | |
| 224 promises[dnsbl] = promise.new(function (resolve) | |
| 225 check_dnsbl(parsed_ip, dnsbl, resolve, true); | |
| 226 end); | |
| 227 end | |
| 228 | |
| 229 return promise.all_settled(promises):next(function (results) | |
| 230 for dnsbl, result in it.sorted_pairs(results) do | |
| 231 local msg; | |
| 232 if result.status == "fulfilled" then | |
| 233 if result.value then | |
| 234 msg = "[X]"; | |
| 235 matches = matches + 1; | |
| 236 else | |
| 237 msg = "[ ]"; | |
| 238 end | |
| 239 else | |
| 240 msg = "[?]"; | |
| 241 end | |
| 242 | |
| 243 print(msg, dnsbl); | |
| 244 end | |
| 245 return ("Found in %d of %d lists"):format(matches, total); | |
| 246 end); | |
| 247 end; | |
| 248 }); |
