Mercurial > prosody-modules
diff mod_delegation/mod_delegation.lua @ 1725:d85d5b0bf977
Merge with Goffi
author | Kim Alvefur <zash@zash.se> |
---|---|
date | Thu, 07 May 2015 23:39:54 +0200 |
parents | 2440a75e868f |
children | 7bfc23b2c038 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_delegation/mod_delegation.lua Thu May 07 23:39:54 2015 +0200 @@ -0,0 +1,519 @@ +-- XEP-0355 (Namespace Delegation) +-- Copyright (C) 2015 Jérôme Poisson +-- +-- This module is MIT/X11 licensed. Please see the +-- COPYING file in the source package for more information. + +-- This module manage namespace delegation, a way to delegate server features +-- to an external entity/component. Only the admin mode is implemented so far + +-- TODO: client mode + +local jid = require("util/jid") +local st = require("util/stanza") +local set = require("util/set") + +local delegation_session = module:shared("/*/delegation/session") + +if delegation_session.connected_cb == nil then + -- set used to have connected event listeners + -- which allow a host to react on events from + -- other hosts + delegation_session.connected_cb = set.new() +end +local connected_cb = delegation_session.connected_cb + +local _DELEGATION_NS = 'urn:xmpp:delegation:1' +local _FORWARDED_NS = 'urn:xmpp:forward:0' +local _DISCO_NS = 'http://jabber.org/protocol/disco#info' +local _DATA_NS = 'jabber:x:data' + +local _MAIN_SEP = '::' +local _BARE_SEP = ':bare:' +local _MAIN_PREFIX = _DELEGATION_NS.._MAIN_SEP +local _BARE_PREFIX = _DELEGATION_NS.._BARE_SEP +local _PREFIXES = {_MAIN_PREFIX, _BARE_PREFIX} + +local disco_nest + +module:log("debug", "Loading namespace delegation module "); + +--> Configuration management <-- + +local ns_delegations = module:get_option("delegations", {}) + +local jid2ns = {} +for namespace, ns_data in pairs(ns_delegations) do + -- "connected" contain the full jid of connected managing entity + ns_data.connected = nil + if ns_data.jid then + if jid2ns[ns_data.jid] == nil then + jid2ns[ns_data.jid] = {} + end + jid2ns[ns_data.jid][namespace] = ns_data + module:log("debug", "Namespace %s is delegated%s to %s", namespace, ns_data.filtering and " (with filtering)" or "", ns_data.jid) + else + module:log("warn", "Ignoring delegation for %s: no jid specified", tostring(namespace)) + ns_delegations[namespace] = nil + end +end + + +local function advertise_delegations(session, to_jid) + -- send <message/> stanza to advertise delegations + -- as expained in § 4.2 + local message = st.message({from=module.host, to=to_jid}) + :tag("delegation", {xmlns=_DELEGATION_NS}) + + -- we need to check if a delegation is granted because the configuration + -- can be complicated if some delegations are granted to bare jid + -- and other to full jids, and several resources are connected. + local have_delegation = false + + for namespace, ns_data in pairs(jid2ns[to_jid]) do + if ns_data.connected == to_jid then + have_delegation = true + message:tag("delegated", {namespace=namespace}) + if type(ns_data.filtering) == "table" then + for _, attribute in pairs(ns_data.filtering) do + message:tag("attribute", {name=attribute}):up() + end + message:up() + end + end + end + + if have_delegation then + session.send(message) + end +end + +local function set_connected(entity_jid) + -- set the "connected" key for all namespace managed by entity_jid + -- if the namespace has already a connected entity, ignore the new one + local function set_config(jid_) + for namespace, ns_data in pairs(jid2ns[jid_]) do + if ns_data.connected == nil then + ns_data.connected = entity_jid + disco_nest(namespace, entity_jid) + end + end + end + local bare_jid = jid.bare(entity_jid) + set_config(bare_jid) + -- We can have a bare jid of a full jid specified in configuration + -- so we try our luck with both (first connected resource will + -- manage the namespaces in case of bare jid) + if bare_jid ~= entity_jid then + set_config(entity_jid) + jid2ns[entity_jid] = jid2ns[bare_jid] + end +end + +local function on_presence(event) + local session = event.origin + local bare_jid = jid.bare(session.full_jid) + + if jid2ns[bare_jid] or jid2ns[session.full_jid] then + set_connected(session.full_jid) + advertise_delegations(session, session.full_jid) + end +end + +local function on_component_connected(event) + -- method called by the module loaded by the component + -- /!\ the event come from the component host, + -- not from the host of this module + local session = event.session + local bare_jid = jid.join(session.username, session.host) + + local jid_delegations = jid2ns[bare_jid] + if jid_delegations ~= nil then + set_connected(bare_jid) + advertise_delegations(session, bare_jid) + end +end + +local function on_component_auth(event) + -- react to component-authenticated event from this host + -- and call the on_connected methods from all other hosts + -- needed for the component to get delegations advertising + for callback in connected_cb:items() do + callback(event) + end +end + +connected_cb:add(on_component_connected) +module:hook('component-authenticated', on_component_auth) +module:hook('presence/initial', on_presence) + + +--> delegated namespaces hook <-- + +local managing_ent_error +local stanza_cache = {} -- we cache original stanza to build reply + +local function managing_ent_result(event) + -- this function manage iq results from the managing entity + -- it do a couple of security check before sending the + -- result to the managed entity + local stanza = event.stanza + if stanza.attr.to ~= module.host then + module:log("warn", 'forwarded stanza result has "to" attribute not addressed to current host, id conflict ?') + return + end + module:unhook("iq-result/host/"..stanza.attr.id, managing_ent_result) + module:unhook("iq-error/host/"..stanza.attr.id, managing_ent_error) + + -- lot of checks to do... + local delegation = stanza.tags[1] + if #stanza ~= 1 or delegation.name ~= "delegation" or + delegation.attr.xmlns ~= _DELEGATION_NS then + module:log("warn", "ignoring invalid iq result from managing entity %s", stanza.attr.from) + stanza_cache[stanza.attr.from][stanza.attr.id] = nil + return true + end + + local forwarded = delegation.tags[1] + if #delegation ~= 1 or forwarded.name ~= "forwarded" or + forwarded.attr.xmlns ~= _FORWARDED_NS then + module:log("warn", "ignoring invalid iq result from managing entity %s", stanza.attr.from) + stanza_cache[stanza.attr.from][stanza.attr.id] = nil + return true + end + + local iq = forwarded.tags[1] + if #forwarded ~= 1 or iq.name ~= "iq" or + iq.attr.xmlns ~= 'jabber:client' or + (iq.attr.type =='result' and #iq ~= 1) or + (iq.attr.type == 'error' and #iq > 2) then + module:log("warn", "ignoring invalid iq result from managing entity %s", stanza.attr.from) + stanza_cache[stanza.attr.from][stanza.attr.id] = nil + return true + end + + iq.attr.xmlns = nil + + local original = stanza_cache[stanza.attr.from][stanza.attr.id] + stanza_cache[stanza.attr.from][stanza.attr.id] = nil + -- we get namespace from original and not iq + -- because the namespace can be lacking in case of error + local namespace = original.tags[1].attr.xmlns + local ns_data = ns_delegations[namespace] + + if stanza.attr.from ~= ns_data.connected or (iq.attr.type ~= "result" and iq.attr.type ~= "error") or + iq.attr.id ~= original.attr.id or iq.attr.to ~= original.attr.from then + module:log("warn", "ignoring forbidden iq result from managing entity %s, please check that the component is no trying to do something bad (stanza: %s)", stanza.attr.from, tostring(stanza)) + module:send(st.error_reply(original, 'cancel', 'service-unavailable')) + return true + end + + -- at this point eveything is checked, + -- and we (hopefully) can send the the result safely + module:send(iq) + return true +end + +function managing_ent_error(event) + local stanza = event.stanza + if stanza.attr.to ~= module.host then + module:log("warn", 'Stanza result has "to" attribute not addressed to current host, id conflict ?') + return + end + module:unhook("iq-result/host/"..stanza.attr.id, managing_ent_result) + module:unhook("iq-error/host/"..stanza.attr.id, managing_ent_error) + local original = stanza_cache[stanza.attr.from][stanza.attr.id] + stanza_cache[stanza.attr.from][stanza.attr.id] = nil + module:log("warn", "Got an error after forwarding stanza to "..stanza.attr.from) + module:send(st.error_reply(original, 'cancel', 'service-unavailable')) + return true +end + +local function forward_iq(stanza, ns_data) + local to_jid = ns_data.connected + stanza.attr.xmlns = 'jabber:client' + local iq_stanza = st.iq({ from=module.host, to=to_jid, type="set" }) + :tag("delegation", { xmlns=_DELEGATION_NS }) + :tag("forwarded", { xmlns=_FORWARDED_NS }) + :add_child(stanza) + local iq_id = iq_stanza.attr.id + -- we save the original stanza to check the managing entity result + if not stanza_cache[to_jid] then stanza_cache[to_jid] = {} end + stanza_cache[to_jid][iq_id] = stanza + module:hook("iq-result/host/"..iq_id, managing_ent_result) + module:hook("iq-error/host/"..iq_id, managing_ent_error) + module:log("debug", "stanza forwarded") + module:send(iq_stanza) +end + +local function iq_hook(event) + -- general hook for all the iq which forward delegated ones + -- and continue normal behaviour else. If a namespace is + -- delegated but managing entity is offline, a service-unavailable + -- error will be sent, as requested by the XEP + local session, stanza = event.origin, event.stanza + if #stanza == 1 and stanza.attr.type == 'get' or stanza.attr.type == 'set' then + local namespace = stanza.tags[1].attr.xmlns + local ns_data = ns_delegations[namespace] + + if ns_data then + if stanza.attr.from == ns_data.connected then + -- we don't forward stanzas from managing entity itself + return + end + if ns_data.filtering then + local first_child = stanza.tags[1] + for _, attribute in ns_data.filtering do + -- if any filtered attribute if not present, + -- we must continue the normal bahaviour + if not first_child.attr[attribute] then + -- Filtered attribute is not present, we do normal workflow + return; + end + end + end + if not ns_data.connected then + module:log("warn", "No connected entity to manage "..namespace) + session.send(st.error_reply(stanza, 'cancel', 'service-unavailable')) + else + forward_iq(stanza, ns_data) + end + return true + else + -- we have no delegation, we continue normal behaviour + return + end + end +end + +module:hook("iq/self", iq_hook, 2^32) +module:hook("iq/bare", iq_hook, 2^32) +module:hook("iq/host", iq_hook, 2^32) + + +--> discovery nesting <-- + +-- disabling internal features/identities + +local function find_form_type(stanza) + local form_type = nil + for field in stanza.childtags('field', 'jabber:x:data') do + if field.attr.var=='FORM_TYPE' and field.attr.type=='hidden' then + local value = field:get_child('value') + if not value then + module:log("warn", "No value found in FORM_TYPE field: "..tostring(stanza)) + else + form_type=value.get_text() + end + end + end + return form_type +end + +-- modules whose features/identities are managed by delegation +local disabled_modules = set.new() +local disabled_identities = set.new() + +local function identity_added(event) + local source = event.source + if disabled_modules:contains(source) then + local item = event.item + local category, type_, name = item.category, item.type, item.name + module:log("debug", "Removing (%s/%s%s) identity because of delegation", category, type_, name and "/"..name or "") + disabled_identities:add(item) + source:remove_item("identity", item) + end +end + +local function feature_added(event) + local source, item = event.source, event.item + for namespace, _ in pairs(ns_delegations) do + if source ~= module and string.sub(item, 1, #namespace) == namespace then + module:log("debug", "Removing %s feature which is delegated", item) + source:remove_item("feature", item) + disabled_modules:add(source) + if source.items and source.items.identity then + -- we remove all identities added by the source module + -- that can cause issues if the module manages several features/identities + -- but this case is probably rare (or doesn't happen at all) + -- FIXME: any better way ? + for _, identity in pairs(source.items.identity) do + identity_added({source=source, item=identity}) + end + end + end + end +end + +local function extension_added(event) + local source, stanza = event.source, event.item + local form_type = find_form_type(stanza) + if not form_type then return; end + + for namespace, _ in pairs(ns_delegations) do + if source ~= module and string.sub(form_type, 1, #namespace) == namespace then + module:log("debug", "Removing extension which is delegated: %s", tostring(stanza)) + source:remove_item("extension", stanza) + end + end +end + +-- for disco nesting (see § 7.2) we need to remove internal features +-- we use handle_items as it allow to remove already added features +-- and catch the ones which can come later +module:handle_items("feature", feature_added, function(_) end) +module:handle_items("identity", identity_added, function(_) end, false) +module:handle_items("extension", extension_added, function(_) end) + + +-- managing entity features/identities collection + +local disco_error +local bare_features = set.new() +local bare_identities = {} +local bare_extensions = {} + +local function disco_result(event) + -- parse result from disco nesting request + -- and fill module features/identities and bare_features/bare_identities accordingly + local session, stanza = event.origin, event.stanza + if stanza.attr.to ~= module.host then + module:log("warn", 'Stanza result has "to" attribute not addressed to current host, id conflict ?') + return + end + module:unhook("iq-result/host/"..stanza.attr.id, disco_result) + module:unhook("iq-error/host/"..stanza.attr.id, disco_error) + local query = stanza:get_child("query", _DISCO_NS) + if not query or not query.attr.node then + session.send(st.error_reply(stanza, 'modify', 'not-acceptable')) + return true + end + + local node = query.attr.node + local main + + if string.sub(node, 1, #_MAIN_PREFIX) == _MAIN_PREFIX then + main=true + elseif string.sub(node, 1, #_BARE_PREFIX) == _BARE_PREFIX then + main=false + else + module:log("warn", "Unexpected node: "..node) + session.send(st.error_reply(stanza, 'modify', 'not-acceptable')) + return true + end + + for feature in query:childtags("feature") do + local namespace = feature.attr.var + if main then + module:add_feature(namespace) + else + bare_features:add(namespace) + end + end + for identity in query:childtags("identity") do + local category, type_, name = identity.attr.category, identity.attr.type, identity.attr.name + if main then + module:add_identity(category, type_, name) + else + table.insert(bare_identities, {category=category, type=type_, name=name}) + end + end + for extension in query:childtags("x", _DATA_NS) do + if main then + module:add_extension(extension) + else + table.insert(bare_extensions, extension) + end + end +end + +function disco_error(event) + local stanza = event.stanza + if stanza.attr.to ~= module.host then + module:log("warn", 'Stanza result has "to" attribute not addressed to current host, id conflict ?') + return + end + module:unhook("iq-result/host/"..stanza.attr.id, disco_result) + module:unhook("iq-error/host/"..stanza.attr.id, disco_error) + module:log("warn", "Got an error while requesting disco for nesting to "..stanza.attr.from) + module:log("warn", "Ignoring disco nesting") +end + +function disco_nest(namespace, entity_jid) + -- manage discovery nesting (see § 7.2) + + -- first we reset the current values + if module.items then + module.items['feature'] = nil + module.items['identity'] = nil + module.items['extension'] = nil + bare_features = set.new() + bare_identities = {} + bare_extensions = {} + end + + for _, prefix in ipairs(_PREFIXES) do + local node = prefix..namespace + + local iq = st.iq({from=module.host, to=entity_jid, type='get'}) + :tag('query', {xmlns=_DISCO_NS, node=node}) + + local iq_id = iq.attr.id + + module:hook("iq-result/host/"..iq_id, disco_result) + module:hook("iq-error/host/"..iq_id, disco_error) + module:send(iq) + end +end + +-- disco to bare jids special case + +module:hook("account-disco-info", function(event) + -- this event is called when a disco info request is done on a bare jid + -- we get the final reply and filter delegated features/identities/extensions + local reply = event.reply; + reply.tags[1]:maptags(function(child) + if child.name == 'feature' then + local feature_ns = child.attr.var + for namespace, _ in pairs(ns_delegations) do + if string.sub(feature_ns, 1, #namespace) == namespace then + module:log("debug", "Removing feature namespace %s which is delegated", feature_ns) + return nil + end + end + elseif child.name == 'identity' then + for item in disabled_identities:items() do + if item.category == child.attr.category + and item.type == child.attr.type + -- we don't check name, because mod_pep use a name for main disco, but not in account-disco-info hook + -- and item.name == child.attr.name + then + module:log("debug", "Removing (%s/%s%s) identity because of delegation", item.category, item.type, item.name and "/"..item.name or "") + return nil + end + end + elseif child.name == 'x' and child.attr.xmlns == _DATA_NS then + local form_type = find_form_type(child) + if form_type then + for namespace, _ in pairs(ns_delegations) do + if string.sub(form_type, 1, #namespace) == namespace then + module:log("debug", "Removing extension which is delegated: %s", tostring(child)) + return nil + end + end + end + + end + return child + end) + for feature in bare_features:items() do + reply:tag('feature', {var=feature}):up() + end + for _, item in ipairs(bare_identities) do + reply:tag('identity', {category=item.category, type=item.type, name=item.name}):up() + end + for _, stanza in ipairs(bare_extensions) do + reply:add_child(stanza) + end + +end, -2^32);