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 +%%% Purpose : External Service Discovery (XEP-0215) +%%% Created : 18 Apr 2020 by Holger Weiss +%%% +%%% +%%% 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 @@ -90,6 +90,12 @@ listen: "/api": mod_http_api "/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 @@ -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 +%%% Created : 22 Apr 2020 by Holger Weiss +%%% +%%% +%%% 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},