> > > >

php PHP Server Query

Сообщения
672
Рейтинг
571
#1
Простой класс для получения информации с сервера для GouldSource серверов (Аналог SourceQuery)
Статус ALFA потому пока что сюда
PHP:

<?php
class ValveServerQueryException extends Exception {
const ERROR_SOCKET = 1;
const ERROR_CONNECT = 2;
const ERROR_WRITE = 3;
const ERROR_READ = 4;
const ERROR_DECODE = 5;
const ERROR_BAD_HEADER = 6;
const ERROR_DECOMPRESS = 7;
const ERROR_RCON = 8;
}

class ValveServerQuery {

const ENGINE_GOLDSRC = 1;
const ENGINE_SOURCE = 2;

const HEADER_PING = 0x6A;
const HEADER_INFO = 0x49;
const HEADER_INFO_OLD = 0x6D;
const HEADER_CHALLENGE = 0x41;
const HEADER_PLAYERS = 0x44;
const HEADER_RULES = 0x45;
const HEADER_RCON = 0x6C;

/**
* @var bool
*/
private $mbExists = false;

/**
* @var int
*/
private $engine;

/**
* @var string
*/
private $host;

/**
* @var int
*/
private $port;

/**
* @var int
*/
private $position;

/**
* @var int
*/
private $length;

/**
* @var int
*/
private $buffer;

/**
* @var resource
*/
private $socket;

/**
* @var int
*/
private $timeout;

/**
* @var
*/
private $rconPassword;

/**
* @var null
*/
private $rconChallenge = null;

/**
* ValveServerQuery constructor.
* @param string $host
* @param array $options
*/
public function __construct($host, $port, array $options = array()) {
$this->host = $host;
$this->port = $port;

if (array_key_exists('timeout', $options)) {
$this->timeout = $options['timeout'];
}

if (array_key_exists('engine', $options)) {
$this->engine = $options['engine'];
}

if (array_key_exists('rcon', $options)) {
$this->rconPassword = $options['rcon'];
}

$this->mbExists = function_exists('mb_substr');
}

public function __destruct() {
$this->disconnect();
}

public function getAddress() {
return $this->host . ':' . $this->port;
}

/**
* Ping server
*
* @return bool
*/
public function ping() {
$this->send("\xFF\xFF\xFF\xFF\x69");
return $this->getByte() == self::HEADER_PING;
}

/**
* Get server info
*
* @return array
* @throws Exception
*/
public function getInfo() {
$this->send("\xFF\xFF\xFF\xFF\x54\x53\x6F\x75\x72\x63\x65\x20\x45\x6E\x67\x69\x6E\x65\x20\x51\x75\x65\x72\x79\x00", true);
$result = array();
$header = $this->getByte();
switch ($header) {
case self::HEADER_INFO_OLD:
$result['address'] = $this->getString();
$result['name'] = $this->getString();
$result['map'] = $this->getString();
$result['folder'] = $this->getString();
$result['game'] = $this->getString();
$result['players'] = $this->getByte();
$result['maxPlayers'] = $this->getByte();
$result['protocol'] = $this->getByte();
$result['dedicated'] = $this->getDedicated();
$result['os'] = $this->getOS();
$result['visibility'] = $this->getByte() === 1;
$mod = $this->getByte() === 1;
if ($mod) {
$result['link'] = $this->getString();
$result['downloadLink'] = $this->getString();
$this->position++;
$result['version'] = $this->getLong();
$result['size'] = $this->getLong();
$result['multiplayer'] = $this->getByte() === 1;
$result['ownDll'] = $this->getByte() === 1;
} else {
$result['link'] = '';
$result['downloadLink'] = '';
$result['version'] = 0;
$result['size'] = 0;
$result['multiplayer'] = false;
$result['ownDll'] = false;
}
$result['vac'] = $this->getByte() === 1;
$result['bots'] = $this->getByte();
break;

case self::HEADER_INFO:
$result['protocol'] = $this->getByte();
$result['name'] = $this->getString();
$result['map'] = $this->getString();
$result['folder'] = $this->getString();
$result['game'] = $this->getString();
$result['gameId'] = $this->getShort();
$result['players'] = $this->getByte();
$result['maxPlayers'] = $this->getByte();
$result['bots'] = $this->getByte();
$result['dedicated'] = $this->getDedicated();
$result['os'] = $this->getOS();
$result['visibility'] = $this->getByte() === 1;
$result['vac'] = $this->getByte() === 1;
$result['version'] = $this->getString();
$extraFlag = $this->getByte();
if ($extraFlag & 0x80) {
$result['port'] = $this->getShort();
}
if ($extraFlag & 0x10) {
if (PHP_INT_SIZE === 8 || extension_loaded('gmp')) {
if (PHP_INT_SIZE === 8) {
$result['steamID'] = $this->getLong() | ($this->getLong() << 32);
} else {
$steamIDLower = gmp_abs($this->getLong());
$steamIDInstance = gmp_mul(gmp_abs($this->getLong()), gmp_pow(2, 32));
$result['steamID'] = gmp_strval(gmp_or($steamIDLower, $steamIDInstance));
unset($steamIDLower, $steamIDInstance);
}
} else {
$result['steamID'] = 0;
$this->skipBytes(8);
}
}
if ($extraFlag & 0x40) {
$result['sourceTV'] = array(
'port' => $this->getShort(),
'name' => $this->getString()
);
}
if ($extraFlag & 0x20) {
$result['keywords'] = $this->getString();
}
if ($extraFlag & 0x01) {
if (PHP_INT_SIZE === 8 || extension_loaded('gmp')) {
if (PHP_INT_SIZE === 8) {
$result['gameId'] = $this->getLong() | ($this->getLong() << 32);
} else {
$gameIDLower = gmp_abs($this->getLong());
$gameIDInstance = gmp_mul(gmp_abs($this->getLong()), gmp_pow(2, 32));
$result['gameId'] = gmp_strval(gmp_or($gameIDLower, $gameIDInstance));
unset($gameIDLower, $gameIDInstance);
}
} else {
$result['gameId'] = 0;
$this->skipBytes(8);
}
}
break;

default:
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X or \x%X',
$header,
self::HEADER_INFO,
self::HEADER_INFO_OLD

), ValveServerQueryException::ERROR_BAD_HEADER);
}

return array(
'protocol' => $result['protocol'],
'name' => $result['name'],
'map' => $result['map'],
'players' => $result['players'],
'maxPlayers' => $result['maxPlayers'],
'bots' => $result['bots'],
'game' => $result['game'],
'dedicated' => $result['dedicated'],
'os' => $result['os'],
'vac' => $result['vac'],
'extra' => $result
);
}

/**
* Get server players
*
* @return array
* @throws Exception
*/
public function getPlayers() {
$this->send("\xFF\xFF\xFF\xFF\x55\xFF\xFF\xFF\xFF"); // Send challenge
$header = $this->getByte();
if ($header != self::HEADER_CHALLENGE) {
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X',
$header,
self::HEADER_CHALLENGE
), ValveServerQueryException::ERROR_BAD_HEADER);
}
$this->send("\xFF\xFF\xFF\xFF\x55" . $this->getBytes(4));

$header = $this->getByte();
if ($header != self::HEADER_PLAYERS) {
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X',
$header,
self::HEADER_PLAYERS
), ValveServerQueryException::ERROR_BAD_HEADER);
}
$players = $this->getByte();
$result = array();
for ($i = 0; $i < $players; $i++) {
$tmp = array();
$tmp['id'] = $this->getByte();
$tmp['name'] = $this->getString();
$tmp['score'] = $this->getLong();
$tmp['time'] = (int)$this->getFloat();
$tmp['timeFormatted'] = gmdate('H:i:s', $tmp['time']);
$result[] = $tmp;
}
return $result;
}

/**
* Get server rules
*
* @return array
* @throws Exception
*/
public function getRules() {
$this->send("\xFF\xFF\xFF\xFF\x56\xFF\xFF\xFF\xFF"); // Send challenge
$header = $this->getByte();
if ($header != self::HEADER_CHALLENGE) {
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X',
$header,
self::HEADER_CHALLENGE
), ValveServerQueryException::ERROR_BAD_HEADER);
}
$this->send("\xFF\xFF\xFF\xFF\x56" . $this->getBytes(4));

$header = $this->getByte();
if ($header != self::HEADER_RULES) {
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X',
$header,
self::HEADER_RULES
), ValveServerQueryException::ERROR_BAD_HEADER);
}
$rules = $this->getByte();
$result = array();
for ($i = 0; $i < $rules; $i++) {
$value = $this->getString();
$name = $this->getString();
$result[$name] = $value;
}
return $result;
}

