├── .gitignore ├── CHANGELOG ├── mc4p ├── plugin │ ├── __init__.py │ ├── log.py │ ├── mute.py │ ├── freecam.py │ └── dvr.py ├── __init__.py ├── util.py ├── encryption.py ├── blocks.py ├── authentication.py ├── plugins.py ├── parsing.py ├── proxy.py ├── messages.py └── debug.py ├── setup.py ├── README.md ├── test_plugins.py └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | server 4 | client 5 | jar 6 | build 7 | dist 8 | *.egg-info 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.2pre 2 | - support for protocol versions 17-21 (Up through 1.9pre5) 3 | - Windows now supported (tested with CPython 2.7) 4 | - various bug fixes 5 | 6 | 0.1pre (Initial release) 7 | - support for protocol version 17 (Minecraft Beta 1.8) 8 | - basic plugin support 9 | - linux/OS X only 10 | 11 | -------------------------------------------------------------------------------- /mc4p/plugin/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | -------------------------------------------------------------------------------- /mc4p/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import logging 22 | 23 | 24 | logger = logging.getLogger("mc4p") 25 | -------------------------------------------------------------------------------- /mc4p/plugin/log.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | # Message logging plugin, by fredreichbier 23 | 24 | 25 | from mc4p.plugins import MC4Plugin 26 | 27 | 28 | class LogPlugin(MC4Plugin): 29 | def default_handler(self, msg, source): 30 | print msg 31 | print 32 | return True 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | from sys import version_info 23 | from setuptools import setup, find_packages 24 | 25 | basename = "mc4p" 26 | version = "1.0" 27 | pyversion = "%s.%s" % (version_info.major, version_info.minor) 28 | 29 | setup( 30 | name=basename, 31 | version=version, 32 | packages=find_packages(), 33 | zip_safe=False, 34 | test_suite='test_plugins', 35 | author="Simon Marti", 36 | author_email="simon.marti@ceilingcat.ch", 37 | description="Pluggable Minecraft proxy", 38 | keywords="minecraft proxy", 39 | url="https://github.com/sadimusi/mc4p", 40 | install_requires=("gevent", "pycrypto", "requests", "certifi", "blessings") 41 | ) 42 | -------------------------------------------------------------------------------- /mc4p/plugin/mute.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | 22 | from mc4p.plugins import MC4Plugin, msghdlr 23 | 24 | class MutePlugin(MC4Plugin): 25 | """Lets the client mute players, hiding their chat messages. 26 | 27 | The client controls the plugin with chat commands: 28 | /mute NAME Hide all messages from player NAME. 29 | /unmute NAME Allow messages from player NAME. 30 | /muted Show the list of currently muted players. 31 | """ 32 | def init(self, args): 33 | self.muted_set = set() # Set of muted player names. 34 | 35 | def send_chat(self, chat_msg): 36 | """Send a chat message to the client.""" 37 | self.to_client({'msgtype': 0x03, 'chat_msg': chat_msg}) 38 | 39 | def mute(self, player_name): 40 | self.muted_set.add(player_name) 41 | self.send_chat('Muted %s' % player_name) 42 | 43 | def unmute(self, player_name): 44 | if player_name in self.muted_set: 45 | self.muted_set.remove(player_name) 46 | self.send_chat('Unmuted %s' % player_name) 47 | else: 48 | self.send_chat('%s is not muted' % player_name) 49 | 50 | def muted(self): 51 | self.send_chat('Currently muted: %s' % ', '.join(self.muted_set)) 52 | 53 | @msghdlr(0x03) 54 | def handle_chat(self, msg, source): 55 | txt = msg['chat_msg'] 56 | if source == 'client': 57 | # Handle mute commands 58 | if txt.startswith('/mute '): self.mute(txt[len('/mute '):]) 59 | elif txt.startswith('/unmute '): self.unmute(txt[len('/unmute '):]) 60 | elif txt == '/muted': self.muted() 61 | else: return True # Forward all other chat messages. 62 | 63 | return False # Drop mute plugin commands. 64 | else: 65 | # Drop messages containing the string , where NAME is a muted player name. 66 | return not any(txt.startswith('<%s>' % name) for name in self.muted_set) 67 | 68 | -------------------------------------------------------------------------------- /mc4p/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import os.path, logging, logging.config 22 | 23 | class PartialPacketException(Exception): 24 | """Thrown during parsing when not a complete packet is not available.""" 25 | pass 26 | 27 | 28 | class Stream(object): 29 | """Represent a stream of bytes.""" 30 | 31 | def __init__(self): 32 | """Initialize the stream.""" 33 | self.buf = "" 34 | self.i = 0 35 | self.tot_bytes = 0 36 | self.wasted_bytes = 0 37 | 38 | def append(self,str): 39 | """Append a string to the stream.""" 40 | self.buf += str 41 | 42 | def read(self,n): 43 | """Read n bytes, returned as a string.""" 44 | if self.i + n > len(self.buf): 45 | self.wasted_bytes += self.i 46 | self.i = 0 47 | raise PartialPacketException() 48 | str = self.buf[self.i:self.i+n] 49 | self.i += n 50 | return str 51 | 52 | def reset(self): 53 | self.i = 0 54 | 55 | def packet_finished(self): 56 | """Mark the completion of a packet, and return its bytes as a string.""" 57 | # Discard all data that was read for the previous packet, 58 | # and reset i. 59 | data = "" 60 | if self.i > 0: 61 | data = self.buf[:self.i] 62 | self.buf = self.buf[self.i:] 63 | self.tot_bytes += self.i 64 | self.i = 0 65 | return data 66 | 67 | def __len__(self): 68 | return len(self.buf) - self.i 69 | 70 | def write_default_logging_file(lpath): 71 | """Write a default logging.conf.""" 72 | contents=""" 73 | [loggers] 74 | keys=root,mc4p,plugins,parsing 75 | 76 | [handlers] 77 | keys=consoleHdlr 78 | 79 | [formatters] 80 | keys=defaultFormatter 81 | 82 | [logger_root] 83 | level=WARN 84 | handlers=consoleHdlr 85 | 86 | [logger_mc4p] 87 | handlers= 88 | qualname=mc4p 89 | 90 | [logger_plugins] 91 | handlers= 92 | qualname=plugins 93 | 94 | [logger_parsing] 95 | handlers= 96 | qualname=parsing 97 | 98 | [handler_consoleHdlr] 99 | class=StreamHandler 100 | formatter=defaultFormatter 101 | args=(sys.stdout,) 102 | 103 | [formatter_defaultFormatter] 104 | format=%(levelname)s|%(asctime)s|%(name)s - %(message)s 105 | datefmt=%H:%M:%S 106 | """ 107 | f=None 108 | try: 109 | f=open(lpath,"w") 110 | f.write(contents) 111 | finally: 112 | if f: f.close() 113 | 114 | logging_configured = False 115 | 116 | def config_logging(logfile=None): 117 | """Configure logging. Can safely be called multiple times.""" 118 | global logging_configured 119 | if not logging_configured: 120 | dir = os.path.dirname(os.path.abspath(__file__)) 121 | if not logfile: 122 | logfile = os.path.join(dir, 'logging.conf') 123 | if not os.path.exists(logfile): 124 | write_default_logging_file(logfile) 125 | logging.config.fileConfig(logfile) 126 | logging_configured = True 127 | 128 | -------------------------------------------------------------------------------- /mc4p/plugin/freecam.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | from mc4p.plugins import MC4Plugin, msghdlr 22 | 23 | class FreecamPlugin(MC4Plugin): 24 | """Lets the client move and fly around without sending the packets 25 | to the real server 26 | 27 | Commands added: 28 | /freecam - toggles freecam mode 29 | /freecam back - returns the player to the last valid position 30 | """ 31 | 32 | def init(self, args): 33 | self.freecam = False 34 | self.last_pos = None 35 | self.safe = True 36 | 37 | self.abilities = None 38 | 39 | def send_chat(self, chat_msg): 40 | self.to_client({'msgtype': 0x03, 'chat_msg': chat_msg}) 41 | 42 | def is_safe(self, current_pos, threshold=4): 43 | get_coords = lambda m: (m['x'], m['y'], m['z']) 44 | result = map(lambda x, y: abs(x - y) < threshold, 45 | get_coords(current_pos), get_coords(self.last_pos)) 46 | return not (False in result) 47 | 48 | @msghdlr(0x0a, 0x0b, 0x0c, 0x0d) 49 | def handle_position(self, msg, source): 50 | if source == 'client': 51 | if self.freecam: 52 | if msg['msgtype'] in (0x0b, 0x0d) and self.last_pos: 53 | self.safe = self.is_safe(msg) 54 | return False 55 | elif msg['msgtype'] == 0x0d: 56 | self.last_pos = msg 57 | return True 58 | 59 | @msghdlr(0xca) 60 | def handle_abilities(self, msg, source): 61 | if self.freecam and source == 'client': 62 | return False 63 | self.abilities = msg 64 | return True 65 | 66 | @msghdlr(0x03) 67 | def handle_chat(self, msg, source): 68 | if source == 'server': 69 | return True 70 | 71 | txt = msg['chat_msg'] 72 | if txt.startswith('/freecam back'): 73 | if self.last_pos: 74 | self.send_chat("Returning to last position") 75 | self.to_client(self.last_pos) 76 | self.safe = True 77 | else: 78 | self.send_chat("No saved position") 79 | return False 80 | elif txt.startswith('/freecam'): 81 | if self.freecam and not self.safe: 82 | self.send_chat("Please use '/freecam back' to go back " 83 | "to a safe position") 84 | return False 85 | 86 | self.freecam = not self.freecam 87 | 88 | if self.abilities: 89 | if self.freecam: 90 | new_abilities = self.abilities.copy() 91 | new_abilities['abilities'] = 0b0100 92 | new_abilities['flying_speed'] = 50 93 | self.to_client(new_abilities) 94 | else: 95 | # restore old ones 96 | self.to_client(self.abilities) 97 | 98 | self.send_chat("Freecam mode is now [%s]" % 99 | ('ON' if self.freecam else 'OFF')) 100 | 101 | return False 102 | return True 103 | -------------------------------------------------------------------------------- /mc4p/encryption.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software. It comes without any warranty, to 9 | # the extent permitted by applicable law. You can redistribute it 10 | # and/or modify it under the terms of the Do What The Fuck You Want 11 | # To Public License, Version 2, as published by Sam Hocevar. See 12 | # http://sam.zoy.org/wtfpl/COPYING for more details 13 | 14 | from Crypto.PublicKey import RSA 15 | from Crypto import Random 16 | from Crypto.Cipher import AES, DES 17 | from hashlib import md5 18 | from struct import unpack 19 | 20 | 21 | def decode_public_key(bytes): 22 | """Decodes a public RSA key in ASN.1 format as defined by x.509""" 23 | return RSA.importKey(bytes) 24 | 25 | 26 | def encode_public_key(key): 27 | """Encodes a public RSA key in ASN.1 format as defined by x.509""" 28 | return key.publickey().exportKey(format="DER") 29 | 30 | 31 | def generate_key_pair(): 32 | """Generates a 1024 bit RSA key pair""" 33 | return RSA.generate(1024) 34 | 35 | 36 | def generate_random_bytes(length): 37 | return Random.get_random_bytes(length) 38 | 39 | 40 | def generate_server_id(): 41 | """Generates 20 random hex characters""" 42 | return "".join("%02x" % ord(c) for c in generate_random_bytes(10)) 43 | 44 | 45 | def generate_challenge_token(): 46 | """Generates 4 random bytes""" 47 | return generate_random_bytes(4) 48 | 49 | 50 | def generate_shared_secret(): 51 | """Generates a 128 bit secret key to be used in symmetric encryption""" 52 | return generate_random_bytes(16) 53 | 54 | 55 | def encrypt_shared_secret(shared_secret, public_key): 56 | """Encrypts the PKCS#1 padded shared secret using the public RSA key""" 57 | return public_key.encrypt(_pkcs1_pad(shared_secret), 0)[0] 58 | 59 | 60 | def decrypt_shared_secret(encrypted_key, private_key): 61 | """Decrypts the PKCS#1 padded shared secret using the private RSA key""" 62 | return _pkcs1_unpad(private_key.decrypt(encrypted_key)) 63 | 64 | 65 | def encryption_for_version(version): 66 | if version <= 32: 67 | return RC4 68 | else: 69 | return AES128CFB8 70 | 71 | 72 | class RC4(object): 73 | def __init__(self, key): 74 | self.key = key 75 | x = 0 76 | self.box = box = range(256) 77 | for i in range(256): 78 | x = (x + box[i] + ord(key[i % len(key)])) % 256 79 | box[i], box[x] = box[x], box[i] 80 | self.x = self.y = 0 81 | 82 | def crypt(self, data): 83 | out = "" 84 | box = self.box 85 | for char in data: 86 | self.x = x = (self.x + 1) % 256 87 | self.y = y = (self.y + box[self.x]) % 256 88 | box[x], box[y] = box[y], box[x] 89 | out += chr(ord(char) ^ box[(box[x] + box[y]) % 256]) 90 | return out 91 | 92 | decrypt = encrypt = crypt 93 | 94 | 95 | def AES128CFB8(shared_secret): 96 | """Creates a AES128 stream cipher using cfb8 mode""" 97 | return AES.new(shared_secret, AES.MODE_CFB, shared_secret) 98 | 99 | 100 | def _pkcs1_unpad(bytes): 101 | pos = bytes.find('\x00') 102 | if pos > 0: 103 | return bytes[pos+1:] 104 | 105 | 106 | def _pkcs1_pad(bytes): 107 | assert len(bytes) < 117 108 | padding = "" 109 | while len(padding) < 125-len(bytes): 110 | byte = Random.get_random_bytes(1) 111 | if byte != '\x00': 112 | padding += byte 113 | return '\x00\x02%s\x00%s' % (padding, bytes) 114 | 115 | 116 | class PBEWithMD5AndDES(object): 117 | """PBES1 implementation according to RFC 2898 section 6.1""" 118 | SALT = '\x0c\x9d\x4a\xe4\x1e\x83\x15\xfc' 119 | COUNT = 5 120 | 121 | def __init__(self, key): 122 | key = self._generate_key(key, self.SALT, self.COUNT, 16) 123 | self.key = key[:8] 124 | self.iv = key[8:16] 125 | 126 | def encrypt(self, plaintext): 127 | padding = 8 - len(plaintext) % 8 128 | plaintext += chr(padding)*padding 129 | return self._cipher().encrypt(plaintext) 130 | 131 | def decrypt(self, ciphertext): 132 | plaintext = self._cipher().decrypt(ciphertext) 133 | return plaintext[:-ord(plaintext[-1])] 134 | 135 | def _cipher(self): 136 | return DES.new(self.key, DES.MODE_CBC, self.iv) 137 | 138 | def _generate_key(self, key, salt, count, length): 139 | key = key + salt 140 | for i in range(count): 141 | key = md5(key).digest() 142 | return key[:length] 143 | 144 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | ## What is mc4p? 3 | 4 | mc4p (short for Minecraft Portable Protocol-Parsing Proxy) is a Minecraft proxy 5 | server. With mc4p, you can create programs (mc4p plugins) that examine 6 | and modify messages sent between the Minecraft client and server without 7 | writing any multi-threaded or network-related code. 8 | 9 | mc4p is a fork of [mmgcill](https://github.com/mmcgill)'s [mc3p](https://github.com/mmcgill/mc3p). 10 | 11 | ### Installing from source 12 | 13 | To install from source, just clone the GitHub repository. You can then run 14 | mc4p directly from the top-level directory of the repository, or install 15 | it in 'development' mode with 16 | 17 | python setup.py develop 18 | 19 | If mc4p is installed in 'development' mode, you can uninstall it with 20 | 21 | python setup.py develop --uninstall 22 | 23 | ### Dependencies 24 | 25 | * [Python 2.7](http://www.python.org/download/releases/2.7.3/) 26 | * [setuptools](http://pypi.python.org/pypi/setuptools) 27 | 28 | #### Linux / Mac OS X 29 | * [libevent](http://libevent.org/) 30 | 31 | #### Windows 32 | * [gevent](https://github.com/SiteSupport/gevent/downloads) 33 | * pycrypto ([32bit](http://www.dragffy.com/wp-content/uploads/2011/11/pycrypto-2.4.1.win32-py2.7.exe) | [64bit](http://dl.dropbox.com/u/90067063/pycrypto-2.6.win-amd64-py2.7.exe)) 34 | 35 | ## Running mc4p. 36 | 37 | To start an mc4p server that listens on port 25566 and forwards connections 38 | to a Minecraft server: 39 | 40 | $ python -m mc4p.proxy -p 25566 41 | 42 | Within your Minecraft client, you can then connect to through 43 | mc4p using the server address 'localhost:25566'. However, to do anything useful 44 | you must enable some plugins. 45 | 46 | ## Using mc4p plugins. 47 | 48 | An mc4p plugin has complete control over all the messages that pass between 49 | the Minecraft client and server. By manipulating messages sent between client 50 | and server, you can add useful functionality without modifying client or server 51 | code. 52 | 53 | To run a plugin, you must enable it with the --plugin option when you start mc4p. 54 | All enabled plugins are initialized after a client successfully connects to a server. 55 | For example, to run the mute example plugin that comes with mc4p: 56 | 57 | $ python -m mc4p.proxy --plugin 'mc4p.plugin.mute' 58 | 59 | Some plugins accept arguments that modify their behavior. To pass arguments 60 | to a plugin, enclose them in parentheses following the plugin's name, like so: 61 | --plugin '()'. Be sure to use quotes, or escape the parentheses 62 | as required by your shell. 63 | 64 | $ python -m mc4p.proxy --plugin '()' 65 | 66 | ## A Plugin Example: mute 67 | 68 | The 'mute' plugin is provided as a simple example of mc4p's flexibility. 69 | This plugin allows a player to mute chat messages from selected players on a 70 | server. It requires no modification to either the Minecraft client or server. 71 | 72 | Give it a try: start mc4p with the 'mute' plugin enabled: 73 | 74 | $ python -m mc4p.proxy --plugin 'mc4p.plugin.mute' your.favorite.server.com 75 | 76 | You can now mute a player by typing '/mute NAME' in chat, 77 | and unmute them with '/unmute NAME'. You can display muted players with '/muted'. 78 | 79 | The plugin works by intercepting all Minecraft chat messages, and silently 80 | discarding those sent by muted players. 81 | 82 | Now take a look at the source code for the 'mute' plugin: 83 | 84 | from mc4p.plugins import mc4plugin, msghdlr 85 | 86 | class MutePlugin(mc4plugin): 87 | """Lets the client mute players, hiding their chat messages. 88 | 89 | The client controls the plugin with chat commands: 90 | /mute NAME Hide all messages from player NAME. 91 | /unmute NAME Allow messages from player NAME. 92 | /muted Show the list of currently muted players. 93 | """ 94 | def init(self, args): 95 | self.muted_set = set() # Set of muted player names. 96 | 97 | def send_chat(self, chat_msg): 98 | """Send a chat message to the client.""" 99 | self.to_client({'msgtype': 0x03, 'chat_msg': chat_msg}) 100 | 101 | def mute(self, player_name): 102 | self.muted_set.add(player_name) 103 | self.send_chat('Muted %s' % player_name) 104 | 105 | def unmute(self, player_name): 106 | if player_name in self.muted_set: 107 | self.muted_set.remove(player_name) 108 | self.send_chat('Unmuted %s' % player_name) 109 | else: 110 | self.send_chat('%s is not muted' % player_name) 111 | 112 | def muted(self): 113 | self.send_chat('Currently muted: %s' % ', '.join(self.muted_set)) 114 | 115 | @msghdlr(0x03) 116 | def handle_chat(self, msg, source): 117 | txt = msg['chat_msg'] 118 | if source == 'client': 119 | # Handle mute commands 120 | if txt.startswith('/mute '): self.mute(txt[len('/mute '):]) 121 | elif txt.startswith('/unmute '): self.unmute(txt[len('/unmute '):]) 122 | elif txt == '/muted': self.muted() 123 | else: return True # Forward all other chat messages. 124 | 125 | return False # Drop mute plugin commands. 126 | else: 127 | # Drop messages containing the string , where NAME is a muted player name. 128 | return not any(txt.startswith('<%s>' % name) for name in self.muted_set) 129 | 130 | Every mc4p plugin is a Python module that contains a single *plugin class*, a 131 | subclass of mc4plugin. A plugin must contain *exactly* one plugin class; 132 | mc4p will print an error if multiple sub-classes of mc4plugin are found. 133 | 134 | Once a client successfully connects to a server through the proxy, mc4p 135 | creates an instance of the plugin class of each enabled plugin, and calls its 136 | 'init' method. Plugin classes should override 'init' to perform plugin-specific 137 | set-up. If the plugin was enabled with an argument string, that string is passed in 138 | the 'args' parameter; otherwise, 'args' is None. 139 | 140 | A plugin class registers a *message handler* for every message type it wishes 141 | to receive. To register a method of a plugin class as a message handler, decorate 142 | it with '@msghdlr'. The '@msghdlr' decorator takes one or more message types 143 | as arguments. Each message handler should take two arguments (in addition to 'self'): 144 | 145 | * 'msg', a dictionary representing the message, and 146 | * 'source', which indicates the sender of the message; source is either 147 | 'client' or 'server'. 148 | 149 | The 'msg' dictionary always maps the key 'msgtype' to the message's type, a 150 | number between 0 and 0xFF. If 151 | your message handler is registered for multiple types, you can determine the type 152 | of a given message by checking `msg['msgtype']`. The other key-value pairs 153 | in the 'msg' dictionary depend on the specific message type. See 154 | [messages.py](https://github.com/mmcgill/mc4p/blob/master/mc4p/messages.py) 155 | for a definition of the keys associated with each message type. 156 | 157 | A message handler returns a boolean value indicating whether the message should 158 | be forwarded to its destination. A return value of True forwards the message, 159 | while a return value of False silently drops it. The message handler may also 160 | modify the message by changing the values of the 'msg' dictionary, and 161 | returning True. 162 | 163 | The mute plugin registers the 'handle_chat' method as a message handler for 164 | messages of type '0x03', which represent chat messages. If the chat message 165 | is sent from the client, we check to see if it is a command to the mute plugin. 166 | If so, then we process it, and drop it by returning False. If not, we forward it 167 | by returning True. If the chat message is from the server, then we forward it 168 | if it was not sent by a currently blocked player. 169 | 170 | Along with modifying or dropping messages, a plugin can create new messages 171 | by passing a 'msg' dictionary with a 'msgtype' and all relevant key-value pairs 172 | to the 'to_client' or 'to_server methods, which are defined in the mc4plugin class. 173 | 174 | The mute plugin uses the 'to_client' method to inject chat messages that indicate 175 | the result of each command issued by the user. Note that since these messages 176 | are sent to the client, and not the server, they are not visible to any other 177 | user on the server. 178 | 179 | -------------------------------------------------------------------------------- /mc4p/blocks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | """Data and helper functions pertaining to Minecraft blocks.""" 22 | 23 | AIR_BLOCK = 0x00 24 | STONE_BLOCK = 0x01 25 | GRASS_BLOCK = 0x02 26 | DIRT_BLOCK = 0x03 27 | COBBLE_BLOCK = 0x04 28 | PLANK_BLOCK = 0x05 29 | SAPLING_BLOCK = 0x06 30 | BEDROCK_BLOCK = 0X07 31 | WATER_BLOCK = 0X08 32 | STILL_WATER_BLOCK = 0x09 33 | LAVA_BLOCK = 0X0a 34 | STILL_LAVA_BLOCK = 0x0b 35 | SAND_BLOCK = 0x0c 36 | GRAVEL_BLOCK = 0x0d 37 | GOLD_ORE_BLOCK = 0x0e 38 | IRON_ORE_BLOCK = 0x0f 39 | COAL_ORE_BLOCK = 0x10 40 | WOOD_BLOCK = 0x11 41 | LEAF_BLOCK = 0x12 42 | SPONGE_BLOCK = 0x13 43 | GLASS_BLOCK = 0x14 44 | LAPIS_ORE_BLOCK = 0x15 45 | LAPIS_BLOCK = 0x16 46 | DISPENSER_BLOCK = 0x17 47 | SANDSTONE_BLOCK = 0x18 48 | NOTE_BLOCK = 0x19 49 | BED_BLOCK = 0x1a 50 | POWERED_RAIL_BLOCK = 0x1b 51 | DETECTOR_RAIL_BLOCK = 0x1c 52 | STICKY_PISTON_BLOCK = 0x1d 53 | COBWEB_BLOCK = 0x1e 54 | TALL_GRASS_BLOCK = 0x1f 55 | DEAD_BUSH_BLOCK = 0x20 56 | PISTON_BLOCK = 0x21 57 | PISTON_ARM_BLOCK = 0x22 58 | WOOL_BLOCK = 0x23 59 | MOVED_BLOCK = 0x24 60 | DANDELION_BLOCK = 0x25 61 | ROSE_BLOCK = 0x26 62 | BROWN_SHROOM_BLOCK = 0x27 63 | RED_SHROOM_BLOCK = 0x28 64 | GOLD_BLOCK = 0x29 65 | IRON_BLOCK = 0x2a 66 | DOUBLE_SLAB_BLOCK = 0x2b 67 | SLAB_BLOCK = 0x2c 68 | BRICK_BLOCK = 0x2d 69 | TNT_BLOCK = 0x2e 70 | BOOKSHELF_BLOCK = 0x2f 71 | MOSSY_COBBLE_BLOCK = 0x30 72 | OBSIDIAN_BLOCK = 0x31 73 | TORCH_BLOCK = 0x32 74 | FIRE_BLOCK = 0x33 75 | SPAWNER_BLOCK = 0x34 76 | PLANK_STAIRS_BLOCK = 0x35 77 | CHEST_BLOCK = 0x36 78 | WIRE_BLOCK = 0x37 79 | DIAMOND_ORE_BLOCK = 0x38 80 | DIAMOND_BLOCK = 0x39 81 | CRAFTBENCH_BLOCK = 0x3a 82 | SEEDS_BLOCK = 0x3b 83 | FARMLAND_BLOCK = 0x3c 84 | FURNACE_BLOCK = 0x3d 85 | LIT_FURNACE_BLOCK = 0x3e 86 | SIGN_BLOCK = 0x3f 87 | WOOD_DOOR_BLOCK = 0x40 88 | LADDER_BLOCK = 0x41 89 | RAIL_BLOCK = 0x42 90 | COBBLE_STAIRS_BLOCK = 0x43 91 | WALL_SIGN_BLOCK = 0x44 92 | LEVER_BLOCK = 0x45 93 | STONE_PLATE_BLOCK = 0x46 94 | IRON_DOOR_BLOCK = 0x47 95 | WOOD_PLATE_BLOCK = 0x48 96 | REDSTONE_ORE_BLOCK = 0x49 97 | LIT_REDSTONE_ORE_BLOCK = 0x4a 98 | REDSTONE_TORCH_BLOCK = 0x4b 99 | LIT_REDSTONE_TORCH_BLOCK = 0x4c 100 | STONE_BUTTON_BLOCK = 0x4d 101 | SNOW_COVER_BLOCK = 0x4e 102 | ICE_BLOCK = 0x4f 103 | SNOW_BLOCK = 0x50 104 | CACTUS_BLOCK = 0x51 105 | CLAY_BLOCK = 0x52 106 | SUGAR_CANE_BLOCK = 0x53 107 | JUKEBOX_BLOCK = 0x54 108 | FENCE_BLOCK = 0x55 109 | PUMPKIN_BLOCK = 0x56 110 | NETHERRACK_BLOCK = 0x57 111 | SOULSAND_BLOCK = 0x58 112 | GLOWSTONE_BLOCK = 0x59 113 | PORTAL_BLOCK = 0x5a 114 | JACKOLANTERN_BLOCK = 0x5b 115 | CAKE_BLOCK = 0x5c 116 | REPEATER_BLOCK = 0x5d 117 | LIT_REPEATER_BLOCK = 0x5e 118 | LOCKED_CHEST_BLOCK = 0x5f 119 | TRAPDOOR_BLOCK = 0x60 120 | SILVERFISH_BLOCK = 0x61 121 | STONE_BRICK_BLOCK = 0x62 122 | BIG_BROWN_SHROOM_BLOCK = 0x63 123 | BIG_RED_SHROOM_BLOCK = 0x64 124 | IRON_BARS_BLOCK = 0x65 125 | GLASS_PANE_BLOCK = 0x66 126 | MELON_BLOCK = 0x67 127 | PUMPKIN_STEM_BLOCK = 0x68 128 | MELON_VINE_BLOCK = 0x69 129 | VINES_BLOCK = 0x6a 130 | FENCE_GATE_BLOCK = 0x6b 131 | BRICK_STAIRS_BLOCK = 0x6c 132 | STONE_BRICK_STAIRS_BLOCK = 0x6d 133 | MYCELIUM_BLOCK = 0x6e 134 | LILYPAD_BLOCK = 0x6f 135 | NETHER_BRICK_BLOCK = 0x70 136 | NETHER_BRICK_STAIRS_BLOCK = 0x71 137 | NETHER_WART_BLOCK = 0x72 138 | ENCHANTING_TABLE_BLOCK = 0x73 139 | BREWING_STAND_BLOCK = 0x74 140 | CAULDRON_BLOCK = 0x75 141 | AIR_PORTAL_BLOCK = 0x76 142 | AIR_PORTAL_FRAME_BLOCK = 0x77 143 | 144 | def tile_offset(row, col): 145 | return (row, col) 146 | 147 | TILE_OFFSETS = { 148 | STONE_BLOCK: tile_offset(0, 1), 149 | GRASS_BLOCK: tile_offset(0, 0), 150 | DIRT_BLOCK: tile_offset(0, 2), 151 | COBBLE_BLOCK: tile_offset(1, 0), 152 | PLANK_BLOCK: tile_offset(0, 4), 153 | BEDROCK_BLOCK: tile_offset(1, 1), 154 | WATER_BLOCK: tile_offset(13, 15), 155 | STILL_WATER_BLOCK: tile_offset(13, 15), 156 | LAVA_BLOCK: tile_offset(15, 15), 157 | STILL_LAVA_BLOCK: tile_offset(15, 15), 158 | SAND_BLOCK: tile_offset(1, 2), 159 | GRAVEL_BLOCK: tile_offset(1, 3), 160 | GOLD_ORE_BLOCK: tile_offset(2, 0), 161 | IRON_ORE_BLOCK: tile_offset(2, 1), 162 | COAL_ORE_BLOCK: tile_offset(2, 2), 163 | WOOD_BLOCK: tile_offset(1, 5), 164 | SPONGE_BLOCK: tile_offset(3, 0), 165 | GLASS_BLOCK: tile_offset(3, 1), 166 | LAPIS_ORE_BLOCK: tile_offset(10, 0), 167 | LAPIS_BLOCK: tile_offset(9, 0), 168 | DISPENSER_BLOCK: tile_offset(3, 14), 169 | SANDSTONE_BLOCK: tile_offset(11, 0), 170 | NOTE_BLOCK: tile_offset(4, 10), 171 | STICKY_PISTON_BLOCK: tile_offset(6, 12), 172 | PISTON_BLOCK: tile_offset(6, 12), 173 | WOOL_BLOCK: tile_offset(4, 0), 174 | GOLD_BLOCK: tile_offset(1, 7), 175 | IRON_BLOCK: tile_offset(1, 6), 176 | DOUBLE_SLAB_BLOCK: tile_offset(0, 6), 177 | SLAB_BLOCK: tile_offset(0, 6), 178 | BRICK_BLOCK: tile_offset(0, 7), 179 | TNT_BLOCK: tile_offset(0, 9), 180 | BOOKSHELF_BLOCK: tile_offset(0, 4), 181 | MOSSY_COBBLE_BLOCK: tile_offset(2, 4), 182 | OBSIDIAN_BLOCK: tile_offset(2, 5), 183 | SPAWNER_BLOCK: tile_offset(4, 1), 184 | PLANK_STAIRS_BLOCK: tile_offset(0, 4), 185 | CHEST_BLOCK: tile_offset(1, 9), 186 | DIAMOND_ORE_BLOCK: tile_offset(3, 2), 187 | DIAMOND_BLOCK: tile_offset(1, 8), 188 | CRAFTBENCH_BLOCK: tile_offset(2, 11), 189 | FARMLAND_BLOCK: tile_offset(5, 7), 190 | FURNACE_BLOCK: tile_offset(3, 14), 191 | LIT_FURNACE_BLOCK: tile_offset(3, 14), 192 | COBBLE_STAIRS_BLOCK: tile_offset(1, 0), 193 | REDSTONE_ORE_BLOCK: tile_offset(3, 3), 194 | LIT_REDSTONE_ORE_BLOCK: tile_offset(3, 3), 195 | ICE_BLOCK: tile_offset(4, 3), 196 | SNOW_BLOCK: tile_offset(4, 2), 197 | CACTUS_BLOCK: tile_offset(4, 5), 198 | CLAY_BLOCK: tile_offset(4, 8), 199 | JUKEBOX_BLOCK: tile_offset(4, 11), 200 | PUMPKIN_BLOCK: tile_offset(6, 6), 201 | NETHERRACK_BLOCK: tile_offset(6, 7), 202 | SOULSAND_BLOCK: tile_offset(6, 8), 203 | GLOWSTONE_BLOCK: tile_offset(6, 9), 204 | JACKOLANTERN_BLOCK: tile_offset(6, 6), 205 | LOCKED_CHEST_BLOCK: tile_offset(1, 9), 206 | SILVERFISH_BLOCK: tile_offset(0, 1), 207 | #STONE_BRICK_BLOCK: 208 | BIG_BROWN_SHROOM_BLOCK: tile_offset(7, 14), 209 | BIG_RED_SHROOM_BLOCK: tile_offset(7, 13), 210 | MELON_BLOCK: tile_offset(8, 9), 211 | BRICK_STAIRS_BLOCK: tile_offset(0, 7), 212 | #STONE_BRICK_STAIRS_BLOCK: 213 | #MYCELIUM_BLOCK: 214 | #NETHER_BRICK_BLOCK: 215 | #NETHER_BRICK_STAIRS_BLOCK: 216 | } 217 | 218 | 219 | SOLID_BLOCK_TYPES = set([ 220 | STONE_BLOCK, GRASS_BLOCK, DIRT_BLOCK, COBBLE_BLOCK, PLANK_BLOCK, 221 | BEDROCK_BLOCK, WATER_BLOCK, STILL_WATER_BLOCK, LAVA_BLOCK, 222 | STILL_LAVA_BLOCK, SAND_BLOCK, GRAVEL_BLOCK, GOLD_ORE_BLOCK, 223 | IRON_ORE_BLOCK, COAL_ORE_BLOCK, WOOD_BLOCK, SPONGE_BLOCK, 224 | LAPIS_ORE_BLOCK, LAPIS_BLOCK, DISPENSER_BLOCK, SANDSTONE_BLOCK, 225 | NOTE_BLOCK, STICKY_PISTON_BLOCK, PISTON_BLOCK, WOOL_BLOCK, MOVED_BLOCK, 226 | GOLD_BLOCK, IRON_BLOCK, DOUBLE_SLAB_BLOCK, SLAB_BLOCK, BRICK_BLOCK, 227 | TNT_BLOCK, BOOKSHELF_BLOCK, MOSSY_COBBLE_BLOCK, OBSIDIAN_BLOCK, 228 | SPAWNER_BLOCK, PLANK_STAIRS_BLOCK, CHEST_BLOCK, DIAMOND_ORE_BLOCK, 229 | DIAMOND_BLOCK, CRAFTBENCH_BLOCK, FARMLAND_BLOCK, FURNACE_BLOCK, 230 | LIT_FURNACE_BLOCK, COBBLE_STAIRS_BLOCK, REDSTONE_ORE_BLOCK, 231 | LIT_REDSTONE_ORE_BLOCK, ICE_BLOCK, SNOW_BLOCK, CACTUS_BLOCK, CLAY_BLOCK, 232 | JUKEBOX_BLOCK, PUMPKIN_BLOCK, NETHERRACK_BLOCK, SOULSAND_BLOCK, 233 | GLOWSTONE_BLOCK, JACKOLANTERN_BLOCK, LOCKED_CHEST_BLOCK, 234 | SILVERFISH_BLOCK, STONE_BRICK_BLOCK, BIG_BROWN_SHROOM_BLOCK, 235 | BIG_RED_SHROOM_BLOCK, MELON_BLOCK, BRICK_STAIRS_BLOCK, 236 | STONE_BRICK_STAIRS_BLOCK, MYCELIUM_BLOCK, NETHER_BRICK_BLOCK, 237 | NETHER_BRICK_STAIRS_BLOCK 238 | ]) 239 | 240 | def is_solid(blocktype): 241 | return blocktype in SOLID_BLOCK_TYPES 242 | 243 | -------------------------------------------------------------------------------- /test_plugins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import sys, unittest, shutil, tempfile, os, os.path, logging, imp 22 | 23 | from mc4p.plugins import PluginConfig, PluginManager, MC4Plugin, msghdlr 24 | 25 | MOCK_PLUGIN_CODE = """ 26 | from mc4p.plugins import MC4Plugin, msghdlr 27 | 28 | print 'Initializing mockplugin.py' 29 | instances = [] 30 | fail_on_init = False 31 | fail_on_destroy = False 32 | 33 | class MockPlugin(MC4Plugin): 34 | def __init__(self, *args, **kargs): 35 | super(MockPlugin, self).__init__(*args, **kargs) 36 | global instances 37 | instances.append(self) 38 | self.initialized = False 39 | self.destroyed = False 40 | self.drop_next_msg = False 41 | self.last_msg = None 42 | 43 | def init(self, args): 44 | self.initialized = True 45 | self.destroyed = False 46 | if fail_on_init: 47 | raise Exception('Failure!') 48 | 49 | def destroy(self): 50 | self.destroyed = True 51 | if fail_on_destroy: 52 | raise Exception('Failure!') 53 | 54 | @msghdlr(0x03) 55 | def handle_chat(self, msg, dest): 56 | self.last_msg = msg 57 | if self.drop_next_msg: 58 | self.drop_next_msg = False 59 | return False 60 | else: 61 | return True 62 | """ 63 | 64 | class MockMinecraftProxy(object): 65 | pass 66 | 67 | def load_source(name, path): 68 | """Replacement for __import__/imp.load_source(). 69 | 70 | When loading 'foo.py', imp.load_source() uses a pre-compiled 71 | file ('foo.pyc' or 'foo.pyo') if its timestamp is not older than 72 | that of 'foo.py'. Unfortunately, the timestamps have a resolution 73 | of seconds on most platforms, so updates made to 'foo.py' within 74 | a second of the imp.load_source() call may or may not be reflected 75 | in the loaded module -- the behavior is non-deterministic. 76 | 77 | This load_source() replacement deletes a pre-compiled 78 | file before calling imp.load_source() if the pre-compiled file's 79 | timestamp is less than or equal to the timestamp of path. 80 | """ 81 | if os.path.exists(path): 82 | for ending in ('c', 'o'): 83 | compiled_path = path+ending 84 | if os.path.exists(compiled_path) and \ 85 | os.path.getmtime(compiled_path) <= os.path.getmtime(path): 86 | os.unlink(compiled_path) 87 | mod = __import__(name) 88 | for p in name.split('.')[1:]: 89 | mod = getattr(mod, p) 90 | return reload(mod) 91 | 92 | class TestPluginManager(unittest.TestCase): 93 | 94 | @classmethod 95 | def setUpClass(cls): 96 | cls.pdir = tempfile.mkdtemp() 97 | cls.pkgdir = tempfile.mkdtemp(dir=cls.pdir) 98 | with open(os.path.join(cls.pkgdir, '__init__.py'), 'w') as f: 99 | f.write('') 100 | sys.path.append(cls.pdir) 101 | 102 | @classmethod 103 | def tearDownClass(cls): 104 | sys.path.pop() 105 | shutil.rmtree(cls.pdir) 106 | 107 | def _write_and_load(self, name, content): 108 | pfile = os.path.join(*name.split('.')) 109 | pfile = os.path.join(self.pdir, pfile+'.py') 110 | print 'loading %s as %s' % (pfile, name) 111 | with open(pfile, 'w') as f: 112 | f.write(content) 113 | mod = load_source(name, pfile) 114 | return mod 115 | 116 | def setUp(self): 117 | self.pdir = self.__class__.pdir 118 | self.cli_proxy, self.srv_proxy = MockMinecraftProxy(), MockMinecraftProxy() 119 | 120 | def tearDown(self): 121 | if hasattr(self,'pmgr') and self.pmgr != None: 122 | try: self.pmgr.destroy() 123 | except Exception as e: 124 | import traceback 125 | traceback.print_exc() 126 | 127 | def testInstantiation(self): 128 | mockplugin = self._write_and_load('mockplugin', MOCK_PLUGIN_CODE) 129 | pcfg = PluginConfig().add('mockplugin', 'p1').add('mockplugin', 'p2') 130 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 131 | self.pmgr._load_plugins() 132 | self.pmgr._instantiate_all() 133 | self.assertEqual(2, len(mockplugin.instances)) 134 | self.assertTrue(mockplugin.instances[0].initialized) 135 | self.assertTrue(mockplugin.instances[1].initialized) 136 | 137 | def testDefaultPluginIds(self): 138 | pcfg = PluginConfig().add('mockplugin') 139 | self.assertEqual('mockplugin', pcfg.ids[0]) 140 | 141 | pcfg.add('mockplugin') 142 | pcfg.add('mockplugin') 143 | self.assertEqual('mockplugin1', pcfg.ids[1]) 144 | self.assertEqual('mockplugin2', pcfg.ids[2]) 145 | 146 | def testEmptyPluginConfig(self): 147 | mockplugin = self._write_and_load('mockplugin', MOCK_PLUGIN_CODE) 148 | pcfg = PluginConfig() 149 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 150 | self.pmgr._load_plugins() 151 | self.pmgr._instantiate_all() 152 | self.assertEqual(0, len(mockplugin.instances)) 153 | 154 | def testMissingPluginClass(self): 155 | self._write_and_load('empty', 'from mc4p.plugins import MC4Plugin\n') 156 | pcfg = PluginConfig().add('empty', 'p') 157 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 158 | self.pmgr._load_plugins() 159 | # Should log an error, but not raise an exception. 160 | self.pmgr._instantiate_all() 161 | 162 | def testMultiplePluginClasses(self): 163 | code = MOCK_PLUGIN_CODE + "class AnotherPlugin(MC4Plugin): pass" 164 | mockplugin = self._write_and_load('mockplugin', code) 165 | pcfg = PluginConfig().add('mockplugin', 'p') 166 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 167 | self.pmgr._load_plugins() 168 | self.pmgr._instantiate_all() 169 | self.assertEqual(0, len(mockplugin.instances)) 170 | 171 | handshake_msg1 = {'msgtype':0x01, 'proto_version': 21, 'username': 'foo', 172 | 'nu1': 0, 'nu2': 0, 'nu3': 0, 'nu4': 0, 'nu5': 0, 'nu6': 0} 173 | handshake_msg2 = {'msgtype':0x01, 'eid': 1, 'reserved': '', 174 | 'map_seed': 42, 'server_mode': 0, 'dimension': 0, 175 | 'difficulty': 2, 'world_height': 128, 'max_players': 16} 176 | 177 | def testLoadingPluginInPackage(self): 178 | pkgname = os.path.basename(self.__class__.pkgdir) 179 | mockplugin = self._write_and_load(pkgname+'.mockplugin', MOCK_PLUGIN_CODE) 180 | pcfg = PluginConfig().add('%s.mockplugin' % pkgname, 'p1') 181 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 182 | self.pmgr._load_plugins() 183 | self.pmgr._instantiate_all() 184 | self.assertEqual(1, len(mockplugin.instances)) 185 | self.assertTrue(mockplugin.instances[0].initialized) 186 | 187 | def testInstantiationAfterSuccessfulHandshake(self): 188 | mockplugin = self._write_and_load('mockplugin', MOCK_PLUGIN_CODE) 189 | pcfg = PluginConfig().add('mockplugin', 'p1').add('mockplugin', 'p2') 190 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 191 | self.assertEqual(0, len(mockplugin.instances)) 192 | 193 | self.pmgr.filter(self.__class__.handshake_msg1, 'client') 194 | self.pmgr.filter(self.__class__.handshake_msg2, 'server') 195 | self.assertEqual(2, len(mockplugin.instances)) 196 | 197 | def testMessageHandlerRegistration(self): 198 | class A(MC4Plugin): 199 | @msghdlr(0x01, 0x02, 0x03) 200 | def hdlr1(self, msg, dir): pass 201 | @msghdlr(0x04) 202 | def hdlr2(self, msg, dir): pass 203 | a = A(21, None, None) 204 | hdlrs = getattr(a, '_MC4Plugin__hdlrs') 205 | for msgtype in (0x01, 0x02, 0x03, 0x04): 206 | self.assertTrue(msgtype in hdlrs) 207 | self.assertEquals('hdlr1', hdlrs[0x01].__name__) 208 | self.assertEquals('hdlr2', hdlrs[0x04].__name__) 209 | 210 | def testMessageHandlerFiltering(self): 211 | mockplugin = self._write_and_load('mockplugin', MOCK_PLUGIN_CODE) 212 | pcfg = PluginConfig().add('mockplugin', 'p1').add('mockplugin', 'p2') 213 | self.pmgr = PluginManager(pcfg, self.cli_proxy, self.srv_proxy) 214 | self.pmgr.filter(self.__class__.handshake_msg1, 'client') 215 | self.pmgr.filter(self.__class__.handshake_msg2, 'server') 216 | p1 = mockplugin.instances[0] 217 | p2 = mockplugin.instances[1] 218 | msg = {'msgtype': 0x03, 'chat_msg': 'foo!'} 219 | 220 | self.assertTrue(self.pmgr.filter(msg, 'client')) 221 | self.assertEquals(msg, p1.last_msg) 222 | self.assertEquals(msg, p2.last_msg) 223 | 224 | p1.drop_next_msg = True 225 | self.assertFalse(self.pmgr.filter({'msgtype': 0x03, 'chat_msg': 'bar!'}, 'server')) 226 | self.assertEquals(msg, p2.last_msg) 227 | 228 | p1.drop_next_msg = True 229 | self.assertTrue(self.pmgr.filter({'msgtype': 0x04, 'time': 42}, 'client')) 230 | 231 | if __name__ == "__main__": 232 | logging.basicConfig(level=logging.INFO) 233 | unittest.main() 234 | 235 | -------------------------------------------------------------------------------- /mc4p/authentication.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software. It comes without any warranty, to 9 | # the extent permitted by applicable law. You can redistribute it 10 | # and/or modify it under the terms of the Do What The Fuck You Want 11 | # To Public License, Version 2, as published by Sam Hocevar. See 12 | # http://sam.zoy.org/wtfpl/COPYING for more details 13 | 14 | import os 15 | import sys 16 | import json 17 | import time 18 | import struct 19 | import hashlib 20 | 21 | import requests 22 | 23 | import encryption 24 | 25 | 26 | class Authenticator(object): 27 | SESSION_URL = 'http://session.minecraft.net/game/joinserver.jsp' 28 | CHECK_SESSION_URL = 'http://session.minecraft.net/game/checkserver.jsp' 29 | VALID_TIME = 60 * 60 30 | 31 | username = None 32 | session_id = None 33 | display_name = None 34 | __last_auth_time = 0 35 | 36 | def authenticate(self): 37 | if self.__last_auth_time + self.VALID_TIME < time.time(): 38 | self._authenticate() 39 | self.__last_auth_time = time.time() 40 | 41 | def valid(self): 42 | try: 43 | self.authenticate() 44 | except: 45 | return False 46 | else: 47 | return True 48 | 49 | def _authenticate(self): 50 | raise NotImplementedError 51 | 52 | def join_server(self, server_id, shared_secret, public_key): 53 | self.authenticate() 54 | requests.get(self.SESSION_URL, 55 | params={'user': self.display_name, 56 | 'sessionId': self.session_id, 57 | 'serverId': self.__login_hash(server_id, 58 | shared_secret, 59 | public_key)}) 60 | 61 | @classmethod 62 | def check_player(cls, username, server_id, shared_secret, public_key): 63 | """Checks if a user is allowed to join the server.""" 64 | return cls.__check_player(username, cls.__login_hash(server_id, 65 | shared_secret, 66 | public_key)) 67 | 68 | @staticmethod 69 | def _minecraft_folder(): 70 | """Finds the folder minecraft stores the account credentials in. 71 | 72 | Copyright (c) 2010 David Rio Vierra 73 | 74 | Permission to use, copy, modify, and/or distribute this software for 75 | any purpose with or without fee is hereby granted, provided that the 76 | above copyright notice and this permission notice appear in all copies. 77 | """ 78 | if sys.platform == "win32": 79 | try: 80 | import win32com.client 81 | objShell = win32com.client.Dispatch("WScript.Shell") 82 | appDataDir = objShell.SpecialFolders("AppData") 83 | except: 84 | try: 85 | from win32com.shell import shell, shellcon 86 | appDataDir = shell.SHGetPathFromIDListEx( 87 | shell.SHGetSpecialFolderLocation( 88 | 0, shellcon.CSIDL_APPDATA 89 | ) 90 | ) 91 | except: 92 | appDataDir = os.environ['APPDATA'].decode( 93 | sys.getfilesystemencoding() 94 | ) 95 | minecraftDir = os.path.join(appDataDir, u".minecraft") 96 | elif sys.platform == "darwin": 97 | appDataDir = os.path.expanduser(u"~/Library/Application Support") 98 | minecraftDir = os.path.join(appDataDir, u"minecraft") 99 | minecraftDir.decode(sys.getfilesystemencoding()) 100 | else: 101 | appDataDir = os.path.expanduser(u"~") 102 | minecraftDir = os.path.expanduser(u"~/.minecraft") 103 | return minecraftDir 104 | 105 | @classmethod 106 | def __check_player(cls, username, session_hash): 107 | r = requests.get(cls.CHECK_SESSION_URL, 108 | params={'user': username, 'serverId': session_hash}) 109 | return r.ok and r.content.strip() == "YES" 110 | 111 | @staticmethod 112 | def __login_hash(server_id, shared_secret, public_key): 113 | """Returns the server id which is then used for joining a server""" 114 | digest = hashlib.sha1() 115 | digest.update(server_id) 116 | digest.update(shared_secret) 117 | digest.update(encryption.encode_public_key(public_key)) 118 | d = long(digest.hexdigest(), 16) 119 | if d >> 39 * 4 & 0x8: 120 | return "-%x" % ((-d) & (2 ** (40 * 4) - 1)) 121 | return "%x" % d 122 | 123 | 124 | class OldLoginAuthenticator(Authenticator): 125 | LOGIN_URL = 'https://login.minecraft.net' 126 | VERSION = 1337 127 | VALID_TIME = 60 128 | 129 | def __init__(self, username, password): 130 | self.username = username 131 | self.password = password 132 | 133 | def _authenticate(self): 134 | r = requests.post(self.LOGIN_URL, 135 | data={'user': self.username, 136 | 'password': self.password, 137 | 'version': self.VERSION}) 138 | if not r.ok: 139 | r.raise_for_status() 140 | if len(r.content.split(":")) < 4: 141 | raise Exception("Invalid response from server: " + r.content) 142 | reponse = r.content.split(":") 143 | self.display_name = reponse[2] 144 | self.session_id = reponse[3].strip() 145 | 146 | def __repr__(self): 147 | return ("OldLoginAuthenticator(user:'%s', player:'%s')" % 148 | (self.username, self.display_name)) 149 | 150 | 151 | class LastLoginAuthenticator(OldLoginAuthenticator): 152 | def __init__(self): 153 | path = os.path.join(self._minecraft_folder(), "lastlogin") 154 | with open(path) as f: 155 | ciphertext = f.read() 156 | cipher = encryption.PBEWithMD5AndDES('passwordfile') 157 | plaintext = cipher.decrypt(ciphertext) 158 | user_size = struct.unpack(">h", plaintext[:2])[0] 159 | username = plaintext[2:user_size + 2] 160 | password = plaintext[4 + user_size:] 161 | super(LastLoginAuthenticator, self).__init__(username, password) 162 | 163 | def __repr__(self): 164 | return ("LastLoginAuthenticator(user:'%s', player:'%s')" % 165 | (self.username, self.display_name)) 166 | 167 | 168 | class YggdrasilAuthenticator(Authenticator): 169 | YGGDRASIL_URL = "https://authserver.mojang.com/" 170 | AUTH_URL = YGGDRASIL_URL + "authenticate" 171 | REFRESH_URL = YGGDRASIL_URL + "refresh" 172 | VALIDATE_URL = YGGDRASIL_URL + "validate" 173 | INVALIDATE_URL = YGGDRASIL_URL + "invalidate" 174 | SIGNOUT_URL = YGGDRASIL_URL + "signout" 175 | 176 | def __init__(self, username, password): 177 | self.username = username 178 | self.password = password 179 | self.client_token = self._random_token() 180 | 181 | @property 182 | def session_id(self): 183 | self.authenticate() 184 | return "token:%s:%s" % (self.access_token, self.uuid) 185 | 186 | def _authenticate(self): 187 | r = self._request(self.AUTH_URL, { 188 | 'agent': { 189 | 'name': "Minecraft", 190 | 'version': 1 191 | }, 192 | 'username': self.username, 193 | 'password': self.password, 194 | 'clientToken': self.client_token 195 | }) 196 | self.access_token = r['accessToken'] 197 | self.display_name = r['selectedProfile']['name'] 198 | self.uuid = r['selectedProfile']['id'] 199 | 200 | def _request(self, url, data): 201 | r = requests.post(url, data=json.dumps(data)) 202 | response = json.loads(r.content) if r.content else None 203 | if not r.ok: 204 | raise self.YggdrasilException(response) 205 | return response 206 | 207 | def __repr__(self): 208 | return ("YggdrasilAuthenticator(user:%s, player:%s)" % 209 | (self.username, self.display_name)) 210 | 211 | @classmethod 212 | def _random_token(cls): 213 | return "".join("%02x" % ord(c) for c in 214 | encryption.generate_random_bytes(16)) 215 | 216 | class YggdrasilException(Exception): 217 | def __init__(self, r): 218 | super(YggdrasilAuthenticator.YggdrasilException, self).__init__( 219 | r.get('errorMessage') 220 | ) 221 | self.error = r.get('error') 222 | self.cause = r.get('cause') 223 | 224 | def __str__(self): 225 | error = "%s: %s" % (self.error, self.message) 226 | if self.cause: 227 | return "%s (cause: %s)" % (error, self.cause) 228 | else: 229 | return error 230 | 231 | 232 | class YggdrasilTokenAuthenticator(YggdrasilAuthenticator): 233 | def __init__(self, profile=None): 234 | config = self._config() 235 | if profile is None: 236 | profile = config['selectedProfile'] 237 | self.profile = profile 238 | profile = config['profiles'][profile] 239 | self.uuid = profile['playerUUID'] 240 | auth = config['authenticationDatabase'][self.uuid] 241 | self.username = auth['username'] 242 | self.access_token = auth['accessToken'] 243 | self.display_name = auth['displayName'] 244 | self.client_token = config['clientToken'] 245 | 246 | def _authenticate(self): 247 | self._request(self.VALIDATE_URL, { 248 | 'accessToken': self.access_token 249 | }) 250 | 251 | @classmethod 252 | def _config(cls): 253 | with open(cls._config_path()) as f: 254 | return json.load(f) 255 | 256 | @classmethod 257 | def _config_path(cls): 258 | return os.path.join(cls._minecraft_folder(), "launcher_profiles.json") 259 | 260 | 261 | def AuthenticatorFactory(): 262 | for authenticator in (YggdrasilTokenAuthenticator, LastLoginAuthenticator): 263 | try: 264 | authenticator = authenticator() 265 | authenticator.authenticate() 266 | except: 267 | continue 268 | else: 269 | return authenticator 270 | 271 | 272 | if __name__ == "__main__": 273 | print LastLoginAuthenticator().password 274 | -------------------------------------------------------------------------------- /mc4p/plugin/dvr.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | """Record and play back server messages. 22 | 23 | When run as an mc4p plugin, dvr saves messages from a client or server to a file. 24 | When run as a stand-alone plugin, dvr replays those saved messages. 25 | 26 | Format of a message file: 27 | := * 28 | := BYTE+ 29 | := FLOAT 30 | := INT 31 | 32 | When dvr is run stand-alone, it acts as both the minecraft client and server. 33 | It listens on a port as the server, and then connects as a client to mc4p. 34 | It then replays all recorded messages, from both the client and server. 35 | 36 | Command-line arguments: 37 | [-X SPEED_FACTOR] 38 | [-s SRV_PORT] 39 | MC3P_PORT 40 | FILE 41 | """ 42 | 43 | import socket, asyncore, logging, logging.config, os.path, sys, optparse, time, struct 44 | 45 | if __name__ == "__main__": 46 | mc4p_dir = os.path.dirname(os.path.abspath(os.path.join(__file__,'..'))) 47 | sys.path.append(mc4p_dir) 48 | 49 | from mc4p.plugins import PluginError, MC4Plugin 50 | 51 | logger = logging.getLogger('plugin.dvr') 52 | 53 | ### Recording ################## 54 | 55 | class DVROptParser(optparse.OptionParser): 56 | def error(self, msg): 57 | raise PluginError(msg) 58 | 59 | class DVRPlugin(MC4Plugin): 60 | 61 | def init(self, args): 62 | self.cli_msgs = set() 63 | self.all_cli_msgs = False 64 | self.cli_msgfile = None 65 | self.srv_msgs = set() 66 | self.all_srv_msgs = False 67 | self.srv_msgfile = None 68 | self.parse_plugin_args(args) 69 | logger.info('initialized') 70 | logger.debug('cli_msgs=%s, srv_msgs=%s' % \ 71 | (repr(self.cli_msgs), repr(self.srv_msgs))) 72 | self.t0 = time.time() 73 | 74 | def parse_plugin_args(self, argstr): 75 | parser = DVROptParser() 76 | parser.add_option('-c', '--from-client', dest='cli_msgs', 77 | default='', metavar='MSGS', 78 | help='comma-delimited list of client message IDs') 79 | parser.add_option('-s', '--from-server', dest='srv_msgs', 80 | default='', metavar='MSGS', 81 | help='comma-delimited list of server message IDs') 82 | # TODO: Add append/overwrite options. 83 | 84 | (opts, args) = parser.parse_args(argstr.split(' ')) 85 | if len(args) == 0: 86 | raise PluginError("Missing capture file") 87 | elif len(args) > 1: 88 | raise PluginError("Unexpected arguments '%s'" % repr(args[1:])) 89 | else: 90 | capfile = args[0] 91 | 92 | if opts.cli_msgs == '' and opts.srv_msgs == '': 93 | raise PluginError("Must supply either --cli-msgs or --srv-msgs") 94 | 95 | if opts.cli_msgs == '*': 96 | self.all_cli_msgs = True 97 | elif opts.cli_msgs != '': 98 | self.cli_msgs = set([self.msg_id(s) for s in opts.cli_msgs.split(',')]) 99 | 100 | if opts.srv_msgs == '*': 101 | self.all_srv_msgs = True 102 | elif opts.srv_msgs != '': 103 | self.srv_msgs = set([self.msg_id(s) for s in opts.srv_msgs.split(',')]) 104 | # Always capture the disconnect messages. 105 | self.cli_msgs.add(0xff) 106 | self.srv_msgs.add(0xff) 107 | 108 | self.cli_msgfile = open(capfile+'.cli', 'w') 109 | try: 110 | self.srv_msgfile = open(capfile+'.srv', 'w') 111 | except: 112 | self.cli_msgfile.close() 113 | 114 | def msg_id(self, s): 115 | base = 16 if s.startswith('0x') else 10 116 | try: return int(s, base) 117 | except: raise PluginError("Invalid message ID '%s'" % s) 118 | 119 | def default_handler(self, msg, dir): 120 | pid = msg['msgtype'] 121 | if 'client' == dir and (self.all_cli_msgs or pid in self.cli_msgs): 122 | self.record_msg(msg, self.cli_msgfile) 123 | if 'server' == dir and (self.all_srv_msgs or pid in self.srv_msgs): 124 | self.record_msg(msg, self.srv_msgfile) 125 | return True 126 | 127 | def record_msg(self, msg, file): 128 | t = time.time() - self.t0 129 | bytes = msg['raw_bytes'] 130 | hdr = struct.pack("= self.tnext * self.timescale: 205 | msgtype = struct.unpack('>B', self.nextmsg[0])[0] 206 | logger.info('%s sending msgtype %x (%d bytes) at t=%f', 207 | self.name, msgtype, len(self.nextmsg), t) 208 | self.send(self.nextmsg) 209 | msgtype = struct.unpack('>B', self.nextmsg[0])[0] 210 | self.nextmsg = None 211 | if msgtype == 255 and self.close_on_ff: 212 | self.closing = True 213 | return not self.closing and asyncore.dispatcher_with_send.writable(self) 214 | 215 | 216 | class MockClient(MockServer): 217 | def __init__(self,host, port, msgfile, timescale): 218 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 219 | logger.debug("connecting to %s:%d" % (host,port)) 220 | sock.connect( (host, port) ) 221 | MockServer.__init__(self, sock, msgfile, "client", timescale, False) 222 | 223 | def playback(): 224 | # Parse arguments. 225 | opts, capfile = parse_args() 226 | 227 | # Override plugin.dvr log level with command-line option 228 | if opts.loglvl: 229 | logger.setLevel(getattr(logging, opts.loglvl.upper())) 230 | 231 | # Open server message file. 232 | try: 233 | srv_msgfile = open(capfile+'.srv', 'r') 234 | except Exception as e: 235 | print "Could not open %s: %s" % (capfile+'.srv', str(e)) 236 | sys.exit(1) 237 | 238 | # Start listener, which will associate MockServer with socket on client connect. 239 | (srv_host, srv_port) = parse_addr(opts.srv_addr) 240 | MockListener(srv_host, srv_port, srv_msgfile, opts.timescale) 241 | print "Started server." 242 | 243 | # Open client message file. 244 | try: 245 | cli_msgfile = open(capfile+'.cli', 'r') 246 | except Exception as e: 247 | print "Could not open %s: %s" % (opts.cli_msgfile, str(e)) 248 | sys.exit(1) 249 | # Start client. 250 | (cli_host, cli_port) = parse_addr(opts.mc4p_addr) 251 | client = MockClient(cli_host, cli_port, cli_msgfile, opts.timescale) 252 | print "Started client." 253 | 254 | # Loop until we're done. 255 | asyncore.loop(0.1) 256 | 257 | # Tear down client. 258 | # You might notice that the client isn't actually used anywhere in this 259 | # scope. That's because of asyncore magic. :c 260 | del client 261 | 262 | print "Done." 263 | 264 | def parse_args(): 265 | parser = make_arg_parser() 266 | (opts, args) = parser.parse_args() 267 | if len(args) == 0: 268 | print "Missing argument CAPFILE" 269 | sys.exit(1) 270 | elif len(args) > 1: 271 | print "Unexpected arguments %s" % repr(opts.args[1:]) 272 | else: 273 | capfile = args[0] 274 | 275 | check_path(parser, capfile+'.srv') 276 | check_path(parser, capfile+'.cli') 277 | 278 | return opts, capfile 279 | 280 | def check_path(parser, path): 281 | if not os.path.exists(path): 282 | print "No such file '%s'" % path 283 | sys.exit(1) 284 | if not os.path.isfile(path): 285 | print "'%s' is not a file" % path 286 | sys.exit(1) 287 | 288 | def parse_addr(addr): 289 | host = 'localhost' 290 | parts = addr.split(':',1) 291 | if len(parts) == 2: 292 | host = parts[0] 293 | parts[0] = parts[1] 294 | try: 295 | port = int(parts[0]) 296 | except: 297 | print "Invalid port '%s'" % parts[0] 298 | sys.exit(1) 299 | return (host,port) 300 | 301 | def make_arg_parser(): 302 | parser = optparse.OptionParser( 303 | usage="usage: %prog [--to [HOST:]PORT] [--via [HOST:]PORT] " +\ 304 | "[-x FACTOR] CAPFILE") 305 | parser.add_option('--via', dest='mc4p_addr', 306 | type='string', metavar='[HOST:]PORT', 307 | help='mc4p address', default='localhost:34343') 308 | parser.add_option('--to', dest='srv_addr', type='string', 309 | metavar='[HOST:]PORT', help='server address', 310 | default='localhost:25565') 311 | parser.add_option('-x', '--timescale', dest='timescale', type='float', 312 | metavar='FACTOR', default=1.0, 313 | help='scale time between messages by FACTOR') 314 | parser.add_option("-l", "--log-level", dest="loglvl", metavar="LEVEL", 315 | choices=["debug","info","warn","error"], default=None, 316 | help="Override logging.conf root log level") 317 | return parser 318 | 319 | if __name__ == "__main__": 320 | logcfg = os.path.join(mc4p_dir,'logging.conf') 321 | if os.path.exists(logcfg) and os.path.isfile(logcfg): 322 | logging.config.fileConfig(logcfg) 323 | else: 324 | logging.basicConfig(level=logging.WARN) 325 | 326 | playback() 327 | 328 | -------------------------------------------------------------------------------- /mc4p/plugins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import Queue 22 | import logging 23 | import messages 24 | import multiprocessing 25 | import traceback 26 | 27 | ### Globals ### 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | ### Exceptions ### 33 | class ConfigError(Exception): 34 | def __init__(self, msg): 35 | Exception.__init__(self) 36 | self.msg = msg 37 | 38 | def __str__(self): 39 | return self.msg 40 | 41 | 42 | class PluginError(Exception): 43 | def __init__(self, msg): 44 | self.msg = msg 45 | 46 | def __str__(self): 47 | return self.msg 48 | 49 | 50 | class PluginConfig(object): 51 | """Store plugin configuration""" 52 | def __init__(self): 53 | self.__ids = [] 54 | self.__plugin_names = {} # { id -> plugin_name } 55 | self.__argstrs = {} # { id -> argstr } 56 | self.__orderings = {} # { msgtype -> [id1, id2, ...] } 57 | 58 | def __default_id(self, plugin_name): 59 | id = plugin_name 60 | i = 1 61 | while id in self.__ids: 62 | id = plugin_name + str(i) 63 | i += 1 64 | return id 65 | 66 | def add(self, plugin_name, id=None, argstr=''): 67 | if id is None: 68 | id = self.__default_id(plugin_name) 69 | if id in self.__ids: 70 | raise ConfigError("Duplicate id '%s'" % id) 71 | self.__ids.append(id) 72 | self.__plugin_names[id] = plugin_name 73 | self.__argstrs[id] = argstr 74 | return self 75 | 76 | def order(self, msgtype, id_list): 77 | if len(set(id_list)) != len(id_list): 78 | raise ConfigError("Duplicate ids in %s" % repr(id_list)) 79 | unknown_ids = set(self.__ids) - set(id_list) 80 | if len(unknown_ids) > 0: 81 | raise ConfigError("No such ids: %s" % repr(unknown_ids)) 82 | self.__orderings[msgtype] = id_list 83 | return self 84 | 85 | @property 86 | def ids(self): 87 | """List of instance ids.""" 88 | return list(self.__ids) 89 | 90 | @property 91 | def plugins(self): 92 | """Set of instantiated plugin names.""" 93 | return set(self.__plugin_names.values()) 94 | 95 | @property 96 | def plugin(self): 97 | """Map of ids to plugin names.""" 98 | return dict(self.__plugin_names) 99 | 100 | @property 101 | def argstr(self): 102 | """Map of ids to argument strings.""" 103 | return dict(self.__argstrs) 104 | 105 | def ordering(self, msgtype): 106 | """Return a total ordering of instance ids for this msgtype.""" 107 | if not msgtype in self.__orderings: 108 | return self.ids 109 | else: 110 | o = list(self.__orderings[msgtype]) 111 | for id in self.__ids: 112 | if not id in o: 113 | o.append(id) 114 | return o 115 | 116 | 117 | class PluginManager(object): 118 | """Manage plugins for an mc4p session.""" 119 | def __init__(self, config, cli_proxy, srv_proxy): 120 | # Map of plugin name to module. 121 | self.__plugins = {} 122 | 123 | # Map of instance ID to MC4Plugin instance. 124 | self.__instances = {} 125 | 126 | # Holds the active protocol after successful handshake. 127 | self.__protocol = messages.protocol[0] 128 | 129 | # For asynchronously injecting messages from the client or server. 130 | self.__from_client_q = multiprocessing.Queue() 131 | self.__from_server_q = multiprocessing.Queue() 132 | 133 | # Plugin configuration. 134 | self.__config = config 135 | 136 | self._load_plugins() 137 | self._instantiate_all() 138 | 139 | def next_injected_msg_from(self, source): 140 | """Return the Queue containing source's messages to be injected.""" 141 | if source == 'client': 142 | q = self.__from_client_q 143 | elif source == 'server': 144 | q = self.__from_server_q 145 | else: 146 | raise Exception('Unrecognized source ' + source) 147 | try: 148 | return q.get(block=False) 149 | except Queue.Empty: 150 | return None 151 | 152 | def _load_plugins(self): 153 | """Load or reload all plugins.""" 154 | logger.info('%s loading plugins' % repr(self)) 155 | for pname in self.__config.plugins: 156 | self._load_plugin(pname) 157 | 158 | def _load_plugin(self, pname): 159 | """Load or reload plugin pname.""" 160 | try: 161 | logger.debug(' Loading %s' % pname) 162 | mod = __import__(pname) 163 | for p in pname.split('.')[1:]: 164 | mod = getattr(mod, p) 165 | self.__plugins[pname] = reload(mod) 166 | except Exception as e: 167 | logger.error("Plugin %s failed to load: %s" % (pname, str(e))) 168 | return 169 | 170 | def _instantiate_all(self): 171 | """Instantiate plugins based on self.__config. 172 | 173 | Assumes plugins have already been loaded. 174 | """ 175 | logger.info('%s instantiating plugins' % repr(self)) 176 | for id in self.__config.ids: 177 | pname = self.__config.plugin[id] 178 | if not pname in self.__plugins: 179 | continue 180 | else: 181 | self._instantiate_one(id, pname) 182 | 183 | def _update_protocol_version(self, version): 184 | self.__protocol = messages.protocol[version] 185 | for plugin in self.__instances.itervalues(): 186 | plugin._set_protocol(self.__protocol) 187 | 188 | def _find_plugin_class(self, pname): 189 | """Return the subclass of MC4Plugin in pmod.""" 190 | pmod = self.__plugins[pname] 191 | class_check = lambda c: \ 192 | c != MC4Plugin and isinstance(c, type) and issubclass(c, MC4Plugin) 193 | classes = filter(class_check, pmod.__dict__.values()) 194 | if len(classes) == 0: 195 | logger.error( 196 | "Plugin '%s' does not contain a subclass of MC4Plugin" % pname 197 | ) 198 | return None 199 | elif len(classes) > 1: 200 | logger.error( 201 | "Plugin '%s' contains multiple subclasses of MC4Plugin: %s" % 202 | (pname, ', '.join([c.__name__ for c in classes])) 203 | ) 204 | else: 205 | return classes[0] 206 | 207 | def _instantiate_one(self, id, pname): 208 | """Instantiate plugin pmod with id.""" 209 | clazz = self._find_plugin_class(pname) 210 | if None == clazz: 211 | return 212 | try: 213 | logger.debug(" Instantiating plugin '%s' as '%s'" % (pname, id)) 214 | inst = clazz(self.__protocol, 215 | self.__from_client_q, 216 | self.__from_server_q) 217 | inst.init(self.__config.argstr[id]) 218 | self.__instances[id] = inst 219 | except Exception as e: 220 | logger.error("Failed to instantiate '%s': %s" % (id, str(e))) 221 | 222 | def destroy(self): 223 | """Destroy plugin instances.""" 224 | self.__plugins = {} 225 | logger.info("%s destroying plugin instances" % repr(self)) 226 | for iname in self.__instances: 227 | logger.debug(" Destroying '%s'" % iname) 228 | try: 229 | self.__instances[iname]._destroy() 230 | except: 231 | logger.error( 232 | "Error cleaning up instance '%s' of plugin '%s'" % 233 | (iname, self.__config.plugin[iname]) 234 | ) 235 | logger.error(traceback.format_exc()) 236 | self.__instances = {} 237 | 238 | def filter(self, msg, source): 239 | """Filter msg through the configured plugins. 240 | 241 | Returns True if msg should be forwarded, False otherwise. 242 | """ 243 | if 0x02 == msg.id: 244 | self._update_protocol_version(msg.version) 245 | logger.debug('PluginManager detected proto version %d' % 246 | self.__protocol.version) 247 | return self._call_plugins(msg, source) 248 | 249 | def _call_plugins(self, msg, source): 250 | msgtype = msg.id 251 | for id in self.__config.ordering(msgtype): 252 | inst = self.__instances.get(id, None) 253 | if inst and not inst.filter(msg, source): 254 | return False 255 | return True 256 | 257 | def __repr__(self): 258 | return '' 259 | 260 | 261 | class MsgHandlerWrapper(object): 262 | def __init__(self, msgtypes, method): 263 | self.msgtypes = msgtypes 264 | self.method = method 265 | 266 | def __call__(self, *args, **kargs): 267 | self.method(*args, **kargs) 268 | 269 | 270 | def msghdlr(*msgtypes): 271 | def wrapper(f): 272 | return MsgHandlerWrapper(msgtypes, f) 273 | return wrapper 274 | 275 | 276 | class MC4Plugin(object): 277 | """Base class for mc4p plugins.""" 278 | 279 | def __init__(self, protocol, from_client, from_server): 280 | self.__protocol = protocol 281 | self.__to_client = from_server 282 | self.__to_server = from_client 283 | self.__hdlrs = {} 284 | self._collect_msg_hdlrs() 285 | 286 | def _set_protocol(self, protocol): 287 | self.__protocol = protocol 288 | 289 | def _collect_msg_hdlrs(self): 290 | wrappers = filter(lambda x: isinstance(x, MsgHandlerWrapper), 291 | self.__class__.__dict__.values()) 292 | for wrapper in wrappers: 293 | self._unwrap_hdlr(wrapper) 294 | 295 | def _unwrap_hdlr(self, wrapper): 296 | hdlr = wrapper.method 297 | name = hdlr.__name__ 298 | for msgtype in wrapper.msgtypes: 299 | if msgtype in self.__hdlrs: 300 | othername = self.__hdlrs[msgtype].__name__ 301 | raise PluginError('Multiple handlers for %x: %s, %s' % 302 | (msgtype, othername, name)) 303 | else: 304 | self.__hdlrs[msgtype] = hdlr 305 | logger.debug(' registered handler %s for %x' 306 | % (name, msgtype)) 307 | 308 | def init(self, args): 309 | """Initialize plugin instance. 310 | Override to provide subclass-specific initialization.""" 311 | 312 | def destroy(self): 313 | """Free plugin resources. 314 | Override in subclass.""" 315 | 316 | def _destroy(self): 317 | """Internal cleanup, do not override.""" 318 | self.__to_client.close() 319 | self.__to_server.close() 320 | self.destroy() 321 | 322 | def create_packet(self, msg_id, side=None, **fields): 323 | if self.__protocol[msg_id] is None: 324 | logger.error(("Plugin %s tried to send message with " 325 | "unrecognized type %d") % 326 | (self.__class__.__name__, msg_id)) 327 | return None 328 | try: 329 | msg = self.__protocol[msg_id](side=side, **fields) 330 | msg.emit() 331 | except: 332 | logger.error("Plugin %s sent invalid message of type %d" % 333 | (self.__class__.__name__, msg_id)) 334 | logger.info(traceback.format_exc()) 335 | if msg: 336 | logger.debug(" msg: %s" % repr(msg)) 337 | return None 338 | return msg 339 | 340 | def to_server(self, msg, **fields): 341 | """Send msg to the server asynchronously.""" 342 | if type(msg) == int: 343 | msg = self.create_packet(msg, "client", **fields) 344 | self.__to_server.put(msg) 345 | 346 | def to_client(self, msg, **fields): 347 | """Send msg to the client asynchronously.""" 348 | if type(msg) == int: 349 | msg = self.create_packet(msg, "server", **fields) 350 | self.__to_client.put(msg) 351 | 352 | def default_handler(self, msg, source): 353 | """Default message handler for all message types. 354 | 355 | Override in subclass to filter all message types.""" 356 | return True 357 | 358 | def filter(self, msg, source): 359 | """Filter msg via the appropriate message handler(s). 360 | 361 | Returns True to forward msg on, False to drop it. 362 | Modifications to msg are passed on to the recipient. 363 | """ 364 | msgtype = msg.id 365 | try: 366 | if not self.default_handler(msg, source): 367 | return False 368 | except: 369 | logger.error('Error in default handler of plugin %s:\n%s' % 370 | (self.__class__.__name__, traceback.format_exc())) 371 | return True 372 | 373 | try: 374 | if msgtype in self.__hdlrs: 375 | return self.__hdlrs[msgtype](self, msg, source) 376 | else: 377 | return True 378 | except: 379 | hdlr = self.__hdlrs[msgtype] 380 | logger.error('Error in handler %s of plugin %s: %s' % 381 | (hdlr.__name__, self.__class__.__name__, 382 | traceback.format_exc())) 383 | return True 384 | -------------------------------------------------------------------------------- /mc4p/parsing.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import re 22 | import struct 23 | import inspect 24 | import collections 25 | 26 | from mc4p import logger 27 | 28 | 29 | class Protocol(dict): 30 | """Collection of multiple protocol versions""" 31 | def version(self, version): 32 | return ProtocolVersion(version, self) 33 | 34 | def __getitem__(self, version): 35 | assert isinstance(version, int) 36 | while version not in self and version > 0: 37 | version -= 1 38 | return super(Protocol, self).__getitem__(version) 39 | 40 | def _register_version(self, protocol_version): 41 | self[protocol_version.version] = protocol_version 42 | 43 | def __str__(self): 44 | return "\n\n".join(str(version) for version in self.itervalues()) 45 | 46 | 47 | class ProtocolVersion(list): 48 | def __init__(self, version, protocol=None): 49 | self.version = version 50 | super(ProtocolVersion, self).__init__([None] * 256) 51 | self.protocol = protocol 52 | 53 | def parse_message(self, stream, side): 54 | message_id = UnsignedByte.parse(stream) 55 | logger.debug("%s trying to parse message type %x" % (side, message_id)) 56 | message = self[message_id] 57 | if message is None: 58 | raise self.UnsupportedPacketException(message_id) 59 | if not message._accept_from(side): 60 | raise self.WrongDirectionException(message, side) 61 | return message(stream, side) 62 | 63 | def __enter__(self): 64 | pass 65 | 66 | def __exit__(self, *args): 67 | """Captures all defined messages""" 68 | potential_messages = inspect.currentframe().f_back.f_locals 69 | for message in potential_messages.itervalues(): 70 | if (inspect.isclass(message) and 71 | issubclass(message, Message) and 72 | message not in (Message, ServerMessage, ClientMessage)): 73 | message._do_magic() 74 | self[message.id] = message 75 | if self.protocol is not None: 76 | self.protocol._register_version(self) 77 | 78 | def __str__(self): 79 | return "\n".join(( 80 | "Protocol version %s" % self.version, 81 | "-------------------", 82 | "\n\n".join(msg._str() for msg in self if msg) 83 | )) 84 | 85 | class UnsupportedPacketException(Exception): 86 | def __init__(self, message_id): 87 | super(ProtocolVersion.UnsupportedPacketException, self).__init__( 88 | "Unsupported packet id 0x%x" % message_id 89 | ) 90 | self.message_id = message_id 91 | 92 | class WrongDirectionException(Exception): 93 | def __init__(self, message, side): 94 | correct_side = "server" if side == "client" else "client" 95 | super(ProtocolVersion.WrongDirectionException, self).__init__( 96 | "Received %s-only packet 0x%02x %s from %s" % 97 | (correct_side, message.id, message._name, side) 98 | ) 99 | self.message = message 100 | self.side = side 101 | 102 | 103 | class Message(object): 104 | _NAME_PATTERN = re.compile("(.)([A-Z])") 105 | id = None 106 | 107 | def __init__(self, stream=None, side=None, **kwargs): 108 | self._side = side 109 | if stream is not None and kwargs: 110 | raise TypeError("Unexpected argument combination") 111 | for name, field in self._fields.iteritems(): 112 | if stream is not None: 113 | setattr(self, name, field.parse(stream, self)) 114 | else: 115 | setattr(self, name, kwargs.get(name)) 116 | if stream is not None: 117 | self._raw_bytes = stream.packet_finished() 118 | 119 | def emit(self): 120 | for name, field in self._fields.iteritems(): 121 | field.prepare(getattr(self, name), self) 122 | return (struct.pack(">B", self.id) + 123 | "".join(field.emit(getattr(self, name), self) 124 | for name, field in self._fields.iteritems())) 125 | 126 | def __str__(self): 127 | if self._fields: 128 | fields = "\n".join( 129 | " %s (%s): %s" % 130 | (name, field, field.format(getattr(self, name))) 131 | for name, field in self._fields.iteritems() 132 | ) 133 | else: 134 | fields = " -- empty --" 135 | if self._side == "client": 136 | direction = "Client -> Server " 137 | elif self._side == "server": 138 | direction = "Server -> Client " 139 | else: 140 | direction = "" 141 | return "\n".join(( 142 | "%s0x%02x %s" % (direction, self.id, self._name), 143 | fields 144 | )) 145 | 146 | @classmethod 147 | def _accept_from(cls, side): 148 | return True 149 | 150 | @classmethod 151 | def _do_magic(cls): 152 | cls._name = cls._NAME_PATTERN.sub( 153 | lambda g: "%s %s" % (g.group(1), g.group(2)), cls.__name__ 154 | ) 155 | cls._fields = collections.OrderedDict(sorted( 156 | ((name, field) for name, field in cls.__dict__.iteritems() 157 | if isinstance(field, MessageField)), 158 | key=lambda i: i[1]._order_id 159 | )) 160 | 161 | @classmethod 162 | def _str(cls): 163 | if cls._fields: 164 | fields = "\n".join( 165 | " %s (%s)" % (name, field) 166 | for name, field in cls._fields.iteritems() 167 | ) 168 | else: 169 | fields = " -- empty --" 170 | return "\n".join(( 171 | "0x%02x %s" % (cls.id, cls._name), 172 | fields 173 | )) 174 | 175 | 176 | class ClientMessage(Message): 177 | """Message sent from client to server""" 178 | @classmethod 179 | def _accept_from(cls, side): 180 | return side == "client" 181 | 182 | 183 | class ServerMessage(Message): 184 | """Message sent from server to client""" 185 | @classmethod 186 | def _accept_from(cls, side): 187 | return side == "server" 188 | 189 | 190 | class MessageField(object): 191 | _NEXT_ID = 1 192 | 193 | def __init__(self): 194 | self._order_id = MessageField._NEXT_ID 195 | MessageField._NEXT_ID += 1 196 | 197 | @classmethod 198 | def parse(cls, stream, message): 199 | return None 200 | 201 | @classmethod 202 | def prepare(cls, value, message): 203 | """Used to set stray length fields""" 204 | pass 205 | 206 | @classmethod 207 | def emit(self, value, message): 208 | return "" 209 | 210 | def format(self, value): 211 | return str(value) 212 | 213 | @classmethod 214 | def _parse_subfield(cls, field, stream, message): 215 | if isinstance(field, MessageField): 216 | return field.parse(stream, message) 217 | elif isinstance(field, basestring): 218 | return getattr(message, field) 219 | elif isinstance(field, dict): 220 | return collections.OrderedDict( 221 | (key, cls._parse_subfield(subfield, stream, message)) 222 | for key, subfield in field.iteritems() 223 | ) 224 | else: 225 | raise NotImplementedError 226 | 227 | @classmethod 228 | def _emit_subfield(cls, field, value, message): 229 | if isinstance(field, MessageField): 230 | return field.emit(value, message) 231 | elif isinstance(field, basestring): 232 | return "" 233 | elif isinstance(field, dict): 234 | return "".join( 235 | cls._emit_subfield(subfield, value[name], message) 236 | for name, subfield in field.iteritems() 237 | ) 238 | else: 239 | raise NotImplementedError 240 | 241 | @classmethod 242 | def _set_subfield(cls, field, value, message): 243 | if isinstance(field, basestring): 244 | setattr(message, field, value) 245 | 246 | def __str__(self): 247 | return self.__class__.__name__ 248 | 249 | 250 | def simple_type_field(name, format): 251 | format = ">" + format 252 | length = struct.calcsize(format) 253 | 254 | class SimpleType(MessageField): 255 | @classmethod 256 | def parse(cls, stream, message=None): 257 | return struct.unpack(format, stream.read(length))[0] 258 | 259 | @classmethod 260 | def emit(cls, value, message=None): 261 | return struct.pack(format, value) 262 | 263 | SimpleType.__name__ = name 264 | return SimpleType 265 | 266 | 267 | class Conditional(MessageField): 268 | def __init__(self, field, condition): 269 | self._field = field 270 | self.condition = condition 271 | super(Conditional, self).__init__() 272 | 273 | def parse(self, stream, message): 274 | if not self.condition(message): 275 | return None 276 | return self._parse_subfield(self._field, stream, message) 277 | 278 | def emit(self, value, message): 279 | if not self.condition(message): 280 | return "" 281 | return self._emit_subfield(self._field, value, message) 282 | 283 | 284 | class List(MessageField): 285 | def __init__(self, field, size): 286 | self._size = size 287 | self._field = field 288 | super(List, self).__init__() 289 | 290 | def parse(self, stream, message=None): 291 | return [ 292 | self._parse_subfield(self._field, stream, message) 293 | for i in range(self._parse_subfield(self._size, stream, message)) 294 | ] 295 | 296 | def emit(self, value, message=None): 297 | return (self._emit_subfield(self._size, len(value), message) + 298 | "".join(self._emit_subfield(self._field, entry, message) 299 | for entry in value)) 300 | 301 | 302 | class Dict(MessageField): 303 | def __init__(self, key, value, size): 304 | self._size = size 305 | self._key = key 306 | self._value = value 307 | super(Dict, self).__init__() 308 | 309 | def parse(self, stream, message=None): 310 | return collections.OrderedDict( 311 | (self._parse_subfield(self._key, stream, message), 312 | self._parse_subfield(self._value, stream, message)) 313 | for i in range(self._parse_subfield(self._size, stream, message)) 314 | ) 315 | 316 | def emit(self, value, message=None): 317 | return (self._emit_subfield(self._size, len(value), message) + 318 | "".join(self._emit_subfield(self._key, key, message) + 319 | self._emit_subfield(self._value, entry, message) 320 | for key, entry in value.iteritems())) 321 | 322 | def format(self, value): 323 | return str(dict(value)) 324 | 325 | 326 | class Object(MessageField): 327 | def __init__(self, **fields): 328 | self._fields = collections.OrderedDict( 329 | sorted(fields.iteritems(), key=lambda f: f[1]._order_id) 330 | ) 331 | 332 | def parse(self, stream, message=None): 333 | return self.ObjectValue(**dict( 334 | (name, field.parse(stream, message)) 335 | for name, field in self._fields.iteritems() 336 | )) 337 | 338 | def emit(self, value, message=None): 339 | return "".join( 340 | field.emit(getattr(value, name), message) 341 | for name, field in self._fields.iteritems() 342 | ) 343 | 344 | class ObjectValue(object): 345 | def __init__(self, **kwargs): 346 | for attr, value in kwargs.iteritems(): 347 | setattr(self, attr, value) 348 | self._dict = kwargs 349 | 350 | def __repr__(self): 351 | return str(self._dict) 352 | 353 | 354 | Byte = simple_type_field("Byte", "b") 355 | UnsignedByte = simple_type_field("UnsignedByte", "B") 356 | Short = simple_type_field("Short", "h") 357 | Int = simple_type_field("Int", "i") 358 | Float = simple_type_field("Float", "f") 359 | Double = simple_type_field("Double", "d") 360 | Long = simple_type_field("Long", "q") 361 | 362 | 363 | class Bool(Byte): 364 | @classmethod 365 | def parse(cls, stream, message): 366 | return super(Bool, cls).parse(stream) == 1 367 | 368 | 369 | class String(MessageField): 370 | @classmethod 371 | def parse(cls, stream, message=None): 372 | return unicode(stream.read(2 * Short.parse(stream)), 373 | encoding="utf-16-be") 374 | 375 | @classmethod 376 | def emit(cls, value, message=None): 377 | return Short.emit(len(value)) + value.encode("utf-16-be") 378 | 379 | def format(self, value): 380 | return value.encode("utf8") 381 | 382 | 383 | class Data(MessageField): 384 | def __init__(self, size): 385 | self._size = size 386 | super(Data, self).__init__() 387 | 388 | def parse(self, stream, message=None): 389 | return stream.read(self._parse_subfield(self._size, stream, message)) 390 | 391 | def emit(self, value, message=None): 392 | return self._emit_subfield(self._size, len(value), message) + value 393 | 394 | def format(self, value): 395 | if value is None: 396 | return "None" + str(self._size) 397 | return "" % len(value) 398 | return " ".join("%02x" % ord(c) for c in value) 399 | 400 | 401 | class ItemStack(MessageField): 402 | @classmethod 403 | def parse(cls, stream, message=None): 404 | item_id = Short.parse(stream) 405 | if item_id == -1: 406 | return None 407 | item = cls.ItemStack( 408 | id=item_id, 409 | count=Byte.parse(stream), 410 | uses=Short.parse(stream) 411 | ) 412 | nbt_size = Short.parse(stream) 413 | item.nbt_data = stream.read(nbt_size) if nbt_size > 0 else None 414 | return item 415 | 416 | @classmethod 417 | def emit(cls, value, message=None): 418 | if value is None: 419 | return Short.emit(-1) 420 | return "".join(( 421 | Short.emit(value.id), 422 | Byte.emit(value.count), 423 | Short.emit(value.uses), 424 | Short.emit(len(value.nbt_data) if value.nbt_data else -1), 425 | value.nbt_data if value.nbt_data else "" 426 | )) 427 | 428 | class ItemStack(Object.ObjectValue): 429 | def __str__(self): 430 | return str(self.id) 431 | 432 | 433 | class Metadata(MessageField): 434 | FIELD_TYPES = [ 435 | Byte, 436 | Short, 437 | Int, 438 | Float, 439 | String, 440 | ItemStack 441 | ] 442 | 443 | @classmethod 444 | def _key_generator(cls, stream): 445 | while True: 446 | key = UnsignedByte.parse(stream) 447 | if key == 127: 448 | return 449 | if key >> 5 >= 6: 450 | raise Exception("Invalid Metadata type: %d" % (key >> 5)) 451 | yield key 452 | 453 | @classmethod 454 | def parse(cls, stream, message=None): 455 | return [{ 456 | 'index': key & 0x1f, 457 | 'type': key >> 5, 458 | 'value': cls.FIELD_TYPES[key >> 5].parse(stream) 459 | } for key in cls._key_generator(stream)] 460 | 461 | @classmethod 462 | def emit(cls, value, message=None): 463 | return "".join( 464 | UnsignedByte.emit(item['index'] | item['type'] << 5) + 465 | cls.FIELD_TYPES[item['type']].emit(item['value']) 466 | for item in value 467 | ) + "\x7f" 468 | -------------------------------------------------------------------------------- /mc4p/proxy.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | import logging 22 | import logging.config 23 | import re 24 | import signal 25 | import sys 26 | import traceback 27 | import gevent 28 | from time import time 29 | from optparse import OptionParser 30 | from getpass import getpass 31 | from gevent.server import StreamServer 32 | from gevent.socket import create_connection 33 | 34 | from plugins import PluginConfig, PluginManager 35 | from util import Stream, PartialPacketException, config_logging 36 | from mc4p import encryption, messages, authentication 37 | 38 | 39 | logger = logging.getLogger("mc4p") 40 | rsa_key = None 41 | auth = None 42 | check_auth = False 43 | 44 | 45 | def parse_args(): 46 | """Return host and port, or print usage and exit.""" 47 | usage = "usage: %prog [options] host [port]" 48 | desc = ("Create a Minecraft proxy listening for a client connection," + 49 | "and forward that connection to :.") 50 | parser = OptionParser(usage=usage, 51 | description=desc) 52 | parser.add_option("-l", "--log-level", dest="loglvl", metavar="LEVEL", 53 | choices=["debug","info","warn","error"], 54 | help="Override logging.conf root log level") 55 | parser.add_option("--log-file", dest='logfile', metavar="FILE", default=None, 56 | help="logging configuration file (optional)") 57 | parser.add_option("-p", "--local-port", dest="locport", metavar="PORT", 58 | default="34343", type="int", help="Listen on this port") 59 | parser.add_option("-c", "--check-authenticity", dest="check_auth", 60 | action="store_true", default=False, 61 | help="Check authenticity of connecting clients") 62 | parser.add_option("-a", "--auto-authenticate", dest="authenticate", 63 | action="store_true", default=False, 64 | help="Authenticate with the credentials stored in the game client") 65 | parser.add_option("-u", "--user", dest="user", metavar="USERNAME", default=None, 66 | help="Authenticate with the given username and ask for the password") 67 | parser.add_option("-P", "--password-file", dest="password_file", 68 | metavar="FILE", default=None, 69 | help="Authenticate with the credentials stored in FILE" + 70 | "in the form \"username:password\"") 71 | parser.add_option("--plugin", dest="plugins", metavar="ID:PLUGIN(ARGS)", type="string", 72 | action="append", help="Configure a plugin", default=[]) 73 | parser.add_option("--profile", dest="perf_data", metavar="FILE", default=None, 74 | help="Enable profiling, save profiling data to FILE") 75 | (opts,args) = parser.parse_args() 76 | 77 | if not 1 <= len(args) <= 2: 78 | parser.error("Incorrect number of arguments.") # Calls sys.exit() 79 | 80 | host = args[0] 81 | port = 25565 82 | if len(args) > 1: 83 | try: 84 | port = int(args[1]) 85 | except ValueError: 86 | parser.error("Invalid port %s" % args[1]) 87 | 88 | pcfg = PluginConfig() 89 | pregex = re.compile('((?P\\w+):)?(?P[\\w\\.\\d_]+)(\\((?P.*)\\))?$') 90 | for pstr in opts.plugins: 91 | m = pregex.match(pstr) 92 | if not m: 93 | logger.error('Invalid --plugin option: %s' % pstr) 94 | sys.exit(1) 95 | else: 96 | parts = {'argstr': ''} 97 | parts.update(m.groupdict()) 98 | pcfg.add(**parts) 99 | 100 | return (host, port, opts, pcfg) 101 | 102 | 103 | class ServerDispatcher(StreamServer): 104 | def __init__(self, locport, pcfg, host, port): 105 | super(ServerDispatcher, self).__init__(("", locport)) 106 | self.pcfg = pcfg 107 | self.target_host = host 108 | self.target_port = port 109 | logger.info("Listening on port %d" % locport) 110 | if rsa_key is None: 111 | generate_rsa_key_pair() 112 | 113 | def handle(self, cli_sock, addr): 114 | logger.info('Incoming connection from %s' % repr(addr)) 115 | try: 116 | srv_sock = create_connection((self.target_host, self.target_port)) 117 | cli_proxy = MinecraftProxy(cli_sock) 118 | except Exception as e: 119 | cli_sock.close() 120 | logger.error("Couldn't connect to %s:%d - %s", self.target_host, 121 | self.target_port, str(e)) 122 | logger.info(traceback.format_exc()) 123 | return 124 | srv_proxy = MinecraftProxy(srv_sock, cli_proxy) 125 | plugin_mgr = PluginManager(pcfg, cli_proxy, srv_proxy) 126 | cli_proxy.plugin_mgr = plugin_mgr 127 | srv_proxy.plugin_mgr = plugin_mgr 128 | cli_proxy.start() 129 | srv_proxy.start() 130 | 131 | def serve_forever(self): 132 | try: 133 | super(ServerDispatcher, self).serve_forever() 134 | except gevent.socket.error, e: 135 | logger.error(e) 136 | 137 | def close(self): 138 | self.stop() 139 | sys.exit() 140 | 141 | 142 | def generate_rsa_key_pair(): 143 | global rsa_key 144 | logger.debug('Generating RSA key pair') 145 | rsa_key = encryption.generate_key_pair() 146 | 147 | 148 | class EOFException(Exception): 149 | pass 150 | 151 | 152 | class MinecraftProxy(gevent.Greenlet): 153 | """Proxies a packet stream from a Minecraft client or server. 154 | """ 155 | 156 | def __init__(self, src_sock, other_side=None): 157 | """Proxies one side of a client-server connection. 158 | 159 | MinecraftProxy instances are created in pairs that have references to 160 | one another. Since a client initiates a connection, the client side of 161 | the pair is always created first, with other_side = None. The creator 162 | of the client proxy is then responsible for connecting to the server 163 | and creating a server proxy with other_side=client. Finally, the 164 | proxy creator should do client_proxy.other_side = server_proxy. 165 | """ 166 | super(MinecraftProxy, self).__init__() 167 | 168 | self.sock = src_sock 169 | self.closed = False 170 | self.plugin_mgr = None 171 | self.other_side = other_side 172 | self.rsa_key = None 173 | self.shared_secret = None 174 | self.challenge_token = None 175 | self.send_cipher = None 176 | self.recv_cipher = None 177 | self.protocol = messages.protocol[0] 178 | if other_side is None: 179 | self.side = 'client' 180 | self.rsa_key = rsa_key 181 | self.username = None 182 | else: 183 | self.side = 'server' 184 | self.other_side.other_side = self 185 | self.shared_secret = encryption.generate_shared_secret() 186 | self.stream = Stream() 187 | self.last_report = 0 188 | self.msg_queue = [] 189 | self.out_of_sync = False 190 | 191 | def handle_read(self): 192 | """Read all available bytes, and process as many packets as possible. 193 | """ 194 | t = time() 195 | if self.last_report + 5 < t and self.stream.tot_bytes > 0: 196 | self.last_report = t 197 | logger.debug( 198 | "%s: total/wasted bytes is %d/%d (%f wasted)" % ( 199 | self.side, self.stream.tot_bytes, self.stream.wasted_bytes, 200 | 100 * float(self.stream.wasted_bytes) / self.stream.tot_bytes) 201 | ) 202 | self.stream.append(self.recv(4092)) 203 | 204 | if self.out_of_sync: 205 | data = self.stream.read(len(self.stream)) 206 | self.stream.packet_finished() 207 | if self.other_side: 208 | self.other_side.send(data) 209 | return 210 | 211 | try: 212 | while True: 213 | packet = self.protocol.parse_message(self.stream, self.side) 214 | if packet.id == 0x02 and self.side == 'client': 215 | # Determine which protocol message definitions to use. 216 | proto_version = packet.version 217 | logger.info('Client requests protocol version %d' % 218 | proto_version) 219 | if not proto_version in messages.protocol: 220 | logger.error("Unsupported protocol version %d" % 221 | proto_version) 222 | self.handle_close() 223 | return 224 | self.username = packet.username 225 | self.protocol = messages.protocol[proto_version] 226 | self.other_side.protocol = self.protocol 227 | self.cipher = encryption.encryption_for_version( 228 | proto_version 229 | ) 230 | self.other_side.cipher = self.cipher 231 | elif packet.id == 0xfd: 232 | self.rsa_key = encryption.decode_public_key( 233 | packet.public_key 234 | ) 235 | self.encoded_rsa_key = packet.public_key 236 | packet.public_key = encryption.encode_public_key( 237 | self.other_side.rsa_key 238 | ) 239 | self.challenge_token = packet.challenge_token 240 | self.other_side.challenge_token = self.challenge_token 241 | self.other_side.server_id = packet.server_id 242 | if check_auth: 243 | packet.server_id = encryption.generate_server_id() 244 | else: 245 | packet.server_id = "-" 246 | self.server_id = packet.server_id 247 | elif packet.id == 0xfc and self.side == 'client': 248 | self.shared_secret = encryption.decrypt_shared_secret( 249 | packet.shared_secret, 250 | self.rsa_key 251 | ) 252 | if (len(self.shared_secret) > 16 and 253 | self.cipher == encryption.RC4): 254 | logger.error("Unsupported protocol version") 255 | self.handle_close() 256 | return 257 | packet.shared_secret = encryption.encrypt_shared_secret( 258 | self.other_side.shared_secret, 259 | self.other_side.rsa_key 260 | ) 261 | challenge_token = encryption.decrypt_shared_secret( 262 | packet.challenge_token, self.rsa_key 263 | ) 264 | if challenge_token != self.challenge_token: 265 | self.kick("Invalid client reply") 266 | return 267 | packet.challenge_token = encryption.encrypt_shared_secret( 268 | self.other_side.challenge_token, 269 | self.other_side.rsa_key 270 | ) 271 | if auth: 272 | logger.info("Authenticating on server") 273 | auth.join_server(self.server_id, 274 | self.other_side.shared_secret, 275 | self.other_side.rsa_key) 276 | if check_auth: 277 | logger.info("Checking authenticity") 278 | if not authentication.Authenticator.check_player( 279 | self.username, self.other_side.server_id, 280 | self.shared_secret, self.rsa_key 281 | ): 282 | self.kick("Unable to verify username") 283 | return 284 | elif packet.id == 0xfc and self.side == 'server': 285 | logger.debug("Starting encryption") 286 | self.start_cipher() 287 | forwarding = True 288 | if self.plugin_mgr: 289 | forwarding = self.plugin_mgr.filter(packet, self.side) 290 | if forwarding and self.other_side is not None: 291 | self.other_side.send(packet.emit()) 292 | if packet.id == 0xfc and self.side == 'server': 293 | self.other_side.start_cipher() 294 | # Since we know we're at a message boundary, we can inject 295 | # any messages in the queue. 296 | while self.other_side: 297 | msg = self.plugin_mgr.next_injected_msg_from(self.side) 298 | if msg is None: 299 | break 300 | self.other_side.send(msg.emit()) 301 | 302 | except PartialPacketException: 303 | pass # Not all data for the current packet is available. 304 | except Exception: 305 | logger.error("MinecraftProxy for %s caught exception, out of sync" 306 | % self.side) 307 | logger.error(traceback.format_exc()) 308 | logger.debug("Current stream buffer: %s" % repr(self.stream.buf)) 309 | self.out_of_sync = True 310 | self.stream.reset() 311 | 312 | def kick(self, reason): 313 | self.send(self.protocol[0xff](reason=reason).emit()) 314 | self.handle_close() 315 | 316 | def handle_close(self): 317 | """Call shutdown handler.""" 318 | logger.info("%s socket closed.", self.side) 319 | self.close() 320 | if self.other_side is not None: 321 | logger.info("shutting down other side") 322 | self.other_side.other_side = None 323 | self.other_side.close() 324 | self.other_side = None 325 | logger.info("shutting down plugin manager") 326 | self.plugin_mgr.destroy() 327 | 328 | def start_cipher(self): 329 | self.recv_cipher = self.cipher(self.shared_secret) 330 | self.send_cipher = self.cipher(self.shared_secret) 331 | 332 | def recv(self, buffer_size): 333 | data = self.sock.recv(buffer_size) 334 | if not data: 335 | raise EOFException() 336 | if self.recv_cipher is None: 337 | return data 338 | return self.recv_cipher.decrypt(data) 339 | 340 | def send(self, data): 341 | if self.send_cipher is not None: 342 | data = self.send_cipher.encrypt(data) 343 | return self.sock.sendall(data) 344 | 345 | def close(self): 346 | self.closed = True 347 | self.sock.close() 348 | 349 | def _run(self): 350 | while not self.closed: 351 | try: 352 | self.handle_read() 353 | except EOFException: 354 | break 355 | gevent.sleep() 356 | self.close() 357 | self.handle_close() 358 | 359 | 360 | if __name__ == "__main__": 361 | logging.basicConfig(level=logging.ERROR) 362 | (host, port, opts, pcfg) = parse_args() 363 | 364 | if opts.logfile: 365 | config_logging(opts.logfile) 366 | 367 | if opts.loglvl: 368 | logging.root.setLevel(getattr(logging, opts.loglvl.upper())) 369 | 370 | if opts.user: 371 | while True: 372 | password = getpass("Minecraft account password: ") 373 | auth = authentication.YggdrasilAuthenticator(opts.user, password) 374 | logger.debug("Authenticating with %s" % opts.user) 375 | if auth.valid(): 376 | break 377 | logger.error("Authentication failed") 378 | logger.debug("Credentials are valid") 379 | 380 | if opts.authenticate or opts.password_file: 381 | if opts.authenticate: 382 | auth = authentication.AuthenticatorFactory() 383 | if auth is None or not auth.valid(): 384 | logger.error("Can't find valid authentication credentials. " + 385 | "Use --user or --password-file option instead.") 386 | sys.exit(1) 387 | else: 388 | try: 389 | with open(opts.password_file) as f: 390 | credentials = f.read().strip() 391 | except IOError as e: 392 | logger.error("Can't read password file: %s" % e) 393 | sys.exit(1) 394 | if ':' not in credentials: 395 | logger.error("Invalid password file") 396 | sys.exit(1) 397 | user = credentials[:credentials.find(':')] 398 | password = credentials[len(user)+1:] 399 | auth = authentication.YggdrasilAuthenticator(user, password) 400 | logger.debug("Authenticating with %s" % auth.username) 401 | if not auth.valid(): 402 | logger.error("Authentication failed") 403 | sys.exit(1) 404 | logger.debug("Authentication was successful") 405 | 406 | if opts.check_auth: 407 | check_auth = True 408 | 409 | server = ServerDispatcher(opts.locport, pcfg, host, port) 410 | 411 | # Install signal handler. 412 | gevent.signal(signal.SIGTERM, server.close) 413 | gevent.signal(signal.SIGINT, server.close) 414 | 415 | # I/O event loop. 416 | if opts.perf_data: 417 | logger.warn("Profiling enabled, saving data to %s" % opts.perf_data) 418 | import cProfile 419 | cProfile.run('server.serve_forever()', opts.perf_data) 420 | else: 421 | server.serve_forever() 422 | 423 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /mc4p/messages.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | 8 | # This program is free software; you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License v2 as published by 10 | # the Free Software Foundation. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License along 18 | # with this program; if not, write to the Free Software Foundation, Inc., 19 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 20 | 21 | from mc4p.parsing import * 22 | 23 | 24 | protocol = Protocol() 25 | 26 | 27 | ### GENERIC MESSAGES - Independent of protocol version 28 | with protocol.version(0): 29 | class MagicConstant(ClientMessage): 30 | id = 0x01 31 | 32 | class Handshake(ClientMessage): 33 | id = 0x02 34 | version = Byte() 35 | username = String() 36 | host = String() 37 | port = Int() 38 | 39 | class PluginMessage(Message): 40 | id = 0xfa 41 | channel = String() 42 | data = Data(Short()) 43 | 44 | class ServerListPing(ClientMessage): 45 | id = 0xfe 46 | 47 | class Diconnect(Message): 48 | id = 0xff 49 | reason = String() 50 | 51 | 52 | ### VERSION 61 - Corresponds to 1.5.2 53 | with protocol.version(61): 54 | class KeepAlive(Message): 55 | id = 0x00 56 | ping_id = Int() 57 | 58 | class Login(Message): 59 | id = 0x01 60 | eid = Int() 61 | level_type = String() 62 | game_mode = Byte() 63 | dimension = Byte() 64 | difficulty = Byte() 65 | unused = Byte() 66 | max_players = Byte() 67 | 68 | class ChatMessage(Message): 69 | id = 0x03 70 | message = String() 71 | 72 | class Time(ServerMessage): 73 | id = 0x04 74 | time = Long() 75 | day_time = Long() 76 | 77 | class EntityEquipment(ServerMessage): 78 | id = 0x05 79 | eid = Int() 80 | slot = Short() 81 | item = ItemStack() 82 | 83 | class SpawnPosition(ServerMessage): 84 | id = 0x06 85 | x = Int() 86 | y = Int() 87 | z = Int() 88 | 89 | class UseEntity(ClientMessage): 90 | id = 0x07 91 | eid = Int() 92 | target_eid = Int() 93 | left_click = Bool() 94 | 95 | class UpdateHealth(ServerMessage): 96 | id = 0x08 97 | health = Short() 98 | food = Short() 99 | food_saturation = Float() 100 | 101 | class Respawn(Message): 102 | id = 0x09 103 | world = Int() 104 | difficulty = Byte() 105 | mode = Byte() 106 | world_height = Short() 107 | level_type = String() 108 | 109 | class PlayerState(ClientMessage): 110 | id = 0x0a 111 | on_ground = Bool() 112 | 113 | class PlayerPosition(Message): 114 | id = 0x0b 115 | x = Double() 116 | y = Double() 117 | stance = Double() 118 | z = Double() 119 | on_ground = Bool() 120 | 121 | class PlayerLook(ClientMessage): 122 | id = 0x0c 123 | yaw = Float() 124 | pitch = Float() 125 | on_ground = Bool() 126 | 127 | class PlayerPositionAndLook(Message): 128 | id = 0x0d 129 | x = Double() 130 | y = Double() 131 | stance = Double() 132 | z = Double() 133 | yaw = Float() 134 | pitch = Float() 135 | on_ground = Bool() 136 | 137 | class Digging(Message): 138 | id = 0x0e 139 | status = Byte() 140 | x = Int() 141 | y = Byte() 142 | z = Int() 143 | face = Byte() 144 | 145 | class BlockPlacement(ClientMessage): 146 | id = 0x0f 147 | x = Int() 148 | y = Byte() 149 | z = Int() 150 | direction = Byte() 151 | item = ItemStack() 152 | block_x = Byte() 153 | block_y = Byte() 154 | block_z = Byte() 155 | 156 | class HeldItemSelection(Message): 157 | id = 0x10 158 | slot = Short() 159 | 160 | class UseBed(ServerMessage): 161 | id = 0x11 162 | eid = Int() 163 | in_bed = Bool() 164 | x = Int() 165 | y = Byte() 166 | z = Int() 167 | 168 | class ChangeAnimation(Message): 169 | id = 0x12 170 | eid = Int() 171 | animation = Byte() 172 | 173 | class EntityAction(Message): 174 | id = 0x13 175 | eid = Int() 176 | action = Byte() 177 | 178 | class EntitySpawn(ServerMessage): 179 | id = 0x14 180 | eid = Int() 181 | name = String() 182 | x = Int() 183 | y = Int() 184 | z = Int() 185 | rotation = Byte() 186 | pitch = Byte() 187 | curr_item = Short() 188 | metadata = Metadata() 189 | 190 | class PickupSpawn(Message): 191 | id = 0x15 192 | eid = Int() 193 | item = ItemStack() 194 | x = Int() 195 | y = Int() 196 | z = Int() 197 | rotation = Byte() 198 | pitch = Byte() 199 | roll = Byte() 200 | 201 | class CollectItem(ServerMessage): 202 | id = 0x16 203 | item_eid = Int() 204 | collector_eid = Int() 205 | 206 | class SpawnObject(ServerMessage): 207 | id = 0x17 208 | eid = Int() 209 | type = Byte() 210 | x = Int() 211 | y = Int() 212 | z = Int() 213 | pitch = Byte() 214 | yaw = Byte() 215 | data = Int() 216 | speed_x = Conditional(Short(), lambda msg: msg.data > 0) 217 | speed_y = Conditional(Short(), lambda msg: msg.data > 0) 218 | speed_z = Conditional(Short(), lambda msg: msg.data > 0) 219 | 220 | class MobSpawn(ServerMessage): 221 | id = 0x18 222 | eid = Int() 223 | mob_type = Byte() 224 | x = Int() 225 | y = Int() 226 | z = Int() 227 | yaw = Byte() 228 | pitch = Byte() 229 | head_yaw = Byte() 230 | u1 = Short() 231 | u2 = Short() 232 | u3 = Short() 233 | metadata = Metadata() 234 | 235 | class Painting(ServerMessage): 236 | id = 0x19 237 | eid = Int() 238 | title = String() 239 | x = Int() 240 | y = Int() 241 | z = Int() 242 | type = Int() 243 | 244 | class ExperienceOrb(ServerMessage): 245 | id = 0x1a 246 | eid = Int() 247 | x = Int() 248 | y = Int() 249 | z = Int() 250 | count = Short() 251 | 252 | class EntityVelocity(Message): 253 | id = 0x1c 254 | eid = Int() 255 | x = Short() 256 | y = Short() 257 | z = Short() 258 | 259 | class DestroyEntity(ServerMessage): 260 | id = 0x1d 261 | eids = List(Int(), size=Byte()) 262 | 263 | class Entity(ServerMessage): 264 | id = 0x1e 265 | eid = Int() 266 | 267 | class EntityRelativeMove(ServerMessage): 268 | id = 0x1f 269 | eid = Int() 270 | x = Byte() 271 | y = Byte() 272 | z = Byte() 273 | 274 | class EntityLook(ServerMessage): 275 | id = 0x20 276 | eid = Int() 277 | yaw = Byte() 278 | pitch = Byte() 279 | 280 | class EntityLookAndRelativeMove(ServerMessage): 281 | id = 0x21 282 | eid = Int() 283 | x = Byte() 284 | y = Byte() 285 | z = Byte() 286 | yaw = Byte() 287 | pitch = Byte() 288 | 289 | class EntityTeleport(ServerMessage): 290 | id = 0x22 291 | eid = Int() 292 | x = Int() 293 | y = Int() 294 | z = Int() 295 | yaw = Byte() 296 | pitch = Byte() 297 | 298 | class EntityHeadLook(ServerMessage): 299 | id = 0x23 300 | eid = Int() 301 | head_yaw = Byte() 302 | 303 | class EntityStatus(ServerMessage): 304 | id = 0x26 305 | eid = Int() 306 | status = Byte() 307 | 308 | class AttachEntity(Message): 309 | id = 0x27 310 | eid = Int() 311 | vehicle_id = Int() 312 | 313 | class EntityMetadata(Message): 314 | id = 0x28 315 | eid = Int() 316 | metadata = Metadata() 317 | 318 | class EntityEffect(Message): 319 | id = 0x29 320 | eid = Int() 321 | effect_id = Byte() 322 | amplifier = Byte() 323 | duration = Short() 324 | 325 | class RemoveEntityEffect(Message): 326 | id = 0x2a 327 | eid = Int() 328 | effect_id = Byte() 329 | 330 | class Experience(ServerMessage): 331 | id = 0x2b 332 | curr_exp = Float() 333 | level = Short() 334 | tot_exp = Short() 335 | 336 | class Chunk(ServerMessage): 337 | id = 0x33 338 | x = Int() 339 | z = Int() 340 | continuous = Bool() 341 | chunk_bitmap = Short() 342 | add_bitmap = Short() 343 | data = Data(Int()) 344 | 345 | class MultiBlockChange(ServerMessage): 346 | id = 0x34 347 | chunk_x = Int() 348 | chunk_z = Int() 349 | block_count = Short() # This does not get updated automatically 350 | changes = Data(Int()) 351 | 352 | class BlockChange(ServerMessage): 353 | id = 0x35 354 | x = Int() 355 | y = Byte() 356 | z = Int() 357 | block_type = Short() 358 | block_metadata = Byte() 359 | 360 | class BlockAction(ServerMessage): 361 | id = 0x36 362 | x = Int() 363 | y = Short() 364 | z = Int() 365 | instrument_type = Byte() 366 | pitch = Byte() 367 | type = Short() 368 | 369 | class BlockMining(ServerMessage): 370 | id = 0x37 371 | eid = Int() 372 | x = Int() 373 | y = Int() 374 | z = Int() 375 | status = Byte() 376 | 377 | class ChunkBulk(ServerMessage): 378 | id = 0x38 379 | chunk_count = Short() 380 | data_length = Int() 381 | sky_light = Bool() 382 | data = Data("data_length") 383 | metadata = List(Object( 384 | x=Int(), 385 | z=Int(), 386 | chunk_bitmap=Short(), 387 | add_bitmap=Short() 388 | ), size="chunk_count") 389 | 390 | class Explosion(ServerMessage): 391 | id = 0x3c 392 | x = Double() 393 | y = Double() 394 | z = Double() 395 | radius = Float() 396 | records = List(Object( 397 | x=Byte(), 398 | y=Byte(), 399 | z=Byte() 400 | ), size=Int()) 401 | push_x = Float() 402 | push_y = Float() 403 | push_z = Float() 404 | 405 | class SoundEffect(ServerMessage): 406 | id = 0x3d 407 | effect_id = Int() 408 | x = Int() 409 | y = Byte() 410 | z = Int() 411 | data = Int() 412 | constant_volume = Bool() 413 | 414 | class NamedSoundEffect(ServerMessage): 415 | id = 0x3e 416 | sound_name = String() 417 | x = Int() 418 | y = Int() 419 | z = Int() 420 | volume = Float() 421 | pitch = Byte() 422 | 423 | class ParticleEffect(ServerMessage): 424 | id = 0x3f 425 | particle_name = String() 426 | x = Float() 427 | y = Float() 428 | z = Float() 429 | offset_x = Float() 430 | offset_y = Float() 431 | offset_z = Float() 432 | speed = Float() 433 | number = Int() 434 | 435 | class ChangeGameState(Message): 436 | id = 0x46 437 | reason = Byte() 438 | game_mode = Byte() 439 | 440 | class SpawnGlobalEntity(ServerMessage): 441 | id = 0x47 442 | eid = Int() 443 | type = Byte() 444 | x = Int() 445 | y = Int() 446 | z = Int() 447 | 448 | class OpenWindow(ServerMessage): 449 | id = 0x64 450 | window_id = Byte() 451 | type = Byte() 452 | title = String() 453 | slot_count = Byte() 454 | custom_title = Bool() 455 | 456 | class CloseWindow(Message): 457 | id = 0x65 458 | window_id = Byte() 459 | 460 | class WindowClick(ClientMessage): 461 | id = 0x66 462 | window_id = Byte() 463 | slot = Short() 464 | right_click = Byte() 465 | action_id = Short() 466 | shift = Byte() 467 | details = ItemStack() 468 | 469 | class SetSlot(ServerMessage): 470 | id = 0x67 471 | window_id = Byte() 472 | slot = Short() 473 | item = ItemStack() 474 | 475 | class WindowItems(ServerMessage): 476 | id = 0x68 477 | window_id = Byte() 478 | items = List(ItemStack(), Short()) 479 | 480 | class UpdateProgressBar(ServerMessage): 481 | id = 0x69 482 | window_id = Byte() 483 | progress_bar = Short() 484 | value = Short() 485 | 486 | class Transaction(Message): 487 | id = 0x6a 488 | window_id = Byte() 489 | action_id = Short() 490 | accepted = Bool() 491 | 492 | class CreativeInventoryAction(Message): 493 | id = 0x6b 494 | slot = Short() 495 | details = ItemStack() 496 | 497 | class EnchantItem(ClientMessage): 498 | id = 0x6c 499 | window_id = Byte() 500 | enchantment = Byte() 501 | 502 | class UpdateSign(Message): 503 | id = 0x82 504 | x = Int() 505 | y = Short() 506 | z = Int() 507 | text1 = String() 508 | text2 = String() 509 | text3 = String() 510 | text4 = String() 511 | 512 | class ItemData(Message): 513 | id = 0x83 514 | item_type = Short() 515 | item_id = Short() 516 | data = Data(Short()) 517 | 518 | class UpdateTileEntity(ServerMessage): 519 | id = 0x84 520 | x = Int() 521 | y = Short() 522 | z = Int() 523 | action = Byte() 524 | data = Data(Short()) 525 | 526 | class IncrementStatistic(ServerMessage): 527 | id = 0xc8 528 | stat_id = Int() 529 | amount = Byte() 530 | 531 | class PlayerListItem(ServerMessage): 532 | id = 0xc9 533 | name = String() 534 | online = Bool() 535 | ping = Short() 536 | 537 | class Abilities(Message): 538 | id = 0xca 539 | abilities = Byte() 540 | flying_speed = Byte() 541 | walking_speed = Byte() 542 | 543 | class TabCompletion(Message): 544 | id = 0xcb 545 | text = String() 546 | 547 | class Settings(ClientMessage): 548 | id = 0xcc 549 | locale = String() 550 | view_distance = Byte() 551 | chat_flags = Byte() 552 | difficulty = Byte() 553 | show_cape = Bool() 554 | 555 | class ClientStatus(Message): 556 | id = 0xcd 557 | payload = Byte() 558 | 559 | class CreateScoreboard(ServerMessage): 560 | id = 0xce 561 | name = String() 562 | display_text = String() 563 | remove = Byte() 564 | 565 | class UpdateScore(ServerMessage): 566 | id = 0xcf 567 | item_name = String() 568 | remove = Byte() 569 | score_name = Conditional(String(), lambda msg: msg.remove != 1) 570 | value = Conditional(Int(), lambda msg: msg.remove != 1) 571 | 572 | class DisplayScoreboard(ServerMessage): 573 | id = 0xd0 574 | position = Byte() 575 | score_name = String() 576 | 577 | class Teams(ServerMessage): 578 | id = 0xd1 579 | name = String() 580 | mode = Byte() 581 | display_name = Conditional(String(), lambda msg: msg.mode in (0, 2)) 582 | prefix = Conditional(String(), lambda msg: msg.mode in (0, 2)) 583 | suffix = Conditional(String(), lambda msg: msg.mode in (0, 2)) 584 | friendly_fire = Conditional(Bool(), lambda msg: msg.mode in (0, 2)) 585 | players = Conditional(List(String(), size=Short()), 586 | lambda msg: msg.mode in (0, 3, 4)) 587 | 588 | class EncryptionKeyResponse(Message): 589 | id = 0xfc 590 | shared_secret = Data(Short()) 591 | challenge_token = Data(Short()) 592 | 593 | class EncryptionKeyRequest(ServerMessage): 594 | id = 0xfd 595 | server_id = String() 596 | public_key = Data(Short()) 597 | challenge_token = Data(Short()) 598 | 599 | 600 | ### VERSION 72 - Corresponds to 1.6 601 | with protocol.version(72): 602 | class UpdateHealth(ServerMessage): 603 | id = 0x08 604 | health = Float() 605 | food = Short() 606 | food_saturation = Float() 607 | 608 | class EntityAction(Message): 609 | id = 0x13 610 | eid = Int() 611 | action = Byte() 612 | unknown = Int() 613 | 614 | class SteerVehicle(Message): 615 | id = 0x1b 616 | sideways = Float() 617 | forward = Float() 618 | jump = Bool() 619 | unmount = Bool() 620 | 621 | class AttachEntity(Message): 622 | id = 0x27 623 | eid = Int() 624 | vehicle_id = Int() 625 | leash = Bool() 626 | 627 | class EntityProperties(ServerMessage): 628 | id = 0x2c 629 | eid = Int() 630 | properties = Dict(String(), Double(), size=Int()) 631 | 632 | class OpenWindow(ServerMessage): 633 | id = 0x64 634 | window_id = Byte() 635 | type = Byte() 636 | title = String() 637 | slot_count = Byte() 638 | custom_title = Byte() 639 | eid = Conditional(Int(), lambda msg: msg.type == 11) 640 | 641 | class IncrementStatistic(ServerMessage): 642 | id = 0xc8 643 | stat_id = Int() 644 | amount = Int() 645 | 646 | class Abilities(Message): 647 | id = 0xca 648 | abilities = Byte() 649 | flying_speed = Float() 650 | walking_speed = Float() 651 | 652 | 653 | ### VERSION 74 - Corresponds to 1.6.2 654 | with protocol.version(74): 655 | class EntityProperties(ServerMessage): 656 | id = 0x2c 657 | eid = Int() 658 | properties = Dict(String(), Object( 659 | value=Double(), 660 | list=List(Object( 661 | most_significant=Long(), 662 | least_significant=Long(), 663 | amount=Double(), 664 | operation=Byte() 665 | ), size=Short()) 666 | ), size=Int()) 667 | 668 | class OpenTileEditor(ServerMessage): 669 | id = 0x85 670 | unknown = Byte() 671 | x = Int() 672 | y = Int() 673 | z = Int() 674 | 675 | 676 | ### VERSION 75 - Corresponds to 13w36a 677 | with protocol.version(75): 678 | class NamedSoundEffect(ServerMessage): 679 | id = 0x3e 680 | sound_name = String() 681 | x = Int() 682 | y = Int() 683 | z = Int() 684 | volume = Float() 685 | pitch = Byte() 686 | category = Byte() 687 | 688 | class IncrementStatistic(ServerMessage): 689 | id = 0xc8 690 | stats = Dict(String(), Int(), size=Int()) 691 | -------------------------------------------------------------------------------- /mc4p/debug.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # This source file is part of mc4p, 4 | # the Minecraft Portable Protocol-Parsing Proxy. 5 | # 6 | # Copyright (C) 2011 Matthew J. McGill, Simon Marti 7 | # 8 | # This program is free software. It comes without any warranty, to 9 | # the extent permitted by applicable law. You can redistribute it 10 | # and/or modify it under the terms of the Do What The Fuck You Want 11 | # To Public License, Version 2, as published by Sam Hocevar. See 12 | # http://sam.zoy.org/wtfpl/COPYING for more details 13 | 14 | import socket 15 | import sys 16 | from struct import pack, unpack 17 | from optparse import OptionParser 18 | from contextlib import closing 19 | from random import choice 20 | from zlib import compress, decompress 21 | 22 | import encryption 23 | from messages import protocol 24 | from parsing import parse_unsigned_byte 25 | from authentication import Authenticator 26 | from proxy import UnsupportedPacketException 27 | 28 | 29 | class MockTerminal(object): 30 | class ColorTag(str): 31 | def __call__(self, string): 32 | return string 33 | 34 | def __str__(self): 35 | return "" 36 | 37 | def __getattr__(self, name): 38 | return self.ColorTag() 39 | 40 | 41 | output_width = 79 42 | if sys.platform == "win32": 43 | is_terminal = False 44 | t = MockTerminal() 45 | else: 46 | from blessings import Terminal 47 | t = Terminal() 48 | is_terminal = t.is_a_tty 49 | if is_terminal: 50 | output_width = max(t.width, output_width) 51 | 52 | 53 | def parse_args(): 54 | """Return options, or print usage and exit.""" 55 | usage = "Usage: %prog [options] [port]" 56 | desc = ("Create a Minecraft server listening for a client connection.") 57 | parser = OptionParser(usage=usage, description=desc) 58 | parser.add_option("-c", "--send-chunks", dest="send_chunks", 59 | action="store_true", default=False, 60 | help="send some more packets after the encrypted " + 61 | "connection was established") 62 | parser.add_option("-s", "--stay-connected", dest="stay_connected", 63 | action="store_true", default=False, 64 | help="don't disconnect after a successfull handshake") 65 | (opts, args) = parser.parse_args() 66 | 67 | if not 0 <= len(args) <= 1: 68 | parser.error("Incorrect number of arguments.") 69 | 70 | port = 25565 71 | if len(args) > 0: 72 | try: 73 | port = int(args[0]) 74 | except ValueError: 75 | parser.error("Invalid port %s" % args[0]) 76 | 77 | return (port, opts) 78 | 79 | 80 | class LoggingSocketStream(object): 81 | def __init__(self, sock): 82 | self.sock = sock 83 | 84 | def __enter__(self): 85 | sys.stdout.write(t.bold("Receiving raw bytes: ")) 86 | 87 | def read(self, length): 88 | data = "" 89 | for i in range(length): 90 | data += self._read_byte() 91 | sys.stdout.write(" ") 92 | return data 93 | 94 | def _read_byte(self): 95 | byte = self.sock.recv(1) 96 | if byte == '': 97 | raise EOFException() 98 | sys.stdout.write("%02x " % ord(byte)) 99 | return byte 100 | 101 | def __exit__(self, exception_type, exception_value, traceback): 102 | sys.stdout.write("\n") 103 | 104 | 105 | class LoggingSocketCipherStream(LoggingSocketStream): 106 | def __init__(self, sock, cipher): 107 | self.sock = sock 108 | self.cipher = cipher 109 | self.data = [] 110 | self.pos = 21 111 | 112 | def __enter__(self): 113 | if is_terminal: 114 | sys.stdout.write(t.bold("Receiving raw bytes: ") + t.move_down) 115 | sys.stdout.write(t.bold("Decrypted bytes: ") + t.move_up) 116 | else: 117 | sys.stdout.write("Receiving raw bytes: ") 118 | 119 | def read(self, length): 120 | data = super(LoggingSocketCipherStream, self).read(length) 121 | self.pos += 1 122 | self.data.append(data) 123 | return data 124 | 125 | def _read_byte(self): 126 | byte = super(LoggingSocketCipherStream, self)._read_byte() 127 | decrypted = self.cipher.decrypt(byte) 128 | if is_terminal: 129 | sys.stdout.write(''.join((t.move_down, t.move_right * self.pos, 130 | "%02x " % ord(decrypted), t.move_up))) 131 | self.pos += 3 132 | return decrypted 133 | 134 | def __exit__(self, exception_type, exception_value, traceback): 135 | if is_terminal: 136 | sys.stdout.write(t.move_down * 2) 137 | else: 138 | sys.stdout.write("\n") 139 | sys.stdout.write("Decrypted bytes: ") 140 | print " ".join( 141 | " ".join("%02x" % ord(c) for c in field) for field in self.data 142 | ) 143 | 144 | 145 | class PacketFormatter(object): 146 | IGNORE = ('msgtype', 'raw_bytes') 147 | 148 | @classmethod 149 | def print_packet(cls, packet): 150 | formatter = "_format_packet_%02x" % packet['msgtype'] 151 | substitutes = {} 152 | lengths = [len(field) for field in packet if field not in cls.IGNORE] 153 | if not lengths: 154 | return 155 | maxlen = max(lengths) 156 | if hasattr(cls, formatter): 157 | substitutes = getattr(cls, formatter)(packet, maxlen + 4) 158 | print t.bold("Packet content:") 159 | for field in packet: 160 | if field in cls.IGNORE: 161 | continue 162 | if field in substitutes: 163 | value = substitutes[field] 164 | if value is None: 165 | continue 166 | if isinstance(value, tuple) or isinstance(value, list): 167 | value = cls._multi_line(value, maxlen + 4) 168 | else: 169 | value = packet[field] 170 | print " %s:%s %s" % (field, " " * (maxlen - len(field)), value) 171 | 172 | @classmethod 173 | def bytes(cls, bytes, prefix="", prefix_format=None): 174 | prefix_length = len(prefix) 175 | if prefix_format is not None: 176 | prefix = prefix_format(prefix) 177 | return cls._multi_line(cls._bytes(bytes, prefix, 0, prefix_length), 0) 178 | 179 | @classmethod 180 | def _format_packet_fd(cls, packet, prelen): 181 | key = encryption.decode_public_key(packet['public_key']) 182 | modulus = cls._split_lines("%x" % key.key.n, "modulus: 0x", prelen) 183 | token = ' '.join("%02x" % ord(c) for c in packet['challenge_token']) 184 | raw = cls._bytes(packet['public_key'], "raw: ", prelen) 185 | return {'challenge_token': token, 186 | 'public_key': ["exponent: 0x%x" % key.key.e] + modulus + raw} 187 | 188 | @classmethod 189 | def _format_packet_38(cls, packet, prelen): 190 | data = cls._bytes(packet['chunks']['data'], "data: ", prelen) 191 | meta = cls._table(packet['chunks']['metadata'], "meta: ", prelen) 192 | return {'chunks': data + meta} 193 | 194 | @classmethod 195 | def _format_packet_fc(cls, packet, prelen): 196 | token = cls._bytes(packet['challenge_token'], prelen=prelen) 197 | secret = cls._bytes(packet['shared_secret'], prelen=prelen) 198 | return {'challenge_token': token, 199 | 'shared_secret': secret} 200 | 201 | @staticmethod 202 | def _table(items, prefix, prelen=0): 203 | if not items: 204 | return [prefix + "Empty"] 205 | titles = items[0].keys() 206 | maxlen = [len(title) for title in titles] 207 | for i in range(len(titles)): 208 | title = titles[i] 209 | for item in items: 210 | if len(str(item[title])) > maxlen[i]: 211 | maxlen[i] = len(str(item[title])) 212 | 213 | def row(values, title=False): 214 | if title: 215 | line = prefix 216 | else: 217 | line = " " * len(prefix) 218 | for i in range(len(values)): 219 | value = values[i] 220 | l = maxlen[i] 221 | if isinstance(value, str): 222 | line += value + " " * (l - len(value) + 1) 223 | else: 224 | line += " " * (l - len(str(value))) + str(value) + " " 225 | return line 226 | 227 | def separator(): 228 | return " " * len(prefix) + "-".join("-" * l for l in maxlen) 229 | 230 | lines = [row(titles, title=True), separator()] 231 | for item in items: 232 | lines.append(row([item[title] for title in titles])) 233 | return lines 234 | 235 | @classmethod 236 | def _bytes(cls, bytes, prefix="", prelen=0, prefix_length=None): 237 | return cls._split_lines(" ".join("%02x" % ord(c) for c in bytes), 238 | prefix=prefix, prelen=prelen, 239 | prefix_length=prefix_length, partlen=3) 240 | 241 | @staticmethod 242 | def _split_lines(text, prefix="", prelen=0, prefix_length=None, partlen=1): 243 | lines = [] 244 | prefix_length = prefix_length or len(prefix) 245 | length = output_width - prelen - prefix_length 246 | length = length - length % partlen 247 | for i in range(0, len(text), length): 248 | line = prefix if i == 0 else " " * prefix_length 249 | line += text[i:min(i + length, len(text))] 250 | lines.append(line) 251 | return lines 252 | 253 | @staticmethod 254 | def _multi_line(lines, offset): 255 | return ("\n" + " " * offset).join(lines) 256 | 257 | 258 | class Server(object): 259 | def __init__(self, port, send_chunks=False, stay_connected=False): 260 | self.port = port 261 | self.send_chunks = send_chunks 262 | self.stay_connected = stay_connected 263 | 264 | def start(self): 265 | with closing(socket.socket(socket.AF_INET, 266 | socket.SOCK_STREAM)) as srvsock: 267 | srvsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 268 | srvsock.bind(("", self.port)) 269 | srvsock.listen(1) 270 | print t.bold("Listening on port %s" % self.port) 271 | while True: 272 | try: 273 | self._handle_client(srvsock.accept()) 274 | except UnexpectedPacketException, e: 275 | print t.bold_red("\nError:"), 276 | print "Received unexpected 0x%02x packet" % (e.id_) 277 | if e.encrypted_id is not None: 278 | print t.bold("\nExpected message id (encrypted):"), 279 | print "%02x" % e.encrypted_id 280 | except UnsupportedPacketException, e: 281 | print t.bold_red("\nError:"), 282 | print "Received unsupported 0x%02x packet" % (e.id_) 283 | except EOFException: 284 | print t.bold_red("\nError:"), 285 | print "Socket closed by client" 286 | print t.bold("\nConnection closed") 287 | 288 | def _handle_client(self, connection): 289 | with closing(connection[0]) as sock: 290 | clt_spec, srv_spec = protocol[0] 291 | print t.bold("\nConnected to %s:%s" % connection[1]) 292 | 293 | print t.bold_cyan("\nExpecting Server Ping (0xfe) " + 294 | "or Handshake (0x02) packet") 295 | packet = parse_packet(sock, clt_spec) 296 | if packet['msgtype'] == 0xfe: 297 | motd = '\x00'.join((u'§1', # magic 298 | str(max(protocol.keys())), # version 299 | 'latest', # version string 300 | 'mc4p debugger', # motd 301 | '0', 302 | '1')) 303 | 304 | send_packet(sock, srv_spec, {'msgtype': 0xff, 305 | 'reason': motd}) 306 | return 307 | elif packet['msgtype'] != 0x02: 308 | raise UnexpectedPacketException(packet['msgtype']) 309 | if packet['proto_version'] < 38: 310 | print t.bold_red("Error:"), 311 | print "Unsupported protocol version" 312 | return 313 | username = packet['username'] 314 | clt_spec, srv_spec = protocol[packet['proto_version']] 315 | 316 | print t.bold("\nGenerating RSA key pair") 317 | key = encryption.generate_key_pair() 318 | challenge = encryption.generate_challenge_token() 319 | server_id = encryption.generate_server_id() 320 | 321 | packet = {'msgtype': 0xfd, 322 | 'server_id': server_id, 323 | 'public_key': encryption.encode_public_key(key), 324 | 'challenge_token': challenge} 325 | send_packet(sock, srv_spec, packet) 326 | 327 | packet = parse_packet(sock, clt_spec, 0xfc) 328 | try: 329 | decrypted_token = encryption.decrypt_shared_secret( 330 | packet['challenge_token'], key 331 | ) 332 | except: 333 | decrypted_token = None 334 | if decrypted_token is None: 335 | try: 336 | decrypted_token = key.decrypt(packet['challenge_token']) 337 | except: 338 | pass 339 | if decrypted_token == challenge: 340 | print t.bold_red("\nError:"), 341 | print ("The challenge token was not padded " + 342 | "correctly. See ftp://ftp.rsasecurity.com/pub/" + 343 | "pkcs/pkcs-1/pkcs-1v2-1.pdf section 7.2.1 if " + 344 | "your library does not support PKCS#1 padding.") 345 | else: 346 | print t.bold_red("\nError:"), 347 | print "The challenge token is not encrypted correctly.\n" 348 | print PacketFormatter.bytes(decrypted_token, 349 | "Decrypted bytes: ", t.bold) 350 | return 351 | elif decrypted_token != challenge: 352 | print t.bold_red("\nError:"), 353 | print "Received challenge token does not", 354 | print "match the expected value.\n" 355 | print PacketFormatter.bytes(decrypted_token, 356 | "Received bytes: ", t.bold) 357 | print 358 | print PacketFormatter.bytes(challenge, 359 | "Expected bytes: ", t.bold) 360 | return 361 | secret = encryption.decrypt_shared_secret(packet['shared_secret'], 362 | key) 363 | if secret is None: 364 | print t.bold_red("\nError:"), 365 | print ("The shared secret was not padded" + 366 | "correctly. See ftp://ftp.rsasecurity.com/pub/" + 367 | "pkcs/pkcs-1/pkcs-1v2-1.pdf section 7.2.1 if " + 368 | "your library does not support PKCS#1 padding.") 369 | return 370 | print PacketFormatter.bytes(secret, "Shared secret: ", t.bold) 371 | if len(secret) != 16: 372 | print t.bold_red("\nError:"), 373 | print "The shared secret must be 16 bytes long", 374 | print "(received length is %s)" % len(secret) 375 | return 376 | 377 | print t.bold_cyan("\nAuthentication") 378 | print PacketFormatter.bytes(server_id, "Server ID: ", t.bold) 379 | print PacketFormatter.bytes(secret, "Shared secret: ", t.bold) 380 | print PacketFormatter.bytes(encryption.encode_public_key(key), 381 | "Public key: ", t.bold) 382 | print t.bold("Login hash: "), 383 | print Authenticator.login_hash(server_id, secret, key) 384 | if Authenticator.check_player(username, server_id, secret, key): 385 | print t.bold_green("Success:"), "You are authenticated" 386 | else: 387 | print t.bold_yellow("Warning:"), "You are not authenticated" 388 | 389 | send_packet(sock, srv_spec, {'msgtype': 0xfc, 390 | 'challenge_token': '', 391 | 'shared_secret': ''}) 392 | 393 | print t.bold("\nStarting AES encryption") 394 | clt_cipher = encryption.AES128CFB8(secret) 395 | srv_cipher = encryption.AES128CFB8(secret) 396 | backup_cipher = encryption.AES128CFB8(secret) 397 | 398 | parse_packet(sock, clt_spec, 0xcd, clt_cipher, backup_cipher) 399 | 400 | send_packet(sock, srv_spec, {'msgtype': 0x01, 401 | 'eid': 1337, 402 | 'level_type': 'flat', 403 | 'server_mode': 0, 404 | 'dimension': 0, 405 | 'difficulty': 2, 406 | 'unused': 0, 407 | 'max_players': 20}, srv_cipher) 408 | 409 | if self.send_chunks: 410 | while True: 411 | print 412 | packet = parse_packet(sock, clt_spec, cipher=clt_cipher) 413 | if packet['msgtype'] == 0x0d: 414 | break 415 | 416 | x, y, z = 5, 9, 5 417 | 418 | send_packet(sock, srv_spec, {'msgtype': 0x06, 419 | 'x': x, 420 | 'y': y, 421 | 'z': z}, srv_cipher) 422 | 423 | send_packet(sock, srv_spec, {'msgtype': 0xca, 424 | 'abilities': 0b0100, 425 | 'walking_speed': 25, 426 | 'flying_speed': 12}, srv_cipher) 427 | 428 | send_packet(sock, srv_spec, {'msgtype': 0x04, 429 | 'time': 0}, srv_cipher) 430 | 431 | send_packet(sock, srv_spec, multi_chunk_packet(), srv_cipher) 432 | 433 | send_packet(sock, srv_spec, {'msgtype': 0x0d, 434 | 'x': x, 435 | 'y': y, 436 | 'stance': y + 1.5, 437 | 'z': z, 438 | 'yaw': 0, 439 | 'pitch': 0, 440 | 'on_ground': False}, srv_cipher) 441 | 442 | buffer = StringSocket() 443 | 444 | send_packet(buffer, srv_spec, 445 | {'msgtype': 0x03, 446 | 'chat_msg': 'First message'}, 447 | srv_cipher) 448 | 449 | send_packet(buffer, srv_spec, 450 | {'msgtype': 0x03, 451 | 'chat_msg': 'Second message'}, srv_cipher) 452 | 453 | sock.sendall(buffer.data) 454 | 455 | if self.stay_connected: 456 | while True: 457 | packet = parse_packet(sock, clt_spec, cipher=clt_cipher, 458 | title=True) 459 | if packet['msgtype'] == 0xff: 460 | break 461 | elif packet['msgtype'] == 0x00: 462 | send_packet(buffer, srv_spec, {'msgtype': 0x00, 463 | 'id': 0}, srv_cipher) 464 | break 465 | else: 466 | send_packet(sock, srv_spec, 467 | {'msgtype': 0xff, 468 | 'reason': "Successfully logged in"}, srv_cipher) 469 | 470 | 471 | def parse_packet(sock, msg_spec, expecting=None, 472 | cipher=None, backup_cipher=None, title=False): 473 | if expecting is not None: 474 | packet = msg_spec[expecting] 475 | print t.bold_cyan("\nExpecting %s (0x%02x) packet" % (packet.name, 476 | expecting)) 477 | if title and is_terminal: 478 | sys.stdout.write(t.move_down * 2) 479 | elif title: 480 | print 481 | 482 | if cipher is None: 483 | stream = LoggingSocketStream(sock) 484 | else: 485 | stream = LoggingSocketCipherStream(sock, cipher) 486 | with stream: 487 | msgtype = parse_unsigned_byte(stream) 488 | if expecting is not None and msgtype != expecting: 489 | if backup_cipher is None: 490 | raise UnexpectedPacketException(msgtype) 491 | else: 492 | raise UnexpectedPacketException( 493 | msgtype, ord(backup_cipher.encrypt(chr(expecting))) 494 | ) 495 | if not msg_spec[msgtype]: 496 | raise UnsupportedPacketException(msgtype) 497 | msg_parser = msg_spec[msgtype] 498 | msg = msg_parser.parse(stream) 499 | if title: 500 | if is_terminal: 501 | sys.stdout.write(t.move_up * 3) 502 | sys.stdout.write(t.bold_cyan("Received %s (0x%02x) packet" % 503 | (msg_parser.name, msgtype))) 504 | if is_terminal: 505 | sys.stdout.write(t.move_down * 3) 506 | else: 507 | print 508 | PacketFormatter.print_packet(msg) 509 | if backup_cipher is not None: 510 | backup_cipher.encrypt(msg_parser.emit(msg)) 511 | return msg 512 | 513 | 514 | def send_packet(sock, msg_spec, msg, cipher=None): 515 | packet = msg_spec[msg['msgtype']] 516 | msgbytes = packet.emit(msg) 517 | print t.bold_cyan("\nSending %s (0x%02x) packet" % (packet.name, 518 | msg['msgtype'])) 519 | if cipher is None: 520 | print t.bold("Raw bytes:"), ''.join("%02x " % ord(c) for c in msgbytes) 521 | else: 522 | print t.bold("Raw bytes: "), 523 | print ''.join("%02x " % ord(c) for c in msgbytes) 524 | msgbytes = cipher.encrypt(msgbytes) 525 | print t.bold("Encrypted bytes:"), 526 | print ''.join("%02x " % ord(c) for c in msgbytes) 527 | PacketFormatter.print_packet(msg) 528 | sock.sendall(msgbytes) 529 | 530 | 531 | def multi_chunk_packet(radius=4): 532 | d = 1 + 2 * radius 533 | data = compress("".join(random_chunk() for i in range(d ** 2))) 534 | meta = [{'x': i / d - radius, 535 | 'z': i % d - radius, 536 | 'bitmap': 1, 537 | 'add_bitmap': 0} for i in range(d ** 2)] 538 | return {'msgtype': 0x38, 539 | 'chunks': {'data': data, 'metadata': meta}} 540 | 541 | 542 | def random_chunk(): 543 | block_ids = [chr(x) for x in range(1, 6)] 544 | blocks = "".join(choice(block_ids) for i in range(16 * 16 * 8)) 545 | blocks += '\x00' * 8 * 16 * 16 546 | meta = '\x00' * 16 * 16 * 8 547 | light = '\x00' * 12 * 16 * 16 + '\xff' * 4 * 16 * 16 548 | biome = '\x01' * 16 * 16 549 | return blocks + meta + light + biome 550 | 551 | 552 | def flat_chunk(): 553 | blocks = '\x07' * 16 * 16 554 | blocks += '\x03' * 2 * 16 * 16 555 | blocks += '\x02' * 16 * 16 556 | blocks += '\x00' * 12 * 16 * 16 557 | meta = '\x00' * 16 * 16 * 8 558 | light = '\x00' * 10 * 16 * 16 + '\xff' * 6 * 16 * 16 559 | biome = '\x01' * 16 * 16 560 | return blocks + meta + light + biome 561 | 562 | 563 | class UnexpectedPacketException(Exception): 564 | def __init__(self, id_, encrypted_id=None): 565 | self.id_ = id_ 566 | self.encrypted_id = encrypted_id 567 | 568 | 569 | class EOFException(Exception): 570 | pass 571 | 572 | 573 | class StringSocket(object): 574 | def __init__(self): 575 | self.data = "" 576 | 577 | def send(self, data): 578 | self.data += data 579 | 580 | def sendall(self, data): 581 | self.send(data) 582 | 583 | 584 | if __name__ == "__main__": 585 | raise NotImplementedError 586 | (port, opts) = parse_args() 587 | 588 | try: 589 | Server(port, opts.send_chunks, opts.stay_connected).start() 590 | except KeyboardInterrupt: 591 | pass 592 | --------------------------------------------------------------------------------