From f07976fbec7f3d999a9353349142c32507c38869 Mon Sep 17 00:00:00 2001 From: Nathan Adams Date: Thu, 4 Sep 2014 22:25:21 +0200 Subject: [PATCH] Added initial work for server pinging. Normal pings work, Query NYI. --- mcstatus/pinger.py | 54 ++++++ mcstatus/protocol/__init__.py | 0 mcstatus/protocol/connection.py | 115 +++++++++++++ mcstatus/server.py | 22 +++ mcstatus/tests/__init__.py | 0 mcstatus/tests/protocol/__init__.py | 1 + mcstatus/tests/protocol/test_connection.py | 185 +++++++++++++++++++++ mcstatus/tests/protocol/test_pinger.py | 44 +++++ mcstatus/tests/test_server.py | 29 ++++ setup.py | 5 +- 10 files changed, 453 insertions(+), 2 deletions(-) create mode 100644 mcstatus/pinger.py create mode 100644 mcstatus/protocol/__init__.py create mode 100644 mcstatus/protocol/connection.py create mode 100644 mcstatus/server.py create mode 100644 mcstatus/tests/__init__.py create mode 100644 mcstatus/tests/protocol/__init__.py create mode 100644 mcstatus/tests/protocol/test_connection.py create mode 100644 mcstatus/tests/protocol/test_pinger.py create mode 100644 mcstatus/tests/test_server.py diff --git a/mcstatus/pinger.py b/mcstatus/pinger.py new file mode 100644 index 0000000..5bce229 --- /dev/null +++ b/mcstatus/pinger.py @@ -0,0 +1,54 @@ +import datetime +import random + +from mcstatus.protocol.connection import Connection + + +class ServerPinger: + def __init__(self, connection, host="", port=0, version=47, ping_token=None): + if ping_token is None: + ping_token = random.randint(0, 1 << 63 - 1) + self.version = version + self.connection = connection + self.host = host + self.port = port + self.ping_token = ping_token + + def handshake(self): + packet = Connection() + packet.write_varint(0) + packet.write_varint(self.version) + packet.write_utf(self.host) + packet.write_short(self.port) + packet.write_varint(1) # Intention to query status + + self.connection.write_buffer(packet) + + def read_status(self): + request = Connection() + request.write_varint(0) # Request status + self.connection.write_buffer(request) + + response = self.connection.read_buffer() + if response.read_varint() != 0: + raise IOError("Received invalid query response packet.") + return response.read_utf() + + def test_ping(self): + request = Connection() + request.write_varint(1) # Test ping + request.write_long(self.ping_token) + sent = datetime.datetime.now() + self.connection.write_buffer(request) + + response = self.connection.read_buffer() + received = datetime.datetime.now() + if response.read_varint() != 1: + raise IOError("Received invalid ping response packet.") + received_token = response.read_long() + if received_token != self.ping_token: + raise IOError("Received mangled ping response packet (expected token %d, received %d)" % (self.ping_token, received_token)) + + delta = (received - sent) + # We have no trivial way of getting a time delta :( + return (delta.days * 24 * 60 * 60 + delta.seconds) * 1000 + delta.microseconds / 1000.0 \ No newline at end of file diff --git a/mcstatus/protocol/__init__.py b/mcstatus/protocol/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcstatus/protocol/connection.py b/mcstatus/protocol/connection.py new file mode 100644 index 0000000..6c77cd9 --- /dev/null +++ b/mcstatus/protocol/connection.py @@ -0,0 +1,115 @@ +import socket +import struct + + +class Connection: + def __init__(self): + self.sent = bytearray() + self.received = bytearray() + + def read(self, length): + result = "" + + result = self.received[:length] + self.received = self.received[length:] + return result + + def write(self, data): + self.sent += data + + def receive(self, data): + self.received += data + + def remaining(self): + return len(self.received) + + def flush(self): + result = self.sent + self.sent = "" + return result + + def read_varint(self): + result = 0 + for i in range(5): + part = ord(self.read(1)) + result |= (part & 0x7F) << 7 * i + if not part & 0x80: + return result + raise IOError("Server sent a varint that was too big!") + + def write_varint(self, value): + remaining = value + for i in range(5): + if remaining & ~0x7F == 0: + self.write(chr(remaining)) + return + self.write(chr(remaining & 0x7F | 0x80)) + remaining >>= 7 + raise ValueError("The value %d is too big to send in a varint" % value) + + def read_utf(self): + length = self.read_varint() + return str(self.read(length)).encode('utf8') + + def write_utf(self, value): + self.write_varint(len(value)) + self.write(bytearray(value.decode('utf8'), 'utf8')) + + def read_short(self): + return struct.unpack(">h", str(self.read(2)))[0] + + def write_short(self, value): + self.write(struct.pack(">h", value)) + + def read_ushort(self): + return struct.unpack(">H", str(self.read(2)))[0] + + def write_ushort(self, value): + self.write(struct.pack(">H", value)) + + def read_long(self): + return struct.unpack(">q", str(self.read(8)))[0] + + def write_long(self, value): + self.write(struct.pack(">q", value)) + + def read_ulong(self): + return struct.unpack(">Q", str(self.read(8)))[0] + + def write_ulong(self, value): + self.write(struct.pack(">Q", value)) + + def read_buffer(self): + length = self.read_varint() + result = Connection() + result.receive(self.read(length)) + return result + + def write_buffer(self, buffer): + data = buffer.flush() + self.write_varint(len(data)) + self.write(data) + + +class TCPSocketConnection(Connection): + def __init__(self, addr): + Connection.__init__(self) + self.socket = socket.create_connection(addr, timeout=10) + + def flush(self): + raise TypeError("SocketConnection does not support flush()") + + def receive(self, data): + raise TypeError("SocketConnection does not support receive()") + + def remaining(self): + raise TypeError("SocketConnection does not support remaining()") + + def read(self, length): + result = "" + while len(result) < length: + result += self.socket.recv(length - len(result)) + return result + + def write(self, data): + self.socket.send(data) \ No newline at end of file diff --git a/mcstatus/server.py b/mcstatus/server.py new file mode 100644 index 0000000..d469887 --- /dev/null +++ b/mcstatus/server.py @@ -0,0 +1,22 @@ +import json +import socket +from mcstatus.pinger import ServerPinger +from mcstatus.protocol.connection import TCPSocketConnection + + +class MinecraftServer: + def __init__(self, host, port=25565): + self.host = host + self.port = port + + def ping_server(self, **kwargs): + connection = TCPSocketConnection((self.host, self.port)) + pinger = ServerPinger(connection, host=self.host, port=self.port, **kwargs) + pinger.handshake() + try : + return { + "status": json.loads(pinger.read_status()), + "latency": pinger.test_ping(), + } + except ValueError as ex: + raise IOError("The server responded with invalid json") \ No newline at end of file diff --git a/mcstatus/tests/__init__.py b/mcstatus/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcstatus/tests/protocol/__init__.py b/mcstatus/tests/protocol/__init__.py new file mode 100644 index 0000000..ea49385 --- /dev/null +++ b/mcstatus/tests/protocol/__init__.py @@ -0,0 +1 @@ +__author__ = 'Dinnerbone' diff --git a/mcstatus/tests/protocol/test_connection.py b/mcstatus/tests/protocol/test_connection.py new file mode 100644 index 0000000..16122e6 --- /dev/null +++ b/mcstatus/tests/protocol/test_connection.py @@ -0,0 +1,185 @@ +import socket +from unittest import TestCase +from mock import Mock, patch + +from mcstatus.protocol.connection import Connection, TCPSocketConnection + + +class TestConnection(TestCase): + def setUp(self): + self.connection = Connection() + + def test_flush(self): + self.connection.sent = "\x7F\xAA\xBB" + + self.assertEqual(self.connection.flush(), "\x7F\xAA\xBB") + self.assertTrue(self.connection.sent == "") + + def test_receive(self): + self.connection.receive("\x7F") + self.connection.receive("\xAA\xBB") + + self.assertEqual(self.connection.received, "\x7F\xAA\xBB") + + def test_remaining(self): + self.connection.receive("\x7F") + self.connection.receive("\xAA\xBB") + + self.assertEqual(self.connection.remaining(), 3) + + def test_send(self): + self.connection.write("\x7F") + self.connection.write("\xAA\xBB") + + self.assertEqual(self.connection.flush(), "\x7F\xAA\xBB") + + def test_read(self): + self.connection.receive("\x7F\xAA\xBB") + + self.assertEqual(self.connection.read(2), "\x7F\xAA") + self.assertEqual(self.connection.read(1), "\xBB") + + def test_readSimpleVarInt(self): + self.connection.receive("\x0F") + + self.assertEqual(self.connection.read_varint(), 15) + + def test_writeSimpleVarInt(self): + self.connection.write_varint(15) + + self.assertEqual(self.connection.flush(), "\x0F") + + def test_readBigVarInt(self): + self.connection.receive("\xFF\xFF\xFF\xFF\x7F") + + self.assertEqual(self.connection.read_varint(), 34359738367) + + def test_writeBigVarInt(self): + self.connection.write_varint(2147483647) + + self.assertEqual(self.connection.flush(), "\xFF\xFF\xFF\xFF\x07") + + def test_readInvalidVarInt(self): + self.connection.receive("\xFF\xFF\xFF\xFF\x80") + + self.assertRaises(IOError, self.connection.read_varint) + + def test_writeInvalidVarInt(self): + self.assertRaises(ValueError, self.connection.write_varint, 34359738368) + + def test_readString(self): + self.connection.receive("\x0D\x48\x65\x6C\x6C\x6F\x2C\x20\x77\x6F\x72\x6C\x64\x21") + + self.assertEqual(self.connection.read_utf(), "Hello, world!") + + def test_writeString(self): + self.connection.write_utf("Hello, world!") + + self.assertEqual(self.connection.flush(), "\x0D\x48\x65\x6C\x6C\x6F\x2C\x20\x77\x6F\x72\x6C\x64\x21") + + def test_readEmptyString(self): + self.connection.write_utf("") + + self.assertEqual(self.connection.flush(), "\x00") + + def test_readShortNegative(self): + self.connection.receive("\x80\x00") + + self.assertEqual(self.connection.read_short(), -32768) + + def test_writeShortNegative(self): + self.connection.write_short(-32768) + + self.assertEqual(self.connection.flush(), "\x80\x00") + + def test_readShortPositive(self): + self.connection.receive("\x7F\xFF") + + self.assertEqual(self.connection.read_short(), 32767) + + def test_writeShortPositive(self): + self.connection.write_short(32767) + + self.assertEqual(self.connection.flush(), "\x7F\xFF") + + def test_readUShortPositive(self): + self.connection.receive("\x80\x00") + + self.assertEqual(self.connection.read_ushort(), 32768) + + def test_writeUShortPositive(self): + self.connection.write_ushort(32768) + + self.assertEqual(self.connection.flush(), "\x80\x00") + + def test_readLongNegative(self): + self.connection.receive("\x80\x00\x00\x00\x00\x00\x00\x00") + + self.assertEqual(self.connection.read_long(), -9223372036854775808) + + def test_writeLongNegative(self): + self.connection.write_long(-9223372036854775808) + + self.assertEqual(self.connection.flush(), "\x80\x00\x00\x00\x00\x00\x00\x00") + + def test_readLongPositive(self): + self.connection.receive("\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF") + + self.assertEqual(self.connection.read_long(), 9223372036854775807) + + def test_writeLongPositive(self): + self.connection.write_long(9223372036854775807) + + self.assertEqual(self.connection.flush(), "\x7F\xFF\xFF\xFF\xFF\xFF\xFF\xFF") + + def test_readULongPositive(self): + self.connection.receive("\x80\x00\x00\x00\x00\x00\x00\x00") + + self.assertEqual(self.connection.read_ulong(), 9223372036854775808) + + def test_writeULongPositive(self): + self.connection.write_ulong(9223372036854775808) + + self.assertEqual(self.connection.flush(), "\x80\x00\x00\x00\x00\x00\x00\x00") + + def test_readBuffer(self): + self.connection.receive("\x02\x7F\xAA") + buffer = self.connection.read_buffer() + + self.assertEqual(buffer.received, "\x7F\xAA") + self.assertEqual(self.connection.flush(), "") + + def test_writeBuffer(self): + buffer = Connection() + buffer.write("\x7F\xAA") + self.connection.write_buffer(buffer) + + self.assertEqual(self.connection.flush(), "\x02\x7F\xAA") + +class TCPSocketConnectionTest(TestCase): + def setUp(self): + socket = Mock() + socket.recv = Mock() + socket.send = Mock() + with patch("socket.create_connection") as create_connection: + create_connection.return_value = socket + self.connection = TCPSocketConnection(("localhost", 1234)) + + def test_flush(self): + self.assertRaises(TypeError, self.connection.flush) + + def test_receive(self): + self.assertRaises(TypeError, self.connection.receive, "") + + def test_remaining(self): + self.assertRaises(TypeError, self.connection.remaining) + + def test_read(self): + self.connection.socket.recv.return_value = "\x7F\xAA" + + self.assertEqual(self.connection.read(2), "\x7F\xAA") + + def test_write(self): + self.connection.write("\x7F\xAA") + + self.connection.socket.send.assert_called_once_with("\x7F\xAA") \ No newline at end of file diff --git a/mcstatus/tests/protocol/test_pinger.py b/mcstatus/tests/protocol/test_pinger.py new file mode 100644 index 0000000..7083d08 --- /dev/null +++ b/mcstatus/tests/protocol/test_pinger.py @@ -0,0 +1,44 @@ +from unittest import TestCase + +from mcstatus.protocol.connection import Connection +from mcstatus.pinger import ServerPinger + + +class TestServerPinger(TestCase): + def setUp(self): + self.pinger = ServerPinger(Connection(), host="localhost", port=25565, version=44) + + def test_handshake(self): + self.pinger.handshake() + + self.assertEqual(self.pinger.connection.flush(), "\x0F\x00\x2C\x09\x6C\x6F\x63\x61\x6C\x68\x6F\x73\x74\x63\xDD\x01") + + def test_read_status(self): + self.pinger.connection.receive("\x72\x00\x70\x7B\x22\x64\x65\x73\x63\x72\x69\x70\x74\x69\x6F\x6E\x22\x3A\x22\x41\x20\x4D\x69\x6E\x65\x63\x72\x61\x66\x74\x20\x53\x65\x72\x76\x65\x72\x22\x2C\x22\x70\x6C\x61\x79\x65\x72\x73\x22\x3A\x7B\x22\x6D\x61\x78\x22\x3A\x32\x30\x2C\x22\x6F\x6E\x6C\x69\x6E\x65\x22\x3A\x30\x7D\x2C\x22\x76\x65\x72\x73\x69\x6F\x6E\x22\x3A\x7B\x22\x6E\x61\x6D\x65\x22\x3A\x22\x31\x2E\x38\x2D\x70\x72\x65\x31\x22\x2C\x22\x70\x72\x6F\x74\x6F\x63\x6F\x6C\x22\x3A\x34\x34\x7D\x7D") + + self.assertEqual(self.pinger.read_status(), '{"description":"A Minecraft Server","players":{"max":20,"online":0},"version":{"name":"1.8-pre1","protocol":44}}') + self.assertEqual(self.pinger.connection.flush(), "\x01\x00") + + def test_read_status_invalid(self): + self.pinger.connection.receive("\x01\x05") + + self.assertRaises(IOError, self.pinger.read_status) + + def test_test_ping(self): + self.pinger.connection.receive("\x09\x01\x00\x00\x00\x00\x00\xDD\x7D\x1C") + self.pinger.ping_token = 14515484 + + self.assertTrue(self.pinger.test_ping() >= 0) + self.assertEqual(self.pinger.connection.flush(), "\x09\x01\x00\x00\x00\x00\x00\xDD\x7D\x1C") + + def test_test_ping_invalid(self): + self.pinger.connection.receive("\x01\x1F") + self.pinger.ping_token = 14515484 + + self.assertRaises(IOError, self.pinger.test_ping) + + def test_test_ping_wrong_token(self): + self.pinger.connection.receive("\x09\x01\x00\x00\x00\x00\x00\xDD\x7D\x1C") + self.pinger.ping_token = 12345 + + self.assertRaises(IOError, self.pinger.test_ping) \ No newline at end of file diff --git a/mcstatus/tests/test_server.py b/mcstatus/tests/test_server.py new file mode 100644 index 0000000..237da54 --- /dev/null +++ b/mcstatus/tests/test_server.py @@ -0,0 +1,29 @@ +from unittest import TestCase +from mock import Mock, patch +from mcstatus.protocol.connection import Connection +from mcstatus.server import MinecraftServer + + +class TestMinecraftServer(TestCase): + def setUp(self): + self.socket = Connection() + self.server = MinecraftServer("localhost", port=25565) + + def test_ping_server(self): + self.socket.receive("\x6D\x00\x6B\x7B\x22\x64\x65\x73\x63\x72\x69\x70\x74\x69\x6F\x6E\x22\x3A\x22\x41\x20\x4D\x69\x6E\x65\x63\x72\x61\x66\x74\x20\x53\x65\x72\x76\x65\x72\x22\x2C\x22\x70\x6C\x61\x79\x65\x72\x73\x22\x3A\x7B\x22\x6D\x61\x78\x22\x3A\x32\x30\x2C\x22\x6F\x6E\x6C\x69\x6E\x65\x22\x3A\x30\x7D\x2C\x22\x76\x65\x72\x73\x69\x6F\x6E\x22\x3A\x7B\x22\x6E\x61\x6D\x65\x22\x3A\x22\x31\x2E\x38\x22\x2C\x22\x70\x72\x6F\x74\x6F\x63\x6F\x6C\x22\x3A\x34\x37\x7D\x7D\x09\x01\x00\x00\x00\x00\x01\xC5\x42\x46") + + with patch("mcstatus.server.TCPSocketConnection") as connection: + connection.return_value = self.socket + info = self.server.ping_server(ping_token=29704774, version=47) + + self.assertEqual(self.socket.flush(), "\x0F\x00\x2F\x09\x6C\x6F\x63\x61\x6C\x68\x6F\x73\x74\x63\xDD\x01\x01\x00\x09\x01\x00\x00\x00\x00\x01\xC5\x42\x46") + self.assertEqual(self.socket.remaining(), 0, msg="Data is pending to be read, but should be empty") + self.assertEqual(info["status"], {"description":"A Minecraft Server","players":{"max":20,"online":0},"version":{"name":"1.8","protocol":47}}) + self.assertTrue(info["latency"] >= 0) + + def test_ping_server_invalid_json(self): + self.socket.receive("\x6D\x00\x6B\x7C\x22\x64\x65\x73\x63\x72\x69\x70\x74\x69\x6F\x6E\x22\x3A\x22\x41\x20\x4D\x69\x6E\x65\x63\x72\x61\x66\x74\x20\x53\x65\x72\x76\x65\x72\x22\x2C\x22\x70\x6C\x61\x79\x65\x72\x73\x22\x3A\x7B\x22\x6D\x61\x78\x22\x3A\x32\x30\x2C\x22\x6F\x6E\x6C\x69\x6E\x65\x22\x3A\x30\x7D\x2C\x22\x76\x65\x72\x73\x69\x6F\x6E\x22\x3A\x7B\x22\x6E\x61\x6D\x65\x22\x3A\x22\x31\x2E\x38\x22\x2C\x22\x70\x72\x6F\x74\x6F\x63\x6F\x6C\x22\x3A\x34\x37\x7D\x7D\x09\x01\x00\x00\x00\x00\x01\xC5\x42\x46") + + with patch("mcstatus.server.TCPSocketConnection") as connection: + connection.return_value = self.socket + self.assertRaises(IOError, self.server.ping_server, ping_token=29704774, version=47) \ No newline at end of file diff --git a/setup.py b/setup.py index a60ef30..90a1836 100644 --- a/setup.py +++ b/setup.py @@ -2,10 +2,11 @@ from distutils.core import setup setup( name='mcstatus', - version='1.0', + version='2.0dev', author='Nathan Adams', author_email='dinnerbone@dinnerbone.com', url='https://pypi.python.org/pypi/mcstatus', packages=['minecraft_query',], - description='A library to query Minecraft Servers for their status and capabilities.' + description='A library to query Minecraft Servers for their status and capabilities.', + tests_require=['mock'], ) \ No newline at end of file