Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Halflife
0.00% covered (danger)
0.00%
0 / 187
0.00% covered (danger)
0.00%
0 / 6
6006
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 rcon_query_server
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
72
 query_server
0.00% covered (danger)
0.00%
0 / 139
0.00% covered (danger)
0.00%
0 / 1
2756
 processPlayers
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
56
 get_string
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_long
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
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_key_exists;
16use function array_map;
17use function array_values;
18use function assert;
19use function chr;
20use function count;
21use function date;
22use function in_array;
23use function is_array;
24use function is_int;
25use function is_numeric;
26use function microtime;
27use function mktime;
28use function preg_match;
29use function round;
30use function strlen;
31use function strpos;
32use function substr;
33use function unpack;
34use Clansuite\ServerQuery\CSQuery;
35use Override;
36
37/**
38 * Queries a halflife server.
39 *
40 * This class works with Halflife only.
41 */
42class Halflife extends CSQuery
43{
44    /**
45     * Protocol name.
46     */
47    public string $name = 'Half-Life';
48
49    /**
50     * Protocol identifier.
51     */
52    public string $protocol = 'Halflife';
53
54    /**
55     * Game series.
56     */
57    public array $game_series_list = ['Half-Life'];
58
59    /**
60     * List of supported games.
61     *
62     * @var array<string>
63     */
64    public array $supportedGames = ['Half-Life'];
65    public string $playerFormat  = '/sscore/x2/ftime';
66
67    /**
68     * Initializes the Halflife protocol instance with server address and query port.
69     *
70     * @param string $address   Server IP address or hostname
71     * @param int    $queryport Query port number
72     */
73    public function __construct(string $address, int $queryport)
74    {
75        parent::__construct();
76        $this->address   = $address;
77        $this->queryport = $queryport;
78    }
79
80    /**
81     * Sends an RCON command to the server and returns the response.
82     *
83     * @param string $command  The RCON command to execute
84     * @param string $rcon_pwd The RCON password
85     *
86     * @return false|string The command response or false on failure
87     */
88    public function rcon_query_server(string $command, string $rcon_pwd): false|string
89    {
90        $get_challenge = "\xFF\xFF\xFF\xFFchallenge rcon\n";
91
92        $address   = $this->address ?? '';
93        $queryport = $this->queryport ?? 0;
94
95        if (($challenge_rcon = $this->sendCommand($address, $queryport, $get_challenge)) === '' || ($challenge_rcon = $this->sendCommand($address, $queryport, $get_challenge)) === '0' || ($challenge_rcon = $this->sendCommand($address, $queryport, $get_challenge)) === false) {
96            $this->debug['Command send ' . $command] = 'No challenge rcon received';
97
98            return false;
99        }
100
101        if (in_array(preg_match('/challenge rcon ([0-9]+)/D', $challenge_rcon), [0, false], true)) {
102            $this->debug['Command send ' . $command] = 'No valid challenge rcon received';
103
104            return false;
105        }
106        $challenge_rcon = substr($challenge_rcon, 19, 10);
107        $command        = "\xFF\xFF\xFF\xFFrcon \"" . $challenge_rcon . '" ' . $rcon_pwd . ' ' . $command . "\n";
108
109        if (($result = $this->sendCommand($address, $queryport, $command)) === '' || ($result = $this->sendCommand($address, $queryport, $command)) === '0' || ($result = $this->sendCommand($address, $queryport, $command)) === false) {
110            $this->debug['Command send ' . $command] = 'No reply received';
111
112            return false;
113        }
114
115        return substr($result, 5);
116    }
117
118    /**
119     * Queries the server for information, optionally including players and rules.
120     *
121     * @param bool $getPlayers Whether to retrieve the player list
122     * @param bool $getRules   Whether to retrieve server rules
123     *
124     * @return bool True on successful query, false on failure
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        $address   = $this->address ?? '';
134        $queryport = $this->queryport ?? 0;
135
136        $starttime = microtime(true);
137
138        // query the basic server info
139        $command = "\xFF\xFF\xFF\xFFTSource Engine Query\x00";
140
141        if (($result = $this->sendCommand($address, $queryport, $command)) === '' || ($result = $this->sendCommand($address, $queryport, $command)) === '0' || ($result = $this->sendCommand($address, $queryport, $command)) === false) {
142            print '_sendCommand Problem while query_server halflife';
143
144            return false;
145        }
146
147        $endtime = microtime(true);
148        $diff    = round(($endtime - $starttime) * 1000, 0);
149        // response time
150        $this->response = round($diff, 2);
151
152        // unlike the other protocols implemented in this class the return value here
153        // is a defined structure.  Because php can't handle structures unpack the string
154        // into an array and step through the elements reading a bytes as required
155
156        // Unpack used as follows...
157        // I = 4 byte long
158        // c = 1 byte
159        // Format is always a long of -1 [header] followed by a byte [indicator] as validated
160        // From that point on array elements are 1 based numeric values
161
162        $data = unpack('Iheader/cindicator/c*', $result);
163
164        if ($data === false) {
165            return false;
166        }
167
168        assert(isset($data['header'], $data['indicator']));
169
170        /** @var array<int, int> $data */
171        if (($data['header'] ?? null) !== -1) {
172            $this->debug[$command] = 'Not a hl server, expected 0xFF 0xFF 0xFF 0xFF in first 4 bytes';
173
174            return false;
175        }
176
177        if (!isset($data['indicator']) || $data['indicator'] !== 0x6D) {
178            $this->debug[$command] = 'Not a hl server, expected 0x6D in byte 5';
179
180            return false;
181        }
182
183        $pos = 1;
184
185        $gameip = $this->get_string($data, $pos);
186        $pos += strlen($gameip) + 1;
187
188        $hostname = $this->get_string($data, $pos);
189        $pos += strlen($hostname) + 1;
190
191        $map = $this->get_string($data, $pos);
192        $pos += strlen($map) + 1;
193
194        $gametype = $this->get_string($data, $pos);
195        $pos += strlen($gametype) + 1;
196
197        $gamedesc = $this->get_string($data, $pos);
198        $pos += strlen($gamedesc) + 1;
199
200        $numplayers = isset($data[$pos]) && is_numeric($data[$pos]) ? (int) $data[$pos] : 0;
201        $pos++;
202
203        $maxplayers = isset($data[$pos]) && is_numeric($data[$pos]) ? (int) $data[$pos] : 0;
204        $pos++;
205        $pos++;
206        $pos++;
207        $pos++;
208
209        $password = isset($data[$pos]) && is_numeric($data[$pos]) ? (int) $data[$pos] : 0;
210        $pos++;
211
212        $ismod = isset($data[$pos]) && is_numeric($data[$pos]) ? (int) $data[$pos] : 0;
213        $pos++;
214
215        // if this is a mod, get mod specific information
216        if ($ismod === 1) {
217            $modurlinfo = $this->get_string($data, $pos);
218            $pos += strlen($modurlinfo) + 1;
219
220            $modurldownload = $this->get_string($data, $pos);
221            $pos += strlen($modurldownload) + 1;
222
223            $unused = $this->get_string($data, $pos);
224            $pos += strlen($unused) + 1;
225
226            $modversion = $this->get_long($data, $pos);
227            $pos += 4;
228
229            $modsize = $this->get_long($data, $pos);
230            $pos += 4;
231
232            $serverside = $data[$pos];
233            $pos++;
234
235            $customclientdll = $data[$pos];
236            $pos++;
237        }
238        $pos++;
239        $pos++;
240
241        $this->gamename    = $gamedesc;
242        $this->gametype    = $gametype;
243        $this->hostport    = $this->queryport ?? 0;
244        $this->servertitle = $hostname;
245        $this->mapname     = $map;
246        $this->numplayers  = $numplayers;
247        # $this->numplayers;
248        $this->maxplayers  = $maxplayers;
249        $this->gameversion = '';
250        $this->maptitle    = '';
251        $this->password    = $password;
252
253        // Before you can query the players and rules you have to get a 4 byte challenge number
254        $command = "\xFF\xFF\xFF\xFF\x57";
255
256        if (($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '0' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === false) {
257            return false;
258        }
259
260        $data = unpack('Iheader/cindicator/c4', $result); // Long followed by bytes
261
262        if (($data['header'] ?? null) !== -1) {
263            $this->debug[$command] = 'Invalid challenge no reponse, expected 0xFF 0xFF 0xFF 0xFF in first 4 bytes';
264
265            return false;
266        }
267
268        if (!isset($data['indicator']) || $data['indicator'] !== 0x41) {
269            $this->debug[$command] = 'Invalid challenge no reponse, expected 0x41 in byte 5';
270
271            return false;
272        }
273
274        // build a string containing the number to be sent
275        $b1 = isset($data[1]) && is_numeric($data[1]) ? (int) $data[1] : 0;
276        $b2 = isset($data[2]) && is_numeric($data[2]) ? (int) $data[2] : 0;
277        $b3 = isset($data[3]) && is_numeric($data[3]) ? (int) $data[3] : 0;
278        $b4 = isset($data[4]) && is_numeric($data[4]) ? (int) $data[4] : 0;
279
280        $challengeno = chr($b1) . chr($b2) . chr($b3) . chr($b4);
281
282        // get players
283        if ($this->numplayers > 0 && $getPlayers) {
284            $command = "\xFF\xFF\xFF\xFF\x55" . $challengeno;
285
286            if (($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '0' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === false) {
287                return false;
288            }
289
290            $data = unpack('Iheader/cindicator/cnumplayers/c*', $result);
291
292            if (($data['header'] ?? null) !== -1) {
293                $this->debug[$command] = 'Invlaid player reponse, expected 0xFF 0xFF 0xFF 0xFF in first 4 bytes';
294
295                return false;
296            }
297
298            if (!isset($data['indicator']) || $data['indicator'] !== 0x44) {
299                $this->debug[$command] = 'Invlaid player reponse, expected 0x44 in byte 5';
300
301                return false;
302            }
303
304            $numplayers = isset($data['numplayers']) ? (int) $data['numplayers'] : 0;
305
306            $pos = 1;
307
308            $players = [];
309
310            for ($i = 0; $i < $numplayers; $i++) {
311                $index = isset($data[$pos]) ? (int) $data[$pos] : 0;
312                $pos++;
313
314                $players[$index]['name'] = $this->get_string($data, $pos);
315                $pos += strlen($players[$index]['name']) + 1;
316
317                $players[$index]['score'] = $this->get_long($data, $pos);
318                $pos += 4;
319
320                // Todo: Get time connected from next 4 bytes as double
321                $pos += 4;
322            }
323
324            $this->playerkeys['name']  = true;
325            $this->playerkeys['score'] = true;
326
327            // normalize players to a sequential array of arrays to match property type
328            /** @phpstan-ignore typeCoverage.paramTypeCoverage */
329            $this->players = array_values(array_map(static function ($p)
330            {
331                return $p;
332            }, $players));
333        }
334
335        // get the server rules
336        $command = "\xFF\xFF\xFF\xFF\x56" . $challengeno;
337
338        if (($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === '0' || ($result = $this->sendCommand((string) $this->address, (int) $this->queryport, $command)) === false) {
339            return false;
340        }
341
342        // This seems to start with a long of -2 then 4 more bytes (don't know what they are), then a byte of 2.
343        // The same 9 bytes are repated at offset 1400, so remove them both
344        // I assume this is some kind of packet check, if anyone can explain to me, please do - BH
345        $offset    = 0;
346        $newresult = '';
347
348        while ($offset < strlen($result)) {
349            $newresult = $newresult . substr($result, $offset + 9, 1391);
350            $offset += 1400;
351        }
352        $result = $newresult;
353
354        // unpack string now that it is formatted as expected
355        // s = 2 byte integer
356        $data = unpack('Iheader/cindicator/snumrules/c*', $result);
357
358        if (($data['header'] ?? null) !== -1) {
359            $this->debug[$command] = 'Invlaid rules reponse, expected 0xFF 0xFF 0xFF 0xFF in first 4 bytes';
360
361            return false;
362        }
363
364        if (!isset($data['indicator']) || $data['indicator'] !== 0x45) {
365            $this->debug[$command] = 'Invlaid rules reponse, expected 0x45 in byte 5';
366
367            return false;
368        }
369
370        $numrules = isset($data['numrules']) ? (int) $data['numrules'] : 0;
371
372        $pos = 1;
373
374        for ($i = 1; $i < $numrules; $i++) {
375            $rulename = $this->get_string($data, $pos);
376            $pos += strlen($rulename) + 1;
377
378            $rulevalue = $this->get_string($data, $pos);
379            $pos += strlen($rulevalue) + 1;
380
381            $this->rules[$rulename] = $rulevalue;
382        }
383
384        return true;
385    }
386
387    /**
388     * _processPlayers method.
389     *
390     * @psalm-param 8 $formatLength
391     */
392    public function processPlayers(string $data, string $format, int $formatLength): bool
393    {
394        $len = strlen($data);
395
396        for ($i = 6; $i < $len; $i = $endPlayerName + $formatLength + 1) {
397            // finding end of player name
398            $endPlayerName = strpos($data, "\x00", ++$i);
399
400            if ($endPlayerName === false) {
401                return false;
402            } // abort on bogus data
403            // unpacking player's score and time
404            $curPlayer = unpack('@' . ($endPlayerName + 1) . $format, $data);
405
406            if ($curPlayer === false) {
407                continue;
408            }
409
410            // format time
411            if (array_key_exists('time', $curPlayer) && is_int($curPlayer['time'])) {
412                $timestamp = mktime(0, 0, $curPlayer['time']);
413
414                if ($timestamp !== false) {
415                    $curPlayer['time'] = date('H:i:s', $timestamp);
416                }
417            }
418            // extract player name
419            $curPlayer['name'] = substr($data, $i, $endPlayerName - $i);
420            // add player to the list of players
421            $this->players[] = $curPlayer;
422        }
423
424        return true;
425    }
426
427    // from an array of bytes keep reading as string until 0x00 terminator
428    /**
429     * _get_string method.
430     *
431     * @param array<mixed>|false $data
432     *
433     * @psalm-param int<1, max> $pos
434     */
435    public function get_string(mixed $data, int $pos): string
436    {
437        $string = '';
438
439        if (!is_array($data)) {
440            return '';
441        }
442
443        $len = count($data);
444
445        while ($pos < $len && isset($data[$pos]) && $data[$pos] !== 0) {
446            $string .= chr((int) $data[$pos]);
447            $pos++;
448        }
449
450        return $string;
451    }
452
453    // from an array of bytes, take 4 bytes starting at $pos and convert to little endian long
454    /**
455     * _get_long method.
456     *
457     * @param array<mixed>|false $data
458     *
459     * @psalm-param int<3, max> $pos
460     */
461    public function get_long(mixed $data, int $pos): int
462    {
463        if (!is_array($data) || $pos + 3 >= count($data)) {
464            return 0;
465        }
466
467        $long = (int) ($data[$pos] ?? 0);
468
469        for ($i = 1; $i < 4; $i++) {
470            $pos++;
471            $long += ((int) ($data[$pos] ?? 0)) << (8 * $i);
472        }
473
474        return $long;
475    }
476}