Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
1.43% covered (danger)
1.43%
3 / 210
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Ventrilo
1.43% covered (danger)
1.43%
3 / 210
14.29% covered (danger)
14.29%
1 / 7
3743.60
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 query_server
0.00% covered (danger)
0.00%
0 / 45
0.00% covered (danger)
0.00%
0 / 1
756
 query
0.00% covered (danger)
0.00%
0 / 13
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
 processPackets
0.00% covered (danger)
0.00%
0 / 58
0.00% covered (danger)
0.00%
0 / 1
240
 decryptPackets
0.00% covered (danger)
0.00%
0 / 89
0.00% covered (danger)
0.00%
0 / 1
272
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_combine;
16use function array_pad;
17use function chr;
18use function count;
19use function define;
20use function defined;
21use function explode;
22use function implode;
23use function is_array;
24use function is_int;
25use function is_string;
26use function ksort;
27use function pack;
28use function preg_replace_callback;
29use function preg_split;
30use function socket_close;
31use function socket_create;
32use function socket_recvfrom;
33use function socket_sendto;
34use function socket_set_option;
35use function strlen;
36use function strpos;
37use function strtolower;
38use function substr;
39use function trim;
40use function unpack;
41use Clansuite\Capture\Protocol\ProtocolInterface;
42use Clansuite\Capture\ServerAddress;
43use Clansuite\Capture\ServerInfo;
44use Clansuite\ServerQuery\CSQuery;
45use Exception;
46use Override;
47use RuntimeException;
48
49/**
50 * Queries Ventrilo voice communication servers.
51 *
52 * Retrieves server information including status, connected clients, channels, and settings
53 * by sending a specific UDP query packet and parsing the encrypted response.
54 * Enables monitoring and display of Ventrilo server details in game server query systems.
55 */
56class Ventrilo extends CSQuery implements ProtocolInterface
57{
58    public string $name = 'Ventrilo';
59
60    /** @var array<string> */
61    public array $supportedGames = ['Ventrilo'];
62    public string $protocol      = 'ventrilo';
63
64    /**
65     * @var array<string>
66     */
67    public array $channels = [];
68
69    /**
70     * Initializes the Ventrilo query instance.
71     *
72     * @param null|string $address   The server address to query
73     * @param null|int    $queryport The query port for the Ventrilo server
74     */
75    public function __construct(?string $address = null, ?int $queryport = null)
76    {
77        parent::__construct();
78        $this->address   = $address ?? '';
79        $this->queryport = $queryport ?? 0;
80    }
81
82    /**
83     * Queries the Ventrilo server and populates server information.
84     *
85     * Sends a UDP query packet to the server and processes the encrypted response
86     * to extract server details, player information, and rules.
87     *
88     * @param bool $getPlayers Whether to retrieve player information
89     * @param bool $getRules   Whether to retrieve server rules/settings
90     *
91     * @return bool True on successful query, false on failure
92     */
93    #[Override]
94    public function query_server(bool $getPlayers = true, bool $getRules = true): bool
95    {
96        // We'll use UDP sockets to send the known Ventrilo status packet and receive response(s).
97
98        // Build query packet
99        $packet = "V\xC8\xF4\xF9`\xA2\x1E\xA5M\xFB\x03\xCCQN\xA1\x10\x95\xAF\xB2g\x17g\x812\xFBW\xFD\x8E\xD2\"r\x034z\xBB\x98";
100
101        $ip   = (string) $this->address;
102        $port = (int) $this->queryport;
103
104        // Create UDP socket
105        if (!defined('AF_INET')) {
106            define('AF_INET', 2);
107            define('SOCK_DGRAM', 2);
108            define('SOL_UDP', 17);
109            define('SOL_SOCKET', 1);
110            define('SO_RCVTIMEO', 20);
111        }
112        $sock = @socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
113
114        if ($sock === false) {
115            $this->errstr = 'Unable to create UDP socket for Ventrilo';
116
117            return false;
118        }
119
120        // Set a short receive timeout via socket options if available
121        @socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 3, 'usec' => 0]);
122
123        // Send packet
124        $sent = @socket_sendto($sock, $packet, strlen($packet), 0, $ip, $port);
125
126        if ($sent === false) {
127            socket_close($sock);
128            $this->errstr = 'Failed to send Ventrilo query packet';
129
130            return false;
131        }
132
133        // Try to receive multiple packets (Ventrilo may split into several). We'll collect up to 8 packets.
134        $packets = [];
135
136        for ($i = 0; $i < 8; $i++) {
137            $buf      = '';
138            $from     = '';
139            $fromPort = 0;
140            $recv     = @socket_recvfrom($sock, $buf, 8192, 0, $from, $fromPort);
141
142            if ($recv === false || $recv === 0 || $buf === '') {
143                break;
144            }
145            $packets[] = $buf;
146
147            // if fewer than 8192 bytes received, likely last packet
148            if ($recv < 8192) {
149                break;
150            }
151        }
152
153        socket_close($sock);
154
155        if (count($packets) === 0) {
156            $this->errstr = 'No response from Ventrilo server';
157
158            return false;
159        }
160
161        try {
162            $result = $this->processPackets($packets);
163        } catch (Exception $e) {
164            $this->errstr = 'Failed to parse Ventrilo response: ' . $e->getMessage();
165
166            return false;
167        }
168
169        // Map result to our properties
170        $this->servertitle = isset($result['name']) && is_string($result['name']) ? $result['name'] : (isset($result['gq_name']) && is_string($result['gq_name']) ? $result['gq_name'] : $this->address ?? '');
171        $this->numplayers  = isset($result['clientcount']) && is_int($result['clientcount']) ? $result['clientcount'] : (isset($result['client_count']) && is_int($result['client_count']) ? $result['client_count'] : 0);
172        $this->maxplayers  = isset($result['maxclients']) && is_int($result['maxclients']) ? $result['maxclients'] : (isset($result['max_players']) && is_int($result['max_players']) ? $result['max_players'] : 0);
173        $this->players     = isset($result['players']) && is_array($result['players']) ? $result['players'] : [];
174        $this->channels    = isset($result['teams']) && is_array($result['teams']) ? $result['teams'] : [];
175        $this->online      = true;
176
177        return true;
178    }
179
180    /**
181     * Performs a query on the specified Ventrilo server address.
182     *
183     * Updates internal state with server information and returns a ServerInfo object
184     * containing the query results.
185     *
186     * @param ServerAddress $addr The server address and port to query
187     *
188     * @return ServerInfo Server information including status, players, and settings
189     */
190    #[Override]
191    public function query(ServerAddress $addr): ServerInfo
192    {
193        $this->address   = $addr->ip;
194        $this->queryport = $addr->port;
195        $this->query_server(true, true);
196
197        return new ServerInfo(
198            address: $this->address,
199            queryport: $this->queryport,
200            online: $this->online,
201            servertitle: $this->servertitle,
202            numplayers: $this->numplayers,
203            maxplayers: $this->maxplayers,
204            players: $this->players,
205            errstr: $this->errstr,
206        );
207    }
208
209    /**
210     * Returns the protocol name for Ventrilo.
211     *
212     * @return string The protocol identifier 'ventrilo'
213     */
214    #[Override]
215    public function getProtocolName(): string
216    {
217        return $this->protocol;
218    }
219
220    /**
221     * Extracts the Ventrilo server version from server information.
222     *
223     * @param ServerInfo $info The server information object
224     *
225     * @return string The server version string, or 'unknown' if not available
226     */
227    #[Override]
228    public function getVersion(ServerInfo $info): string
229    {
230        return $info->gameversion ?? 'unknown';
231    }
232
233    /**
234     * Process and decrypt raw Ventrilo packets (array of binary strings)
235     * and return parsed associative array.
236     *
237     * @param array<mixed> $packets
238     *
239     * @throws RuntimeException
240     *
241     * @return array<mixed>
242     */
243    protected function processPackets(array $packets): array
244    {
245        // decrypt packets into one string
246        $decrypted = $this->decryptPackets($packets);
247
248        // convert %HEX sequences
249        $decrypted = preg_replace_callback(
250            '|%([0-9A-F]{2})|i',
251            static fn (array $matches): string => pack('H*', $matches[1]),
252            $decrypted,
253        ) ?? $decrypted;
254
255        $lines = preg_split('/\r?\n/', $decrypted);
256
257        if ($lines === false) {
258            $lines = [];
259        }
260
261        $result  = [];
262        $players = [];
263        $teams   = [];
264
265        $channelFields = 5;
266        $playerFields  = 7;
267
268        foreach ($lines as $line) {
269            $line = trim($line);
270
271            if ($line === '') {
272                continue;
273            }
274
275            $colonPos = strpos($line, ':');
276
277            if ($colonPos === false) {
278                continue;
279            }
280
281            if ($colonPos <= 0) {
282                continue;
283            }
284
285            $key   = strtolower(substr($line, 0, $colonPos));
286            $value = trim(substr($line, $colonPos + 1));
287
288            switch ($key) {
289                case 'client':
290                    $items = explode(',', $value, $playerFields);
291                    $p     = [];
292
293                    foreach ($items as $item) {
294                        $parts = array_pad(explode('=', $item, 2), 2, '');
295                        $k     = strtolower((string) $parts[0]);
296                        $v     = $parts[1] ?? '';
297                        $p[$k] = $v;
298                    }
299                    $players[] = $p;
300
301                    break;
302
303                case 'channel':
304                    $items = explode(',', $value, $channelFields);
305                    $c     = [];
306
307                    foreach ($items as $item) {
308                        $parts = array_pad(explode('=', $item, 2), 2, '');
309                        $k     = strtolower((string) $parts[0]);
310                        $v     = $parts[1] ?? '';
311                        $c[$k] = $v;
312                    }
313                    $teams[] = $c;
314
315                    break;
316
317                case 'channelfields':
318                    $channelFields = count(explode(',', $value));
319
320                    break;
321
322                case 'clientfields':
323                    $playerFields = count(explode(',', $value));
324
325                    break;
326
327                default:
328                    $result[$key] = $value;
329
330                    break;
331            }
332        }
333
334        if ($players !== []) {
335            $result['players'] = $players;
336        }
337
338        if ($teams !== []) {
339            $result['teams'] = $teams;
340        }
341
342        return $result;
343    }
344
345    /**
346     * Decrypt Ventrilo header/data packets and return combined plaintext string.
347     *
348     * @param array<mixed> $packets
349     *
350     * @throws RuntimeException
351     */
352    protected function decryptPackets(array $packets): string
353    {
354        $head_encrypt_table = [
355            0x80, 0xE5, 0x0E, 0x38, 0xBA, 0x63, 0x4C, 0x99, 0x88, 0x63, 0x4C, 0xD6, 0x54, 0xB8, 0x65, 0x7E,
356            0xBF, 0x8A, 0xF0, 0x17, 0x8A, 0xAA, 0x4D, 0x0F, 0xB7, 0x23, 0x27, 0xF6, 0xEB, 0x12, 0xF8, 0xEA,
357            0x17, 0xB7, 0xCF, 0x52, 0x57, 0xCB, 0x51, 0xCF, 0x1B, 0x14, 0xFD, 0x6F, 0x84, 0x38, 0xB5, 0x24,
358            0x11, 0xCF, 0x7A, 0x75, 0x7A, 0xBB, 0x78, 0x74, 0xDC, 0xBC, 0x42, 0xF0, 0x17, 0x3F, 0x5E, 0xEB,
359            0x74, 0x77, 0x04, 0x4E, 0x8C, 0xAF, 0x23, 0xDC, 0x65, 0xDF, 0xA5, 0x65, 0xDD, 0x7D, 0xF4, 0x3C,
360            0x4C, 0x95, 0xBD, 0xEB, 0x65, 0x1C, 0xF4, 0x24, 0x5D, 0x82, 0x18, 0xFB, 0x50, 0x86, 0xB8, 0x53,
361            0xE0, 0x4E, 0x36, 0x96, 0x1F, 0xB7, 0xCB, 0xAA, 0xAF, 0xEA, 0xCB, 0x20, 0x27, 0x30, 0x2A, 0xAE,
362            0xB9, 0x07, 0x40, 0xDF, 0x12, 0x75, 0xC9, 0x09, 0x82, 0x9C, 0x30, 0x80, 0x5D, 0x8F, 0x0D, 0x09,
363            0xA1, 0x64, 0xEC, 0x91, 0xD8, 0x8A, 0x50, 0x1F, 0x40, 0x5D, 0xF7, 0x08, 0x2A, 0xF8, 0x60, 0x62,
364            0xA0, 0x4A, 0x8B, 0xBA, 0x4A, 0x6D, 0x00, 0x0A, 0x93, 0x32, 0x12, 0xE5, 0x07, 0x01, 0x65, 0xF5,
365            0xFF, 0xE0, 0xAE, 0xA7, 0x81, 0xD1, 0xBA, 0x25, 0x62, 0x61, 0xB2, 0x85, 0xAD, 0x7E, 0x9D, 0x3F,
366            0x49, 0x89, 0x26, 0xE5, 0xD5, 0xAC, 0x9F, 0x0E, 0xD7, 0x6E, 0x47, 0x94, 0x16, 0x84, 0xC8, 0xFF,
367            0x44, 0xEA, 0x04, 0x40, 0xE0, 0x33, 0x11, 0xA3, 0x5B, 0x1E, 0x82, 0xFF, 0x7A, 0x69, 0xE9, 0x2F,
368            0xFB, 0xEA, 0x9A, 0xC6, 0x7B, 0xDB, 0xB1, 0xFF, 0x97, 0x76, 0x56, 0xF3, 0x52, 0xC2, 0x3F, 0x0F,
369            0xB6, 0xAC, 0x77, 0xC4, 0xBF, 0x59, 0x5E, 0x80, 0x74, 0xBB, 0xF2, 0xDE, 0x57, 0x62, 0x4C, 0x1A,
370            0xFF, 0x95, 0x6D, 0xC7, 0x04, 0xA2, 0x3B, 0xC4, 0x1B, 0x72, 0xC7, 0x6C, 0x82, 0x60, 0xD1, 0x0D,
371        ];
372
373        $data_encrypt_table = [
374            0x82, 0x8B, 0x7F, 0x68, 0x90, 0xE0, 0x44, 0x09, 0x19, 0x3B, 0x8E, 0x5F, 0xC2, 0x82, 0x38, 0x23,
375            0x6D, 0xDB, 0x62, 0x49, 0x52, 0x6E, 0x21, 0xDF, 0x51, 0x6C, 0x6C, 0x76, 0x37, 0x86, 0x50, 0x7D,
376            0x48, 0x1F, 0x65, 0xE7, 0x52, 0x6A, 0x88, 0xAA, 0xC1, 0x32, 0x2F, 0xF7, 0x54, 0x4C, 0xAA, 0x6D,
377            0x7E, 0x6D, 0xA9, 0x8C, 0x0D, 0x3F, 0xFF, 0x6C, 0x09, 0xB3, 0xA5, 0xAF, 0xDF, 0x98, 0x02, 0xB4,
378            0xBE, 0x6D, 0x69, 0x0D, 0x42, 0x73, 0xE4, 0x34, 0x50, 0x07, 0x30, 0x79, 0x41, 0x2F, 0x08, 0x3F,
379            0x42, 0x73, 0xA7, 0x68, 0xFA, 0xEE, 0x88, 0x0E, 0x6E, 0xA4, 0x70, 0x74, 0x22, 0x16, 0xAE, 0x3C,
380            0x81, 0x14, 0xA1, 0xDA, 0x7F, 0xD3, 0x7C, 0x48, 0x7D, 0x3F, 0x46, 0xFB, 0x6D, 0x92, 0x25, 0x17,
381            0x36, 0x26, 0xDB, 0xDF, 0x5A, 0x87, 0x91, 0x6F, 0xD6, 0xCD, 0xD4, 0xAD, 0x4A, 0x29, 0xDD, 0x7D,
382            0x59, 0xBD, 0x15, 0x34, 0x53, 0xB1, 0xD8, 0x50, 0x11, 0x83, 0x79, 0x66, 0x21, 0x9E, 0x87, 0x5B,
383            0x24, 0x2F, 0x4F, 0xD7, 0x73, 0x34, 0xA2, 0xF7, 0x09, 0xD5, 0xD9, 0x42, 0x9D, 0xF8, 0x15, 0xDF,
384            0x0E, 0x10, 0xCC, 0x05, 0x04, 0x35, 0x81, 0xB2, 0xD5, 0x7A, 0xD2, 0xA0, 0xA5, 0x7B, 0xB8, 0x75,
385            0xD2, 0x35, 0x0B, 0x39, 0x8F, 0x1B, 0x44, 0x0E, 0xCE, 0x66, 0x87, 0x1B, 0x64, 0xAC, 0xE1, 0xCA,
386            0x67, 0xB4, 0xCE, 0x33, 0xDB, 0x89, 0xFE, 0xD8, 0x8E, 0xCD, 0x58, 0x92, 0x41, 0x50, 0x40, 0xCB,
387            0x08, 0xE1, 0x15, 0xEE, 0xF4, 0x64, 0xFE, 0x1C, 0xEE, 0x25, 0xE7, 0x21, 0xE6, 0x6C, 0xC6, 0xA6,
388            0x2E, 0x52, 0x23, 0xA7, 0x20, 0xD2, 0xD7, 0x28, 0x07, 0x23, 0x14, 0x24, 0x3D, 0x45, 0xA5, 0xC7,
389            0x90, 0xDB, 0x77, 0xDD, 0xEA, 0x38, 0x59, 0x89, 0x32, 0xBC, 0x00, 0x3A, 0x6D, 0x61, 0x4E, 0xDB,
390            0x29,
391        ];
392
393        // Decrypt each packet's header and data
394        $decryptedParts = [];
395
396        foreach ($packets as $packet) {
397            if (strlen($packet) < 20) {
398                throw new RuntimeException('Ventrilo packet too short');
399            }
400
401            $header = substr($packet, 0, 20);
402
403            // unpack first 2 bytes as unsigned short (n)
404            $tmp = @unpack('n', $header);
405
406            if (!is_array($tmp) || !isset($tmp[1])) {
407                throw new RuntimeException('Invalid header unpack');
408            }
409            $u = (int) $tmp[1];
410
411            $unpacked = @unpack('C*', substr($header, 2));
412            $chars    = is_array($unpacked) ? $unpacked : [];
413
414            $a1 = $u & 0xFF;
415            $a2 = $u >> 8;
416
417            if ($a1 === 0) {
418                throw new RuntimeException('Header key invalid');
419            }
420
421            $header_items   = [];
422            $table          = $head_encrypt_table;
423            $characterCount = count($chars);
424            $key            = 0;
425
426            for ($index = 1; $index <= $characterCount; $index++) {
427                $chars[$index] = (($chars[$index] ?? 0) - (($table[$a2] ?? 0) + (($index - 1) % 5))) & 0xFF;
428                $a2            = ($a2 + $a1) & 0xFF;
429
430                if (($index % 2) === 0) {
431                    $b1          = $chars[$index - 1] ?? 0;
432                    $b2          = $chars[$index] ?? 0;
433                    $packed      = chr($b1) . chr($b2);
434                    $short_array = @unpack('n', $packed);
435
436                    if (is_array($short_array) && isset($short_array[1])) {
437                        $header_items[$key] = (int) $short_array[1];
438                        $key++;
439                    }
440                }
441            }
442
443            $keys = ['zero', 'cmd', 'id', 'totlen', 'len', 'totpck', 'pck', 'datakey', 'crc'];
444
445            $header_assoc = array_combine($keys, $header_items);
446
447            // decrypt data
448            $table = $data_encrypt_table;
449
450            if (!isset($header_assoc['datakey']) || !isset($header_assoc['pck'])) {
451                throw new RuntimeException('Header missing datakey or pck');
452            }
453
454            $a1 = (int) $header_assoc['datakey'] & 0xFF;
455            $a2 = (int) $header_assoc['datakey'] >> 8;
456
457            if ($a1 === 0) {
458                throw new RuntimeException('Data key invalid');
459            }
460
461            $charsDataUnpacked = @unpack('C*', substr($packet, 20));
462            $charsData         = is_array($charsDataUnpacked) ? $charsDataUnpacked : [];
463            $data              = '';
464            $characterCount    = count($charsData);
465
466            for ($index = 1; $index <= $characterCount; $index++) {
467                $byte              = ($charsData[$index] ?? 0);
468                $byte              = ($byte - (($table[$a2] ?? 0) + (($index - 1) % 72))) & 0xFF;
469                $charsData[$index] = $byte;
470                $a2                = ($a2 + $a1) & 0xFF;
471                $data .= chr($byte);
472            }
473
474            $decryptedParts[(int) $header_assoc['pck']] = $data;
475        }
476
477        ksort($decryptedParts);
478
479        return implode('', $decryptedParts);
480    }
481}