| 1: | <?php declare(strict_types=1); |
| 2: | |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 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: | |
| 51: | |
| 52: | |
| 53: | |
| 54: | |
| 55: | |
| 56: | class Ventrilo extends CSQuery implements ProtocolInterface |
| 57: | { |
| 58: | public string $name = 'Ventrilo'; |
| 59: | |
| 60: | |
| 61: | public array $supportedGames = ['Ventrilo']; |
| 62: | public string $protocol = 'ventrilo'; |
| 63: | |
| 64: | |
| 65: | |
| 66: | |
| 67: | public array $channels = []; |
| 68: | |
| 69: | |
| 70: | |
| 71: | |
| 72: | |
| 73: | |
| 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: | |
| 84: | |
| 85: | |
| 86: | |
| 87: | |
| 88: | |
| 89: | |
| 90: | |
| 91: | |
| 92: | |
| 93: | #[Override] |
| 94: | public function query_server(bool $getPlayers = true, bool $getRules = true): bool |
| 95: | { |
| 96: | |
| 97: | |
| 98: | |
| 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: | |
| 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: | |
| 121: | @socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, ['sec' => 3, 'usec' => 0]); |
| 122: | |
| 123: | |
| 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: | |
| 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: | |
| 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: | |
| 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: | |
| 182: | |
| 183: | |
| 184: | |
| 185: | |
| 186: | |
| 187: | |
| 188: | |
| 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: | |
| 211: | |
| 212: | |
| 213: | |
| 214: | #[Override] |
| 215: | public function getProtocolName(): string |
| 216: | { |
| 217: | return $this->protocol; |
| 218: | } |
| 219: | |
| 220: | |
| 221: | |
| 222: | |
| 223: | |
| 224: | |
| 225: | |
| 226: | |
| 227: | #[Override] |
| 228: | public function getVersion(ServerInfo $info): string |
| 229: | { |
| 230: | return $info->gameversion ?? 'unknown'; |
| 231: | } |
| 232: | |
| 233: | |
| 234: | |
| 235: | |
| 236: | |
| 237: | |
| 238: | |
| 239: | |
| 240: | |
| 241: | |
| 242: | |
| 243: | protected function processPackets(array $packets): array |
| 244: | { |
| 245: | |
| 246: | $decrypted = $this->decryptPackets($packets); |
| 247: | |
| 248: | |
| 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: | |
| 347: | |
| 348: | |
| 349: | |
| 350: | |
| 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: | |
| 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: | |
| 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: | |
| 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: | |