# HG changeset patch # User Guus der Kinderen <guus.der.kinderen@gmail.com> # Date 1704319514 -3600 # Node ID f55e65315ba0bc772ba13297a482ad2c92575af8 # Parent f6a2602129c801ef762ddc1f5fd6a33643b89e48 mod_pubsub_serverinfo: implemented all basic features This commit replaces the earlier proof-of-concept to a solution that: - reports on remotely-connected domains - uses disco/info to detect if those domains opt-in - publishes domain names for remote domains that do so - caches the disco/info response diff -r f6a2602129c8 -r f55e65315ba0 mod_pubsub_serverinfo/README.markdown --- a/mod_pubsub_serverinfo/README.markdown Wed Jan 03 07:53:55 2024 +0100 +++ b/mod_pubsub_serverinfo/README.markdown Wed Jan 03 23:05:14 2024 +0100 @@ -5,4 +5,39 @@ Exposes server information over Pub/Sub per ProtoXEP: PubSub Server Information. -This initial version only announces support (used to 'opt-in', per the XEP). It does not publish any data. This is intended to be a future addition to this mod. +This version, announces support (used to 'opt-in', per the XEP) and publishes the name of the local domain via a Pub/Sub node. The published data +will contain an unnamed 'remote-domain' element for each inbound or outgoing s2s connection. + +Features yet to be implemented: +- For 'remote-domain' elements, add domain name _only if_ through service discovery that domain advertises the 'urn:xmpp:serverinfo:0' feature. + +Installation +============ + +Enable this module in the global or a virtual host. + +The default configuration requires the existence of a Pub/Sub component that uses the 'pubsub' subdomain of the host in which the module is enabled: + + Component "pubsub.example.org" "pubsub" + +The module will create a node and publish data, using a JID that matches the XMPP domain name of the host. Ensure that this actor is an admin of the +Pub/Sub service: + + admins = { "example.org" } + +Configuration +============= + +The Pub/Sub service on which data is published, by default, is a component addressed as the `pubsub` subdomain of the domain of the virtual host that +the module is loaded under. To change this, apply this configuration setting: + + pubsub_serverinfo_service = "anotherpubsub.example.org" + +The Pub/Sub node on which data is published is, by default, a leaf-node named `serverinfo`. To change this, apply this configuration setting: + + pubsub_serverinfo_node = "foobar" + +To prevent a surplus of event notifications, this module will only publish new data after a certain period of time has expired. The default duration +is 300 seconds (5 minutes). To change this simply put in the config: + + pubsub_serverinfo_publication_interval = 180 -- or any other number of seconds diff -r f6a2602129c8 -r f55e65315ba0 mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua --- a/mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua Wed Jan 03 07:53:55 2024 +0100 +++ b/mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua Wed Jan 03 23:05:14 2024 +0100 @@ -1,1 +1,168 @@ -module:add_feature("urn:xmpp:serverinfo:0"); +local st = require "util.stanza"; +local new_id = require"util.id".medium; +local dataform = require "util.dataforms".new; + +local local_domain = module:get_host(); +local service = module:get_option(module.name .. "_service") or "pubsub." .. local_domain; +local node = module:get_option(module.name .. "_node") or "serverinfo"; +local actor = module.host .. "/modules/" .. module.name; +local publication_interval = module:get_option(module.name .. "_publication_interval") or 300; + +local opt_in_reports + +function module.load() + -- Will error out with a 'conflict' if the node already exists. TODO: create the node only when it's missing. + create_node():next() + + module:add_feature("urn:xmpp:serverinfo:0"); + + module:add_extension(dataform { + { name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/network/serverinfo" }, + { name = "serverinfo-pubsub-node", type = "text-single" }, + }:form({ ["serverinfo-pubsub-node"] = ("xmpp:%s?;node=%s"):format(service, node) }, "result")); + + module:add_timer(10, publish_serverinfo); +end + +function module.unload() + -- This removes all subscribers, which may or may not be desirable, depending on the reason for the unload. + delete_node(); -- Should this block, to delay unload() until the node is deleted? +end + +-- Returns a promise +function create_node() + local request = st.iq({ type = "set", to = service, from = actor, id = new_id() }) + :tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("create", { node = node }):up() + :tag("configure") + :tag("x", { xmlns = "jabber:x:data", type = "submit" }) + :tag("field", { var = "FORM_TYPE", type = "hidden"}) + :text_tag("value", "http://jabber.org/protocol/pubsub#node_config") + :up() + :tag("field", { var = "pubsub#max_items" }) + :text_tag("value", "1") + :up() + :tag("field", { var = "pubsub#persist_items" }) + :text_tag("value", "0") + return module:send_iq(request); +end + +-- Returns a promise +function delete_node() + local request = st.iq({ type = "set", to = service, from = actor, id = new_id() }) + :tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("delete", { node = node }); + + return module:send_iq(request); +end + +function publish_serverinfo() + -- Iterate over s2s sessions, adding them to a multimap, where the key is the local domain name, + -- mapped to a collection of remote domain names. De-duplicate all remote domain names by using + -- them as an index in a table. + local domains_by_host = {} + for session, _ in pairs(prosody.incoming_s2s) do + if session ~= nil and session.from_host ~= nil and local_domain == session.to_host then + local sessions = domains_by_host[session.to_host] + if sessions == nil then sessions = {} end; -- instantiate a new entry if none existed + sessions[session.from_host] = true + domains_by_host[session.to_host] = sessions + end + end + + -- At an earlier stage, the code iterated voer all prosody.hosts - but that turned out to be to noisy. + -- for host, data in pairs(prosody.hosts) do + local host = local_domain + local data = prosody.hosts[host] + if data ~= nil then + local sessions = domains_by_host[host] + if sessions == nil then sessions = {} end; -- instantiate a new entry if none existed + if data.s2sout ~= nil then + for _, session in pairs(data.s2sout) do + if session.to_host ~= nil then + sessions[session.to_host] = true + domains_by_host[host] = sessions + end + end + end + end + + -- Build the publication stanza. + local request = st.iq({ type = "set", to = service, from = actor, id = new_id() }) + :tag("pubsub", { xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("publish", { node = node, xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("item", { id = "current", xmlns = "http://jabber.org/protocol/pubsub" }) + :tag("serverinfo", { xmlns = "urn:xmpp:serverinfo:0" }) + + request:tag("domain", { name = local_domain }) + :tag("federation") + + local remotes = domains_by_host[host] + + if remotes ~= nil then + for remote, _ in pairs(remotes) do + -- include a domain name for remote domains, but only if they advertise support. + if does_opt_in(remote) then + request:tag("remote-domain", { name = remote }):up() + else + request:tag("remote-domain"):up() + end + end + end + + request:up():up() + + module:send_iq(request):next() + + return publication_interval; +end + +local opt_in_cache = {} + +function does_opt_in(remoteDomain) + + -- try to read answer from cache. + local cached_value = opt_in_cache[remoteDomain] + if cached_value ~= nil and os.difftime(cached_value.expires, os.time()) > 0 then + return cached_value.opt_in; + end + + -- TODO worry about not having multiple requests in flight to the same domain.cached_value + + -- Cache could not provide an answer. Perform service discovery. + local discoRequest = st.iq({ type = "get", to = remoteDomain, from = actor, id = new_id() }) + :tag("query", { xmlns = "http://jabber.org/protocol/disco#info" }) + + module:send_iq(discoRequest):next( + function(response) + if response.stanza ~= nil and response.stanza.attr.type == "result" then + local query = response.stanza:get_child("query", "http://jabber.org/protocol/disco#info") + if query ~= nil then + for feature in query:childtags("feature", "http://jabber.org/protocol/disco#info") do + if feature.attr.var == 'urn:xmpp:serverinfo:0' then + opt_in_cache[remoteDomain] = { + opt_in = true; + expires = os.time() + 3600; + } + return; -- prevent 'false' to be cached, down below. + end + end + end + end + opt_in_cache[remoteDomain] = { + opt_in = false; + expires = os.time() + 3600; + } + end, + function(response) + opt_in_cache[remoteDomain] = { + opt_in = false; + expires = os.time() + 3600; + } + end + ); + + -- return 'false' for now. Better luck next time... + return false; + +end