Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_xep_0047.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0047.py@524856bd7b19 |
children | 0d7bb4df2343 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_xep_0047.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 + + +# SAT plugin for managing gateways (xep-0047) +# Copyright (C) 2009-2021 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 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 wokkel import disco, iwokkel + +from zope.interface import implementer + +import base64 + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.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 <open> element is received + + @param iq_elt(domish.Element): the whole <iq> 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 <close> element is received + + @param iq_elt(domish.Element): the whole <iq> 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 <iq> or <message> stanzas with data element + + Manage the data elelement (check validity and write to the stream_object) + @param element(domish.Element): <iq> or <message> 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 <iq> 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 <message> 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(iwokkel.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 []