483 lines
18 KiB
Python
483 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Libervia plugin for Namespace Delegation (XEP-0355).
|
|
# Copyright (C) 2009-2026 Jérôme Poisson (goffi@goffi.org)
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU Affero General Public License as published by
|
|
# the Free Software Foundation, either version 3 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 Affero General Public License for more details.
|
|
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
from dataclasses import dataclass, field
|
|
from functools import partial
|
|
from typing import TYPE_CHECKING, NamedTuple, cast
|
|
|
|
from twisted.internet import defer, reactor
|
|
from twisted.internet.interfaces import IDelayedCall
|
|
from twisted.words.protocols.jabber import error, jid
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
from twisted.words.xish import domish
|
|
from tx_xmpp import disco, itx_xmpp, pubsub
|
|
from tx_xmpp import data_form
|
|
from zope.interface import implementer
|
|
|
|
from libervia.backend.core import exceptions
|
|
from libervia.backend.core.constants import Const as C
|
|
from libervia.backend.core.core_types import SatXMPPComponent, SatXMPPEntity
|
|
from libervia.backend.core.i18n import _
|
|
from libervia.backend.core.log import getLogger
|
|
from libervia.backend.plugins.plugin_xep_0297 import NS_FORWARD, XEP_0297
|
|
from libervia.backend.tools.utils import as_deferred, ensure_deferred
|
|
from libervia.backend.tools.xml_tools import element_copy
|
|
|
|
if TYPE_CHECKING:
|
|
from libervia.backend.core.main import LiberviaBackend
|
|
|
|
|
|
log = getLogger(__name__)
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "Namespace Delegation",
|
|
C.PI_IMPORT_NAME: "XEP-0355",
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_MODES: [C.PLUG_MODE_COMPONENT],
|
|
C.PI_PROTOCOLS: ["XEP-0355"],
|
|
C.PI_DEPENDENCIES: ["XEP-0297"],
|
|
C.PI_RECOMMENDATIONS: [],
|
|
C.PI_MAIN: "XEP_0355",
|
|
C.PI_HANDLER: "yes",
|
|
C.PI_DESCRIPTION: _("""Namespace delegation implementation."""),
|
|
}
|
|
|
|
NS_DELEGATION = "urn:xmpp:delegation:2"
|
|
|
|
DELEGATION_MAIN_SEP = "::"
|
|
DELEGATION_BARE_SEP = ":bare:"
|
|
|
|
SEND_HOOK_TIMEOUT = 300
|
|
|
|
|
|
@dataclass
|
|
class DelegationData:
|
|
delegation_received: defer.Deferred[None] = field(
|
|
default_factory=lambda: defer.Deferred()
|
|
)
|
|
|
|
|
|
class DelegatedIQ(NamedTuple):
|
|
"""Data of IQ request being tracked for delegation."""
|
|
|
|
#: The client that received the delegation.
|
|
client: SatXMPPComponent
|
|
#: The original IQ request.
|
|
iq_request_elt: domish.Element
|
|
#: The wrapping delegation IQ from the server.
|
|
wrapping_iq_elt: domish.Element
|
|
#: Timer for the request.
|
|
timeout: IDelayedCall
|
|
|
|
|
|
class XEP_0355:
|
|
"""XEP-0355: Namespace Delegation handler."""
|
|
|
|
namespace = NS_DELEGATION
|
|
|
|
def __init__(self, host: "LiberviaBackend") -> None:
|
|
log.info(f"plugin {PLUGIN_INFO[C.PI_NAME]!r} initialization")
|
|
self.host = host
|
|
self._forward = cast(XEP_0297, host.plugins["XEP-0297"])
|
|
self.delegated_namespaces: set[str] = set()
|
|
self._current_iqs: dict[str, DelegatedIQ] = {}
|
|
|
|
host.register_namespace("delegation", NS_DELEGATION)
|
|
host.trigger.add("stream_hooks", self.add_hooks)
|
|
|
|
def add_hooks(self, client, receive_hooks, send_hooks):
|
|
"""Register send hook to intercept outgoing IQ results/errors."""
|
|
send_hooks.append(partial(self.on_send, client=client))
|
|
return True
|
|
|
|
def on_send(self, obj, client):
|
|
"""Intercept outgoing stanzas to catch delegated IQ responses."""
|
|
if not client.is_component:
|
|
return
|
|
|
|
if not self._current_iqs:
|
|
return
|
|
|
|
if not isinstance(obj, domish.Element):
|
|
return
|
|
|
|
if obj.name != "iq":
|
|
return
|
|
|
|
if obj.getAttribute("type") not in ("result", "error"):
|
|
return
|
|
|
|
self.handle_outgoing_iq(obj)
|
|
|
|
def handle_outgoing_iq(self, iq_elt: domish.Element) -> None:
|
|
"""Check if outgoing IQ is a response to a delegated stanza.
|
|
|
|
@param iq_elt: The outgoing IQ to check.
|
|
"""
|
|
iq_id = iq_elt.getAttribute("id")
|
|
if not iq_id or iq_id not in self._current_iqs:
|
|
return
|
|
|
|
delegated_iq = self._current_iqs[iq_id]
|
|
|
|
iq_to = iq_elt.getAttribute("to")
|
|
iq_request_from = delegated_iq.iq_request_elt.getAttribute("from")
|
|
|
|
# Response's "to" must match original request's "from"
|
|
if iq_to != iq_request_from:
|
|
log.error(
|
|
f"IQ id conflict for {iq_id}: expected response to {iq_request_from!r}, "
|
|
f"got {iq_to!r}"
|
|
)
|
|
del self._current_iqs[iq_id]
|
|
return
|
|
|
|
if not delegated_iq.timeout.called:
|
|
delegated_iq.timeout.cancel()
|
|
|
|
del self._current_iqs[iq_id]
|
|
self.delegated_result(
|
|
delegated_iq.client,
|
|
delegated_iq.iq_request_elt,
|
|
iq_elt,
|
|
delegated_iq.wrapping_iq_elt,
|
|
)
|
|
raise exceptions.CancelError()
|
|
|
|
def is_pep(self, client: SatXMPPComponent, entity_jid: jid.JID) -> bool:
|
|
"""Check if a request is a PEP request.
|
|
|
|
@param client: Client instance.
|
|
@param to_jid: The JID to check.
|
|
@return: True if this is a PEP request to a local user.
|
|
@raise exceptions.InternalError: Client is not a component (XEP-0355 can be used
|
|
only with components).
|
|
"""
|
|
if not client.is_component:
|
|
raise exceptions.InternalError("XEP-0355 used with a non-component.")
|
|
|
|
if entity_jid == client.jid:
|
|
return False
|
|
|
|
if entity_jid.user is None:
|
|
return False
|
|
|
|
if entity_jid.resource is not None:
|
|
return False
|
|
|
|
if not client.is_local(entity_jid):
|
|
return False
|
|
|
|
return True
|
|
|
|
def get_handler(self, client: SatXMPPEntity) -> "XEP_0355_Handler":
|
|
return XEP_0355_Handler(self)
|
|
|
|
def on_advertise(self, message_elt: domish.Element, client: SatXMPPComponent) -> None:
|
|
"""Handle delegation advertisement from server.
|
|
|
|
@param client: Client instance.
|
|
@param message_elt: Delegation advertisement message.
|
|
"""
|
|
server_jid = client.server_jid
|
|
if jid.JID(message_elt["from"]) != server_jid:
|
|
log.warning(
|
|
f"Delegation advertising message received from {message_elt['from']},"
|
|
f" while expected server JID is {server_jid}, this may be a security"
|
|
" threat, please check your configuration and network."
|
|
)
|
|
raise error.StanzaError("not-allowed")
|
|
|
|
try:
|
|
delegation_elt = next(message_elt.elements(NS_DELEGATION, "delegation"))
|
|
except StopIteration:
|
|
log.warning("Invalid delegation advertisement: missing delegation element")
|
|
raise error.StanzaError("not-acceptable")
|
|
|
|
delegated = {}
|
|
for child in delegation_elt.elements(NS_DELEGATION):
|
|
if child.name != "delegated":
|
|
log.warning(
|
|
f"Invalid delegation advertisement: unexpected element {child.name!r}"
|
|
)
|
|
raise error.StanzaError("not-acceptable")
|
|
|
|
try:
|
|
namespace = child["namespace"]
|
|
except KeyError:
|
|
log.warning(
|
|
"Invalid delegation advertisement: missing namespace attribute in"
|
|
" delegated element"
|
|
)
|
|
raise error.StanzaError("not-acceptable")
|
|
|
|
delegated[namespace] = []
|
|
for attribute_elt in child.elements(NS_DELEGATION, "attribute"):
|
|
try:
|
|
delegated[namespace].append(attribute_elt["name"])
|
|
except KeyError:
|
|
log.warning(
|
|
"Invalid delegation advertisement: missing name attribute in"
|
|
" attribute element"
|
|
)
|
|
raise error.StanzaError("not-acceptable")
|
|
|
|
self.delegated_namespaces = set(delegated.keys())
|
|
|
|
log_lines = [f"Delegations updated for {client.profile}:"]
|
|
for ns, attrs in sorted(delegated.items()):
|
|
if attrs:
|
|
log_lines.append(
|
|
f" - namespace {ns!r} with filtering on {len(attrs)} attribute(s):"
|
|
f" {', '.join(map(repr, attrs))}"
|
|
)
|
|
else:
|
|
log_lines.append(f" - namespace {ns!r}")
|
|
|
|
log.info("\n".join(log_lines))
|
|
|
|
delegation_data = client.get_plugin_data(C.PI_IMPORT_NAME, DelegationData)
|
|
if not delegation_data.delegation_received.called:
|
|
delegation_data.delegation_received.callback(None)
|
|
|
|
def on_forward(
|
|
self, delegation_iq_elt: domish.Element, client: SatXMPPComponent
|
|
) -> None:
|
|
"""Handle forwarded stanza from server.
|
|
|
|
@param client: Client instance.
|
|
@param delegation_iq_elt: Full delegation stanza.
|
|
"""
|
|
server_jid = client.server_jid
|
|
if jid.JID(delegation_iq_elt["from"]) != server_jid:
|
|
log.warning(
|
|
"SECURITY: forwarded stanza from unexpected server:"
|
|
f" {delegation_iq_elt.toXml()}"
|
|
)
|
|
raise error.StanzaError("not-allowed")
|
|
|
|
try:
|
|
delegation_elt = next(delegation_iq_elt.elements(NS_DELEGATION, "delegation"))
|
|
forwarded_elt = next(delegation_elt.elements(C.NS_FORWARD, "forwarded"))
|
|
forwarded_iq_elt = forwarded_elt.firstChildElement()
|
|
if forwarded_iq_elt is None:
|
|
raise error.StanzaError("not-acceptable")
|
|
except StopIteration:
|
|
raise error.StanzaError("not-acceptable")
|
|
|
|
iq_id = forwarded_iq_elt["id"]
|
|
iq_to = forwarded_iq_elt.getAttribute("to")
|
|
timeout = reactor.callLater(SEND_HOOK_TIMEOUT, self.on_timeout, iq_id, iq_to)
|
|
self._current_iqs[iq_id] = DelegatedIQ(
|
|
client, forwarded_iq_elt, delegation_iq_elt, timeout
|
|
)
|
|
|
|
if not forwarded_iq_elt.hasAttribute("to"):
|
|
forwarded_iq_elt["to"] = jid.JID(forwarded_iq_elt["from"]).userhost()
|
|
|
|
# This attribute may be used by plugins to track that the stanza is delegated.
|
|
forwarded_iq_elt.delegated = True
|
|
|
|
# Prevent further processing.
|
|
delegation_iq_elt.handled = True
|
|
|
|
client.xmlstream.dispatch(forwarded_iq_elt)
|
|
|
|
def delegated_result(
|
|
self,
|
|
client: SatXMPPComponent,
|
|
iq_req_elt: domish.Element,
|
|
iq_resp_elt: domish.Element,
|
|
wrapping_iq_elt: domish.Element,
|
|
) -> None:
|
|
"""Wrap result/error stanza and send back to server.
|
|
|
|
@param client: Client instance.
|
|
@param iq_req_elt: The original forwarded IQ request.
|
|
@param iq_resp_elt: The response IQ (result or error).
|
|
@param wrapping_iq_elt: The original delegation IQ from the server.
|
|
"""
|
|
iq_result_elt = domish.Element((None, "iq"))
|
|
iq_result_elt["from"] = wrapping_iq_elt["to"]
|
|
iq_result_elt["to"] = wrapping_iq_elt["from"]
|
|
iq_result_elt["id"] = wrapping_iq_elt["id"]
|
|
iq_result_elt["type"] = "result"
|
|
|
|
delegation_elt = domish.Element((NS_DELEGATION, "delegation"))
|
|
|
|
wrapped_iq_elt = domish.Element(("jabber:client", "iq"))
|
|
# According to the specs, we don't set a "from" here
|
|
wrapped_iq_elt["to"] = iq_req_elt["from"]
|
|
wrapped_iq_elt["id"] = iq_req_elt["id"]
|
|
wrapped_iq_elt["type"] = iq_resp_elt["type"]
|
|
|
|
for child in iq_resp_elt.children:
|
|
wrapped_iq_elt.addChild(element_copy(child))
|
|
|
|
forwarded_elt = XEP_0297.build_forwarded(wrapped_iq_elt)
|
|
delegation_elt.addChild(forwarded_elt)
|
|
iq_result_elt.addChild(delegation_elt)
|
|
|
|
client.xmlstream.send(iq_result_elt)
|
|
|
|
def on_timeout(self, iq_id: str, iq_to: str | None) -> None:
|
|
"""Handle timeout for a delegated IQ request.
|
|
|
|
@param iq_id: The ID of the timed-out IQ.
|
|
@param iq_to: The 'to' attribute of the timed-out IQ.
|
|
"""
|
|
self._current_iqs.pop(iq_id, None)
|
|
log.error(f"Timeout waiting for response to delegated IQ {iq_id!r} to {iq_to!r}")
|
|
|
|
|
|
@implementer(itx_xmpp.IDisco)
|
|
class XEP_0355_Handler(XMPPHandler):
|
|
"""Handler for delegation stanzas on a per-client basis."""
|
|
|
|
def __init__(self, plugin_parent: XEP_0355) -> None:
|
|
self.plugin_parent = plugin_parent
|
|
|
|
@property
|
|
def client(self) -> SatXMPPComponent:
|
|
assert self.parent is not None
|
|
return self.parent
|
|
|
|
def connectionInitialized(self) -> None:
|
|
"""Handle connection initialization for delegation stanzas."""
|
|
self.client.xmlstream.addObserver(
|
|
f"/message/delegation[@xmlns='{NS_DELEGATION}']",
|
|
self.plugin_parent.on_advertise,
|
|
client=self.client,
|
|
)
|
|
self.client.xmlstream.addObserver(
|
|
f"/iq[@type='set']/delegation[@xmlns='{NS_DELEGATION}']"
|
|
f"/forwarded[@xmlns='{NS_FORWARD}']",
|
|
self.plugin_parent.on_forward,
|
|
client=self.client,
|
|
)
|
|
|
|
@ensure_deferred
|
|
async def getDiscoInfo(
|
|
self,
|
|
requestor: jid.JID,
|
|
target: jid.JID,
|
|
nodeIdentifier: str = "",
|
|
) -> list[disco.DiscoFeature | disco.DiscoIdentity | data_form.Form]:
|
|
"""Get disco info for delegation nesting.
|
|
|
|
Server queries this handler for features/identities to nest in its own disco info.
|
|
Node format: urn:xmpp:delegation:2::<namespace> for server
|
|
urn:xmpp:delegation:2:bare:<namespace> for bare JID
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: Node identifier
|
|
@return: Disco features/identities for the delegated namespace
|
|
"""
|
|
if not nodeIdentifier.startswith(NS_DELEGATION):
|
|
return []
|
|
|
|
# Extract namespace from delegation node identifier
|
|
namespace: str | None = None
|
|
if DELEGATION_MAIN_SEP in nodeIdentifier:
|
|
_, namespace = nodeIdentifier.split(DELEGATION_MAIN_SEP, 1)
|
|
elif DELEGATION_BARE_SEP in nodeIdentifier:
|
|
_, namespace = nodeIdentifier.split(DELEGATION_BARE_SEP, 1)
|
|
else:
|
|
log.error(f"Unexpected disco node: {nodeIdentifier}")
|
|
|
|
if not namespace:
|
|
return []
|
|
|
|
return await self._get_disco_info_for_namespace(namespace, requestor, target)
|
|
|
|
def getDiscoItems(
|
|
self,
|
|
requestor: jid.JID,
|
|
target: jid.JID,
|
|
nodeIdentifier: str = "",
|
|
) -> list[disco.DiscoItem]:
|
|
"""Get disco items.
|
|
|
|
Actual disco#items delegation is handled via stanza forwarding.
|
|
|
|
@param requestor: JID of the requesting entity
|
|
@param target: JID of the target entity
|
|
@param nodeIdentifier: Node identifier
|
|
@return: list of disco items
|
|
"""
|
|
return []
|
|
|
|
async def _get_disco_info_for_namespace(
|
|
self,
|
|
namespace: str,
|
|
requestor: jid.JID,
|
|
target: jid.JID,
|
|
) -> list[disco.DiscoFeature | disco.DiscoIdentity | data_form.Form]:
|
|
"""Collect disco info for a delegated namespace from all handlers.
|
|
|
|
@param namespace: The delegated namespace
|
|
@return: List of features/identities matching the namespace
|
|
"""
|
|
# Normalize pubsub namespace: pubsub uses several namespaces starting with
|
|
# NS_PUBSUB; e.g. http://jabber.org/protocol/pubsub#owner. We return the same
|
|
# disco info for all pubsub variants (matches SàT Pubsub reference implementation)
|
|
normalized_namespace = namespace
|
|
if namespace.startswith(pubsub.NS_PUBSUB):
|
|
normalized_namespace = pubsub.NS_PUBSUB
|
|
|
|
disco_info: list[disco.DiscoFeature | disco.DiscoIdentity | data_form.Form] = []
|
|
|
|
for handler in self.client.handlers:
|
|
if not hasattr(handler, "getDiscoInfo"):
|
|
continue
|
|
|
|
try:
|
|
handler_disco_info = await as_deferred(
|
|
handler.getDiscoInfo, requestor, target, ""
|
|
)
|
|
if not handler_disco_info:
|
|
continue
|
|
|
|
for info in handler_disco_info:
|
|
if isinstance(info, disco.DiscoFeature):
|
|
if info.startswith(normalized_namespace):
|
|
disco_info.append(info)
|
|
elif isinstance(info, disco.DiscoIdentity):
|
|
# Check if identity matches namespace (pubsub category for pubsub
|
|
# namespace)
|
|
# FIXME: we should handle other identities
|
|
if (
|
|
normalized_namespace == pubsub.NS_PUBSUB
|
|
and info.category == "pubsub"
|
|
):
|
|
disco_info.append(info)
|
|
elif isinstance(info, data_form.Form):
|
|
if info.formNamespace and info.formNamespace.startswith(
|
|
normalized_namespace
|
|
):
|
|
disco_info.append(info)
|
|
except Exception:
|
|
log.exception(f"Error collecting disco info from handler")
|
|
|
|
# Add PEP identity for pubsub namespace
|
|
# FIXME: to be moved to Pubsub component
|
|
if normalized_namespace == pubsub.NS_PUBSUB:
|
|
disco_info.append(disco.DiscoIdentity("pubsub", "pep", "PEP"))
|
|
|
|
return disco_info
|