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:
13: namespace Clansuite\ServerQuery\ServerProtocols;
14:
15: use function array_filter;
16: use function chr;
17: use function count;
18: use function explode;
19: use function fclose;
20: use function fread;
21: use function fsockopen;
22: use function fwrite;
23: use function is_array;
24: use function is_string;
25: use function json_decode;
26: use function ord;
27: use function pack;
28: use function random_int;
29: use function stream_set_timeout;
30: use function strlen;
31: use function substr;
32: use Clansuite\Capture\Protocol\ProtocolInterface;
33: use Clansuite\Capture\ServerAddress;
34: use Clansuite\Capture\ServerInfo;
35: use Clansuite\ServerQuery\CSQuery;
36: use 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: */
52: class 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: }
508: