diff --git a/mix.exs b/mix.exs index c8d6d82bc..7a12a248a 100644 --- a/mix.exs +++ b/mix.exs @@ -130,7 +130,7 @@ defmodule Ejabberd.MixProject do {:p1_utils, "~> 1.0"}, {:pkix, "~> 1.0"}, {:stringprep, ">= 1.0.26"}, - {:xmpp, git: "https://github.com/processone/xmpp", ref: "74ed2d87222d3bd5e86f7c41daaa28fae59d4995", override: true}, + {:xmpp, git: "https://github.com/processone/xmpp", ref: "9b028c110083e4d979d88c286873d0abf08fa532", override: true}, {:yconf, ">= 1.0.18"}] ++ cond_deps() end diff --git a/rebar.config b/rebar.config index 63c51b8fb..eb160ed8b 100644 --- a/rebar.config +++ b/rebar.config @@ -77,7 +77,7 @@ {stringprep, "~> 1.0.31", {git, "https://github.com/processone/stringprep", {tag, "1.0.31"}}}, {if_var_true, stun, {stun, "~> 1.2.17", {git, "https://github.com/processone/stun", {tag, "1.2.17"}}}}, - {xmpp, "~> 1.10.0", {git, "https://github.com/processone/xmpp", "74ed2d87222d3bd5e86f7c41daaa28fae59d4995"}}, + {xmpp, "~> 1.10.0", {git, "https://github.com/processone/xmpp", "9b028c110083e4d979d88c286873d0abf08fa532"}}, {yconf, "~> 1.0.18", {git, "https://github.com/processone/yconf", {tag, "1.0.18"}}} ]}. diff --git a/rebar.lock b/rebar.lock index 973df5a07..d4452766d 100644 --- a/rebar.lock +++ b/rebar.lock @@ -16,23 +16,23 @@ {<<"jiffy">>,{pkg,<<"jiffy">>,<<"1.1.2">>},1}, {<<"jose">>,{pkg,<<"jose">>,<<"1.11.10">>},0}, {<<"luerl">>,{pkg,<<"luerl">>,<<"1.2.3">>},0}, - {<<"mqtree">>,{pkg,<<"mqtree">>,<<"1.0.17">>},0}, + {<<"mqtree">>,{pkg,<<"mqtree">>,<<"1.0.18">>},0}, {<<"p1_acme">>, {git,"https://github.com/processone/p1_acme", {ref,"27a590789add30ff507a49ffd440eeeb28c96ce5"}}, 0}, {<<"p1_mysql">>,{pkg,<<"p1_mysql">>,<<"1.0.26">>},0}, {<<"p1_oauth2">>,{pkg,<<"p1_oauth2">>,<<"0.6.14">>},0}, - {<<"p1_pgsql">>,{pkg,<<"p1_pgsql">>,<<"1.1.32">>},0}, + {<<"p1_pgsql">>,{pkg,<<"p1_pgsql">>,<<"1.1.33">>},0}, {<<"p1_utils">>,{pkg,<<"p1_utils">>,<<"1.0.27">>},0}, {<<"pkix">>,{pkg,<<"pkix">>,<<"1.0.10">>},0}, {<<"sqlite3">>,{pkg,<<"sqlite3">>,<<"1.1.15">>},0}, - {<<"stringprep">>,{pkg,<<"stringprep">>,<<"1.0.31">>},0}, - {<<"stun">>,{pkg,<<"stun">>,<<"1.2.17">>},0}, - {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.0">>},1}, + {<<"stringprep">>,{pkg,<<"stringprep">>,<<"1.0.32">>},0}, + {<<"stun">>,{pkg,<<"stun">>,<<"1.2.19">>},0}, + {<<"unicode_util_compat">>,{pkg,<<"unicode_util_compat">>,<<"0.7.1">>},1}, {<<"xmpp">>, {git,"https://github.com/processone/xmpp", - {ref,"74ed2d87222d3bd5e86f7c41daaa28fae59d4995"}}, + {ref,"9b028c110083e4d979d88c286873d0abf08fa532"}}, 0}, {<<"yconf">>,{pkg,<<"yconf">>,<<"1.0.18">>},0}]}. [ @@ -50,16 +50,16 @@ {<<"jiffy">>, <<"A9B6C9A7EC268E7CF493D028F0A4C9144F59CCB878B1AFE42841597800840A1B">>}, {<<"jose">>, <<"A903F5227417BD2A08C8A00A0CBCC458118BE84480955E8D251297A425723F83">>}, {<<"luerl">>, <<"DF25F41944E57A7C4D9EF09D238BC3E850276C46039CFC12B8BB42ECCF36FCB1">>}, - {<<"mqtree">>, <<"82F54B8F2D22B4445DB1D6CCCB7FE9EAD049D61410C29E32475F3CEB3EE62A89">>}, + {<<"mqtree">>, <<"B004E80BBEE5BC49E774B839F88162BDFF5CB654ABBDB79C8381AE4B13510A1B">>}, {<<"p1_mysql">>, <<"574D07C9936C53B1EC3556DB3CF064CC14A6C39039835B3D940471BFA5AC8E2B">>}, {<<"p1_oauth2">>, <<"1C5F82535574DE87E2059695AC4B91F8F9AEBACBC1C80287DAE6F02552D47AEA">>}, - {<<"p1_pgsql">>, <<"3F95D7E3413FC8F0BE80ABB4BE1A0D7F67066A36905085CD5A423145598B0CB0">>}, + {<<"p1_pgsql">>, <<"585F720C76B9BD27C5313DBB2C3CAD0CA19AB4493DE0B34A7496B51B65F5613A">>}, {<<"p1_utils">>, <<"F468D84C6FFA6E4B12A6160826DCF2D015527189D57865568A78B49C5ED972A1">>}, {<<"pkix">>, <<"D3BFADF7B7CFE2A3636F1B256C9CCE5F646A07CE31E57EE527668502850765A0">>}, {<<"sqlite3">>, <<"E819DEFD280145C328457D7AF897D2E45E8E5270E18812EE30B607C99CDD21AF">>}, - {<<"stringprep">>, <<"FA1688C156DD271722AA18C423A4163E710D2F4F475AD0BC220910DF669B53AF">>}, - {<<"stun">>, <<"C54614A592812EA125A2E6827AAC5A438571B591616426EC1419BA9B48252F54">>}, - {<<"unicode_util_compat">>, <<"BC84380C9AB48177092F43AC89E4DFA2C6D62B40B8BD132B1059ECC7232F9A78">>}, + {<<"stringprep">>, <<"63FD7FF5417A4A48DB6BB529C83D678361D34188367C1B13B3EAF39ACDEDB8E5">>}, + {<<"stun">>, <<"FF5BD2D2E3A0C2ADE41FC71A7A069EEBAA492ECDB35ECA35350FFF3C194B381A">>}, + {<<"unicode_util_compat">>, <<"A48703A25C170EEDADCA83B11E88985AF08D35F37C6F664D6DCFB106A97782FC">>}, {<<"yconf">>, <<"E565EDC8AABB8164C3BEBC86969095D296AD315DCBB46AF65DCCBC6C71EAE0F6">>}]}, {pkg_hash_ext,[ {<<"base64url">>, <<"F9B3ADD4731A02A9B0410398B475B33E7566A695365237A6BDEE1BB447719F5C">>}, @@ -75,15 +75,15 @@ {<<"jiffy">>, <<"BB61BC42A720BBD33CB09A410E48BB79A61012C74CB8B3E75F26D988485CF381">>}, {<<"jose">>, <<"0D6CD36FF8BA174DB29148FC112B5842186B68A90CE9FC2B3EC3AFE76593E614">>}, {<<"luerl">>, <<"1B4B9D0CA5D7D280D1D2787A6A5EE9F5A212641B62BFF91556BAA53805DF3AED">>}, - {<<"mqtree">>, <<"5FE8B7CF8FBC4783D0FCEB94654AC2BBF3242A58CD0397D249DED8AE021BE2A3">>}, + {<<"mqtree">>, <<"F73827CECF9A310670F7D7909FC88EAB40B45290FE48E5C7E45AB7235B29B919">>}, {<<"p1_mysql">>, <<"EA138083F2C54719B9CF549DBF5802A288B0019EA3E5449B354C74CC03FAFDEC">>}, {<<"p1_oauth2">>, <<"1FD3AC474E43722D9D5A87C6DF8D36F698ED87AF7BB81CBBB66361451D99AE8F">>}, - {<<"p1_pgsql">>, <<"268B01E8F4EB75C211A31495A25C2815C549AECCE2F0DF1A161C6E0A2CDE061E">>}, + {<<"p1_pgsql">>, <<"3FB6A9617DB146419D420FFE7E94B9179A7CB5063D9C9450EF8B13FDAD2A709F">>}, {<<"p1_utils">>, <<"F1AF942B0A62BCFA0D59FBE30679BE4FFEB5E241A0C49ED5F094DB2F5B80F5E0">>}, {<<"pkix">>, <<"E02164F83094CB124C41B1AB28988A615D54B9ADC38575F00F19A597A3AC5D0E">>}, {<<"sqlite3">>, <<"3C0BA4E13322C2AD49DE4E2DDD28311366ADDE54BEAE8DBA9D9E3888F69D2857">>}, - {<<"stringprep">>, <<"E9699C88E8DB16B3A41F0E45AC6874A4DA81A6E4854A77D76EDE6D09B08E3530">>}, - {<<"stun">>, <<"6B318244C21E8524A9AAE3AC9A05CD8234EE994C1C2C815DE68D306086AD768D">>}, - {<<"unicode_util_compat">>, <<"25EEE6D67DF61960CF6A794239566599B09E17E668D3700247BC498638152521">>}, + {<<"stringprep">>, <<"6069CB059F5D18A312C42E5F374E9DE415DF68F7E3090C9C1A5505E2A8532710">>}, + {<<"stun">>, <<"66DC035EBF21DE8ABE51ECCC2C3D4BBF63C78650F74C3AFCAF2E4BB15C555927">>}, + {<<"unicode_util_compat">>, <<"B3A917854CE3AE233619744AD1E0102E05673136776FB2FA76234F3E03B23642">>}, {<<"yconf">>, <<"FA950EC6503F92D6417FB8CC1D982403F041697E8E1BBF4D4588FB919B9562EA">>}]} ]. diff --git a/src/mod_pubsub_serverinfo.erl b/src/mod_pubsub_serverinfo.erl new file mode 100644 index 000000000..cc7672dfe --- /dev/null +++ b/src/mod_pubsub_serverinfo.erl @@ -0,0 +1,373 @@ +%%%---------------------------------------------------------------------- +%%% 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 - 2025 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'). +-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("Exposes s2s information over Pub/Sub"), "", + ?T("Announces support for the ProtoXEP PubSub Server Information, by adding its Service Discovery feature." + "Active S2S connections are published to a local pubsub node as advertised by Service Discovery. Only those connections that support this feature as well are exposed with their domain names, otherwise they are shown as anonymous nodes. At startup a list of well known public servers is being fetched. Those are not shown as anonymous even if they don't support this feature." + "Currently the name of the node is hardcoded as \"serverinfo\". The local service to be used can be configured as `pubsub_host`. Otherwise a good guess is taken." + "This module has a hard dependency on `mod_pubsub` for this reason. Also `mod_disco` must be configured for this feature to work."), "", + ?T("NOTE: The module only shows S2S connections established while the module is running: after installing the module, please run `ejabberdctl stop_s2s_connections`, or restart ejabberd.")], + note => "added in 25.xx", + opts => [{pubsub_host, + #{value => "undefined | string()", + desc => ?T("This option specifies which pubsub host to use to advertise S2S connections. This must be a vhost local to this service and handled by `mod_pubsub`. This is only needed if your configuration has more than one vhost in mod_pubsub's `hosts` option. If there's more than one and this option is not given, we just pick the first one.")} + }], + 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.