mirror of
https://github.com/ammaraskar/pyCraft.git
synced 2024-12-03 07:33:56 +01:00
317 lines
12 KiB
Python
317 lines
12 KiB
Python
from collections import deque
|
|
from threading import Lock
|
|
from zlib import decompress
|
|
import threading
|
|
import socket
|
|
import time
|
|
import select
|
|
import authentication
|
|
|
|
from packets import *
|
|
from types import VarInt
|
|
|
|
PROTOCOL_VERSION = 47
|
|
|
|
|
|
class ConnectionOptions(object):
|
|
# TODO: allow these options to be overriden from a constructor below
|
|
address = None
|
|
port = None
|
|
use_encryption = True
|
|
compression_threshold = -1
|
|
compression_enabled = False
|
|
|
|
|
|
class Connection(object):
|
|
"""This class represents a connection to a minecraft
|
|
server, it handles everything from connecting, sending packets to
|
|
handling default network behaviour
|
|
"""
|
|
_outgoing_packet_queue = deque()
|
|
_write_lock = Lock()
|
|
networking_thread = None
|
|
options = ConnectionOptions()
|
|
#: Indicates if this connection is spawned in the Minecraft game world
|
|
spawned = False
|
|
|
|
def __init__(self, address, port, login_response):
|
|
"""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 login_response: :class:`authentication.LoginResponse` object as obtained from the authentication package
|
|
"""
|
|
self.options.address = address
|
|
self.options.port = port
|
|
self.login_response = login_response
|
|
self.reactor = PacketReactor(self)
|
|
|
|
def _start_network_thread(self):
|
|
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
|
|
"""
|
|
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 _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()
|
|
print "Writing out: " + hex(packet.id) + " / " + packet.packet_name
|
|
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 = RequestPacket()
|
|
self.write_packet(request_packet)
|
|
|
|
def connect(self):
|
|
"""Attempt to begin connecting to the server
|
|
"""
|
|
self._connect()
|
|
self._handshake()
|
|
|
|
self.reactor = LoginReactor(self)
|
|
self._start_network_thread()
|
|
login_start_packet = LoginStartPacket()
|
|
login_start_packet.name = self.login_response.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()
|
|
|
|
def _handshake(self, next_state=2):
|
|
handshake = HandShakePacket()
|
|
handshake.protocol_version = 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):
|
|
interrupt = False
|
|
|
|
def __init__(self, connection):
|
|
threading.Thread.__init__(self)
|
|
self.connection = connection
|
|
self.name = "Networking Thread"
|
|
self.daemon = True
|
|
|
|
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()
|
|
while self.connection._pop_packet():
|
|
num_packets += 1
|
|
if num_packets >= 300:
|
|
break
|
|
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
|
|
|
|
self.connection.reactor.react(packet)
|
|
if num_packets >= 50:
|
|
break
|
|
|
|
packet = self.connection.reactor.read_packet(self.connection.file_object)
|
|
|
|
time.sleep(0.05)
|
|
|
|
|
|
class PacketReactor(object):
|
|
state_name = None
|
|
clientbound_packets = None
|
|
|
|
TIME_OUT = 1
|
|
|
|
def __init__(self, connection):
|
|
self.connection = connection
|
|
|
|
# Design objectives:
|
|
#
|
|
# Avoid buffering data as much as possible
|
|
# Wherever possible, read directly from the network stream
|
|
# Minimize any overheads, reading packets should be simple
|
|
def read_packet(self, stream):
|
|
ready_to_read, _, __ = select.select([self.connection.socket], [], [], self.TIME_OUT)
|
|
|
|
if self.connection.socket in ready_to_read:
|
|
length = VarInt.read_socket(self.connection.socket)
|
|
|
|
packet_data = 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)
|
|
|
|
print "Reading packet: " + str(packet_id) + " / " + hex(packet_id) + " (Size: " + str(length) + ") (Actual Size: " + str(len(packet_data.get_writable())) + ")"
|
|
|
|
# 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.read(packet_data)
|
|
return packet
|
|
else:
|
|
return Packet()
|
|
else:
|
|
return None
|
|
|
|
def react(self, packet):
|
|
raise NotImplementedError("Call to base reactor")
|
|
|
|
|
|
class LoginReactor(PacketReactor):
|
|
clientbound_packets = state_login_clientbound
|
|
|
|
def react(self, packet):
|
|
# TODO: Add some way to bypass encryption? (connection.options.use_encryption) Not sure if it's still possible.
|
|
if packet.packet_name == "encryption request":
|
|
import encryption
|
|
|
|
secret = encryption.generate_shared_secret()
|
|
encrypted_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 != '-':
|
|
url = "https://sessionserver.mojang.com/session/minecraft/join"
|
|
server_id = encryption.generate_verification_hash(packet.server_id, secret, packet.public_key)
|
|
payload = {'accessToken': self.connection.login_response.access_token,
|
|
'selectedProfile': self.connection.login_response.profile_id,
|
|
'serverId': server_id}
|
|
|
|
authentication.make_request(url, payload)
|
|
|
|
encryption_response = EncryptionResponsePacket()
|
|
encryption_response.shared_secret = encrypted_secret
|
|
encryption_response.verify_token = encrypted_token
|
|
|
|
# Forced because we don't want to send this encrypted which it will be
|
|
# if we put it in the queue as we'd have wrapped the socket and file object by then
|
|
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":
|
|
print(packet.json_data) # TODO: handle propagating this back
|
|
|
|
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):
|
|
clientbound_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 = KeepAlivePacket()
|
|
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":
|
|
position_response = 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):
|
|
clientbound_packets = state_status_clientbound
|
|
|
|
def react(self, packet):
|
|
if packet.id == ResponsePacket.id:
|
|
import json
|
|
|
|
print json.loads(packet.json_response)
|
|
|
|
ping_packet = PingPacket()
|
|
ping_packet.time = int(time.time())
|
|
self.connection.write_packet(ping_packet)
|
|
|
|
self.connection.networking_thread.interrupt = True
|
|
# TODO: More shutdown? idk
|