aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJérôme Sautret <jerome.sautret@process-one.net>2020-04-28 16:03:21 +0200
committerGitHub <noreply@github.com>2020-04-28 16:03:21 +0200
commit24a11fc8e880a8e0d154425c8b76a6119303942b (patch)
tree8f8e6befdb53b9ed4a8059dc707d954dccd268e0
parentDon't offer X-OAUTH2 if the only auth method enabled is Anonymous (#3209) (diff)
parentejabberd_stun: Tone down 'auth_realm' warning (diff)
Merge pull request #3235 from weiss/xep-0215
Support STUN/TURN service discovery
-rw-r--r--ejabberd.yml.example1
-rw-r--r--mix.exs4
-rw-r--r--rebar.config4
-rw-r--r--src/ejabberd_stun.erl14
-rw-r--r--src/gen_mod.erl4
-rw-r--r--src/mod_stun_disco.erl670
-rw-r--r--src/mod_stun_disco_opt.erl41
-rw-r--r--test/ejabberd_SUITE.erl1
-rw-r--r--test/ejabberd_SUITE_data/ejabberd.yml12
-rw-r--r--test/ejabberd_SUITE_data/macros.yml1
-rw-r--r--test/stundisco_tests.erl192
-rw-r--r--test/suite.erl1
12 files changed, 932 insertions, 13 deletions
diff --git a/ejabberd.yml.example b/ejabberd.yml.example
index 57f7fef12..51204ebe3 100644
--- a/ejabberd.yml.example
+++ b/ejabberd.yml.example
@@ -203,6 +203,7 @@ modules:
mod_shared_roster: {}
mod_stream_mgmt:
resend_on_timeout: if_offline
+ mod_stun_disco: {}
mod_vcard: {}
mod_vcard_xupdate: {}
mod_version:
diff --git a/mix.exs b/mix.exs
index 5330425ea..9e7daf398 100644
--- a/mix.exs
+++ b/mix.exs
@@ -85,12 +85,12 @@ defmodule Ejabberd.Mixfile do
[{:lager, "~> 3.6.0"},
{:p1_utils, "~> 1.0"},
{:fast_xml, "~> 1.1"},
- {:xmpp, "~> 1.4"},
+ {:xmpp, git: "https://github.com/processone/xmpp", ref: "f8c5b3bb"},
{:cache_tab, "~> 1.0"},
{:stringprep, "~> 1.0"},
{:fast_yaml, "~> 1.0"},
{:fast_tls, "~> 1.1"},
- {:stun, "~> 1.0"},
+ {:stun, git: "https://github.com/processone/stun", ref: "f1516827", override: true},
{:esip, "~> 1.0"},
{:p1_mysql, "~> 1.0"},
{:mqtree, "~> 1.0"},
diff --git a/rebar.config b/rebar.config
index 047cda73d..32d13078c 100644
--- a/rebar.config
+++ b/rebar.config
@@ -25,7 +25,7 @@
{stringprep, ".*", {git, "https://github.com/processone/stringprep", {tag, "1.0.19"}}},
{fast_xml, ".*", {git, "https://github.com/processone/fast_xml", {tag, "1.1.39"}}},
{idna, ".*", {git, "https://github.com/benoitc/erlang-idna", {tag, "6.0.0"}}},
- {xmpp, ".*", {git, "https://github.com/processone/xmpp", "c23e66ebac8fdec4aa08c8926091b0dcf6dacf22"}},
+ {xmpp, ".*", {git, "https://github.com/processone/xmpp", "f8c5b3bb"}},
{fast_yaml, ".*", {git, "https://github.com/processone/fast_yaml", {tag, "1.0.24"}}},
{yconf, ".*", {git, "https://github.com/processone/yconf", {tag, "1.0.4"}}},
{jiffy, ".*", {git, "https://github.com/davisp/jiffy", {tag, "1.0.4"}}},
@@ -36,7 +36,7 @@
{mqtree, ".*", {git, "https://github.com/processone/mqtree", {tag, "1.0.7"}}},
{p1_acme, ".*", {git, "https://github.com/processone/p1_acme.git", {tag, "1.0.5"}}},
{base64url, ".*", {git, "https://github.com/dvv/base64url.git", {tag, "v1.0"}}},
- {if_var_true, stun, {stun, ".*", {git, "https://github.com/processone/stun", {tag, "1.0.31"}}}},
+ {if_var_true, stun, {stun, ".*", {git, "https://github.com/processone/stun", "f1516827"}}},
{if_var_true, sip, {esip, ".*", {git, "https://github.com/processone/esip", {tag, "1.0.32"}}}},
{if_var_true, mysql, {p1_mysql, ".*", {git, "https://github.com/processone/p1_mysql",
"604f7b339e845c2fb4219960a19fc5c048f16c0b"}}},
diff --git a/src/ejabberd_stun.erl b/src/ejabberd_stun.erl
index 6363355cf..92552cc8a 100644
--- a/src/ejabberd_stun.erl
+++ b/src/ejabberd_stun.erl
@@ -124,13 +124,13 @@ prepare_turn_opts(Opts, _UseTurn = true) ->
Realm = case proplists:get_value(auth_realm, Opts) of
undefined when AuthType == user ->
if NumberOfMyHosts > 1 ->
- ?WARNING_MSG("You have several virtual "
- "hosts configured, but option "
- "'auth_realm' is undefined and "
- "'auth_type' is set to 'user', "
- "most likely the TURN relay won't "
- "be working properly. Using ~ts as "
- "a fallback", [ejabberd_config:get_myname()]);
+ ?INFO_MSG("You have several virtual hosts "
+ "configured, but option 'auth_realm' is "
+ "undefined and 'auth_type' is set to "
+ "'user', so the TURN relay might not be "
+ "working properly. Using ~ts as a "
+ "fallback",
+ [ejabberd_config:get_myname()]);
true ->
ok
end,
diff --git a/src/gen_mod.erl b/src/gen_mod.erl
index d424f4b5c..fbcc6b8bc 100644
--- a/src/gen_mod.erl
+++ b/src/gen_mod.erl
@@ -86,7 +86,7 @@ start_link() ->
end.
init([]) ->
- ejabberd_hooks:add(config_reloaded, ?MODULE, config_reloaded, 50),
+ ejabberd_hooks:add(config_reloaded, ?MODULE, config_reloaded, 60),
ejabberd_hooks:add(host_up, ?MODULE, start_modules, 40),
ejabberd_hooks:add(host_down, ?MODULE, stop_modules, 70),
ets:new(ejabberd_modules,
@@ -97,7 +97,7 @@ init([]) ->
-spec stop() -> ok.
stop() ->
- ejabberd_hooks:delete(config_reloaded, ?MODULE, config_reloaded, 50),
+ ejabberd_hooks:delete(config_reloaded, ?MODULE, config_reloaded, 60),
ejabberd_hooks:delete(host_up, ?MODULE, start_modules, 40),
ejabberd_hooks:delete(host_down, ?MODULE, stop_modules, 70),
stop_modules(),
diff --git a/src/mod_stun_disco.erl b/src/mod_stun_disco.erl
new file mode 100644
index 000000000..52ced9b28
--- /dev/null
+++ b/src/mod_stun_disco.erl
@@ -0,0 +1,670 @@
+%%%----------------------------------------------------------------------
+%%% File : mod_stun_disco.erl
+%%% Author : Holger Weiss <holger@zedat.fu-berlin.de>
+%%% Purpose : External Service Discovery (XEP-0215)
+%%% Created : 18 Apr 2020 by Holger Weiss <holger@zedat.fu-berlin.de>
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2020 ProcessOne
+%%%
+%%% 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_stun_disco).
+-author('holger@zedat.fu-berlin.de').
+-protocol({xep, 215, '0.7'}).
+
+-behaviour(gen_server).
+-behaviour(gen_mod).
+
+%% gen_mod callbacks.
+-export([start/2,
+ stop/1,
+ reload/3,
+ mod_opt_type/1,
+ mod_options/1,
+ depends/2]).
+-export([mod_doc/0]).
+
+%% gen_server callbacks.
+-export([init/1,
+ handle_call/3,
+ handle_cast/2,
+ handle_info/2,
+ terminate/2,
+ code_change/3]).
+
+%% ejabberd_hooks callbacks.
+-export([disco_local_features/5, stun_get_password/3]).
+
+%% gen_iq_handler callback.
+-export([process_iq/1]).
+
+-include("logger.hrl").
+-include("translate.hrl").
+-include("xmpp.hrl").
+
+-define(STUN_MODULE, ejabberd_stun).
+
+-type host_or_hash() :: binary() | {hash, binary()}.
+-type service_type() :: stun | stuns | turn | turns | undefined.
+
+-record(request,
+ {host :: binary() | inet:ip_address() | undefined,
+ port :: 0..65535 | undefined,
+ transport :: udp | tcp | undefined,
+ type :: service_type(),
+ restricted :: true | undefined}).
+
+-record(state,
+ {host :: binary(),
+ services :: [service()],
+ secret :: binary(),
+ ttl :: non_neg_integer()}).
+
+-type request() :: #request{}.
+-type state() :: #state{}.
+
+%%--------------------------------------------------------------------
+%% gen_mod callbacks.
+%%--------------------------------------------------------------------
+-spec start(binary(), gen_mod:opts()) -> {ok, pid()} | {error, any()}.
+start(Host, Opts) ->
+ Proc = get_proc_name(Host),
+ gen_mod:start_child(?MODULE, Host, Opts, Proc).
+
+-spec stop(binary()) -> ok | {error, any()}.
+stop(Host) ->
+ Proc = get_proc_name(Host),
+ gen_mod:stop_child(Proc).
+
+-spec reload(binary(), gen_mod:opts(), gen_mod:opts()) -> ok.
+reload(Host, NewOpts, OldOpts) ->
+ cast(Host, {reload, NewOpts, OldOpts}).
+
+-spec depends(binary(), gen_mod:opts()) -> [{module(), hard | soft}].
+depends(_Host, _Opts) ->
+ [].
+
+-spec mod_opt_type(atom()) -> econf:validator().
+mod_opt_type(access) ->
+ econf:acl();
+mod_opt_type(credentials_lifetime) ->
+ econf:timeout(second);
+mod_opt_type(offer_local_services) ->
+ econf:bool();
+mod_opt_type(secret) ->
+ econf:binary();
+mod_opt_type(services) ->
+ econf:list(
+ econf:and_then(
+ econf:options(
+ #{host => econf:either(econf:ip(), econf:binary()),
+ port => econf:port(),
+ type => econf:enum([stun, turn, stuns, turns]),
+ transport => econf:enum([tcp, udp]),
+ restricted => econf:bool()},
+ [{required, [host]}]),
+ fun(Opts) ->
+ DefPort = fun(stun) -> 3478;
+ (turn) -> 3478;
+ (stuns) -> 5349;
+ (turns) -> 5349
+ end,
+ DefTrns = fun(stun) -> udp;
+ (turn) -> udp;
+ (stuns) -> tcp;
+ (turns) -> tcp
+ end,
+ DefRstr = fun(stun) -> false;
+ (turn) -> true;
+ (stuns) -> false;
+ (turns) -> true
+ end,
+ Host = proplists:get_value(host, Opts),
+ Type = proplists:get_value(type, Opts, stun),
+ Port = proplists:get_value(port, Opts, DefPort(Type)),
+ Trns = proplists:get_value(transport, Opts, DefTrns(Type)),
+ Rstr = proplists:get_value(restricted, Opts, DefRstr(Type)),
+ #service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Trns,
+ restricted = Rstr}
+ end)).
+
+-spec mod_options(binary()) -> [{services, [tuple()]} | {atom(), any()}].
+mod_options(_Host) ->
+ [{access, local},
+ {credentials_lifetime, timer:minutes(10)},
+ {offer_local_services, true},
+ {secret, undefined},
+ {services, []}].
+
+mod_doc() ->
+ #{desc =>
+ ?T("This module allows XMPP clients to discover STUN/TURN services "
+ "and to obtain temporary credentials for using them as per "
+ "https://xmpp.org/extensions/xep-0215.html"
+ "[XEP-0215: External Service Discovery]."),
+ opts =>
+ [{access,
+ #{value => ?T("AccessName"),
+ desc =>
+ ?T("This option defines which access rule will be used to "
+ "control who is allowed to discover STUN/TURN services "
+ "and to request temporary credentials. The default value "
+ "is 'local'.")}},
+ {credentials_lifetime,
+ #{value => "timeout()",
+ desc =>
+ ?T("The lifetime of temporary credentails offered to "
+ "clients. If a lifetime longer than the default value of "
+ "'10' minutes is specified, it's strongly recommended to "
+ "also specify a 'secret' (see below).")}},
+ {offer_local_services,
+ #{value => "true | false",
+ desc =>
+ ?T("This option specifies whether local STUN/TURN services "
+ "configured as ejabberd listeners should be announced "
+ "automatically. Note that this will not include "
+ "TLS-enabled services, which must be configured manually "
+ "using the 'services' option (see below). For "
+ "non-anonymous TURN services, temporary credentials will "
+ "be offered to the client. The default value is "
+ "'true'.")}},
+ {secret,
+ #{value => ?T("Text"),
+ desc =>
+ ?T("The secret used for generating temporary credentials. If "
+ "this option isn't specified, a secret will be "
+ "auto-generated. However, a secret must be specified if "
+ "non-anonymous TURN services running on other ejabberd "
+ "nodes and/or external TURN 'services' are configured. "
+ "Also note that auto-generated secrets are lost when the "
+ "node is restarted, which invalidates any credentials "
+ "offered before the restart. Therefore, the "
+ "'credentials_lifetime' should not exceed a few minutes "
+ "if no 'secret' is specified.")}},
+ {services,
+ #{value => "[Service, ...]",
+ example =>
+ ["services:",
+ " -",
+ " host: 203.0.113.3",
+ " port: 3478",
+ " type: stun",
+ " transport: udp",
+ " restricted: false",
+ " -",
+ " host: 203.0.113.3",
+ " port: 3478",
+ " type: turn",
+ " transport: udp",
+ " restricted: true",
+ " -",
+ " host: 203.0.113.3",
+ " port: 3478",
+ " type: stun",
+ " transport: tcp",
+ " restricted: false",
+ " -",
+ " host: 203.0.113.3",
+ " port: 3478",
+ " type: turn",
+ " transport: tcp",
+ " restricted: true",
+ " -",
+ " host: server.example.com",
+ " port: 5349",
+ " type: stuns",
+ " transport: tcp",
+ " restricted: false",
+ " -",
+ " host: server.example.com",
+ " port: 5349",
+ " type: turns",
+ " transport: tcp",
+ " restricted: true"],
+ desc =>
+ ?T("The list of services offered to clients. This list can "
+ "include STUN/TURN services running on any ejabberd node "
+ "and/or external services. However, if any listed TURN "
+ "service not running on the local ejabberd node requires "
+ "authentication, a 'secret' must be specified explicitly, "
+ "and must be shared with that service. This will only "
+ "work with ejabberd's built-in STUN/TURN server and with "
+ "external servers that support the same "
+ "https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00"
+ "[REST API For Access To TURN Services]. Unless the "
+ "'offer_local_services' is set to 'false', the explicitly "
+ "listed services will be offered in addition to those "
+ "announced automatically.")},
+ [{host,
+ #{value => ?T("Host"),
+ desc =>
+ ?T("The host name or IPv4 address the STUN/TURN service is "
+ "listening on. For non-TLS services, it's recommended "
+ "to specify an IPv4 address (to avoid additional DNS "
+ "lookup latency on the client side). For TLS services, "
+ "the host name (or possible IPv4 address) should match "
+ "the certificate. Specifying the 'host' option is "
+ "mandatory.")}},
+ {port,
+ #{value => "1..65535",
+ desc =>
+ ?T("The port number the STUN/TURN service is listening "
+ "on. The default port number is 3478 for non-TLS "
+ "services and 5349 for TLS services.")}},
+ {type,
+ #{value => "stun | turn | stuns | turns",
+ desc =>
+ ?T("The type of service. Must be 'stun' or 'turn' for "
+ "non-TLS services, 'stuns' or 'turns' for TLS services. "
+ "The default type is 'stun'.")}},
+ {transport,
+ #{value => "tcp | udp",
+ desc =>
+ ?T("The transport protocol supported by the service. The "
+ "default is 'udp' for non-TLS services and 'tcp' for "
+ "TLS services.")}},
+ {restricted,
+ #{value => "true | false",
+ desc =>
+ ?T("This option determines whether temporary credentials "
+ "for accessing the service are offered. The default is "
+ "'false' for STUN/STUNS services and 'true' for "
+ "TURN/TURNS services.")}}]}]}.
+
+%%--------------------------------------------------------------------
+%% gen_server callbacks.
+%%--------------------------------------------------------------------
+-spec init(list()) -> {ok, state()}.
+init([Host, Opts]) ->
+ process_flag(trap_exit, true),
+ Services = get_configured_services(Opts),
+ Secret = get_configured_secret(Opts),
+ TTL = get_configured_ttl(Opts),
+ register_iq_handlers(Host),
+ register_hooks(Host),
+ {ok, #state{host = Host, services = Services, secret = Secret, ttl = TTL}}.
+
+-spec handle_call(term(), {pid(), term()}, state())
+ -> {reply, {turn_disco, [service()] | binary()}, state()} |
+ {noreply, state()}.
+handle_call({get_services, JID, #request{host = ReqHost,
+ port = ReqPort,
+ type = ReqType,
+ transport = ReqTrns,
+ restricted = ReqRstr}}, _From,
+ #state{host = Host,
+ services = List0,
+ secret = Secret,
+ ttl = TTL} = State) ->
+ ?DEBUG("Getting STUN/TURN service list for ~ts", [jid:encode(JID)]),
+ Hash = <<(hash(jid:encode(JID)))/binary, (hash(Host))/binary>>,
+ List = lists:filtermap(
+ fun(#service{host = H, port = P, type = T, restricted = R})
+ when (ReqHost /= undefined) and (H /= ReqHost);
+ (ReqPort /= undefined) and (P /= ReqPort);
+ (ReqType /= undefined) and (T /= ReqType);
+ (ReqTrns /= undefined) and (T /= ReqTrns);
+ (ReqRstr /= undefined) and (R /= ReqRstr) ->
+ false;
+ (#service{restricted = false}) ->
+ true;
+ (#service{restricted = true} = Service) ->
+ {true, add_credentials(Service, Hash, Secret, TTL)}
+ end, List0),
+ ?INFO_MSG("Offering STUN/TURN services to ~ts (~s)",
+ [jid:encode(JID), Hash]),
+ {reply, {turn_disco, List}, State};
+handle_call({get_password, Username}, _From, #state{secret = Secret} = State) ->
+ ?DEBUG("Getting STUN/TURN password for ~ts", [Username]),
+ Password = make_password(Username, Secret),
+ {reply, {turn_disco, Password}, State};
+handle_call(Request, From, State) ->
+ ?ERROR_MSG("Got unexpected request from ~p: ~p", [From, Request]),
+ {noreply, State}.
+
+-spec handle_cast(term(), state()) -> {noreply, state()}.
+handle_cast({reload, NewOpts, _OldOpts}, #state{host = Host} = State) ->
+ ?DEBUG("Reloading STUN/TURN discovery configuration for ~ts", [Host]),
+ Services = get_configured_services(NewOpts),
+ Secret = get_configured_secret(NewOpts),
+ TTL = get_configured_ttl(NewOpts),
+ {noreply, State#state{services = Services, secret = Secret, ttl = TTL}};
+handle_cast(Request, State) ->
+ ?ERROR_MSG("Got unexpected request from: ~p", [Request]),
+ {noreply, State}.
+
+-spec handle_info(term(), state()) -> {noreply, state()}.
+handle_info(Info, State) ->
+ ?ERROR_MSG("Got unexpected info: ~p", [Info]),
+ {noreply, State}.
+
+-spec terminate(normal | shutdown | {shutdown, term()} | term(), state()) -> ok.
+terminate(Reason, #state{host = Host}) ->
+ ?DEBUG("Stopping STUN/TURN discovery process for ~ts: ~p",
+ [Host, Reason]),
+ unregister_hooks(Host),
+ unregister_iq_handlers(Host).
+
+-spec code_change({down, term()} | term(), state(), term()) -> {ok, state()}.
+code_change(_OldVsn, #state{host = Host} = State, _Extra) ->
+ ?DEBUG("Updating STUN/TURN discovery process for ~ts", [Host]),
+ {ok, State}.
+
+%%--------------------------------------------------------------------
+%% Register/unregister hooks.
+%%--------------------------------------------------------------------
+-spec register_hooks(binary()) -> ok.
+register_hooks(Host) ->
+ ejabberd_hooks:add(disco_local_features, Host, ?MODULE,
+ disco_local_features, 50),
+ ejabberd_hooks:add(stun_get_password, ?MODULE,
+ stun_get_password, 50).
+
+-spec unregister_hooks(binary()) -> ok.
+unregister_hooks(Host) ->
+ ejabberd_hooks:delete(disco_local_features, Host, ?MODULE,
+ disco_local_features, 50),
+ case gen_mod:is_loaded_elsewhere(Host, ?MODULE) of
+ false ->
+ ejabberd_hooks:delete(stun_get_password, ?MODULE,
+ stun_get_password, 50);
+ true ->
+ ok
+ end.
+
+%%--------------------------------------------------------------------
+%% Hook callbacks.
+%%--------------------------------------------------------------------
+-spec disco_local_features(mod_disco:features_acc(), jid(), jid(), binary(),
+ binary()) -> mod_disco:features_acc().
+disco_local_features(empty, From, To, Node, Lang) ->
+ disco_local_features({result, []}, From, To, Node, Lang);
+disco_local_features({result, OtherFeatures} = Acc, From,
+ #jid{lserver = LServer}, <<"">>, _Lang) ->
+ Access = mod_stun_disco_opt:access(LServer),
+ case acl:match_rule(LServer, Access, From) of
+ allow ->
+ ?DEBUG("Announcing feature to ~ts", [jid:encode(From)]),
+ {result, [?NS_EXTDISCO_2 | OtherFeatures]};
+ deny ->
+ ?DEBUG("Not announcing feature to ~ts", [jid:encode(From)]),
+ Acc
+ end;
+disco_local_features(Acc, _From, _To, _Node, _Lang) ->
+ Acc.
+
+-spec stun_get_password(any(), binary(), binary())
+ -> binary() | {stop, binary()}.
+stun_get_password(<<>>, Username, _Realm) ->
+ case binary:split(Username, <<$:>>) of
+ [Expiration, <<_UserHash:8/binary, HostHash:8/binary>>] ->
+ try binary_to_integer(Expiration) of
+ ExpireTime ->
+ case erlang:system_time(second) of
+ Now when Now < ExpireTime ->
+ ?DEBUG("Looking up password for: ~ts", [Username]),
+ {stop, get_password(Username, HostHash)};
+ Now when Now >= ExpireTime ->
+ ?INFO_MSG("Credentials expired: ~ts", [Username]),
+ {stop, <<>>}
+ end
+ catch _:badarg ->
+ ?DEBUG("Non-numeric expiration field: ~ts", [Username]),
+ <<>>
+ end;
+ _ ->
+ ?DEBUG("Not an ephemeral username: ~ts", [Username]),
+ <<>>
+ end;
+stun_get_password(Acc, _Username, _Realm) ->
+ Acc.
+
+%%--------------------------------------------------------------------
+%% IQ handlers.
+%%--------------------------------------------------------------------
+-spec register_iq_handlers(binary()) -> ok.
+register_iq_handlers(Host) ->
+ gen_iq_handler:add_iq_handler(ejabberd_local, Host,
+ ?NS_EXTDISCO_2, ?MODULE, process_iq).
+
+-spec unregister_iq_handlers(binary()) -> ok.
+unregister_iq_handlers(Host) ->
+ gen_iq_handler:remove_iq_handler(ejabberd_local, Host, ?NS_EXTDISCO_2).
+
+-spec process_iq(iq()) -> iq().
+process_iq(#iq{type = get,
+ sub_els = [#services{type = ReqType}]} = IQ) ->
+ Request = #request{type = ReqType},
+ process_iq_get(IQ, Request);
+process_iq(#iq{type = get,
+ sub_els = [#credentials{
+ services = [#service{
+ host = ReqHost,
+ port = ReqPort,
+ type = ReqType,
+ transport = ReqTrns,
+ name = <<>>,
+ username = <<>>,
+ password = <<>>,
+ expires = undefined,
+ restricted = undefined,
+ action = undefined,
+ xdata = undefined}]}]} = IQ) ->
+ % Accepting the 'transport' request attribute is an ejabberd extension.
+ Request = #request{host = ReqHost,
+ port = ReqPort,
+ type = ReqType,
+ transport = ReqTrns,
+ restricted = true},
+ process_iq_get(IQ, Request);
+process_iq(#iq{type = set, lang = Lang} = IQ) ->
+ Txt = ?T("Value 'set' of 'type' attribute is not allowed"),
+ xmpp:make_error(IQ, xmpp:err_not_allowed(Txt, Lang));
+process_iq(#iq{lang = Lang} = IQ) ->
+ Txt = ?T("No module is handling this query"),
+ xmpp:make_error(IQ, xmpp:err_service_unavailable(Txt, Lang)).
+
+-spec process_iq_get(iq(), request()) -> iq().
+process_iq_get(#iq{from = From, to = #jid{lserver = Host}, lang = Lang} = IQ,
+ Request) ->
+ Access = mod_stun_disco_opt:access(Host),
+ case acl:match_rule(Host, Access, From) of
+ allow ->
+ ?DEBUG("Performing external service discovery for ~ts",
+ [jid:encode(From)]),
+ case get_services(Host, From, Request) of
+ {ok, Services} ->
+ xmpp:make_iq_result(IQ, #services{list = Services});
+ {error, timeout} -> % Has been logged already.
+ Txt = ?T("Service list retrieval timed out"),
+ Err = xmpp:err_internal_server_error(Txt, Lang),
+ xmpp:make_error(IQ, Err)
+ end;
+ deny ->
+ ?DEBUG("Won't perform external service discovery for ~ts",
+ [jid:encode(From)]),
+ Txt = ?T("Access denied by service policy"),
+ xmpp:make_error(IQ, xmpp:err_forbidden(Txt, Lang))
+ end.
+
+%%--------------------------------------------------------------------
+%% Internal functions.
+%%--------------------------------------------------------------------
+-spec get_configured_services(gen_mod:opts()) -> [service()].
+get_configured_services(Opts) ->
+ LocalServices = case mod_stun_disco_opt:offer_local_services(Opts) of
+ true ->
+ ?DEBUG("Discovering local services", []),
+ find_local_services();
+ false ->
+ ?DEBUG("Won't discover local services", []),
+ []
+ end,
+ dedup(LocalServices ++ mod_stun_disco_opt:services(Opts)).
+
+-spec get_configured_secret(gen_mod:opts()) -> binary().
+get_configured_secret(Opts) ->
+ case mod_stun_disco_opt:secret(Opts) of
+ undefined ->
+ ?DEBUG("Auto-generating secret", []),
+ new_secret();
+ Secret ->
+ ?DEBUG("Using configured secret", []),
+ Secret
+ end.
+
+-spec get_configured_ttl(gen_mod:opts()) -> non_neg_integer().
+get_configured_ttl(Opts) ->
+ mod_stun_disco_opt:credentials_lifetime(Opts) div 1000.
+
+-spec new_secret() -> binary().
+new_secret() ->
+ p1_rand:bytes(20).
+
+-spec add_credentials(service(), binary(), binary(), non_neg_integer())
+ -> service().
+add_credentials(Service, Hash, Secret, TTL) ->
+ ExpireAt = erlang:system_time(second) + TTL,
+ Username = make_username(ExpireAt, Hash),
+ Password = make_password(Username, Secret),
+ ?DEBUG("Created ephemeral credentials: ~s | ~s", [Username, Password]),
+ Service#service{username = Username,
+ password = Password,
+ expires = seconds_to_timestamp(ExpireAt)}.
+
+-spec make_username(non_neg_integer(), binary()) -> binary().
+make_username(ExpireAt, Hash) ->
+ <<(integer_to_binary(ExpireAt))/binary, $:, Hash/binary>>.
+
+-spec make_password(binary(), binary()) -> binary().
+make_password(Username, Secret) ->
+ base64:encode(crypto:hmac(sha, Secret, Username)).
+
+-spec get_password(binary(), binary()) -> binary().
+get_password(Username, HostHash) ->
+ try call({hash, HostHash}, {get_password, Username}) of
+ {turn_disco, Password} ->
+ Password
+ catch
+ exit:{timeout, _} ->
+ ?ERROR_MSG("Asking ~ts for password timed out", [HostHash]),
+ <<>>;
+ exit:{noproc, _} -> % Can be triggered by bogus Username.
+ ?DEBUG("Cannot retrieve password for ~ts", [Username]),
+ <<>>
+ end.
+
+-spec get_services(binary(), jid(), request())
+ -> {ok, [service()]} | {error, timeout}.
+get_services(Host, JID, Request) ->
+ try call(Host, {get_services, JID, Request}) of
+ {turn_disco, Services} ->
+ {ok, Services}
+ catch
+ exit:{timeout, _} ->
+ ?ERROR_MSG("Asking ~ts for services timed out", [Host]),
+ {error, timeout}
+ end.
+
+-spec find_local_services() -> [service()].
+find_local_services() ->
+ ParseListener = fun(Listener) -> parse_listener(Listener) end,
+ lists:flatmap(ParseListener, ejabberd_option:listen()).
+
+-spec parse_listener(ejabberd_listener:listener()) -> [service()].
+parse_listener({_EndPoint, ?STUN_MODULE, #{tls := true}}) ->
+ ?DEBUG("Ignoring TLS-enabled STUN/TURN listener", []),
+ []; % Avoid certificate hostname issues.
+parse_listener({{Port, _Addr, Transport}, ?STUN_MODULE, Opts}) ->
+ case get_listener_ip(Opts) of
+ {127, _, _, _} = Addr ->
+ ?INFO_MSG("Won't auto-announce STUN/TURN service with loopback "
+ "address: ~s:~B (~s), please specify a public 'turn_ip'",
+ [misc:ip_to_list(Addr), Port, Transport]),
+ [];
+ Addr ->
+ StunService = #service{host = Addr,
+ port = Port,
+ transport = Transport,
+ restricted = false,
+ type = stun},
+ case Opts of
+ #{use_turn := true} ->
+ ?DEBUG("Found STUN/TURN listener: ~s:~B (~s)",
+ [misc:ip_to_list(Addr), Port, Transport]),
+ [StunService, #service{host = Addr,
+ port = Port,
+ transport = Transport,
+ restricted = is_restricted(Opts),
+ type = turn}];
+ #{use_turn := false} ->
+ ?DEBUG("Found STUN listener: ~s:~B (~s)",
+ [misc:ip_to_list(Addr), Port, Transport]),
+ [StunService]
+ end
+ end;
+parse_listener({_EndPoint, Module, _Opts}) ->
+ ?DEBUG("Ignoring ~s listener", [Module]),
+ [].
+
+-spec get_listener_ip(map()) -> inet:ip_address().
+get_listener_ip(#{ip := { 0, 0, 0, 0}} = Opts) -> get_turn_ip(Opts);
+get_listener_ip(#{ip := {127, _, _, _}} = Opts) -> get_turn_ip(Opts);
+get_listener_ip(#{ip := { 10, _, _, _}} = Opts) -> get_turn_ip(Opts);
+get_listener_ip(#{ip := {172, 16, _, _}} = Opts) -> get_turn_ip(Opts);
+get_listener_ip(#{ip := {192, 168, _, _}} = Opts) -> get_turn_ip(Opts);
+get_listener_ip(#{ip := IP}) -> IP.
+
+-spec get_turn_ip(map()) -> inet:ip_address().
+get_turn_ip(#{turn_ip := {_, _, _, _} = TurnIP}) -> TurnIP;
+get_turn_ip(#{turn_ip := undefined}) -> misc:get_my_ip().
+
+-spec is_restricted(map()) -> boolean().
+is_restricted(#{auth_type := user}) -> true;
+is_restricted(#{auth_type := anonymous}) -> false.
+
+-spec call(host_or_hash(), term()) -> term().
+call(Host, Request) ->
+ Proc = get_proc_name(Host),
+ gen_server:call(Proc, Request, timer:seconds(15)).
+
+-spec cast(host_or_hash(), term()) -> ok.
+cast(Host, Request) ->
+ Proc = get_proc_name(Host),
+ gen_server:cast(Proc, Request).
+
+-spec get_proc_name(host_or_hash()) -> atom().
+get_proc_name(Host) when is_binary(Host) ->
+ get_proc_name({hash, hash(Host)});
+get_proc_name({hash, HostHash}) ->
+ gen_mod:get_module_proc(HostHash, ?MODULE).
+
+-spec hash(binary()) -> binary().
+hash(Host) ->
+ str:to_hexlist(binary_part(crypto:hash(sha, Host), 0, 4)).
+
+-spec dedup(list()) -> list().
+dedup([]) -> [];
+dedup([H | T]) -> [H | [E || E <- dedup(T), E /= H]].
+
+-spec seconds_to_timestamp(non_neg_integer()) -> erlang:timestamp().
+seconds_to_timestamp(Seconds) ->
+ {Seconds div 1000000, Seconds rem 1000000, 0}.
diff --git a/src/mod_stun_disco_opt.erl b/src/mod_stun_disco_opt.erl
new file mode 100644
index 000000000..43b8102e6
--- /dev/null
+++ b/src/mod_stun_disco_opt.erl
@@ -0,0 +1,41 @@
+%% Generated automatically
+%% DO NOT EDIT: run `make options` instead
+
+-module(mod_stun_disco_opt).
+
+-export([access/1]).
+-export([credentials_lifetime/1]).
+-export([offer_local_services/1]).
+-export([secret/1]).
+-export([services/1]).
+
+-spec access(gen_mod:opts() | global | binary()) -> 'local' | acl:acl().
+access(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(access, Opts);
+access(Host) ->
+ gen_mod:get_module_opt(Host, mod_stun_disco, access).
+
+-spec credentials_lifetime(gen_mod:opts() | global | binary()) -> pos_integer().
+credentials_lifetime(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(credentials_lifetime, Opts);
+credentials_lifetime(Host) ->
+ gen_mod:get_module_opt(Host, mod_stun_disco, credentials_lifetime).
+
+-spec offer_local_services(gen_mod:opts() | global | binary()) -> boolean().
+offer_local_services(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(offer_local_services, Opts);
+offer_local_services(Host) ->
+ gen_mod:get_module_opt(Host, mod_stun_disco, offer_local_services).
+
+-spec secret(gen_mod:opts() | global | binary()) -> 'undefined' | binary().
+secret(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(secret, Opts);
+secret(Host) ->
+ gen_mod:get_module_opt(Host, mod_stun_disco, secret).
+
+-spec services(gen_mod:opts() | global | binary()) -> [tuple()].
+services(Opts) when is_map(Opts) ->
+ gen_mod:get_opt(services, Opts);
+services(Host) ->
+ gen_mod:get_module_opt(Host, mod_stun_disco, services).
+
diff --git a/test/ejabberd_SUITE.erl b/test/ejabberd_SUITE.erl
index 6658f6734..e471823aa 100644
--- a/test/ejabberd_SUITE.erl
+++ b/test/ejabberd_SUITE.erl
@@ -361,6 +361,7 @@ no_db_tests() ->
muc_tests:master_slave_cases(),
proxy65_tests:single_cases(),
proxy65_tests:master_slave_cases(),
+ stundisco_tests:single_cases(),
replaced_tests:master_slave_cases(),
upload_tests:single_cases(),
carbons_tests:single_cases(),
diff --git a/test/ejabberd_SUITE_data/ejabberd.yml b/test/ejabberd_SUITE_data/ejabberd.yml
index 922325609..5f584b6da 100644
--- a/test/ejabberd_SUITE_data/ejabberd.yml
+++ b/test/ejabberd_SUITE_data/ejabberd.yml
@@ -91,6 +91,12 @@ listen:
"/upload": mod_http_upload
"/captcha": ejabberd_captcha
-
+ port: STUN_PORT
+ module: ejabberd_stun
+ transport: udp
+ use_turn: true
+ turn_ip: "203.0.113.3"
+ -
port: COMPONENT_PORT
module: ejabberd_service
password: PASSWORD
@@ -124,6 +130,12 @@ Welcome to this XMPP server."
mod_stream_mgmt:
max_ack_queue: 10
resume_timeout: 3
+ mod_stun_disco:
+ secret: "cryptic"
+ services:
+ -
+ host: "example.com"
+ type: turns
mod_time: []
mod_version: []
mod_http_upload:
diff --git a/test/ejabberd_SUITE_data/macros.yml b/test/ejabberd_SUITE_data/macros.yml
index fdd467584..fd4e09f01 100644
--- a/test/ejabberd_SUITE_data/macros.yml
+++ b/test/ejabberd_SUITE_data/macros.yml
@@ -4,6 +4,7 @@ define_macro:
C2S_PORT: @@c2s_port@@
S2S_PORT: @@s2s_port@@
WEB_PORT: @@web_port@@
+ STUN_PORT: @@stun_port@@
COMPONENT_PORT: @@component_port@@
PROXY_PORT: @@proxy_port@@
PASSWORD: >-
diff --git a/test/stundisco_tests.erl b/test/stundisco_tests.erl
new file mode 100644
index 000000000..8cb026dc0
--- /dev/null
+++ b/test/stundisco_tests.erl
@@ -0,0 +1,192 @@
+%%%-------------------------------------------------------------------
+%%% Author : Holger Weiss <holger@zedat.fu-berlin.de>
+%%% Created : 22 Apr 2020 by Holger Weiss <holger@zedat.fu-berlin.de>
+%%%
+%%%
+%%% ejabberd, Copyright (C) 2020 ProcessOne
+%%%
+%%% 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(stundisco_tests).
+
+%% API
+-compile(export_all).
+-import(suite, [send_recv/2, disconnect/1, is_feature_advertised/2,
+ server_jid/1]).
+
+-include("suite.hrl").
+
+%%%===================================================================
+%%% API
+%%%===================================================================
+%%%===================================================================
+%%% Single user tests
+%%%===================================================================
+single_cases() ->
+ {stundisco_single, [sequence],
+ [single_test(feature_enabled),
+ single_test(stun_service),
+ single_test(turn_service),
+ single_test(turns_service),
+ single_test(turn_credentials),
+ single_test(turns_credentials)]}.
+
+feature_enabled(Config) ->
+ true = is_feature_advertised(Config, ?NS_EXTDISCO_2),
+ disconnect(Config).
+
+stun_service(Config) ->
+ ServerJID = server_jid(Config),
+ Host = {203, 0, 113, 3},
+ Port = ct:get_config(stun_port, 3478),
+ Type = stun,
+ Transport = udp,
+ Request = #services{type = Type},
+ #iq{type = result,
+ sub_els = [#services{
+ type = undefined,
+ list = [#service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Transport,
+ restricted = false,
+ username = <<>>,
+ password = <<>>,
+ expires = undefined,
+ action = undefined,
+ xdata = undefined}]}]} =
+ send_recv(Config, #iq{type = get, to = ServerJID, sub_els = [Request]}),
+ disconnect(Config).
+
+turn_service(Config) ->
+ ServerJID = server_jid(Config),
+ Host = {203, 0, 113, 3},
+ Port = ct:get_config(stun_port, 3478),
+ Type = turn,
+ Transport = udp,
+ Request = #services{type = Type},
+ #iq{type = result,
+ sub_els = [#services{
+ type = undefined,
+ list = [#service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Transport,
+ restricted = true,
+ username = Username,
+ password = Password,
+ expires = Expires,
+ action = undefined,
+ xdata = undefined}]}]} =
+ send_recv(Config, #iq{type = get, to = ServerJID, sub_els = [Request]}),
+ true = check_password(Username, Password),
+ true = check_expires(Expires),
+ disconnect(Config).
+
+turns_service(Config) ->
+ ServerJID = server_jid(Config),
+ Host = <<"example.com">>,
+ Port = 5349,
+ Type = turns,
+ Transport = tcp,
+ Request = #services{type = Type},
+ #iq{type = result,
+ sub_els = [#services{
+ type = undefined,
+ list = [#service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Transport,
+ restricted = true,
+ username = Username,
+ password = Password,
+ expires = Expires,
+ action = undefined,
+ xdata = undefined}]}]} =
+ send_recv(Config, #iq{type = get, to = ServerJID, sub_els = [Request]}),
+ true = check_password(Username, Password),
+ true = check_expires(Expires),
+ disconnect(Config).
+
+turn_credentials(Config) ->
+ ServerJID = server_jid(Config),
+ Host = {203, 0, 113, 3},
+ Port = ct:get_config(stun_port, 3478),
+ Type = turn,
+ Transport = udp,
+ Request = #credentials{services = [#service{host = Host,
+ port = Port,
+ type = Type}]},
+ #iq{type = result,
+ sub_els = [#services{
+ type = undefined,
+ list = [#service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Transport,
+ restricted = true,
+ username = Username,
+ password = Password,
+ expires = Expires,
+ action = undefined,
+ xdata = undefined}]}]} =
+ send_recv(Config, #iq{type = get, to = ServerJID, sub_els = [Request]}),
+ true = check_password(Username, Password),
+ true = check_expires(Expires),
+ disconnect(Config).
+
+turns_credentials(Config) ->
+ ServerJID = server_jid(Config),
+ Host = <<"example.com">>,
+ Port = 5349,
+ Type = turns,
+ Transport = tcp,
+ Request = #credentials{services = [#service{host = Host,
+ port = Port,
+ type = Type}]},
+ #iq{type = result,
+ sub_els = [#services{
+ type = undefined,
+ list = [#service{host = Host,
+ port = Port,
+ type = Type,
+ transport = Transport,
+ restricted = true,
+ username = Username,
+ password = Password,
+ expires = Expires,
+ action = undefined,
+ xdata = undefined}]}]} =
+ send_recv(Config, #iq{type = get, to = ServerJID, sub_els = [Request]}),
+ true = check_password(Username, Password),
+ true = check_expires(Expires),
+ disconnect(Config).
+
+%%%===================================================================
+%%% Internal functions
+%%%===================================================================
+single_test(T) ->
+ list_to_atom("stundisco_" ++ atom_to_list(T)).
+
+check_password(Username, Password) ->
+ Secret = <<"cryptic">>,
+ Password == base64:encode(crypto:hmac(sha, Secret, Username)).
+
+check_expires({_, _, _} = Expires) ->
+ Now = {MegaSecs, Secs, MicroSecs} = erlang:timestamp(),
+ Later = {MegaSecs + 1, Secs, MicroSecs},
+ (Expires > Now) and (Expires < Later).
diff --git a/test/suite.erl b/test/suite.erl
index 78732d00b..ca6123c60 100644
--- a/test/suite.erl
+++ b/test/suite.erl
@@ -60,6 +60,7 @@ init_config(Config) ->
{loglevel, 4},
{new_schema, false},
{s2s_port, 5269},
+ {stun_port, 3478},
{component_port, 5270},
{web_port, 5280},
{proxy_port, 7777},