%%%---------------------------------------------------------------------- %%% File : mod_pubsub_serverinfo.erl %%% Author : Stefan Strigler %%% Purpose : Exposes server information over Pub/Sub %%% Created : 26 Dec 2023 by Guus der Kinderen %%% %%% %%% ejabberd, Copyright (C) 2023 - 2026 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_pubsub_serverinfo). -author('stefan@strigler.de'). -protocol({xep, 485, '1.0.0', '25.07', "complete", ""}). -behaviour(gen_mod). -behaviour(gen_server). -include("logger.hrl"). -include("translate.hrl"). -include_lib("xmpp/include/xmpp.hrl"). %% gen_mod callbacks. -export([start/2, stop/1, depends/2, mod_options/1, mod_opt_type/1, get_local_features/5, mod_doc/0]). -export([init/1, handle_cast/2, handle_call/3, handle_info/2, terminate/2]). -export([in_auth_result/3, out_auth_result/2, get_info/5]). -define(NS_URN_SERVERINFO, <<"urn:xmpp:serverinfo:0">>). -define(PUBLIC_HOSTS_URL, <<"https://data.xmpp.net/providers/v2/providers-Ds.json">>). -record(state, {host, pubsub_host, node, monitors = #{}, timer = undefined, public_hosts = []}). %% @format-begin start(Host, Opts) -> case pubsub_host(Host, Opts) of {error, _Reason} = Error -> Error; PubsubHost -> ejabberd_hooks:add(disco_local_features, Host, ?MODULE, get_local_features, 50), ejabberd_hooks:add(disco_info, Host, ?MODULE, get_info, 50), ejabberd_hooks:add(s2s_out_auth_result, Host, ?MODULE, out_auth_result, 50), ejabberd_hooks:add(s2s_in_auth_result, Host, ?MODULE, in_auth_result, 50), gen_mod:start_child(?MODULE, Host, PubsubHost) end. stop(Host) -> ejabberd_hooks:delete(disco_local_features, Host, ?MODULE, get_local_features, 50), ejabberd_hooks:delete(disco_info, Host, ?MODULE, get_info, 50), ejabberd_hooks:delete(s2s_out_auth_result, Host, ?MODULE, out_auth_result, 50), ejabberd_hooks:delete(s2s_in_auth_result, Host, ?MODULE, in_auth_result, 50), gen_mod:stop_child(?MODULE, Host). init([Host, PubsubHost]) -> TRef = timer:send_interval( timer:minutes(5), self(), update_pubsub), Monitors = init_monitors(Host), PublicHosts = fetch_public_hosts(), State = #state{host = Host, pubsub_host = PubsubHost, node = <<"serverinfo">>, timer = TRef, monitors = Monitors, public_hosts = PublicHosts}, self() ! update_pubsub, {ok, State}. -spec init_monitors(binary()) -> map(). init_monitors(Host) -> lists:foldl(fun(Domain, Monitors) -> RefIn = make_ref(), % just dummies RefOut = make_ref(), maps:merge(#{RefIn => {incoming, {Host, Domain, true}}, RefOut => {outgoing, {Host, Domain, true}}}, Monitors) end, #{}, ejabberd_option:hosts() -- [Host]). -spec fetch_public_hosts() -> list(). fetch_public_hosts() -> try {ok, {{_, 200, _}, _Headers, Body}} = httpc:request(get, {?PUBLIC_HOSTS_URL, []}, [{timeout, 1000}], [{body_format, binary}]), case misc:json_decode(Body) of PublicHosts when is_list(PublicHosts) -> PublicHosts; Other -> ?WARNING_MSG("Parsed JSON for public hosts was not a list: ~p", [Other]), [] end catch E:R -> ?WARNING_MSG("Failed fetching public hosts (~p): ~p", [E, R]), [] end. handle_cast({Event, Domain, Pid}, #state{host = Host, monitors = Mons} = State) when Event == register_in; Event == register_out -> Ref = monitor(process, Pid), IsPublic = check_if_public(Domain, State), NewMons = maps:put(Ref, {event_to_dir(Event), {Host, Domain, IsPublic}}, Mons), {noreply, State#state{monitors = NewMons}}; handle_cast(_, State) -> {noreply, State}. event_to_dir(register_in) -> incoming; event_to_dir(register_out) -> outgoing. handle_call(pubsub_host, _From, #state{pubsub_host = PubsubHost} = State) -> {reply, {ok, PubsubHost}, State}; handle_call(_Request, _From, State) -> {noreply, State}. handle_info({iq_reply, IQReply, {LServer, RServer}}, #state{monitors = Mons} = State) -> case IQReply of #iq{type = result, sub_els = [El]} -> case xmpp:decode(El) of #disco_info{features = Features} -> case lists:member(?NS_URN_SERVERINFO, Features) of true -> NewMons = maps:fold(fun (Ref, {Dir, {LServer0, RServer0, _}}, Acc) when LServer == LServer0, RServer == RServer0 -> maps:put(Ref, {Dir, {LServer, RServer, true}}, Acc); (Ref, Other, Acc) -> maps:put(Ref, Other, Acc) end, #{}, Mons), {noreply, State#state{monitors = NewMons}}; _ -> {noreply, State} end; _ -> {noreply, State} end; _ -> {noreply, State} end; handle_info(update_pubsub, State) -> update_pubsub(State), {noreply, State}; handle_info({'DOWN', Mon, process, _Pid, _Info}, #state{monitors = Mons} = State) -> {noreply, State#state{monitors = maps:remove(Mon, Mons)}}; handle_info(_Request, State) -> {noreply, State}. terminate(_Reason, #state{monitors = Mons, timer = Timer}) -> case is_reference(Timer) of true -> case erlang:cancel_timer(Timer) of false -> receive {timeout, Timer, _} -> ok after 0 -> ok end; _ -> ok end; _ -> ok end, maps:fold(fun(Mon, _, _) -> demonitor(Mon) end, ok, Mons). depends(_Host, _Opts) -> [{mod_pubsub, hard}]. mod_options(_Host) -> [{pubsub_host, undefined}]. mod_opt_type(pubsub_host) -> econf:either(undefined, econf:host()). mod_doc() -> #{desc => [?T("This module adds support for " "https://xmpp.org/extensions/xep-0485.html[XEP-0485: PubSub Server Information] " "to expose S2S information over the Pub/Sub service."), "", ?T("Active S2S connections are published to a local PubSub node. " "Currently the node name is hardcoded as '\"serverinfo\"'."), "", ?T("Connections that support this feature are exposed with their domain names, " "otherwise they are shown as anonymous nodes. " "At startup a list of well known public servers is fetched. " "Those are not shown as anonymous even if they don't support this feature."), "", ?T("Please note that the module only shows S2S connections established while the module is running. " "If you install the module at runtime, run _`stop_s2s_connections`_ API or restart ejabberd " "to force S2S reconnections that the module will detect and publish."), "", ?T("This module depends on _`mod_pubsub`_ and _`mod_disco`_.")], note => "added in 25.07", opts => [{pubsub_host, #{value => "undefined | string()", desc => ?T("Use this local PubSub host to advertise S2S connections. " "This must be a host local to this service handled by _`mod_pubsub`_. " "This option is only needed if your configuration has more than one host in mod_pubsub's 'hosts' option. " "The default value is the first host defined in mod_pubsub 'hosts' option.")}}], example => ["modules:", " mod_pubsub_serverinfo:", " pubsub_host: custom.pubsub.domain.local"]}. in_auth_result(#{server_host := Host, remote_server := RServer} = State, true, _Server) -> gen_server:cast( gen_mod:get_module_proc(Host, ?MODULE), {register_in, RServer, self()}), State; in_auth_result(State, _, _) -> State. out_auth_result(#{server_host := Host, remote_server := RServer} = State, true) -> gen_server:cast( gen_mod:get_module_proc(Host, ?MODULE), {register_out, RServer, self()}), State; out_auth_result(State, _) -> State. check_if_public(Domain, State) -> maybe_send_disco_info(is_public(Domain, State) orelse is_monitored(Domain, State), Domain, State). is_public(Domain, #state{public_hosts = PublicHosts}) -> lists:member(Domain, PublicHosts). is_monitored(Domain, #state{host = Host, monitors = Mons}) -> maps:size( maps:filter(fun (_Ref, {_Dir, {LServer, RServer, IsPublic}}) when LServer == Host, RServer == Domain -> IsPublic; (_Ref, _Other) -> false end, Mons)) =/= 0. maybe_send_disco_info(true, _Domain, _State) -> true; maybe_send_disco_info(false, Domain, #state{host = Host}) -> Proc = gen_mod:get_module_proc(Host, ?MODULE), IQ = #iq{type = get, from = jid:make(Host), to = jid:make(Domain), sub_els = [#disco_info{}]}, ejabberd_router:route_iq(IQ, {Host, Domain}, Proc), false. update_pubsub(#state{host = Host, pubsub_host = PubsubHost, node = Node, monitors = Mons}) -> Map = maps:fold(fun(_, {Dir, {MyDomain, Target, IsPublic}}, Acc) -> maps:update_with(MyDomain, fun(Acc2) -> maps:update_with(Target, fun({Types, _}) -> {Types#{Dir => true}, IsPublic} end, {#{Dir => true}, IsPublic}, Acc2) end, #{Target => {#{Dir => true}, IsPublic}}, Acc) end, #{}, Mons), Domains = maps:fold(fun(MyDomain, Targets, Acc) -> Remote = maps:fold(fun (Remote, {Types, true}, Acc2) -> [#pubsub_serverinfo_remote_domain{name = Remote, type = maps:keys(Types)} | Acc2]; (_HiddenRemote, {Types, false}, Acc2) -> [#pubsub_serverinfo_remote_domain{type = maps:keys(Types)} | Acc2] end, [], Targets), [#pubsub_serverinfo_domain{name = MyDomain, remote_domain = Remote} | Acc] end, [], Map), PubOpts = [{persist_items, true}, {max_items, 1}, {access_model, open}], ?DEBUG("Publishing serverinfo pubsub item on ~s: ~p", [PubsubHost, Domains]), mod_pubsub:publish_item(PubsubHost, Host, Node, jid:make(Host), <<"current">>, [xmpp:encode(#pubsub_serverinfo{domain = Domains})], PubOpts, all). get_local_features({error, _} = Acc, _From, _To, _Node, _Lang) -> Acc; get_local_features(Acc, _From, _To, Node, _Lang) when Node == <<>> -> case Acc of {result, Features} -> {result, [?NS_URN_SERVERINFO | Features]}; empty -> {result, [?NS_URN_SERVERINFO]} end; get_local_features(Acc, _From, _To, _Node, _Lang) -> Acc. get_info(Acc, Host, Mod, Node, Lang) when Mod == undefined orelse Mod == mod_disco, Node == <<"">> -> case mod_disco:get_info(Acc, Host, Mod, Node, Lang) of [#xdata{fields = Fields} = XD | Rest] -> PubsubHost = pubsub_host(Host), NodeField = #xdata_field{var = <<"serverinfo-pubsub-node">>, values = [<<"xmpp:", PubsubHost/binary, "?;node=serverinfo">>]}, {stop, [XD#xdata{fields = Fields ++ [NodeField]} | Rest]}; _ -> Acc end; get_info(Acc, Host, Mod, Node, _Lang) when Node == <<"">>, is_atom(Mod) -> PubsubHost = pubsub_host(Host), [#xdata{type = result, fields = [#xdata_field{type = hidden, var = <<"FORM_TYPE">>, values = [?NS_SERVERINFO]}, #xdata_field{var = <<"serverinfo-pubsub-node">>, values = [<<"xmpp:", PubsubHost/binary, "?;node=serverinfo">>]}]} | Acc]; get_info(Acc, _Host, _Mod, _Node, _Lang) -> Acc. pubsub_host(Host) -> {ok, PubsubHost} = gen_server:call( gen_mod:get_module_proc(Host, ?MODULE), pubsub_host), PubsubHost. pubsub_host(Host, Opts) -> case gen_mod:get_opt(pubsub_host, Opts) of undefined -> PubsubHost = hd(get_mod_pubsub_hosts(Host)), ?INFO_MSG("No pubsub_host in configuration for ~p, choosing ~s", [?MODULE, PubsubHost]), PubsubHost; PubsubHost -> case check_pubsub_host_exists(Host, PubsubHost) of true -> PubsubHost; false -> {error, {pubsub_host_does_not_exist, PubsubHost}} end end. check_pubsub_host_exists(Host, PubsubHost) -> lists:member(PubsubHost, get_mod_pubsub_hosts(Host)). get_mod_pubsub_hosts(Host) -> case gen_mod:get_module_opt(Host, mod_pubsub, hosts) of [] -> [gen_mod:get_module_opt(Host, mod_pubsub, host)]; PubsubHosts -> PubsubHosts end.