minecraft-status/minestat.php
2020-10-06 13:31:35 +02:00

372 lines
13 KiB
PHP

<?php
/*
* minestat.php - A Minecraft server status checker
* Copyright (C) 2014-2020 Lloyd Dilley
* http://www.dilley.me/
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
class MineStat
{
const NUM_FIELDS = 6; // number of values expected from server
const NUM_FIELDS_BETA = 3; // number of values expected from a 1.8b/1.3 server
const MAX_VARINT_SIZE = 5; // maximum number of bytes a varint can be
// No enums or class nesting in PHP, so this is our workaround for return values
const RETURN_SUCCESS = 0;
const RETURN_CONNFAIL = -1;
const RETURN_TIMEOUT = -2;
const RETURN_UNKNOWN = -3;
private $address; // hostname or IP address of the Minecraft server
private $port; // port number the Minecraft server accepts connections on
private $online; // online or offline?
private $version; // Minecraft server version
private $motd; // message of the day
private $current_players; // current number of players online
private $max_players; // maximum player capacity
private $protocol; // protocol level
private $json_data; // JSON data for 1.7 queries
private $latency; // ping time to server in milliseconds
private $timeout; // timeout in seconds
private $socket; // network socket
public function __construct($address, $port = 25565, $timeout = 5)
{
$this->address = $address;
$this->port = $port;
$this->timeout = $timeout;
$this->online = false;
$retval = $this->json_query(); // 1.7
if($retval != MineStat::RETURN_SUCCESS && $retval != MineStat::RETURN_CONNFAIL)
$retval = $this->new_query(); // 1.6
if($retval != MineStat::RETURN_SUCCESS && $retval != MineStat::RETURN_CONNFAIL)
$retval = $this->legacy_query(); // 1.4/1.5
if($retval != MineStat::RETURN_SUCCESS && $retval != MineStat::RETURN_CONNFAIL)
$retval = $this->beta_query(); // 1.8b/1.3
}
public function __destruct()
{
if(@socket_read($this->socket, 1))
{
socket_shutdown($this->socket);
socket_close($this->socket);
$this->socket = null;
}
}
public function get_address() { return $this->address; }
public function get_port() { return $this->port; }
public function is_online() { return $this->online; }
public function get_version() { return $this->version; }
public function get_motd() { return $this->motd; }
public function get_current_players() { return $this->current_players; }
public function get_max_players() { return $this->max_players; }
public function get_protocol() { return $this->protocol; }
public function get_json() { return $this->json_data; }
public function get_latency() { return $this->latency; }
/* Connects to remote server */
private function connect()
{
$this->socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_set_option($this->socket, SOL_SOCKET, SO_RCVTIMEO, array('sec' => $this->timeout, 'usec' => 0));
if($this->socket === false)
return MineStat::RETURN_CONNFAIL;
// Since socket_connect() does not respect timeout, we have to toggle non-blocking mode and enforce the timeout
socket_set_nonblock($this->socket);
$time = time();
$start_time = microtime(true);
while(!@socket_connect($this->socket, $this->address, $this->port))
{
if((time() - $time) >= $this->timeout)
{
socket_close($this->socket);
return MineStat::RETURN_TIMEOUT;
}
usleep(0);
}
$result = @socket_connect($this->socket, $this->address, $this->port);
$this->latency = round((microtime(true) - $start_time) * 1000);
socket_set_block($this->socket);
if($result === false && socket_last_error($this->socket) != SOCKET_EISCONN)
return MineStat::RETURN_CONNFAIL;
return MineStat::RETURN_SUCCESS;
}
/* Populates object fields after connecting */
private function parse_data($delimiter, $is_beta = false)
{
$response = @unpack('C', socket_read($this->socket, 1));
//socket_recv($this->socket, $response, 2, MSG_PEEK);
if(!empty($response) && $response[1] == 0xFF) // kick packet (255)
{
$len = unpack('n', socket_read($this->socket, 2))[1];
$raw_data = mb_convert_encoding(socket_read($this->socket, ($len * 2)), "UTF-8", "UTF-16BE");
socket_close($this->socket);
}
else
{
socket_close($this->socket);
return MineStat::RETURN_UNKNOWN;
}
if(isset($raw_data))
{
$server_info = explode($delimiter, $raw_data); // split on delimiter
if($is_beta)
$num_fields = MineStat::NUM_FIELDS_BETA;
else
$num_fields = MineStat::NUM_FIELDS;
if(isset($server_info) && sizeof($server_info) >= $num_fields)
{
if($is_beta)
{
$this->version = "1.8b/1.3"; // since server does not return version, set it
$this->motd = $server_info[0];
$this->current_players = (int)$server_info[1];
$this->max_players = (int)$server_info[2];
$this->online = true;
}
else
{
// $server_info[0] contains the section symbol and 1
$this->protocol = (int)$server_info[1]; // contains the protocol version (51 for 1.9 or 78 for 1.6.4 for example)
$this->version = $server_info[2];
$this->motd = $server_info[3];
$this->current_players = (int)$server_info[4];
$this->max_players = (int)$server_info[5];
$this->online = true;
}
}
else
return MineStat::RETURN_UNKNOWN;
}
else
return MineStat::RETURN_UNKNOWN;
return MineStat::RETURN_SUCCESS;
}
/*
* 1.8b/1.3
* 1.8 beta through 1.3 servers communicate as follows for a ping query:
* 1. Client sends \xFE (server list ping)
* 2. Server responds with:
* 2a. \xFF (kick packet)
* 2b. data length
* 2c. 3 fields delimited by \u00A7 (section symbol)
* The 3 fields, in order, are: message of the day, current players, and max players
*/
public function beta_query()
{
try
{
$retval = $this->connect();
if($retval != MineStat::RETURN_SUCCESS)
return $retval;
// Start the handshake and attempt to acquire data
socket_write($this->socket, "\xFE");
$retval = $this->parse_data("\xA7", true);
}
catch(Exception $e)
{
return MineStat::RETURN_UNKNOWN;
}
return $retval;
}
/*
* 1.4/1.5
* 1.4 and 1.5 servers communicate as follows for a ping query:
* 1. Client sends:
* 1a. \xFE (server list ping)
* 1b. \x01 (server list ping payload)
* 2. Server responds with:
* 2a. \xFF (kick packet)
* 2b. data length
* 2c. 6 fields delimited by \x00 (null)
* The 6 fields, in order, are: the section symbol and 1, protocol version,
* server version, message of the day, current players, and max players.
* The protocol version corresponds with the server version and can be the
* same for different server versions.
*/
public function legacy_query()
{
try
{
$retval = $this->connect();
if($retval != MineStat::RETURN_SUCCESS)
return $retval;
// Start the handshake and attempt to acquire data
socket_write($this->socket, "\xFE\x01");
$retval = $this->parse_data("\x00");
}
catch(Exception $e)
{
return MineStat::RETURN_UNKNOWN;
}
return $retval;
}
/*
* 1.6
* 1.6 servers communicate as follows for a ping query:
* 1. Client sends:
* 1a. \xFE (server list ping)
* 1b. \x01 (server list ping payload)
* 1c. \xFA (plugin message)
* 1d. \x00\x0B (11 which is the length of "MC|PingHost")
* 1e. "MC|PingHost" encoded as a UTF-16BE string
* 1f. length of remaining data as a short: remote address (encoded as UTF-16BE) + 7
* 1g. arbitrary 1.6 protocol version (\x4E for example for 78)
* 1h. length of remote address as a short
* 1i. remote address encoded as a UTF-16BE string
* 1j. remote port as an int
* 2. Server responds with:
* 2a. \xFF (kick packet)
* 2b. data length
* 2c. 6 fields delimited by \x00 (null)
* The 6 fields, in order, are: the section symbol and 1, protocol version,
* server version, message of the day, current players, and max players.
* The protocol version corresponds with the server version and can be the
* same for different server versions.
*/
public function new_query()
{
try
{
$retval = $this->connect();
if($retval != MineStat::RETURN_SUCCESS)
return $retval;
// Start the handshake and attempt to acquire data
socket_write($this->socket, "\xFE\x01\xFA");
socket_write($this->socket, "\x00\x0B"); // 11 (length of "MC|PingHost")
socket_write($this->socket, mb_convert_encoding("MC|PingHost", "UTF-16BE")); // requires PHP mbstring
socket_write($this->socket, pack('n', (7 + 2 * strlen($this->address))));
socket_write($this->socket, "\x4E"); // 78 (protocol version of 1.6.4)
socket_write($this->socket, pack('n', strlen($this->address)));
socket_write($this->socket, mb_convert_encoding($this->address, "UTF-16BE"));
socket_write($this->socket, pack('N', $this->port));
$retval = $this->parse_data("\x00");
}
catch(Exception $e)
{
return MineStat::RETURN_UNKNOWN;
}
return $retval;
}
/*
* 1.7
* 1.7 to current servers communicate as follows for a ping query:
* 1. Client sends:
* 1a. \x00 (handshake packet containing the fields specified below)
* 1b. \x00 (request)
* The handshake packet contains the following fields respectively:
* 1. protocol version as a varint (\x00 suffices)
* 2. remote address as a string
* 3. remote port as an unsigned short
* 4. state as a varint (should be 1 for status)
* 2. Server responds with:
* 2a. \x00 (JSON response)
* An example JSON string contains:
* {'players': {'max': 20, 'online': 0},
* 'version': {'protocol': 404, 'name': '1.13.2'},
* 'description': {'text': 'A Minecraft Server'}}
*/
public function json_query()
{
try
{
$retval = $this->connect();
if($retval != MineStat::RETURN_SUCCESS)
return $retval;
// Start handshake
$payload = "\x00\x00";
$payload .= pack('c', strlen($this->address)) . $this->address;
$payload .= pack('n', $this->port);
$payload .= "\x01";
$payload = pack('c', strlen($payload)) . $payload;
socket_write($this->socket, $payload);
socket_write($this->socket, "\x01\x00");
// Acquire data
$total_len = $this->unpack_varint();
if($this->unpack_varint() != 0)
return MineStat::RETURN_UNKNOWN;
$json_len = $this->unpack_varint();
socket_recv($this->socket, $response, $json_len, MSG_WAITALL);
socket_close($this->socket);
$json_data = json_decode($response, true);
if(json_last_error() != 0)
{
//echo(json_last_error_msg());
return MineStat::RETURN_UNKNOWN;
}
$this->json_data = $json_data;
// Parse data
//var_dump($json_data);
$this->protocol = (int)@$json_data['version']['protocol'];
$this->version = @$json_data['version']['name'];
$this->motd = @$json_data['description']['text'];
$this->current_players = (int)@$json_data['players']['online'];
$this->max_players = (int)@$json_data['players']['max'];
if(isset($this->version) && isset($this->motd) && isset($this->current_players) && isset($this->max_players))
$this->online = true;
else
return MineStat::RETURN_UNKNOWN;
}
catch(Exception $e)
{
return MineStat::RETURN_UNKNOWN;
}
return MineStat::RETURN_SUCCESS;
}
/* Returns value of varint type */
private function unpack_varint()
{
$vint = 0;
for($i = 0; $i <= MineStat::MAX_VARINT_SIZE; $i++)
{
$data = socket_read($this->socket, 1);
if(!$data)
return 0;
$data = ord($data);
$vint |= ($data & 0x7F) << $i++ * 7;
if(($data & 0x80) != 128)
break;
}
return $vint;
}
}
?>