public function rcon($cmd) {
if (empty($this->rconPassword)) {
throw new ValveServerQueryException('Bad rcon password.', ValveServerQueryException::ERROR_RCON);
}

if ($this->rconChallenge === null) {
$this->send("\xFF\xFF\xFF\xFF\x63\x68\x61\x6C\x6C\x65\x6E\x67\x65\x20\x72\x63\x6F\x6E");
if ($this->getBytes(14) !== 'challenge rcon') {
throw new ValveServerQueryException('Failed to get RCON challenge.', ValveServerQueryException::ERROR_RCON);
}
$this->skipBytes(15);
$this->rconChallenge = trim($this->getBytes(-2));
}

$this->send(sprintf("\xFF\xFF\xFF\xFFrcon %s %s %s\0", $this->rconChallenge, $this->rconPassword, $cmd));
$header = $this->getByte();
if ($header != self::HEADER_RCON) {
throw new ValveServerQueryException(sprintf(
'Bad header \x%X in packet. Need to be \x%X',
$header,
self::HEADER_RCON
), ValveServerQueryException::ERROR_BAD_HEADER);
}

$result = trim($this->getBytes(-1));

if ($result === 'Bad rcon_password.') {
throw new ValveServerQueryException('Bad rcon password.', ValveServerQueryException::ERROR_RCON);
}

if ($result === 'You have been banned from this server.') {
throw new ValveServerQueryException('You have been banned from this server.', ValveServerQueryException::ERROR_RCON);
}

return $result;
}

/**
* Open socket connection
*
* @throws Exception
*/
public function connect() {
$this->socket = null;
if ( ($this->socket = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP)) === false ) {
throw new ValveServerQueryException(sprintf(
'Unable to create a socket: %s',
socket_strerror(socket_last_error())
), ValveServerQueryException::ERROR_SOCKET);
}

socket_set_nonblock($this->socket);

$error = NULL;
$attempts = 0;
$timeout = $this->timeout * 1000; // adjust because we sleeping in 1 millisecond increments
$connected = null;
while (!($connected = @socket_connect($this->socket, $this->host, $this->port)) && $attempts++ < $timeout) {
$error = socket_last_error();
if ($error != SOCKET_EINPROGRESS && $error != SOCKET_EALREADY) {
$this->disconnect();
throw new ValveServerQueryException(sprintf(
'Unable to connect to server %s:%d: %s',
$this->host,
$this->port,
socket_strerror(socket_last_error())
), ValveServerQueryException::ERROR_CONNECT);
}
usleep(1000);
}

if (!$connected) {
$this->disconnect();
throw new ValveServerQueryException(sprintf(
'Unable to connect to server %s:%d: %s',
$this->host,
$this->port,
socket_strerror(socket_last_error())
), ValveServerQueryException::ERROR_CONNECT);
}
}

/**
* Close socket connection
*/
public function disconnect() {
if ($this->socket) {
socket_close($this->socket);
$this->socket = null;
}
}

