mirror of
https://github.com/ammaraskar/pyCraft.git
synced 2024-11-16 23:35:14 +01:00
487 lines
18 KiB
Python
487 lines
18 KiB
Python
from __future__ import print_function
|
|
|
|
from collections import deque
|
|
from threading import Lock
|
|
from zlib import decompress
|
|
import threading
|
|
import socket
|
|
import time
|
|
import select
|
|
import sys
|
|
import json
|
|
import re
|
|
|
|
from future.utils import raise_
|
|
|
|
from ..compat import unicode
|
|
from .types import VarInt
|
|
from . import packets
|
|
from . import encryption
|
|
from .. import SUPPORTED_PROTOCOL_VERSIONS
|
|
from .. import SUPPORTED_MINECRAFT_VERSIONS
|
|
|
|
|
|
class ConnectionContext(object):
|
|
"""A ConnectionContext encapsulates the static configuration parameters
|
|
shared by the Connection class with other classes, such as Packet.
|
|
Importantly, it can be used without knowing the interface of Connection.
|
|
"""
|
|
def __init__(self, **kwds):
|
|
self.protocol_version = kwds.get('protocol_version')
|
|
|
|
|
|
class _ConnectionOptions(object):
|
|
def __init__(self, address=None, port=None, compression_threshold=-1,
|
|
compression_enabled=False):
|
|
self.address = address
|
|
self.port = port
|
|
self.compression_threshold = compression_threshold
|
|
self.compression_enabled = compression_enabled
|
|
|
|
|
|
class Connection(object):
|
|
"""This class represents a connection to a minecraft
|
|
server, it handles everything from connecting, sending packets to
|
|
handling default network behaviour
|
|
"""
|
|
def __init__(
|
|
self,
|
|
address,
|
|
port,
|
|
auth_token=None,
|
|
username=None,
|
|
initial_version=None,
|
|
allowed_versions=None,
|
|
):
|
|
"""Sets up an instance of this object to be able to connect to a
|
|
minecraft server.
|
|
|
|
The connect method needs to be called in order to actually begin
|
|
the connection
|
|
|
|
:param address: address of the server to connect to
|
|
:param port(int): port of the server to connect to
|
|
:param auth_token: :class:`authentication.AuthenticationToken` object.
|
|
If None, no authentication is attempted and the
|
|
server is assumed to be running in offline mode.
|
|
:param username: Username string; only applicable in offline mode.
|
|
:param initial_version: A Minecraft version string or protocol version
|
|
number to use as a first guess when connecting
|
|
to the server.
|
|
:param allowed_versions: A set of versions, each being a Minecraft
|
|
version string or protocol version number,
|
|
restricting the versions that the client may
|
|
use in connecting to the server.
|
|
"""
|
|
|
|
self._write_lock = Lock()
|
|
self.networking_thread = None
|
|
self.packet_listeners = []
|
|
|
|
def proto_version(version):
|
|
if isinstance(version, str):
|
|
proto_version = SUPPORTED_MINECRAFT_VERSIONS.get(version)
|
|
elif isinstance(version, int):
|
|
proto_version = version
|
|
else:
|
|
proto_version = None
|
|
if proto_version not in SUPPORTED_PROTOCOL_VERSIONS:
|
|
raise ValueError('Unsupported version number: %r.' % version)
|
|
return proto_version
|
|
|
|
if allowed_versions is None:
|
|
self.allowed_proto_versions = SUPPORTED_PROTOCOL_VERSIONS
|
|
else:
|
|
allowed_version = set(map(proto_version, allowed_versions))
|
|
self.allowed_proto_versions = allowed_version
|
|
|
|
if initial_version is None:
|
|
initial_proto_version = max(self.allowed_proto_versions)
|
|
else:
|
|
initial_proto_version = proto_version(initial_version)
|
|
self.context = ConnectionContext(
|
|
protocol_version=initial_proto_version)
|
|
|
|
self.options = _ConnectionOptions()
|
|
self.options.address = address
|
|
self.options.port = port
|
|
self.auth_token = auth_token
|
|
self.username = username
|
|
|
|
# The reactor handles all the default responses to packets,
|
|
# it should be changed per networking state
|
|
self.reactor = PacketReactor(self)
|
|
|
|
def _start_network_thread(self):
|
|
"""May safely be called multiple times."""
|
|
if self.networking_thread is None:
|
|
self.networking_thread = NetworkingThread(self)
|
|
self.networking_thread.start()
|
|
|
|
def write_packet(self, packet, force=False):
|
|
"""Writes a packet to the server.
|
|
|
|
If force is set to true, the method attempts to acquire the write lock
|
|
and write the packet out immediately, and as such may block.
|
|
|
|
If force is false then the packet will be added to the end of the
|
|
packet writing queue to be sent 'as soon as possible'
|
|
|
|
:param packet: The :class:`network.packets.Packet` to write
|
|
:param force(bool): Specifies if the packet write should be immediate
|
|
"""
|
|
packet.context = self.context
|
|
if force:
|
|
self._write_lock.acquire()
|
|
if self.options.compression_enabled:
|
|
packet.write(self.socket, self.options.compression_threshold)
|
|
else:
|
|
packet.write(self.socket)
|
|
self._write_lock.release()
|
|
else:
|
|
self._outgoing_packet_queue.append(packet)
|
|
|
|
def register_packet_listener(self, method, *args):
|
|
"""
|
|
Registers a listener method which will be notified when a packet of
|
|
a selected type is received
|
|
|
|
:param method: The method which will be called back with the packet
|
|
:param args: The packets to listen for
|
|
"""
|
|
self.packet_listeners.append(packets.PacketListener(method, *args))
|
|
|
|
def _pop_packet(self):
|
|
# Pops the topmost packet off the outgoing queue and writes it out
|
|
# through the socket
|
|
#
|
|
# Mostly an internal convenience function, caller should make sure
|
|
# they have the write lock acquired to avoid issues caused by
|
|
# asynchronous access to the socket.
|
|
# This should be the only method that removes elements from the
|
|
# outbound queue
|
|
if len(self._outgoing_packet_queue) == 0:
|
|
return False
|
|
else:
|
|
packet = self._outgoing_packet_queue.popleft()
|
|
if self.options.compression_enabled:
|
|
packet.write(self.socket, self.options.compression_threshold)
|
|
else:
|
|
packet.write(self.socket)
|
|
return True
|
|
|
|
def status(self):
|
|
self._connect()
|
|
self._handshake(1)
|
|
self._start_network_thread()
|
|
self.reactor = StatusReactor(self)
|
|
|
|
request_packet = packets.RequestPacket()
|
|
self.write_packet(request_packet)
|
|
|
|
def connect(self):
|
|
"""
|
|
Attempt to begin connecting to the server.
|
|
May safely be called multiple times after the first, i.e. to reconnect.
|
|
"""
|
|
self.spawned = False
|
|
with self._write_lock:
|
|
self._outgoing_packet_queue = deque()
|
|
|
|
self._connect()
|
|
self._handshake()
|
|
|
|
self.reactor = LoginReactor(self)
|
|
self._start_network_thread()
|
|
login_start_packet = packets.LoginStartPacket()
|
|
if self.auth_token:
|
|
login_start_packet.name = self.auth_token.profile.name
|
|
else:
|
|
login_start_packet.name = self.username
|
|
self.write_packet(login_start_packet)
|
|
|
|
def _connect(self):
|
|
# Connect a socket to the server and create a file object from the
|
|
# socket.
|
|
# The file object is used to read any and all data from the socket
|
|
# since it's "guaranteed" to read the number of bytes specified,
|
|
# the socket itself will mostly be used to write data upstream to
|
|
# the server.
|
|
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
self.socket.connect((self.options.address, self.options.port))
|
|
self.file_object = self.socket.makefile("rb", 0)
|
|
|
|
def _handshake(self, next_state=2):
|
|
handshake = packets.HandShakePacket()
|
|
handshake.protocol_version = self.context.protocol_version
|
|
handshake.server_address = self.options.address
|
|
handshake.server_port = self.options.port
|
|
handshake.next_state = next_state
|
|
|
|
self.write_packet(handshake)
|
|
|
|
|
|
class NetworkingThread(threading.Thread):
|
|
def __init__(self, connection):
|
|
threading.Thread.__init__(self)
|
|
self.interrupt = False
|
|
self.connection = connection
|
|
self.name = "Networking Thread"
|
|
self.daemon = True
|
|
|
|
def run(self):
|
|
try:
|
|
self._run()
|
|
except:
|
|
ty, ex, tb = sys.exc_info()
|
|
ex.exc_info = ty, ex, tb
|
|
self.connection.exception = ex
|
|
|
|
def _run(self):
|
|
while True:
|
|
if self.interrupt:
|
|
break
|
|
|
|
# Attempt to write out as many as 300 packets as possible every
|
|
# 0.05 seconds (20 ticks per second)
|
|
num_packets = 0
|
|
self.connection._write_lock.acquire()
|
|
try:
|
|
while self.connection._pop_packet():
|
|
num_packets += 1
|
|
if num_packets >= 300:
|
|
break
|
|
exc_info = None
|
|
except:
|
|
exc_info = sys.exc_info()
|
|
self.connection._write_lock.release()
|
|
|
|
# Read and react to as many as 50 packets
|
|
num_packets = 0
|
|
packet = self.connection.reactor.read_packet(
|
|
self.connection.file_object)
|
|
while packet:
|
|
num_packets += 1
|
|
|
|
# Do not raise an IOError if it occurred while a disconnect
|
|
# packet was received, as this may be part of an orderly
|
|
# disconnection.
|
|
if packet.packet_name == 'disconnect' and \
|
|
exc_info is not None and \
|
|
isinstance(exc_info[1], IOError):
|
|
exc_info = None
|
|
|
|
try:
|
|
self.connection.reactor.react(packet)
|
|
for listener in self.connection.packet_listeners:
|
|
listener.call_packet(packet)
|
|
except IgnorePacket:
|
|
pass
|
|
|
|
if num_packets >= 50:
|
|
break
|
|
|
|
packet = self.connection.reactor.read_packet(
|
|
self.connection.file_object)
|
|
|
|
if exc_info is not None:
|
|
raise_(*exc_info)
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
class IgnorePacket(Exception):
|
|
"""
|
|
This exception may be raised from within a packet handler, such as
|
|
`PacketReactor.react' or a packet listener added with
|
|
`Connection.register_packet_listener', to stop any subsequent handlers from
|
|
being called on that particular packet.
|
|
"""
|
|
pass
|
|
|
|
|
|
class PacketReactor(object):
|
|
"""
|
|
Reads and reacts to packets
|
|
"""
|
|
state_name = None
|
|
TIME_OUT = 0
|
|
|
|
get_clientbound_packets = staticmethod(lambda context: set())
|
|
|
|
def __init__(self, connection):
|
|
self.connection = connection
|
|
context = self.connection.context
|
|
self.clientbound_packets = {
|
|
packet.get_id(context): packet
|
|
for packet in self.__class__.get_clientbound_packets(context)}
|
|
|
|
def read_packet(self, stream):
|
|
ready_to_read = select.select([stream], [], [], self.TIME_OUT)[0]
|
|
|
|
if ready_to_read:
|
|
length = VarInt.read(stream)
|
|
|
|
packet_data = packets.PacketBuffer()
|
|
packet_data.send(stream.read(length))
|
|
# Ensure we read all the packet
|
|
while len(packet_data.get_writable()) < length:
|
|
packet_data.send(
|
|
stream.read(length - len(packet_data.get_writable())))
|
|
packet_data.reset_cursor()
|
|
|
|
if self.connection.options.compression_enabled:
|
|
compressed_size = VarInt.read(packet_data)
|
|
|
|
if compressed_size > 0:
|
|
decompressed_packet = decompress(
|
|
packet_data.read(compressed_size))
|
|
packet_data.reset()
|
|
packet_data.send(decompressed_packet)
|
|
packet_data.reset_cursor()
|
|
|
|
packet_id = VarInt.read(packet_data)
|
|
|
|
# If we know the structure of the packet, attempt to parse it
|
|
# otherwise just skip it
|
|
if packet_id in self.clientbound_packets:
|
|
packet = self.clientbound_packets[packet_id]()
|
|
packet.context = self.connection.context
|
|
packet.read(packet_data)
|
|
return packet
|
|
else:
|
|
return packets.Packet(context=self.connection.context)
|
|
else:
|
|
return None
|
|
|
|
def react(self, packet):
|
|
raise NotImplementedError("Call to base reactor")
|
|
|
|
|
|
class LoginReactor(PacketReactor):
|
|
get_clientbound_packets = staticmethod(packets.state_login_clientbound)
|
|
|
|
def react(self, packet):
|
|
if packet.packet_name == "encryption request":
|
|
|
|
secret = encryption.generate_shared_secret()
|
|
token, encrypted_secret = encryption.encrypt_token_and_secret(
|
|
packet.public_key, packet.verify_token, secret)
|
|
|
|
# A server id of '-' means the server is in offline mode
|
|
if packet.server_id != '-':
|
|
server_id = encryption.generate_verification_hash(
|
|
packet.server_id, secret, packet.public_key)
|
|
if self.connection.auth_token is not None:
|
|
self.connection.auth_token.join(server_id)
|
|
|
|
encryption_response = packets.EncryptionResponsePacket()
|
|
encryption_response.shared_secret = encrypted_secret
|
|
encryption_response.verify_token = token
|
|
|
|
# Forced because we'll have encrypted the connection by the time
|
|
# it reaches the outgoing queue
|
|
self.connection.write_packet(encryption_response, force=True)
|
|
|
|
# Enable the encryption
|
|
cipher = encryption.create_AES_cipher(secret)
|
|
encryptor = cipher.encryptor()
|
|
decryptor = cipher.decryptor()
|
|
self.connection.socket = encryption.EncryptedSocketWrapper(
|
|
self.connection.socket, encryptor, decryptor)
|
|
self.connection.file_object = \
|
|
encryption.EncryptedFileObjectWrapper(
|
|
self.connection.file_object, decryptor)
|
|
|
|
if packet.packet_name == "disconnect":
|
|
# Test for a disconnect packet indicating a version mismatch.
|
|
# (Note: it is known how the disconnect messages are formatted for
|
|
# official servers within SUPPORTED_MINECRAFT_VERSIONS, but in case
|
|
# new versions are added, this section may need to be updated.)
|
|
try:
|
|
data = json.loads(packet.json_data)
|
|
except ValueError:
|
|
pass
|
|
if isinstance(data, dict) and 'text' in data:
|
|
data = data['text']
|
|
if not isinstance(data, (str, unicode)):
|
|
return
|
|
match = re.match(
|
|
r"(Outdated client! Please use"
|
|
r"|Outdated server! I'm still on) (?P<version>.*)", data)
|
|
if not match:
|
|
return
|
|
version = match.group('version')
|
|
if version in SUPPORTED_MINECRAFT_VERSIONS:
|
|
new_version = SUPPORTED_MINECRAFT_VERSIONS[version]
|
|
elif data.startswith('Outdated client!'):
|
|
new_version = max(SUPPORTED_PROTOCOL_VERSIONS)
|
|
elif data.startswith('Outdated server!'):
|
|
new_version = min(SUPPORTED_PROTOCOL_VERSIONS)
|
|
if new_version != self.connection.context.protocol_version \
|
|
and new_version in self.connection.allowed_proto_versions:
|
|
# Ignore this disconnect packet and reconnect with the new
|
|
# protocol version, making it appear (on the client side) as if
|
|
# the client had initially connected with the (hopefully)
|
|
# correct version.
|
|
self.connection.context.protocol_version = new_version
|
|
self.connection.connect()
|
|
raise IgnorePacket
|
|
|
|
if packet.packet_name == "login success":
|
|
self.connection.reactor = PlayingReactor(self.connection)
|
|
|
|
if packet.packet_name == "set compression":
|
|
self.connection.options.compression_threshold = packet.threshold
|
|
self.connection.options.compression_enabled = True
|
|
|
|
|
|
class PlayingReactor(PacketReactor):
|
|
get_clientbound_packets = staticmethod(packets.state_playing_clientbound)
|
|
|
|
def react(self, packet):
|
|
if packet.packet_name == "set compression":
|
|
self.connection.options.compression_threshold = packet.threshold
|
|
self.connection.options.compression_enabled = True
|
|
|
|
if packet.packet_name == "keep alive":
|
|
keep_alive_packet = packets.KeepAlivePacketServerbound()
|
|
keep_alive_packet.keep_alive_id = packet.keep_alive_id
|
|
self.connection.write_packet(keep_alive_packet)
|
|
|
|
if packet.packet_name == "player position and look":
|
|
teleport_confirm = packets.TeleportConfirmPacket()
|
|
teleport_confirm.teleport_id = packet.teleport_id
|
|
self.connection.write_packet(teleport_confirm)
|
|
'''
|
|
position_response = packets.PositionAndLookPacket()
|
|
position_response.x = packet.x
|
|
position_response.feet_y = packet.y
|
|
position_response.z = packet.z
|
|
position_response.yaw = packet.yaw
|
|
position_response.pitch = packet.pitch
|
|
position_response.on_ground = True
|
|
self.connection.write_packet(position_response)
|
|
'''
|
|
self.connection.spawned = True
|
|
|
|
'''
|
|
if packet.packet_name == "disconnect":
|
|
print(packet.json_data) # TODO: handle propagating this back
|
|
'''
|
|
|
|
|
|
class StatusReactor(PacketReactor):
|
|
get_clientbound_packets = staticmethod(packets.state_status_clientbound)
|
|
|
|
def react(self, packet):
|
|
if packet.packet_name == "response":
|
|
print(json.loads(packet.json_response))
|
|
|
|
ping_packet = packets.PingPacket()
|
|
ping_packet.time = int(time.time())
|
|
self.connection.write_packet(ping_packet)
|
|
|
|
self.connection.networking_thread.interrupt = True
|
|
# TODO: More shutdown? idk
|