• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

processone / ejabberd / 1296

19 Jan 2026 11:25AM UTC coverage: 33.562% (+0.09%) from 33.468%
1296

push

github

badlop
mod_conversejs: Cosmetic change: sort paths alphabetically

0 of 4 new or added lines in 1 file covered. (0.0%)

11245 existing lines in 174 files now uncovered.

15580 of 46421 relevant lines covered (33.56%)

1074.56 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

67.5
/src/mod_antispam.erl
1
%%%----------------------------------------------------------------------
2
%%% File    : mod_antispam.erl
3
%%% Author  : Holger Weiss <holger@zedat.fu-berlin.de>
4
%%% Author  : Stefan Strigler <stefan@strigler.de>
5
%%% Purpose : Filter spam messages based on sender JID and content
6
%%% Created : 31 Mar 2019 by Holger Weiss <holger@zedat.fu-berlin.de>
7
%%%
8
%%%
9
%%% ejabberd, Copyright (C) 2019-2026 ProcessOne
10
%%%
11
%%% This program is free software; you can redistribute it and/or
12
%%% modify it under the terms of the GNU General Public License as
13
%%% published by the Free Software Foundation; either version 2 of the
14
%%% License, or (at your option) any later version.
15
%%%
16
%%% This program is distributed in the hope that it will be useful,
17
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
18
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
19
%%% General Public License for more details.
20
%%%
21
%%% You should have received a copy of the GNU General Public License along
22
%%% with this program; if not, write to the Free Software Foundation, Inc.,
23
%%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
24
%%%
25
%%%----------------------------------------------------------------------
26

27
%%| definitions
28

29
-module(mod_antispam).
30
-author('holger@zedat.fu-berlin.de').
31
-author('stefan@strigler.de').
32

33
-behaviour(gen_server).
34
-behaviour(gen_mod).
35

36
%% gen_mod callbacks.
37
-export([start/2,
38
         prep_stop/1,
39
         stop/1,
40
         reload/3,
41
         depends/2,
42
         mod_doc/0,
43
         mod_opt_type/1,
44
         mod_options/1]).
45

46
%% gen_server callbacks.
47
-export([init/1,
48
         handle_call/3,
49
         handle_cast/2,
50
         handle_info/2,
51
         terminate/2,
52
         code_change/3]).
53

54
-export([get_rtbl_services_option/1]).
55

56
%% ejabberd_commands callbacks.
57
-export([add_blocked_domain/2,
58
         add_to_spam_filter_cache/2,
59
         drop_from_spam_filter_cache/2,
60
         expire_spam_filter_cache/2,
61
         get_blocked_domains/1,
62
         get_commands_spec/0,
63
         get_spam_filter_cache/1,
64
         reload_spam_filter_files/1,
65
         remove_blocked_domain/2]).
66

67
-include("ejabberd_commands.hrl").
68
-include("logger.hrl").
69
-include("mod_antispam.hrl").
70
-include("translate.hrl").
71

72
-include_lib("xmpp/include/xmpp.hrl").
73

74
-record(state,
75
        {host = <<>>                        :: binary(),
76
         dump_fd = undefined                :: file:io_device() | undefined,
77
         url_set = sets:new()                :: url_set(),
78
         jid_set = sets:new()                :: jid_set(),
79
         jid_cache = #{}                :: map(),
80
         max_cache_size = 0                :: non_neg_integer() | unlimited,
81
         rtbl_host = none                :: binary() | none,
82
         rtbl_subscribed = false        :: boolean(),
83
         rtbl_retry_timer = undefined        :: reference() | undefined,
84
         rtbl_domains_node                :: binary(),
85
         blocked_domains = #{}                :: #{binary() => any()},
86
         whitelist_domains = #{}        :: #{binary() => false}
87
        }).
88

89
-type state() :: #state{}.
90

91
-define(COMMAND_TIMEOUT, timer:seconds(30)).
92
-define(DEFAULT_CACHE_SIZE, 10000).
93

94
%% @format-begin
95

96
%%--------------------------------------------------------------------
97
%%| gen_mod callbacks
98

99
-spec start(binary(), gen_mod:opts()) -> ok | {error, any()}.
100
start(Host, Opts) ->
UNCOV
101
    case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of
2✔
102
        false ->
UNCOV
103
            ejabberd_commands:register_commands(?MODULE, get_commands_spec());
2✔
104
        true ->
UNCOV
105
            ok
×
106
    end,
UNCOV
107
    gen_mod:start_child(?MODULE, Host, Opts).
2✔
108

109
-spec prep_stop(binary()) -> ok | {error, any()}.
110
prep_stop(Host) ->
UNCOV
111
    case try_call_by_host(Host, prepare_stop) of
2✔
112
        ready_to_stop ->
UNCOV
113
            ok
2✔
114
    end.
115

116
-spec stop(binary()) -> ok | {error, any()}.
117
stop(Host) ->
UNCOV
118
    case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of
2✔
119
        false ->
UNCOV
120
            ejabberd_commands:unregister_commands(get_commands_spec());
2✔
121
        true ->
UNCOV
122
            ok
×
123
    end,
UNCOV
124
    gen_mod:stop_child(?MODULE, Host).
2✔
125

126
-spec reload(binary(), gen_mod:opts(), gen_mod:opts()) -> ok.
127
reload(Host, NewOpts, OldOpts) ->
UNCOV
128
    ?DEBUG("reloading", []),
4✔
UNCOV
129
    Proc = get_proc_name(Host),
4✔
UNCOV
130
    gen_server:cast(Proc, {reload, NewOpts, OldOpts}).
4✔
131

132
-spec depends(binary(), gen_mod:opts()) -> [{module(), hard | soft}].
133
depends(_Host, _Opts) ->
UNCOV
134
    [{mod_pubsub, soft}].
2✔
135

136
-spec mod_opt_type(atom()) -> econf:validator().
137
mod_opt_type(access_spam) ->
UNCOV
138
    econf:acl();
2✔
139
mod_opt_type(cache_size) ->
UNCOV
140
    econf:pos_int(unlimited);