/**
* @param string $buffer
* @param bool $waitForSecondPacket
* @throws Exception
*/
private function send($buffer, $waitForSecondPacket = false) {
if ($this->socket === null) {
$this->connect();
}
$this->position = 0;
$this->buffer = '';
$this->length = 0;

$read = null;
$write = array($this->socket);
$except = null;
$select = socket_select($read, $write, $except, 3, null);

if ($select === 0) {
throw new ValveServerQueryException('Write timeout', ValveServerQueryException::ERROR_WRITE);
}

if (socket_write($this->socket, $buffer) === false ) {
throw new ValveServerQueryException(sprintf(
'Unable to write to socket: %s',
socket_strerror(socket_last_error())
), ValveServerQueryException::ERROR_WRITE);
}

$packets = array();
$answerId = null;
$attempts = 0;
$compressed = false;
$checksum = null;
do {
$read = array($this->socket);
$write = null;
$except = null;
$timeout = $waitForSecondPacket && $attempts >= 1 ? 1 : 15;
$select = socket_select($read, $write, $except, $timeout, null);

if ($select === 0) {
if ($waitForSecondPacket) {
break;
} else {
throw new ValveServerQueryException('Read timeout', ValveServerQueryException::ERROR_READ);
}
}

$buffer = socket_read($this->socket, 1400);
if ($buffer === false) {
break;
}

$this->buffer = $buffer;

$this->position = 0;
$this->length = $this->getLength();

$header = $this->getLong();
if ($header === -1) {
$packets[0] = $this->buffer;

$attempts++;
if ($waitForSecondPacket && $attempts < 2) {
$count = 2;
continue;
} else {
break;
}
}

$id = $this->getLong();
if ($answerId === null) {
$answerId = $id;
} elseif ($answerId !== $id) {
break;
}

if ($this->engine === self::ENGINE_SOURCE) {
$compressed = ($answerId & 0x80000000) !== 0;
$count = $this->getByte();
$number = $this->getByte() + 1;
if ($compressed) {
$this->skipBytes(4);
$checksum = (string)$this->getLong();
} else {
$this->skipBytes(2);
}
} else {
$tmp = $this->getByte();
$count = $tmp & 0xF;
$number = $tmp >> 4;
}


$packets[$number] = $this->getBytes($this->length - $this->position);

} while ($count > count($packets) && $attempts++ < 5);

ksort($packets, SORT_NUMERIC);

$this->buffer = implode('', $packets);

if ($compressed) {
if (!function_exists('bzdecompress')) {
throw new ValveServerQueryException(
'Received compressed packet, PHP doesn\'t have Bzip2 library installed, can\'t decompress.',
ValveServerQueryException::ERROR_DECOMPRESS
);
}

$this->buffer = bzdecompress($this->buffer);

if (sprintf('%u', crc32($this->buffer)) !== $checksum) {
throw new ValveServerQueryException('CRC32 checksum mismatch of uncompressed packet data',
ValveServerQueryException::ERROR_DECOMPRESS
);
}
}

$this->position = 4;
$this->length = $this->getLength();

unset($buffer, $read, $write, $attempts, $select, $packets, $count, $number, $answerId, $compressed, $checksum);
}

private function getDedicated() {
switch (chr($this->getByte())) {
case 'd':
case 'D':
return 'dedicated';
case 'l':
case 'L':
return 'local';
case 'p':
case 'P':
return 'HLTV';
default:
return 'unknown';
}
}

private function getOS() {
switch (chr($this->getByte())) {
case 'l':
case 'L':
return 'linux';
case 'w':
case 'W':
return'windows';
case 'm':
case 'o':
case 'M':
case 'O':
return 'mac';
break;
default:
return 'unknown';
}
}

/**
* @return int
*/
private function getByte() {
return $this->getData('C', 1);
}

/**
* @return int
*/
private function getShort() {
return $this->getData('s', 2);
}

/**
* @return int
*/
private function getLong() {
return $this->getData('l', 4);
}

/**
* @return float
*/
private function getFloat() {
return $this->getData('f', 4);
}

/**
* @param string $format
* @param int $length
* @return mixed
* @throws Exception
*/
private function getData($format, $length) {
if (($this->length - $this->position) < $length) {
throw new ValveServerQueryException(sprintf(
'Not enough data to unpack %d',
$length
), ValveServerQueryException::ERROR_DECODE);
}

$result = unpack($format, $this->getBytes($length));
$this->position += $length;
return $result[1];
}

private function skipBytes($length) {
$this->position += $length;
}

/**
* @param int $length
* @return string
*/
private function getBytes($length = null) {
return $this->mbExists
? mb_substr($this->buffer, $this->position , $length, '8bit')
: substr($this->buffer, $this->position , $length);
}

/**
* @return string
*/
private function getString() {
$result = '';
while ($this->buffer[$this->position] != "\x00") {
$result .= $this->buffer[$this->position];
$this->position++;

}
$this->position++;
return $result;
}

/**
* @return int
*/
private function getLength() {
return $this->mbExists
? mb_strlen($this->buffer, '8bit')
: strlen($this->buffer);
}
}

Пример использования
PHP:
try {
$serverQuery = new ValveServerQuery('127.0.0.1', 27015, array(
'timeout' => 10,
'engine' => ValveServerQuery::ENGINE_GOLDSRC,
'rcon' => '123456'
));
$serverQuery->connect();
$data = $serverQuery->ping();
var_dump($data);
$data = $serverQuery->getInfo();
var_dump($data);
$data = $serverQuery->getPlayers();
var_dump($data);
$data = $serverQuery->getRules();
var_dump($data);
$serverQuery->disconnect();
} catch (Exception $e) {
echo sprintf('Error #%d: %s', $e->getCode(), $e->getMessage()) . PHP_EOL;
echo sprintf('IN %s:%s', $e->getFile(), $e->getLine()) . PHP_EOL;
}


