diff options
Diffstat (limited to 'src/mod_fail2ban.erl')
-rw-r--r-- | src/mod_fail2ban.erl | 231 |
1 files changed, 144 insertions, 87 deletions
diff --git a/src/mod_fail2ban.erl b/src/mod_fail2ban.erl index c57ac21b0..0d2473c15 100644 --- a/src/mod_fail2ban.erl +++ b/src/mod_fail2ban.erl @@ -1,12 +1,11 @@ %%%------------------------------------------------------------------- -%%% @author Evgeny Khramtsov <ekhramtsov@process-one.net> -%%% @doc -%%% -%%% @end +%%% File : mod_fail2ban.erl +%%% Author : Evgeny Khramtsov <ekhramtsov@process-one.net> +%%% Purpose : %%% Created : 15 Aug 2014 by Evgeny Khramtsov <ekhramtsov@process-one.net> %%% %%% -%%% ejabberd, Copyright (C) 2014-2016 ProcessOne +%%% ejabberd, Copyright (C) 2014-2019 ProcessOne %%% %%% This program is free software; you can redistribute it and/or %%% modify it under the terms of the GNU General Public License as @@ -21,26 +20,31 @@ %%% You should have received a copy of the GNU General Public License along %%% with this program; if not, write to the Free Software Foundation, Inc., %%% 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - +%%% %%%------------------------------------------------------------------- + -module(mod_fail2ban). -behaviour(gen_mod). -behaviour(gen_server). %% API --export([start_link/2, start/2, stop/1, c2s_auth_result/4, check_bl_c2s/3]). +-export([start/2, stop/1, reload/3, c2s_auth_result/3, + c2s_stream_started/2]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3, - mod_opt_type/1, depends/2]). + mod_opt_type/1, mod_options/1, depends/2]). + +%% ejabberd command. +-export([get_commands_spec/0, unban/1]). -include_lib("stdlib/include/ms_transform.hrl"). --include("ejabberd.hrl"). +-include("ejabberd_commands.hrl"). -include("logger.hrl"). +-include("xmpp.hrl"). +-include("translate.hrl"). --define(C2S_AUTH_BAN_LIFETIME, 3600). %% 1 hour --define(C2S_MAX_AUTH_FAILURES, 20). -define(CLEAN_INTERVAL, timer:minutes(10)). -record(state, {host = <<"">> :: binary()}). @@ -48,77 +52,75 @@ %%%=================================================================== %%% API %%%=================================================================== -start_link(Host, Opts) -> - Proc = gen_mod:get_module_proc(Host, ?MODULE), - gen_server:start_link({local, Proc}, ?MODULE, [Host, Opts], []). - -c2s_auth_result(false, _User, LServer, {Addr, _Port}) -> +-spec c2s_auth_result(ejabberd_c2s:state(), true | {false, binary()}, binary()) + -> ejabberd_c2s:state() | {stop, ejabberd_c2s:state()}. +c2s_auth_result(#{sasl_mech := Mech} = State, {false, _}, _User) + when Mech == <<"EXTERNAL">> -> + State; +c2s_auth_result(#{ip := {Addr, _}, lserver := LServer} = State, {false, _}, _User) -> case is_whitelisted(LServer, Addr) of true -> - ok; + State; false -> - BanLifetime = gen_mod:get_module_opt( - LServer, ?MODULE, c2s_auth_ban_lifetime, - fun(T) when is_integer(T), T > 0 -> T end, - ?C2S_AUTH_BAN_LIFETIME), - MaxFailures = gen_mod:get_module_opt( - LServer, ?MODULE, c2s_max_auth_failures, - fun(I) when is_integer(I), I > 0 -> I end, - ?C2S_MAX_AUTH_FAILURES), - UnbanTS = p1_time_compat:system_time(seconds) + BanLifetime, - case ets:lookup(failed_auth, Addr) of + BanLifetime = mod_fail2ban_opt:c2s_auth_ban_lifetime(LServer), + MaxFailures = mod_fail2ban_opt:c2s_max_auth_failures(LServer), + UnbanTS = current_time() + BanLifetime, + Attempts = case ets:lookup(failed_auth, Addr) of [{Addr, N, _, _}] -> - ets:insert(failed_auth, {Addr, N+1, UnbanTS, MaxFailures}); + ets:insert(failed_auth, + {Addr, N+1, UnbanTS, MaxFailures}), + N+1; [] -> - ets:insert(failed_auth, {Addr, 1, UnbanTS, MaxFailures}) + ets:insert(failed_auth, + {Addr, 1, UnbanTS, MaxFailures}), + 1 + end, + if Attempts >= MaxFailures -> + log_and_disconnect(State, Attempts, UnbanTS); + true -> + State end end; -c2s_auth_result(true, _User, _Server, _AddrPort) -> - ok. +c2s_auth_result(#{ip := {Addr, _}} = State, true, _User) -> + ets:delete(failed_auth, Addr), + State. -check_bl_c2s(_Acc, Addr, Lang) -> +-spec c2s_stream_started(ejabberd_c2s:state(), stream_start()) + -> ejabberd_c2s:state() | {stop, ejabberd_c2s:state()}. +c2s_stream_started(#{ip := {Addr, _}} = State, _) -> case ets:lookup(failed_auth, Addr) of [{Addr, N, TS, MaxFailures}] when N >= MaxFailures -> - case TS > p1_time_compat:system_time(seconds) of + case TS > current_time() of true -> - IP = jlib:ip_to_list(Addr), - UnbanDate = format_date( - calendar:now_to_universal_time(seconds_to_now(TS))), - LogReason = io_lib:fwrite( - "Too many (~p) failed authentications " - "from this IP address (~s). The address " - "will be unblocked at ~s UTC", - [N, IP, UnbanDate]), - ReasonT = io_lib:fwrite( - translate:translate( - Lang, - <<"Too many (~p) failed authentications " - "from this IP address (~s). The address " - "will be unblocked at ~s UTC">>), - [N, IP, UnbanDate]), - {stop, {true, LogReason, ReasonT}}; + log_and_disconnect(State, N, TS); false -> ets:delete(failed_auth, Addr), - false + State end; _ -> - false + State end. %%==================================================================== %% gen_mod callbacks %%==================================================================== start(Host, Opts) -> - catch ets:new(failed_auth, [named_table, public]), - Proc = gen_mod:get_module_proc(Host, ?MODULE), - ChildSpec = {Proc, {?MODULE, start_link, [Host, Opts]}, - transient, 1000, worker, [?MODULE]}, - supervisor:start_child(ejabberd_sup, ChildSpec). + catch ets:new(failed_auth, [named_table, public, + {heir, erlang:group_leader(), none}]), + ejabberd_commands:register_commands(get_commands_spec()), + gen_mod:start_child(?MODULE, Host, Opts). stop(Host) -> - Proc = gen_mod:get_module_proc(Host, ?MODULE), - supervisor:terminate_child(ejabberd_sup, Proc), - supervisor:delete_child(ejabberd_sup, Proc). + case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of + false -> + ejabberd_commands:unregister_commands(get_commands_spec()); + true -> + ok + end, + gen_mod:stop_child(?MODULE, Host). + +reload(_Host, _NewOpts, _OldOpts) -> + ok. depends(_Host, _Opts) -> []. @@ -126,74 +128,129 @@ depends(_Host, _Opts) -> %%%=================================================================== %%% gen_server callbacks %%%=================================================================== -init([Host, _Opts]) -> +init([Host|_]) -> + process_flag(trap_exit, true), ejabberd_hooks:add(c2s_auth_result, Host, ?MODULE, c2s_auth_result, 100), - ejabberd_hooks:add(check_bl_c2s, ?MODULE, check_bl_c2s, 100), + ejabberd_hooks:add(c2s_stream_started, Host, ?MODULE, c2s_stream_started, 100), erlang:send_after(?CLEAN_INTERVAL, self(), clean), {ok, #state{host = Host}}. -handle_call(_Request, _From, State) -> - Reply = ok, - {reply, Reply, State}. +handle_call(Request, From, State) -> + ?WARNING_MSG("Unexpected call from ~p: ~p", [From, Request]), + {noreply, State}. handle_cast(_Msg, State) -> - ?ERROR_MSG("got unexpected cast = ~p", [_Msg]), + ?WARNING_MSG("Unexpected cast = ~p", [_Msg]), {noreply, State}. handle_info(clean, State) -> - ?DEBUG("cleaning ~p ETS table", [failed_auth]), - Now = p1_time_compat:system_time(seconds), + ?DEBUG("Cleaning ~p ETS table", [failed_auth]), + Now = current_time(), ets:select_delete( failed_auth, ets:fun2ms(fun({_, _, UnbanTS, _}) -> UnbanTS =< Now end)), erlang:send_after(?CLEAN_INTERVAL, self(), clean), {noreply, State}; handle_info(_Info, State) -> - ?ERROR_MSG("got unexpected info = ~p", [_Info]), + ?WARNING_MSG("Unexpected info = ~p", [_Info]), {noreply, State}. terminate(_Reason, #state{host = Host}) -> ejabberd_hooks:delete(c2s_auth_result, Host, ?MODULE, c2s_auth_result, 100), - case is_loaded_at_other_hosts(Host) of + ejabberd_hooks:delete(c2s_stream_started, Host, ?MODULE, c2s_stream_started, 100), + case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of true -> ok; false -> - ejabberd_hooks:delete(check_bl_c2s, ?MODULE, check_bl_c2s, 100), ets:delete(failed_auth) end. code_change(_OldVsn, State, _Extra) -> {ok, State}. +%%-------------------------------------------------------------------- +%% ejabberd command callback. +%%-------------------------------------------------------------------- +-spec get_commands_spec() -> [ejabberd_commands()]. +get_commands_spec() -> + [#ejabberd_commands{name = unban_ip, tags = [accounts], + desc = "Remove banned IP addresses from the fail2ban table", + longdesc = "Accepts an IP address with a network mask. " + "Returns the number of unbanned addresses, or a negative integer if there were any error.", + module = ?MODULE, function = unban, + args = [{address, binary}], + args_example = [<<"::FFFF:127.0.0.1/128">>], + args_desc = ["IP address, optionally with network mask."], + result_example = 3, + result_desc = "Amount of unbanned entries, or negative in case of error.", + result = {unbanned, integer}}]. + +-spec unban(binary()) -> integer(). +unban(S) -> + case misc:parse_ip_mask(S) of + {ok, {Net, Mask}} -> + unban(Net, Mask); + error -> + ?WARNING_MSG("Invalid network address when trying to unban: ~p", [S]), + -1 + end. + +-spec unban(inet:ip_address(), 0..128) -> non_neg_integer(). +unban(Net, Mask) -> + ets:foldl( + fun({Addr, _, _, _}, Acc) -> + case misc:match_ip_mask(Addr, Net, Mask) of + true -> + ets:delete(failed_auth, Addr), + Acc+1; + false -> Acc + end + end, 0, failed_auth). + %%%=================================================================== %%% Internal functions %%%=================================================================== +-spec log_and_disconnect(ejabberd_c2s:state(), pos_integer(), non_neg_integer()) + -> {stop, ejabberd_c2s:state()}. +log_and_disconnect(#{ip := {Addr, _}, lang := Lang} = State, Attempts, UnbanTS) -> + IP = misc:ip_to_list(Addr), + UnbanDate = format_date( + calendar:now_to_universal_time(msec_to_now(UnbanTS))), + Format = ?T("Too many (~p) failed authentications " + "from this IP address (~ts). The address " + "will be unblocked at ~ts UTC"), + Args = [Attempts, IP, UnbanDate], + ?WARNING_MSG("Connection attempt from blacklisted IP ~ts: ~ts", + [IP, io_lib:fwrite(Format, Args)]), + Err = xmpp:serr_policy_violation({Format, Args}, Lang), + {stop, ejabberd_c2s:send(State, Err)}. + +-spec is_whitelisted(binary(), inet:ip_address()) -> boolean(). is_whitelisted(Host, Addr) -> - Access = gen_mod:get_module_opt(Host, ?MODULE, access, - fun(A) -> A end, - none), + Access = mod_fail2ban_opt:access(Host), acl:match_rule(Host, Access, Addr) == allow. -is_loaded_at_other_hosts(Host) -> - lists:any( - fun(VHost) when VHost == Host -> - false; - (VHost) -> - gen_mod:is_loaded(VHost, ?MODULE) - end, ?MYHOSTS). - -seconds_to_now(Secs) -> +-spec msec_to_now(pos_integer()) -> erlang:timestamp(). +msec_to_now(MSecs) -> + Secs = MSecs div 1000, {Secs div 1000000, Secs rem 1000000, 0}. +-spec format_date(calendar:datetime()) -> iolist(). format_date({{Year, Month, Day}, {Hour, Minute, Second}}) -> io_lib:format("~2..0w:~2..0w:~2..0w ~2..0w.~2..0w.~4..0w", [Hour, Minute, Second, Day, Month, Year]). +current_time() -> + erlang:system_time(millisecond). + mod_opt_type(access) -> - fun acl:access_rules_validator/1; + econf:acl(); mod_opt_type(c2s_auth_ban_lifetime) -> - fun (T) when is_integer(T), T > 0 -> T end; + econf:timeout(second); mod_opt_type(c2s_max_auth_failures) -> - fun (I) when is_integer(I), I > 0 -> I end; -mod_opt_type(_) -> - [access, c2s_auth_ban_lifetime, c2s_max_auth_failures]. + econf:pos_int(). + +mod_options(_Host) -> + [{access, none}, + {c2s_auth_ban_lifetime, timer:hours(1)}, + {c2s_max_auth_failures, 20}]. |