2✔
141
mod_opt_type(rtbl_services) ->
UNCOV
142
    econf:list(
2✔
143
        econf:either(
144
            econf:binary(),
145
            econf:map(
146
                econf:binary(),
147
                econf:map(
148
                    econf:enum([spam_source_domains_node]), econf:binary()))));
149
mod_opt_type(spam_domains_file) ->
UNCOV
150
    econf:either(
2✔
151
        econf:enum([none]), econf:file());
152
mod_opt_type(spam_dump_file) ->
UNCOV
153
    econf:either(
2✔
154
        econf:bool(), econf:file(write));
155
mod_opt_type(spam_jids_file) ->
UNCOV
156
    econf:either(
2✔
157
        econf:enum([none]), econf:file());
158
mod_opt_type(spam_urls_file) ->
UNCOV
159
    econf:either(
2✔
160
        econf:enum([none]), econf:file());
161
mod_opt_type(whitelist_domains_file) ->
UNCOV
162
    econf:either(
2✔
163
        econf:enum([none]), econf:file()).
164

165
-spec mod_options(binary()) -> [{rtbl_services, [tuple()]} | {atom(), any()}].
166
mod_options(_Host) ->
UNCOV
167
    [{access_spam, none},
2✔
168
     {cache_size, ?DEFAULT_CACHE_SIZE},
169
     {rtbl_services, []},
170
     {spam_domains_file, none},
171
     {spam_dump_file, false},
172
     {spam_jids_file, none},
173
     {spam_urls_file, none},
174
     {whitelist_domains_file, none}].
175

176
mod_doc() ->
177
    #{desc =>
×
178
          ?T("Filter spam messages and subscription requests received from "
179
             "remote servers based on "
180
             "https://xmppbl.org/[Real-Time Block Lists (RTBL)], "
181
             "lists of known spammer JIDs and/or URLs mentioned in spam messages. "
182
             "Traffic classified as spam is rejected with an error "
183
             "(and an '[info]' message is logged) unless the sender "
184
             "is subscribed to the recipient's presence."),
185
      note => "added in 25.07",
186
      opts =>
187
          [{access_spam,
188
            #{value => ?T("Access"),
189
              desc =>
190
                  ?T("Access rule that controls what accounts may receive spam messages. "
191
                     "If the rule returns 'allow' for a given recipient, "
192
                     "spam messages aren't rejected for that recipient. "
193
                     "The default value is 'none', which means that all recipients "
194
                     "are subject to spam filtering verification.")}},
195
           {cache_size,
196
            #{value => "pos_integer()",
197
              desc =>
198
                  ?T("Maximum number of JIDs that will be cached due to sending spam URLs. "
199
                     "If that limit is exceeded, the least recently used "
200
                     "entries are removed from the cache. "
201
                     "Setting this option to '0' disables the caching feature. "
202
                     "Note that separate caches are used for each virtual host, "
203
                     " and that the caches aren't distributed across cluster nodes. "
204
                     "The default value is '10000'.")}},
205
           {rtbl_services,
206
            #{value => ?T("[Service]"),
207
              example =>
208
                  ["rtbl_services:",
209
                   "  - pubsub.server1.localhost:",
210
                   "      spam_source_domains_node: actual_custom_pubsub_node"],
211
              desc =>
212
                  ?T("Query a RTBL service to get domains to block, as provided by "
213
                     "https://xmppbl.org/[xmppbl.org]. "
214
                     "Please note right now this option only supports one service in that list. "
215
                     "For blocking spam and abuse on MUC channels, please use _`mod_muc_rtbl`_ for now. "
216
                     "If only the host is provided, the default node names will be assumed. "
217
                     "If the node name is different than 'spam_source_domains', "
218
                     "you can setup the custom node name with the option 'spam_source_domains_node'. "
219
                     "The default value is an empty list of services.")}},
220
           {spam_domains_file,
221
            #{value => ?T("none | Path"),
222
              desc =>
223
                  ?T("Path to a plain text file containing a list of "
224
                     "known spam domains, one domain per line. "
225
                     "Messages and subscription requests sent from one of the listed domains "
226
                     "are classified as spam if sender is not in recipient's roster. "
227
                     "This list of domains gets merged with the one retrieved "
228
                     "by an RTBL host if any given. "
229
                     "Use an absolute path, or the '@CONFIG_PATH@' "
230
                     "https://docs.ejabberd.im/admin/configuration/file-format/#predefined-keywords[predefined keyword] "
231
                     "if the file is available in the configuration directory. "
232
                     "The default value is 'none'.")}},
233
           {spam_dump_file,
234
            #{value => ?T("false | true | Path"),
235
              desc =>
236
                  ?T("Path to the file to store blocked messages. "
237
                     "Use an absolute path, or the '@LOG_PATH@' "
238
                     "https://docs.ejabberd.im/admin/configuration/file-format/#predefined-keywords[predefined keyword] "
239
                     "to store logs "
240
                     "in the same place that the other ejabberd log files. "
241
                     "If set to 'false', it doesn't dump stanzas, which is the default. "
242
                     "If set to 'true', it stores in '\"@LOG_PATH@/spam_dump_@HOST@.log\"'.")}},
243
           {spam_jids_file,
244
            #{value => ?T("none | Path"),
245
              desc =>
246
                  ?T("Path to a plain text file containing a list of "
247
                     "known spammer JIDs, one JID per line. "
248
                     "Messages and subscription requests sent from one of "
249
                     "the listed JIDs are classified as spam. "
250
                     "Messages containing at least one of the listed JIDs"
251
                     "are classified as spam as well. "
252
                     "Furthermore, the sender's JID will be cached, "
253
                     "so that future traffic originating from that JID will also be classified as spam. "
254
                     "Use an absolute path, or the '@CONFIG_PATH@' "
255
                     "https://docs.ejabberd.im/admin/configuration/file-format/#predefined-keywords[predefined keyword] "
256
                     "if the file is available in the configuration directory. "
257
                     "The default value is 'none'.")}},
