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 is_array;
16: use function is_float;
17: use function is_int;
18: use function is_string;
19: use function pack;
20: use function strlen;
21: use function substr;
22: use function unpack;
23: use Clansuite\ServerQuery\CSQuery;
24: use Override;
25:
26: /**
27: * SQP (Server Query Protocol) implementation.
28: *
29: * This is based on Unity Technologies' SQP protocol.
30: * Used for querying Unity-based game servers, like TF2E, Unturned, etc.
31: *
32: * @see https://docs.unity.com/ugs/en-us/manual/game-server-hosting/manual/concepts/sqp
33: */
34: class SQP extends CSQuery
35: {
36: /**
37: * SQP constants.
38: */
39: public const QueryRequestType = 0x00;
40:
41: public const QueryResponseType = 0x00;
42: public const ChallengeRequestType = 0x01;
43: public const ChallengeResponseType = 0x01;
44: public const Version = 1;
45: public const DefaultMaxPacketSize = 1400;
46:
47: // Chunk types
48: public const ServerInfo = 0x01;
49: public const ServerRules = 0x02;
50: public const PlayerInfo = 0x04;
51: public const TeamInfo = 0x08;
52: public const Metrics = 0x10;
53:
54: /**
55: * Protocol name.
56: */
57: public string $name = 'SQP';
58:
59: /**
60: * Protocol identifier.
61: */
62: public string $protocol = 'SQP';
63:
64: /**
65: * List of supported games.
66: *
67: * @var array<string>
68: */
69: public array $supportedGames = ['Unity'];
70:
71: /**
72: * Challenge ID.
73: */
74: private int $challengeID = 0;
75:
76: /**
77: * Constructor.
78: */
79: public function __construct(string $address, int $queryport)
80: {
81: parent::__construct();
82: $this->address = $address;
83: $this->queryport = $queryport;
84: }
85:
86: /**
87: * query_server method.
88: */
89: #[Override]
90: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
91: {
92: if ($this->online) {
93: $this->reset();
94: }
95:
96: $address = (string) $this->address;
97: $port = (int) $this->queryport;
98:
99: // Get challenge
100: if (!$this->getChallenge()) {
101: return false;
102: }
103:
104: // Build requested chunks
105: $requestedChunks = self::ServerInfo;
106:
107: if ($getRules) {
108: $requestedChunks |= self::ServerRules;
109: }
110:
111: if ($getPlayers) {
112: $requestedChunks |= self::PlayerInfo;
113: }
114:
115: // Send query
116: $queryPacket = $this->buildQueryPacket($requestedChunks);
117:
118: if (($result = $this->sendCommand($address, $port, $queryPacket)) === '' || ($result = $this->sendCommand($address, $port, $queryPacket)) === '0' || ($result = $this->sendCommand($address, $port, $queryPacket)) === false) {
119: return false;
120: }
121:
122: // Parse response
123: return $this->parseResponse($result, $requestedChunks);
124: }
125:
126: private function getChallenge(): bool
127: {
128: $address = (string) $this->address;
129: $port = (int) $this->queryport;
130:
131: $challengePacket = pack('C', self::ChallengeRequestType);
132:
133: if (($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === '' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === '0' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $challengePacket)) === false) {
134: return false;
135: }
136:
137: if (strlen($result) < 5) {
138: $this->errstr = 'Invalid challenge response';
139:
140: return false;
141: }
142:
143: $data = @unpack('Ctype/Nchallenge', $result);
144:
145: if (!is_array($data) || !isset($data['type']) || !isset($data['challenge']) || !is_int($data['type']) || !is_int($data['challenge'])) {
146: $this->errstr = 'Invalid challenge response';
147:
148: return false;
149: }
150:
151: if ($data['type'] !== self::ChallengeResponseType) {
152: $this->errstr = 'Invalid challenge response type';
153:
154: return false;
155: }
156:
157: $this->challengeID = $data['challenge'];
158:
159: return true;
160: }
161:
162: private function buildQueryPacket(int $requestedChunks): string
163: {
164: return pack(
165: 'CNNC',
166: self::QueryRequestType,
167: $this->challengeID,
168: self::Version,
169: $requestedChunks,
170: );
171: }
172:
173: private function parseResponse(string $data, int $requestedChunks): bool
174: {
175: $pos = 0;
176: $len = strlen($data);
177: $tmp = null;
178:
179: // Read header
180: if (1 > $len) {
181: return false;
182: }
183: $tmp = @unpack('C', substr($data, $pos, 1));
184:
185: if (!is_array($tmp) || !isset($tmp[1])) {
186: return false;
187: }
188: $pktType = $tmp[1];
189: $pos++;
190:
191: if ($pktType !== self::QueryResponseType) {
192: $this->errstr = 'Invalid response type';
193:
194: return false;
195: }
196:
197: // Validate challenge
198: if ($pos + 4 > $len) {
199: return false;
200: }
201: $tmp = @unpack('N', substr($data, $pos, 4));
202:
203: if (!is_array($tmp) || !isset($tmp[1])) {
204: return false;
205: }
206: $challenge = $tmp[1];
207: $pos += 4;
208:
209: if ($challenge !== $this->challengeID) {
210: $this->errstr = 'Challenge mismatch';
211:
212: return false;
213: }
214:
215: // Version
216: if ($pos + 2 > $len) {
217: return false;
218: }
219: $tmp = @unpack('n', substr($data, $pos, 2));
220:
221: if (!is_array($tmp) || !isset($tmp[1])) {
222: return false;
223: }
224: unset($tmp); // Currently unused
225: $pos += 2;
226:
227: // Packet info (assuming single packet for simplicity)
228: if ($pos + 4 > $len) {
229: return false;
230: }
231: $tmp = @unpack('C', substr($data, $pos, 1));
232:
233: if (!is_array($tmp) || !isset($tmp[1])) {
234: return false;
235: }
236: $curPkt = $tmp[1];
237: $pos++;
238: $tmp = @unpack('C', substr($data, $pos, 1));
239:
240: if (!is_array($tmp) || !isset($tmp[1])) {
241: return false;
242: }
243: $lastPkt = $tmp[1];
244: $pos++;
245: $tmp = @unpack('n', substr($data, $pos, 2));
246:
247: if (!is_array($tmp) || !isset($tmp[1])) {
248: return false;
249: }
250: $pktLen = $tmp[1];
251: $pos += 2;
252:
253: if ($curPkt > $lastPkt) {
254: $this->errstr = 'Invalid packet sequence';
255:
256: return false;
257: }
258:
259: // Parse chunks
260: $remaining = $pktLen;
261:
262: if (($requestedChunks & self::ServerInfo) !== 0) {
263: $bytesRead = $this->parseServerInfo($data, $pos);
264:
265: if ($bytesRead === false) {
266: return false;
267: }
268: $pos += $bytesRead;
269: $remaining -= $bytesRead;
270: }
271:
272: if (($requestedChunks & self::ServerRules) !== 0) {
273: $bytesRead = $this->parseServerRules($data, $pos);
274:
275: if ($bytesRead === false) {
276: return false;
277: }
278: $pos += $bytesRead;
279: $remaining -= $bytesRead;
280: }
281:
282: if (($requestedChunks & self::PlayerInfo) !== 0) {
283: $bytesRead = $this->parsePlayerInfo($data, $pos);
284:
285: if ($bytesRead === false) {
286: return false;
287: }
288: $pos += $bytesRead;
289: $remaining -= $bytesRead;
290: }
291:
292: // Skip remaining bytes
293: $this->online = true;
294:
295: return true;
296: }
297:
298: private function parseServerInfo(string $data, int $pos): false|int
299: {
300: $startPos = $pos;
301: $tmp = null;
302:
303: if ($pos + 4 > strlen($data)) {
304: return false;
305: }
306: $tmp = @unpack('N', substr($data, $pos, 4));
307:
308: if (!is_array($tmp) || !isset($tmp[1])) {
309: return false;
310: }
311: $this->challengeID = $tmp[1];
312: $pos += 4;
313:
314: if ($pos + 2 > strlen($data)) {
315: return false;
316: }
317: $tmp = @unpack('n', substr($data, $pos, 2));
318:
319: if (!is_array($tmp) || !isset($tmp[1])) {
320: return false;
321: }
322: $this->numplayers = $tmp[1];
323: $pos += 2;
324:
325: if ($pos + 2 > strlen($data)) {
326: return false;
327: }
328: $tmp = @unpack('n', substr($data, $pos, 2));
329:
330: if (!is_array($tmp) || !isset($tmp[1])) {
331: return false;
332: }
333: $this->maxplayers = $tmp[1];
334: $pos += 2;
335:
336: $this->servertitle = $this->readString($data, $pos);
337: $this->gametype = $this->readString($data, $pos);
338: $this->readString($data, $pos);
339: $this->mapname = $this->readString($data, $pos);
340:
341: if ($pos + 2 > strlen($data)) {
342: return false;
343: }
344: $tmp = @unpack('n', substr($data, $pos, 2));
345:
346: if (!is_array($tmp) || !isset($tmp[1])) {
347: return false;
348: }
349: $this->hostport = $tmp[1];
350: $pos += 2;
351:
352: return $pos - $startPos;
353: }
354:
355: private function parseServerRules(string $data, int &$pos): false|int
356: {
357: $startPos = $pos;
358: $tmp = null;
359:
360: if ($pos + 4 > strlen($data)) {
361: return false;
362: }
363: $tmp = @unpack('N', substr($data, $pos, 4));
364:
365: if (!is_array($tmp) || !isset($tmp[1])) {
366: return false;
367: }
368: $chunkLen = $tmp[1];
369: $pos += 4;
370:
371: $endPos = $startPos + 4 + $chunkLen;
372:
373: while ($pos < $endPos) {
374: $key = $this->readString($data, $pos);
375: $value = $this->readString($data, $pos);
376: $this->rules[$key] = $value;
377: }
378:
379: return $pos - $startPos;
380: }
381:
382: private function parsePlayerInfo(string $data, int &$pos): false|int
383: {
384: $startPos = $pos;
385: $tmp = null;
386:
387: if ($pos + 4 > strlen($data)) {
388: return false;
389: }
390: $tmp = @unpack('N', substr($data, $pos, 4));
391:
392: if (!is_array($tmp) || !isset($tmp[1])) {
393: return false;
394: }
395: $chunkLen = $tmp[1];
396: $pos += 4;
397:
398: if ($pos + 2 > strlen($data)) {
399: return false;
400: }
401: $tmp = @unpack('n', substr($data, $pos, 2));
402:
403: if (!is_array($tmp) || !isset($tmp[1])) {
404: return false;
405: }
406: $playerCount = $tmp[1];
407: $pos += 2;
408:
409: if ($playerCount === 0) {
410: return $pos - $startPos + $chunkLen - 6; // Skip chunk
411: }
412:
413: // Read header
414: $header = $this->readInfoHeader($data, $pos);
415:
416: // Read players
417: for ($i = 0; $i < $playerCount; $i++) {
418: $player = [];
419:
420: foreach ($header as $field) {
421: $type = $field['type'] ?? 0;
422: $name = $field['name'] ?? '';
423:
424: if (is_int($type) && is_string($name)) {
425: $value = $this->readDynamicValue($data, $pos, $type);
426: $player[$name] = $value;
427: }
428: }
429: $this->players[] = $player;
430: }
431:
432: return $pos - $startPos;
433: }
434:
435: /**
436: * @return array<array<string, mixed>>
437: */
438: private function readInfoHeader(string $data, int &$pos): array
439: {
440: $tmp = null;
441: $tmp = @unpack('C', substr($data, $pos, 1));
442: $pos++;
443: $fieldCount = 0;
444:
445: if (is_array($tmp) && isset($tmp[1])) {
446: $fieldCount = $tmp[1];
447: }
448: $header = [];
449:
450: for ($i = 0; $i < $fieldCount; $i++) {
451: $name = $this->readString($data, $pos);
452: $tmp = @unpack('C', substr($data, $pos, 1));
453: $pos++;
454: $type = 0;
455:
456: if (is_array($tmp) && isset($tmp[1])) {
457: $type = $tmp[1];
458: }
459: $header[] = ['name' => $name, 'type' => $type];
460: }
461:
462: return $header;
463: }
464:
465: private function readDynamicValue(string $data, int &$pos, int $type): null|float|int|string
466: {
467: $tmp = null;
468:
469: switch ($type) {
470: case 0: // String
471: return $this->readString($data, $pos);
472:
473: case 1: // Uint8
474: $tmp = @unpack('C', $data[$pos] ?? "\x00");
475: $pos++;
476: $val = 0;
477:
478: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
479: $val = $tmp[1];
480: }
481:
482: return $val;
483:
484: case 2: // Uint16
485: $tmp = @unpack('n', substr($data, $pos, 2));
486: $pos += 2;
487: $val = 0;
488:
489: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
490: $val = $tmp[1];
491: }
492:
493: return $val;
494:
495: case 3: // Uint32
496: $tmp = @unpack('N', substr($data, $pos, 4));
497: $pos += 4;
498: $val = 0;
499:
500: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
501: $val = $tmp[1];
502: }
503:
504: return $val;
505:
506: case 4: // Uint64
507: $tmp = @unpack('J', substr($data, $pos, 8));
508: $pos += 8;
509: $val = 0;
510:
511: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
512: $val = $tmp[1];
513: }
514:
515: return $val;
516:
517: case 5: // Int8
518: $tmp = @unpack('c', $data[$pos] ?? "\x00");
519: $pos++;
520: $val = 0;
521:
522: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
523: $val = $tmp[1];
524: }
525:
526: return $val;
527:
528: case 6: // Int16
529: $tmp = @unpack('s', substr($data, $pos, 2));
530: $pos += 2;
531: $val = 0;
532:
533: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
534: $val = $tmp[1];
535: }
536:
537: return $val;
538:
539: case 7: // Int32
540: $tmp = @unpack('l', substr($data, $pos, 4));
541: $pos += 4;
542: $val = 0;
543:
544: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
545: $val = $tmp[1];
546: }
547:
548: return $val;
549:
550: case 8: // Int64
551: $tmp = @unpack('q', substr($data, $pos, 8));
552: $pos += 8;
553: $val = 0;
554:
555: if (is_array($tmp) && isset($tmp[1]) && is_int($tmp[1])) {
556: $val = $tmp[1];
557: }
558:
559: return $val;
560:
561: case 9: // Float32
562: $tmp = @unpack('f', substr($data, $pos, 4));
563: $pos += 4;
564: $val = 0.0;
565:
566: if (is_array($tmp) && isset($tmp[1]) && is_float($tmp[1])) {
567: $val = $tmp[1];
568: }
569:
570: return $val;
571:
572: case 10: // Float64
573: $tmp = @unpack('d', substr($data, $pos, 8));
574: $pos += 8;
575: $val = 0.0;
576:
577: if (is_array($tmp) && isset($tmp[1]) && is_float($tmp[1])) {
578: $val = $tmp[1];
579: }
580:
581: return $val;
582:
583: default:
584: return null;
585: }
586: }
587:
588: private function readString(string $data, int &$pos): string
589: {
590: $start = $pos;
591:
592: while ($pos < strlen($data) && $data[$pos] !== "\x00") {
593: $pos++;
594: }
595: $str = substr($data, $start, $pos - $start);
596: $pos++; // Skip null terminator
597:
598: return $str;
599: }
600: }
601: