Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
41.81% covered (danger)
41.81%
120 / 287
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Steam
41.81% covered (danger)
41.81%
120 / 287
22.22% covered (danger)
22.22%
2 / 9
3253.84
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
44.26% covered (danger)
44.26%
104 / 235
0.00% covered (danger)
0.00%
0 / 1
2014.80
 processPlayers
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
110
 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
 readInt16Signed
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 readInt64
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 readString
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
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 const PREG_SPLIT_NO_EMPTY;
16use function array_key_exists;
17use function chr;
18use function count;
19use function date;
20use function explode;
21use function is_array;
22use function is_finite;
23use function is_numeric;
24use function microtime;
25use function mktime;
26use function ord;
27use function preg_match;
28use function preg_split;
29use function round;
30use function strlen;
31use function strpos;
32use function substr;
33use function unpack;
34use Clansuite\Capture\Protocol\ProtocolInterface;
35use Clansuite\Capture\ServerAddress;
36use Clansuite\Capture\ServerInfo;
37use Clansuite\ServerQuery\CSQuery;
38use Override;
39
40/**
41 * Steam protocol implementation.
42 *
43 * @see https://developer.valvesoftware.com/wiki/Server_queries
44 */
45class 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}