This is my first major PHP project and I haven't gotten much feedback. I wanted to learn how to use PHP and I wanted to learn about Minecraft's status protocol, so I did what was obvious at the time.
File listing
The code is also on GitHub.
mcstat.php
Contains class MinecraftStatus
and is also a stand-alone CLI tool.
#!/usr/bin/env php
<?php
class MinecraftStatus {
public $hostname;
public $port;
public $stats;
function __construct($hostname, $port=25565)
{
$this->hostname = $hostname;
$this->port = $port;
}
public function ping()
{
$newStats = $this->serverListPing($this->hostname, $this->port);
$this->stats[microtime()] = array(
'stats' => $newStats,
'method' => 'Server List Ping',
'hostname' => $this->hostname,
'port' => $this->port
);
return $newStats;
}
public function query($fullQuery=true)
{
if ($fullQuery) {
$newStats = $this->fullQuery($this->hostname, $this->port);
$this->stats[microtime()] = array(
'stats' => $newStats,
'method' => 'Full Query',
'hostname' => $this->hostname,
'port' => $this->port
);
} else {
$newStats = $this->basicQuery($this->hostname, $this->port);
$this->stats[microtime()] = array(
'stats' => $newStats,
'method' => 'Basic Query',
'hostname' => $this->hostname,
'port' => $this->port
);
}
return $newStats;
}
/*
================
Server List Ping
================
An example of how to get a Minecraft server status's using a "Server List Ping" packet.
See details here: http://www.wiki.vg/Server_List_Ping
*/
private function packString($string)
{
return pack('n', strlen($string)) . mb_convert_encoding($string, 'UCS-2BE');
}
// This is needed since UCS-2 text rendered as UTF-8 contains unnecessary null bytes
// and could cause other components, especially string functions to blow up. Boom!
private function decodeUCS2BE($string)
{
return mb_convert_encoding($string, 'UTF-8', 'UCS-2BE');
}
private function serverListPing($hostname, $port=25565)
{
// 1. pack data to send
$request = pack('nc', 0xfe01, 0xfa) .
$this->packString('MC|PingHost') .
pack('nc', 7+2*strlen($hostname), 73) .
$this->packString($hostname) .
pack('N', 25565);
// 2. open communication socket and make transaction
$time = microtime(true);
$fp = stream_socket_client('tcp://' . $hostname . ':' . $port);
if (!$fp) {
return false;
}
fwrite($fp, $request);
$response = fread($fp, 2048);
fclose($fp);
$time = round((microtime(true)-$time)*1000);
// 3. unpack data and return
if (strpos($response, 0xFF) !== 0) {
return false;
}
$response = substr($response, 3);
$response = explode(pack('n', 0), $response);
return array(
'player_count' => $this->decodeUCS2BE($response[4]),
'player_max' => $this->decodeUCS2BE($response[5]),
'motd' => $this->decodeUCS2BE($response[3]),
'server_version' => $this->decodeUCS2BE($response[2]),
'protocol_version' => $this->decodeUCS2BE($response[1]),
'latency' => $time
);
}
/*
=====
Query
=====
This section utilizes the UT3 Query protocol to query a Minecraft server.
Read about it here: http://wiki.vg/Query
*/
private function getStrings($fp, $count)
{
$nulsProcessed = 0;
while ($nulsProcessed < $count) {
while ($c != chr(0)) {
$s .= $c;
$c = fread($fp, 1);
}
$strings[] = $s;
$nulsProcessed++;
unset($c);
unset($s);
}
return $strings;
}
private function makeSessionId()
{
return rand(1, 0xFFFFFFFF) & 0x0F0F0F0F;
}
// Verify packet type and ensure it references our session ID.
private function validateQueryResponse($response, $responseType, $sessionId)
{
if (strpos($response, $responseType) !== 0 && (int)substr($response, 1, 4) === $sessionId) {
error_log('Received invalid response "' . bin2hex($response) . '". Returning.');
return false;
}
return true;
}
private function handleQueryHandshake($fp, $sessionId)
{
$handshakeRequest = pack('cccN', 0xFE, 0xFD, 9, $sessionId);
fwrite($fp, $handshakeRequest);
$handshakeResponse = fread($fp, 2048);
if (!$this->validateQueryResponse($handshakeResponse, 9, $sessionId)) {
return false;
}
$challengeToken = substr($handshakeResponse, 5, -1);
return $challengeToken;
}
private function basicQuery($hostname, $port=25565)
{
$sessionId = $this->makeSessionId();
$fp = stream_socket_client('udp://' . $hostname . ':' . $port);
if (!$fp) {
return false;
}
$time = microtime(true);
$challengeToken = $this->handleQueryHandshake($fp, $sessionId);
if (!$challengeToken) {
fclose($fp);
return false;
}
$time = round((microtime(true)-$time)*1000);
$statRequest = pack('cccNN', 0xFE, 0xFD, 0, $sessionId, $challengeToken);
fwrite($fp, $statRequest);
$statResponseHeader = fread($fp, 5);
if (!$this->validateQueryResponse($statResponseHeader, 0, $sessionId)) {
fclose($fp);
return false;
}
$statData = array_merge($this->getStrings($fp, 5), unpack('v', fread($fp, 2)), $this->getStrings($fp, 1));
fclose($fp);
return array(
'motd' => $statData[0],
'gametype' => $statData[1],
'map' => $statData[2],
'player_count' => $statData[3],
'player_max' => $statData[4],
'port' => (string)$statData[5],
'ip' => $statData[6],
'latency' => $time
);
}
private function fullQuery($hostname, $port=25565)
{
$sessionId = $this->makeSessionId();
$fp = stream_socket_client('udp://' . $hostname . ':' . $port);
if (!$fp) {
return false;
}
$time = microtime(true);
$challengeToken = $this->handleQueryHandshake($fp, $sessionId);
if (!$challengeToken) {
fclose($fp);
return false;
}
$time = round((microtime(true)-$time)*1000);
$statRequest = pack('cccNNN', 0xFE, 0xFD, 0, $sessionId, $challengeToken, 0);
fwrite($fp, $statRequest);
$statResponseHeader = fread($fp, 5);
if (!$this->validateQueryResponse($statResponseHeader, 0, $sessionId)) {
fclose($fp);
return false;
}
fread($fp, 11);
// Should only encounter double null thrice.
while ($doubleNulsEncountered < 3) {
$c = fread($fp, 1);
$statResponse .= $c;
if ($lastWasNul && $c === chr(0)) {
$doubleNulsEncountered++;
}
$lastWasNul = ($c === chr(0));
}
fclose($fp);
$statResponseData = explode(pack('cccccccccccc', 0x00, 0x00, 0x01, 0x70, 0x6C, 0x61,
0x79, 0x65, 0x72, 0x5F, 0x00, 0x00), $statResponse);
foreach (explode(chr(0), $statResponseData[0]) as $index => $item) {
if (!($index % 2)) {
switch ($item) {
case 'numplayers':
$key = 'player_count';
break;
case 'maxplayers':
$key = 'player_max';
break;
case 'hostname':
$key = 'motd';
break;
case 'hostip':
$key = 'ip';
break;
case 'hostport':
$key = 'port';
break;
default:
$key = $item;
break;
}
} else {
if ($key == 'port') {
$item = (string)$item;
}
$stats[$key] = $item;
}
}
$stats['latency'] = $time;
$players = explode(chr(0), $statResponseData[1]);
array_pop($players);
$stats['players'] = $players;
return $stats;
}
}
// ============================================================
if (!count(debug_backtrace())) {
$args = array_slice($argv, 1);
foreach ($args as $arg) {
$e = explode(':', $arg, 1);
$len = count($e);
if ($len > 2 || $len < 1) {
print('Invalid host '.$arg);
exit(1);
} elseif ($len == 1) {
$e[1] = 25565;
}
$m = new MinecraftStatus($e[0], $e[1]);
$reply = $m->ping();
$motd = preg_replace("/(\\x{00A7}.|\n)/u", '', $reply['motd']);
$message = $e[0].':'.$e[1].' '.$motd.' ';
$message .= $reply['player_count'].'/'.$reply['player_max']. ' ';
$message .= $reply['latency'].'ms'."\n";
print($message);
}
}
?>
minecraft_users.php
Stand-alone Munin plugin.
#!/usr/bin/env php
<?php
/*
===============
minecraft_users
===============
This is munin plugin to monitor the player count on a Minecraft server.
Install it like any other munin plugin:
# cp minecraft_users.php /usr/share/munin/plugins/minecraft_users
# chmod 755 /usr/share/munin/plugins/minecraft_users
# ln -s /usr/share/munin/plugins/minecraft_users /etc/munin/plugins/minecraft_users
Config:
[minecraft_users]
env.host aminecraftserver.org
env.port 25565
*/
error_reporting(E_ERROR | E_PARSE);
$host = getenv('host');
$host = $host ? $host : 'localhost';
$port = getenv('port');
$port = $port ? $port : '25565';
if ((count($argv) > 1) && ($argv[1] == 'config')) {
print("graph_title Connected players\n");
print("graph_vlabel players\n");
print("players.label Number of players\n");
print("max_players.label Max players\n");
print("graph_info Number of players connected to Minecraft\n");
print("graph_scale no\n");
print("graph_category minecraft\n");
exit();
}
/*
================
Server List Ping
================
An example of how to get a Minecraft server status's using a "Server List Ping" packet.
See details here: http://www.wiki.vg/Server_List_Ping
*/
function MC_packString($string)
{
return pack('n', strlen($string)) . mb_convert_encoding($string, 'UCS-2BE');
}
// This is needed since UCS-2 text rendered as UTF-8 contains unnecessary null bytes
// and could cause other components, especially string functions to blow up. Boom!
function MC_decodeUCS2BE($string)
{
return mb_convert_encoding($string, 'UTF-8', 'UCS-2BE');
}
function MC_serverListPing($hostname, $port=25565)
{
// 1. pack data to send
$request = pack('nc', 0xfe01, 0xfa) .
MC_packString('MC|PingHost') .
pack('nc', 7+2*strlen($hostname), 73) .
MC_packString($hostname) .
pack('N', 25565);
// 2. open communication socket and make transaction
$time = microtime(true);
$fp = stream_socket_client('tcp://' . $hostname . ':' . $port);
if (!$fp) {
return false;
}
fwrite($fp, $request);
$response = fread($fp, 2048);
fclose($fp);
$time = round((microtime(true)-$time)*1000);
// 3. unpack data and return
if (strpos($response, 0xFF) !== 0) {
return false;
}
$response = substr($response, 3);
$response = explode(pack('n', 0), $response);
return array(
'player_count' => MC_decodeUCS2BE($response[4]),
'player_max' => MC_decodeUCS2BE($response[5]),
'motd' => MC_decodeUCS2BE($response[3]),
'server_version' => MC_decodeUCS2BE($response[2]),
'protocol_version' => MC_decodeUCS2BE($response[1]),
'latency' => $time
);
}
// ============================================================
$reply = MC_serverListPing($host, $port);
print('players.value ' . $reply['player_count'] . "\n");
print('max_players.value ' . $reply['player_max'] . "\n");
?>
stat.php
Basic webpage to check Minecraft server status.
<?php
require_once './mcstat.php';
require_once './mcformat.php';
$hostname = NULL;
if (array_key_exists('server', $_GET)) {
$hostname = $_GET['server'];
}
if ($hostname) {
$m = new MinecraftStatus($hostname);
$status = $m->ping();
}
$hostname = htmlspecialchars($hostname);
echo '
<html>
<head>
<style>
.motd {
text-shadow: 1px 1px 1px #000000;
filter: dropshadow(color=#000000, offx=1, offy=1);
}
</style>';
echo '<title> Minecraft Server Status' . ($hostname ? ' :: ' . $hostname : '') . '</title>';
echo '</head>
<body>
<p>Query server status:</p>
<form name="MC" method="get" action="">
<input type="text" name="server" onClick="this.select();" value="'.($hostname ? $hostname : '').'">
<input type="submit">
</form>';
if ($hostname) {
echo '<h1>Status for ' . $hostname . '</h1>';
if ($status) {
echo '<table>
<tr><th>MOTD</th><th>Server version</th><th>Players</th><th>Ping</th></tr>';
echo '<tr><td class="motd">' . MC_parseMotdColors($status['motd']). '</td><td>' .
$status['server_version'] . '</td><td>' . $status['player_count'] .
'/' . $status['player_max'] . '</td><td>' . $status['latency'] . '</td></tr>';
echo '</table>';
} else {
echo '<p>Could not query server.</p>';
}
}
echo '</body>
</html>';
?>
mcformat.php
HTML formatter for Minecraft chat color escapes.
<?php
// Multibyte str_split()
function MC_str_split( $string )
{
return preg_split('/(?<!^)(?!$)/u', $string );
}
// http://www.wiki.vg/Chat
function MC_parseMotdColors($motd)
{
$inColorSequence = false;
$openSpan = false;
$coloredMotd = '';
foreach (MC_str_split($motd) as $character) {
if ($inColorSequence) {
// find color and insert span
switch ($character) {
case '0':
$color = '#000000';
break;
case '1':
$color = '#0000aa';
break;
case '2':
$color = '#00aa00';
break;
case '3':
$color = '#00aaaa';
break;
case '4':
$color = '#aa0000';
break;
case '5':
$color = '#aa00aa';
break;
case '6':
$color = '#ffaa00';
break;
case '7':
$color = '#aaaaaa';
break;
case '8':
$color = '#555555';
break;
case '9':
$color = '#5555ff';
break;
case 'a':
$color = '#55ff55';
break;
case 'b':
$color = '#55ffff';
break;
case 'c':
$color = '#ff5555';
break;
case 'd':
$color = '#ff55ff';
break;
case 'e':
$color = '#ffff55';
break;
case 'f':
case 'r':
$color = '#ffffff';
break;
default:
$color = false;
break;
}
if ($color) {
if ($openSpan) {
$coloredMotd .= '</span>';
}
$coloredMotd .= '<span style="color:' . $color . ';">';
$openSpan = true;
}
$inColorSequence = false;
} elseif ($character== '§') {
$inColorSequence = true;
} else {
$coloredMotd .= $character;
}
}
if ($openSpan) {
$coloredMotd .= '</span>';
}
return $coloredMotd;
}
?>
Information on Minecraft's Protocols
If you want to learn more about getting information from Minecraft, see this article on Server List Ping and this on the Query protocol.
If you're looking for a server to test my code with, try Uberminecraft.com.
FILTER
yourGETs
though using PHP'sfilter_input
function$foo = filter_input(INPUT_GET, 'foo', FILTER_SANITIZE_SPECIAL_CHARS);
\$\endgroup\$