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 const PREG_SPLIT_NO_EMPTY;
16: use function array_key_exists;
17: use function chr;
18: use function count;
19: use function date;
20: use function explode;
21: use function is_array;
22: use function is_finite;
23: use function is_numeric;
24: use function microtime;
25: use function mktime;
26: use function ord;
27: use function preg_match;
28: use function preg_split;
29: use function round;
30: use function strlen;
31: use function strpos;
32: use function substr;
33: use function unpack;
34: use Clansuite\Capture\Protocol\ProtocolInterface;
35: use Clansuite\Capture\ServerAddress;
36: use Clansuite\Capture\ServerInfo;
37: use Clansuite\ServerQuery\CSQuery;
38: use Override;
39:
40: /**
41: * Steam protocol implementation.
42: *
43: * @see https://developer.valvesoftware.com/wiki/Server_queries
44: */
45: class Steam extends CSQuery implements ProtocolInterface
46: {
47: /**
48: * Protocol name.
49: */
50: public string $name = 'Steam';
51:
52: /**
53: * List of supported games.
54: *
55: * @var array<string>
56: */
57: public array $supportedGames = [
58: 'Steam',
59: 'Counter-Strike: Global Offensive',
60: 'Counter-Strike: Source',
61: 'Team Fortress 2',
62: 'Left 4 Dead 2',
63: 'Garry\'s Mod',
64: 'Half-Life 2: Deathmatch',
65: 'Day of Defeat: Source',
66: 'Zombie Panic! Source',
67: 'Alien Swarm',
68: 'Black Mesa',
69: 'Blade Symphony',
70: 'Ballistic Overkill',
71: 'Battalion 1944',
72: 'Barotrauma',
73: 'Abiotic Factor',
74: 'Avorion',
75: 'Atlas',
76: 'ARMA 2',
77: 'Age of Chivalry',
78: 'America\'s Army 3',
79: 'America\'s Army: Proving Grounds',
80: 'Aliens vs. Predator 2010',
81: 'Base Defense',
82: 'Contagion',
83: 'Insurgency',
84: 'Insurgency: Sandstorm',
85: 'Homefront',
86: 'Hurtworld',
87: 'Killing Floor 2',
88: 'Natural Selection',
89: 'Monday Night Combat',
90: 'No More Room in Hell',
91: 'Nuclear Dawn',
92: 'Perfect Dark',
93: 'Portal 2',
94: 'Rust',
95: 'Serious Sam 3: BFE',
96: 'Space Engineers',
97: 'Squad',
98: 'The Ship',
99: 'Unturned',
100: 'Vampire: The Masquerade - Bloodlines',
101: 'Warframe',
102: 'Worms Armageddon',
103: 'ZPS',
104: ];
105:
106: /**
107: * Protocol identifier.
108: */
109: public string $protocol = 'A2S';
110: public string $playerFormat = '/sscore/x2/ftime';
111:
112: // Response time in milliseconds
113: public float $response = 0.0;
114:
115: /**
116: * Constructor.
117: */
118: public function __construct(?string $address = null, ?int $queryport = null)
119: {
120: parent::__construct($address, $queryport);
121: }
122:
123: /**
124: * query_server method.
125: */
126: #[Override]
127: public function query_server(bool $getPlayers = true, bool $getRules = true): bool
128: {
129: if ($this->online) {
130: $this->reset();
131: }
132:
133: $starttime = microtime(true);
134:
135: $address = (string) $this->address;
136: $port = (int) $this->queryport;
137:
138: $command = "\xFF\xFF\xFF\xFF\x54\x53\x6F\x75\x72\x63\x65\x20\x45\x6E\x67\x69\x6E\x65\x20\x51\x75\x65\x72\x79\x00";
139:
140: // Try up to three times to get a valid response. Preserve the first
141: // non-empty/non-false/non-'0' result so we don't accidentally
142: // overwrite a valid reply with a later '(no response)' capture.
143: $attempts = 0;
144: $result = false;
145:
146: while ($attempts < 3) {
147: $attempts++;
148: $tmp = $this->sendCommand($address, $port, $command);
149:
150: // If we got a non-empty and non-false and non-'0' response, keep it.
151: if ($tmp !== '' && $tmp !== false && $tmp !== '0') {
152: $result = $tmp;
153:
154: break;
155: }
156:
157: // Otherwise if we got something (like an informative string), keep it
158: // only if we don't already have a valid result. This mirrors the
159: // previous behaviour but avoids overwriting the first real reply.
160: if ($result === false) {
161: $result = $tmp;
162: }
163: }
164:
165: if ($result === '' || $result === '0' || $result === false) {
166: $this->errstr = 'No response from server';
167:
168: return false;
169: }
170:
171: $endtime = microtime(true);
172: $diff = round(($endtime - $starttime) * 1000, 0);
173: // response time
174: $this->response = round($diff, 2);
175:
176: $this->hostport = $this->queryport ?? 0;
177:
178: // Ensure rule keys exist to avoid undefined index notices when appending
179: $this->rules['gamedir'] ??= '';
180: $this->rules['IP'] = $this->rules['gamedir'];
181: $this->rules['gamename'] ??= '';
182: $this->rules['mod_url'] ??= '';
183:
184: $i = 4; // start after header
185: $len = strlen($result);
186:
187: if ($i >= $len) {
188: $this->errstr = 'Invalid response (too short)';
189:
190: return false;
191: }
192:
193: // A2S (Source) replies may start with 'I' (0x49) or 'A' (0x41) in practice.
194: $firstTypeChar = $result[$i++] ?? "\0";
195: $this->rules['Type'] = ($firstTypeChar === 'I' || $firstTypeChar === 'A') ? 'Source' : 'HL1';
196:
197: // If we received an S2C_CHALLENGE ('A') the server returned a 4-byte
198: // challenge. Clients should resend the original A2S_INFO request with
199: // that 4-byte challenge appended. Some servers reply with the challenge
200: // instead of the full info to mitigate reflection attacks.
201: if ($firstTypeChar === 'A') {
202: // Extract last 4 bytes as challenge (if present)
203: if ($len >= 9) {
204: $challenge = substr($result, -4);
205: } else {
206: $this->errstr = 'Invalid challenge response';
207:
208: return false;
209: }
210:
211: // Build original A2S_INFO command and append the challenge
212: $infoCommand = "\xFF\xFF\xFF\xFF\x54\x53\x6F\x75\x72\x63\x65\x20\x45\x6E\x67\x69\x6E\x65\x20\x51\x75\x65\x72\x79\x00" . $challenge;
213:
214: // Retry once to obtain the full info response
215: $retryResult = $this->sendCommand($address, $port, $infoCommand);
216:
217: if ($retryResult === '' || $retryResult === '0' || $retryResult === false) {
218: $this->errstr = 'Failed to retrieve info after challenge';
219:
220: return false;
221: }
222:
223: $result = $retryResult;
224: $len = strlen($result);
225: // reset index to parse the new full response
226: $i = 4;
227: $firstTypeChar = $result[$i++] ?? "\0";
228: $this->rules['Type'] = ($firstTypeChar === 'I' || $firstTypeChar === 'A') ? 'Source' : 'HL1';
229: }
230:
231: if ($this->rules['Type'] === 'Source') {
232: if ($i >= $len) {
233: $this->errstr = 'Invalid response when reading network version';
234:
235: return false;
236: }
237:
238: $this->rules['NetworkVersion'] = ord(substr($result, $i++, 1));
239:
240: while ($i < $len && (($result[$i] ?? "\0") !== chr(0))) {
241: $this->servertitle .= $result[$i++] ?? '';
242: }
243:
244: if ($i >= $len) {
245: $this->errstr = 'Invalid response while reading servertitle';
246:
247: return false;
248: }
249:
250: $i++;
251:
252: while ($i < $len && (($result[$i] ?? "\0") !== chr(0))) {
253: $this->mapname .= $result[$i++] ?? '';
254: }
255:
256: if ($i >= $len) {
257: $this->errstr = 'Invalid response while reading mapname';
258:
259: return false;
260: }
261:
262: $i++;
263:
264: while ($i < $len && (($result[$i] ?? "\0") !== chr(0))) {
265: $this->rules['gamedir'] = $this->rules['gamedir'] . $result[$i++];
266: }
267:
268: if ($i >= $len) {
269: $this->errstr = 'Invalid response while reading gamedir';
270:
271: return false;
272: }
273:
274: $i++;
275:
276: while ($i < $len && (($result[$i] ?? "\0") !== chr(0))) {
277: $this->gamename .= $result[$i++] ?? '';
278: }
279:
280: if ($i >= $len) {
281: $this->errstr = 'Invalid response while reading gamename';
282:
283: return false;
284: }
285:
286: $i++;
287:
288: if ($i + 1 >= $len) {
289: $this->errstr = 'Invalid response while reading appid';
290:
291: return false;
292: }
293:
294: $tmp = @unpack('n', substr($result, $i, 2));
295: $this->rules['appid'] = is_array($tmp) && isset($tmp[1]) ? $tmp[1] : 0;
296: $i = $i + 2;
297:
298: if ($i >= $len) {
299: $this->errstr = 'Invalid response while reading player counts';
300:
301: return false;
302: }
303:
304: $this->numplayers = ord(substr($result, $i++, 1));
305: $this->maxplayers = ord(substr($result, $i++, 1));
306: $this->rules['botplayers'] = ord(substr($result, $i++, 1));
307:
308: if ($i >= $len) {
309: $this->errstr = 'Invalid response while reading server flags';
310:
311: return false;
312: }
313:
314: $this->rules['dedicated'] = ($result[$i++] === 'd' ? 'Yes' : 'No');
315: $this->rules['server_os'] = ($result[$i++] === 'l' ? 'Linux' : 'Windows');
316:
317: if ($i >= $len) {
318: $this->errstr = 'Invalid response while reading password flag';
319:
320: return false;
321: }
322:
323: $this->password = ord(substr($result, $i++, 1));
324: $this->rules['secure'] = ($result[$i++] === '1' ? 'Yes' : 'No');
325:
326: while ($i < $len && (($result[$i] ?? "\0") !== chr(0))) {
327: $this->gameversion .= $result[$i++] ?? '';
328: }
329:
330: if ($i >= $len) {
331: $this->errstr = 'Invalid response while reading gameversion';
332:
333: return false;
334: }
335:
336: $i++;
337:
338: // Extra Data Flag (EDF) handling
339: if ($i < $len) {
340: $edf = ord(substr($result, $i, 1));
341: $i++;
342:
343: if (($edf & 0x80) !== 0) {
344: $this->rules['port'] = $this->readInt16Signed($result, $i);
345: }
346:
347: if (($edf & 0x10) !== 0) {
348: $this->rules['steam_id'] = $this->readInt64($result, $i);
349: }
350:
351: if (($edf & 0x40) !== 0) {
352: $this->rules['sourcetv_port'] = $this->readInt16Signed($result, $i);
353: $this->rules['sourcetv_name'] = $this->readString($result, $i);
354: }
355:
356: if (($edf & 0x20) !== 0) {
357: $this->rules['keywords'] = $this->readString($result, $i);
358: }
359:
360: if (($edf & 0x01) !== 0) {
361: $this->rules['game_id'] = $this->readInt64($result, $i);
362: }
363: }
364: } else { // For HL 1
365: while ($i < $len && $result[$i] !== chr(0)) {
366: $this->rules['IP'] = ($this->rules['IP'] ?? '') . $result[$i++];
367: }
368:
369: if ($i >= $len) {
370: $this->errstr = 'Invalid response while reading IP';
371:
372: return false;
373: }
374: $i++;
375:
376: while ($i < $len && $result[$i] !== chr(0)) {
377: $this->servertitle .= $result[$i++];
378: }
379:
380: if ($i >= $len) {
381: $this->errstr = 'Invalid response while reading servertitle (HL1)';
382:
383: return false;
384: }
385: $i++;
386:
387: while ($i < $len && $result[$i] !== chr(0)) {
388: $this->mapname .= $result[$i++];
389: }
390:
391: if ($i >= $len) {
392: $this->errstr = 'Invalid response while reading mapname (HL1)';
393:
394: return false;
395: }
396: $i++;
397:
398: while ($i < $len && $result[$i] !== chr(0)) {
399: $this->rules['gamedir'] = ($this->rules['gamedir'] ?? '') . $result[$i++];
400: }
401:
402: if ($i >= $len) {
403: $this->errstr = 'Invalid response while reading gamedir (HL1)';
404:
405: return false;
406: }
407: $i++;
408:
409: while ($i < $len && $result[$i] !== chr(0)) {
410: $this->rules['gamename'] = ($this->rules['gamename'] ?? '') . $result[$i++];
411: }
412:
413: if ($i >= $len) {
414: $this->errstr = 'Invalid response while reading gamename (HL1)';
415:
416: return false;
417: }
418: // while ($result[$i]!=chr(0)) $this->gamename.=$result[$i++];
419: $i++;
420:
421: if ($i >= $len) {
422: $this->errstr = 'Invalid response while reading player counts (HL1)';
423:
424: return false;
425: }
426:
427: $this->numplayers = ord(substr($result, $i++, 1));
428:
429: if ($i >= $len) {
430: $this->errstr = 'Invalid response while reading maxplayers (HL1)';
431:
432: return false;
433: }
434: $this->maxplayers = ord(substr($result, $i++, 1));
435:
436: if ($i >= $len) {
437: $this->errstr = 'Invalid response while reading gameversion (HL1)';
438:
439: return false;
440: }
441: $this->gameversion = (string) ord(substr($result, $i++, 1));
442:
443: if ($this->gameversion === '47') {
444: $this->gameversion .= ' (1.6)';
445: }
446:
447: if ($i >= $len) {
448: $this->errstr = 'Invalid response while reading server flags (HL1)';
449:
450: return false;
451: }
452: $this->rules['dedicated'] = ($result[$i++] === 'd' ? 'Yes' : 'No');
453:
454: if ($i >= $len) {
455: $this->errstr = 'Invalid response while reading server OS (HL1)';
456:
457: return false;
458: }
459: $this->rules['server_os'] = ($result[$i++] === 'l' ? 'Linux' : 'Windows');
460:
461: if ($i >= $len) {
462: $this->errstr = 'Invalid response while reading password (HL1)';
463:
464: return false;
465: }
466: $this->password = ord(substr($result, $i++, 1));
467:
468: if ($i >= $len) {
469: $this->errstr = 'Invalid response while reading secure flag (HL1)';
470:
471: return false;
472: }
473: $this->rules['secure'] = ($result[$i++] === '1' ? 'Yes' : 'No');
474:
475: while ($i < $len && $result[$i] !== chr(0)) {
476: $this->rules['mod_url'] = ($this->rules['mod_url'] ?? '') . $result[$i++];
477: }
478:
479: if ($i >= $len) {
480: $this->errstr = 'Invalid response while reading mod_url (HL1)';
481:
482: return false;
483: }
484: $i++;
485: }
486:
487: // do rules
488: // challange
489: $command = "\xFF\xFF\xFF\xFF\x57";
490:
491: if (($result = $this->sendCommand($address, $port, $command)) !== false) {
492: $challenge = substr($result, -4);
493: // query
494: $command = "\xFF\xFF\xFF\xFF\x56";
495:
496: if (($result = $this->sendCommand($address, $port, $command . $challenge)) !== false) {
497: // Process rules...
498: if ($this->rules['Type'] === 'HL1') {
499: // rules can be in multiple packets in 1.6, we have to sort it out
500: // First packet has a 16 byte header, subsequent packet has an 8 byte header.
501: $str = "/\xFE\xFF\xFF\xFF/"; // packet signature (both first and second start with this)
502:
503: $block = preg_split($str, $result, -1, PREG_SPLIT_NO_EMPTY);
504:
505: $str = "/\xFF\xFF\xFF\xFF/"; // first packet signature (only first packet matches this)
506:
507: if (isset($block[0]) && ($block[0] !== '' && $block[0] !== '0') && (isset($block[1]) && ($block[1] !== '' && $block[1] !== '0'))) {
508: if (preg_match($str, $block[0]) !== false) {
509: $result = substr($block[0], 12, strlen($block[0])) . substr($block[1], 5, strlen($block[1]));
510: } elseif (preg_match($str, $block[1]) !== false) {
511: $result = substr($block[1], 12, strlen($block[1])) . substr($block[0], 5, strlen($block[1])) . substr($block[0], 5, strlen($block[0]));
512: }
513: } elseif (isset($block[0]) && ($block[0] !== '' && $block[0] !== '0')) {
514: $result = substr($block[0], 5, strlen($block[0]));
515: }
516: $j = 0; // beginning value off for
517: } else {
518: $j = 1; // beginning value off for
519: }
520:
521: $exploded_data = explode(chr(0), $result);
522: $this->password = -1;
523: $z = count($exploded_data);
524:
525: $idx = $j;
526:
527: while ($idx < $z - 1) {
528: $key = $exploded_data[$idx++] ?? '';
529: $value = $exploded_data[$idx++] ?? '';
530:
531: switch ($key) {
532: case 'sv_password':
533: $this->password = (int) $value;
534:
535: break;
536:
537: case 'deathmatch':
538: if ($value === '1') {
539: $this->gametype = 'Deathmatch';
540: }
541:
542: break;
543:
544: case 'coop':
545: if ($value === '1') {
546: $this->gametype = 'Cooperative';
547: }
548:
549: break;
550:
551: default:
552: $this->rules[$key] = $value;
553: }
554: }
555: }
556: }
557:
558: if ($getPlayers) {
559: // challange
560: $command = "\xFF\xFF\xFF\xFF\x57";
561:
562: if (($result = $this->sendCommand($address, $port, $command)) !== false) {
563: $challenge = substr($result, -4);
564: // query
565: $command = "\xFF\xFF\xFF\xFF\x55";
566:
567: if (($result = $this->sendCommand($address, $port, $command . $challenge)) !== false) {
568: $this->processPlayers($result, $this->playerFormat, 8);
569:
570: $this->playerkeys['name'] = true;
571: $this->playerkeys['score'] = true;
572: $this->playerkeys['time'] = true;
573: }
574: }
575: }
576:
577: $this->online = true;
578:
579: return true;
580: }
581:
582: /**
583: * processPlayers method.
584: *
585: * @psalm-param 8 $formatLength
586: *
587: * @return null|false
588: */
589: public function processPlayers(string $data, string $format, int $formatLength): ?bool
590: {
591: $len = strlen($data);
592:
593: for ($i = 6; $i < $len; $i = $endPlayerName + $formatLength + 1) {
594: // finding end of player name
595: $endPlayerName = strpos($data, "\x00", ++$i);
596:
597: if ($endPlayerName === false) {
598: return false;
599: } // abort on bogus data
600:
601: // unpacking player's score and time
602: $unpacked = unpack('@' . ($endPlayerName + 1) . $format, $data);
603: $curPlayer = $unpacked !== false ? $unpacked : [];
604:
605: /** @var array<string, mixed> $curPlayer */
606:
607: // format time
608: if (array_key_exists('time', $curPlayer) && is_numeric($curPlayer['time']) && is_finite((float) $curPlayer['time']) && $curPlayer['time'] >= 0 && $curPlayer['time'] <= 86400) {
609: $timestamp = mktime(0, 0, (int) $curPlayer['time']);
610: $curPlayer['time'] = $timestamp !== false ? date('H:i:s', $timestamp) : '00:00:00';
611: } else {
612: $curPlayer['time'] = '00:00:00'; // default if invalid
613: }
614: // extract player name
615: $curPlayer['name'] = substr($data, $i, $endPlayerName - $i);
616: // add player to the list of players
617: $this->players[] = $curPlayer;
618: }
619:
620: return null;
621: }
622:
623: /**
624: * query method.
625: */
626: #[Override]
627: public function query(ServerAddress $addr): ServerInfo
628: {
629: $this->address = $addr->ip;
630: $this->queryport = $addr->port;
631: $this->query_server(true, true);
632:
633: return new ServerInfo(
634: address: $this->address,
635: queryport: $this->queryport,
636: online: $this->online,
637: gamename: $this->gamename,
638: gameversion: $this->gameversion,
639: servertitle: $this->servertitle,
640: mapname: $this->mapname,
641: gametype: $this->gametype,
642: numplayers: $this->numplayers,
643: maxplayers: $this->maxplayers,
644: rules: $this->rules,
645: players: $this->players,
646: errstr: $this->errstr,
647: );
648: }
649:
650: /**
651: * getProtocolName method.
652: */
653: #[Override]
654: public function getProtocolName(): string
655: {
656: return $this->protocol;
657: }
658:
659: /**
660: * getVersion method.
661: */
662: #[Override]
663: public function getVersion(ServerInfo $info): string
664: {
665: return $info->gameversion ?? 'unknown';
666: }
667:
668: /**
669: * @psalm-param int<21, max> $i
670: */
671: private function readInt16Signed(string $data, int &$i): int
672: {
673: $unpacked = unpack('s', substr($data, $i, 2));
674:
675: if ($unpacked === false) {
676: $val = [1 => 0];
677: } else {
678: $val = $unpacked;
679: }
680: $i += 2;
681:
682: /** @var array{1: int} $val */
683: return (int) $val[1];
684: }
685:
686: /**
687: * @psalm-param int<21, max> $i
688: */
689: private function readInt64(string $data, int &$i): int
690: {
691: $unpacked = unpack('q', substr($data, $i, 8));
692:
693: if ($unpacked === false) {
694: $val = [1 => 0];
695: } else {
696: $val = $unpacked;
697: }
698: $i += 8;
699:
700: /** @var array{1: int} $val */
701: return (int) $val[1];
702: }
703:
704: /**
705: * @psalm-param int<21, max> $i
706: */
707: private function readString(string $data, int &$i): string
708: {
709: $str = '';
710:
711: while ($i < strlen($data) && $data[$i] !== chr(0)) {
712: $str .= $data[$i++];
713: }
714: $i++; // skip null
715:
716: return $str;
717: }
718: }
719: