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_key_exists;
16: use function array_map;
17: use function array_values;
18: use function assert;
19: use function chr;
20: use function count;
21: use function date;
22: use function in_array;
23: use function is_array;
24: use function is_int;
25: use function is_numeric;
26: use function microtime;
27: use function mktime;
28: use function preg_match;
29: use function round;
30: use function strlen;
31: use function strpos;
32: use function substr;
33: use function unpack;
34: use Clansuite\ServerQuery\CSQuery;
35: use Override;
36:
37: /**
38: * Queries a halflife server.
39: *
40: * This class works with Halflife only.
41: */
42: class 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: }
477: