#!/usr/bin/env python3 # SAT plugin for managing gateways (xep-0047) # 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 libervia.backend.core.i18n import _ from libervia.backend.core.log import getLogger log = getLogger(__name__) from libervia.backend.core.constants import Const as C from libervia.backend.core import exceptions from twisted.words.protocols.jabber import jid from twisted.words.protocols.jabber import xmlstream from twisted.words.protocols.jabber import error from twisted.internet import reactor from twisted.internet import defer from twisted.python import failure from tx_xmpp import disco, itx_xmpp from zope.interface import implementer import base64 try: from twisted.words.protocols.xmlstream import XMPPHandler except ImportError: from tx_xmpp.subprotocols import XMPPHandler MESSAGE = "/message" IQ_SET = '/iq[@type="set"]' NS_IBB = "http://jabber.org/protocol/ibb" IBB_OPEN = IQ_SET + '/open[@xmlns="' + NS_IBB + '"]' IBB_CLOSE = IQ_SET + '/close[@xmlns="' + NS_IBB + '" and @sid="{}"]' IBB_IQ_DATA = IQ_SET + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]' IBB_MESSAGE_DATA = MESSAGE + '/data[@xmlns="' + NS_IBB + '" and @sid="{}"]' TIMEOUT = 120 # timeout for workflow DEFER_KEY = "finished" # key of the deferred used to track session end PLUGIN_INFO = { C.PI_NAME: "In-Band Bytestream Plugin", C.PI_IMPORT_NAME: "XEP-0047", C.PI_TYPE: "XEP", C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0047"], C.PI_MAIN: "XEP_0047", C.PI_HANDLER: "yes", C.PI_DESCRIPTION: _("""Implementation of In-Band Bytestreams"""), } class XEP_0047(object): NAMESPACE = NS_IBB BLOCK_SIZE = 4096 def __init__(self, host): log.info(_("In-Band Bytestreams plugin initialization")) self.host = host def get_handler(self, client): return XEP_0047_handler(self) def profile_connected(self, client): client.xep_0047_current_stream = {} # key: stream_id, value: data(dict) def _time_out(self, sid, client): """Delete current_stream id, called after timeout @param sid(unicode): session id of client.xep_0047_current_stream @param client: %(doc_client)s """ log.info( "In-Band Bytestream: TimeOut reached for id {sid} [{profile}]".format( sid=sid, profile=client.profile ) ) self._kill_session(sid, client, "TIMEOUT") def _kill_session(self, sid, client, failure_reason=None): """Delete a current_stream id, clean up associated observers @param sid(unicode): session id @param client: %(doc_client)s @param failure_reason(None, unicode): if None the session is successful else, will be used to call failure_cb """ try: session = client.xep_0047_current_stream[sid] except KeyError: log.warning("kill id called on a non existant id") return try: observer_cb = session["observer_cb"] except KeyError: pass else: client.xmlstream.removeObserver(session["event_data"], observer_cb) if session["timer"].active(): session["timer"].cancel() del client.xep_0047_current_stream[sid] success = failure_reason is None stream_d = session[DEFER_KEY] if success: stream_d.callback(None) else: stream_d.errback(failure.Failure(exceptions.DataError(failure_reason))) def create_session(self, *args, **kwargs): """like [_create_session] but return the session deferred instead of the whole session session deferred is fired when transfer is finished """ return self._create_session(*args, **kwargs)[DEFER_KEY] def _create_session(self, client, stream_object, local_jid, to_jid, sid): """Called when a bytestream is imminent @param stream_object(IConsumer): stream object where data will be written @param local_jid(jid.JID): same as [start_stream] @param to_jid(jid.JId): jid of the other peer @param sid(unicode): session id @return (dict): session data """ if sid in client.xep_0047_current_stream: raise exceptions.ConflictError("A session with this id already exists !") session_data = client.xep_0047_current_stream[sid] = { "id": sid, DEFER_KEY: defer.Deferred(), "local_jid": local_jid, "to": to_jid, "stream_object": stream_object, "seq": -1, "timer": reactor.callLater(TIMEOUT, self._time_out, sid, client), } return session_data def _on_ibb_open(self, iq_elt, client): """ "Called when an IBB element is received @param iq_elt(domish.Element): the whole stanza """ log.debug(_("IBB stream opening")) iq_elt.handled = True open_elt = next(iq_elt.elements(NS_IBB, "open")) block_size = open_elt.getAttribute("block-size") sid = open_elt.getAttribute("sid") stanza = open_elt.getAttribute("stanza", "iq") if not sid or not block_size or int(block_size) > 65535: return self._sendError("not-acceptable", sid or None, iq_elt, client) if not sid in client.xep_0047_current_stream: log.warning(_("Ignoring unexpected IBB transfer: %s" % sid)) return self._sendError("not-acceptable", sid or None, iq_elt, client) session_data = client.xep_0047_current_stream[sid] if session_data["to"] != jid.JID(iq_elt["from"]): log.warning( _("sended jid inconsistency (man in the middle attack attempt ?)") ) return self._sendError("not-acceptable", sid, iq_elt, client) # at this stage, the session looks ok and will be accepted # we reset the timeout: session_data["timer"].reset(TIMEOUT) # we save the xmlstream, events and observer data to allow observer removal session_data["event_data"] = event_data = ( IBB_MESSAGE_DATA if stanza == "message" else IBB_IQ_DATA ).format(sid) session_data["observer_cb"] = observer_cb = self._on_ibb_data event_close = IBB_CLOSE.format(sid) # we now set the stream observer to look after data packet # FIXME: if we never get the events, the observers stay. # would be better to have generic observer and check id once triggered client.xmlstream.addObserver(event_data, observer_cb, client=client) client.xmlstream.addOnetimeObserver( event_close, self._on_ibb_close, client=client ) # finally, we send the accept stanza iq_result_elt = xmlstream.toResponse(iq_elt, "result") client.send(iq_result_elt) def _on_ibb_close(self, iq_elt, client): """ "Called when an IBB element is received @param iq_elt(domish.Element): the whole stanza """ iq_elt.handled = True log.debug(_("IBB stream closing")) close_elt = next(iq_elt.elements(NS_IBB, "close")) # XXX: this observer is only triggered on valid sid, so we don't need to check it sid = close_elt["sid"] iq_result_elt = xmlstream.toResponse(iq_elt, "result") client.send(iq_result_elt) self._kill_session(sid, client) def _on_ibb_data(self, element, client): """Observer called on or stanzas with data element Manage the data elelement (check validity and write to the stream_object) @param element(domish.Element): or stanza """ element.handled = True data_elt = next(element.elements(NS_IBB, "data")) sid = data_elt["sid"] try: session_data = client.xep_0047_current_stream[sid] except KeyError: log.warning(_("Received data for an unknown session id")) return self._sendError("item-not-found", None, element, client) from_jid = session_data["to"] stream_object = session_data["stream_object"] if from_jid.full() != element["from"]: log.warning( _( "sended jid inconsistency (man in the middle attack attempt ?)\ninitial={initial}\ngiven={given}" ).format(initial=from_jid, given=element["from"]) ) if element.name == "iq": self._sendError("not-acceptable", sid, element, client) return session_data["seq"] = (session_data["seq"] + 1) % 65535 if int(data_elt.getAttribute("seq", -1)) != session_data["seq"]: log.warning(_("Sequence error")) if element.name == "iq": reason = "not-acceptable" self._sendError(reason, sid, element, client) self.terminate_stream(session_data, client, reason) return # we reset the timeout: session_data["timer"].reset(TIMEOUT) # we can now decode the data try: stream_object.write(base64.b64decode(str(data_elt))) except TypeError: # The base64 data is invalid log.warning(_("Invalid base64 data")) if element.name == "iq": self._sendError("not-acceptable", sid, element, client) self.terminate_stream(session_data, client, reason) return # we can now ack success if element.name == "iq": iq_result_elt = xmlstream.toResponse(element, "result") client.send(iq_result_elt) def _sendError(self, error_condition, sid, iq_elt, client): """Send error stanza @param error_condition: one of twisted.words.protocols.jabber.error.STANZA_CONDITIONS keys @param sid(unicode,None): jingle session id, or None, if session must not be destroyed @param iq_elt(domish.Element): full stanza @param client: %(doc_client)s """ iq_elt = error.StanzaError(error_condition).toResponse(iq_elt) log.warning( "Error while managing in-band bytestream session, cancelling: {}".format( error_condition ) ) if sid is not None: self._kill_session(sid, client, error_condition) client.send(iq_elt) def start_stream( self, client, stream_object, local_jid, to_jid, sid, block_size=None ): """Launch the stream workflow @param stream_object(ifaces.IStreamProducer): stream object to send @param local_jid(jid.JID): jid to use as local jid This is needed for client which can be addressed with a different jid than client.jid if a local part is used (e.g. piotr@file.example.net where client.jid would be file.example.net) @param to_jid(jid.JID): JID of the recipient @param sid(unicode): Stream session id @param block_size(int, None): size of the block (or None for default) """ session_data = self._create_session(client, stream_object, local_jid, to_jid, sid) if block_size is None: block_size = XEP_0047.BLOCK_SIZE assert block_size <= 65535 session_data["block_size"] = block_size iq_elt = client.IQ() iq_elt["from"] = local_jid.full() iq_elt["to"] = to_jid.full() open_elt = iq_elt.addElement((NS_IBB, "open")) open_elt["block-size"] = str(block_size) open_elt["sid"] = sid open_elt["stanza"] = "iq" # TODO: manage stanza ? args = [session_data, client] d = iq_elt.send() d.addCallbacks(self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args) return session_data[DEFER_KEY] def _iq_data_stream_cb(self, iq_elt, session_data, client): """Called during the whole data streaming @param iq_elt(domish.Element): iq result @param session_data(dict): data of this streaming session @param client: %(doc_client)s """ session_data["timer"].reset(TIMEOUT) # FIXME: producer/consumer mechanism is not used properly here buffer_ = session_data["stream_object"].file_obj.read(session_data["block_size"]) if buffer_: next_iq_elt = client.IQ() next_iq_elt["from"] = session_data["local_jid"].full() next_iq_elt["to"] = session_data["to"].full() data_elt = next_iq_elt.addElement((NS_IBB, "data")) seq = session_data["seq"] = (session_data["seq"] + 1) % 65535 data_elt["seq"] = str(seq) data_elt["sid"] = session_data["id"] data_elt.addContent(base64.b64encode(buffer_).decode()) args = [session_data, client] d = next_iq_elt.send() d.addCallbacks( self._iq_data_stream_cb, self._iq_data_stream_eb, args, None, args ) else: self.terminate_stream(session_data, client) def _iq_data_stream_eb(self, failure, session_data, client): if failure.check(error.StanzaError): log.warning("IBB transfer failed: {}".format(failure.value)) else: log.error("IBB transfer failed: {}".format(failure.value)) self.terminate_stream(session_data, client, "IQ_ERROR") def terminate_stream(self, session_data, client, failure_reason=None): """Terminate the stream session @param session_data(dict): data of this streaming session @param client: %(doc_client)s @param failure_reason(unicode, None): reason of the failure, or None if steam was successful """ iq_elt = client.IQ() iq_elt["from"] = session_data["local_jid"].full() iq_elt["to"] = session_data["to"].full() close_elt = iq_elt.addElement((NS_IBB, "close")) close_elt["sid"] = session_data["id"] iq_elt.send() self._kill_session(session_data["id"], client, failure_reason) @implementer(itx_xmpp.IDisco) class XEP_0047_handler(XMPPHandler): def __init__(self, parent): self.plugin_parent = parent def connectionInitialized(self): self.xmlstream.addObserver( IBB_OPEN, self.plugin_parent._on_ibb_open, client=self.parent ) def getDiscoInfo(self, requestor, target, nodeIdentifier=""): return [disco.DiscoFeature(NS_IBB)] def getDiscoItems(self, requestor, target, nodeIdentifier=""): return []