"""\ Copyright (c) 2014 Justin Wind Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================ MUD Client Protocol, Twisted ============================ Based on this specification: http://www.moo.mud.org/mcp2/mcp2.html """ from twisted.protocols import basic from collections import deque from random import choice import logging import string import shlex import re logger = logging.getLogger(__name__) class MCPError(Exception): pass def generateKey(length, chars): """Return a string of length, composed from chars, for use as a key.""" return ''.join([choice(chars) for i in range(length)]) def versionGEQ(v1, v2): """Return True if the MCP version string v1 ("2.1", exempli gratia) is greater-than-or-equal to v2.""" (v1major, v1minor) = v1.split('.', 1) (v2major, v2minor) = v2.split('.', 1) if int(v1major) > int(v2major): return True if int(v1major) == int(v2major) and int(v1minor) >= int(v2minor): return True return False def versionMin(v1, v2): """Return the minimum of two MCP version strings.""" (v1major, v1minor) = v1.split('.', 1) (v2major, v2minor) = v2.split('.', 1) if int(v1major) < int(v2major): return v1 elif int(v2major) < int(v1major): return v2 elif int(v1minor) < int(v2minor): return v1 return v2 def versionBest(iRange, rRange): """Return the best version common to the two ranges.""" (iMin, iMax) = iRange (rMin, rMax) = rRange if versionGEQ(rMax, iMin) and versionGEQ(iMax, rMin): return versionMin(iMax, rMax) return None class MCPPackage(object): """\ Bundle of handlers which make up an MCP package. """ packageName = '' versionRange = None def __init__(self, mcp): self.mcp = mcp self.version = None def attach(self, supportedVersion): """\ Invoked when the other end has indicated it will support this package, this method should install the message handlers for the supportedVersion. """ self.version = supportedVersion raise NotImplementedError def handle(self, message, data): """\ Handle a packageName message here. """ raise NotImplementedError def send(self): """\ Send a packageName message here. """ msg = 'package-message' self.mcp.sendMessage(msg, None) raise NotImplementedError class MCPPackageNegotiate(MCPPackage): """Handle 'mcp-negotiate' commands.""" packageName = 'mcp-negotiate' versionRange = ("1.0", "2.0") def __init__(self, mcp): MCPPackage.__init__(self, mcp) self.negotiated = False def attach(self, supportedVersion): """Install support for mcp-negotiate commands.""" if supportedVersion is None: # normal packages return here, but negotiate is an integral MCP # package, and needs to be able to bootstrap itself supportedVersion = "2.0" self.version = supportedVersion if versionGEQ(supportedVersion, "2.0"): self.mcp.messageHandlers[self.packageName + '-end'] = self.handleEnd if versionGEQ(supportedVersion, "1.0"): self.mcp.messageHandlers[self.packageName + '-can'] = self.handleCan logger.debug( "attached package '%s' (%s)", self.packageName, supportedVersion) if not versionGEQ(supportedVersion, "2.0"): # version 1.0 does not have an end-of-negotiations, so just pretend self.handleEnd(None, {}) def proffer(self): """Send the list of packages.""" for packageName in self.mcp.packagesCapable: package = self.mcp.packagesCapable[packageName] self.sendCan(package) self.sendEnd() def sendEnd(self): """Send the command indicating no more packages.""" msg = self.packageName + '-end' if self.version is None and not versionGEQ(self.mcp.version, "2.1"): # pre-negotiation, but MCP version doesn't support this message return if self.version is not None and not versionGEQ(self.version, "2.0"): # fully negotiated, but mcp-negotiate version doesn't support this message return self.mcp.sendMessage(msg, None) def handleEnd(self, message, data): """Receive the end of packages command.""" self.mcp.negotiated = True logger.debug( "negotiations complete") self.mcp.connectionNegotiated() def sendCan(self, package): """Send the command indicating a package is available.""" msg = self.packageName + '-can' if self.version is not None and not versionGEQ(self.version, "1.0"): # this should never occur, but hey return self.mcp.sendMessage(msg, { 'package': package.packageName, 'min-version': package.versionRange[0], 'max-version': package.versionRange[1]}) def handleCan(self, message, data): """Receive an available package notification.""" for requiredKey in ('package', 'min-version', 'max-version'): if requiredKey not in data: logger.warning( "ignoring '%s' due to missing key '%s'", message, requiredKey) return if data['package'] not in self.mcp.packagesCapable: logger.debug( "unsupported package '%s'", data['package']) return package = self.mcp.packagesCapable[data['package']] supportedVersion = versionBest( (data['min-version'], data['max-version']), package.versionRange) if supportedVersion is None: logger.debug( "no version match for package '%s'", data['package']) return package.attach(supportedVersion) class MCPPackageCord(MCPPackage): """Handle 'mcp-cord' comamnds.""" packageName = 'mcp-cord' versionRange = ("1.0", "1.0") CORDFORMAT = "%s%d" def __init__(self, mcp): MCPPackage.__init__(self, mcp) self.cordNext = 0 self.cords = {} def attach(self, supportedVersion): """Install support for mcp-cord commands.""" if supportedVersion is None: return self.version = supportedVersion if versionGEQ(supportedVersion, "1.0"): self.mcp.messageHandlers[self.packageName + '-open'] = self.handleOpen self.mcp.messageHandlers[self.packageName + '-close'] = self.handleClose self.mcp.messageHandlers[self.packageName] = self.handle logger.debug( "attached package %s (%s)", self.packageName, supportedVersion) def sendOpen(self, cordType, cb): """\ Open a cord, return the cord id. Callback cb(mcp, type, id, msg, data) will be invoked whenever a cord message on the opened id is received, until that cord id is closed.""" msg = self.packageName + '-open' cordID = self.CORDFORMAT % ("I" if self.mcp.initiator else "R", self.cordNext) self.cordNext += 1 self.mcp.sendMessage(msg, { '_id': cordID, '_type': cordType}) self.cords[cordID] = (cb, cordType) return cordID def handleOpen(self, message, data): """""" for requiredKey in ['_id', '_type']: if requiredKey not in data: logger.warning( "'%s' missing required key '%s'", message, requiredKey) return if data['_id'] in self.cords: logger.warning( "'%s' of duplicate cord '%s'", message, data['_id']) return self.cords[data['_id']] = data['_type'] logger.debug( "opened cord '%s'", data['_id']) def sendClose(self, cordID): """""" msg = self.packageName + '-close' if cordID not in self.cords: logger.warning( "tried to close non-existant cord '%s'", cordID) return self.mcp.sendMessage(msg, { '_id': cordID}) def handleClose(self, message, data): """""" if '_id' not in data: logger.warning( "'%s' missing required key '%s'", message, '_id') return if data['_id'] not in self.cords: logger.warning( "tried to close non-existant cord '%s'", data['_id']) return del self.cords[data['_id']] def send(self, cordID, cordMsg, data=None): """""" msg = self.packageName if data is None: data = {} if '_id' not in self.cords: logger.warning( "could not send to non-existant cord '%s'", cordID) return data['_id'] = cordID data['_message'] = cordMsg self.mcp.sendMessage(msg, data) def handle(self, message, data): """""" for requiredKey in ('_id', '_message'): if requiredKey not in data: logger.warning( "'%s' missing required key '%s'", message, requiredKey) return if data['_id'] not in self.cords: logger.warning( "'%s' for non-existant cord '%s'", message, data['_id']) return cordID = data['_id'] cordMsg = data['_message'] # FIXME: maybe delete _id and _message from data before hitting the # callback, because they aren't part of the message proper? (cordCallback, cordType) = self.cords[cordID] if callable(cordCallback): cordCallback(self.mcp, cordType, cordID, cordMsg, data) # object is inhereted here so that super() will work, # because Twisted's BaseProtocol is an old-style class class MCP(basic.LineOnlyReceiver, object): """\ A line-oriented protocol, supporting out-of-band messages. """ VERSION_MIN = "1.0" VERSION_MAX = "2.1" MCP_HEADER = '#$#' MCP_ESCAPE = '#$"' AUTHKEY_CHARACTERS = string.ascii_lowercase + \ string.ascii_uppercase + \ string.digits + \ "-~`!@#$%^&()=+{}[]|';?/><.," AUTHKEY_SET = set(AUTHKEY_CHARACTERS) AUTHKEY_LEN = 16 KEY_CHARACTERS = string.ascii_lowercase + \ string.ascii_uppercase + \ string.digits + \ '-' KEY_SET = set(KEY_CHARACTERS) KEY_LEN = 6 def __init__(self, initiator=False): """\ Create a new MCP protocol. If initiator is True, proffer handshake; otherwise respond to it. This only affects the initial handshake. """ self.initiator = initiator # Which side of the conversation we are. self.version = None # The state of the connection handshake. # This will be set to the running protocol version, once established. self.authKey = '' # This connection's authentication key. # Blank until handshake complete. self.inProgress = {} # A list of multi-line messages which have not yet been terminated, # indexed by their _data-tag property. self.packagesCapable = {} # All the packages we know how to handle. self.packagesActive = {} # The packages the remote side proffered via negotiation, # which matched ones we can deal with. self.messageHandlers = {} # A dispatch table mapping messages to the package functions which # will handle them. self.sendQueue = deque() # A list of lines to transmit once the handshake has been completed. self.sendMessageQueue = deque() # A list of messages to transmit once the package negotiations have completed. self.negotiated = False # True once package negotiations have completed. # register the standard packages self.addPackage(MCPPackageNegotiate) self.addPackage(MCPPackageCord) # bootstrap support for the negotiation package self.packagesCapable[MCPPackageNegotiate.packageName].attach(None) def connectionMade(self): """Send the initiator handshake on connection.""" self._peer = self.transport.getPeer().host logger.debug("connectionMade, peer is '%s", self._peer) if self.initiator: self.sendMessage('mcp', { 'version': MCP.VERSION_MIN, 'to': MCP.VERSION_MAX}) def connectionEstablished(self): """Called when the MCP handshake has been completed.""" # send our package negotiations self.packagesCapable[MCPPackageNegotiate.packageName].proffer() # and flush our queue of pending normal data while True: try: self.sendLine(self.sendQueue.popleft()) except IndexError: break def connectionNegotiated(self): """Called when MCP package exchange has completed.""" logger.debug("connection negotiated, flushing queued messages") while True: try: (message, kvs) = self.sendMessageQueue.popleft() except IndexError: break self.sendMessage(message, kvs) def addPackage(self, packageClass, *args, **kwargs): """Register a package type as one we are capable of handling.""" if not issubclass(packageClass, MCPPackage): raise MCPError("cannot install unknown package type") pkg = packageClass(self, *args, **kwargs) self.packagesCapable[pkg.packageName] = pkg class __InProgress(object): """\ An unterminated multi-line stanza, waiting for completion. data is kept distinct from multiData to ease checking of collisions. the keys used to store data are all collapsed to lowercase. """ def __init__(self, message): self.message = message self.data = {} self.multiData = {} def setKey(self, key, data): if key in self.multiData: logger.warning( "ignoring attempt to overwrite multiline key '%s' with single value", key) return self.data[key] = data def setMultiKey(self, key, data): if key in self.data: logger.warning( "ignoring attempt to overwrite single value key '%s' with multiline datum", key) return if key not in self.multiData: self.multiData[key] = [] if data is not None: self.multiData[key].append(data) def allData(self): """Return the combined simple and multikey data.""" return dict(self.multiData, **self.data) def __multiKeyEnd(self, datatag): if datatag not in self.inProgress: logger.warning( "termination of unknown multi-line stanza '%s'", datatag) return self._dispatchMessage( self.inProgress[datatag].message, self.inProgress[datatag].allData()) del self.inProgress[datatag] def __multiKeyContinue(self, line): try: (datatag, line) = re.split(r'\s+', line, 1) except ValueError: (datatag, line) = (line, '') if datatag not in self.inProgress: logger.warning( "continuation of unknown multi-line stanza '%s'", datatag) return inProgress = self.inProgress[datatag] try: (key, line) = line.split(': ', 1) except ValueError: (key, line) = (line, '') key = key.tolower() if key in inProgress.data: logger.warning( "multi-line stanza '%s' tried to update non-multi-line key '%s'", datatag, key) return if key not in inProgress.multiData: logger.warning( "multi-line stanza '%s' tried to update untracked key '%s'", datatag, key) return inProgress.data[key].append(line) self.messageUpdate(datatag, key) def __lineParseMCP(self, line): """Process an out-of-band message.""" line = line[len(MCP.MCP_HEADER):] try: (message, line) = re.split(r'\s+', line, 1) except ValueError: (message, line) = (line, '') if message == ':': # end of multi-line stanza self.__multiKeyEnd(line) elif message == '*': # continuation of multi-line stanza self.__multiKeyContinue(line) else: # simple message # "#$#message authkey [k: v [...]]" inProgress = MCP.__InProgress(message) multiline = False if self.version: try: (authKey, line) = re.split(r'\s+', line, 1) except ValueError: (authKey, line) = (line, '') if authKey != self.authKey: logger.warning( "ignoring message with foreign key '%s'", authKey) return lexer = shlex.shlex(line, posix=True) lexer.commenters = '' lexer.quotes = '"' lexer.whitespace_split = True try: for key in lexer: # keys are case-insensitive, normalize here key = key.lower() if key[-1] != ':': logger.warning( "message '%s' could not parse key '%s'", message, key) return key = key[:-1] if key[0] not in string.ascii_lowercase: logger.warning( "message '%s' ignored due to invalid key '%s'", message, key) return if not set(key).issubset(MCP.KEY_SET): logger.warning( "message '%s' ignored due to invalid key '%s'", message, key) return try: value = next(lexer) except StopIteration: logger.warning( "message '%s' has key '%s' without value", message, key) return if key[-1] == '*': key = key[:-1] if key in inProgress.multiData or key in inProgress.data: logger.warning( "message '%s' ignoring duplicate key '%s'", message, key) continue inProgress.multiData[key] = [] multiline = True else: if key in inProgress.data or key in inProgress.multiData: logger.warning( "message '%s' ignoring duplicate key '%s'", message, key) continue inProgress.data[key] = value except ValueError: logger.warning( "message '%s' has unparsable data", message) return if multiline: if '_data-tag' not in inProgress.data: logger.warning( "ignoring message with multi-line variables but no _data-tag") return self.inProgress[inProgress.data['_data-tag']] = inProgress self.messageUpdate(inProgress.data['_data-tag'], None) else: self.__dispatchMessage(inProgress.message, inProgress.allData()) def messageUpdate(self, datatag, key): """\ Called when a multiline message has received a new line, but has not completed. Generally ignorable, but some servers awkwardly use multiline messages as continuous channels. Override this to handle such a beast. """ pass def __dispatchMessage(self, message, data): """Invoke the handler function for a message.""" logger.debug( "MCP message: %s %s", message, repr(data)) # handle handshaking messages directly if message == 'mcp': self.__handshake(message, data) else: if message in self.messageHandlers: self.messageHandlers[message](message, data) else: self.messageReceived(message, data) def __handshake(self, message, data): """Handle 'mcp' messages, which establish a connection.""" if self.version: logger.warning( "ignoring handshake message during established session") return if 'version' not in data: logger.warning( "%s did not send enough version information", "responder" if self.initiator else "initiator") return if 'to' not in data: data['to'] = data['version'] supportedVersion = versionBest( (MCP.VERSION_MIN, MCP.VERSION_MAX), (data['version'], data['to'])) if supportedVersion is None: logger.warning( "handshake failed, incompatible versions") # FIXME: maybe raise exception on this return if self.initiator: if 'authentication-key' in data: if not set(data['authentication-key']).issubset(MCP.AUTHKEY_SET): logger.warning( "responder proffered unexpected characters in authentication-key") return self.authKey = data['authentication-key'] logger.debug( "client started new session with key '%s'", self.authKey) else: logger.warning( "ignoring message '%s' before session established", message) return else: authKey = generateKey(MCP.AUTHKEY_LEN, MCP.AUTHKEY_CHARACTERS) # send before setting, as handshake message doesn't include authkey self.sendMessage('mcp', { 'authentication-key': authKey, 'version': MCP.VERSION_MIN, 'to': MCP.VERSION_MAX}) self.authKey = authKey logger.debug( "established new session (%s) with key '%s'", supportedVersion, authKey) self.version = supportedVersion self.connectionEstablished() def lineReceived(self, line): """Process a received line for MCP messages.""" if line.startswith(MCP.MCP_HEADER): self.__lineParseMCP(line) else: if line.startswith(MCP.MCP_ESCAPE): line = line[len(MCP.MCP_ESCAPE):] self.lineReceivedInband(line) def sendLine(self, line): """ Sends a line of normal data. """ if not self.initiator and self.version is None: self.sendQueue.append(line) return if line.startswith((MCP.MCP_HEADER, MCP.MCP_ESCAPE)): line = ''.join([MCP.MCP_ESCAPE, line]) super(MCP, self).sendLine(line) def sendMessage(self, message, kvs=None): """ Sends an MCP message, with data. """ # FIXME: this is janky # queue non-core messages until after package negotiation if not self.negotiated: if not message.startswith('mcp'): logger.debug( "deferred MCP-send of '%s'", message) self.sendMessageQueue.append((message, kvs)) return datatag = None msg = [] line = [MCP.MCP_HEADER, message] if self.authKey is not '': line.extend([' ', self.authKey]) if kvs is not None: for k, v in kvs.iteritems(): if isinstance(v, basestring) and '\n' not in v: line.extend([' ', k, ': "', v, '"']) else: if not datatag: datatag = generateKey(MCP.KEY_LEN, MCP.KEY_CHARACTERS) line.extend([' ', k, '*: ""']) if not isinstance(v, basestring): vLines = v else: vLines = v.split('\n') for l in vLines: msg.append(''.join([MCP.MCP_HEADER, '* ', datatag, ' ', k, ': ', l])) msg.insert(0, ''.join(line)) for m in msg: super(MCP, self).sendLine(m) logger.debug( "MCP-send: %s", m) def lineReceivedInband(self, line): """ Called when there's a line of normal data to process. Override in implementation. """ print "in: ", line return raise NotImplementedError def messageReceived(self, message, data): """ Called when there's an otherwise-unhandled MCP message. """ logger.warning( "unhandled message '%s' %s", message, repr(data)) pass if __name__ == '__main__': from twisted.internet import reactor from twisted.internet.endpoints import TCP4ClientEndpoint, connectProtocol import sys, os.path logging.basicConfig(level=logging.DEBUG) if len(sys.argv) < 3: print "Usage: %s " % (os.path.basename(sys.argv[0])) sys.exit(64) HOST = sys.argv[1] PORT = int(sys.argv[2]) def gotMCP(p): print 'got' # p.sendLine("WHO") # reactor.callLater(1, p.sendLine("QUIT")) # reactor.callLater(2, p.transport.loseConnection) print "establishing endpoing" point = TCP4ClientEndpoint(reactor, HOST, PORT) print "connecting" d = connectProtocol(point, MCP()) print "adding things" d.addCallback(gotMCP) print "running" reactor.run()