Допишу почему создал этот класс.
1. Учтена настройка http://php.net/manual/en/mbstring.overload.php которая при некоторых обстоятельствах неверно считала байты в строке
2. Учтено мультыпакетный ответ
3. Учтено возможность старого и нового варианта ответа от сервера

P.S. Я не хочу выкладывать на github. Мне лень
Проcьба кому нелень проверить у себя
 
Последнее редактирование:
  7
Сообщения
55
GitHub
oxoTHuk89
Рейтинг
54
#2
Проверил на CSGO сервере. Работает круто. Инфа вся на месте. Не плохо бы добавить gethostbyname или аналог, чтобы подсовывать доменное имя вместо адреса.
 
  2
Сообщения
672
Рейтинг
571
#3
oxoTHuk, домены и так можно сувать. Но вот что что, но я в шоке что заработало на ксго. Ведь я делал для 1.6. И там не все нюансы под сорс учтены (там немного иначе мультыпакеты и возможна компрессия) плюс в A2S_INFO в конце возможен long long. В пхп только с расшмрением такое число может быть Потому это я пропустил
 
 
Сообщения
55
GitHub
oxoTHuk89
Рейтинг
54
#4
fantom По домену падает в 504 таймаут.
http://xen2.g-nation.ru/test.php
Через пол часика поставлю xDebug, будет более наглядно (хоте тебе то врядли важно =) )
4 Ноя 2017
fantom,
Хм... сейчас такой код приводит к 504:
Код:
<?php
require_once $_SERVER['DOCUMENT_ROOT'].'/QuerySource.php';
try {
$serverQuery = new ValveServerQuery('195.2.253.251', 27015);
$serverQuery->connect();
$data = $serverQuery->ping();
var_dump($data);
$data = $serverQuery->getInfo();
var_dump($data);
$data = $serverQuery->getPlayers();
var_dump($data);
$data = $serverQuery->getRules();
var_dump($data);
$serverQuery->disconnect();
} catch (Exception $e) {
echo sprintf('Error #%d: %s', $e->getCode(), $e->getMessage()) . PHP_EOL;
echo sprintf('IN %s:%s', $e->getFile(), $e->getLine()) . PHP_EOL;
}
 
 
Сообщения
672
Рейтинг
571
#5
oxoTHuk, нужно таймауты задать и неблокирующий мокет с помощью select (((
 
 
Сообщения
55
GitHub
oxoTHuk89
Рейтинг
54
#6
Не понял. Таймаут по дефотлу стоит. Сервер один.
Или ты к тому, что тебе класс переделать нужно?))
 
 
Сообщения
672
Рейтинг
571
#7
oxoTHuk, класс переделать. Там не блокирующий мокет при чтении. При конекте он не блокирующий. Так вот пхп ждет пока получит данные, а они не приходят. И отваливаеться по таймауту
10 Ноя 2017
Update: обновил класс
20 Ноя 2017
oxoTHuk обновил шапку. Добавил поддержку КС:ГО исправил багы, Нужны новые тесты
 
  3
Сообщения
55
GitHub
oxoTHuk89
Рейтинг
54
#8
fantom CSGO так и падает по таймауту. Когда поставил 1.6 (первый попавшийся из ГТ), он мне сказал что заголовки не те, чет ты сломал =)
Error #6: Bad header \xD0 in packet. Need to be \x49 or \x6D
 
 
Сообщения
672
Рейтинг
571
#9
oxoTHuk, го кстати a2s_rules не отвечает. Сколько не пробовал серваков. Насчет 1.6 странно. Можешь сказать какая строка екзепшн?
21 Ноя 2017
oxoTHuk, нашел причину. На 111 строке нужно убрать $this->skipBytes(4);
21 Ноя 2017
Апнул клас. Исправил ошибку с заголовком. Добавил поддержку RCON команд для GOLDSRC игр
 
Последнее редактирование:
 
Сообщения
672
Рейтинг
571
#10
Перенесено в общий раздел
 
 
> > > >