From f421091cf02a2b95ba527cd4290bc1d38a610dd8 Mon Sep 17 00:00:00 2001 From: Anton Smirnov Date: Thu, 6 Feb 2014 11:59:31 +0400 Subject: [PATCH] Initial rewrite from python + motd cleaning --- .gitignore | 5 + README.md | 18 +++ classes/NetworkException.php | 5 + classes/ServerQuery.php | 232 +++++++++++++++++++++++++++++++++++ composer.json | 20 +++ example/query.php | 14 +++ 6 files changed, 294 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 classes/NetworkException.php create mode 100644 classes/ServerQuery.php create mode 100644 composer.json create mode 100644 example/query.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f669e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +*.iml +vendor/ +composer.phar +composer.lock diff --git a/README.md b/README.md new file mode 100644 index 0000000..05d92ad --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +Minecraft Server Query +====================== + +A PHP library rewritten from python +[https://github.com/Dinnerbone/mcstatus](https://github.com/Dinnerbone/mcstatus) + +Usage +----- + +```php +$minecraft = new \SandFoxIM\Minecraft\ServerQuery($argv[1], $argv[2], 2); + +echo "Basic info:\n"; +var_export($minecraft->getStatus()); + +echo "Full info:\n"; +var_export($minecraft->getRules()); +``` diff --git a/classes/NetworkException.php b/classes/NetworkException.php new file mode 100644 index 0000000..ec374fc --- /dev/null +++ b/classes/NetworkException.php @@ -0,0 +1,5 @@ + + * @license MIT + */ + +namespace SandFoxIM\Minecraft; + +class ServerQuery +{ + const MAGIC_PREFIX = "\xFE\xFD"; + const PACKET_TYPE_CHALLENGE = 9; + const PACKET_TYPE_QUERY = 0; + + public $HUMAN_READABLE_NAMES = array( + 'game_id' => 'Game Name', + 'gametype' => 'Game Type', + 'motd' => 'Message of the Day', + 'hostname' => 'Server Address', + 'hostport' => 'Server Port', + 'map' => 'Main World Name', + 'maxplayers' => 'Maximum Players', + 'numplayers' => 'Players Online', + 'players' => 'List of Players', + 'plugins' => 'List of Plugins', + 'raw_plugins' => 'Raw Plugin Info', + 'software' => 'Server Software', + 'version' => 'Game Version', + ); + + private $host; + private $port; + private $id; + private $id_packed; + private $challenge; + private $challenge_packed; + private $retries; + private $max_retries; + + private $socket; + + public function __construct($host, $port = 25565, $timeout = 10, $id = 0, $retries = 2) + { + $this->host = $host; + $this->port = $port; + $this->id = $id; + $this->id_packed = pack('N', $id); + $this->challenge_packed = pack('N', 0); + $this->retries = 0; + $this->max_retries = $retries; + + $this->socket = socket_create(AF_INET, SOCK_DGRAM, 0); + socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, array('sec' => $timeout, 'usec' => 0)); + socket_set_option($this->socket, SOL_SOCKET, SO_SNDTIMEO, array('sec' => $timeout, 'usec' => 0)); + } + + private function sendRaw($data) + { + $rawdata = self::MAGIC_PREFIX . $data; + socket_sendto($this->socket, $rawdata, strlen($rawdata), 0, $this->host, $this->port); + } + + private function sendPacket($type, $data = '') + { + $this->sendRaw(pack('C', $type) . $this->id_packed . $this->challenge_packed . $data); + } + + private function readPacket() + { + $result = socket_recvfrom($this->socket, $buff, 1460, 0, $name, $port); + + if ($result === false) { + throw new NetworkException('Cannot obtain packet data'); + } + + $type = unpack('C', substr($buff, 0, 1)); + $id = unpack('N', substr($buff, 1, 4)); + $data = substr($buff, 5); + + return array($type, $id, $data); + } + + private function handshake($bypass_retries = false) + { + $this->sendPacket(self::PACKET_TYPE_CHALLENGE); + + try { + list(, , $buff) = $this->readPacket(); + } catch (NetworkException $e) { + if ($bypass_retries === false) { + $this->retries += 1; + } + + if ($this->retries < $this->max_retries) { + $this->handshake($bypass_retries); + return; + } else { + throw $e; + } + } + + $this->challenge = intval(substr($buff, 0, -1)); + $this->challenge_packed = pack('N', $this->challenge); + } + + public function getStatus() + { + if (empty($this->challenge)) { + $this->handshake(); + } + + $this->sendPacket(self::PACKET_TYPE_QUERY); + + try { + list(,, $buff) = $this->readPacket(); + } catch (NetworkException $e) { + $this->handshake(); + return $this->getStatus(); + } + + $data = array(); + + list( + $data['motd'], + $data['gametype'], + $data['map'], + $data['numplayers'], + $data['maxplayers'], + $buff + ) = explode("\x00", $buff, 6); + + list(, $data['hostport']) = unpack('v', substr($buff, 0, 2)); + + $buff = substr($buff, 2); + + $data['hostname'] = substr($buff, 0, -1); + + $data['numplayers'] = intval($data['numplayers']); + $data['maxplayers'] = intval($data['maxplayers']); + + return $data; + } + + public function getRules() + { + if (empty($this->challenge)) { + $this->handshake(); + } + + $this->sendPacket(self::PACKET_TYPE_QUERY, $this->id_packed); + + try { + list(,, $buff) = $this->readPacket(); + } catch (NetworkException $e) { + $this->retries += 1; + + if ($this->retries < $this->max_retries) { + $this->handshake(true); + return $this->getRules(); + } else { + throw $e; + } + } + + $buff = substr($buff, 11); // splitnum + 2 ints + list($items, $players) = explode("\x00\x00\x01player_\x00\x00", $buff); // I hope it works + + if (substr($items, 0, 8) === 'hostname') { + $items = 'motd' . substr($items, 8); + } + + $items = explode("\x00", $items); + + // it should mean data = dict(zip(items[::2], items[1::2])) + $items_keys = array(); + $items_values = array(); + for ($i = 0; $i < count($items);) { + $items_keys []= $items[$i++]; + $items_values []= $items[$i++]; + } + + $data = array_combine($items_keys, $items_values); + + $players = substr($players, 0, -2); + + if ($players) { + $data['players'] = explode("\x00", $players); + } else { + $data['players'] = array(); + } + + foreach (array('numplayers', 'maxplayers', 'hostport') as $key) { + if ($data[$key]) { + $data[$key] = intval($data[$key]); + } + } + + $data['raw_motd'] = $data['motd']; + $data['motd'] = $this->cleanMotd($data['motd']); + + $data['raw_plugins'] = $data['plugins']; + + list($data['software'], $data['plugins']) = $this->parsePlugins($data['raw_plugins']); + + return $data; + } + + private function parsePlugins($raw) + { + $parts = explode(':', $raw, 2); + $server = trim($parts[0]); + $plugins = array(); + + if (count($parts) === 2) { + $plugins = explode(';', $parts[1]); + $plugins = array_map(function($s) { + return trim($s); + }, $plugins); + } + + return array($server, $plugins); + } + + private function cleanMotd($motd) + { + return preg_replace('/&./', '', $motd); + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..2b07ff5 --- /dev/null +++ b/composer.json @@ -0,0 +1,20 @@ +{ + "name": "sandfox-im/minecraft-query", + "description": "A PHP class for checking the status of an enabled Minecraft server", + "license": "MIT", + "authors": [ + { + "name": "Anton Smirnov", + "email": "sandfox@sandfox.im" + } + ], + "autoload": { + "psr-4": { + "SandFoxIM\\Minecraft\\": "classes/" + } + }, + "require": { + "php": ">= 5.3", + "ext-sockets": "*" + } +} diff --git a/example/query.php b/example/query.php new file mode 100644 index 0000000..81f3fc9 --- /dev/null +++ b/example/query.php @@ -0,0 +1,14 @@ +addPsr4('SandFoxIM\\Minecraft\\', __DIR__. '/../classes'); + +$minecraft = new \SandFoxIM\Minecraft\ServerQuery($argv[1], $argv[2], 2); + +echo "Basic info:\n"; +var_export($minecraft->getStatus()); + +echo "Full info:\n"; +var_export($minecraft->getRules());