# 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)))