Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.50% covered (danger)
0.50%
1 / 201
5.88% covered (danger)
5.88%
1 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Minecraft
0.50% covered (danger)
0.50%
1 / 201
5.88% covered (danger)
5.88%
1 / 17
4357.31
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 query_server
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 query
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
2
 getProtocolName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getVersion
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 queryLegacy
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 querySLP
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
30
 buildHandshakePacket
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 buildStatusRequestPacket
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 readPacket
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
42
 parseSLPResponse
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
72
 parseLegacyResponse
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
156
 writeVarInt
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 readVarInt
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 readVarIntFromString
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 writeString
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseFormattedText
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
42
1<?php declare(strict_types=1);
2
3/**
4 * Clansuite Server Query
5 *
6 * SPDX-FileCopyrightText: 2003-2025 Jens A. Koch
7 * SPDX-License-Identifier: MIT
8 *
9 * For the full copyright and license information, please view
10 * the LICENSE file that was distributed with this source code.
11 */
12
13namespace Clansuite\ServerQuery\ServerProtocols;
14
15use function array_filter;
16use function chr;
17use function count;
18use function explode;
19use function fclose;
20use function fread;
21use function fsockopen;
22use function fwrite;
23use function is_array;
24use function is_string;
25use function json_decode;
26use function ord;
27use function pack;
28use function random_int;
29use function stream_set_timeout;
30use function strlen;
31use function substr;
32use Clansuite\Capture\Protocol\ProtocolInterface;
33use Clansuite\Capture\ServerAddress;
34use Clansuite\Capture\ServerInfo;
35use Clansuite\ServerQuery\CSQuery;
36use Override;
37
38/**
39 * Minecraft server protocol implementation.
40 *
41 * The SLP protocol does not provide server variables or rules.
42 * These are only available through the Legacy Query protocol,
43 * which requires the server administrator to explicitly enable
44 * query in server.properties with enable-query=true.
45 *
46 * Protocols:
47 * - SLP (Serer List Ping) provides only basic infos.
48 * - Legacy Query: More detailed server variables.
49 *
50 * @see https://minecraft.wiki/w/Query
51 */
52class Minecraft extends CSQuery implements ProtocolInterface
53{
54    /**
55     * Protocol name.
56     */
57    public string $name = 'Minecraft';
58
59    /**
60     * List of supported games.
61     */
62    public array $supportedGames = ['Minecraft'];
63
64    /**
65     * Protocol identifier.
66     */
67    public string $protocol = 'minecraft';
68
69    /**
70     * Constructor.
71     *
72     * @param $address         Server address
73     * @param $queryport       Query port
74     * @param $protocolVersion Protocol version to use: 'slp' (Server List Ping) or 'legacy' (Legacy Query)
75     */
76    public function __construct(?string $address = null, ?int $queryport = null, public string $protocolVersion = 'slp')
77    {
78        parent::__construct($address, $queryport);
79    }
80
81    /**
82     * Query server information.
83     */
84    #[Override]
85    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
86    {
87        if ($this->online) {
88            $this->reset();
89        }
90
91        if ($this->protocolVersion === 'legacy') {
92            return $this->queryLegacy();
93        }
94
95        return $this->querySLP();
96    }
97
98    /**
99     * query method.
100     */
101    #[Override]
102    public function query(ServerAddress $addr): ServerInfo
103    {
104        $this->address   = $addr->ip;
105        $this->queryport = $addr->port;
106        $this->query_server(true, true);
107
108        return new ServerInfo(
109            address: $this->address,
110            queryport: $this->queryport,
111            online: $this->online,
112            gamename: $this->gamename,
113            gameversion: $this->gameversion,
114            servertitle: $this->servertitle,
115            mapname: $this->mapname,
116            gametype: $this->gametype,
117            numplayers: $this->numplayers,
118            maxplayers: $this->maxplayers,
119            rules: $this->rules,
120            players: $this->players,
121            errstr: $this->errstr,
122        );
123    }
124
125    /**
126     * getProtocolName method.
127     */
128    #[Override]
129    public function getProtocolName(): string
130    {
131        return $this->protocol;
132    }
133
134    /**
135     * getVersion method.
136     */
137    #[Override]
138    public function getVersion(ServerInfo $info): string
139    {
140        return $info->gameversion ?? 'unknown';
141    }
142
143    /**
144     * Query using Legacy Query protocol (UDP).
145     */
146    private function queryLegacy(): bool
147    {
148        $address = (string) $this->address;
149        $port    = (int) $this->queryport;
150
151        // Generate session ID
152        $sessionId = random_int(1, 0x7FFFFFFF);
153        // Send handshake
154        $handshake = pack('c3N', 0xFE, 0xFD, 0x09, $sessionId);
155        $response  = $this->sendCommand($address, $port, $handshake);
156
157        if ($response === false || strlen($response) < 5) {
158            $this->errstr = 'No handshake response from Minecraft server';
159
160            return false;
161        }
162
163        // Parse challenge token
164        if ($response[0] !== chr(0x09)) {
165            $this->errstr = 'Invalid handshake response';
166
167            return false;
168        }
169        $challengeToken = (int) substr($response, 1, -1);
170        // Send full query
171        $fullQuery = pack('c3N', 0xFE, 0xFD, 0x00, $sessionId) .
172                     pack('N', $challengeToken) .
173                     pack('c2', 0x00, 0x00);
174        $response = $this->sendCommand($address, $port, $fullQuery);
175
176        if ($response === false || strlen($response) < 5) {
177            $this->errstr = 'No query response from Minecraft server';
178
179            return false;
180        }
181
182        // Parse response
183        if ($response[0] !== chr(0x00)) {
184            $this->errstr = 'Invalid query response';
185
186            return false;
187        }
188        $this->parseLegacyResponse(substr($response, 1));
189        $this->online = true;
190
191        return true;
192    }
193
194    /**
195     * Query using Server List Ping (TCP JSON).
196     */
197    private function querySLP(): bool
198    {
199        $host = (string) $this->address;
200        $port = (int) $this->queryport;
201
202        $errno  = 0;
203        $errstr = '';
204
205        $fp = @fsockopen($host, $port, $errno, $errstr, 5);
206
207        if ($fp === false) {
208            $this->errstr = 'Unable to connect to Minecraft server';
209
210            return false;
211        }
212        stream_set_timeout($fp, 5);
213        // Send handshake
214        $handshake = $this->buildHandshakePacket();
215        fwrite($fp, $handshake);
216        // Send status request
217        $statusRequest = $this->buildStatusRequestPacket();
218        fwrite($fp, $statusRequest);
219        // Read response
220        $response = $this->readPacket($fp);
221
222        if ($response === false) {
223            fclose($fp);
224            $this->errstr = 'No response from Minecraft server';
225
226            return false;
227        }
228        $ptr = 1;
229        // Skip ID
230        $jsonLength = $this->readVarIntFromString($response, $ptr);
231        $json       = substr($response, $ptr, $jsonLength);
232        $data       = json_decode($json, true);
233
234        if ($data === null) {
235            fclose($fp);
236            $this->errstr = 'Invalid JSON response';
237
238            return false;
239        }
240
241        if (!is_array($data)) {
242            fclose($fp);
243            $this->errstr = 'Invalid JSON response structure';
244
245            return false;
246        }
247
248        $this->parseSLPResponse($data);
249        $this->online = true;
250        fclose($fp);
251
252        return true;
253    }
254
255    private function buildHandshakePacket(): string
256    {
257        $handshakeData = pack('c', 0) . // Packet ID
258                         $this->writeVarInt(0) . // Protocol version 0
259                         $this->writeString((string) $this->address) . // Server address
260                         pack('n', (int) $this->queryport) . // Server port
261                         pack('c', 1); // Next state (status)
262
263        return $this->writeVarInt(strlen($handshakeData)) . $handshakeData;
264    }
265
266    private function buildStatusRequestPacket(): string
267    {
268        $data = pack('c', 0); // Packet ID
269
270        return $this->writeVarInt(strlen($data)) . $data;
271    }
272
273    /**
274     * @param resource $fp
275     */
276    private function readPacket(mixed $fp): false|string
277    {
278        $length = $this->readVarInt($fp);
279
280        if ($length === false || $length <= 0) {
281            return false;
282        }
283
284        $data = '';
285
286        while (strlen($data) < $length) {
287            /** @phpstan-ignore argument.type */
288            $chunk = fread($fp, $length - strlen($data));
289
290            if ($chunk === false || $chunk === '') {
291                return false;
292            }
293            $data .= $chunk;
294        }
295
296        return $data;
297    }
298
299    /**
300     * @param array<mixed> $data
301     */
302    private function parseSLPResponse(array $data): void
303    {
304        $description = $data['description'] ?? '';
305
306        if (is_array($description)) {
307            // Handle formatted text objects
308            $this->servertitle = $this->parseFormattedText($description);
309        } else {
310            $this->servertitle = (string) $description;
311        }
312
313        $playersData = $data['players'] ?? [];
314
315        if (is_array($playersData)) {
316            $this->numplayers = (int) ($playersData['online'] ?? 0);
317            $this->maxplayers = (int) ($playersData['max'] ?? 0);
318
319            if (isset($playersData['sample']) && is_array($playersData['sample'])) {
320                $this->players = [];
321
322                foreach ($playersData['sample'] as $player) {
323                    if (is_array($player)) {
324                        $this->players[] = [
325                            'name' => (string) ($player['name'] ?? ''),
326                            'id'   => (string) ($player['id'] ?? ''),
327                        ];
328                    }
329                }
330            }
331        }
332
333        $versionData = $data['version'] ?? [];
334
335        if (is_array($versionData)) {
336            $this->gameversion = (string) ($versionData['name'] ?? '');
337        }
338    }
339
340    private function parseLegacyResponse(string $data): void
341    {
342        // Split by null bytes
343        $parts = explode("\0", $data);
344        $parts = array_filter($parts, static fn (string $v): bool => $v !== ''); // Remove empty parts
345
346        $this->rules = [];
347
348        for ($i = 0; $i < count($parts); $i += 2) {
349            $key   = $parts[$i] ?? '';
350            $value = $parts[$i + 1] ?? '';
351
352            // Map to standard fields
353            switch ($key) {
354                case 'hostname':
355                    $this->servertitle = $value;
356
357                    break;
358
359                case 'gametype':
360                    $this->gametype = $value;
361
362                    break;
363
364                case 'game_id':
365                    $this->gamename = $value;
366
367                    break;
368
369                case 'version':
370                    $this->gameversion = $value;
371
372                    break;
373
374                case 'plugins':
375                    // Parse plugins if present
376                    $this->rules['plugins'] = $value;
377
378                    break;
379
380                case 'map':
381                    $this->mapname = $value;
382
383                    break;
384
385                case 'numplayers':
386                    $this->numplayers = (int) $value;
387
388                    break;
389
390                case 'maxplayers':
391                    $this->maxplayers = (int) $value;
392
393                    break;
394
395                case 'hostport':
396                    $this->hostport = (int) $value;
397
398                    break;
399
400                default:
401                    $this->rules[$key] = $value;
402
403                    break;
404            }
405        }
406
407        // Players are not provided in legacy query basic response
408        $this->players = [];
409    }
410
411    private function writeVarInt(int $value): string
412    {
413        $result = '';
414
415        do {
416            $byte = $value & 0x7F;
417            $value >>= 7;
418
419            if ($value !== 0) {
420                $byte |= 0x80;
421            }
422            $result .= chr($byte);
423        } while ($value !== 0);
424
425        return $result;
426    }
427
428    /**
429     * @param resource $fp
430     */
431    private function readVarInt(mixed $fp): false|int
432    {
433        $value = 0;
434        $shift = 0;
435
436        while (true) {
437            $byte = fread($fp, 1);
438
439            if ($byte === false || $byte === '') {
440                return false;
441            }
442
443            $byte = ord($byte);
444            $value |= ($byte & 0x7F) << $shift;
445            $shift += 7;
446
447            if ((($byte & 0x80) === 0)) {
448                break;
449            }
450
451            if ($shift >= 35) {
452                return false; // VarInt too big
453            }
454        }
455
456        return $value;
457    }
458
459    private function readVarIntFromString(string $buffer, int &$ptr): int
460    {
461        $value = 0;
462        $shift = 0;
463
464        while (true) {
465            $byte = ord($buffer[$ptr++]);
466            $value |= ($byte & 0x7F) << $shift;
467            $shift += 7;
468
469            if ((($byte & 0x80) === 0)) {
470                break;
471            }
472
473            if ($shift >= 35) {
474                return 0; // Error
475            }
476        }
477
478        return $value;
479    }
480
481    private function writeString(string $string): string
482    {
483        return $this->writeVarInt(strlen($string)) . $string;
484    }
485
486    /**
487     * @param array<mixed> $textObject
488     */
489    private function parseFormattedText(array $textObject): string
490    {
491        $text = (string) ($textObject['text'] ?? '');
492
493        $extra = $textObject['extra'] ?? null;
494
495        if (is_array($extra)) {
496            foreach ($extra as $item) {
497                if (is_array($item) && isset($item['text'])) {
498                    $text .= (string) ($item['text'] ?? '');
499                } elseif (is_string($item)) {
500                    $text .= $item;
501                }
502            }
503        }
504
505        return $text;
506    }
507}