#!/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 .
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:: for server
urn:xmpp:delegation:2:bare: 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