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/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). +