258
           {spam_urls_file,
259
            #{value => ?T("none | Path"),
260
              desc =>
261
                  ?T("Path to a plain text file containing a list of "
262
                     "URLs known to be mentioned in spam message bodies. "
263
                     "Messages containing at least one of the listed URLs are classified as spam. "
264
                     "Furthermore, the sender's JID will be cached, "
265
                     "so that future traffic originating from that JID will be classified as spam as well. "
266
                     "Use an absolute path, or the '@CONFIG_PATH@' "
267
                     "https://docs.ejabberd.im/admin/configuration/file-format/#predefined-keywords[predefined keyword] "
268
                     "if the file is available in the configuration directory. "
269
                     "The default value is 'none'.")}},
270
           {whitelist_domains_file,
271
            #{value => ?T("none | Path"),
272
              desc =>
273
                  ?T("Path to a file containing a list of "
274
                     "domains to whitelist from being blocked, one per line. "
275
                     "If either it is in 'spam_domains_file' or more realistically "
276
                     "in a domain sent by a RTBL host (see option 'rtbl_services') "
277
                     "then this domain will be ignored and stanzas from there won't be blocked. "
278
                     "Use an absolute path, or the '@CONFIG_PATH@' "
279
                     "https://docs.ejabberd.im/admin/configuration/file-format/#predefined-keywords[predefined keyword] "
280
                     "if the file is available in the configuration directory. "
281
                     "The default value is 'none'.")}}],
282
      example =>
283
          ["modules:",
284
           "  mod_antispam:",
285
           "    rtbl_services:",
286
           "      - xmppbl.org",
287
           "    spam_jids_file: \"@CONFIG_PATH@/spam_jids.txt\"",
288
           "    spam_dump_file: \"@LOG_PATH@/spam/host-@HOST@.log\""]}.
289

290
%%--------------------------------------------------------------------
291
%%| gen_server callbacks
292

293
-spec init(list()) -> {ok, state()} | {stop, term()}.
294
init([Host, Opts]) ->
UNCOV
295
    process_flag(trap_exit, true),
2✔
UNCOV
296
    mod_antispam_files:init_files(Host),
2✔
UNCOV
297
    FilesResults = read_files(Host),
2✔
UNCOV
298
    #{jid := JIDsSet,
2✔
299
      url := URLsSet,
300
      domains := SpamDomainsSet,
301
      whitelist_domains := WhitelistDomains} =
302
        FilesResults,
UNCOV
303
    ejabberd_hooks:add(local_send_to_resource_hook,
2✔
304
                       Host,
305
                       mod_antispam_rtbl,
306
                       pubsub_event_handler,
307
                       50),
UNCOV
308
    [#rtbl_service{host = RTBLHost, node = RTBLDomainsNode}] = get_rtbl_services_option(Opts),
2✔
UNCOV
309
    mod_antispam_filter:init_filtering(Host),
2✔
UNCOV
310
    InitState =
2✔
311
        #state{host = Host,
312
               jid_set = JIDsSet,
313
               url_set = URLsSet,
314
               dump_fd = mod_antispam_dump:init_dumping(Host),
315
               max_cache_size = gen_mod:get_opt(cache_size, Opts),
316
               blocked_domains = set_to_map(SpamDomainsSet),
317
               whitelist_domains = set_to_map(WhitelistDomains, false),
318
               rtbl_host = RTBLHost,
319
               rtbl_domains_node = RTBLDomainsNode},
UNCOV
320
    mod_antispam_rtbl:request_blocked_domains(RTBLHost, RTBLDomainsNode, Host),
2✔
UNCOV
321
    {ok, InitState}.
2✔
322

323
-spec handle_call(term(), {pid(), term()}, state()) ->
324
                     {reply, {spam_filter, term()}, state()} | {noreply, state()}.
