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_combine;
16: use function array_pad;
17: use function chr;
18: use function count;
19: use function define;
20: use function defined;
21: use function explode;
22: use function implode;
23: use function is_array;
24: use function is_int;
25: use function is_string;
26: use function ksort;
27: use function pack;
28: use function preg_replace_callback;
29: use function preg_split;
30: use function socket_close;
31: use function socket_create;
32: use function socket_recvfrom;
33: use function socket_sendto;
34: use function socket_set_option;
35: use function strlen;
36: use function strpos;
37: use function strtolower;
38: use function substr;
39: use function trim;
40: use function unpack;
41: use Clansuite\Capture\Protocol\ProtocolInterface;
42: use Clansuite\Capture\ServerAddress;
43: use Clansuite\Capture\ServerInfo;
44: use Clansuite\ServerQuery\CSQuery;
45: use Exception;
46: use Override;
47: use 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: */
56: class 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: }
482: