%%%---------------------------------------------------------------------- %%% 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-2023 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', '20.04', "", ""}). -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_lib("xmpp/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:hours(12)}, {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]. " "This module is included in ejabberd since version 20.04."), 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 credentials offered to " "clients. If ejabberd's built-in TURN service is used, " "TURN relays allocated using temporary credentials will " "be terminated shortly after the credentials expired. The " "default value is '12 hours'. Note that restarting the " "ejabberd node invalidates any temporary credentials " "offered before the restart unless a 'secret' is " "specified (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 " "explicitly 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, it's " "recommended to explicitly specify a secret if clients " "cache retrieved credentials (for later use) across " "service restarts.")}}, {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: 2001:db8::3", " port: 3478", " type: stun", " transport: udp", " restricted: false", " -", " host: 2001:db8::3", " port: 3478", " type: turn", " transport: udp", " restricted: true", " -", " 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 hostname or IP address the STUN/TURN service is " "listening on. For non-TLS services, it's recommended " "to specify an IP address (to avoid additional DNS " "lookup latency on the client side). For TLS services, " "the hostname (or IP 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: ~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(misc: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_ips(Opts) of {undefined, undefined} -> ?INFO_MSG("Won't auto-announce STUN/TURN service on port ~B (~s) " "without public IP address, please specify " "'turn_ipv4_address' and optionally 'turn_ipv6_address'", [Port, Transport]), []; {IPv4Addr, IPv6Addr} -> lists:flatmap( fun(undefined) -> []; (Addr) -> StunService = #service{host = Addr, port = Port, transport = Transport, restricted = false, type = stun}, case Opts of #{use_turn := true} -> ?INFO_MSG("Going to offer STUN/TURN service: " "~s (~s)", [addr_to_str(Addr, Port), Transport]), [StunService, #service{host = Addr, port = Port, transport = Transport, restricted = is_restricted(Opts), type = turn}]; #{use_turn := false} -> ?INFO_MSG("Going to offer STUN service: " "~s (~s)", [addr_to_str(Addr, Port), Transport]), [StunService] end end, [IPv4Addr, IPv6Addr]) end; parse_listener({_EndPoint, Module, _Opts}) -> ?DEBUG("Ignoring ~s listener", [Module]), []. -spec get_listener_ips(map()) -> {inet:ip4_address() | undefined, inet:ip6_address() | undefined}. get_listener_ips(#{ip := {0, 0, 0, 0}} = Opts) -> {get_turn_ipv4_addr(Opts), undefined}; get_listener_ips(#{ip := {0, 0, 0, 0, 0, 0, 0, 0}} = Opts) -> {get_turn_ipv4_addr(Opts), get_turn_ipv6_addr(Opts)}; % Assume dual-stack. get_listener_ips(#{ip := {127, _, _, _}} = Opts) -> {get_turn_ipv4_addr(Opts), undefined}; get_listener_ips(#{ip := {0, 0, 0, 0, 0, 0, 0, 1}} = Opts) -> {undefined, get_turn_ipv6_addr(Opts)}; get_listener_ips(#{ip := {_, _, _, _} = IP}) -> {IP, undefined}; get_listener_ips(#{ip := {_, _, _, _, _, _, _, _} = IP}) -> {undefined, IP}. -spec get_turn_ipv4_addr(map()) -> inet:ip4_address() | undefined. get_turn_ipv4_addr(#{turn_ipv4_address := {_, _, _, _} = TurnIP}) -> TurnIP; get_turn_ipv4_addr(#{turn_ipv4_address := undefined}) -> case misc:get_my_ipv4_address() of {127, _, _, _} -> undefined; IP -> IP end. -spec get_turn_ipv6_addr(map()) -> inet:ip6_address() | undefined. get_turn_ipv6_addr(#{turn_ipv6_address := {_, _, _, _, _, _, _, _} = TurnIP}) -> TurnIP; get_turn_ipv6_addr(#{turn_ipv6_address := undefined}) -> case misc:get_my_ipv6_address() of {0, 0, 0, 0, 0, 0, 0, 1} -> undefined; IP -> IP end. -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}. -spec addr_to_str(inet:ip_address(), 0..65535) -> iolist(). addr_to_str({_, _, _, _, _, _, _, _} = Addr, Port) -> [$[, inet_parse:ntoa(Addr), $], $:, integer_to_list(Port)]; addr_to_str({_, _, _, _} = Addr, Port) -> [inet_parse:ntoa(Addr), $:, integer_to_list(Port)].