7
\$\begingroup\$

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.

\$\endgroup\$
3
  • 1
    \$\begingroup\$ Looks fairly concise, I would maybe FILTER your GETs though using PHP's filter_input function $foo = filter_input(INPUT_GET, 'foo', FILTER_SANITIZE_SPECIAL_CHARS); \$\endgroup\$
    – CodeX
    Jul 3, 2015 at 11:32
  • \$\begingroup\$ Hello. I'm using github.com/xPaw/PHP-Minecraft-Query solution I got some issue with response HostName: ?8?m-]?5?m--?2?l ?b?l+!6?c?l7/2,$ ?5?m--?8?m[-?c ?f?lKavBuy.Ru ?e?l1.8-1.10 ?b?l?e?lSurvival?b?l?b,?a?lMarry?b?l?b,?b?lSkyWars,?c?lClans?b?l?b,?f?lParkour How to convert it into human view? \$\endgroup\$
    – Adobe
    Nov 8, 2016 at 3:43
  • 1
    \$\begingroup\$ @Adobe you're better off asking at github.com/xPaw/PHP-Minecraft-Query/issues \$\endgroup\$
    – Winny
    Nov 9, 2016 at 4:29

1 Answer 1

3
\$\begingroup\$

Foreword

Bearing in mind that this post was created more than 4 years ago, you likely have changed the code and learned more about PHP features - perhaps you don't even use/maintain this code anymore. I honestly have never played Minecraft, nor had any reason to monitor server statuses. But I wanted to give some feedback to this question.

Feedback

The code looks pretty sophisticated. I honestly haven't used pack() before. That is neat that the class MinecraftStatus can function as a stand-alone CLI tool.

Suggestions

Utilize member variable instead of passing

The methods MinecraftStatus::ping() and MinecraftStatus::query() make calls to private methods like serverListPing(), basicQuery() and fullQuery(), which all appear to be private methods. Instead of passing the hostname and portnumber to those methods, the methods can utilize the member variables using $this->. That way, the member variables don't need to be passed around.

Mapping Character to colors

The switch statement in MC_parseMotdColors() could be replaced by a lookup in an array.

define('COLOR_MAPPING', [
    '0' => '000000',
    '1' => '0000aa',
    //2-9, a-e...
    'f' => 'ffffff',
    'r' => 'ffffff'
]);

Then lookup the value of $character using array_key_exists():

$color = false; //default value
if (array_key_exists($character, COLOR_MAPPING)) {
    $color = '#' . COLOR_MAPPING[$character];
}

The same is true for the switch statement within MinecraftStatus::fullQuery() - though instead of using define(), a class constant could be created using const:

const RESPONSE_ITEM_KEY_MAPPING = [
    'numplayers' => 'player_count',
    'maxplayers' => 'player_max',
    'hostname' => 'motd',
    'hostip' => 'ip',
    'hostport' => 'port',
];

and then that constant can be accessed using self::RESPONSE_ITEM_KEY_MAPPING.

$key = $item; //default value
if (array_key_exists($item, self::RESPONSE_ITEM_KEY_MAPPING)) {
    $key = self::RESPONSE_ITEM_KEY_MAPPING[$item];
}

Alternatives to fwrite(), fread(), fclose()

I am not sure if it would work but you could consider using cURL wrapper functions or file_get_contents - that way the parsing of individual characters could possibly be eliminated.

Reading characters in getStrings()

I haven't tested it but in theory the (outer) while loop could be simplified to a for loop:

for ($nulsProcessed = 0; $nulsProcessed < $count; $nulsProcessed++) {
    while ($c != chr(0)) {
        $s .= $c;
        $c = fread($fp, 1);
    }
    $strings[] = $s;
    unset($c);
    unset($s);
})

And also the nested while could also be rewritten as a for loop:

while($c = fread($fp, 1); $c != char(0); $c = fread($fp, 1)) {
    $s .= $c;
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.