2016-12-28 07:47:11 +01:00
|
|
|
%%%-------------------------------------------------------------------
|
|
|
|
%%% Created : 16 Dec 2016 by Evgeny Khramtsov <ekhramtsov@process-one.net>
|
|
|
|
%%%
|
|
|
|
%%%
|
2024-01-22 16:40:01 +01:00
|
|
|
%%% ejabberd, Copyright (C) 2002-2024 ProcessOne
|
2016-12-28 07:47:11 +01:00
|
|
|
%%%
|
|
|
|
%%% This program is free software; you can redistribute it and/or
|
|
|
|
%%% modify it under the terms of the GNU General Public License as
|
|
|
|
%%% published by the Free Software Foundation; either version 2 of the
|
|
|
|
%%% License, or (at your option) any later version.
|
|
|
|
%%%
|
|
|
|
%%% This program is distributed in the hope that it will be useful,
|
|
|
|
%%% but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
%%% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
|
|
|
%%% General Public License for more details.
|
|
|
|
%%%
|
|
|
|
%%% 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_s2s_dialback).
|
|
|
|
-behaviour(gen_mod).
|
|
|
|
-protocol({xep, 220, '1.1.1'}).
|
|
|
|
-protocol({xep, 185, '1.0'}).
|
|
|
|
|
|
|
|
%% gen_mod API
|
2019-06-14 11:33:26 +02:00
|
|
|
-export([start/2, stop/1, reload/3, depends/2, mod_opt_type/1, mod_options/1]).
|
2020-01-08 10:24:51 +01:00
|
|
|
-export([mod_doc/0]).
|
2016-12-28 07:47:11 +01:00
|
|
|
%% Hooks
|
2016-12-29 22:00:36 +01:00
|
|
|
-export([s2s_out_auth_result/2, s2s_out_downgraded/2,
|
2016-12-31 11:48:55 +01:00
|
|
|
s2s_in_packet/2, s2s_out_packet/2, s2s_in_recv/3,
|
2019-06-14 11:33:26 +02:00
|
|
|
s2s_in_features/2, s2s_out_init/2, s2s_out_closed/2,
|
|
|
|
s2s_out_tls_verify/2]).
|
2016-12-28 07:47:11 +01:00
|
|
|
|
2020-09-03 13:45:57 +02:00
|
|
|
-include_lib("xmpp/include/xmpp.hrl").
|
2016-12-28 07:47:11 +01:00
|
|
|
-include("logger.hrl").
|
2019-06-22 16:08:45 +02:00
|
|
|
-include("translate.hrl").
|
2016-12-28 07:47:11 +01:00
|
|
|
|
|
|
|
%%%===================================================================
|
|
|
|
%%% API
|
|
|
|
%%%===================================================================
|
2023-08-04 17:53:50 +02:00
|
|
|
start(_Host, _Opts) ->
|
|
|
|
{ok, [{hook, s2s_out_init, s2s_out_init, 50},
|
|
|
|
{hook, s2s_out_closed, s2s_out_closed, 50},
|
|
|
|
{hook, s2s_in_pre_auth_features, s2s_in_features, 50},
|
|
|
|
{hook, s2s_in_post_auth_features, s2s_in_features, 50},
|
|
|
|
{hook, s2s_in_handle_recv, s2s_in_recv, 50},
|
|
|
|
{hook, s2s_in_unauthenticated_packet, s2s_in_packet, 50},
|
|
|
|
{hook, s2s_in_authenticated_packet, s2s_in_packet, 50},
|
|
|
|
{hook, s2s_out_packet, s2s_out_packet, 50},
|
|
|
|
{hook, s2s_out_downgraded, s2s_out_downgraded, 50},
|
|
|
|
{hook, s2s_out_auth_result, s2s_out_auth_result, 50},
|
|
|
|
{hook, s2s_out_tls_verify, s2s_out_tls_verify, 50}]}.
|
2016-12-28 07:47:11 +01:00
|
|
|
|
2023-08-04 17:53:50 +02:00
|
|
|
stop(_Host) ->
|
|
|
|
ok.
|
2016-12-28 07:47:11 +01:00
|
|
|
|
2019-06-14 11:33:26 +02:00
|
|
|
reload(_Host, _NewOpts, _OldOpts) ->
|
|
|
|
ok.
|
2017-02-22 17:46:47 +01:00
|
|
|
|
2016-12-28 07:47:11 +01:00
|
|
|
depends(_Host, _Opts) ->
|
|
|
|
[].
|
|
|
|
|
2019-06-14 11:33:26 +02:00
|
|
|
mod_opt_type(access) ->
|
|
|
|
econf:acl().
|
|
|
|
|
2018-01-23 08:54:52 +01:00
|
|
|
mod_options(_Host) ->
|
2019-06-14 11:33:26 +02:00
|
|
|
[{access, all}].
|
2016-12-28 07:47:11 +01:00
|
|
|
|
2020-01-08 10:24:51 +01:00
|
|
|
mod_doc() ->
|
|
|
|
#{desc =>
|
|
|
|
[?T("The module adds support for "
|
|
|
|
"https://xmpp.org/extensions/xep-0220.html"
|
|
|
|
"[XEP-0220: Server Dialback] to provide server identity "
|
|
|
|
"verification based on DNS."), "",
|
|
|
|
?T("WARNING: DNS-based verification is vulnerable to "
|
|
|
|
"https://en.wikipedia.org/wiki/DNS_spoofing"
|
|
|
|
"[DNS cache poisoning], so modern servers rely on "
|
|
|
|
"verification based on PKIX certificates. Thus this module "
|
|
|
|
"is only recommended for backward compatibility "
|
|
|
|
"with servers running outdated software or non-TLS servers, "
|
|
|
|
"or those with invalid certificates (as long as you accept "
|
|
|
|
"the risks, e.g. you assume that the remote server has "
|
|
|
|
"an invalid certificate due to poor administration and "
|
|
|
|
"not because it's compromised).")],
|
|
|
|
opts =>
|
|
|
|
[{access,
|
|
|
|
#{value => ?T("AccessName"),
|
|
|
|
desc =>
|
|
|
|
?T("An access rule that can be used to restrict "
|
|
|
|
"dialback for some servers. The default value "
|
|
|
|
"is 'all'.")}}],
|
|
|
|
example =>
|
|
|
|
["modules:",
|
|
|
|
" ...",
|
|
|
|
" mod_s2s_dialback:",
|
|
|
|
" access:",
|
|
|
|
" allow:",
|
|
|
|
" server: legacy.domain.tld",
|
|
|
|
" server: invalid-cert.example.org",
|
|
|
|
" deny: all",
|
|
|
|
" ..."]}.
|
|
|
|
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_in_features(Acc, _) ->
|
|
|
|
[#db_feature{errors = true}|Acc].
|
|
|
|
|
|
|
|
s2s_out_init({ok, State}, Opts) ->
|
|
|
|
case proplists:get_value(db_verify, Opts) of
|
|
|
|
{StreamID, Key, Pid} ->
|
|
|
|
%% This is an outbound s2s connection created at step 1.
|
|
|
|
%% The purpose of this connection is to verify dialback key ONLY.
|
|
|
|
%% The connection is not registered in s2s table and thus is not
|
|
|
|
%% seen by anyone.
|
|
|
|
%% The connection will be closed immediately after receiving the
|
|
|
|
%% verification response (at step 3)
|
|
|
|
{ok, State#{db_verify => {StreamID, Key, Pid}}};
|
|
|
|
undefined ->
|
|
|
|
{ok, State#{db_enabled => true}}
|
|
|
|
end;
|
|
|
|
s2s_out_init(Acc, _Opts) ->
|
|
|
|
Acc.
|
|
|
|
|
|
|
|
s2s_out_closed(#{server := LServer,
|
|
|
|
remote_server := RServer,
|
2018-02-19 19:47:20 +01:00
|
|
|
lang := Lang,
|
2016-12-29 22:00:36 +01:00
|
|
|
db_verify := {StreamID, _Key, _Pid}} = State, Reason) ->
|
2016-12-28 07:47:11 +01:00
|
|
|
%% Outbound s2s verificating connection (created at step 1) is
|
|
|
|
%% closed suddenly without receiving the response.
|
|
|
|
%% Building a response on our own
|
|
|
|
Response = #db_verify{from = RServer, to = LServer,
|
|
|
|
id = StreamID, type = error,
|
2018-02-19 19:47:20 +01:00
|
|
|
sub_els = [mk_error(Reason, Lang)]},
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_out_packet(State, Response);
|
|
|
|
s2s_out_closed(State, _Reason) ->
|
|
|
|
State.
|
|
|
|
|
2016-12-29 22:00:36 +01:00
|
|
|
s2s_out_auth_result(#{db_verify := _} = State, _) ->
|
2016-12-28 07:47:11 +01:00
|
|
|
%% The temporary outbound s2s connect (intended for verification)
|
|
|
|
%% has passed authentication state (either successfully or not, no matter)
|
|
|
|
%% and at this point we can send verification request as described
|
|
|
|
%% in section 2.1.2, step 2
|
2016-12-29 22:00:36 +01:00
|
|
|
{stop, send_verify_request(State)};
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_out_auth_result(#{db_enabled := true,
|
|
|
|
socket := Socket, ip := IP,
|
|
|
|
server := LServer,
|
2017-01-09 15:02:17 +01:00
|
|
|
remote_server := RServer} = State, {false, _}) ->
|
2016-12-28 07:47:11 +01:00
|
|
|
%% SASL authentication has failed, retrying with dialback
|
|
|
|
%% Sending dialback request, section 2.1.1, step 1
|
2019-09-23 14:17:20 +02:00
|
|
|
?INFO_MSG("(~ts) Retrying with s2s dialback authentication: ~ts -> ~ts (~ts)",
|
2017-12-26 16:55:57 +01:00
|
|
|
[xmpp_socket:pp(Socket), LServer, RServer,
|
2017-04-11 12:13:58 +02:00
|
|
|
ejabberd_config:may_hide_data(misc:ip_to_list(IP))]),
|
2016-12-28 07:47:11 +01:00
|
|
|
State1 = maps:remove(stop_reason, State#{on_route => queue}),
|
2016-12-29 22:00:36 +01:00
|
|
|
{stop, send_db_request(State1)};
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_out_auth_result(State, _) ->
|
|
|
|
State.
|
|
|
|
|
2016-12-29 22:00:36 +01:00
|
|
|
s2s_out_downgraded(#{db_verify := _} = State, _) ->
|
|
|
|
%% The verifying outbound s2s connection detected non-RFC compliant
|
|
|
|
%% server, send verification request immediately without auth phase,
|
|
|
|
%% section 2.1.2, step 2
|
|
|
|
{stop, send_verify_request(State)};
|
|
|
|
s2s_out_downgraded(#{db_enabled := true,
|
|
|
|
socket := Socket, ip := IP,
|
|
|
|
server := LServer,
|
|
|
|
remote_server := RServer} = State, _) ->
|
|
|
|
%% non-RFC compliant server detected, send dialback request instantly,
|
|
|
|
%% section 2.1.1, step 1
|
2019-09-23 14:17:20 +02:00
|
|
|
?INFO_MSG("(~ts) Trying s2s dialback authentication with "
|
|
|
|
"non-RFC compliant server: ~ts -> ~ts (~ts)",
|
2017-12-26 16:55:57 +01:00
|
|
|
[xmpp_socket:pp(Socket), LServer, RServer,
|
2017-04-11 12:13:58 +02:00
|
|
|
ejabberd_config:may_hide_data(misc:ip_to_list(IP))]),
|
2016-12-29 22:00:36 +01:00
|
|
|
{stop, send_db_request(State)};
|
|
|
|
s2s_out_downgraded(State, _) ->
|
|
|
|
State.
|
|
|
|
|
2018-02-19 19:47:20 +01:00
|
|
|
s2s_in_packet(#{stream_id := StreamID, lang := Lang} = State,
|
2016-12-28 07:47:11 +01:00
|
|
|
#db_result{from = From, to = To, key = Key, type = undefined}) ->
|
|
|
|
%% Received dialback request, section 2.2.1, step 1
|
|
|
|
try
|
|
|
|
ok = check_from_to(From, To),
|
|
|
|
%% We're creating a temporary outbound s2s connection to
|
|
|
|
%% send verification request and to receive verification response
|
|
|
|
{ok, Pid} = ejabberd_s2s_out:start(
|
|
|
|
To, From, [{db_verify, {StreamID, Key, self()}}]),
|
|
|
|
ejabberd_s2s_out:connect(Pid),
|
2017-01-23 11:51:05 +01:00
|
|
|
{stop, State}
|
2016-12-28 07:47:11 +01:00
|
|
|
catch _:{badmatch, {error, Reason}} ->
|
2017-01-23 11:51:05 +01:00
|
|
|
{stop,
|
|
|
|
send_db_result(State,
|
|
|
|
#db_verify{from = From, to = To, type = error,
|
2018-02-19 19:47:20 +01:00
|
|
|
sub_els = [mk_error(Reason, Lang)]})}
|
2016-12-28 07:47:11 +01:00
|
|
|
end;
|
|
|
|
s2s_in_packet(State, #db_verify{to = To, from = From, key = Key,
|
|
|
|
id = StreamID, type = undefined}) ->
|
|
|
|
%% Received verification request, section 2.2.2, step 2
|
|
|
|
Type = case make_key(To, From, StreamID) of
|
|
|
|
Key -> valid;
|
|
|
|
_ -> invalid
|
|
|
|
end,
|
|
|
|
Response = #db_verify{from = To, to = From, id = StreamID, type = Type},
|
2017-01-23 11:51:05 +01:00
|
|
|
{stop, ejabberd_s2s_in:send(State, Response)};
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_in_packet(State, Pkt) when is_record(Pkt, db_result);
|
|
|
|
is_record(Pkt, db_verify) ->
|
2019-09-23 14:17:20 +02:00
|
|
|
?WARNING_MSG("Got stray dialback packet:~n~ts", [xmpp:pp(Pkt)]),
|
2016-12-28 07:47:11 +01:00
|
|
|
State;
|
|
|
|
s2s_in_packet(State, _) ->
|
|
|
|
State.
|
|
|
|
|
2018-02-19 19:47:20 +01:00
|
|
|
s2s_in_recv(#{lang := Lang} = State, El, {error, Why}) ->
|
2016-12-31 11:48:55 +01:00
|
|
|
case xmpp:get_name(El) of
|
|
|
|
Tag when Tag == <<"db:result">>;
|
|
|
|
Tag == <<"db:verify">> ->
|
|
|
|
case xmpp:get_type(El) of
|
|
|
|
T when T /= <<"valid">>,
|
|
|
|
T /= <<"invalid">>,
|
|
|
|
T /= <<"error">> ->
|
2018-02-19 19:47:20 +01:00
|
|
|
Err = xmpp:make_error(El, mk_error({codec_error, Why}, Lang)),
|
2016-12-31 11:48:55 +01:00
|
|
|
{stop, ejabberd_s2s_in:send(State, Err)};
|
|
|
|
_ ->
|
|
|
|
State
|
|
|
|
end;
|
|
|
|
_ ->
|
|
|
|
State
|
|
|
|
end;
|
|
|
|
s2s_in_recv(State, _El, _Pkt) ->
|
|
|
|
State.
|
|
|
|
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_out_packet(#{server := LServer,
|
|
|
|
remote_server := RServer,
|
|
|
|
db_verify := {StreamID, _Key, Pid}} = State,
|
|
|
|
#db_verify{from = RServer, to = LServer,
|
|
|
|
id = StreamID, type = Type} = Response)
|
|
|
|
when Type /= undefined ->
|
|
|
|
%% Received verification response, section 2.1.2, step 3
|
|
|
|
%% This is a response for the request sent at step 2
|
|
|
|
ejabberd_s2s_in:update_state(
|
|
|
|
Pid, fun(S) -> send_db_result(S, Response) end),
|
|
|
|
%% At this point the connection is no longer needed and we can terminate it
|
2020-04-01 14:35:49 +02:00
|
|
|
ejabberd_s2s_out:stop_async(self()),
|
|
|
|
State;
|
2016-12-28 07:47:11 +01:00
|
|
|
s2s_out_packet(#{server := LServer, remote_server := RServer} = State,
|
|
|
|
#db_result{to = LServer, from = RServer,
|
|
|
|
type = Type} = Result) when Type /= undefined ->
|
|
|
|
%% Received dialback response, section 2.1.1, step 4
|
|
|
|
%% This is a response to the request sent at step 1
|
|
|
|
State1 = maps:remove(db_enabled, State),
|
|
|
|
case Type of
|
|
|
|
valid ->
|
|
|
|
State2 = ejabberd_s2s_out:handle_auth_success(<<"dialback">>, State1),
|
|
|
|
ejabberd_s2s_out:establish(State2);
|
|
|
|
_ ->
|
2020-01-22 12:52:30 +01:00
|
|
|
Reason = str:format("Peer responded with error: ~s",
|
2017-02-28 08:13:09 +01:00
|
|
|
[format_error(Result)]),
|
|
|
|
ejabberd_s2s_out:handle_auth_failure(
|
|
|
|
<<"dialback">>, {auth, Reason}, State1)
|
2016-12-28 07:47:11 +01:00
|
|
|
end;
|
|
|
|
s2s_out_packet(State, Pkt) when is_record(Pkt, db_result);
|
|
|
|
is_record(Pkt, db_verify) ->
|
2019-09-23 14:17:20 +02:00
|
|
|
?WARNING_MSG("Got stray dialback packet:~n~ts", [xmpp:pp(Pkt)]),
|
2016-12-28 07:47:11 +01:00
|
|
|
State;
|
|
|
|
s2s_out_packet(State, _) ->
|
|
|
|
State.
|
|
|
|
|
2019-06-14 11:33:26 +02:00
|
|
|
-spec s2s_out_tls_verify(boolean(), ejabberd_s2s_out:state()) -> boolean().
|
2019-07-03 18:08:38 +02:00
|
|
|
s2s_out_tls_verify(_, #{server_host := ServerHost, remote_server := RServer}) ->
|
|
|
|
Access = mod_s2s_dialback_opt:access(ServerHost),
|
|
|
|
case acl:match_rule(ServerHost, Access, jid:make(RServer)) of
|
2019-06-14 11:33:26 +02:00
|
|
|
allow -> false;
|
|
|
|
deny -> true
|
|
|
|
end.
|
|
|
|
|
2016-12-28 07:47:11 +01:00
|
|
|
%%%===================================================================
|
|
|
|
%%% Internal functions
|
|
|
|
%%%===================================================================
|
|
|
|
-spec make_key(binary(), binary(), binary()) -> binary().
|
|
|
|
make_key(From, To, StreamID) ->
|
2019-06-14 11:33:26 +02:00
|
|
|
Secret = ejabberd_config:get_shared_key(),
|
2017-03-14 00:31:51 +01:00
|
|
|
str:to_hexlist(
|
2020-06-01 10:35:28 +02:00
|
|
|
misc:crypto_hmac(sha256, str:to_hexlist(crypto:hash(sha256, Secret)),
|
2016-12-28 07:47:11 +01:00
|
|
|
[To, " ", From, " ", StreamID])).
|
|
|
|
|
2016-12-29 22:00:36 +01:00
|
|
|
-spec send_verify_request(ejabberd_s2s_out:state()) -> ejabberd_s2s_out:state().
|
|
|
|
send_verify_request(#{server := LServer,
|
|
|
|
remote_server := RServer,
|
|
|
|
db_verify := {StreamID, Key, _Pid}} = State) ->
|
|
|
|
Request = #db_verify{from = LServer, to = RServer,
|
|
|
|
key = Key, id = StreamID},
|
|
|
|
ejabberd_s2s_out:send(State, Request).
|
|
|
|
|
|
|
|
-spec send_db_request(ejabberd_s2s_out:state()) -> ejabberd_s2s_out:state().
|
|
|
|
send_db_request(#{server := LServer,
|
|
|
|
remote_server := RServer,
|
|
|
|
stream_remote_id := StreamID} = State) ->
|
|
|
|
Key = make_key(LServer, RServer, StreamID),
|
|
|
|
ejabberd_s2s_out:send(State, #db_result{from = LServer,
|
|
|
|
to = RServer,
|
|
|
|
key = Key}).
|
|
|
|
|
2016-12-28 07:47:11 +01:00
|
|
|
-spec send_db_result(ejabberd_s2s_in:state(), db_verify()) -> ejabberd_s2s_in:state().
|
|
|
|
send_db_result(State, #db_verify{from = From, to = To,
|
|
|
|
type = Type, sub_els = Els}) ->
|
|
|
|
%% Sending dialback response, section 2.2.1, step 4
|
|
|
|
%% This is a response to the request received at step 1
|
|
|
|
Response = #db_result{from = To, to = From, type = Type, sub_els = Els},
|
|
|
|
State1 = ejabberd_s2s_in:send(State, Response),
|
|
|
|
case Type of
|
|
|
|
valid ->
|
|
|
|
State2 = ejabberd_s2s_in:handle_auth_success(
|
|
|
|
From, <<"dialback">>, undefined, State1),
|
|
|
|
ejabberd_s2s_in:establish(State2);
|
|
|
|
_ ->
|
2020-01-22 12:52:30 +01:00
|
|
|
Reason = str:format("Verification failed: ~s",
|
2017-02-28 08:13:09 +01:00
|
|
|
[format_error(Response)]),
|
2016-12-28 07:47:11 +01:00
|
|
|
ejabberd_s2s_in:handle_auth_failure(
|
|
|
|
From, <<"dialback">>, Reason, State1)
|
|
|
|
end.
|
|
|
|
|
|
|
|
-spec check_from_to(binary(), binary()) -> ok | {error, forbidden | host_unknown}.
|
|
|
|
check_from_to(From, To) ->
|
|
|
|
case ejabberd_router:is_my_route(To) of
|
|
|
|
false -> {error, host_unknown};
|
|
|
|
true ->
|
|
|
|
LServer = ejabberd_router:host_of_route(To),
|
|
|
|
case ejabberd_s2s:allow_host(LServer, From) of
|
|
|
|
true -> ok;
|
|
|
|
false -> {error, forbidden}
|
|
|
|
end
|
|
|
|
end.
|
|
|
|
|
2018-02-19 19:47:20 +01:00
|
|
|
-spec mk_error(term(), binary()) -> stanza_error().
|
|
|
|
mk_error(forbidden, Lang) ->
|
2019-06-22 16:08:45 +02:00
|
|
|
xmpp:err_forbidden(?T("Access denied by service policy"), Lang);
|
2018-02-19 19:47:20 +01:00
|
|
|
mk_error(host_unknown, Lang) ->
|
2019-06-22 16:08:45 +02:00
|
|
|
xmpp:err_not_allowed(?T("Host unknown"), Lang);
|
2018-02-19 19:47:20 +01:00
|
|
|
mk_error({codec_error, Why}, Lang) ->
|
|
|
|
xmpp:err_bad_request(xmpp:io_format_error(Why), Lang);
|
|
|
|
mk_error({_Class, _Reason} = Why, Lang) ->
|
2016-12-29 22:00:36 +01:00
|
|
|
Txt = xmpp_stream_out:format_error(Why),
|
2018-02-19 19:47:20 +01:00
|
|
|
xmpp:err_remote_server_not_found(Txt, Lang);
|
|
|
|
mk_error(_, _) ->
|
2016-12-28 07:47:11 +01:00
|
|
|
xmpp:err_internal_server_error().
|
|
|
|
|
|
|
|
-spec format_error(db_result()) -> binary().
|
|
|
|
format_error(#db_result{type = invalid}) ->
|
|
|
|
<<"invalid dialback key">>;
|
2017-12-06 15:21:57 +01:00
|
|
|
format_error(#db_result{type = error} = Result) ->
|
|
|
|
case xmpp:get_error(Result) of
|
2017-02-28 08:13:09 +01:00
|
|
|
#stanza_error{} = Err ->
|
2018-06-25 08:45:45 +02:00
|
|
|
xmpp:format_stanza_error(Err);
|
2016-12-28 07:47:11 +01:00
|
|
|
undefined ->
|
|
|
|
<<"unrecognized error">>
|
|
|
|
end;
|
|
|
|
format_error(_) ->
|
|
|
|
<<"unexpected dialback result">>.
|