# HG changeset patch # Parent 49294b2cf829414b42141731b5130d91474c0443 # Parent 0a68c01ed5b5f429eed343df521bc706fe88afd1 Add factory for accepting client connections. The new `XMPPC2SServerFactory` is a server factory for accepting client connections. It uses `XMPPClientListenAuthenticator` to perform the steps for authentication and binding of a resource. For each connection, the factory also sets up subprotocol handlers by calling `setupHandlers`. By default these are `RecipientAddressStamper` and `StanzaForwarder`. The former makes sure that all XML stanzas received from the client are stamped with a proper recipient address. The latter passes stanzas on to the stream's avatar. TODO: * Add tests. diff --git a/wokkel/client.py b/wokkel/client.py --- a/wokkel/client.py +++ b/wokkel/client.py @@ -22,7 +22,9 @@ from wokkel import generic from wokkel.iwokkel import IUserSession +from wokkel.subprotocols import ServerStreamManager from wokkel.subprotocols import StreamManager +from wokkel.subprotocols import XMPPHandler NS_CLIENT = 'jabber:client' @@ -480,3 +482,70 @@ self.portal = self.portals[self.xmlstream.thisEntity] except KeyError: raise error.StreamError('host-unknown') + + + +class RecipientAddressStamper(XMPPHandler): + """ + Protocol handler to ensure client stanzas have a sender address. + """ + + def connectionInitialized(self): + self.xmlstream.addObserver('/*', self.onStanza, priority=1) + + + def onStanza(self, element): + """ + Make sure each stanza has a sender address. + """ + if element.uri != self.xmlstream.namespace: + return + + if (element.name == 'presence' and + element.getAttribute('type') in ('subscribe', 'subscribed', + 'unsubscribe', 'unsubscribed')): + element['from'] = self.xmlstream.avatar.entity.userhost() + elif element.name in ('message', 'presence', 'iq'): + element['from'] = self.xmlstream.avatar.entity.full() + + + +class XMPPC2SServerFactory(xmlstream.XmlStreamServerFactory): + """ + Server factory for XMPP client-server connections. + """ + + def __init__(self, portals): + def authenticatorFactory(): + return XMPPClientListenAuthenticator(portals) + + xmlstream.XmlStreamServerFactory.__init__(self, authenticatorFactory) + self.addBootstrap(xmlstream.STREAM_CONNECTED_EVENT, + self.onConnectionMade) + + + def onConnectionMade(self, xs): + """ + Called when a connection is made. + + This creates a stream manager, calls L{setupHandlers} to attach + subprotocol handlers and then signals the stream manager that + the connection was made. + """ + sm = ServerStreamManager() + sm.logTraffic = self.logTraffic + + for handler in self.setupHandlers(): + handler.setHandlerParent(sm) + + sm.makeConnection(xs) + + + def setupHandlers(self): + """ + Set up XMPP subprotocol handlers. + """ + return [ + generic.StanzaForwarder(), + RecipientAddressStamper(), + ] diff --git a/wokkel/generic.py b/wokkel/generic.py --- a/wokkel/generic.py +++ b/wokkel/generic.py @@ -480,3 +480,64 @@ standard full stop. """ return name.encode('idna') + + + +class StanzaForwarder(XMPPHandler): + """ + XMPP protocol for passing incoming stanzas to the stream avatar. + + This handler adds an observer for all XML Stanzas to forward to the C{send} + method on the cred avatar set on the XML Stream, unless it has been handled + by other observers. + + Stream errors are logged. + """ + + def connectionMade(self): + """ + Called when a connection is made. + """ + self.xmlstream.addObserver(xmlstream.STREAM_ERROR_EVENT, self.onError) + + + def connectionInitialized(self): + """ + Called when the stream has been initialized. + """ + self.xmlstream.addObserver('/*', self.onStanza, priority=-1) + + + + def onStanza(self, element): + """ + Called when a stanza element was received. + + If this is an XML stanza, and it has not been handled by another + subprotocol handler, the stanza is passed on to the avatar's C{send} + method. + + If there is no recipient address on the stanza, a service-unavailable + is returned instead. + """ + if element.handled: + return + + if (element.name not in ('iq', 'message', 'presence') or + element.uri != self.xmlstream.namespace): + return + + stanza = Stanza.fromElement(element) + + if not stanza.recipient: + exc = error.StanzaError('service-unavailable') + self.send(exc.toResponse(stanza.element)) + else: + self.xmlstream.avatar.send(stanza.element) + + + def onError(self, reason): + """ + Log a stream error. + """ + log.err(reason, "Stream error") diff --git a/wokkel/test/test_client.py b/wokkel/test/test_client.py --- a/wokkel/test/test_client.py +++ b/wokkel/test/test_client.py @@ -7,7 +7,7 @@ from base64 import b64encode -from zope.interface import implements +from zope.interface import implementer from twisted.cred.portal import IRealm, Portal from twisted.cred.checkers import InMemoryUsernamePasswordDatabaseDontUse @@ -28,6 +28,8 @@ from wokkel import client, iwokkel from wokkel.generic import TestableXmlStream, FeatureListenAuthenticator +from wokkel.generic import parseXml +from wokkel.test.helpers import XmlStreamStub class XMPPClientTest(unittest.TestCase): """ @@ -180,8 +182,8 @@ +@implementer(iwokkel.IUserSession) class TestSession(object): - implements(iwokkel.IUserSession) def __init__(self, domain, user): self.domain = domain @@ -189,14 +191,14 @@ def bindResource(self, resource): - return defer.succeed(JID(tuple=(self.user, self.domain, resource))) + self.entity = JID(tuple=(self.user, self.domain, resource)) + return defer.succeed(self.entity) +@implementer(IRealm) class TestRealm(object): - implements(IRealm) - logoutCalled = False def __init__(self, domain): @@ -679,3 +681,91 @@ "to='example.com' " "version='1.0'>") self.xmlstream.assertStreamError(self, condition='host-unknown') + + + +class RecipientAddressStamperTest(unittest.TestCase): + """ + Tests for L{client.RecipientAddressStamper}. + """ + + + def setUp(self): + self.stub = XmlStreamStub() + self.stub.xmlstream.namespace = '' + avatar = TestSession(u'example.org', u'test') + avatar.bindResource(u'Home') + self.stub.xmlstream.avatar = avatar + + self.protocol = client.RecipientAddressStamper() + self.protocol.makeConnection(self.stub.xmlstream) + self.protocol.connectionInitialized() + + + def test_presence(self): + """ + The from address is set to the full JID on presence stanzas. + """ + xml = """""" + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertEqual(u'test@example.org/Home', + element.getAttribute('from')) + + + def test_presenceSubscribe(self): + """ + The from address is set to the bare JID on presence subscribe. + """ + xml = """""" + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertEqual(u'test@example.org', + element.getAttribute('from')) + + + def test_fromAlreadySet(self): + """ + The from address is overridden if already present. + """ + xml = """""" + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertEqual(u'test@example.org/Home', + element.getAttribute('from')) + + + def test_notHandled(self): + """ + The stanza will not have its 'handled' attribute set to True. + """ + xml = """""" + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertFalse(element.handled) + + + def test_message(self): + """ + The from address is set to the full JID on message stanzas. + """ + xml = """ + Hi! + """ + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertEqual(u'test@example.org/Home', + element.getAttribute('from')) + + + def test_iq(self): + """ + The from address is set to the full JID on iq stanzas. + """ + xml = """ + + """ + element = parseXml(xml) + self.stub.xmlstream.dispatch(element) + self.assertEqual(u'test@example.org/Home', + element.getAttribute('from')) diff --git a/wokkel/test/test_generic.py b/wokkel/test/test_generic.py --- a/wokkel/test/test_generic.py +++ b/wokkel/test/test_generic.py @@ -728,3 +728,91 @@ name = u"example.com." result = generic.prepareIDNName(name) self.assertEqual(b"example.com.", result) + + + +class StanzaForwarderTest(unittest.TestCase): + """ + Tests for L{generic.StanzaForwarder}. + """ + + def setUp(self): + class Avatar(object): + def __init__(self): + self.sent = [] + + def send(self, element): + self.sent.append(element) + + self.stub = XmlStreamStub() + self.avatar = Avatar() + self.protocol = generic.StanzaForwarder() + self.protocol.makeConnection(self.stub.xmlstream) + self.protocol.xmlstream.avatar = self.avatar + self.protocol.xmlstream.namespace = u'jabber:client' + self.protocol.send = self.protocol.xmlstream.send + + + def test_onStanza(self): + """ + An XML stanza is delivered at the stream avatar. + """ + self.protocol.connectionInitialized() + + element = domish.Element((u'jabber:client', u'message')) + element[u'to'] = u'other@example.org' + self.stub.send(element) + + self.assertEqual(1, len(self.avatar.sent)) + self.assertEqual(0, len(self.stub.output)) + + + def test_onStanzaNoRecipient(self): + """ + Stanzas without recipient are rejected. + """ + self.protocol.connectionInitialized() + + element = domish.Element((u'jabber:client', u'message')) + self.stub.send(element) + + self.assertEqual(0, len(self.avatar.sent)) + self.assertEqual(1, len(self.stub.output)) + + + def test_onStanzaWrongNamespace(self): + """ + If there is no xmlns on the stanza, it should still be delivered. + """ + self.protocol.connectionInitialized() + + element = domish.Element((u'testns', u'message')) + element[u'to'] = u'other@example.org' + self.stub.send(element) + + self.assertEqual(0, len(self.avatar.sent)) + self.assertEqual(0, len(self.stub.output)) + + + def test_onStanzaAlreadyHandled(self): + """ + If the stanza is marked as handled, ignore it. + """ + self.protocol.connectionInitialized() + + element = domish.Element((None, u'message')) + element[u'to'] = u'other@example.org' + element.handled = True + self.stub.send(element) + + self.assertEqual(0, len(self.avatar.sent)) + self.assertEqual(0, len(self.stub.output)) + + + def test_onError(self): + """ + A stream error is logged. + """ + exc = error.StreamError('host-unknown') + self.stub.xmlstream.dispatch(exc, xmlstream.STREAM_ERROR_EVENT) + self.assertEqual(1, len(self.flushLoggedErrors(error.StreamError)))