diff --git a/README.md b/README.md index 91fada4e..ce04da32 100644 --- a/README.md +++ b/README.md @@ -333,3 +333,116 @@ async def get_status(ip: str, port: int) -> dict: ``` Well, that wasn't so hard, was it? + +### Using Client class for communication + +Now that you understand how packets work, let's take a look at a much nicer and easier way to do this. Note that it's +still very important that you understand the basics shown above, as even though this is the best way of using mcproto, +and by far the easiest, as a lot of the flows are already implemented for you, if you intent on writing any more +complex bot accounts or doing similar things, you will still need to understand how to work with packets on their own, +and where to learn what to send and when. + +```python + +import httpx + +from mcproto.interactions.client import Client +from mcproto.connection import TCPAsyncConnection +from mcproto.auth.account import Account +from mcproto.types.uuid import UUID + +HOST = "localhost" +PORT = 25565 + +MINECRAFT_USERNAME = "YourMinecraftUsername" + +# To get your UUID, go to: # https://api.mojang.com/users/profiles/minecraft/YourMinecraftUsername +MINECRAFT_UUID = UUID("YourMinecraftUUID") + +# This can be left empty for warez accounts, but if you want to connect to online mode servers, +# you will need to set this. See: https://mcproto.readthedocs.io/en/stable/usage/authentication/ +MINECRAFT_ACCESS_TOKEN = "" + + +account = Account(MINECRAFT_USERNAME, MINECRAFT_UUID, MINECRAFT_ACCESS_TOKEN) + + +async def main(): + async with httpx.AsyncClient() as client: + async with (await TCPAsyncConnection.make_client((HOST, PORT), 2)) as connection: + client = Client( + host=HOST, + port=PORT, + httpx_client=client, + account=account, + conn=connection, + protocol_version=763, # 1.20.1 + ) + + # To request status, you can now simply do: + status_response = client.status() + + # `status_response` will now contain an instance of StatusResponse packet, + # so you can access the data just like in the above example, with `status_response.data` + + # In the back, the `status` function has performed a handshake to transition us from + # the initial (None) game state, to the STATUS game state, and then sent a status + # request, getting back a response. + # + # The Client instance also includes a `login` function, which is capable to go through + # the entire login flow, leaving you in PLAY game state. Note that unless you've + # set MINECRAFT_ACCESS_TOKEN, you will only be able to do this for warez servers. + # + # But since we just called `status`, it left us in the STATUS game state, but we need + # to be in LOGIN game state. The `login` function will work if called from an initial + # game state (None), as it's smart enough to perform a handshake getting us to LOGIN, + # however it doesn't know what to do from STATUS game state. + # + # What we can do, is simply set game_state back to None (this is what happens during + # initialization of the Client class), making the login function send out another + # handshake, this time transitioning to LOGIN instead of STATUS. We could also create + # a completely new client instance. + # + # Note that this way of naively resetting the game-state won't always work, as the + # underlying connection isn't actually reset, and it's possible that in some cases, + # the server simply won't let us perform another handshake on the same connection. + # You will likely encounter this if you attempt to request status twice, however + # transitioning to login in this way will generally work. + client.game_state = None + + client.login() + + # Play state, yay! +``` + +### Using Server class to create a basic server + +Along with the `Client` class, mcproto also has a `Server` class, which is capable of partially simulating a minecraft +server. Note that this is a very basic implementation and it doesn't currently support PLAY state at all, however this +class is extendable, so you can absolutely subclass it to fit your needs. + +To start this server, you can run the following: + +```python +import httpx +from mcproto.interactions.server import Server + +HOST = "0.0.0.0" +PORT = 25565 + +async with httpx.AsyncClient() as client: + server = Server( + HOST, + PORT, + httpx_client=client, + enable_encryption=True, + online=True, + compression_threshold=-1, + prevent_proxy_connections=False, + ) + await server.start() +``` + +The `server.start()` function will block forever, as the server will be running. With this, you should be able to +actually see this server in minecraft's multiplayer menu, as it does support returning status. However actually trying +to connect will fail at finishConnect(), because of the lack of PLAY gamestate support. diff --git a/changes/182.feature.md b/changes/182.feature.md new file mode 100644 index 00000000..4ec49f5f --- /dev/null +++ b/changes/182.feature.md @@ -0,0 +1 @@ +Add server and client classes, containing most of the supported flows and interactions. diff --git a/mcproto/interactions/__init__.py b/mcproto/interactions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/mcproto/interactions/client.py b/mcproto/interactions/client.py new file mode 100644 index 00000000..59b7a4f0 --- /dev/null +++ b/mcproto/interactions/client.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import httpx + +from mcproto.auth.account import Account +from mcproto.connection import TCPAsyncConnection +from mcproto.encryption import encrypt_token_and_secret, generate_shared_secret +from mcproto.interactions.exceptions import InvalidGameStateError, UnexpectedPacketError +from mcproto.multiplayer import compute_server_hash, join_request +from mcproto.packets.handshaking.handshake import Handshake, NextState +from mcproto.packets.interactions import async_read_packet, async_write_packet +from mcproto.packets.login.login import ( + LoginDisconnect, + LoginEncryptionRequest, + LoginEncryptionResponse, + LoginSetCompression, + LoginStart, + LoginSuccess, +) +from mcproto.packets.packet import ClientBoundPacket, GameState, PacketDirection, ServerBoundPacket +from mcproto.packets.packet_map import generate_packet_map +from mcproto.packets.status.ping import PingPong +from mcproto.packets.status.status import StatusRequest, StatusResponse + + +class Client: + """Class representing the client, capable of connecting to the server. + + This class holds the logic for all client interactions/flows, is aware if the current + game state, packet compression, encryption, etc. + """ + + __slots__ = ( + "host", + "port", + "httpx_client", + "account", + "conn", + "protocol_version", + "game_state", + "packet_compression_threshold", + ) + + def __init__( + self, + host: str, + port: int, + httpx_client: httpx.AsyncClient, + account: Account, + conn: TCPAsyncConnection, + protocol_version: int, + game_state: GameState | None = None, + packet_compression_threshold: int = -1, + ) -> None: + self.host = host + self.port = port + self.httpx_client = httpx_client + self.account = account + self.conn = conn + self.protocol_version = protocol_version + self.game_state = game_state + self.packet_compression_threshold = packet_compression_threshold + + async def _write_packet(self, packet: ServerBoundPacket) -> None: + """Write a packet to the connection. + + This sends the given ``packet`` to the server, respecting the current configuration + (compression threshold, encryption, ...) + """ + await async_write_packet(self.conn, packet, compression_threshold=self.packet_compression_threshold) + + async def _read_packet(self) -> ClientBoundPacket: + """Read a packet from the connection. + + This receives a packet from the server, resolving it based on the current configuration + (using a packet map for current game state, compression threshold, encryption, ...) + """ + if self.game_state is None: + raise InvalidGameStateError( + "Receiving packet failed", + expected=tuple(state for state in GameState.__members__.values()), # Any non-None game state + found=self.game_state, # None + ) + + packet_map = generate_packet_map(PacketDirection.CLIENTBOUND, self.game_state) + return await async_read_packet( + self.conn, + packet_map, + compression_threshold=self.packet_compression_threshold, + ) + + async def _handshake(self, next_state: NextState) -> None: + """Send the handshake packet, transitioning us to ``next_state``.""" + if self.game_state is not None: + raise InvalidGameStateError("Sending handshake failed", expected=None, found=self.game_state) + + packet = Handshake( + protocol_version=self.protocol_version, + server_address=self.host, + server_port=self.port, + next_state=next_state, + ) + await self._write_packet(packet) + self.game_state = GameState.STATUS if next_state is NextState.STATUS else GameState.LOGIN + + async def ping(self, payload: int) -> PingPong: + """Ping the server.""" + if self.game_state is None: + await self._handshake(NextState.STATUS) + + if self.game_state is not GameState.STATUS: + raise InvalidGameStateError("Requesting ping failed", expected=GameState.STATUS, found=self.game_state) + + packet = PingPong(payload) + await self._write_packet(packet) + + recv_packet = await self._read_packet() + if not isinstance(recv_packet, PingPong): + raise UnexpectedPacketError("Receiving ping response failed", expected=PingPong, found=recv_packet) + + return recv_packet + + async def status(self) -> StatusResponse: + """Obtain status data from the server. + + This goes through the status flow, obtaining back a status response packet. + """ + if self.game_state is None: + await self._handshake(NextState.STATUS) + + if self.game_state is not GameState.STATUS: + raise InvalidGameStateError("Requesting status failed", expected=GameState.STATUS, found=self.game_state) + + packet = StatusRequest() + await self._write_packet(packet) + + recv_packet = await self._read_packet() + if not isinstance(recv_packet, StatusResponse): + raise UnexpectedPacketError("Receiving status response failed", expected=StatusResponse, found=recv_packet) + + return recv_packet + + async def _handle_encryption_request(self, packet: LoginEncryptionRequest) -> None: + """Handle receiving the :class:`mcproto.packets.login.login.LoginEncryptionRequest` packet. + + This will create a new shared secret for symmetric AES/CFB8 encryption, send it back to + the server encrypted using it's public key from the ``LoginEncryptionRequest`` packet. + + This allows the server to safely receive our randomly generated shared secret, and as + both sides now have the same encryption key, encryption is enabled. All further + communication will be encrypted. + """ + shared_secret = generate_shared_secret() + + # If the server isn't in offline mode (has server_id of "-"), contact the session server API. + if packet.server_id != "-": + server_hash = compute_server_hash(packet.server_id, shared_secret, packet.public_key) + await join_request(self.httpx_client, self.account, server_hash) + + encrypted_token, encrypted_secret = encrypt_token_and_secret( + packet.public_key, + packet.verify_token, + shared_secret, + ) + + response_packet = LoginEncryptionResponse(shared_secret=encrypted_secret, verify_token=encrypted_token) + await self._write_packet(response_packet) + + self.conn.enable_encryption(shared_secret) + + async def login(self) -> None: + """Go through the.""" + if self.game_state is None: + await self._handshake(NextState.LOGIN) + + if self.game_state is not GameState.LOGIN: + raise InvalidGameStateError("Login flow failed", expected=GameState.LOGIN, found=self.game_state) + + start_packet = LoginStart(username=self.account.username, uuid=self.account.uuid) + await self._write_packet(start_packet) + + # Keep reading new packets until we get LoginSuccess from server + # we don't really know the receive order here, as some servers use non-standard ordering + # (i.e. sending set compression before encryption request) + while not isinstance((recv_packet := await self._read_packet()), LoginSuccess): + if isinstance(recv_packet, LoginDisconnect): + raise UnexpectedPacketError( + f"Login failed, server sent disconnect! Reason: {recv_packet.reason!r}", + expected=(LoginEncryptionRequest, LoginSetCompression, LoginSuccess), + found=recv_packet, + ) + + if isinstance(recv_packet, LoginSetCompression): + self.packet_compression_threshold = recv_packet.threshold + continue + + if isinstance(recv_packet, LoginEncryptionRequest): + await self._handle_encryption_request(recv_packet) + continue + + raise UnexpectedPacketError( + "Login failed, server sent unexpected packet", + expected=(LoginEncryptionRequest, LoginSetCompression, LoginSuccess), + found=recv_packet, + ) + + # We now know the received packet is LoginSuccess, do some sanity checks for it's validity + if recv_packet.username != self.account.username: + raise IOError( + "Received LoginSuccess packet that didn't match request account username!" + f" request_username={self.account.username!r}, received_username={recv_packet.username!r}" + ) + if recv_packet.uuid != self.account.uuid: + raise IOError( + "Received LoginSuccess packet that didn't match request account uuid!" + f" request_uuid={self.account.uuid!r}, received_uuid={recv_packet.uuid!r}" + ) + + # Transition to PLAY state + self.game_state = GameState.PLAY + + # Wait for Login (play) packet now. It could take a while for some servers to + # transition to the play state, but we can be certain the server won't send any + # other packets before this Login one. + raise NotImplementedError("Play state packets aren't implemented yet") diff --git a/mcproto/interactions/exceptions.py b/mcproto/interactions/exceptions.py new file mode 100644 index 00000000..5afe46c2 --- /dev/null +++ b/mcproto/interactions/exceptions.py @@ -0,0 +1,124 @@ +from __future__ import annotations +from typing_extensions import override + +from mcproto.packets.packet import GameState, Packet + + +class InvalidGameStateError(Exception): + """Exception raised when the current game state didn't match the expected game state. + + Many of the minecraft communication flows, such as login, or status requesting requires + to start from a specific game state. For example login can't be performed again, if we're + already in the play game state. + """ + + def __init__( + self, + reason: str | None = None, + /, + *, + expected: GameState | None | tuple[GameState | None, ...], + found: GameState | None, + ) -> None: + if not isinstance(expected, tuple): + expected = (expected,) + + self.reason = reason + self.expected_state = expected + self.found_state = found + super().__init__(self.msg) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + if len(self.expected_state) == 1: + state = self.expected_state[0] + msg = "Expected initial (no) game state" if state is None else f"Expected {state.name} game state" + else: + states = ", ".join(state.name if state is not None else "None" for state in self.expected_state) + msg = f"Expected one of: {states} game states" + + if self.found_state is None: + msg += ", but found initial (no) game state" + else: + msg += f", but found {self.found_state.name} game state" + + if self.reason: + msg += f": {self.reason}" + return msg + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" + + +class UnexpectedPacketError(Exception): + """Exception produced when the obtained packet wasn't the packet we expected. + + In many minecraft communication flows, such as login, there are only a few (or just one) + specific packet types that we expect to be sent next. If a different packet is received, + this exception will be raised. + """ + + def __init__( + self, + reason: str | None = None, + /, + *, + expected: type[Packet] | tuple[type[Packet], ...], + found: Packet, + ) -> None: + if not isinstance(expected, tuple): + expected = (expected,) + + self.reason = reason + self.expected_packet_types = expected + self.found_packet = found + super().__init__(self.reason) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + if len(self.expected_packet_types) == 1: + expected_packet = self.expected_packet_types[0].__name__ + msg = f"Expected a {expected_packet} packet" + else: + expected_packets = ", ".join(packet.__name__ for packet in self.expected_packet_types) + msg = f"Expected one of {expected_packets} packets" + + msg += f", but found {self.found_packet!r}" + + if self.reason: + msg += f": {self.reason}" + + return msg + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" + + +class InvalidVerifyTokenError(Exception): + """Exception produced when the verify_token from client doesn't match the one sent by the server. + + The verify_token is sent by the server in `~mcproto.packets.login.login.LoginEncryptionRequest`, then + encrypted by the client and sent back in `~mcproto.packets.login.login.LoginEncryptionResponse`. + """ + + def __init__(self, original_token: bytes, received_decrypted_token: bytes) -> None: + self.original_token = original_token + self.received_decrypted_token = received_decrypted_token + super().__init__(self.msg) + + @property + def msg(self) -> str: + """Produce a message for this error.""" + return ( + "Client sent mismatched verify token:" + f" original: {self.original_token!r}," + f" received: {self.received_decrypted_token!r}" + ) + + @override + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self.msg!r})" diff --git a/mcproto/interactions/server.py b/mcproto/interactions/server.py new file mode 100644 index 00000000..d2dbf9bf --- /dev/null +++ b/mcproto/interactions/server.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import asyncio +from typing import NoReturn +from uuid import uuid4 + +import httpx +from typing_extensions import Self + +from mcproto.connection import TCPAsyncConnection +from mcproto.encryption import decrypt_token_and_secret, generate_rsa_key, generate_verify_token +from mcproto.interactions.exceptions import InvalidVerifyTokenError, UnexpectedPacketError +from mcproto.multiplayer import compute_server_hash, join_check +from mcproto.packets.handshaking.handshake import Handshake, NextState +from mcproto.packets.interactions import async_read_packet, async_write_packet +from mcproto.packets.login.login import ( + LoginEncryptionRequest, + LoginEncryptionResponse, + LoginSetCompression, + LoginStart, + LoginSuccess, +) +from mcproto.packets.packet import ClientBoundPacket, GameState, PacketDirection, ServerBoundPacket +from mcproto.packets.packet_map import generate_packet_map +from mcproto.packets.status.ping import PingPong +from mcproto.packets.status.status import StatusRequest, StatusResponse +from mcproto.types.uuid import UUID as McUUID # noqa: N811 # UUID isn't a constant + + +class ConnectedClient: + """Class holding data about a client connected to the server. + + This class is aware of the current gamestate for this client, compression, encryption, ... + """ + + __slots__ = ("conn", "game_state", "compression_threshold", "username", "uuid") + + def __init__( + self, + conn: TCPAsyncConnection, + *, + game_state: GameState, + compression_threshold: int = -1, + ): + self.conn = conn + self.game_state = game_state + self.compression_threshold = compression_threshold + + # Manually set by the server, once LoginStart is received + self.username: str + self.uuid: McUUID + + @classmethod + def new_connection( + cls, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + *, + compression_threshold: int = -1, + timeout: int = 3, + ) -> Self: + """Create a new client from the received `reader` and `writer`.""" + conn = TCPAsyncConnection(reader, writer, timeout=timeout) + return cls(conn, game_state=GameState.HANDSHAKING, compression_threshold=compression_threshold) + + async def close(self) -> None: + """Close the connection with this client.""" + await self.conn.close() + + async def write_packet(self, packet: ClientBoundPacket) -> None: + """Write a packet to the client connection. + + This sends the given ``packet`` to the client, respecting the current configuration + (compression threshold, encryption, ...) + """ + await async_write_packet(self.conn, packet, compression_threshold=self.compression_threshold) + + async def read_packet(self) -> ServerBoundPacket: + """Read a packet from the client connection. + + This receives a packet from the client, resolving it based on the current configuration + (using a packet map for current game state, compression threshold, encryption, ...) + """ + packet_map = generate_packet_map(PacketDirection.SERVERBOUND, self.game_state) + return await async_read_packet(self.conn, packet_map, compression_threshold=self.compression_threshold) + + @property + def ip(self) -> str: + """Obtain the IP address of the client.""" + return self.conn.writer.get_extra_info("peername")[0] + + +class Server: + """Class representing the server, capable of communication with multiple clients. + + This class holds the logic for all server interactions/flows, and is capable to + process received client requests and act accordingly. + """ + + __slots__ = ( + "host", + "port", + "httpx_client", + "enable_encryption", + "online", + "compression_threshold", + "prevent_proxy_connections", + ) + + def __init__( + self, + host: str, + port: int, + *, + httpx_client: httpx.AsyncClient, + enable_encryption: bool, + online: bool, + compression_threshold: int = -1, + prevent_proxy_connections: bool = False, + ): + if online and not enable_encryption: + raise ValueError("Can't use online mode without encryption") + + self.host = host + self.port = port + self.httpx_client = httpx_client + self.enable_encryption = enable_encryption + self.online = online + self.compression_threshold = compression_threshold + self.prevent_proxy_connections = prevent_proxy_connections + + async def start(self) -> NoReturn: + """Start the server, and run it forever.""" + server = await asyncio.start_server(self.handle_client, self.host, self.port) + async with server: + await server.serve_forever() + + async def handle_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + """Handle incoming connection from a client.""" + client = ConnectedClient.new_connection(reader, writer) + + try: + await self.handle_handshaking_gamestate(client) + + if client.game_state is GameState.STATUS: + await self.handle_status_gamestate(client) + elif client.game_state is GameState.LOGIN: + await self.handle_login_gamestate(client) + else: + raise # never + finally: + await client.close() + + async def handle_handshaking_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the handshaking gamestate (initial state).""" + handshake_packet = await client.read_packet() + if not isinstance(handshake_packet, Handshake): + raise UnexpectedPacketError("Receiving handshake failed", expected=Handshake, found=handshake_packet) + + if handshake_packet.next_state is NextState.LOGIN: + client.game_state = GameState.LOGIN + elif handshake_packet.next_state is NextState.STATUS: + client.game_state = GameState.STATUS + else: + raise # never + + async def handle_status_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the status state. + + The client is now expected to either send a status request, or a ping, or both + with status request being the first packet. + + If the first requested packet wasn't a status request, it can't be requested anymore! + However ping can be requested as many times as the client wants. + """ + recv_packet = await client.read_packet() + if isinstance(recv_packet, StatusRequest): + packet = StatusResponse(self.status) + await client.write_packet(packet) + + try: + recv_packet = await client.read_packet() + # If we can't read any more packets here, the client has probably + # ended the connection, do the same + except IOError: + return + + while True: + if isinstance(recv_packet, PingPong): + packet = PingPong(recv_packet.payload) + await client.write_packet(packet) + else: + raise UnexpectedPacketError("Status flow failed", expected=PingPong, found=recv_packet) + + try: + recv_packet = await client.read_packet() + # If we can't read any more packets here, the client has probably + # ended the connection, do the same + except IOError: + return + + async def _handle_encryption_request(self, client: ConnectedClient) -> None: + """Handle sending the :class:`~mcproto.packets.login.login.LoginEncryptionRequest` packet. + + This will generate an RSA keypair, sending the public key to the client, which will use it + to encrypt the shared secret value for symmetric AES/CFB8 encryption generated by the client. + + This allows the client to safely send a randomly generated shared secret, and as both + sides will now have the same encryption key, encryption is enabled. All further + communication will be encrypted. + """ + rsa_key = generate_rsa_key() + verify_token = generate_verify_token() + server_id = "" if self.online else "-" + + packet = LoginEncryptionRequest( + server_id=server_id, + public_key=rsa_key.public_key(), + verify_token=verify_token, + ) + await client.write_packet(packet) + + recv_packet = await client.read_packet() + if not isinstance(recv_packet, LoginEncryptionResponse): + raise UnexpectedPacketError("Login flow failed", expected=LoginEncryptionResponse, found=recv_packet) + + decrypted_token, decrypted_secret = decrypt_token_and_secret( + rsa_key, + recv_packet.verify_token, + recv_packet.shared_secret, + ) + if decrypted_token != verify_token: + raise InvalidVerifyTokenError(verify_token, decrypted_token) + + client.conn.enable_encryption(decrypted_secret) + + if self.online: + client_ip = client.ip if self.prevent_proxy_connections else None + server_hash = compute_server_hash(server_id, decrypted_secret, rsa_key.public_key()) + ack_data = await join_check(self.httpx_client, client.username, server_hash, client_ip) + + client.uuid = McUUID(ack_data["id"]) + client.username = ack_data["name"] + + async def handle_login_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the login state.""" + login_start_packet = await client.read_packet() + if not isinstance(login_start_packet, LoginStart): + raise UnexpectedPacketError("Login flow failed", expected=LoginStart, found=login_start_packet) + + client.username = login_start_packet.username + client.uuid = login_start_packet.uuid or McUUID(str(uuid4())) + + if self.enable_encryption: + await self._handle_encryption_request(client) + + if self.compression_threshold >= 0: + packet = LoginSetCompression(self.compression_threshold) + await client.write_packet(packet) + + packet = LoginSuccess(client.uuid, client.username) + await client.write_packet(packet) + + # Transition to play + return await self.handle_play_gamestate(client) + + async def handle_play_gamestate(self, client: ConnectedClient) -> None: + """Handle client entering the play state.""" + raise NotImplementedError("Play state packets aren't implemented yet") + while True: + await client.read_packet() + + @property + def status(self) -> dict[str, object]: + """Return the server info (used in `~mcproto.packets.status.status.StatusResponse`).""" + return { + "version": {"name": "1.20.2", "protocol": 764}, + "enforcesSecureChat": True, + "description": {"text": "A Vanilla Minecraft Server powered by Mcproto"}, + "players": {"max": 20, "online": 0}, + }