325
handle_call({check_jid, From}, _From, #state{jid_set = JIDsSet} = State) ->
UNCOV
326
    {Result, State1} = filter_jid(From, JIDsSet, State),
2,588✔
UNCOV
327
    {reply, {spam_filter, Result}, State1};
2,588✔
328
handle_call({check_body, URLs, JIDs, From},
329
            _From,
330
            #state{url_set = URLsSet, jid_set = JIDsSet} = State) ->
UNCOV
331
    {Result1, State1} = filter_body(URLs, URLsSet, From, State),
12✔
UNCOV
332
    {Result2, State2} = filter_body(JIDs, JIDsSet, From, State1),
12✔
UNCOV
333
    Result =
12✔
334
        if Result1 == spam ->
UNCOV
335
               Result1;
2✔
336
           true ->
UNCOV
337
               Result2
10✔
338
        end,
UNCOV
339
    {reply, {spam_filter, Result}, State2};
12✔
340
handle_call(reload_spam_files, _From, State) ->
341
    {Result, State1} = reload_files(State),
×
342
    {reply, {spam_filter, Result}, State1};
×
343
handle_call({expire_cache, Age}, _From, State) ->
344
    {Result, State1} = expire_cache(Age, State),
×
345
    {reply, {spam_filter, Result}, State1};
×
346
handle_call({add_to_cache, JID}, _From, State) ->
UNCOV
347
    {Result, State1} = add_to_cache(JID, State),
2✔
UNCOV
348
    {reply, {spam_filter, Result}, State1};
2✔
349
handle_call({drop_from_cache, JID}, _From, State) ->
UNCOV
350
    {Result, State1} = drop_from_cache(JID, State),
4✔
UNCOV
351
    {reply, {spam_filter, Result}, State1};
4✔
352
handle_call(get_cache, _From, #state{jid_cache = Cache} = State) ->
353
    {reply, {spam_filter, maps:to_list(Cache)}, State};
×
354
handle_call({add_blocked_domain, Domain},
355
            _From,
356
            #state{blocked_domains = BlockedDomains} = State) ->
UNCOV
357
    BlockedDomains1 = maps:merge(BlockedDomains, #{Domain => true}),
4✔
UNCOV
358
    Txt = format("~s added to blocked domains", [Domain]),
4✔
UNCOV
359
    {reply, {spam_filter, {ok, Txt}}, State#state{blocked_domains = BlockedDomains1}};
4✔
360
handle_call({remove_blocked_domain, Domain},
361
            _From,
362
            #state{blocked_domains = BlockedDomains} = State) ->
UNCOV
363
    BlockedDomains1 = maps:remove(Domain, BlockedDomains),
10✔
UNCOV
364
    Txt = format("~s removed from blocked domains", [Domain]),
10✔
UNCOV
365
    {reply, {spam_filter, {ok, Txt}}, State#state{blocked_domains = BlockedDomains1}};
10✔
366
handle_call(get_blocked_domains,
367
            _From,
368
            #state{blocked_domains = BlockedDomains, whitelist_domains = WhitelistDomains} =
369
                State) ->
UNCOV
370
    {reply, {blocked_domains, maps:merge(BlockedDomains, WhitelistDomains)}, State};
26✔
371
handle_call({is_blocked_domain, Domain},
372
            _From,
373
            #state{blocked_domains = BlockedDomains, whitelist_domains = WhitelistDomains} =
374
                State) ->
UNCOV
375
    {reply,
2,594✔
376
     maps:get(Domain, maps:merge(BlockedDomains, WhitelistDomains), false) =/= false,
377
     State};
378
handle_call(prepare_stop,
379
            _From,
380
            #state{host = Host,
381
                   rtbl_host = RTBLHost,
382
                   rtbl_domains_node = RTBLDomainsNode} =
383
                State) ->
UNCOV
384
    mod_antispam_rtbl:unsubscribe(RTBLHost, RTBLDomainsNode, Host),
2✔
UNCOV
385
    {reply, ready_to_stop, State};
2✔
386
handle_call(Request, From, State) ->
387
    ?ERROR_MSG("Got unexpected request from ~p: ~p", [From, Request]),
×
388
    {noreply, State}.
×
389

390
-spec handle_cast(term(), state()) -> {noreply, state()}.
391
handle_cast({dump_stanza, XML}, #state{dump_fd = Fd} = State) ->
UNCOV
392
    mod_antispam_dump:write_stanza_dump(Fd, XML),
16✔
UNCOV
393
    {noreply, State};
16✔
394
handle_cast(reopen_log, #state{host = Host, dump_fd = Fd} = State) ->
395
    {noreply, State#state{dump_fd = mod_antispam_dump:reopen_dump_file(Host, Fd)}};
×
396
handle_cast({reload, NewOpts, OldOpts},
397
            #state{host = Host,
398
                   dump_fd = Fd,
399
                   rtbl_host = OldRTBLHost,
400
                   rtbl_domains_node = OldRTBLDomainsNode,
401
                   rtbl_retry_timer = RTBLRetryTimer} =
402
                State) ->
UNCOV
403
    misc:cancel_timer(RTBLRetryTimer),
4✔
UNCOV
404
    State1 =
4✔
405
        State#state{dump_fd = mod_antispam_dump:reload_dumping(Host, Fd, OldOpts, NewOpts)},
UNCOV
406
    State2 =
4✔
407
        case {gen_mod:get_opt(cache_size, OldOpts), gen_mod:get_opt(cache_size, NewOpts)} of
408
            {OldMax, NewMax} when NewMax < OldMax ->
409
                shrink_cache(State1#state{max_cache_size = NewMax});
×
410
            {OldMax, NewMax} when NewMax > OldMax ->
411
                State1#state{max_cache_size = NewMax};
×
412
            {_OldMax, _NewMax} ->
UNCOV
413
                State1
4✔
414
        end,
UNCOV
415
    ok = mod_antispam_rtbl:unsubscribe(OldRTBLHost, OldRTBLDomainsNode, Host),
4✔
UNCOV
416
    {_Result, State3} = reload_files(State2#state{blocked_domains = #{}}),
4✔
UNCOV
417
    [#rtbl_service{host = RTBLHost, node = RTBLDomainsNode}] =
4✔
418
        get_rtbl_services_option(NewOpts),
UNCOV
419
    ok = mod_antispam_rtbl:request_blocked_domains(RTBLHost, RTBLDomainsNode, Host),
4✔
UNCOV
420
    {noreply, State3#state{rtbl_host = RTBLHost, rtbl_domains_node = RTBLDomainsNode}};
4✔
421
handle_cast({update_blocked_domains, NewItems},
422
            #state{blocked_domains = BlockedDomains} = State) ->
UNCOV
423
    {noreply, State#state{blocked_domains = maps:merge(BlockedDomains, NewItems)}};
8✔
424
handle_cast(Request, State) ->
425
    ?ERROR_MSG("Got unexpected request from: ~p", [Request]),
×
426
    {noreply, State}.
×
427

428
-spec handle_info(term(), state()) -> {noreply, state()}.
429
handle_info({iq_reply, timeout, blocked_domains}, State) ->
430
    ?WARNING_MSG("Fetching blocked domains failed: fetch timeout. Retrying in 60 seconds",
×
431
                 []),
×
432
    {noreply,
×
433
     State#state{rtbl_retry_timer =
434
                     erlang:send_after(60000, self(), request_blocked_domains)}};
435
handle_info({iq_reply, #iq{type = error} = IQ, blocked_domains}, State) ->
UNCOV
436
    ?WARNING_MSG("Fetching blocked domains failed: ~p. Retrying in 60 seconds",
2✔
437
                 [xmpp:format_stanza_error(
UNCOV
438
                      xmpp:get_error(IQ))]),
2✔
UNCOV
439
    {noreply,
2✔
440
     State#state{rtbl_retry_timer =
441
                     erlang:send_after(60000, self(), request_blocked_domains)}};
442
handle_info({iq_reply, IQReply, blocked_domains},
443
            #state{blocked_domains = OldBlockedDomains,
444
                   rtbl_host = RTBLHost,
445
                   rtbl_domains_node = RTBLDomainsNode,
446
                   host = Host} =
447
                State) ->
UNCOV
448
    case mod_antispam_rtbl:parse_blocked_domains(IQReply) of
4✔
449
        undefined ->
450
            ?WARNING_MSG("Fetching initial list failed: invalid result payload", []),
×
451
            {noreply, State#state{rtbl_retry_timer = undefined}};
×
452
        NewBlockedDomains ->
UNCOV
453
            ok = mod_antispam_rtbl:subscribe(RTBLHost, RTBLDomainsNode, Host),
4✔
UNCOV
454
            {noreply,
4✔
455
             State#state{rtbl_retry_timer = undefined,
456
                         rtbl_subscribed = true,
457
                         blocked_domains = maps:merge(OldBlockedDomains, NewBlockedDomains)}}
458
    end;
459
handle_info({iq_reply, timeout, subscribe_result}, State) ->
460
    ?WARNING_MSG("Subscription error: request timeout", []),
×
461
    {noreply, State#state{rtbl_subscribed = false}};
×
462
handle_info({iq_reply, #iq{type = error} = IQ, subscribe_result}, State) ->
463
    ?WARNING_MSG("Subscription error: ~p",
×
464
                 [xmpp:format_stanza_error(
465
                      xmpp:get_error(IQ))]),
×
466
    {noreply, State#state{rtbl_subscribed = false}};
×
467
handle_info({iq_reply, IQReply, subscribe_result}, State) ->
UNCOV
468
    ?DEBUG("Got subscribe result: ~p", [IQReply]),
4✔
UNCOV
469
    {noreply, State#state{rtbl_subscribed = true}};
4✔
470
handle_info({iq_reply, _IQReply, unsubscribe_result}, State) ->
471
    %% FIXME: we should check it's true (of type `result`, not `error`), but at that point, what
472
    %% would we do?
UNCOV
473
    {noreply, State#state{rtbl_subscribed = false}};
6✔
474
handle_info(request_blocked_domains,
475
            #state{host = Host,
476
                   rtbl_host = RTBLHost,
477
                   rtbl_domains_node = RTBLDomainsNode} =
478
                State) ->
UNCOV
479
    mod_antispam_rtbl:request_blocked_domains(RTBLHost, RTBLDomainsNode, Host),
×
UNCOV
480
    {noreply, State};
×
481
handle_info(Info, State) ->
482
    ?ERROR_MSG("Got unexpected info: ~p", [Info]),
×
483
    {noreply, State}.
×
484

485
-spec terminate(normal | shutdown | {shutdown, term()} | term(), state()) -> ok.
486
terminate(Reason,
487
          #state{host = Host,
488
                 dump_fd = Fd,
489
                 rtbl_host = RTBLHost,
490
                 rtbl_domains_node = RTBLDomainsNode,
491
                 rtbl_retry_timer = RTBLRetryTimer} =
492
              _State) ->
UNCOV
493
    ?DEBUG("Stopping spam filter process for ~s: ~p", [Host, Reason]),
2✔
UNCOV
494
    misc:cancel_timer(RTBLRetryTimer),
2✔
UNCOV
495
    mod_antispam_dump:terminate_dumping(Host, Fd),
2✔
UNCOV
496
    mod_antispam_files:terminate_files(Host),
2✔
UNCOV
497
    mod_antispam_filter:terminate_filtering(Host),
2✔
UNCOV
498
    ejabberd_hooks:delete(local_send_to_resource_hook,
2✔
499
                          Host,
500
                          mod_antispam_rtbl,
501
                          pubsub_event_handler,
502
                          50),
UNCOV
503
    mod_antispam_rtbl:unsubscribe(RTBLHost, RTBLDomainsNode, Host),
2✔
UNCOV
504
    ok.
2✔
505

506
-spec code_change({down, term()} | term(), state(), term()) -> {ok, state()}.
507
code_change(_OldVsn, #state{host = Host} = State, _Extra) ->
508
    ?DEBUG("Updating spam filter process for ~s", [Host]),
×
509
    {ok, State}.
×
510

511
%%--------------------------------------------------------------------
512
%%| Internal functions
513

514
-spec filter_jid(ljid(), jid_set(), state()) -> {ham | spam, state()}.
515
filter_jid(From, Set, #state{host = Host} = State) ->
UNCOV
516
    case sets:is_element(From, Set) of
2,588✔
517
        true ->
UNCOV
518
            ?DEBUG("Spam JID found: ~s", [jid:encode(From)]),
4✔
UNCOV
519
            ejabberd_hooks:run(spam_found, Host, [{jid, From}]),
4✔
UNCOV
520
            {spam, State};
4✔
521
        false ->
UNCOV
522
            case cache_lookup(From, State) of
2,584✔
523
                {true, State1} ->
UNCOV
524
                    ?DEBUG("Spam JID found: ~s", [jid:encode(From)]),
4✔
UNCOV
525
                    ejabberd_hooks:run(spam_found, Host, [{jid, From}]),
4✔
UNCOV
526
                    {spam, State1};
4✔
527
                {false, State1} ->
UNCOV
528
                    ?DEBUG("JID not listed: ~s", [jid:encode(From)]),
2,580✔
UNCOV
529
                    {ham, State1}
2,580✔
530
            end
531
    end.
532

533
-spec filter_body({urls, [url()]} | {jids, [ljid()]} | none,
534
                  url_set() | jid_set(),
535
                  jid(),
536
                  state()) ->
537
                     {ham | spam, state()}.
538
filter_body({_, Addrs}, Set, From, #state{host = Host} = State) ->
UNCOV
539
    case lists:any(fun(Addr) -> sets:is_element(Addr, Set) end, Addrs) of
12✔
540
        true ->
UNCOV
541
            ?DEBUG("Spam addresses found: ~p", [Addrs]),
2✔
UNCOV
542
            ejabberd_hooks:run(spam_found, Host, [{body, Addrs}]),
2✔
UNCOV
543
            {spam, cache_insert(From, State)};
2✔
544
        false ->
UNCOV
545
            ?DEBUG("Addresses not listed: ~p", [Addrs]),
10✔
UNCOV
546
            {ham, State}
10✔
547
    end;
548
filter_body(none, _Set, _From, State) ->
UNCOV
549
    {ham, State}.
12✔
550

551
-spec reload_files(state()) -> {ok | {error, binary()}, state()}.
552
reload_files(#state{host = Host, blocked_domains = BlockedDomains} = State) ->
UNCOV
553
    case read_files(Host) of
4✔
554
        #{jid := JIDsSet,
555
          url := URLsSet,
556
          domains := SpamDomainsSet,
557
          whitelist_domains := WhitelistDomains} ->
UNCOV
558
            case sets_equal(JIDsSet, State#state.jid_set) of
4✔
559
                true ->
UNCOV
560
                    ?INFO_MSG("Reloaded spam JIDs for ~s (unchanged)", [Host]);
4✔
561
                false ->
562
                    ?INFO_MSG("Reloaded spam JIDs for ~s (changed)", [Host])
×
563
            end,
UNCOV
564
            case sets_equal(URLsSet, State#state.url_set) of
4✔
565
                true ->
UNCOV
566
                    ?INFO_MSG("Reloaded spam URLs for ~s (unchanged)", [Host]);
4✔
567
                false ->
568
                    ?INFO_MSG("Reloaded spam URLs for ~s (changed)", [Host])
×
569
            end,
UNCOV
570
            {ok,
4✔
571
             State#state{jid_set = JIDsSet,
572
                         url_set = URLsSet,
573
                         blocked_domains = maps:merge(BlockedDomains, set_to_map(SpamDomainsSet)),
574
                         whitelist_domains = set_to_map(WhitelistDomains, false)}};
575
        {config_error, ErrorText} ->
576
            {{error, ErrorText}, State}
×
577
    end.
578

579
set_to_map(Set) ->
UNCOV
580
    set_to_map(Set, true).
6✔
581

582
set_to_map(Set, V) ->
UNCOV
583
    sets:fold(fun(K, M) -> M#{K => V} end, #{}, Set).
12✔
584

585
read_files(Host) ->
UNCOV
586
    AccInitial =
6✔
587
        #{jid => sets:new(),
588
          url => sets:new(),
589
          domains => sets:new(),
590
          whitelist_domains => sets:new()},
UNCOV
591
    Files =
6✔
592
        #{jid => gen_mod:get_module_opt(Host, ?MODULE, spam_jids_file),
593
          url => gen_mod:get_module_opt(Host, ?MODULE, spam_urls_file),
594
          domains => gen_mod:get_module_opt(Host, ?MODULE, spam_domains_file),
595
          whitelist_domains => gen_mod:get_module_opt(Host, ?MODULE, whitelist_domains_file)},
UNCOV
596
    ejabberd_hooks:run_fold(antispam_get_lists, Host, AccInitial, [Files]).
6✔
597

598
get_rtbl_services_option(Host) when is_binary(Host) ->
UNCOV
599
    get_rtbl_services_option(gen_mod:get_module_opts(Host, ?MODULE));
20✔
600
get_rtbl_services_option(Opts) when is_map(Opts) ->
UNCOV
601
    Services = gen_mod:get_opt(rtbl_services, Opts),
26✔
UNCOV
602
    case length(Services) =< 1 of
26✔
603
        true ->
UNCOV
604
            ok;
26✔
605
        false ->
606
            ?WARNING_MSG("Option rtbl_services only supports one service, but several "
×
607
                         "were configured. Will use only first one",
608
                         [])
×
609
    end,
UNCOV
610
    case Services of
26✔
611
        [] ->
612
            [#rtbl_service{}];
×
613
        [Host | _] when is_binary(Host) ->
UNCOV
614
            [#rtbl_service{host = Host, node = ?DEFAULT_RTBL_DOMAINS_NODE}];
26✔
615
        [[{Host, [{spam_source_domains_node, Node}]}] | _] ->
616
            [#rtbl_service{host = Host, node = Node}]
×
617
    end.
618

619
-spec get_proc_name(binary()) -> atom().
620
get_proc_name(Host) ->
UNCOV
621
    gen_mod:get_module_proc(Host, ?MODULE).
52✔
622

623
-spec get_spam_filter_hosts() -> [binary()].
624
get_spam_filter_hosts() ->
UNCOV
625
    [H || H <- ejabberd_option:hosts(), gen_mod:is_loaded(H, ?MODULE)].
4✔
626

627
-spec sets_equal(sets:set(), sets:set()) -> boolean().
628
sets_equal(A, B) ->
UNCOV
629
    sets:is_subset(A, B) andalso sets:is_subset(B, A).
8✔
630

631
-spec format(io:format(), [term()]) -> binary().
632
format(Format, Data) ->
UNCOV
633
    iolist_to_binary(io_lib:format(Format, Data)).
20✔
634

635
%%--------------------------------------------------------------------
636
%%| Caching
637

638
-spec cache_insert(ljid(), state()) -> state().
639
cache_insert(_LJID, #state{max_cache_size = 0} = State) ->
640
    State;
×
641
cache_insert(LJID, #state{jid_cache = Cache, max_cache_size = MaxSize} = State)
642
    when MaxSize /= unlimited, map_size(Cache) >= MaxSize ->
643
    cache_insert(LJID, shrink_cache(State));
×
644
cache_insert(LJID, #state{jid_cache = Cache} = State) ->
UNCOV
645
    ?INFO_MSG("Caching spam JID: ~s", [jid:encode(LJID)]),
4✔
UNCOV
646
    Cache1 = Cache#{LJID => erlang:monotonic_time(second)},
4✔
UNCOV
647
    State#state{jid_cache = Cache1}.
4✔
648

649
-spec cache_lookup(ljid(), state()) -> {boolean(), state()}.
650
cache_lookup(LJID, #state{jid_cache = Cache} = State) ->
UNCOV
651
    case Cache of
2,584✔
652
        #{LJID := _Timestamp} ->
UNCOV
653
            Cache1 = Cache#{LJID => erlang:monotonic_time(second)},
4✔
UNCOV
654
            State1 = State#state{jid_cache = Cache1},
4✔
UNCOV
655
            {true, State1};
4✔
656
        #{} ->
UNCOV
657
            {false, State}
2,580✔
658
    end.
659

660
-spec shrink_cache(state()) -> state().
661
shrink_cache(#state{jid_cache = Cache, max_cache_size = MaxSize} = State) ->
662
    ShrinkedSize = round(MaxSize / 2),
×
663
    N = map_size(Cache) - ShrinkedSize,
×
664
    L = lists:keysort(2, maps:to_list(Cache)),
×
665
    Cache1 =
×
666
        maps:from_list(
667
            lists:nthtail(N, L)),
668
    State#state{jid_cache = Cache1}.
×
669

670
-spec expire_cache(integer(), state()) -> {{ok, binary()}, state()}.
671
expire_cache(Age, #state{jid_cache = Cache} = State) ->
672
    Threshold = erlang:monotonic_time(second) - Age,
×
673
    Cache1 = maps:filter(fun(_, TS) -> TS >= Threshold end, Cache),
×
674
    NumExp = map_size(Cache) - map_size(Cache1),
×
675
    Txt = format("Expired ~B cache entries", [NumExp]),
×
676
    {{ok, Txt}, State#state{jid_cache = Cache1}}.
×
677

678
-spec add_to_cache(ljid(), state()) -> {{ok, binary()}, state()}.
679
add_to_cache(LJID, State) ->
UNCOV
680
    State1 = cache_insert(LJID, State),
2✔
UNCOV
681
    Txt = format("~s added to cache", [jid:encode(LJID)]),
2✔
UNCOV
682
    {{ok, Txt}, State1}.
2✔
683

684
-spec drop_from_cache(ljid(), state()) -> {{ok, binary()}, state()}.
685
drop_from_cache(LJID, #state{jid_cache = Cache} = State) ->
UNCOV
686
    Cache1 = maps:remove(LJID, Cache),
4✔
UNCOV
687
    if map_size(Cache1) < map_size(Cache) ->
4✔
UNCOV
688
           Txt = format("~s removed from cache", [jid:encode(LJID)]),
4✔
UNCOV
689
           {{ok, Txt}, State#state{jid_cache = Cache1}};
4✔
690
       true ->
691
           Txt = format("~s wasn't cached", [jid:encode(LJID)]),
×
692
           {{ok, Txt}, State}
×
693
    end.
694

695
%%--------------------------------------------------------------------
696
%%| ejabberd command callbacks
697

698
-spec get_commands_spec() -> [ejabberd_commands()].
699
get_commands_spec() ->
UNCOV
700
    [#ejabberd_commands{name = reload_spam_filter_files,
4✔
701
                        tags = [spam],
702
                        desc = "Reload spam JID/URL files",
703
                        module = ?MODULE,
704
                        function = reload_spam_filter_files,
705
                        note = "added in 25.07",
706
                        args = [{host, binary}],
707
                        result = {res, rescode}},
708
     #ejabberd_commands{name = get_spam_filter_cache,
709
                        tags = [spam],
710
                        desc = "Show spam filter cache contents",
711
                        module = ?MODULE,
712
                        function = get_spam_filter_cache,
713
                        note = "added in 25.07",
714
                        args = [{host, binary}],
715
                        result =
716
                            {spammers,
717
                             {list, {spammer, {tuple, [{jid, string}, {timestamp, integer}]}}}}},
718
     #ejabberd_commands{name = expire_spam_filter_cache,
719
                        tags = [spam],
720
                        desc = "Remove old/unused spam JIDs from cache",
721
                        module = ?MODULE,
722
                        function = expire_spam_filter_cache,
723
                        note = "added in 25.07",
724
                        args = [{host, binary}, {seconds, integer}],
725
                        result = {res, restuple}},
726
     #ejabberd_commands{name = add_to_spam_filter_cache,
727
                        tags = [spam],
728
                        desc = "Add JID to spam filter cache",
729
                        module = ?MODULE,
730
                        function = add_to_spam_filter_cache,
731
                        note = "added in 25.07",
732
                        args = [{host, binary}, {jid, binary}],
733
                        result = {res, restuple}},
734
     #ejabberd_commands{name = drop_from_spam_filter_cache,
735
                        tags = [spam],
736
                        desc = "Drop JID from spam filter cache",
737
                        module = ?MODULE,
738
                        function = drop_from_spam_filter_cache,
739
                        note = "added in 25.07",
740
                        args = [{host, binary}, {jid, binary}],
741
                        result = {res, restuple}},
742
     #ejabberd_commands{name = get_blocked_domains,
743
                        tags = [spam],
744
                        desc = "Get list of domains being blocked",
745
                        module = ?MODULE,
746
                        function = get_blocked_domains,
747
                        note = "added in 25.07",
748
                        args = [{host, binary}],
749
                        result = {blocked_domains, {list, {jid, string}}}},
750
     #ejabberd_commands{name = add_blocked_domain,
751
                        tags = [spam],
752
                        desc = "Add domain to list of blocked domains",
753
                        module = ?MODULE,
754
                        function = add_blocked_domain,
755
                        note = "added in 25.07",
756
                        args = [{host, binary}, {domain, binary}],
757
                        result = {res, restuple}},
758
     #ejabberd_commands{name = remove_blocked_domain,
759
                        tags = [spam],
760
                        desc = "Remove domain from list of blocked domains",
761
                        module = ?MODULE,
762
                        function = remove_blocked_domain,
763
                        note = "added in 25.07",
764
                        args = [{host, binary}, {domain, binary}],
765
                        result = {res, restuple}}].
766

767
for_all_hosts(F, A) ->
UNCOV
768
    try lists:map(fun(Host) -> apply(F, [Host | A]) end, get_spam_filter_hosts()) of
4✔
769
        List ->
UNCOV
770
            case lists:filter(fun ({error, _}) ->
4✔
771
                                      true;
×
772
                                  (_) ->
UNCOV
773
                                      false
4✔
774
                              end,
775
                              List)
776
            of
777
                [] ->
UNCOV
778
                    hd(List);
4✔
779
                Errors ->
780
                    hd(Errors)
×
781
            end
782
    catch
783
        error:{badmatch, {error, _Reason} = Error} ->
784
            Error
×
785
    end.
786

787
try_call_by_host(Host, Call) ->
UNCOV
788
    LServer = jid:nameprep(Host),
48✔
UNCOV
789
    Proc = get_proc_name(LServer),
48✔
UNCOV
790
    try gen_server:call(Proc, Call, ?COMMAND_TIMEOUT) of
48✔
791
        Result ->
UNCOV
792
            Result
48✔
793
    catch
794
        exit:{noproc, _} ->
795
            {error, "Not configured for " ++ binary_to_list(Host)};
×
796
        exit:{timeout, _} ->
797
            {error, "Timeout while querying ejabberd"}
×
798
    end.
799

800
-spec reload_spam_filter_files(binary()) -> ok | {error, string()}.
801
reload_spam_filter_files(<<"global">>) ->
802
    for_all_hosts(fun reload_spam_filter_files/1, []);
×
803
reload_spam_filter_files(Host) ->
804
    case try_call_by_host(Host, reload_spam_files) of
×
805
        {spam_filter, ok} ->
806
            ok;
×
807
        {spam_filter, {error, Txt}} ->
808
            {error, Txt};
×
809
        {error, _R} = Error ->
810
            Error
×
811
    end.
812

813
-spec get_blocked_domains(binary()) -> [binary()].
814
get_blocked_domains(Host) ->
UNCOV
815
    case try_call_by_host(Host, get_blocked_domains) of
26✔
816
        {blocked_domains, BlockedDomains} ->
UNCOV
817
            maps:keys(
26✔
818
                maps:filter(fun (_, false) ->
UNCOV
819
                                    false;
28✔
820
                                (_, _) ->
UNCOV
821
                                    true
22✔
822
                            end,
823
                            BlockedDomains));
824
        {error, _R} = Error ->
825
            Error
×
826
    end.
827

828
-spec add_blocked_domain(binary(), binary()) -> {ok, string()}.
829
add_blocked_domain(<<"global">>, Domain) ->
UNCOV
830
    for_all_hosts(fun add_blocked_domain/2, [Domain]);
2✔
831
add_blocked_domain(Host, Domain) ->
UNCOV
832
    case try_call_by_host(Host, {add_blocked_domain, Domain}) of
4✔
833
        {spam_filter, {Status, Txt}} ->
UNCOV
834
            {Status, binary_to_list(Txt)};
4✔
835
        {error, _R} = Error ->
836
            Error
×
837
    end.
838

839
-spec remove_blocked_domain(binary(), binary()) -> {ok, string()}.
840
remove_blocked_domain(<<"global">>, Domain) ->
UNCOV
841
    for_all_hosts(fun remove_blocked_domain/2, [Domain]);
2✔
842
remove_blocked_domain(Host, Domain) ->
UNCOV
843
    case try_call_by_host(Host, {remove_blocked_domain, Domain}) of
10✔
844
        {spam_filter, {Status, Txt}} ->
UNCOV
845
            {Status, binary_to_list(Txt)};
10✔
846
        {error, _R} = Error ->
847
            Error
×
848
    end.
849

850
-spec get_spam_filter_cache(binary()) -> [{binary(), integer()}] | {error, string()}.
851
get_spam_filter_cache(Host) ->
852
    case try_call_by_host(Host, get_cache) of
×
853
        {spam_filter, Cache} ->
854
            [{jid:encode(JID), TS + erlang:time_offset(second)} || {JID, TS} <- Cache];
×
855
        {error, _R} = Error ->
856
            Error
×
857
    end.
858

859
-spec expire_spam_filter_cache(binary(), integer()) -> {ok | error, string()}.
860
expire_spam_filter_cache(<<"global">>, Age) ->
861
    for_all_hosts(fun expire_spam_filter_cache/2, [Age]);
×
862
expire_spam_filter_cache(Host, Age) ->
863
    case try_call_by_host(Host, {expire_cache, Age}) of
×
864
        {spam_filter, {Status, Txt}} ->
865
            {Status, binary_to_list(Txt)};
×
866
        {error, _R} = Error ->
867
            Error
×
868
    end.
869

870
-spec add_to_spam_filter_cache(binary(), binary()) ->
871
                                  [{binary(), integer()}] | {error, string()}.
872
add_to_spam_filter_cache(<<"global">>, JID) ->
873
    for_all_hosts(fun add_to_spam_filter_cache/2, [JID]);
×
874
add_to_spam_filter_cache(Host, EncJID) ->
UNCOV
875
    try jid:decode(EncJID) of
2✔
876
        #jid{} = JID ->
UNCOV
877
            LJID =
2✔
878
                jid:remove_resource(
879
                    jid:tolower(JID)),
UNCOV
880
            case try_call_by_host(Host, {add_to_cache, LJID}) of
2✔
881
                {spam_filter, {Status, Txt}} ->
UNCOV
882
                    {Status, binary_to_list(Txt)};
2✔
883
                {error, _R} = Error ->
884
                    Error
×
885
            end
886
    catch
887
        _:{bad_jid, _} ->
888
            {error, "Not a valid JID: " ++ binary_to_list(EncJID)}
×
889
    end.
890

891
-spec drop_from_spam_filter_cache(binary(), binary()) -> {ok | error, string()}.
892
drop_from_spam_filter_cache(<<"global">>, JID) ->
893
    for_all_hosts(fun drop_from_spam_filter_cache/2, [JID]);
×
894
drop_from_spam_filter_cache(Host, EncJID) ->
UNCOV
895
    try jid:decode(EncJID) of
4✔
896
        #jid{} = JID ->
UNCOV
897
            LJID =
4✔
898
                jid:remove_resource(
899
                    jid:tolower(JID)),
UNCOV
900
            case try_call_by_host(Host, {drop_from_cache, LJID}) of
4✔
901
                {spam_filter, {Status, Txt}} ->
UNCOV
902
                    {Status, binary_to_list(Txt)};
4✔
903
                {error, _R} = Error ->
904
                    Error
×
905
            end
906
    catch
907
        _:{bad_jid, _} ->
908
            {error, "Not a valid JID: " ++ binary_to_list(EncJID)}
×
909
    end.
910

911
%%--------------------------------------------------------------------
912

913
%%| vim: set foldmethod=marker foldmarker=%%|,%%-:
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc