├── src └── mcidle │ ├── __init__.py │ ├── networking │ ├── __init__.py │ ├── packets │ │ ├── __init__.py │ │ ├── exceptions │ │ │ └── __init__.py │ │ ├── packet_buffer.py │ │ ├── serverbound │ │ │ └── __init__.py │ │ ├── packet.py │ │ └── clientbound │ │ │ └── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── profile.py │ │ ├── exceptions.py │ │ └── auth.py │ ├── types │ │ ├── __init__.py │ │ ├── utility.py │ │ └── type.py │ ├── packet_handler │ │ ├── clientbound │ │ │ ├── __init__.py │ │ │ └── login_handler.py │ │ ├── serverbound │ │ │ ├── __init__.py │ │ │ ├── idle_handler.py │ │ │ └── login_handler.py │ │ ├── __init__.py │ │ ├── worker_processor.py │ │ ├── packet_handler.py │ │ └── packet_processor.py │ ├── game_state.py │ ├── listen_thread.py │ ├── upstream.py │ ├── anti_afk.py │ ├── encryption.py │ └── connection.py │ └── cli.py ├── setup.py ├── requirements.txt ├── pyproject.toml ├── Pipfile ├── LICENSE ├── setup.cfg ├── .gitignore ├── README.md └── Pipfile.lock /src/mcidle/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcidle/networking/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/mcidle/networking/packets/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | setuptools.setup() 4 | -------------------------------------------------------------------------------- /src/mcidle/networking/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth import Auth -------------------------------------------------------------------------------- /src/mcidle/networking/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .type import * 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cryptography==2.4.2 2 | requests==2.20.1 3 | nbt==1.5.0 -------------------------------------------------------------------------------- /src/mcidle/networking/packets/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | class InvalidPacketID(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/clientbound/__init__.py: -------------------------------------------------------------------------------- 1 | from .login_handler import LoginHandler 2 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/serverbound/__init__.py: -------------------------------------------------------------------------------- 1 | from .idle_handler import IdleHandler 2 | from .login_handler import LoginHandler 3 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel", "setuptools_scm[toml]>=3.4"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/__init__.py: -------------------------------------------------------------------------------- 1 | from .packet_handler import PacketHandler 2 | from .worker_processor import WorkerProcessor 3 | from .packet_processor import ClientboundProcessor -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | name = "pypi" 3 | url = "https://pypi.org/simple" 4 | verify_ssl = true 5 | 6 | [dev-packages] 7 | setuptools-scm = {extras = ["toml"], version = "*"} 8 | 9 | [packages] 10 | cryptography = "==2.4.2" 11 | requests = "==2.20.1" 12 | NBT = "==1.5.0" 13 | mcidle = {editable = true, path = "."} 14 | 15 | [requires] 16 | python_version = "3.8" 17 | -------------------------------------------------------------------------------- /src/mcidle/networking/packets/packet_buffer.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | 4 | class PacketBuffer: 5 | """ Wrapper around BytesIO """ 6 | def __init__(self): 7 | self.bytes_ = BytesIO() 8 | 9 | def write(self, value): 10 | return self.bytes_.write(value) 11 | 12 | def read(self, length=None): 13 | return self.bytes_.read(length) 14 | 15 | def clear(self): 16 | self.bytes_ = BytesIO() 17 | 18 | def reset_cursor(self): 19 | self.bytes_.seek(0) 20 | 21 | def __len__(self): 22 | return len(self.bytes) 23 | 24 | @property 25 | def bytes(self): 26 | return self.bytes_.getvalue() 27 | 28 | # Hex representation of bytes array 29 | def __str__(self): 30 | return ' '.join(["%02X" % b for b in self.bytes_.getvalue()]) 31 | -------------------------------------------------------------------------------- /src/mcidle/networking/auth/profile.py: -------------------------------------------------------------------------------- 1 | class Profile(object): 2 | """ 3 | Container class for a MineCraft Selected profile. 4 | See: ``_ 5 | """ 6 | def __init__(self, id_=None, name=None): 7 | self.id_ = id_ 8 | self.name = name 9 | 10 | def to_dict(self): 11 | """ 12 | Returns ``self`` in dictionary-form, which can be serialized by json. 13 | """ 14 | if self: 15 | return {"id": self.id_, 16 | "name": self.name} 17 | else: 18 | raise AttributeError("Profile is not yet populated.") 19 | 20 | def __bool__(self): 21 | bool_state = self.id_ is not None and self.name is not None 22 | return bool_state 23 | 24 | # Python 2 support 25 | def __nonzero__(self): 26 | return self.__bool__() 27 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/worker_processor.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from multiprocessing import Queue 4 | 5 | 6 | # Starts a worker processor thread to process packets 7 | # and optionally write any responses in a thread-safe manner 8 | class WorkerProcessor(threading.Thread): 9 | def __init__(self, connection, packet_processor): 10 | threading.Thread.__init__(self, daemon=True) 11 | self.connection = connection 12 | self.packet_processor = packet_processor 13 | self.queue = Queue() 14 | self.running = True 15 | 16 | def enqueue(self, packet): 17 | self.queue.put(packet) 18 | 19 | def stop(self): 20 | self.running = False 21 | 22 | def run(self): 23 | while self.running: 24 | if not self.queue.empty(): 25 | packet = self.queue.get() 26 | response = self.packet_processor.process_packet(packet) 27 | 28 | if response: 29 | self.connection.send_packet(response) 30 | -------------------------------------------------------------------------------- /src/mcidle/networking/game_state.py: -------------------------------------------------------------------------------- 1 | from threading import RLock 2 | 3 | 4 | class GameState: 5 | def __init__(self, join_ids=[]): 6 | self.held_item_slot = 0 7 | self.last_pos_packet = None 8 | self.last_yaw = 0 9 | self.last_pitch = 0 10 | self.teleport_id = 0 11 | 12 | self.gamemode = None 13 | 14 | self.client_uuid = None 15 | self.client_username = None 16 | 17 | self.abilities = None 18 | 19 | self.player_pos = None 20 | 21 | self.state_lock = RLock() 22 | 23 | self.received_position = False 24 | self.update_health = None 25 | 26 | # Every other packet goes here 27 | self.packet_log = {} 28 | 29 | self.main_inventory = {} 30 | self.chunks = {} 31 | self.player_list = {} 32 | self.entities = {} 33 | 34 | self.join_ids = join_ids 35 | 36 | def acquire(self): 37 | self.state_lock.acquire() 38 | 39 | def release(self): 40 | self.state_lock.release() 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 cub 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/mcidle/networking/listen_thread.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | 4 | 5 | class ListenThread(threading.Thread): 6 | def __init__(self, address): 7 | self.address = address 8 | threading.Thread.__init__(self, daemon=True) 9 | self.socket = socket.socket() 10 | self.server = None 11 | self.server_lock = threading.RLock() 12 | self.running = True 13 | 14 | def set_server(self, server): 15 | with self.server_lock: 16 | self.server = server 17 | return self 18 | 19 | def run(self): 20 | self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 21 | self.socket.bind(self.address) 22 | self.socket.listen(1) # Listen for 1 incoming connection 23 | 24 | while self.running: 25 | try: 26 | (connection, address) = self.socket.accept() 27 | 28 | with self.server_lock: 29 | if self.server: 30 | print("Client connected", flush=True) 31 | self.server.start_with_socket(connection) 32 | except OSError: 33 | print("Failed to bind socket (race condition?), it's already on", flush=True) 34 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mcidle 3 | author = qubard 4 | author-email = qubard@gmail.com 5 | home-page = https://github.com/qubard/mcidle-python/ 6 | description = Idling middleware for deploying a remote Minecraft connection to allow reconnection at any time. 7 | long-description = file: README.md 8 | long_description_content_type = text/markdown 9 | license = MIT 10 | license-file = LICENSE 11 | platform = any 12 | keywords = minecraft bouncer 13 | classifiers = 14 | Development Status :: 3 - Alpha 15 | License :: OSI Approved :: MIT License 16 | Operating System :: OS Independent 17 | Programming Language :: Python 18 | Programming Language :: Python :: 3.6 19 | Programming Language :: Python :: 3.7 20 | Programming Language :: Python :: 3.8 21 | project_urls = 22 | Bug Tracker = https://github.com/qubard/mcidle-python/issues/ 23 | 24 | [options] 25 | zip_safe = False 26 | packages = find: 27 | include_package_data = True 28 | package_dir = 29 | =src 30 | install_requires = 31 | cryptography~=2.4.0 32 | requests~=2.20.0 33 | NBT~=1.5.0 34 | 35 | [options.packages.find] 36 | where = src 37 | 38 | [options.entry_points] 39 | console_scripts = 40 | mcidle = mcidle.cli:main 41 | 42 | [bdist_wheel] 43 | universal = 1 44 | -------------------------------------------------------------------------------- /src/mcidle/networking/upstream.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | from multiprocessing import Queue 4 | 5 | 6 | class UpstreamThread(threading.Thread): 7 | def __init__(self): 8 | threading.Thread.__init__(self, daemon=True) 9 | self.queue = Queue() 10 | self.socket = None 11 | self.socket_lock = threading.RLock() 12 | self.running = True 13 | 14 | def set_socket(self, socket): 15 | self.clear() 16 | with self.socket_lock: 17 | self.socket = socket 18 | 19 | def connected(self): 20 | with self.socket_lock: 21 | return self.socket is not None 22 | 23 | def put(self, b): 24 | self.queue.put(b) 25 | 26 | def clear(self): 27 | while not self.queue.empty(): 28 | self.queue.get() 29 | 30 | def stop(self): 31 | self.set_socket(None) 32 | self.running = False 33 | 34 | def run(self): 35 | while self.running: 36 | if not self.queue.empty(): 37 | # Acquire the lock since socket can be None when set in another thread 38 | with self.socket_lock: 39 | if self.socket: 40 | while not self.queue.empty(): 41 | pkt = self.queue.get() 42 | try: 43 | self.socket.send(pkt) 44 | except Exception as _: 45 | pass # Keep on throwing exceptions until we get a new socket 46 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/serverbound/idle_handler.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.packet_handler import PacketHandler 2 | from mcidle.networking.packets.clientbound import KeepAlive 3 | 4 | import select 5 | 6 | 7 | class IdleHandler(PacketHandler): 8 | # Idling occurs when we've disconnected our client or have yet to connect 9 | def handle(self): 10 | while self.running: 11 | try: 12 | # Read a packet from the target server 13 | ready_to_read = select.select([self.connection.stream], [], [], self._timeout)[0] 14 | if ready_to_read: 15 | packet = self.read_packet_from_stream() 16 | if packet: 17 | # Entirely thread safe (worker processor only read, not destroyed) 18 | self.connection.worker_processor.enqueue(packet) 19 | 20 | # Forward the packets if a client is connected 21 | # Ignore KeepAlive's because those are processed by worker threads 22 | # This is thread safe because the old connection is only reassigned never deleted 23 | # so while we have a reference it can't be None 24 | # Since client_upstream is set in another thread it is wrapped in an RLock 25 | if packet.id != KeepAlive.id: 26 | self.connection.send_to_client(packet) 27 | else: 28 | raise EOFError() 29 | except EOFError: 30 | print("Disconnected from server, closing", flush=True) 31 | self.connection.on_disconnect() 32 | break 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | credentials.json 7 | 8 | .out 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | .idea/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # pyenv 82 | .python-version 83 | 84 | # celery beat schedule file 85 | celerybeat-schedule 86 | 87 | # SageMath parsed files 88 | *.sage.py 89 | 90 | # Environments 91 | .env 92 | .venv 93 | env/ 94 | venv/ 95 | ENV/ 96 | env.bak/ 97 | venv.bak/ 98 | 99 | # Spyder project settings 100 | .spyderproject 101 | .spyproject 102 | 103 | # Rope project settings 104 | .ropeproject 105 | 106 | # mkdocs documentation 107 | /site 108 | 109 | # mypy 110 | .mypy_cache/ 111 | -------------------------------------------------------------------------------- /src/mcidle/networking/anti_afk.py: -------------------------------------------------------------------------------- 1 | import threading 2 | import time 3 | 4 | from mcidle.networking.packets.serverbound import Animation, ChatMessage, PlayerLook, PlayerPosition 5 | 6 | from random import randint, uniform 7 | 8 | 9 | class AntiAFKThread(threading.Thread): 10 | def __init__(self, connection, rate=30): 11 | threading.Thread.__init__(self, daemon=True) 12 | self.connection = connection 13 | self.rate = rate 14 | self.running = True 15 | 16 | def stop(self): 17 | self.running = False 18 | 19 | def run(self): 20 | while self.running: 21 | if self.connection.game_state.received_position \ 22 | and not (self.connection.client_upstream and self.connection.client_upstream.connected()): 23 | print("Sent AntiAFK packet", flush=True) 24 | # Try spamming /help 25 | self.connection.send_packet(ChatMessage(Message="/help")) 26 | # Swing arm randomly 27 | self.connection.send_packet(Animation(Hand=randint(0, 1))) 28 | 29 | if self.connection.game_state.player_pos: 30 | # Move 3 blocks ahead and back 31 | for off in range(0, 30): 32 | pos = self.connection.game_state.player_pos 33 | self.connection.send_packet(PlayerPosition(X=pos[0] + 0.1 * off, \ 34 | Y=pos[1] + 0.1, Z=pos[2], OnGround=True)) 35 | time.sleep(0.1) 36 | 37 | for off in range(30, 0, -1): 38 | pos = self.connection.game_state.player_pos 39 | self.connection.send_packet(PlayerPosition(X=pos[0] + 0.1 * off, \ 40 | Y=pos[1] + 0.1, Z=pos[2], OnGround=True)) 41 | time.sleep(0.1) 42 | 43 | print(self.connection.game_state.player_pos, flush=True) 44 | 45 | # Look around 46 | self.connection.send_packet(PlayerLook(Yaw=uniform(0, 360), Pitch=uniform(0, 360), OnGround=True)) 47 | time.sleep(self.rate) 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/mcidle/networking/auth/exceptions.py: -------------------------------------------------------------------------------- 1 | """ Boilerplate from https://github.com/ammaraskar/pyCraft/blob/master/minecraft/exceptions.py """ 2 | class YggdrasilError(Exception): 3 | """ 4 | Base `Exception` for the Yggdrasil authentication service. 5 | :param str message: A human-readable string representation of the error. 6 | :param int status_code: Initial value of :attr:`status_code`. 7 | :param str yggdrasil_error: Initial value of :attr:`yggdrasil_error`. 8 | :param str yggdrasil_message: Initial value of :attr:`yggdrasil_message`. 9 | :param str yggdrasil_cause: Initial value of :attr:`yggdrasil_cause`. 10 | """ 11 | 12 | def __init__( 13 | self, 14 | message=None, 15 | status_code=None, 16 | yggdrasil_error=None, 17 | yggdrasil_message=None, 18 | yggdrasil_cause=None, 19 | ): 20 | super(YggdrasilError, self).__init__(message) 21 | self.status_code = status_code 22 | self.yggdrasil_error = yggdrasil_error 23 | self.yggdrasil_message = yggdrasil_message 24 | self.yggdrasil_cause = yggdrasil_cause 25 | 26 | status_code = None 27 | """`int` or `None`. The associated HTTP status code. May be set.""" 28 | 29 | yggdrasil_error = None 30 | """`str` or `None`. The `"error"` field of the Yggdrasil response: a short 31 | description such as `"Method Not Allowed"` or 32 | `"ForbiddenOperationException"`. May be set. 33 | """ 34 | 35 | yggdrasil_message = None 36 | """`str` or `None`. The `"errorMessage"` field of the Yggdrasil response: 37 | a longer description such as `"Invalid credentials. Invalid username or 38 | password."`. May be set. 39 | """ 40 | 41 | yggdrasil_cause = None 42 | """`str` or `None`. The `"cause"` field of the Yggdrasil response: a string 43 | containing additional information about the error. May be set. 44 | """ 45 | 46 | 47 | class ConnectionFailure(Exception): 48 | """Raised by 'minecraft.networking.Connection' when a connection attempt 49 | fails. 50 | """ 51 | 52 | 53 | class VersionMismatch(ConnectionFailure): 54 | """Raised by 'minecraft.networking.Connection' when connection is not 55 | possible due to a difference between the server's and client's 56 | supported protocol versions. 57 | """ 58 | 59 | 60 | class LoginDisconnect(ConnectionFailure): 61 | """Raised by 'minecraft.networking.Connection' when a connection attempt 62 | is terminated by the server sending a Disconnect packet, during login, 63 | with an unknown message format. 64 | """ 65 | 66 | 67 | class InvalidState(ConnectionFailure): 68 | """Raised by 'minecraft.networking.Connection' when a connection attempt 69 | fails due to to the internal state of the Connection being unsuitable, 70 | for example if there is an existing ongoing connection. 71 | """ 72 | 73 | 74 | class IgnorePacket(Exception): 75 | """This exception may be raised from within a packet handler, such as 76 | `PacketReactor.react' or a packet listener added with 77 | `Connection.register_packet_listener', to stop any subsequent handlers 78 | from being called on that particular packet. 79 | """ -------------------------------------------------------------------------------- /src/mcidle/networking/types/utility.py: -------------------------------------------------------------------------------- 1 | """Minecraft data types that are used by packets, but don't have a specific 2 | network representation. 3 | """ 4 | from __future__ import division 5 | from collections import namedtuple 6 | 7 | 8 | class Vector(namedtuple('BaseVector', ('x', 'y', 'z'))): 9 | """An immutable type usually used to represent 3D spatial coordinates, 10 | supporting elementwise vector addition, subtraction, and negation; and 11 | scalar multiplication and (right) division. 12 | NOTE: subclasses of 'Vector' should have '__slots__ = ()' to avoid the 13 | creation of a '__dict__' attribute, which would waste space. 14 | """ 15 | __slots__ = () 16 | 17 | def __add__(self, other): 18 | return NotImplemented if not isinstance(other, Vector) else \ 19 | type(self)(self.x + other.x, self.y + other.y, self.z + other.z) 20 | 21 | def __sub__(self, other): 22 | return NotImplemented if not isinstance(other, Vector) else \ 23 | type(self)(self.x - other.x, self.y - other.y, self.z - other.z) 24 | 25 | def __neg__(self): 26 | return type(self)(-self.x, -self.y, -self.z) 27 | 28 | def __mul__(self, other): 29 | return type(self)(self.x*other, self.y*other, self.z*other) 30 | 31 | def __rmul__(self, other): 32 | return type(self)(other*self.x, other*self.y, other*self.z) 33 | 34 | def __truediv__(self, other): 35 | return type(self)(self.x/other, self.y/other, self.z/other) 36 | 37 | def __floordiv__(self, other): 38 | return type(self)(self.x//other, self.y//other, self.z//other) 39 | 40 | __div__ = __floordiv__ 41 | 42 | def __repr__(self): 43 | return '%s(%r, %r, %r)' % (type(self).__name__, self.x, self.y, self.z) 44 | 45 | 46 | class MutableRecord(object): 47 | """An abstract base class providing namedtuple-like repr(), ==, and hash() 48 | implementations for types containing mutable fields given by __slots__. 49 | """ 50 | __slots__ = () 51 | 52 | def __init__(self, **kwds): 53 | for attr, value in kwds.items(): 54 | setattr(self, attr, value) 55 | 56 | def __repr__(self): 57 | return '%s(%s)' % (type(self).__name__, ', '.join( 58 | '%s=%r' % (a, getattr(self, a)) for a in self.__slots__)) 59 | 60 | def __eq__(self, other): 61 | return type(self) is type(other) and \ 62 | all(getattr(self, a) == getattr(other, a) for a in self.__slots__) 63 | 64 | def __ne__(self, other): 65 | return not (self == other) 66 | 67 | def __hash__(self): 68 | values = tuple(getattr(self, a, None) for a in self.__slots__) 69 | return hash((type(self), values)) 70 | 71 | 72 | class PositionAndLook(MutableRecord): 73 | """A mutable record containing 3 spatial position coordinates 74 | and 2 rotational coordinates for a look direction. 75 | """ 76 | __slots__ = 'x', 'y', 'z', 'yaw', 'pitch' 77 | 78 | # Access the fields 'x', 'y', 'z' as a Vector. 79 | def position(self, position): 80 | self.x, self.y, self.z = position 81 | position = property(lambda self: Vector(self.x, self.y, self.z), position) 82 | 83 | # Access the fields 'yaw', 'pitch' as a tuple. 84 | def look(self, look): 85 | self.yaw, self.pitch = look 86 | look = property(lambda self: (self.yaw, self.pitch), look) 87 | -------------------------------------------------------------------------------- /src/mcidle/networking/packets/serverbound/__init__.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.packets.packet import Packet 2 | from mcidle.networking.types import String, Long, UnsignedShort, VarInt, VarIntPrefixedByteArray, \ 3 | Double, Float, Boolean, UnsignedByte, Short, Byte 4 | 5 | """ 6 | Note: not using an OrderedDict for `definition` will break 7 | in anything older than Python 3.7.1 (the keys will not be in order) 8 | """ 9 | 10 | 11 | class TeleportConfirm(Packet): 12 | id = 0x00 13 | definition = { 14 | "TeleportID": VarInt 15 | } 16 | 17 | 18 | class Handshake(Packet): 19 | id = 0x00 20 | definition = { 21 | "ProtocolVersion": VarInt, 22 | "ServerAddress": String, 23 | "ServerPort": UnsignedShort, 24 | "NextState": VarInt 25 | } 26 | 27 | 28 | class ClientStatus(Packet): 29 | id = 0x41 30 | definition = { 31 | "ActionID": VarInt, 32 | } 33 | 34 | 35 | class HeldItemChange(Packet): 36 | id = 0x1A 37 | definition = { 38 | "Slot": Short 39 | } 40 | 41 | 42 | class Animation(Packet): 43 | id = 0x1D 44 | definition = { 45 | "Hand": VarInt 46 | } 47 | 48 | 49 | class PlayerAbilities(Packet): 50 | id = 0x13 51 | definition = { 52 | "Flags": Byte, 53 | "FlyingSpeed": Float, 54 | "WalkingSpeed": Float, 55 | } 56 | 57 | 58 | class LoginStart(Packet): 59 | id = 0x00 60 | definition = { 61 | "Name": String 62 | } 63 | 64 | 65 | class EncryptionResponse(Packet): 66 | id = 0x01 67 | definition = { 68 | "SharedSecret": VarIntPrefixedByteArray, 69 | "VerifyToken": VarIntPrefixedByteArray 70 | } 71 | 72 | 73 | class ChatMessage(Packet): 74 | id = 0x02 75 | definition = { 76 | "Message": String 77 | } 78 | 79 | 80 | class EntityAction(Packet): 81 | id = 0x15 82 | definition = { 83 | "EntityID": VarInt, 84 | "ActionID": VarInt, 85 | "JumpBoost": VarInt, 86 | } 87 | 88 | 89 | class ClientStatus(Packet): 90 | id = 0x03 91 | definition = { 92 | "ActionID": VarInt 93 | } 94 | 95 | 96 | class PlayerLook(Packet): 97 | id = 0x0F 98 | definition = { 99 | "Yaw": Float, 100 | "Pitch": Float, 101 | "OnGround": Boolean, 102 | } 103 | 104 | 105 | class KeepAlive(Packet): 106 | id = 0x0B 107 | definition = { 108 | "KeepAliveID": Long 109 | } 110 | 111 | 112 | class PlayerPosition(Packet): 113 | id = 0x0D 114 | definition = { 115 | "X": Double, 116 | "Y": Double, 117 | "Z": Double, 118 | "OnGround": Boolean 119 | } 120 | 121 | 122 | class PlayerPositionAndLook(Packet): 123 | id = 0x0E 124 | definition = { 125 | "X": Double, 126 | "Y": Double, 127 | "Z": Double, 128 | "Yaw": Float, 129 | "Pitch": Float, 130 | "OnGround": Boolean 131 | } 132 | 133 | 134 | class Player(Packet): 135 | id = 0x0C 136 | definition = { 137 | "OnGround": Boolean 138 | } 139 | 140 | 141 | class ClickWindow(Packet): 142 | id = 0x07 143 | definition = { 144 | "WindowID": UnsignedByte, 145 | "Slot": Short, 146 | "Button": Byte, 147 | "ActionNumber": Short, 148 | "Mode": VarInt, 149 | "ClickedSlot": VarInt 150 | } 151 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/packet_handler.py: -------------------------------------------------------------------------------- 1 | from zlib import decompress 2 | from mcidle.networking.types import VarInt 3 | from mcidle.networking.packets.packet_buffer import PacketBuffer 4 | from mcidle.networking.packets.packet import Packet 5 | 6 | 7 | class PacketHandler: 8 | _timeout = 0.05 9 | 10 | """ Generic packet handler responsible for processing incoming packets """ 11 | def __init__(self, connection): 12 | self.connection = connection 13 | self.running = True 14 | self.nextHandler = None 15 | 16 | """ Setup the packet handler 17 | Return whether or not setup succeeded 18 | """ 19 | def setup(self): 20 | raise NotImplementedError("setup() is not implemented!") 21 | 22 | """ Called when setup() succeeds """ 23 | def on_setup(self): 24 | pass 25 | 26 | """ Default behaviour is to consume packets """ 27 | def handle(self): 28 | pass 29 | 30 | def stop(self): 31 | self.running = False 32 | 33 | def is_running(self): 34 | return self.running 35 | 36 | def next_handler(self): 37 | return self.nextHandler 38 | 39 | # TODO: Maybe put this in a separate class, like PacketStreamReader that takes a connection? 40 | """ Read the next packet from the stream """ 41 | def read_packet_from_stream(self): 42 | packet_buffer = PacketBuffer() 43 | 44 | try: 45 | length = VarInt.read(self.connection.stream) 46 | data = self.connection.stream.read(length) 47 | except (ConnectionAbortedError, ConnectionResetError, EOFError, AttributeError) as e: 48 | print("Exception", e) 49 | return None 50 | 51 | id_buffer = PacketBuffer() 52 | 53 | # Decompress if needed 54 | threshold = self.connection.compression_threshold 55 | 56 | if threshold is not None and threshold >= 0: 57 | tmp_buf = PacketBuffer() 58 | tmp_buf.write(data) 59 | tmp_buf.reset_cursor() # Need to reset to read off the compression byte(s) 60 | 61 | decompressed_length = VarInt.read(tmp_buf) 62 | is_compressed = decompressed_length > 0 63 | 64 | if is_compressed: 65 | # Read all the remaining bytes past the compression indicator into the packet buffer 66 | decompressed_data = decompress(tmp_buf.read()) 67 | assert(len(decompressed_data) == decompressed_length) 68 | id_buffer.write(decompressed_data[:5]) # Only need the first 5 bytes for an ID 69 | packet_buffer.write(decompressed_data) 70 | else: 71 | id_buffer.write(data[1:6]) # Ignore the compression=0 byte 72 | packet_buffer.write(data[1:]) 73 | else: 74 | id_buffer.write(data[:5]) 75 | packet_buffer.write(data) 76 | 77 | id_buffer.reset_cursor() 78 | id_ = VarInt.read(id_buffer) 79 | 80 | # Write a compressed buffer with its length and compression indicator 81 | # This packet may or may not actually be compressed 82 | # Storing the compressed buffer helps w/ performance since we don't have to re-compress it 83 | compressed_buffer = PacketBuffer() 84 | VarInt.write(length, compressed_buffer) 85 | compressed_buffer.write(data) 86 | compressed_buffer.reset_cursor() 87 | 88 | packet_buffer.reset_cursor() 89 | 90 | return Packet(packet_buffer_=packet_buffer, compressed_buffer=compressed_buffer, id=id_) 91 | -------------------------------------------------------------------------------- /src/mcidle/networking/encryption.py: -------------------------------------------------------------------------------- 1 | import os 2 | from hashlib import sha1 3 | from cryptography.hazmat.backends import default_backend 4 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 5 | from cryptography.hazmat.primitives.serialization import load_der_public_key 6 | from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes 7 | 8 | from threading import RLock 9 | 10 | 11 | def generate_shared_secret(): 12 | return os.urandom(16) 13 | 14 | 15 | def create_AES_cipher(shared_secret): 16 | cipher = Cipher(algorithms.AES(shared_secret), modes.CFB8(shared_secret), 17 | backend=default_backend()) 18 | return cipher 19 | 20 | 21 | def encrypt_token_and_secret(pubkey, verification_token, shared_secret): 22 | """Encrypts the verification token and shared secret 23 | with the server's public key. 24 | 25 | :param pubkey: The RSA public key provided by the server 26 | :param verification_token: The verification token provided by the server 27 | :param shared_secret: The generated shared secret 28 | :return: A tuple containing (encrypted token, encrypted secret) 29 | """ 30 | pubkey = load_der_public_key(pubkey, default_backend()) 31 | 32 | encrypted_token = pubkey.encrypt(verification_token, PKCS1v15()) 33 | encrypted_secret = pubkey.encrypt(shared_secret, PKCS1v15()) 34 | return encrypted_token, encrypted_secret 35 | 36 | 37 | def generate_verification_hash(server_id, shared_secret, public_key): 38 | verification_hash = sha1() 39 | 40 | verification_hash.update(server_id.encode('utf-8')) 41 | verification_hash.update(shared_secret) 42 | verification_hash.update(public_key) 43 | 44 | return minecraft_sha1_hash_digest(verification_hash) 45 | 46 | 47 | def minecraft_sha1_hash_digest(sha1_hash): 48 | # Minecraft first parses the sha1 bytes as a signed number and then 49 | # spits outs its hex representation 50 | number_representation = _number_from_bytes(sha1_hash.digest(), signed=True) 51 | return format(number_representation, 'x') 52 | 53 | 54 | def _number_from_bytes(b, signed=False): 55 | try: 56 | return int.from_bytes(b, byteorder='big', signed=signed) 57 | except AttributeError: # pragma: no cover 58 | # py-2 compatibility 59 | if len(b) == 0: 60 | b = b'\x00' 61 | num = int(str(b).encode('hex'), 16) 62 | if signed and (ord(b[0]) & 0x80): 63 | num -= 2 ** (len(b) * 8) 64 | return num 65 | 66 | 67 | class EncryptedFileObjectWrapper(object): 68 | def __init__(self, file_object, decryptor): 69 | self.actual_file_object = file_object 70 | self.decryptor = decryptor 71 | self.lock = RLock() 72 | 73 | def read(self, length): 74 | with self.lock: 75 | return self.decryptor.update(self.actual_file_object.read(length)) 76 | 77 | def fileno(self): 78 | return self.actual_file_object.fileno() 79 | 80 | def close(self): 81 | self.actual_file_object.close() 82 | 83 | 84 | class EncryptedSocketWrapper(object): 85 | def __init__(self, socket, encryptor, decryptor): 86 | self.actual_socket = socket 87 | self.encryptor = encryptor 88 | self.decryptor = decryptor 89 | self.lock = RLock() 90 | 91 | def recv(self, length): 92 | with self.lock: 93 | return self.decryptor.update(self.actual_socket.recv(length)) 94 | 95 | def send(self, data): 96 | with self.lock: 97 | self.actual_socket.send(self.encryptor.update(data)) 98 | 99 | def fileno(self): 100 | return self.actual_socket.fileno() 101 | 102 | def close(self): 103 | return self.actual_socket.close() 104 | 105 | def shutdown(self, *args, **kwds): 106 | return self.actual_socket.shutdown(*args, **kwds) 107 | -------------------------------------------------------------------------------- /src/mcidle/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | 3 | from mcidle.networking.auth import Auth 4 | from mcidle.networking.listen_thread import ListenThread 5 | from mcidle.networking.connection import MinecraftConnection 6 | 7 | parser = argparse.ArgumentParser(add_help=True) 8 | parser.add_argument('--ip', help='The ip address of the server to connect to (e.g localhost)') 9 | parser.add_argument('--port', default=25565, type=int, help='The port of the server to connect to (default=25565)') 10 | parser.add_argument('--protocol', default=340, type=int, help='The protocol version of the server to connect to (default=340)') 11 | parser.add_argument('--username', help='Your Mojang account username (an email or legacy name)') 12 | parser.add_argument('--password', help='Your Mojang account password') 13 | parser.add_argument('--dport', default=1337, type=int, help='The port to connect to with mcidle (default=1337)') 14 | parser.add_argument('--bindip', default='', help='The IP to bind to with mcidle') 15 | parser.add_argument('--reconnect', default=10, type=int, help='The reconnect rate in seconds') 16 | args = parser.parse_args() 17 | 18 | 19 | def update_credentials(username, password): 20 | if not Auth.has_credentials(): 21 | if username is None or username is None: 22 | raise ValueError("Please provide both your username and password.") 23 | 24 | auth = Auth() 25 | Auth.save_to_disk(auth.authenticate(username=username, password=password)) 26 | 27 | 28 | def try_auth(username, password): 29 | try: 30 | credentials = Auth.read_from_disk() 31 | except FileNotFoundError: 32 | print("Credentials not found..", flush=True) 33 | try: 34 | update_credentials(username, password) 35 | credentials = Auth.read_from_disk() 36 | except Exception as e: 37 | return None 38 | 39 | auth = Auth().assign_profile(credentials) 40 | 41 | if not auth.validate(): 42 | Auth.delete_credentials() 43 | return None # Invalid credentials 44 | else: 45 | print("Credentials are valid!", flush=True) 46 | return credentials 47 | 48 | 49 | def main(): 50 | if args.ip is None: 51 | raise RuntimeError("Please specify an ip address!") 52 | 53 | # We use this to listen for incoming connections 54 | listen_thread = ListenThread(address=(args.bindip, args.dport)) 55 | listen_thread.start() 56 | 57 | # We do this loop because the session information may be invalidated at any point 58 | # Due to restarting the Minecraft client over and over 59 | # So when we reconnect we need to generate potentially new credentials to avoid session errors 60 | import time 61 | 62 | while True: 63 | print("Trying to auth..", flush=True) 64 | try: 65 | credentials = try_auth(args.username, args.password) # Make sure we can still auth 66 | except: 67 | print("Invalid password or blocked from auth server for reconnecting too fast..", flush=True) 68 | 69 | print("Finished auth", flush=True) 70 | if credentials: 71 | print("Starting..", flush=True) 72 | listen_thread.set_server(None) 73 | conn = MinecraftConnection(ip=args.ip, port=args.port, server_port=args.dport, protocol=args.protocol, \ 74 | username=credentials['selectedProfile']['name'], profile=credentials, \ 75 | listen_thread=listen_thread) 76 | conn.run_handler() 77 | conn.stop() 78 | print("Disconnected..reconnecting in %s seconds" % args.reconnect, flush=True) 79 | time.sleep(args.reconnect) 80 | print("Reconnecting..", flush=True) 81 | else: 82 | if not args.username or not args.password: 83 | print("Can't re-auth user because no user or password provided!", flush=True) 84 | return 85 | print("Username or password wrong, waiting 15 seconds before reconnecting..") 86 | time.sleep(15) 87 | 88 | 89 | if __name__ == '__main__': 90 | main() 91 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/serverbound/login_handler.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.encryption import encrypt_token_and_secret, generate_verification_hash, generate_shared_secret 2 | from mcidle.networking.packets.serverbound import Handshake, LoginStart, EncryptionResponse 3 | from mcidle.networking.packets.clientbound import EncryptionRequest, SetCompression, LoginSuccess 4 | from mcidle.networking.packet_handler import PacketHandler 5 | from mcidle.networking.packets.exceptions import InvalidPacketID 6 | 7 | from .idle_handler import IdleHandler 8 | 9 | 10 | class LoginHandler(PacketHandler): 11 | def on_setup(self): 12 | print("Switched to idling.", flush=True) 13 | self.nextHandler = IdleHandler(self.connection) 14 | 15 | """ Do all the authentication and logging in""" 16 | def setup(self): 17 | # Send a handshake and login start packet 18 | try: 19 | handshake = Handshake(ProtocolVersion=self.connection.protocol, ServerAddress=self.connection.address[0], \ 20 | ServerPort=self.connection.address[1], NextState=2) 21 | login_start = LoginStart(Name=self.connection.username) 22 | 23 | print("Sending handshake", flush=True) 24 | self.connection.send_packet_raw(handshake) 25 | print("Done sending handshake", flush=True) 26 | self.connection.send_packet_raw(login_start) 27 | 28 | encryption_request = EncryptionRequest().read(self.read_packet_from_stream().packet_buffer) 29 | 30 | self.connection.VerifyToken = encryption_request.VerifyToken 31 | 32 | # Generate the encryption response to send over 33 | shared_secret = generate_shared_secret() 34 | (encrypted_token, encrypted_secret) = encrypt_token_and_secret(encryption_request.PublicKey, 35 | encryption_request.VerifyToken, shared_secret) 36 | encryption_response = EncryptionResponse(SharedSecret=encrypted_secret, VerifyToken=encrypted_token) 37 | 38 | # Generate an auth token, serverID is always empty 39 | server_id_hash = generate_verification_hash(encryption_request.ServerID, shared_secret, 40 | encryption_request.PublicKey) 41 | 42 | # Client auth 43 | self.connection.auth.join(server_id_hash) 44 | 45 | # Send the encryption response 46 | self.connection.send_packet_raw(encryption_response) 47 | 48 | # Enable encryption using the shared secret 49 | self.connection.enable_encryption(shared_secret) 50 | 51 | print("Enabled encryption", flush=True) 52 | 53 | # Enable compression and set the threshold 54 | # We aren't sure if compression will be sent, or LoginSuccess immediately after 55 | unknown_packet = self.read_packet_from_stream().packet_buffer 56 | 57 | print("Unknown packet", unknown_packet, flush=True) 58 | except (EOFError, ValueError, AttributeError, \ 59 | ConnectionResetError, ConnectionAbortedError, ConnectionRefusedError, ConnectionError): 60 | # Fail to join 61 | return False 62 | 63 | try: 64 | set_compression = SetCompression().read(unknown_packet) 65 | self.connection.compression_threshold = set_compression.Threshold 66 | print("Set compression threshold to %s" % self.connection.compression_threshold) 67 | 68 | login_success = LoginSuccess().read(self.read_packet_from_stream().packet_buffer) 69 | 70 | self.connection.game_state.client_uuid = login_success.UUID 71 | self.connection.game_state.client_username = login_success.Username 72 | 73 | print(login_success.UUID, login_success.Username, flush=True) 74 | except InvalidPacketID: 75 | print("Skipping compression..invalid compression packet") 76 | unknown_packet.reset_cursor() 77 | self.connection.compression_threshold = -1 # disabled 78 | 79 | self.connection.get_upstream().start() 80 | return False 81 | 82 | self.connection.upstream.start() 83 | # Start listening for a connection only if we've officially connected 84 | self.connection.start_server() 85 | 86 | return True 87 | 88 | 89 | -------------------------------------------------------------------------------- /src/mcidle/networking/packets/packet.py: -------------------------------------------------------------------------------- 1 | from .packet_buffer import PacketBuffer 2 | from ..types import VarInt 3 | from zlib import compress 4 | from base64 import b64encode 5 | 6 | from .exceptions import InvalidPacketID 7 | 8 | 9 | class Packet: 10 | id = None 11 | ids = None 12 | definition = None 13 | 14 | def __init__(self, **kwargs): 15 | self.packet_buffer_ = PacketBuffer() 16 | self.assert_fields(**kwargs) 17 | self.set_fields(**kwargs) 18 | 19 | def set_fields(self, **kwargs): 20 | for k, v in kwargs.items(): 21 | setattr(self, k, v) 22 | 23 | @property 24 | def bytes(self): 25 | return self.packet_buffer_.bytes 26 | 27 | @property 28 | def packet_buffer(self): 29 | return self.packet_buffer_ 30 | 31 | def clear(self): 32 | self.packet_buffer_ = PacketBuffer() 33 | 34 | @property 35 | def buffer(self): 36 | return self.packet_buffer_ 37 | 38 | """ Ensure that the fields match the packet's definition """ 39 | def assert_fields(self, **kwargs): 40 | assert not kwargs or not self.definition or set(kwargs.keys()) == set(self.definition.keys()), "Packet fields do not match definition!" 41 | 42 | def read_fields(self, packet_buffer): 43 | for var_name, data_type in self.definition.items(): 44 | val = data_type.read(packet_buffer) 45 | setattr(self, var_name, val) 46 | 47 | """ Read from the packet buffer into the packet's fields """ 48 | def read(self, packet_buffer): 49 | id_ = VarInt.read(packet_buffer) 50 | 51 | if not (id_ == self.id or (self.ids and id_ in self.ids)): # Invalid packet id 52 | raise InvalidPacketID('Invalid packet id! Read %s instead of' % hex(id_), hex(self.id), self.ids) 53 | 54 | self.read_fields(packet_buffer) 55 | 56 | self.packet_buffer_ = packet_buffer 57 | self.packet_buffer_.reset_cursor() 58 | 59 | return self 60 | 61 | def write(self, compression_threshold=None): 62 | if self.id is None: 63 | raise AttributeError("Packet ID is undefined.") 64 | 65 | if len(self.bytes) > 0: 66 | self.clear() # If we re-use packets we need to clear past byte data 67 | 68 | data_length = 0 69 | """ Create a temporary PacketBuffer """ 70 | packet_buffer = PacketBuffer() 71 | """ Write the packet id """ 72 | data_length += VarInt.write(self.id, packet_buffer) 73 | """ Write the data fields """ 74 | data_length += self.__write_fields(packet_buffer) 75 | 76 | """ Apply compression if needed """ 77 | if compression_threshold and compression_threshold >= 0: 78 | return self.__write_compressed(packet_buffer, data_length, data_length >= compression_threshold) 79 | 80 | """ Uncompressed packet """ 81 | VarInt.write(data_length, self.packet_buffer_) # Write the packet length 82 | self.packet_buffer_.write(packet_buffer.bytes) # Write the data 83 | return self 84 | 85 | """ Write the compressed packet to the buffer """ 86 | def __write_compressed(self, packet_buffer, data_length, is_compressed): 87 | actual_data_length = 0 88 | data = packet_buffer.bytes 89 | 90 | if is_compressed: 91 | actual_data_length = data_length 92 | data = compress(data) 93 | 94 | # Clear the last packet buffer to be overwritten 95 | packet_buffer.clear() 96 | 97 | packet_length = VarInt.write(actual_data_length, PacketBuffer()) + len(data) 98 | 99 | VarInt.write(packet_length, self.packet_buffer_) 100 | VarInt.write(actual_data_length, self.packet_buffer_) 101 | self.packet_buffer_.write(data) 102 | self.compressed_buffer = self.packet_buffer_ 103 | return self 104 | 105 | def __write_fields(self, packet_buffer): 106 | length = 0 107 | for var_name, data_type in self.definition.items(): 108 | """ Get the field's data """ 109 | data = getattr(self, var_name) 110 | length += data_type.write(data, packet_buffer) 111 | return length 112 | 113 | def field_string(self, field): 114 | """ The string representation of the value of the given named field 115 | of this packet. Override to customise field value representation. 116 | """ 117 | value = getattr(self, field, None) 118 | 119 | # Byte arrays are represented in base64 120 | if isinstance(value, bytes) or isinstance(value, bytearray): 121 | return b64encode(value).decode("utf-8") 122 | 123 | return repr(value) 124 | 125 | @property 126 | def fields(self): 127 | """ An iterable of the names of the packet's fields, or None. """ 128 | if self.definition is None: 129 | return None 130 | return self.definition.keys() 131 | 132 | def __str__(self): 133 | _str = type(self).__name__ 134 | if self.id is not None: 135 | _str = '0x%02X %s' % (self.id, _str) 136 | fields = self.fields 137 | if fields is not None: 138 | _str = '%s(%s)' % (_str, ', '.join('%s=%s' % 139 | (k, self.field_string(k)) for k in fields)) 140 | _str += " | " + str(self.packet_buffer_) 141 | return _str 142 | 143 | def __repr__(self): 144 | return str(self) 145 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mcidle-python 2 | 3 | An idling cli for Minecraft that tunnels your connection to a Minecraft server allowing you to disconnect at any point but remain connected to the server through `mcidle`. 4 | 5 | [Watch a demo here!](https://youtu.be/r26vacizGJw) 6 | 7 | # Why? 8 | 9 | It is particularly useful for servers which punish you for disconnecting (e.g `2b2t.org` which has queues). 10 | 11 | Feel free to submit an issue if something doesn't work properly. I would like to point out that this has not been 12 | heavily tested so there will definitely be bugs. 13 | 14 | This only has only been tested to work on python3.7 and below! python3.8 seems to break it with the `cryptography` library not being compiled for 3.8 or above. [Install python3.6.8 here](https://www.python.org/downloads/release/python-368/) if you're experiencing issues. You can see your python version by running `python` in a command prompt. 15 | 16 | # Supported Versions 17 | 18 | If your game/server version is not listed below then `mcidle` will not function properly. 19 | 20 | | Version | Protocol | 21 | |:-------------:|:-------------:| 22 | | 1.12.2 | 340 | 23 | 24 | Make sure you connect with the exact game version that matches the mcidle server and the real-server. 25 | 26 | # Installation Guide 27 | 28 | You will need [pipx](https://github.com/pipxproject/pipx) to install this. 29 | 30 | Run 31 | 32 | ``` 33 | pipx install git+https://github.com/qubard/mcidle-python.git 34 | ``` 35 | 36 | to install the application. If you're on Windows you may have to restart your command line. 37 | 38 | Then `mcidle` should be an available command in your command line on Mac, Windows or Linux. 39 | 40 | A simple way to run it in the background of a server is to use `nohup mcidle > output.log &` (with flags). To terminate, run `pkill python` which will kill all running instances of python. 41 | 42 | # Notes 43 | 44 | When you provide arguments `username` and `password` to the CLI you do not need to provide them again so long as your credentials have not been invalidated. Your username and password are not saved and the login credentials are stored in `credentials.json` (keep these secret). 45 | 46 | ***If you re-login to Minecraft after starting `mcidle` your `credentials.json` file will be invalidated, simply re-login and don't close mcidle and you'll be able to connect.*** 47 | 48 | ***You'll know you have this error if you see an "Invalid Session" error after connecting to the local mcidle server.*** 49 | 50 | Make sure that when you connect you connect with the same game client version number as the server. 51 | 52 | If you keep on logging in too fast (re-updating credentials) you might get an "invalid username or password" error even though your username/password is correct. Wait 5 minutes before running the script again (this has to do with the way Mojang auth works). 53 | 54 | # Example Usage 55 | 56 | Run `mcidle --username=example@example.com --password=pw123 --ip=2b2t.org` to point mcidle to `2b2t.org` and listen on `localhost:1337` with the login information `example@example.com` and password `pw123`. 57 | 58 | Connecting to `localhost:1337` with your Minecraft client will let you resume your connection to `2b2t.org`. You can change the port at any time by changing the `dport` flag. 59 | 60 | Run `mcidle --help` for additional instructions on how to use the command-line utility. 61 | 62 | ``` 63 | usage: mcidle.exe [-h] [--ip IP] [--port PORT] [--protocol PROTOCOL] 64 | [--username USERNAME] [--password PASSWORD] [--dport DPORT] 65 | [--bindip BINDIP] [--reconnect RECONNECT] 66 | 67 | optional arguments: 68 | -h, --help show this help message and exit 69 | --ip IP The ip address of the server to connect to (e.g 70 | localhost) 71 | --port PORT The port of the server to connect to (default=25565) 72 | --protocol PROTOCOL The protocol version of the server to connect to 73 | (default=340) 74 | --username USERNAME Your Mojang account username (an email or legacy name) 75 | --password PASSWORD Your Mojang account password 76 | --dport DPORT The port to connect to with mcidle (default=1337) 77 | --bindip BINDIP The IP to bind to with mcidle 78 | --reconnect RECONNECT 79 | The reconnect rate in seconds 80 | ``` 81 | 82 | # Known Issues 83 | 84 | - Since Python is slow, reading from a buffer/passing chunks to be processed is slow which can halt the processing of KeepAlives which means that the player can disconnect randomly. The only real solution to this is dedicating a separate thread just to KeepAlives or converting this to C/C++. This would depend on how fast the server you run mcidle on is though, in practice on an Intel i7 8700k I did not have any issues in a single threaded setup. 85 | 86 | - Anti AFK is broken on certain servers. Currently moves you 3 blocks in the X direction and 3 blocks back. I would recommend using an anti-afk pool until this is fixed 87 | 88 | - In past versions we used multiple threads for worker loggers with Python's `multiprocessing` library, but this actually slowed down the program significantly due to the huge cost of acquiring a lock on dictionary objects so by default now we use 1 thread for packet processing and a synchronized queue to avoid heavy lock hits 89 | 90 | - On some windows installs (Windows 10) you may not be able to install the `cryptography` package. This is because `cryptography` was not compiled for 3.8+. Install `python3.7` or below (preferably `3.6.8`) and try again. 91 | 92 | - Placing a block or modifying the chunk you're in then reconnecting will not show the changes. This is because I did 93 | not add the processing for chunk sections yet/digging packets (see the `experimental` branch, but Python is still too 94 | slow to handle these things it seems). To solve this walk out of range of the chunks and then back in to force the 95 | game to reload them 96 | 97 | - Some servers use something like `TCP shield` which can detect if you're using a proxy (use wireshark to see the endpoint you connect to) which stops you from connecting. 98 | 99 | - If you run this on some VPS providers your ip range might be blocked and you won't be able to connect 100 | 101 | - Since you don't move while idling if someone digs blocks underneath you and makes the game think you're falling you can be kicked for flying 102 | -------------------------------------------------------------------------------- /src/mcidle/networking/packets/clientbound/__init__.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.packets.packet import Packet 2 | from mcidle.networking.types import String, VarIntPrefixedByteArray, VarInt, Integer, VarIntArray, \ 3 | Long, Byte, Double, Float, Boolean, UUID, Short, UnsignedByte 4 | 5 | """ 6 | Note: not using an OrderedDict for `definition` will break 7 | in anything older than Python 3.7.1 (the keys will not be in order) 8 | """ 9 | 10 | 11 | class EncryptionRequest(Packet): 12 | id = 0x01 13 | definition = { 14 | "ServerID": String, 15 | "PublicKey": VarIntPrefixedByteArray, 16 | "VerifyToken": VarIntPrefixedByteArray 17 | } 18 | 19 | 20 | class TimeUpdate(Packet): 21 | id = 0x47 22 | definition = { 23 | "WorldAge": Long, 24 | "TimeOfDay": Long 25 | } 26 | 27 | 28 | class HeldItemChange(Packet): 29 | id = 0x3A 30 | definition = { 31 | "Slot": Byte 32 | } 33 | 34 | 35 | class UpdateHealth(Packet): 36 | id = 0x41 37 | definition = { 38 | "Health": Float, 39 | "Food": VarInt, 40 | "FoodSaturation": Float, 41 | } 42 | 43 | 44 | class LoginSuccess(Packet): 45 | id = 0x02 46 | definition = { 47 | "UUID": String, 48 | "Username": String 49 | } 50 | 51 | 52 | class JoinGame(Packet): 53 | id = 0x23 54 | definition = { 55 | "EntityID": Integer, 56 | "Gamemode": UnsignedByte, 57 | "Dimension": Integer, 58 | "Difficulty": UnsignedByte, 59 | "MaxPlayers": UnsignedByte, 60 | "LevelType": String, 61 | "Debug": Boolean, 62 | } 63 | 64 | 65 | class SetCompression(Packet): 66 | id = 0x03 67 | definition = { 68 | "Threshold": VarInt 69 | } 70 | 71 | 72 | class SetSlot(Packet): 73 | id = 0x16 74 | definition = { 75 | "WindowID": None, 76 | "Slot": None, 77 | "SlotData": None, 78 | } 79 | 80 | # This packet changes a lot depending on the current protocol 81 | # But only SlotData changes 82 | # See https://wiki.vg/index.php?title=Slot_Data&oldid=7835 (1.12.2) 83 | def read_fields(self, packet_buffer): 84 | self.WindowID = Byte.read(packet_buffer) 85 | self.Slot = Short.read(packet_buffer) 86 | 87 | # The rest of the packet is SlotData which we don't need to parse 88 | 89 | 90 | 91 | class ChunkData(Packet): 92 | id = 0x20 93 | definition = { 94 | "ChunkX": Integer, 95 | "ChunkZ": Integer 96 | } 97 | 98 | def read_fields(self, packet_buffer): 99 | self.ChunkX = Integer.read(packet_buffer) 100 | self.ChunkZ = Integer.read(packet_buffer) 101 | 102 | 103 | class UnloadChunk(Packet): 104 | id = 0x1D 105 | definition = { 106 | "ChunkX": Integer, 107 | "ChunkZ": Integer 108 | } 109 | 110 | 111 | class SpawnEntity(Packet): 112 | id = 0x03 113 | ids = [0x00, 0x01, 0x03, 0x04, 0x05, 0x25] 114 | definition = { 115 | "EntityID": VarInt 116 | } 117 | 118 | 119 | class DestroyEntities(Packet): 120 | id = 0x32 121 | definition = { 122 | "Entities": VarIntArray 123 | } 124 | 125 | 126 | class KeepAlive(Packet): 127 | id = 0x1F 128 | definition = { 129 | "KeepAliveID": Long 130 | } 131 | 132 | 133 | class ChatMessage(Packet): 134 | id = 0x0F 135 | definition = { 136 | "Chat": String, 137 | "Position": Byte 138 | } 139 | 140 | 141 | class Respawn(Packet): 142 | id = 0x35 143 | definition = { 144 | "Dimension": Integer, 145 | "Difficulty": UnsignedByte, 146 | "Gamemode": UnsignedByte, 147 | "LevelType": String, 148 | } 149 | 150 | 151 | class PlayerPositionAndLook(Packet): 152 | id = 0x2F 153 | definition = { 154 | "X": Double, 155 | "Y": Double, 156 | "Z": Double, 157 | "Yaw": Float, 158 | "Pitch": Float, 159 | "Flags": Byte, 160 | "TeleportID": VarInt 161 | } 162 | 163 | 164 | class GameState(Packet): 165 | id = 0x1E 166 | definition = { 167 | "Reason": UnsignedByte, 168 | "Value": Float, 169 | } 170 | 171 | 172 | class Disconnect(Packet): 173 | id = 0x1A 174 | definition = { 175 | "Reason": String 176 | } 177 | 178 | 179 | class PlayerAbilities(Packet): 180 | id = 0x2C 181 | definition = { 182 | "Flags": Byte, 183 | "FlyingSpeed": Float, 184 | "FOV": Float, 185 | } 186 | 187 | 188 | class PlayerListItem(Packet): 189 | id = 0x2E 190 | definition = { 191 | "Action": None, 192 | "NumberOfPlayers": None, 193 | "Players": None 194 | } 195 | 196 | def read_fields(self, packet_buffer): 197 | self.Action = VarInt.read(packet_buffer) 198 | self.NumberOfPlayers = VarInt.read(packet_buffer) 199 | self.Players = [] 200 | 201 | for _ in range(0, self.NumberOfPlayers): 202 | uuid = UUID.read(packet_buffer) 203 | player = [uuid] 204 | if self.Action == 0: # Add Player 205 | name = String.read(packet_buffer) 206 | number_of_properties = VarInt.read(packet_buffer) 207 | properties = [] 208 | for _ in range(0, number_of_properties): 209 | name = String.read(packet_buffer) 210 | value = String.read(packet_buffer) 211 | signature = None 212 | if Boolean.read(packet_buffer): # has signature 213 | signature = String.read(packet_buffer) 214 | properties.append((name, value, signature)) 215 | 216 | gamemode = VarInt.read(packet_buffer) 217 | ping = VarInt.read(packet_buffer) 218 | 219 | display_name = None 220 | if Boolean.read(packet_buffer): # has display name 221 | display_name = String.read(packet_buffer) 222 | 223 | player.append((name, properties, gamemode, ping, display_name)) 224 | elif self.Action == 1: # Update Gamemode 225 | player.append(VarInt.read(packet_buffer)) 226 | elif self.Action == 2: # Update Latency 227 | player.append(VarInt.read(packet_buffer)) 228 | elif self.Action == 3: # Update Display Name 229 | has_display_name = Boolean.read(packet_buffer) 230 | if has_display_name: 231 | player.append(String.read(packet_buffer)) 232 | self.Players.append(player) 233 | -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/packet_processor.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.packets.serverbound import KeepAlive as KeepAliveServerbound, TeleportConfirm, ClientStatus 2 | from mcidle.networking.packets.clientbound import ChunkData, UnloadChunk, SpawnEntity, \ 3 | DestroyEntities, KeepAlive, ChatMessage, PlayerPositionAndLook, TimeUpdate, \ 4 | HeldItemChange, SetSlot, PlayerListItem, PlayerAbilities, Respawn, UpdateHealth, JoinGame 5 | 6 | from mcidle.networking.packets.clientbound import GameState as GameStateP 7 | 8 | 9 | class PacketProcessor: 10 | # A packet processor processes packets and mutates the game state 11 | def __init__(self, game_state): 12 | self.game_state = game_state 13 | 14 | # Processes a packet and returns a response packet if needed 15 | def process_packet(self, packet): 16 | return None 17 | 18 | 19 | class ClientboundProcessor(PacketProcessor): 20 | def __init__(self, game_state): 21 | super().__init__(game_state) 22 | 23 | def destroy_entities(self, packet): 24 | destroy_entities = DestroyEntities().read(packet.packet_buffer) 25 | for entity_id in destroy_entities.Entities: 26 | if entity_id in self.game_state.entities: 27 | print("Removed entity ID: %s" % entity_id, flush=True) 28 | del self.game_state.entities[entity_id] # Delete the entity 29 | 30 | def player_list(self, packet): 31 | player_list_item = PlayerListItem().read(packet.packet_buffer) 32 | 33 | add_player = 0 34 | update_gamemode = 1 35 | remove_player = 4 36 | 37 | for player in player_list_item.Players: 38 | uuid = player[0] 39 | if uuid == self.game_state.client_uuid and player_list_item.Action == update_gamemode: 40 | self.game_state.gamemode = player[1] 41 | 42 | if player_list_item.Action == add_player: 43 | self.game_state.player_list[uuid] = packet 44 | elif player_list_item.Action == remove_player: 45 | if uuid in self.game_state.packet_log: 46 | del self.game_state.player_list[uuid] 47 | 48 | def spawn_entity(self, packet): 49 | spawn_entity = SpawnEntity().read(packet.packet_buffer) 50 | if spawn_entity.EntityID not in self.game_state.entities: 51 | self.game_state.entities[spawn_entity.EntityID] = packet 52 | print("Added entity ID: %s" % spawn_entity.EntityID, flush=True) 53 | 54 | def chunk_unload(self, packet): 55 | unload_chunk = UnloadChunk().read(packet.packet_buffer) 56 | chunk_key = (unload_chunk.ChunkX, unload_chunk.ChunkZ) 57 | if chunk_key in self.game_state.chunks: 58 | del self.game_state.chunks[chunk_key] 59 | print("UnloadChunk", unload_chunk.ChunkX, unload_chunk.ChunkZ, flush=True) 60 | 61 | def chunk_load(self, packet): 62 | chunk_data = ChunkData().read(packet.packet_buffer) 63 | chunk_key = (chunk_data.ChunkX, chunk_data.ChunkZ) 64 | if chunk_key not in self.game_state.chunks: 65 | self.game_state.chunks[chunk_key] = packet 66 | print("ChunkData", chunk_data.ChunkX, chunk_data.ChunkZ, flush=True) 67 | 68 | def process_packet(self, packet): 69 | with self.game_state.state_lock: 70 | if packet.id == Respawn.id: 71 | # In case the gamemode is changed through a respawn packet 72 | respawn = Respawn().read(packet.packet_buffer) 73 | self.game_state.gamemode = respawn.Gamemode 74 | print("Set gamemode to", respawn.Gamemode, flush=True) 75 | if packet.id == JoinGame.id: 76 | join_game = JoinGame().read(packet.packet_buffer) 77 | self.game_state.gamemode = join_game.Gamemode & 3 # Bit 4 (0x8) is the hardcore flaga 78 | print("Set gamemode to", self.game_state.gamemode, "JoinGame", flush=True) 79 | 80 | if packet.id in self.game_state.join_ids: 81 | self.game_state.packet_log[packet.id] = packet 82 | elif packet.id == ChunkData.id: # ChunkData 83 | self.chunk_load(packet) 84 | elif packet.id == UnloadChunk.id: # UnloadChunk 85 | self.chunk_unload(packet) 86 | elif packet.id in SpawnEntity.ids: 87 | self.spawn_entity(packet) 88 | elif packet.id == DestroyEntities.id: 89 | self.destroy_entities(packet) 90 | elif packet.id == KeepAlive.id: # KeepAlive Clientbound 91 | keep_alive = KeepAlive().read(packet.packet_buffer) 92 | print("Responded to KeepAlive", keep_alive, flush=True) 93 | return KeepAliveServerbound(KeepAliveID=keep_alive.KeepAliveID) 94 | elif packet.id == ChatMessage.id: 95 | chat_message = ChatMessage().read(packet.packet_buffer) 96 | print(chat_message, flush=True) 97 | elif packet.id == PlayerPositionAndLook.id: 98 | pos_packet = PlayerPositionAndLook().read(packet.packet_buffer) 99 | 100 | # Log the packet 101 | self.game_state.packet_log[packet.id] = packet 102 | self.game_state.received_position = True 103 | 104 | self.game_state.player_pos = (pos_packet.X, pos_packet.Y, pos_packet.Z) 105 | 106 | # Send back a teleport confirm 107 | return TeleportConfirm(TeleportID=pos_packet.TeleportID) 108 | elif packet.id == TimeUpdate.id: 109 | self.game_state.packet_log[packet.id] = packet 110 | elif packet.id == HeldItemChange.id: 111 | self.game_state.held_item_slot = HeldItemChange().read(packet.packet_buffer).Slot 112 | elif packet.id == GameStateP.id: 113 | game_state = GameStateP().read(packet.packet_buffer) 114 | if game_state.Reason == 3: # Change Gamemode 115 | print("Set gamemode to ", game_state.Value, flush=True) 116 | self.game_state.gamemode = game_state.Value 117 | elif packet.id == SetSlot.id: 118 | set_slot = SetSlot().read(packet.packet_buffer) 119 | self.game_state.main_inventory[set_slot.Slot] = packet 120 | elif packet.id == PlayerListItem.id: 121 | self.player_list(packet) 122 | elif packet.id == PlayerAbilities.id: 123 | self.game_state.abilities = PlayerAbilities().read(packet.packet_buffer) 124 | elif packet.id == UpdateHealth.id: 125 | update_health = UpdateHealth().read(packet.packet_buffer) 126 | self.game_state.update_health = update_health 127 | # Respawn the player if they're dead.. 128 | print("Health: %s" % update_health.Health, flush=True) 129 | if update_health.Health == 0: 130 | print("Client died, respawning", flush=True) 131 | return ClientStatus(ActionID=0) 132 | 133 | 134 | return None 135 | -------------------------------------------------------------------------------- /src/mcidle/networking/types/type.py: -------------------------------------------------------------------------------- 1 | """Contains definitions for minecraft's different data types 2 | Each type has a method which is used to read and write it. 3 | These definitions and methods are used by the packet definitions 4 | """ 5 | from __future__ import division 6 | import struct 7 | import uuid 8 | 9 | from .utility import Vector 10 | 11 | 12 | class Type: 13 | __slots__ = () 14 | 15 | @staticmethod 16 | def read(stream): 17 | raise NotImplementedError("Base data type not de-serializable") 18 | 19 | @staticmethod 20 | def write(value, stream): 21 | raise NotImplementedError("Base data type not serializable") 22 | 23 | 24 | class Boolean(Type): 25 | @staticmethod 26 | def read(stream): 27 | return struct.unpack('?', stream.read(1))[0] 28 | 29 | @staticmethod 30 | def write(value, stream): 31 | return stream.write(struct.pack('?', value)) 32 | 33 | 34 | class UnsignedByte(Type): 35 | @staticmethod 36 | def read(stream): 37 | return struct.unpack('>B', stream.read(1))[0] 38 | 39 | @staticmethod 40 | def write(value, stream): 41 | return stream.write(struct.pack('>B', value)) 42 | 43 | 44 | class Byte(Type): 45 | @staticmethod 46 | def read(stream): 47 | return struct.unpack('>b', stream.read(1))[0] 48 | 49 | @staticmethod 50 | def write(value, stream): 51 | return stream.write(struct.pack('>b', value)) 52 | 53 | 54 | class Short(Type): 55 | @staticmethod 56 | def read(stream): 57 | return struct.unpack('>h', stream.read(2))[0] 58 | 59 | @staticmethod 60 | def write(value, stream): 61 | return stream.write(struct.pack('>h', value)) 62 | 63 | 64 | class UnsignedShort(Type): 65 | @staticmethod 66 | def read(stream): 67 | return struct.unpack('>H', stream.read(2))[0] 68 | 69 | @staticmethod 70 | def write(value, stream): 71 | return stream.write(struct.pack('>H', value)) 72 | 73 | 74 | class Integer(Type): 75 | @staticmethod 76 | def read(stream): 77 | return struct.unpack('>i', stream.read(4))[0] 78 | 79 | @staticmethod 80 | def write(value, stream): 81 | return stream.write(struct.pack('>i', value)) 82 | 83 | 84 | class FixedPointInteger(Type): 85 | @staticmethod 86 | def read(stream): 87 | return Integer.read(stream) / 32 88 | 89 | @staticmethod 90 | def write(value, stream): 91 | return Integer.write(int(value * 32), stream) 92 | 93 | 94 | class VarInt(Type): 95 | @staticmethod 96 | def read(stream): 97 | number = 0 98 | # Limit of 5 bytes, otherwise its possible to cause 99 | # a DOS attack by sending VarInts that just keep 100 | # going 101 | bytes_encountered = 0 102 | while True: 103 | byte = stream.read(1) 104 | if len(byte) < 1: 105 | raise EOFError("Unexpected end of message.") 106 | 107 | byte = ord(byte) 108 | number |= (byte & 0x7F) << 7 * bytes_encountered 109 | if not byte & 0x80: 110 | break 111 | 112 | bytes_encountered += 1 113 | if bytes_encountered > 5: 114 | raise ValueError("Tried to read too long of a VarInt") 115 | return number 116 | 117 | @staticmethod 118 | def write(value, stream): 119 | out = bytes() 120 | while True: 121 | byte = value & 0x7F 122 | value >>= 7 123 | out += struct.pack("B", byte | (0x80 if value > 0 else 0)) 124 | if value == 0: 125 | break 126 | return stream.write(out) 127 | 128 | @staticmethod 129 | def size(value): 130 | for max_value, size in VARINT_SIZE_TABLE.items(): 131 | if value < max_value: 132 | return size 133 | raise ValueError("Integer too large") 134 | 135 | 136 | # Maps (maximum integer value -> size of VarInt in bytes) 137 | VARINT_SIZE_TABLE = { 138 | 2 ** 7: 1, 139 | 2 ** 14: 2, 140 | 2 ** 21: 3, 141 | 2 ** 28: 4, 142 | 2 ** 35: 5, 143 | 2 ** 42: 6, 144 | 2 ** 49: 7, 145 | 2 ** 56: 8, 146 | 2 ** 63: 9, 147 | 2 ** 70: 10, 148 | 2 ** 77: 11, 149 | 2 ** 84: 12 150 | } 151 | 152 | 153 | class Long(Type): 154 | @staticmethod 155 | def read(stream): 156 | return struct.unpack('>q', stream.read(8))[0] 157 | 158 | @staticmethod 159 | def write(value, stream): 160 | return stream.write(struct.pack('>q', value)) 161 | 162 | 163 | class UnsignedLong(Type): 164 | @staticmethod 165 | def read(stream): 166 | return struct.unpack('>Q', stream.read(8))[0] 167 | 168 | @staticmethod 169 | def write(value, stream): 170 | return stream.write(struct.pack('>Q', value)) 171 | 172 | 173 | class Float(Type): 174 | @staticmethod 175 | def read(stream): 176 | return struct.unpack('>f', stream.read(4))[0] 177 | 178 | @staticmethod 179 | def write(value, stream): 180 | return stream.write(struct.pack('>f', value)) 181 | 182 | 183 | class Double(Type): 184 | @staticmethod 185 | def read(stream): 186 | return struct.unpack('>d', stream.read(8))[0] 187 | 188 | @staticmethod 189 | def write(value, stream): 190 | return stream.write(struct.pack('>d', value)) 191 | 192 | 193 | class VarIntArray(Type): 194 | @staticmethod 195 | def read(stream): 196 | count = VarInt.read(stream) 197 | arr = [] 198 | for _ in range(0, count): 199 | arr.append(VarInt.read(stream)) 200 | return arr 201 | 202 | @staticmethod 203 | def write(values, stream): 204 | size = 0 205 | for value in values: 206 | size += VarInt.write(value, stream) 207 | return size 208 | 209 | 210 | class ShortPrefixedByteArray(Type): 211 | @staticmethod 212 | def read(stream): 213 | length = Short.read(stream) 214 | return struct.unpack(str(length) + "s", stream.read(length))[0] 215 | 216 | @staticmethod 217 | def write(value, stream): 218 | return Short.write(len(value), stream) + stream.write(value) 219 | 220 | 221 | class VarIntPrefixedByteArray(Type): 222 | @staticmethod 223 | def read(stream): 224 | length = VarInt.read(stream) 225 | return struct.unpack(str(length) + "s", stream.read(length))[0] 226 | 227 | @staticmethod 228 | def write(value, stream): 229 | return VarInt.write(len(value), stream) + stream.write(struct.pack(str(len(value)) + "s", value)) 230 | 231 | 232 | class TrailingByteArray(Type): 233 | """ A byte array consisting of all remaining data. If present in a packet 234 | definition, this should only be the type of the last field. """ 235 | 236 | @staticmethod 237 | def read(stream): 238 | return stream.read() 239 | 240 | @staticmethod 241 | def write(value, stream): 242 | return stream.write(value) 243 | 244 | 245 | class String(Type): 246 | @staticmethod 247 | def read(stream): 248 | length = VarInt.read(stream) 249 | return stream.read(length).decode("utf-8") 250 | 251 | @staticmethod 252 | def write(value, stream): 253 | value = value.encode('utf-8') 254 | return VarInt.write(len(value), stream) + stream.write(value) 255 | 256 | 257 | class UUID(Type): 258 | @staticmethod 259 | def read(stream): 260 | return str(uuid.UUID(bytes=stream.read(16))) 261 | 262 | @staticmethod 263 | def write(value, stream): 264 | return stream.write(uuid.UUID(value).bytes) 265 | 266 | 267 | class Position(Type, Vector): 268 | """3D position vectors with a specific, compact network representation.""" 269 | __slots__ = () 270 | 271 | @staticmethod 272 | def read(stream): 273 | location = UnsignedLong.read(stream) 274 | x = int(location >> 38) 275 | y = int((location >> 26) & 0xFFF) 276 | z = int(location & 0x3FFFFFF) 277 | 278 | if x >= pow(2, 25): 279 | x -= pow(2, 26) 280 | 281 | if y >= pow(2, 11): 282 | y -= pow(2, 12) 283 | 284 | if z >= pow(2, 25): 285 | z -= pow(2, 26) 286 | 287 | return Position(x=x, y=y, z=z) 288 | 289 | @staticmethod 290 | def write(position, stream): 291 | # 'position' can be either a tuple or Position object. 292 | x, y, z = position 293 | value = ((x & 0x3FFFFFF) << 38) | ((y & 0xFFF) << 26) | (z & 0x3FFFFFF) 294 | return UnsignedLong.write(value, stream) -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "faad98eb5e97e9a390b2562a2a26a7b83d2b0e293bc2b86cb1809fba6d795d72" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.8" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "asn1crypto": { 20 | "hashes": [ 21 | "sha256:5a215cb8dc12f892244e3a113fe05397ee23c5c4ca7a69cd6e69811755efc42d", 22 | "sha256:831d2710d3274c8a74befdddaf9f17fcbf6e350534565074818722d6d615b315" 23 | ], 24 | "version": "==1.3.0" 25 | }, 26 | "certifi": { 27 | "hashes": [ 28 | "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", 29 | "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" 30 | ], 31 | "version": "==2020.4.5.1" 32 | }, 33 | "cffi": { 34 | "hashes": [ 35 | "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", 36 | "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", 37 | "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", 38 | "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", 39 | "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", 40 | "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", 41 | "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", 42 | "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", 43 | "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", 44 | "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", 45 | "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", 46 | "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", 47 | "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", 48 | "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", 49 | "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", 50 | "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", 51 | "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", 52 | "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", 53 | "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", 54 | "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", 55 | "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", 56 | "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", 57 | "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", 58 | "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", 59 | "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", 60 | "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", 61 | "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", 62 | "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" 63 | ], 64 | "version": "==1.14.0" 65 | }, 66 | "chardet": { 67 | "hashes": [ 68 | "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", 69 | "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" 70 | ], 71 | "version": "==3.0.4" 72 | }, 73 | "cryptography": { 74 | "hashes": [ 75 | "sha256:05a6052c6a9f17ff78ba78f8e6eb1d777d25db3b763343a1ae89a7a8670386dd", 76 | "sha256:0eb83a24c650a36f68e31a6d0a70f7ad9c358fa2506dc7b683398b92e354a038", 77 | "sha256:0ff4a3d6ea86aa0c9e06e92a9f986de7ee8231f36c4da1b31c61a7e692ef3378", 78 | "sha256:1699f3e916981df32afdd014fb3164db28cdb61c757029f502cb0a8c29b2fdb3", 79 | "sha256:1b1f136d74f411f587b07c076149c4436a169dc19532e587460d9ced24adcc13", 80 | "sha256:21e63dd20f5e5455e8b34179ac43d95b3fb1ffa54d071fd2ed5d67da82cfe6dc", 81 | "sha256:2454ada8209bbde97065453a6ca488884bbb263e623d35ba183821317a58b46f", 82 | "sha256:3cdc5f7ca057b2214ce4569e01b0f368b3de9d8ee01887557755ccd1c15d9427", 83 | "sha256:418e7a5ec02a7056d3a4f0c0e7ea81df374205f25f4720bb0e84189aa5fd2515", 84 | "sha256:471a097076a7c4ab85561d7fa9a1239bd2ae1f9fd0047520f13d8b340bf3210b", 85 | "sha256:5ecaf9e7db3ca582c6de6229525d35db8a4e59dc3e8a40a331674ed90e658cbf", 86 | "sha256:63b064a074f8dc61be81449796e2c3f4e308b6eba04a241a5c9f2d05e882c681", 87 | "sha256:6afe324dfe6074822ccd56d80420df750e19ac30a4e56c925746c735cf22ae8b", 88 | "sha256:70596e90398574b77929cd87e1ac6e43edd0e29ba01e1365fed9c26bde295aa5", 89 | "sha256:70c2b04e905d3f72e2ba12c58a590817128dfca08949173faa19a42c824efa0b", 90 | "sha256:8908f1db90be48b060888e9c96a0dee9d842765ce9594ff6a23da61086116bb6", 91 | "sha256:af12dfc9874ac27ebe57fc28c8df0e8afa11f2a1025566476b0d50cdb8884f70", 92 | "sha256:b4fc04326b2d259ddd59ed8ea20405d2e695486ab4c5e1e49b025c484845206e", 93 | "sha256:da5b5dda4aa0d5e2b758cc8dfc67f8d4212e88ea9caad5f61ba132f948bab859" 94 | ], 95 | "index": "pypi", 96 | "version": "==2.4.2" 97 | }, 98 | "idna": { 99 | "hashes": [ 100 | "sha256:156a6814fb5ac1fc6850fb002e0852d56c0c8d2531923a51032d1b70760e186e", 101 | "sha256:684a38a6f903c1d71d6d5fac066b58d7768af4de2b832e426ec79c30daa94a16" 102 | ], 103 | "version": "==2.7" 104 | }, 105 | "mcidle": { 106 | "editable": true, 107 | "path": "." 108 | }, 109 | "nbt": { 110 | "hashes": [ 111 | "sha256:77dcf9d3fcb41a04fe7f407e902cf78c1dadc589d2e68d0a77e1b62b64a9be7e", 112 | "sha256:834505e9137d00ab5b098979b4e1d383a14061e80364a7f1b9587839b2743f5e", 113 | "sha256:f3585248e6747c6ede39be49a9d54ade8f63ce9beb42a6682c0323d32073c7f9" 114 | ], 115 | "index": "pypi", 116 | "version": "==1.5.0" 117 | }, 118 | "pycparser": { 119 | "hashes": [ 120 | "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", 121 | "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" 122 | ], 123 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 124 | "version": "==2.20" 125 | }, 126 | "requests": { 127 | "hashes": [ 128 | "sha256:65b3a120e4329e33c9889db89c80976c5272f56ea92d3e74da8a463992e3ff54", 129 | "sha256:ea881206e59f41dbd0bd445437d792e43906703fff75ca8ff43ccdb11f33f263" 130 | ], 131 | "index": "pypi", 132 | "version": "==2.20.1" 133 | }, 134 | "six": { 135 | "hashes": [ 136 | "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", 137 | "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" 138 | ], 139 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 140 | "version": "==1.15.0" 141 | }, 142 | "urllib3": { 143 | "hashes": [ 144 | "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", 145 | "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" 146 | ], 147 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3' and python_version < '4'", 148 | "version": "==1.24.3" 149 | } 150 | }, 151 | "develop": { 152 | "setuptools-scm": { 153 | "extras": [ 154 | "toml" 155 | ], 156 | "hashes": [ 157 | "sha256:21a5f539d42feb1891e05d9fc03099338e5ca409b75c70b2274bf0f56efe36d1", 158 | "sha256:25484c46873f35ee5715cc0861c93a37fa4ee9885c6384b284927e40f657d220", 159 | "sha256:3ae7a2c889a33c72181b683e3cfd570e00a18b57169139e4ef77a37482c98bb3", 160 | "sha256:5e1c7d6cbad00458a6e3d32426203147e072bf49eff1cab426efb3d4d754bdc6", 161 | "sha256:65005ecfde4b2e45370cbc118ff5bbfa2740e7c7ed85c7da574e6fd244c4c7b8", 162 | "sha256:a2c0f4e51b3d7fe18303375fff582ca4d8450d4e153e22ef01c71ee3403d93d8", 163 | "sha256:ae4c33ff8f1ab4eca2dd7e7f7ff8b3f765766230fe13a1d23c41ca976a47b3bc", 164 | "sha256:b485b8b8b05fa119c8b67ad3c23e47ee7424fd81a292234e201c5a99830b9017" 165 | ], 166 | "index": "pypi", 167 | "version": "==4.1.1" 168 | }, 169 | "toml": { 170 | "hashes": [ 171 | "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", 172 | "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" 173 | ], 174 | "version": "==0.10.1" 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/mcidle/networking/auth/auth.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | from .exceptions import YggdrasilError 4 | 5 | from .profile import Profile 6 | 7 | #: The base url for Ygdrassil requests 8 | AUTH_SERVER = "https://authserver.mojang.com" 9 | SESSION_SERVER = "https://sessionserver.mojang.com/session/minecraft" 10 | # Need this content type, or authserver will complain 11 | CONTENT_TYPE = "application/json" 12 | HEADERS = {"content-type": CONTENT_TYPE} 13 | CREDENTIALS_FILENAME = './credentials.json' 14 | 15 | 16 | class Auth: 17 | """ 18 | Represents an authentication token. 19 | 20 | See http://wiki.vg/Authentication. 21 | """ 22 | AGENT_NAME = "Minecraft" 23 | AGENT_VERSION = 1 24 | 25 | def __init__(self, username=None, profile=None): 26 | """ 27 | Constructs an `AuthenticationToken` based on `access_token` and 28 | `client_token`. 29 | 30 | Parameters: 31 | access_token - An `str` object containing the `access_token`. 32 | client_token - An `str` object containing the `client_token`. 33 | 34 | Returns: 35 | A `AuthenticationToken` with `access_token` and `client_token` set. 36 | """ 37 | self.username = username 38 | self.profile = Profile() 39 | 40 | self.access_token = None 41 | self.client_token = None 42 | 43 | if profile: 44 | self.assign_profile(profile) 45 | 46 | def assign_profile(self, json_resp): 47 | self.access_token = json_resp["accessToken"] 48 | self.client_token = json_resp["clientToken"] 49 | self.profile.id_ = json_resp["selectedProfile"]["id"] 50 | self.profile.name = json_resp["selectedProfile"]["name"] 51 | self.username = self.profile.name 52 | return self 53 | 54 | def __str__(self): 55 | return "%s %s %s" % (self.username, self.access_token, self.client_token) 56 | 57 | @property 58 | def authenticated(self): 59 | """ 60 | Attribute which is ``True`` when the token is authenticated and 61 | ``False`` when it isn't. 62 | """ 63 | if not self.username: 64 | return False 65 | 66 | if not self.access_token: 67 | return False 68 | 69 | if not self.client_token: 70 | return False 71 | 72 | if not self.profile: 73 | return False 74 | 75 | return True 76 | 77 | def save_to_disk(credentials): 78 | with open(CREDENTIALS_FILENAME, 'w') as outfile: 79 | json.dump(credentials, outfile) 80 | 81 | def read_from_disk(): 82 | with open(CREDENTIALS_FILENAME, 'r') as infile: 83 | return json.load(infile) 84 | 85 | def has_credentials(): 86 | import os 87 | return os.path.isfile(CREDENTIALS_FILENAME) 88 | 89 | def delete_credentials(): 90 | import os 91 | os.remove(CREDENTIALS_FILENAME) 92 | 93 | def authenticate(self, username, password): 94 | """ 95 | Authenticates the user against https://authserver.mojang.com using 96 | `username` and `password` parameters. 97 | 98 | Parameters: 99 | username - An `str` object with the username (unmigrated accounts) 100 | or email address for a Mojang account. 101 | password - An `str` object with the password. 102 | 103 | Returns: 104 | Returns `True` if successful. 105 | Otherwise it will raise an exception. 106 | 107 | Raises: 108 | minecraft.exceptions.YggdrasilError 109 | """ 110 | payload = { 111 | "agent": { 112 | "name": self.AGENT_NAME, 113 | "version": self.AGENT_VERSION 114 | }, 115 | "username": username, 116 | "password": password 117 | } 118 | 119 | res = _make_request(AUTH_SERVER, "authenticate", payload) 120 | 121 | _raise_from_response(res) 122 | 123 | json_resp = res.json() 124 | 125 | self.username = username 126 | self.access_token = json_resp["accessToken"] 127 | self.client_token = json_resp["clientToken"] 128 | self.profile.id_ = json_resp["selectedProfile"]["id"] 129 | self.profile.name = json_resp["selectedProfile"]["name"] 130 | 131 | return json_resp 132 | 133 | def refresh(self): 134 | """ 135 | Refreshes the `AuthenticationToken`. Used to keep a user logged in 136 | between sessions and is preferred over storing a user's password in a 137 | file. 138 | 139 | Returns: 140 | Returns `True` if `AuthenticationToken` was successfully refreshed. 141 | Otherwise it raises an exception. 142 | 143 | Raises: 144 | minecraft.exceptions.YggdrasilError 145 | ValueError - if `AuthenticationToken.access_token` or 146 | `AuthenticationToken.client_token` isn't set. 147 | """ 148 | if self.access_token is None: 149 | raise ValueError("'access_token' not set!'") 150 | 151 | if self.client_token is None: 152 | raise ValueError("'client_token' is not set!") 153 | 154 | res = _make_request(AUTH_SERVER, 155 | "refresh", {"accessToken": self.access_token, 156 | "clientToken": self.client_token}) 157 | 158 | _raise_from_response(res) 159 | 160 | json_resp = res.json() 161 | 162 | self.access_token = json_resp["accessToken"] 163 | self.client_token = json_resp["clientToken"] 164 | self.profile.id_ = json_resp["selectedProfile"]["id"] 165 | self.profile.name = json_resp["selectedProfile"]["name"] 166 | 167 | return True 168 | 169 | def validate(self): 170 | """ 171 | Validates the AuthenticationToken. 172 | 173 | `AuthenticationToken.access_token` must be set! 174 | 175 | Returns: 176 | Returns `True` if `AuthenticationToken` is valid. 177 | Otherwise it will raise an exception. 178 | 179 | Raises: 180 | minecraft.exceptions.YggdrasilError 181 | ValueError - if `AuthenticationToken.access_token` is not set. 182 | """ 183 | if self.access_token is None: 184 | raise ValueError("'access_token' not set!") 185 | 186 | res = _make_request(AUTH_SERVER, "validate", 187 | {"accessToken": self.access_token}) 188 | 189 | # Validate returns 204 to indicate success 190 | # http://wiki.vg/Authentication#Response_3 191 | if res.status_code == 204: 192 | return True 193 | 194 | return False 195 | 196 | @staticmethod 197 | def sign_out(username, password): 198 | """ 199 | Invalidates `access_token`s using an account's 200 | `username` and `password`. 201 | 202 | Parameters: 203 | username - ``str`` containing the username 204 | password - ``str`` containing the password 205 | 206 | Returns: 207 | Returns `True` if sign out was successful. 208 | Otherwise it will raise an exception. 209 | 210 | Raises: 211 | minecraft.exceptions.YggdrasilError 212 | """ 213 | res = _make_request(AUTH_SERVER, "signout", 214 | {"username": username, "password": password}) 215 | 216 | if _raise_from_response(res) is None: 217 | return True 218 | 219 | def invalidate(self): 220 | """ 221 | Invalidates `access_token`s using the token pair stored in 222 | the `AuthenticationToken`. 223 | 224 | Returns: 225 | ``True`` if tokens were successfully invalidated. 226 | 227 | Raises: 228 | :class:`minecraft.exceptions.YggdrasilError` 229 | """ 230 | res = _make_request(AUTH_SERVER, "invalidate", 231 | {"accessToken": self.access_token, 232 | "clientToken": self.client_token}) 233 | 234 | if res.status_code != 204: 235 | _raise_from_response(res) 236 | return True 237 | 238 | def join(self, server_id_hash): 239 | """ 240 | Informs the Mojang session-server that we're joining the 241 | MineCraft server with id ``server_id``. 242 | 243 | Parameters: 244 | server_id - ``str`` with the server id 245 | 246 | Returns: 247 | ``True`` if no errors occured 248 | 249 | Raises: 250 | :class:`minecraft.exceptions.YggdrasilError` 251 | 252 | """ 253 | if not self.authenticated: 254 | err = "AuthenticationToken hasn't been authenticated yet!" 255 | raise YggdrasilError(err) 256 | 257 | res = _make_request(SESSION_SERVER, "join", 258 | {"accessToken": self.access_token, 259 | "selectedProfile": self.profile.to_dict(), 260 | "serverId": server_id_hash}) 261 | 262 | if res.status_code != 204: 263 | _raise_from_response(res) 264 | 265 | return True 266 | 267 | 268 | def _make_request(server, endpoint, data): 269 | """ 270 | Fires a POST with json-packed data to the given endpoint and returns 271 | response. 272 | 273 | Parameters: 274 | endpoint - An `str` object with the endpoint, e.g. "authenticate" 275 | data - A `dict` containing the payload data. 276 | 277 | Returns: 278 | A `requests.Request` object. 279 | """ 280 | res = requests.post(server + "/" + endpoint, data=json.dumps(data), 281 | headers=HEADERS) 282 | return res 283 | 284 | 285 | def _raise_from_response(res): 286 | """ 287 | Raises an appropriate `YggdrasilError` based on the `status_code` and 288 | `json` of a `requests.Request` object. 289 | """ 290 | if res.status_code == requests.codes['ok']: 291 | return None 292 | 293 | exception = YggdrasilError() 294 | exception.status_code = res.status_code 295 | 296 | try: 297 | json_resp = res.json() 298 | if not ("error" in json_resp and "errorMessage" in json_resp): 299 | raise ValueError 300 | except ValueError: 301 | message = "[{status_code}] Malformed error message: '{response_text}'" 302 | message = message.format(status_code=str(res.status_code), 303 | response_text=res.text) 304 | exception.args = (message,) 305 | else: 306 | message = "[{status_code}] {error}: '{error_message}'" 307 | message = message.format(status_code=str(res.status_code), 308 | error=json_resp["error"], 309 | error_message=json_resp["errorMessage"]) 310 | exception.args = (message,) 311 | exception.yggdrasil_error = json_resp["error"] 312 | exception.yggdrasil_message = json_resp["errorMessage"] 313 | exception.yggdrasil_cause = json_resp.get("cause") 314 | 315 | raise exception -------------------------------------------------------------------------------- /src/mcidle/networking/packet_handler/clientbound/login_handler.py: -------------------------------------------------------------------------------- 1 | from mcidle.networking.packet_handler import PacketHandler 2 | from mcidle.networking.packets.serverbound import ( 3 | Handshake, LoginStart, EncryptionResponse, ClientStatus, 4 | PlayerPositionAndLook, TeleportConfirm, HeldItemChange, PlayerAbilities, PlayerPosition 5 | ) 6 | from mcidle.networking.packets.clientbound import ( 7 | EncryptionRequest, SetCompression, TimeUpdate, GameState, LoginSuccess 8 | ) 9 | from mcidle.networking.packets.clientbound import PlayerPositionAndLook as PlayerPositionAndLookClientbound 10 | from mcidle.networking.packets.clientbound import HeldItemChange as HeldItemChangeClientbound 11 | from mcidle.networking.packets.clientbound import PlayerAbilities as PlayerAbilitiesClientbound 12 | 13 | from mcidle.networking.packets.exceptions import InvalidPacketID 14 | 15 | from cryptography.hazmat.backends import default_backend 16 | from cryptography.hazmat.primitives.asymmetric import rsa 17 | from cryptography.hazmat.primitives import serialization 18 | from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 19 | 20 | import select 21 | 22 | 23 | class LoginHandler(PacketHandler): 24 | def __init__(self, connection, mc_connection): 25 | super().__init__(connection) 26 | self.mc_connection = mc_connection 27 | 28 | def handle_player_abilities(self, packet): 29 | if packet.id != PlayerAbilities.id: 30 | return 31 | 32 | self.mc_connection.game_state.acquire() 33 | 34 | abilities = PlayerAbilities().read(packet.packet_buffer) 35 | self.mc_connection.game_state.abilities = PlayerAbilitiesClientbound(Flags=abilities.Flags, \ 36 | FlyingSpeed=abilities.FlyingSpeed, \ 37 | FOV=abilities.WalkingSpeed) 38 | 39 | self.mc_connection.game_state.release() 40 | 41 | def handle_held_item_change(self, packet): 42 | if packet.id != HeldItemChange.id: 43 | return 44 | 45 | self.mc_connection.game_state.acquire() 46 | 47 | self.mc_connection.game_state.held_item_slot = HeldItemChange().read(packet.packet_buffer).Slot 48 | 49 | self.mc_connection.game_state.release() 50 | 51 | def handle_position(self, packet): 52 | if packet.id == PlayerPositionAndLook.id: 53 | pos_packet = PlayerPositionAndLook().read(packet.packet_buffer) 54 | 55 | self.mc_connection.game_state.acquire() 56 | 57 | self.mc_connection.game_state.last_yaw = pos_packet.Yaw 58 | self.mc_connection.game_state.last_pitch = pos_packet.Pitch 59 | self.mc_connection.game_state.player_pos = (pos_packet.X, pos_packet.Y, pos_packet.Z) 60 | 61 | # Replace the currently logged PlayerPositionAndLookClientbound packet 62 | self.mc_connection.game_state.last_pos_packet = pos_packet 63 | 64 | self.mc_connection.game_state.release() 65 | elif packet.id == PlayerPosition.id: 66 | pos_packet = PlayerPosition().read(packet.packet_buffer) 67 | 68 | self.mc_connection.game_state.acquire() 69 | 70 | self.mc_connection.game_state.player_pos = (pos_packet.X, pos_packet.Y, pos_packet.Z) 71 | 72 | self.mc_connection.game_state.release() 73 | 74 | def join_world(self): 75 | # If there's an exception releasing a lock actually happens this way 76 | with self.mc_connection.game_state.state_lock: 77 | # Send the player all the packets that lets them join the world 78 | for id_ in self.mc_connection.game_state.join_ids: 79 | if id_ in self.mc_connection.game_state.packet_log: 80 | packet = self.mc_connection.game_state.packet_log[id_] 81 | self.connection.send_packet_buffer_raw(packet.compressed_buffer) 82 | 83 | # Send their health 84 | if self.mc_connection.game_state.update_health: 85 | self.connection.send_packet_raw(self.mc_connection.game_state.update_health) 86 | 87 | # Send their player abilities 88 | if self.mc_connection.game_state.abilities: 89 | self.connection.send_packet_raw(self.mc_connection.game_state.abilities) 90 | 91 | # Send them their last position/look if it exists 92 | if PlayerPositionAndLookClientbound.id in self.mc_connection.game_state.packet_log: 93 | if self.mc_connection and self.mc_connection.game_state.last_pos_packet: 94 | last_packet = self.mc_connection.game_state.last_pos_packet 95 | 96 | pos_packet = PlayerPositionAndLookClientbound( \ 97 | X=last_packet.X, Y=last_packet.Y, Z=last_packet.Z, \ 98 | Yaw=self.mc_connection.game_state.last_yaw, Pitch=self.mc_connection.game_state.last_pitch, Flags=0, \ 99 | TeleportID=self.mc_connection.game_state.teleport_id) 100 | self.mc_connection.game_state.teleport_id += 1 101 | self.connection.send_packet_raw(pos_packet) 102 | else: 103 | self.connection.send_packet_buffer_raw( 104 | self.mc_connection.game_state.packet_log[PlayerPositionAndLookClientbound.id] \ 105 | .compressed_buffer) # Send the last packet that we got 106 | 107 | if TimeUpdate.id in self.mc_connection.game_state.packet_log: 108 | self.connection.send_packet_buffer_raw(self.mc_connection.game_state.packet_log\ 109 | [TimeUpdate.id].compressed_buffer) 110 | 111 | # Send the player list items (to see other players) 112 | self.connection.send_single_packet_dict(self.mc_connection.game_state.player_list) 113 | 114 | # Send all loaded chunks 115 | print("Sending chunks", flush=True) 116 | self.connection.send_single_packet_dict(self.mc_connection.game_state.chunks) 117 | print("Done sending chunks", flush=True) 118 | 119 | # Send the player all the currently loaded entities 120 | self.connection.send_single_packet_dict(self.mc_connection.game_state.entities) 121 | 122 | # Player sends ClientStatus, this is important for respawning if died 123 | self.mc_connection.send_packet_raw(ClientStatus(ActionID=0)) 124 | 125 | # Send their last held item 126 | self.connection.send_packet_raw(HeldItemChangeClientbound(Slot=self.mc_connection.game_state.held_item_slot)) 127 | 128 | # Send their current gamemode if it's defined 129 | if self.mc_connection.game_state.gamemode is not None: 130 | print("Sent gamemode", self.mc_connection.game_state.gamemode, flush=True) 131 | self.connection.send_packet_raw(GameState(Reason=3,\ 132 | Value=self.mc_connection.game_state.gamemode)) 133 | else: 134 | print("Gamemode not present", flush=True) 135 | # Send their inventory 136 | self.connection.send_single_packet_dict(self.mc_connection.game_state.main_inventory) 137 | 138 | def setup(self): 139 | try: 140 | print("Reading handshake", flush=True) 141 | 142 | Handshake().read(self.read_packet_from_stream().packet_buffer) 143 | 144 | print("Reading login start", flush=True) 145 | LoginStart().read(self.read_packet_from_stream().packet_buffer) 146 | 147 | # Generate a dummy (pubkey, privkey) pair 148 | privkey = rsa.generate_private_key(public_exponent=65537, key_size=2048, backend=default_backend()) 149 | pubkey = privkey.public_key().public_bytes(encoding=serialization.Encoding.DER, 150 | format=serialization.PublicFormat.SubjectPublicKeyInfo) 151 | 152 | print("Trying to send encryption request", flush=True) 153 | self.connection.send_packet_raw( 154 | EncryptionRequest(ServerID='', PublicKey=pubkey, VerifyToken=self.mc_connection.VerifyToken)) 155 | 156 | print("Encryption request sent", flush=True) 157 | 158 | # The encryption response will be encrypted with the server's public key 159 | # Luckily, when this goes wrong read_packet returns None 160 | _ = self.read_packet_from_stream() 161 | 162 | if _ is None: 163 | print("Invalid encryption response!", flush=True) 164 | self.connection.on_disconnect() 165 | return False 166 | 167 | encryption_response = EncryptionResponse().read(_.packet_buffer) 168 | 169 | # Decrypt and verify the verify token 170 | verify_token = privkey.decrypt(encryption_response.VerifyToken, PKCS1v15()) 171 | assert (verify_token == self.mc_connection.VerifyToken) 172 | 173 | # Decrypt the shared secret 174 | shared_secret = privkey.decrypt(encryption_response.SharedSecret, PKCS1v15()) 175 | 176 | # Enable encryption using the shared secret 177 | self.connection.enable_encryption(shared_secret) 178 | 179 | # Enable compression and assign the threshold to the connection 180 | if self.mc_connection.compression_threshold >= 0: 181 | self.connection.send_packet_raw(SetCompression(Threshold=self.mc_connection.compression_threshold)) 182 | self.connection.compression_threshold = self.mc_connection.compression_threshold 183 | 184 | self.connection.send_packet_raw(LoginSuccess(Username=self.mc_connection.game_state.client_username, \ 185 | UUID=self.mc_connection.game_state.client_uuid)) 186 | 187 | print("Joining world", flush=True) 188 | self.join_world() 189 | print("Finished joining world", flush=True) 190 | except (ValueError, EOFError, InvalidPacketID, AttributeError, ConnectionRefusedError, ConnectionAbortedError, \ 191 | ConnectionResetError): 192 | return False 193 | 194 | # Let the real connection know about our client 195 | # Now the client can start receiving forwarded data 196 | # Technically self.connection.upstream is always the same though 197 | self.connection.upstream.clear() # Clear it just to be safe 198 | self.mc_connection.set_client_upstream(self.connection.upstream) 199 | print("Connected to upstream", flush=True) 200 | 201 | return True 202 | 203 | def handle(self): 204 | while self.running: 205 | ready_to_read = select.select([self.connection.stream], [], [], self._timeout)[0] 206 | 207 | if ready_to_read: 208 | packet = self.read_packet_from_stream() 209 | 210 | if packet is not None: 211 | if packet and packet.id != TeleportConfirm.id: # Sending these will crash us 212 | self.handle_position(packet) 213 | self.handle_held_item_change(packet) 214 | self.handle_player_abilities(packet) 215 | self.mc_connection.send_packet_buffer(packet.compressed_buffer) 216 | else: 217 | print("Client disconnected (invalid packet). Exiting thread", flush=True) 218 | self.connection.on_disconnect() 219 | break 220 | 221 | -------------------------------------------------------------------------------- /src/mcidle/networking/connection.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import threading 3 | 4 | from .auth import Auth 5 | 6 | from .encryption import ( 7 | EncryptedFileObjectWrapper, EncryptedSocketWrapper, create_AES_cipher 8 | ) 9 | 10 | from .packet_handler.serverbound import LoginHandler as ServerboundLoginHandler 11 | from .packet_handler.clientbound import LoginHandler as ClientboundLoginHandler 12 | from .packets.clientbound import Respawn, JoinGame 13 | 14 | from .packet_handler import WorkerProcessor, ClientboundProcessor 15 | 16 | from .upstream import UpstreamThread 17 | from .anti_afk import AntiAFKThread 18 | from .game_state import GameState 19 | 20 | 21 | class Connection(threading.Thread): 22 | def __init__(self, ip=None, port=None, upstream=None): 23 | threading.Thread.__init__(self, daemon=True) 24 | self.threshold = None 25 | self.address = (ip, port) 26 | self.packet_handler = None 27 | 28 | self.socket = None 29 | self.stream = None 30 | 31 | self.upstream_lock = threading.RLock() 32 | self.upstream = upstream 33 | 34 | self.compression_threshold = None 35 | 36 | # By default we generate a new socket for our upstream 37 | # But this is replaced in MinecraftServer with the client 38 | self.initialize_socket_upstream(socket.socket()) 39 | 40 | def stop(self): 41 | if self.packet_handler: 42 | self.packet_handler.stop() 43 | 44 | with self.upstream_lock: 45 | if self.upstream: 46 | self.upstream.stop() 47 | 48 | def initialize_socket_upstream(self, sock): 49 | self.initialize_socket(sock) 50 | with self.upstream_lock: 51 | if self.upstream: 52 | self.upstream.set_socket(self.socket) 53 | 54 | def initialize_socket(self, sock): 55 | self.socket = sock 56 | self.stream = self.socket.makefile('rb') 57 | 58 | def destroy_socket(self): 59 | try: 60 | if self.socket: 61 | self.socket.close() 62 | self.socket = None 63 | self.stream = None 64 | print("Socket shutdown and closed.", flush=True) 65 | except OSError: 66 | print("Failed to reset socket", flush=True) 67 | pass 68 | 69 | def enable_encryption(self, shared_secret): 70 | cipher = create_AES_cipher(shared_secret) 71 | # Generate the encrypted endpoints 72 | encryptor = cipher.encryptor() 73 | decryptor = cipher.decryptor() 74 | 75 | # Replace the socket used with an encrypted socket 76 | self.socket = EncryptedSocketWrapper(self.socket, encryptor, decryptor) 77 | print("Set upstream socket", flush=True) 78 | with self.upstream_lock: 79 | if self.upstream: 80 | self.upstream.set_socket(self.socket) 81 | self.stream = EncryptedFileObjectWrapper(self.stream, decryptor) 82 | 83 | def initialize_connection(self): 84 | return True 85 | 86 | # We need this to stop reading packets from the dead stream 87 | # which halts the wait thread 88 | def on_disconnect(self): 89 | self.stop() 90 | self.destroy_socket() 91 | 92 | def send_packet_buffer_raw(self, packet_buffer): 93 | self.socket.send(packet_buffer.bytes) 94 | 95 | def send_packet_raw(self, packet): 96 | self.socket.send(packet.write(self.compression_threshold).bytes) 97 | 98 | def send_packet(self, packet): 99 | with self.upstream_lock: 100 | if self.upstream: 101 | self.upstream.put(packet.write(self.compression_threshold).bytes) 102 | 103 | def send_packet_buffer(self, packet_buffer): 104 | with self.upstream_lock: 105 | if self.upstream: 106 | self.upstream.put(packet_buffer.bytes) 107 | 108 | def send_packet_dict(self, id_, m): 109 | if id_ in m: 110 | packet_dict = m[id_] 111 | for packet in packet_dict.values(): 112 | self.send_packet_buffer_raw(packet.compressed_buffer) 113 | 114 | def send_single_packet_dict(self, m): 115 | for packet in m.values(): 116 | self.send_packet_buffer_raw(packet.compressed_buffer) 117 | 118 | def run_handler(self): 119 | if not self.initialize_connection(): 120 | print("Failed to run_handler!", flush=True) 121 | return 122 | 123 | if self.packet_handler is not None: 124 | if self.packet_handler.setup(): 125 | self.packet_handler.on_setup() # Could possibly change the packet handler 126 | 127 | if self.packet_handler.next_handler() is not None \ 128 | and self.packet_handler.next_handler() != self.packet_handler: 129 | self.packet_handler = self.packet_handler.next_handler() 130 | 131 | self.packet_handler.handle() 132 | else: 133 | # Clean up if we can't even setup the handler 134 | self.on_disconnect() 135 | 136 | def run(self): 137 | self.run_handler() 138 | 139 | 140 | # Assume that a MinecraftConnection has to stay active at all times 141 | class MinecraftConnection(Connection): 142 | def __init__(self, username, ip, protocol, port=25565, server_port=1001, profile=None, listen_thread=None): 143 | super().__init__(ip, port, UpstreamThread()) 144 | 145 | self.username = username 146 | self.protocol = protocol 147 | self.server = None 148 | self.server_port = server_port 149 | self.listen_thread = listen_thread 150 | 151 | # JoinGame, ServerDifficulty, SpawnPosition, Respawn, Experience 152 | join_ids = [JoinGame.id, 0x0D, 0x46, Respawn.id, 0x40] 153 | self.game_state = GameState(join_ids) 154 | 155 | self.packet_processor = ClientboundProcessor(self.game_state) 156 | 157 | self.client_connection = None 158 | 159 | self.local_client_upstream = None 160 | self.client_upstream_lock = threading.RLock() 161 | 162 | # Keeping the child server's upstream alive as long as possible prevents the BrokenPipeError bug 163 | # So pass it in as a construction argument instead of something it spawns itself 164 | # Then we can just redirect its socket if need be 165 | self.server_upstream = UpstreamThread() 166 | self.server_upstream.start() 167 | 168 | self.auth = Auth(username, profile) 169 | 170 | # Make sure the access token we are using is still valid 171 | self.auth.validate() 172 | 173 | self.packet_handler = ServerboundLoginHandler(self) 174 | 175 | # Every second send an animation swing to prevent AFK kicks while client_upstream is DCed 176 | self.anti_afk = AntiAFKThread(self) 177 | self.anti_afk.start() 178 | 179 | # Process packets in another thread 180 | self.worker_processor = WorkerProcessor(self, self.packet_processor) 181 | 182 | @property 183 | def client_upstream(self): 184 | with self.client_upstream_lock: 185 | return self.local_client_upstream 186 | 187 | def set_client_upstream(self, upstream): 188 | with self.client_upstream_lock: 189 | self.local_client_upstream = upstream 190 | 191 | # Sends to the client through its upstream if we have one 192 | # Guarantees upstream is not set to None while putting 193 | def send_to_client(self, packet): 194 | with self.client_upstream_lock: 195 | if self.local_client_upstream: 196 | self.local_client_upstream.put(packet.compressed_buffer.bytes) 197 | 198 | """ Connect to the socket and start a connection thread """ 199 | def connect(self): 200 | try: 201 | self.socket.connect(self.address) 202 | self.worker_processor.start() 203 | print("Connected MinecraftConnection", flush=True) 204 | return True 205 | except ConnectionRefusedError: 206 | print("Cannot connect to target server, connection refused!", flush=True) 207 | return False 208 | 209 | def stop(self): 210 | super().stop() 211 | self.anti_afk.stop() 212 | with self.client_upstream_lock: 213 | self.server_upstream.stop() 214 | self.worker_processor.stop() 215 | 216 | def on_disconnect(self): 217 | print("Called MinecraftConnection::on_disconnect()...", flush=True) 218 | super().on_disconnect() 219 | 220 | # Terminate all existing threads 221 | self.stop() 222 | 223 | # Terminate the server threads if there is one 224 | if self.server: 225 | self.server.stop() 226 | self.server.destroy_socket() 227 | 228 | def initialize_connection(self): 229 | return self.connect() 230 | 231 | def start_server(self): 232 | self.server = MinecraftServer(self, self.server_port, self.listen_thread, self.server_upstream) 233 | 234 | 235 | class MinecraftServer(Connection): 236 | """ Used for listening on a port for a connection """ 237 | def __init__(self, mc_connection, port=25565, listen_thread=None, upstream=None): 238 | super().__init__('localhost', port, upstream) 239 | self.mc_connection = mc_connection 240 | self.packet_handler = ClientboundLoginHandler(self, mc_connection) 241 | 242 | self.start_lock = threading.Lock() 243 | 244 | self.client_socket = None 245 | 246 | self.listen_thread = listen_thread.set_server(self) 247 | 248 | def finalize_socket_upstream(self): 249 | self.initialize_socket_upstream(self.client_socket) 250 | 251 | # Note that when mcidle terminates first MinecraftConnection does 252 | def on_disconnect(self): 253 | # Sometimes on_disconnect() is called and then in the middle of executing 254 | # we call start_with_socket which causes a weird bug so we need the lock here 255 | # to make sure they are separate events 256 | with self.start_lock: 257 | print("Called MinecraftServer::on_disconnect()...", flush=True) 258 | self.stop() 259 | # Only re-create the server if we're still connected to our target server 260 | if self.mc_connection and self.mc_connection.upstream.connected(): 261 | self.destroy_socket() 262 | self.mc_connection.set_client_upstream(None) # Client is no longer connected 263 | # Replace our server object to restart the MinecraftServer state easily 264 | # To be honest this is a bad pattern and it's better to just never kill MinecraftServer 265 | # but then we'd have to overhaul how the packet handlers work since start() calls run() 266 | # which calls packet handler logic 267 | # The overhaul would just be to construct the handlers in start_with_socket 268 | self.mc_connection.start_server() 269 | 270 | def start_with_socket(self, sock): 271 | with self.start_lock: 272 | if self.mc_connection.upstream.connected(): 273 | if not self.client_socket: 274 | print("Starting MinecraftServer!", flush=True) 275 | self.initialize_socket(sock) 276 | self.client_socket = sock 277 | super().start() 278 | else: 279 | print("Rejected client start_with_socket, client already connected", flush=True) 280 | sock.close() 281 | 282 | def stop(self): 283 | # Bugfix: Makes sure listen_thread does not have a server 284 | # So it accepts a new client forcefully 285 | self.listen_thread.set_server(None) 286 | self.upstream.set_socket(None) 287 | 288 | if self.packet_handler: 289 | self.packet_handler.stop() 290 | 291 | --------------------------------------------------------------------------------