2 Copyright (c) 2014 Justin Wind
4 Permission is hereby granted, free of charge, to any person obtaining a copy
5 of this software and associated documentation files (the "Software"), to deal
6 in the Software without restriction, including without limitation the rights
7 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 copies of the Software, and to permit persons to whom the Software is
9 furnished to do so, subject to the following conditions:
11 The above copyright notice and this permission notice shall be included in
12 all copies or substantial portions of the Software.
14 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22 ============================
23 MUD Client Protocol, Twisted
24 ============================
26 Based on this specification: http://www.moo.mud.org/mcp2/mcp2.html
29 from twisted
.protocols
import basic
30 from collections
import deque
31 from random
import choice
38 logger
= logging
.getLogger(__name__
)
41 class MCPError(Exception):
45 def generateKey(length
, chars
):
46 """Return a string of length, composed from chars, for use as a key."""
47 return ''.join([choice(chars
) for i
in range(length
)])
50 def versionGEQ(v1
, v2
):
51 """Return True if the MCP version string v1 ("2.1", exempli gratia) is
52 greater-than-or-equal to v2."""
53 (v1major
, v1minor
) = v1
.split('.', 1)
54 (v2major
, v2minor
) = v2
.split('.', 1)
55 if int(v1major
) > int(v2major
):
57 if int(v1major
) == int(v2major
) and int(v1minor
) >= int(v2minor
):
62 def versionMin(v1
, v2
):
63 """Return the minimum of two MCP version strings."""
64 (v1major
, v1minor
) = v1
.split('.', 1)
65 (v2major
, v2minor
) = v2
.split('.', 1)
66 if int(v1major
) < int(v2major
):
68 elif int(v2major
) < int(v1major
):
70 elif int(v1minor
) < int(v2minor
):
75 def versionBest(iRange
, rRange
):
76 """Return the best version common to the two ranges."""
79 if versionGEQ(rMax
, iMin
) and versionGEQ(iMax
, rMin
):
80 return versionMin(iMax
, rMax
)
84 class MCPPackage(object):
86 Bundle of handlers which make up an MCP package.
91 def __init__(self
, mcp
):
95 def attach(self
, supportedVersion
):
97 Invoked when the other end has indicated it will support this package,
98 this method should install the message handlers for the supportedVersion.
100 self
.version
= supportedVersion
101 raise NotImplementedError
103 def handle(self
, message
, data
):
105 Handle a packageName message here.
107 raise NotImplementedError
111 Send a packageName message here.
113 msg
= 'package-message'
114 self
.mcp
.sendMessage(msg
, None)
115 raise NotImplementedError
118 class MCPPackageNegotiate(MCPPackage
):
119 """Handle 'mcp-negotiate' commands."""
120 packageName
= 'mcp-negotiate'
121 versionRange
= ("1.0", "2.0")
123 def __init__(self
, mcp
):
124 MCPPackage
.__init
__(self
, mcp
)
125 self
.negotiated
= False
127 def attach(self
, supportedVersion
):
128 """Install support for mcp-negotiate commands."""
129 if supportedVersion
is None:
130 # normal packages return here, but negotiate is an integral MCP
131 # package, and needs to be able to bootstrap itself
132 supportedVersion
= "2.0"
133 self
.version
= supportedVersion
134 if versionGEQ(supportedVersion
, "2.0"):
135 self
.mcp
.messageHandlers
[self
.packageName
+ '-end'] = self
.handleEnd
136 if versionGEQ(supportedVersion
, "1.0"):
137 self
.mcp
.messageHandlers
[self
.packageName
+ '-can'] = self
.handleCan
139 "attached package '%s' (%s)",
142 if not versionGEQ(supportedVersion
, "2.0"):
143 # version 1.0 does not have an end-of-negotiations, so just pretend
144 self
.handleEnd(None, {})
147 """Send the list of packages."""
148 for packageName
in self
.mcp
.packagesCapable
:
149 package
= self
.mcp
.packagesCapable
[packageName
]
150 self
.sendCan(package
)
154 """Send the command indicating no more packages."""
155 msg
= self
.packageName
+ '-end'
156 if self
.version
is None and not versionGEQ(self
.mcp
.version
, "2.1"):
157 # pre-negotiation, but MCP version doesn't support this message
159 if self
.version
is not None and not versionGEQ(self
.version
, "2.0"):
160 # fully negotiated, but mcp-negotiate version doesn't support this message
162 self
.mcp
.sendMessage(msg
, None)
164 def handleEnd(self
, message
, data
):
165 """Receive the end of packages command."""
166 self
.mcp
.negotiated
= True
168 "negotiations complete")
169 self
.mcp
.connectionNegotiated()
171 def sendCan(self
, package
):
172 """Send the command indicating a package is available."""
173 msg
= self
.packageName
+ '-can'
174 if self
.version
is not None and not versionGEQ(self
.version
, "1.0"):
175 # this should never occur, but hey
177 self
.mcp
.sendMessage(msg
, {
178 'package': package
.packageName
,
179 'min-version': package
.versionRange
[0],
180 'max-version': package
.versionRange
[1]})
182 def handleCan(self
, message
, data
):
183 """Receive an available package notification."""
184 for requiredKey
in ('package', 'min-version', 'max-version'):
185 if requiredKey
not in data
:
187 "ignoring '%s' due to missing key '%s'",
191 if data
['package'] not in self
.mcp
.packagesCapable
:
193 "unsupported package '%s'",
196 package
= self
.mcp
.packagesCapable
[data
['package']]
198 supportedVersion
= versionBest(
199 (data
['min-version'], data
['max-version']),
200 package
.versionRange
)
201 if supportedVersion
is None:
203 "no version match for package '%s'",
207 package
.attach(supportedVersion
)
210 class MCPPackageCord(MCPPackage
):
211 """Handle 'mcp-cord' comamnds."""
212 packageName
= 'mcp-cord'
213 versionRange
= ("1.0", "1.0")
217 def __init__(self
, mcp
):
218 MCPPackage
.__init
__(self
, mcp
)
222 def attach(self
, supportedVersion
):
223 """Install support for mcp-cord commands."""
224 if supportedVersion
is None:
226 self
.version
= supportedVersion
227 if versionGEQ(supportedVersion
, "1.0"):
228 self
.mcp
.messageHandlers
[self
.packageName
+ '-open'] = self
.handleOpen
229 self
.mcp
.messageHandlers
[self
.packageName
+ '-close'] = self
.handleClose
230 self
.mcp
.messageHandlers
[self
.packageName
] = self
.handle
232 "attached package %s (%s)",
236 def sendOpen(self
, cordType
, cb
):
238 Open a cord, return the cord id.
239 Callback cb(mcp, type, id, msg, data) will be invoked whenever a cord
240 message on the opened id is received, until that cord id is closed."""
241 msg
= self
.packageName
+ '-open'
242 cordID
= self
.CORDFORMAT
% ("I" if self
.mcp
.initiator
else "R", self
.cordNext
)
244 self
.mcp
.sendMessage(msg
, {
247 self
.cords
[cordID
] = (cb
, cordType
)
250 def handleOpen(self
, message
, data
):
252 for requiredKey
in ['_id', '_type']:
253 if requiredKey
not in data
:
255 "'%s' missing required key '%s'",
259 if data
['_id'] in self
.cords
:
261 "'%s' of duplicate cord '%s'",
265 self
.cords
[data
['_id']] = data
['_type']
270 def sendClose(self
, cordID
):
272 msg
= self
.packageName
+ '-close'
273 if cordID
not in self
.cords
:
275 "tried to close non-existant cord '%s'",
278 self
.mcp
.sendMessage(msg
, {
281 def handleClose(self
, message
, data
):
283 if '_id' not in data
:
285 "'%s' missing required key '%s'",
289 if data
['_id'] not in self
.cords
:
291 "tried to close non-existant cord '%s'",
294 del self
.cords
[data
['_id']]
296 def send(self
, cordID
, cordMsg
, data
=None):
298 msg
= self
.packageName
301 if '_id' not in self
.cords
:
303 "could not send to non-existant cord '%s'",
307 data
['_message'] = cordMsg
308 self
.mcp
.sendMessage(msg
, data
)
310 def handle(self
, message
, data
):
312 for requiredKey
in ('_id', '_message'):
313 if requiredKey
not in data
:
315 "'%s' missing required key '%s'",
319 if data
['_id'] not in self
.cords
:
321 "'%s' for non-existant cord '%s'",
326 cordMsg
= data
['_message']
327 # FIXME: maybe delete _id and _message from data before hitting the
328 # callback, because they aren't part of the message proper?
329 (cordCallback
, cordType
) = self
.cords
[cordID
]
330 if callable(cordCallback
):
331 cordCallback(self
.mcp
, cordType
, cordID
, cordMsg
, data
)
334 # object is inhereted here so that super() will work,
335 # because Twisted's BaseProtocol is an old-style class
336 class MCP(basic
.LineOnlyReceiver
, object):
338 A line-oriented protocol, supporting out-of-band messages.
346 AUTHKEY_CHARACTERS
= string
.ascii_lowercase
+ \
347 string
.ascii_uppercase
+ \
349 "-~`!@#$%^&()=+{}[]|';?/><.,"
350 AUTHKEY_SET
= set(AUTHKEY_CHARACTERS
)
353 KEY_CHARACTERS
= string
.ascii_lowercase
+ \
354 string
.ascii_uppercase
+ \
357 KEY_SET
= set(KEY_CHARACTERS
)
360 def __init__(self
, initiator
=False):
362 Create a new MCP protocol.
364 If initiator is True, proffer handshake; otherwise respond to it.
365 This only affects the initial handshake.
367 self
.initiator
= initiator
368 # Which side of the conversation we are.
371 # The state of the connection handshake.
372 # This will be set to the running protocol version, once established.
375 # This connection's authentication key.
376 # Blank until handshake complete.
379 # A list of multi-line messages which have not yet been terminated,
380 # indexed by their _data-tag property.
382 self
.packagesCapable
= {}
383 # All the packages we know how to handle.
385 self
.packagesActive
= {}
386 # The packages the remote side proffered via negotiation,
387 # which matched ones we can deal with.
389 self
.messageHandlers
= {}
390 # A dispatch table mapping messages to the package functions which
393 self
.sendQueue
= deque()
394 # A list of lines to transmit once the handshake has been completed.
396 self
.sendMessageQueue
= deque()
397 # A list of messages to transmit once the package negotiations have completed.
399 self
.negotiated
= False
400 # True once package negotiations have completed.
402 # register the standard packages
403 self
.addPackage(MCPPackageNegotiate
)
404 self
.addPackage(MCPPackageCord
)
406 # bootstrap support for the negotiation package
407 self
.packagesCapable
[MCPPackageNegotiate
.packageName
].attach(None)
410 def connectionMade(self
):
411 """Send the initiator handshake on connection."""
413 self
._peer
= self
.transport
.getPeer().host
415 logger
.debug("connectionMade, peer is '%s", self
._peer
)
419 self
.sendMessage('mcp', {
420 'version': MCP
.VERSION_MIN
,
421 'to': MCP
.VERSION_MAX
})
424 def connectionEstablished(self
):
425 """Called when the MCP handshake has been completed."""
426 # send our package negotiations
427 self
.packagesCapable
[MCPPackageNegotiate
.packageName
].proffer()
428 # and flush our queue of pending normal data
431 self
.sendLine(self
.sendQueue
.popleft())
436 def connectionNegotiated(self
):
437 """Called when MCP package exchange has completed."""
438 logger
.debug("connection negotiated, flushing queued messages")
441 (message
, kvs
) = self
.sendMessageQueue
.popleft()
444 self
.sendMessage(message
, kvs
)
447 def addPackage(self
, packageClass
, *args
, **kwargs
):
448 """Register a package type as one we are capable of handling."""
449 if not issubclass(packageClass
, MCPPackage
):
450 raise MCPError("cannot install unknown package type")
452 pkg
= packageClass(self
, *args
, **kwargs
)
453 self
.packagesCapable
[pkg
.packageName
] = pkg
456 class __InProgress(object):
458 An unterminated multi-line stanza, waiting for completion.
460 data is kept distinct from multiData to ease checking of collisions.
461 the keys used to store data are all collapsed to lowercase.
463 def __init__(self
, message
):
464 self
.message
= message
468 def setKey(self
, key
, data
):
469 if key
in self
.multiData
:
471 "ignoring attempt to overwrite multiline key '%s' with single value",
474 self
.data
[key
] = data
476 def setMultiKey(self
, key
, data
):
479 "ignoring attempt to overwrite single value key '%s' with multiline datum",
482 if key
not in self
.multiData
:
483 self
.multiData
[key
] = []
485 self
.multiData
[key
].append(data
)
488 """Return the combined simple and multikey data."""
489 return dict(self
.multiData
, **self
.data
)
491 def __multiKeyEnd(self
, datatag
):
492 if datatag
not in self
.inProgress
:
494 "termination of unknown multi-line stanza '%s'",
498 self
._dispatchMessage
(
499 self
.inProgress
[datatag
].message
,
500 self
.inProgress
[datatag
].allData())
501 del self
.inProgress
[datatag
]
503 def __multiKeyContinue(self
, line
):
505 (datatag
, line
) = re
.split(r
'\s+', line
, 1)
507 (datatag
, line
) = (line
, '')
509 if datatag
not in self
.inProgress
:
511 "continuation of unknown multi-line stanza '%s'",
514 inProgress
= self
.inProgress
[datatag
]
517 (key
, line
) = line
.split(': ', 1)
519 (key
, line
) = (line
, '')
522 if key
in inProgress
.data
:
524 "multi-line stanza '%s' tried to update non-multi-line key '%s'",
528 if key
not in inProgress
.multiData
:
530 "multi-line stanza '%s' tried to update untracked key '%s'",
535 inProgress
.data
[key
].append(line
)
536 self
.messageUpdate(datatag
, key
)
538 def __lineParseMCP(self
, line
):
539 """Process an out-of-band message."""
541 line
= line
[len(MCP
.MCP_HEADER
):]
544 (message
, line
) = re
.split(r
'\s+', line
, 1)
546 (message
, line
) = (line
, '')
548 if message
== ':': # end of multi-line stanza
549 self
.__multiKeyEnd
(line
)
551 elif message
== '*': # continuation of multi-line stanza
552 self
.__multiKeyContinue
(line
)
554 else: # simple message
555 # "#$#message authkey [k: v [...]]"
556 inProgress
= MCP
.__InProgress
(message
)
561 (authKey
, line
) = re
.split(r
'\s+', line
, 1)
563 (authKey
, line
) = (line
, '')
565 if authKey
!= self
.authKey
:
567 "ignoring message with foreign key '%s'",
571 lexer
= shlex
.shlex(line
, posix
=True)
572 lexer
.commenters
= ''
574 lexer
.whitespace_split
= True
577 # keys are case-insensitive, normalize here
582 "message '%s' could not parse key '%s'",
588 if key
[0] not in string
.ascii_lowercase
:
590 "message '%s' ignored due to invalid key '%s'",
594 if not set(key
).issubset(MCP
.KEY_SET
):
596 "message '%s' ignored due to invalid key '%s'",
603 except StopIteration:
605 "message '%s' has key '%s' without value",
612 if key
in inProgress
.multiData
or key
in inProgress
.data
:
614 "message '%s' ignoring duplicate key '%s'",
618 inProgress
.multiData
[key
] = []
621 if key
in inProgress
.data
or key
in inProgress
.multiData
:
623 "message '%s' ignoring duplicate key '%s'",
627 inProgress
.data
[key
] = value
631 "message '%s' has unparsable data",
636 if '_data-tag' not in inProgress
.data
:
638 "ignoring message with multi-line variables but no _data-tag")
640 self
.inProgress
[inProgress
.data
['_data-tag']] = inProgress
641 self
.messageUpdate(inProgress
.data
['_data-tag'], None)
643 self
.__dispatchMessage
(inProgress
.message
, inProgress
.allData())
646 def messageUpdate(self
, datatag
, key
):
648 Called when a multiline message has received a new line, but has not
651 Generally ignorable, but some servers awkwardly use multiline messages
652 as continuous channels.
653 Override this to handle such a beast.
658 def __dispatchMessage(self
, message
, data
):
659 """Invoke the handler function for a message."""
661 "MCP message: %s %s",
665 # handle handshaking messages directly
667 self
.__handshake
(message
, data
)
669 if message
in self
.messageHandlers
:
670 self
.messageHandlers
[message
](message
, data
)
672 self
.messageReceived(message
, data
)
675 def __handshake(self
, message
, data
):
676 """Handle 'mcp' messages, which establish a connection."""
679 "ignoring handshake message during established session")
682 if 'version' not in data
:
684 "%s did not send enough version information",
685 "responder" if self
.initiator
else "initiator")
688 data
['to'] = data
['version']
689 supportedVersion
= versionBest(
690 (MCP
.VERSION_MIN
, MCP
.VERSION_MAX
),
691 (data
['version'], data
['to']))
692 if supportedVersion
is None:
694 "handshake failed, incompatible versions")
695 # FIXME: maybe raise exception on this
699 if 'authentication-key' in data
:
700 if not set(data
['authentication-key']).issubset(MCP
.AUTHKEY_SET
):
702 "responder proffered unexpected characters in authentication-key")
704 self
.authKey
= data
['authentication-key']
706 "client started new session with key '%s'",
710 "ignoring message '%s' before session established",
714 authKey
= generateKey(MCP
.AUTHKEY_LEN
, MCP
.AUTHKEY_CHARACTERS
)
715 # send before setting, as handshake message doesn't include authkey
716 self
.sendMessage('mcp', {
717 'authentication-key': authKey
,
718 'version': MCP
.VERSION_MIN
,
719 'to': MCP
.VERSION_MAX
})
720 self
.authKey
= authKey
722 "established new session (%s) with key '%s'",
726 self
.version
= supportedVersion
727 self
.connectionEstablished()
730 def lineReceived(self
, line
):
731 """Process a received line for MCP messages."""
732 if line
.startswith(MCP
.MCP_HEADER
):
733 self
.__lineParseMCP
(line
)
735 if line
.startswith(MCP
.MCP_ESCAPE
):
736 line
= line
[len(MCP
.MCP_ESCAPE
):]
737 self
.lineReceivedInband(line
)
740 def sendLine(self
, line
):
742 Sends a line of normal data.
745 if not self
.initiator
and self
.version
is None:
746 self
.sendQueue
.append(line
)
749 if line
.startswith((MCP
.MCP_HEADER
, MCP
.MCP_ESCAPE
)):
750 line
= ''.join([MCP
.MCP_ESCAPE
, line
])
751 super(MCP
, self
).sendLine(line
)
754 def sendMessage(self
, message
, kvs
=None):
756 Sends an MCP message, with data.
758 # FIXME: this is janky
759 # queue non-core messages until after package negotiation
760 if not self
.negotiated
:
761 if not message
.startswith('mcp'):
763 "deferred MCP-send of '%s'",
765 self
.sendMessageQueue
.append((message
, kvs
))
770 line
= [MCP
.MCP_HEADER
, message
]
771 if self
.authKey
is not '':
772 line
.extend([' ', self
.authKey
])
774 for k
, v
in kvs
.iteritems():
775 if isinstance(v
, basestring
) and '\n' not in v
:
776 line
.extend([' ', k
, ': "', v
, '"'])
779 datatag
= generateKey(MCP
.KEY_LEN
, MCP
.KEY_CHARACTERS
)
780 line
.extend([' ', k
, '*: ""'])
781 if not isinstance(v
, basestring
):
784 vLines
= v
.split('\n')
786 msg
.append(''.join([MCP
.MCP_HEADER
, '* ', datatag
, ' ', k
, ': ', l
]))
787 msg
.insert(0, ''.join(line
))
789 super(MCP
, self
).sendLine(m
)
795 def lineReceivedInband(self
, line
):
797 Called when there's a line of normal data to process.
799 Override in implementation.
803 raise NotImplementedError
806 def messageReceived(self
, message
, data
):
808 Called when there's an otherwise-unhandled MCP message.
811 "unhandled message '%s' %s",
817 if __name__
== '__main__':
818 from twisted
.internet
import reactor
819 from twisted
.internet
.endpoints
import TCP4ClientEndpoint
, connectProtocol
822 logging
.basicConfig(level
=logging
.DEBUG
)
824 if len(sys
.argv
) < 3:
825 print "Usage: %s <host> <port>" % (os
.path
.basename(sys
.argv
[0]))
829 PORT
= int(sys
.argv
[2])
834 # reactor.callLater(1, p.sendLine("QUIT"))
835 # reactor.callLater(2, p.transport.loseConnection)
837 print "establishing endpoing"
838 point
= TCP4ClientEndpoint(reactor
, HOST
, PORT
)
840 d
= connectProtocol(point
, MCP())
841 print "adding things"
842 d
.addCallback(gotMCP
)