Split up Socket and Rcon classes to be engine specific extensions.

pull/150/head
Anthony Birkett 4 years ago
parent a2f7834ef5
commit b01c1f643f

@ -5,6 +5,8 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use xPaw\SourceQuery\SourceQuery; use xPaw\SourceQuery\SourceQuery;
use xPaw\SourceQuery\Socket\SourceSocket;
use xPaw\SourceQuery\Socket\SocketType;
// For the sake of this example // For the sake of this example
header('Content-Type: text/plain'); header('Content-Type: text/plain');
@ -14,19 +16,19 @@ header('X-Content-Type-Options: nosniff');
define('SQ_SERVER_ADDR', 'localhost'); define('SQ_SERVER_ADDR', 'localhost');
define('SQ_SERVER_PORT', 27015); define('SQ_SERVER_PORT', 27015);
define('SQ_TIMEOUT', 1); define('SQ_TIMEOUT', 1);
define('SQ_ENGINE', SourceQuery::SOURCE); define('SQ_ENGINE', SocketType::SOURCE);
// Edit this <- // Edit this <-
$Query = new SourceQuery(); $Query = new SourceQuery(new SourceSocket());
try { try {
$Query->Connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE); $Query->connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE);
print_r($Query->GetInfo()); print_r($Query->getInfo());
print_r($Query->GetPlayers()); print_r($Query->getPlayers());
print_r($Query->GetRules()); print_r($Query->getRules());
} catch (Exception $e) { } catch (Exception $e) {
echo $e->getMessage(); echo $e->getMessage();
} finally { } finally {
$Query->Disconnect(); $Query->disconnect();
} }

@ -5,6 +5,8 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use xPaw\SourceQuery\SourceQuery; use xPaw\SourceQuery\SourceQuery;
use xPaw\SourceQuery\Socket\SourceSocket;
use xPaw\SourceQuery\Socket\SocketType;
// For the sake of this example // For the sake of this example
header('Content-Type: text/plain'); header('Content-Type: text/plain');
@ -14,19 +16,19 @@ header('X-Content-Type-Options: nosniff');
define('SQ_SERVER_ADDR', 'localhost'); define('SQ_SERVER_ADDR', 'localhost');
define('SQ_SERVER_PORT', 27015); define('SQ_SERVER_PORT', 27015);
define('SQ_TIMEOUT', 1); define('SQ_TIMEOUT', 1);
define('SQ_ENGINE', SourceQuery::SOURCE); define('SQ_ENGINE', SocketType::SOURCE);
// Edit this <- // Edit this <-
$Query = new SourceQuery(); $Query = new SourceQuery(new SourceSocket());
try { try {
$Query->Connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE); $Query->connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE);
$Query->SetRconPassword('my_awesome_password'); $Query->setRconPassword('my_awesome_password');
var_dump($Query->Rcon('say hello')); var_dump($Query->rcon('say hello'));
} catch (Exception $e) { } catch (Exception $e) {
echo $e->getMessage(); echo $e->getMessage();
} finally { } finally {
$Query->Disconnect(); $Query->disconnect();
} }

@ -5,17 +5,19 @@ declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php'; require __DIR__ . '/../vendor/autoload.php';
use xPaw\SourceQuery\SourceQuery; use xPaw\SourceQuery\SourceQuery;
use xPaw\SourceQuery\Socket\SourceSocket;
use xPaw\SourceQuery\Socket\SocketType;
// Edit this -> // Edit this ->
define('SQ_SERVER_ADDR', 'localhost'); define('SQ_SERVER_ADDR', 'localhost');
define('SQ_SERVER_PORT', 27015); define('SQ_SERVER_PORT', 27015);
define('SQ_TIMEOUT', 3); define('SQ_TIMEOUT', 3);
define('SQ_ENGINE', SourceQuery::SOURCE); define('SQ_ENGINE', SocketType::SOURCE);
// Edit this <- // Edit this <-
$Timer = microtime(true); $Timer = microtime(true);
$Query = new SourceQuery(); $Query = new SourceQuery(new SourceSocket());
$Info = []; $Info = [];
$Rules = []; $Rules = [];
@ -23,16 +25,16 @@ $Players = [];
$Exception = null; $Exception = null;
try { try {
$Query->Connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE); $Query->connect(SQ_SERVER_ADDR, SQ_SERVER_PORT, SQ_TIMEOUT, SQ_ENGINE);
//$Query->SetUseOldGetChallengeMethod( true ); // Use this when players/rules retrieval fails on games like Starbound //$Query->SetUseOldGetChallengeMethod( true ); // Use this when players/rules retrieval fails on games like Starbound
$Info = $Query->GetInfo(); $Info = $Query->getInfo();
$Players = $Query->GetPlayers(); $Players = $Query->getPlayers();
$Rules = $Query->GetRules(); $Rules = $Query->getRules();
} catch (Exception $e) { } catch (Exception $e) {
$Exception = $e; $Exception = $e;
} finally { } finally {
$Query->Disconnect(); $Query->disconnect();
} }
$Timer = number_format(microtime(true) - $Timer, 4, '.', ''); $Timer = number_format(microtime(true) - $Timer, 4, '.', '');

@ -1,177 +0,0 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException;
/**
* Base socket interface
*
* @package xPaw\SourceQuery
*
* @uses InvalidPacketException
* @uses SocketException
*/
abstract class BaseSocket
{
/**
* @var resource|null
*/
public $Socket;
/**
* @var int $Engine
*/
public int $Engine = SourceQuery::SOURCE;
/**
* @var string $Address
*/
public string $Address = '';
/**
* @var int $Port
*/
public int $Port = 0;
/**
* @var int $Timeout
*/
public int $Timeout = 0;
/**
* Destructor
*/
public function __destruct()
{
$this->Close();
}
/**
* Close
*/
abstract public function Close(): void;
/**
* @param string $Address
* @param int $Port
* @param int $Timeout
* @param int $Engine
*/
abstract public function Open(string $Address, int $Port, int $Timeout, int $Engine): void;
/**
* @param int $Header
* @param string $String
*
* @return bool
*/
abstract public function Write(int $Header, string $String = ''): bool;
/**
* @param int $Length
*
* @return Buffer
*/
abstract public function Read(int $Length = 1400): Buffer;
/**
* @param Buffer $Buffer
* @param int $Length
* @param callable $SherlockFunction
*
* @return Buffer
*
* @throws InvalidPacketException
* @throws SocketException
*/
protected function ReadInternal(Buffer $Buffer, int $Length, callable $SherlockFunction): Buffer
{
if ($Buffer->Remaining() === 0) {
throw new InvalidPacketException('Failed to read any data from socket', InvalidPacketException::BUFFER_EMPTY);
}
$Header = $Buffer->GetLong();
// Single packet, do nothing.
if ($Header === -1) {
return $Buffer;
}
if ($Header === -2) { // Split packet
$Packets = [];
$IsCompressed = false;
$PacketChecksum = null;
do {
$RequestID = $Buffer->GetLong();
switch ($this->Engine) {
case SourceQuery::GOLDSOURCE:
{
$PacketCountAndNumber = $Buffer->GetByte();
$PacketCount = $PacketCountAndNumber & 0xF;
$PacketNumber = $PacketCountAndNumber >> 4;
break;
}
case SourceQuery::SOURCE:
{
$IsCompressed = ($RequestID & 0x80000000) !== 0;
$PacketCount = $Buffer->GetByte();
$PacketNumber = $Buffer->GetByte() + 1;
if ($IsCompressed) {
$Buffer->GetLong(); // Split size
$PacketChecksum = $Buffer->GetUnsignedLong();
} else {
$Buffer->GetShort(); // Split size
}
break;
}
default:
{
throw new SocketException('Unknown engine.', SocketException::INVALID_ENGINE);
}
}
$Packets[ $PacketNumber ] = $Buffer->Get();
$ReadMore = $PacketCount > count($Packets);
} while ($ReadMore && $SherlockFunction($Buffer, $Length));
$Data = implode($Packets);
// TODO: Test this
if ($IsCompressed) {
$Data = bzdecompress($Data);
if (!is_string($Data) || crc32($Data) !== $PacketChecksum) {
throw new InvalidPacketException('CRC32 checksum mismatch of uncompressed packet data.', InvalidPacketException::CHECKSUM_MISMATCH);
}
}
$Buffer->Set(substr($Data, 4));
} else {
throw new InvalidPacketException('Socket read: Raw packet header mismatch. (0x' . dechex($Header) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
return $Buffer;
}
}

@ -29,28 +29,28 @@ final class Buffer
/** /**
* Buffer * Buffer
*/ */
private string $Buffer = ''; private string $buffer = '';
/** /**
* Buffer length * Buffer length
*/ */
private int $Length = 0; private int $length = 0;
/** /**
* Current position in buffer * Current position in buffer
*/ */
private int $Position = 0; private int $position = 0;
/** /**
* Sets buffer * Sets buffer
* *
* @param string $Buffer * @param string $buffer
*/ */
public function Set(string $Buffer): void public function set(string $buffer): void
{ {
$this->Buffer = $Buffer; $this->buffer = $buffer;
$this->Length = strlen($Buffer); $this->length = strlen($buffer);
$this->Position = 0; $this->position = 0;
} }
/** /**
@ -58,9 +58,9 @@ final class Buffer
* *
* @return int Remaining bytes in buffer * @return int Remaining bytes in buffer
*/ */
public function Remaining(): int public function remaining(): int
{ {
return $this->Length - $this->Position; return $this->length - $this->position;
} }
/** /**
@ -68,43 +68,43 @@ final class Buffer
*/ */
public function isEmpty(): bool public function isEmpty(): bool
{ {
return $this->Remaining() <= 0; return $this->remaining() <= 0;
} }
/** /**
* Gets data from buffer * Gets data from buffer
* *
* @param int $Length Bytes to read * @param int $length Bytes to read
* *
* @return string * @return string
*/ */
public function Get(int $Length = -1): string public function get(int $length = -1): string
{ {
if ($Length === 0) { if ($length === 0) {
return ''; return '';
} }
$Remaining = $this->Remaining(); $remaining = $this->remaining();
if ($Length === -1) { if ($length === -1) {
$Length = $Remaining; $length = $remaining;
} elseif ($Length > $Remaining) { } elseif ($length > $remaining) {
return ''; return '';
} }
$Data = substr($this->Buffer, $this->Position, $Length); $data = substr($this->buffer, $this->position, $length);
$this->Position += $Length; $this->position += $length;
return $Data; return $data;
} }
/** /**
* Get byte from buffer * Get byte from buffer
*/ */
public function GetByte(): int public function getByte(): int
{ {
return ord($this->Get(1)); return ord($this->get(1));
} }
/** /**
@ -112,15 +112,15 @@ final class Buffer
* *
* @throws InvalidPacketException * @throws InvalidPacketException
*/ */
public function GetShort(): int public function getShort(): int
{ {
if ($this->Remaining() < 2) { if ($this->remaining() < 2) {
throw new InvalidPacketException('Not enough data to unpack a short.', InvalidPacketException::BUFFER_EMPTY); throw new InvalidPacketException('Not enough data to unpack a short.', InvalidPacketException::BUFFER_EMPTY);
} }
$Data = unpack('v', $this->Get(2)); $data = unpack('v', $this->get(2));
return (int)$Data[ 1 ]; return (int)$data[ 1 ];
} }
/** /**
@ -128,15 +128,15 @@ final class Buffer
* *
* @throws InvalidPacketException * @throws InvalidPacketException
*/ */
public function GetLong(): int public function getLong(): int
{ {
if ($this->Remaining() < 4) { if ($this->remaining() < 4) {
throw new InvalidPacketException('Not enough data to unpack a long.', InvalidPacketException::BUFFER_EMPTY); throw new InvalidPacketException('Not enough data to unpack a long.', InvalidPacketException::BUFFER_EMPTY);
} }
$Data = unpack('l', $this->Get(4)); $data = unpack('l', $this->get(4));
return (int)$Data[ 1 ]; return (int)$data[ 1 ];
} }
/** /**
@ -144,15 +144,15 @@ final class Buffer
* *
* @throws InvalidPacketException * @throws InvalidPacketException
*/ */
public function GetFloat(): float public function getFloat(): float
{ {
if ($this->Remaining() < 4) { if ($this->remaining() < 4) {
throw new InvalidPacketException('Not enough data to unpack a float.', InvalidPacketException::BUFFER_EMPTY); throw new InvalidPacketException('Not enough data to unpack a float.', InvalidPacketException::BUFFER_EMPTY);
} }
$Data = unpack('f', $this->Get(4)); $data = unpack('f', $this->get(4));
return (float)$Data[ 1 ]; return (float)$data[ 1 ];
} }
/** /**
@ -160,32 +160,32 @@ final class Buffer
* *
* @throws InvalidPacketException * @throws InvalidPacketException
*/ */
public function GetUnsignedLong(): int public function getUnsignedLong(): int
{ {
if ($this->Remaining() < 4) { if ($this->remaining() < 4) {
throw new InvalidPacketException('Not enough data to unpack an usigned long.', InvalidPacketException::BUFFER_EMPTY); throw new InvalidPacketException('Not enough data to unpack an usigned long.', InvalidPacketException::BUFFER_EMPTY);
} }
$Data = unpack('V', $this->Get(4)); $data = unpack('V', $this->get(4));
return (int)$Data[ 1 ]; return (int)$data[ 1 ];
} }
/** /**
* Read one string from buffer ending with null byte * Read one string from buffer ending with null byte
*/ */
public function GetString(): string public function getString(): string
{ {
$ZeroBytePosition = strpos($this->Buffer, "\0", $this->Position); $zeroBytePosition = strpos($this->buffer, "\0", $this->position);
if ($ZeroBytePosition === false) { if ($zeroBytePosition === false) {
return ''; return '';
} }
$String = $this->Get($ZeroBytePosition - $this->Position); $string = $this->get($zeroBytePosition - $this->position);
$this->Position++; $this->position++;
return $String; return $string;
} }
} }

@ -1,171 +0,0 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
/**
* Class GoldSourceRcon
*
* @package xPaw\SourceQuery
*
* @uses AuthenticationException
* @uses InvalidPacketException
*/
final class GoldSourceRcon
{
/**
* Points to socket class
*
* @var BaseSocket
*/
private BaseSocket $Socket;
/**
* @var string $RconPassword
*/
private string $RconPassword = '';
/**
* @var string $RconChallenge
*/
private string $RconChallenge = '';
/**
* @param BaseSocket $Socket
*/
public function __construct(BaseSocket $Socket)
{
$this->Socket = $Socket;
}
/**
* Close
*/
public function Close(): void
{
$this->RconChallenge = '';
$this->RconPassword = '';
}
/**
* Open
*/
public function Open(): void
{
}
/**
* @param string $String
*
* @return bool
*/
public function Write(string $String = ''): bool
{
$Command = pack('cccca*', 0xFF, 0xFF, 0xFF, 0xFF, $String);
$Length = strlen($Command);
return $Length === fwrite($this->Socket->Socket, $Command, $Length);
}
/**
* @throws AuthenticationException
* @throws InvalidPacketException
*
* @return Buffer
*/
public function Read(): Buffer
{
// GoldSource RCON has same structure as Query
$Buffer = $this->Socket->Read();
$StringBuffer = '';
// There is no indentifier of the end, so we just need to continue reading
do {
$ReadMore = $Buffer->Remaining() > 0;
if ($ReadMore) {
if ($Buffer->GetByte() !== SourceQuery::S2A_RCON) {
throw new InvalidPacketException('Invalid rcon response.', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
$Packet = $Buffer->Get();
$StringBuffer .= $Packet;
//$StringBuffer .= SubStr( $Packet, 0, -2 );
// Let's assume if this packet is not long enough, there are no more after this one
$ReadMore = strlen($Packet) > 1000; // use 1300?
if ($ReadMore) {
$Buffer = $this->Socket->Read();
}
}
} while ($ReadMore);
$Trimmed = trim($StringBuffer);
if ($Trimmed === 'Bad rcon_password.') {
throw new AuthenticationException($Trimmed, AuthenticationException::BAD_PASSWORD);
} elseif ($Trimmed === 'You have been banned from this server.') {
throw new AuthenticationException($Trimmed, AuthenticationException::BANNED);
}
$Buffer->Set($Trimmed);
return $Buffer;
}
/**
* @param string $Command
*
* @return string
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function Command(string $Command): string
{
if (!$this->RconChallenge) {
throw new AuthenticationException('Tried to execute a RCON command before successful authorization.', AuthenticationException::BAD_PASSWORD);
}
$this->Write('rcon ' . $this->RconChallenge . ' "' . $this->RconPassword . '" ' . $Command . "\0");
$Buffer = $this->Read();
return $Buffer->Get();
}
/**
* @param string $Password
*
* @throws AuthenticationException
*/
public function Authorize(string $Password): void
{
$this->RconPassword = $Password;
$this->Write('challenge rcon');
$Buffer = $this->Socket->Read();
if ($Buffer->Get(14) !== 'challenge rcon') {
throw new AuthenticationException('Failed to get RCON challenge.', AuthenticationException::BAD_PASSWORD);
}
$this->RconChallenge = trim($Buffer->Get());
}
}

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Rcon;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
abstract class AbstractRcon implements RconInterface
{
/**
* @param int|null $header
* @param string $string
*
* @return bool
*/
abstract protected function write(?int $header, string $string = ''): bool;
/**
* @throws AuthenticationException
* @throws InvalidPacketException
*
* @return Buffer
*/
abstract protected function read(): Buffer;
}

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery\Rcon;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Socket\SocketInterface;
use xPaw\SourceQuery\SourceQuery;
/**
* Class GoldSourceRcon
*
* @package xPaw\SourceQuery
*
* @uses AuthenticationException
* @uses InvalidPacketException
*/
final class GoldSourceRcon extends AbstractRcon
{
/**
* Points to socket class
*
* @var SocketInterface
*/
private SocketInterface $socket;
/**
* @var string
*/
private string $rconPassword = '';
/**
* @var string
*/
private string $rconChallenge = '';
/**
* @param SocketInterface $socket
*/
public function __construct(SocketInterface $socket)
{
$this->socket = $socket;
}
/**
* Close
*/
public function close(): void
{
$this->rconChallenge = '';
$this->rconPassword = '';
}
/**
* Open
*/
public function open(): void
{
}
/**
* @param string $command
*
* @return string
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function command(string $command): string
{
if (!$this->rconChallenge) {
throw new AuthenticationException('Tried to execute a RCON command before successful authorization.', AuthenticationException::BAD_PASSWORD);
}
$this->write(null, 'rcon ' . $this->rconChallenge . ' "' . $this->rconPassword . '" ' . $command . "\0");
$buffer = $this->read();
return $buffer->get();
}
/**
* @param string $password
*
* @throws AuthenticationException
*/
public function authorize(string $password): void
{
$this->rconPassword = $password;
$this->write(null, 'challenge rcon');
$buffer = $this->socket->read();
if ($buffer->get(14) !== 'challenge rcon') {
throw new AuthenticationException('Failed to get RCON challenge.', AuthenticationException::BAD_PASSWORD);
}
$this->rconChallenge = trim($buffer->get());
}
/**
* @param int|null $header
* @param string $string
*
* @return bool
*/
protected function write(?int $header, string $string = ''): bool
{
$command = pack('cccca*', 0xFF, 0xFF, 0xFF, 0xFF, $string);
$length = strlen($command);
return $length === fwrite($this->socket->getSocket(), $command, $length);
}
/**
* @throws AuthenticationException
* @throws InvalidPacketException
*
* @return Buffer
*/
protected function read(): Buffer
{
// GoldSource RCON has same structure as Query
$buffer = $this->socket->read();
$stringBuffer = '';
// There is no indentifier of the end, so we just need to continue reading
do {
$readMore = $buffer->remaining() > 0;
if ($readMore) {
if ($buffer->getByte() !== SourceQuery::S2A_RCON) {
throw new InvalidPacketException('Invalid rcon response.', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
$packet = $buffer->get();
$stringBuffer .= $packet;
//$stringBuffer .= SubStr( $packet, 0, -2 );
// Let's assume if this packet is not long enough, there are no more after this one
$readMore = strlen($packet) > 1000; // use 1300?
if ($readMore) {
$buffer = $this->socket->read();
}
}
} while ($readMore);
$trimmed = trim($stringBuffer);
if ($trimmed === 'Bad rcon_password.') {
throw new AuthenticationException($trimmed, AuthenticationException::BAD_PASSWORD);
} elseif ($trimmed === 'You have been banned from this server.') {
throw new AuthenticationException($trimmed, AuthenticationException::BANNED);
}
$buffer->set($trimmed);
return $buffer;
}
}

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Rcon;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Socket\SocketInterface;
interface RconInterface
{
/**
* @param SocketInterface $socket
*/
public function __construct(SocketInterface $socket);
/**
* Close
*/
public function close(): void;
/**
* Open
*/
public function open(): void;
/**
* @param string $command
*
* @return string
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function command(string $command): string;
/**
* @param string $password
*
* @throws AuthenticationException
*/
public function authorize(string $password): void;
}

@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery\Rcon;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException;
use xPaw\SourceQuery\Socket\SocketInterface;
use xPaw\SourceQuery\SourceQuery;
/**
* Class SourceRcon
*
* @package xPaw\SourceQuery
*
* @uses AuthenticationException
* @uses InvalidPacketException
* @uses SocketException
*/
final class SourceRcon extends AbstractRcon
{
/**
* Points to socket class
*/
private SocketInterface $socket;
/**
* @var ?resource
*/
private $rconSocket;
/**
* @var int
*/
private int $rconRequestId = 0;
/**
* @param SocketInterface $socket
*/
public function __construct(SocketInterface $socket)
{
$this->socket = $socket;
}
/**
* Close
*/
public function close(): void
{
if ($this->rconSocket) {
fclose($this->rconSocket);
$this->rconSocket = null;
}
$this->rconRequestId = 0;
}
/**
* @throws SocketException
*/
public function open(): void
{
if (!$this->rconSocket) {
$rconSocket = @fsockopen(
$this->socket->getAddress(),
$this->socket->getPort(),
$errNo,
$errStr,
$this->socket->getTimeout()
);
if ($errNo || !$rconSocket) {
throw new SocketException('Can\'t connect to RCON server: ' . $errStr, SocketException::CONNECTION_FAILED);
}
$this->rconSocket = $rconSocket;
stream_set_timeout($this->rconSocket, $this->socket->getTimeout());
stream_set_blocking($this->rconSocket, true);
}
}
/**
* @param string $command
*
* @return string
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function command(string $command): string
{
$this->write(SourceQuery::SERVERDATA_EXECCOMMAND, $command);
$buffer = $this->read();
$buffer->getLong(); // RequestID
$type = $buffer->getLong();
if ($type === SourceQuery::SERVERDATA_AUTH_RESPONSE) {
throw new AuthenticationException('Bad rcon_password.', AuthenticationException::BAD_PASSWORD);
} elseif ($type !== SourceQuery::SERVERDATA_RESPONSE_VALUE) {
throw new InvalidPacketException('Invalid rcon response.', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
$data = $buffer->get();
// We do this stupid hack to handle split packets
// See https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Multiple-packet_Responses
if (strlen($data) >= 4000) {
$this->write(SourceQuery::SERVERDATA_REQUESTVALUE);
do {
$buffer = $this->read();
$buffer->getLong(); // RequestID
if ($buffer->getLong() !== SourceQuery::SERVERDATA_RESPONSE_VALUE) {
break;
}
$data2 = $buffer->get();
if ($data2 === "\x00\x01\x00\x00\x00\x00") {
break;
}
$data .= $data2;
} while (true);
}
return rtrim($data, "\0");
}
/**
* @param string $password
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function authorize(string $password): void
{
$this->write(SourceQuery::SERVERDATA_AUTH, $password);
$buffer = $this->read();
$requestId = $buffer->getLong();
$type = $buffer->getLong();
// If we receive SERVERDATA_RESPONSE_VALUE, then we need to read again
// More info: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Additional_Comments
if ($type === SourceQuery::SERVERDATA_RESPONSE_VALUE) {
$buffer = $this->read();
$requestId = $buffer->getLong();
$type = $buffer->getLong();
}
if ($requestId === -1 || $type !== SourceQuery::SERVERDATA_AUTH_RESPONSE) {
throw new AuthenticationException('RCON authorization failed.', AuthenticationException::BAD_PASSWORD);
}
}
/**
* @param int|null $header
* @param string $string
*
* @return bool
*/
protected function write(?int $header, string $string = ''): bool
{
// Pack the packet together
$command = pack('VV', ++$this->rconRequestId, $header) . $string . "\x00\x00";
// Prepend packet length
$command = pack('V', strlen($command)) . $command;
$length = strlen($command);
return $length === fwrite($this->rconSocket, $command, $length);
}
/**
* @return Buffer
*
* @throws InvalidPacketException
*/
protected function read(): Buffer
{
$buffer = new Buffer();
$buffer->set(fread($this->rconSocket, 4));
if ($buffer->remaining() < 4) {
throw new InvalidPacketException('Rcon read: Failed to read any data from socket', InvalidPacketException::BUFFER_EMPTY);
}
$packetSize = $buffer->getLong();
$buffer->set(fread($this->rconSocket, $packetSize));
$data = $buffer->get();
$remaining = $packetSize - strlen($data);
while ($remaining > 0) {
$data2 = fread($this->rconSocket, $remaining);
$packetSize = strlen($data2);
if ($packetSize === 0) {
throw new InvalidPacketException('Read ' . strlen($data) . ' bytes from socket, ' . $remaining . ' remaining', InvalidPacketException::BUFFER_EMPTY);
}
$data .= $data2;
$remaining -= $packetSize;
}
$buffer->set($data);
return $buffer;
}
}

@ -1,130 +0,0 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException;
/**
* Class Socket
*
* @package xPaw\SourceQuery
*
* @uses InvalidPacketException
* @uses SocketException
*/
final class Socket extends BaseSocket
{
/**
* Close
*/
public function Close(): void
{
if ($this->Socket !== null && $this->Socket !== 0) {
fclose($this->Socket);
$this->Socket = null;
}
}
/**
* @param string $Address
* @param int $Port
* @param int $Timeout
* @param int $Engine
*
* @throws SocketException
*/
public function Open(string $Address, int $Port, int $Timeout, int $Engine): void
{
$this->Timeout = $Timeout;
$this->Engine = $Engine;
$this->Port = $Port;
$this->Address = $Address;
$Socket = @fsockopen('udp://' . $Address, $Port, $ErrNo, $ErrStr, $Timeout);
if ($ErrNo || $Socket === false) {
throw new SocketException('Could not create socket: ' . $ErrStr, SocketException::COULD_NOT_CREATE_SOCKET);
}
$this->Socket = $Socket;
stream_set_timeout($this->Socket, $Timeout);
stream_set_blocking($this->Socket, true);
}
/**
* @param int $Header
* @param string $String
*
* @return bool
*/
public function Write(int $Header, string $String = ''): bool
{
$Command = pack('ccccca*', 0xFF, 0xFF, 0xFF, 0xFF, $Header, $String);
$Length = strlen($Command);
return $Length === fwrite($this->Socket, $Command, $Length);
}
/**
* Reads from socket and returns Buffer.
*
* @param int $Length
*
* @return Buffer Buffer
*
* @throws InvalidPacketException
* @throws SocketException
*
*/
public function Read(int $Length = 1400): Buffer
{
$Buffer = new Buffer();
$data = fread($this->Socket, $Length);
if (!$data) {
throw new SocketException('Failed to open socket.');
}
$Buffer->Set($data);
$this->ReadInternal($Buffer, $Length, [ $this, 'Sherlock' ]);
return $Buffer;
}
/**
* @param Buffer $Buffer
* @param int $Length
*
* @return bool
*
* @throws InvalidPacketException
*/
public function Sherlock(Buffer $Buffer, int $Length): bool
{
$Data = fread($this->Socket, $Length);
if (strlen($Data) < 4) {
return false;
}
$Buffer->Set($Data);
return $Buffer->GetLong() === -2;
}
}

@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery\Socket;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\InvalidArgumentException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException;
/**
* Base socket
*
* @package xPaw\SourceQuery
*
* @uses InvalidPacketException
* @uses SocketException
*/
abstract class AbstractSocket implements SocketInterface
{
/**
* @var resource|null
*/
public $socket;
/**
* @var int
*/
public int $engine = SocketType::SOURCE;
/**
* @var string $address
*/
public string $address = '';
/**
* @var int $port
*/
public int $port = 0;
/**
* @var int $timeout
*/
public int $timeout = 0;
/**
* Destructor
*/
public function __destruct()
{
$this->close();
}
/**
* @return string
*/
public function getAddress(): string
{
return $this->address;
}
/**
* @return int
*/
public function getPort(): int
{
return $this->port;
}
/**
* @return int
*/
public function getTimeout(): int
{
return $this->timeout;
}
/**
* @return resource
*
* @throws InvalidArgumentException
*/
public function getSocket()
{
if (!$this->socket) {
throw new InvalidArgumentException('Socket not open.');
}
return $this->socket;
}
/**
* Close
*/
public function close(): void
{
if ($this->socket) {
fclose($this->socket);
$this->socket = null;
}
}
/**
* @param string $address
* @param int $port
* @param int $timeout
* @param int $engine
*
* @throws SocketException
*/
public function open(string $address, int $port, int $timeout, int $engine): void
{
$this->timeout = $timeout;
$this->engine = $engine;
$this->port = $port;
$this->address = $address;
$socket = @fsockopen('udp://' . $address, $port, $errNo, $errStr, $timeout);
if ($errNo || $socket === false) {
throw new SocketException('Could not create socket: ' . $errStr, SocketException::COULD_NOT_CREATE_SOCKET);
}
$this->socket = $socket;
stream_set_timeout($this->socket, $timeout);
stream_set_blocking($this->socket, true);
}
/**
* @param int $header
* @param string $string
*
* @return bool
*/
public function write(int $header, string $string = ''): bool
{
$command = pack('ccccca*', 0xFF, 0xFF, 0xFF, 0xFF, $header, $string);
$length = strlen($command);
return $length === fwrite($this->socket, $command, $length);
}
/**
* Reads from socket and returns Buffer.
*
* @param int $length
*
* @return Buffer Buffer
*
* @throws InvalidPacketException
* @throws SocketException
*
*/
public function read(int $length = 1400): Buffer
{
$buffer = new Buffer();
$data = fread($this->socket, $length);
if (!$data) {
throw new SocketException('Failed to open socket.');
}
$buffer->set($data);
$this->readInternal($buffer, $length, [ $this, 'sherlock' ]);
return $buffer;
}
/**
* @param Buffer $buffer
* @param int $length
*
* @return bool
*
* @throws InvalidPacketException
*/
public function sherlock(Buffer $buffer, int $length): bool
{
$data = fread($this->socket, $length);
if (strlen($data) < 4) {
return false;
}
$buffer->set($data);
return $buffer->getLong() === -2;
}
/**
*
* Get packet data (count, number, checksum) from the buffer. Different for goldsrc/src.
*
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*/
abstract protected function readInternalPacketData(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void;
/**
* @param Buffer $buffer
* @param int $length
* @param callable $sherlockFunction
*
* @return Buffer
*
* @throws InvalidPacketException
*/
protected function readInternal(Buffer $buffer, int $length, callable $sherlockFunction): Buffer
{
if ($buffer->remaining() === 0) {
throw new InvalidPacketException('Failed to read any data from socket', InvalidPacketException::BUFFER_EMPTY);
}
$header = $buffer->getLong();
// Single packet, do nothing.
if ($header === -1) {
return $buffer;
}
if ($header === -2) { // Split packet
$packets = [];
$packetCount = 0;
$packetNumber = 0;
$packetChecksum = null;
do {
$requestId = $buffer->getLong();
$isCompressed = ($requestId & 0x80000000) !== 0;
$this->readInternalPacketData(
$buffer,
$packetCount,
$packetNumber,
$isCompressed,
$packetChecksum
);
$packets[$packetNumber] = $buffer->get();
$readMore = $packetCount > count($packets);
} while ($readMore && $sherlockFunction($buffer, $length));
$data = implode($packets);
// TODO: Test this
if ($isCompressed) {
$data = bzdecompress($data);
if (!is_string($data) || crc32($data) !== $packetChecksum) {
throw new InvalidPacketException('CRC32 checksum mismatch of uncompressed packet data.', InvalidPacketException::CHECKSUM_MISMATCH);
}
}
$buffer->set(substr($data, 4));
} else {
throw new InvalidPacketException('Socket read: Raw packet header mismatch. (0x' . dechex($header) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
return $buffer;
}
}

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Socket;
use xPaw\SourceQuery\Buffer;
final class GoldSourceSocket extends AbstractSocket
{
/**
* @return int
*/
public function getType(): int
{
return SocketType::GOLDSOURCE;
}
/**
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*/
protected function readInternalPacketData(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void {
$packetCountAndNumber = $buffer->getByte();
$count = $packetCountAndNumber & 0xF;
$number = $packetCountAndNumber >> 4;
$isCompressed = false;
}
}

@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery\Socket;
use xPaw\SourceQuery\Buffer;
/**
* Base socket interface
*
* @package xPaw\SourceQuery\Socket
*/
interface SocketInterface
{
/**
* @return string
*/
public function getAddress(): string;
/**
* @return int
*/
public function getPort(): int;
/**
* @return int
*/
public function getTimeout(): int;
/**
* @return resource
*/
public function getSocket();
/**
* Get the socket type (goldsrc/src).
*/
public function getType(): int;
/**
* Close
*/
public function close(): void;
/**
* @param string $address
* @param int $port
* @param int $timeout
* @param int $engine
*/
public function open(string $address, int $port, int $timeout, int $engine): void;
/**
* @param int $header
* @param string $string
*
* @return bool
*/
public function write(int $header, string $string = ''): bool;
/**
* @param int $length
*
* @return Buffer
*/
public function read(int $length = 1400): Buffer;
}

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Socket;
abstract class SocketType
{
public const GOLDSOURCE = 0;
public const SOURCE = 1;
}

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Socket;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\InvalidPacketException;
final class SourceSocket extends AbstractSocket
{
/**
* @return int
*/
public function getType(): int
{
return SocketType::SOURCE;
}
/**
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*
* @throws InvalidPacketException
*/
protected function readInternalPacketData(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void {
$count = $buffer->getByte();
$number = $buffer->getByte() + 1;
if ($isCompressed) {
$buffer->getLong(); // Split size
$checksum = $buffer->getUnsignedLong();
} else {
$buffer->getShort(); // Split size
}
}
}

@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace xPaw\SourceQuery\Socket;
use SplQueue;
use SplDoublyLinkedList;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\InvalidPacketException;
final class TestableSocket extends AbstractSocket
{
/**
* @var SplQueue<string>
*/
private SplQueue $packetQueue;
/**
* @var int
*/
private int $type;
/**
* TestableSocket constructor.
*
* @param int $type
*/
public function __construct(int $type)
{
$this->packetQueue = new SplQueue();
$this->packetQueue->setIteratorMode(SplDoublyLinkedList::IT_MODE_DELETE);
$this->type = $type;
}
/**
* @return int
*/
public function getType(): int
{
return $this->type;
}
/**
* @param string $data
*/
public function queue(string $data): void
{
$this->packetQueue->push($data);
}
/**
* Close.
*/
public function close(): void
{
}
/**
* @param string $address
* @param int $port
* @param int $timeout
* @param int $engine
*/
public function open(string $address, int $port, int $timeout, int $engine): void
{
$this->timeout = $timeout;
$this->engine = $engine;
$this->port = $port;
$this->address = $address;
}
/**
* @param int $header
* @param string $string
*
* @return bool
*/
public function write(int $header, string $string = ''): bool
{
return true;
}
/**
* @param int $length
*
* @return Buffer
*
* @throws InvalidPacketException
*/
public function read(int $length = 1400): Buffer
{
$buffer = new Buffer();
$buffer->set($this->packetQueue->shift());
$this->readInternal($buffer, $length, [ $this, 'sherlock' ]);
return $buffer;
}
/**
* @param Buffer $buffer
* @param int $length
*
* @return bool
*
* @throws InvalidPacketException
*/
public function sherlock(Buffer $buffer, int $length): bool
{
if ($this->packetQueue->isEmpty()) {
return false;
}
$buffer->set($this->packetQueue->shift());
return $buffer->getLong() === -2;
}
/**
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*
* @throws InvalidPacketException
*/
protected function readInternalPacketData(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void {
switch ($this->type) {
case SocketType::GOLDSOURCE:
$this->readInternalPacketDataGoldSource(
$buffer,
$count,
$number,
$isCompressed,
$checksum
);
break;
case SocketType::SOURCE:
default:
$this->readInternalPacketDataSource(
$buffer,
$count,
$number,
$isCompressed,
$checksum
);
}
}
/**
* Same as GoldSourceSocket::readInternalPacketData.
*
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*/
protected function readInternalPacketDataGoldSource(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void {
$packetCountAndNumber = $buffer->getByte();
$count = $packetCountAndNumber & 0xF;
$number = $packetCountAndNumber >> 4;
$isCompressed = false;
}
/**
* Same as SourceSocket::readInternalPacketData.
*
* @param Buffer $buffer
* @param int $count
* @param int $number
* @param bool $isCompressed
* @param int|null $checksum
*
* @throws InvalidPacketException
*/
protected function readInternalPacketDataSource(
Buffer $buffer,
int &$count,
int &$number,
bool &$isCompressed,
?int &$checksum
): void {
$count = $buffer->getByte();
$number = $buffer->getByte() + 1;
if ($isCompressed) {
$buffer->getLong(); // Split size
$checksum = $buffer->getUnsignedLong();
} else {
$buffer->getShort(); // Split size
}
}
}

@ -20,6 +20,11 @@ use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidArgumentException; use xPaw\SourceQuery\Exception\InvalidArgumentException;
use xPaw\SourceQuery\Exception\InvalidPacketException; use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException; use xPaw\SourceQuery\Exception\SocketException;
use xPaw\SourceQuery\Rcon\GoldSourceRcon;
use xPaw\SourceQuery\Rcon\RconInterface;
use xPaw\SourceQuery\Rcon\SourceRcon;
use xPaw\SourceQuery\Socket\SocketInterface;
use xPaw\SourceQuery\Socket\SocketType;
/** /**
* Class SourceQuery * Class SourceQuery
@ -33,12 +38,6 @@ use xPaw\SourceQuery\Exception\SocketException;
*/ */
final class SourceQuery final class SourceQuery
{ {
/**
* Engines
*/
public const GOLDSOURCE = 0;
public const SOURCE = 1;
/** /**
* Packets sent * Packets sent
*/ */
@ -75,36 +74,36 @@ final class SourceQuery
/** /**
* Points to rcon class * Points to rcon class
* *
* @var SourceRcon|GoldSourceRcon|null * @var RconInterface|null
*/ */
private $Rcon; private $rcon;
/** /**
* Points to socket class * Points to socket class
*/ */
private BaseSocket $Socket; private SocketInterface $socket;
/** /**
* True if connection is open, false if not * True if connection is open, false if not
*/ */
private bool $Connected = false; private bool $connected = false;
/** /**
* Contains challenge * Contains challenge
*/ */
private string $Challenge = ''; private string $challenge = '';
/** /**
* Use old method for getting challenge number * Use old method for getting challenge number
*/ */
private bool $UseOldGetChallengeMethod = false; private bool $useOldGetChallengeMethod = false;
/** /**
* @param BaseSocket|null $Socket * @param SocketInterface $socket
*/ */
public function __construct(BaseSocket $Socket = null) public function __construct(SocketInterface $socket)
{ {
$this->Socket = $Socket ?: new Socket(); $this->socket = $socket;
} }
/** /**
@ -112,63 +111,63 @@ final class SourceQuery
*/ */
public function __destruct() public function __destruct()
{ {
$this->Disconnect(); $this->disconnect();
} }
/** /**
* Opens connection to server * Opens connection to server
* *
* @param string $Address Server ip * @param string $address Server ip
* @param int $Port Server port * @param int $port Server port
* @param int $Timeout Timeout period * @param int $timeout Timeout period
* @param int $Engine Engine the server runs on (goldsource, source) * @param int $engine Engine the server runs on (goldsource, source)
* *
* @throws InvalidArgumentException * @throws InvalidArgumentException
* @throws SocketException * @throws SocketException
*/ */
public function Connect(string $Address, int $Port, int $Timeout = 3, int $Engine = self::SOURCE): void public function connect(string $address, int $port, int $timeout = 3, int $engine = SocketType::SOURCE): void
{ {
$this->Disconnect(); $this->disconnect();
if ($Timeout < 0) { if ($timeout < 0) {
throw new InvalidArgumentException('Timeout must be a positive integer.', InvalidArgumentException::TIMEOUT_NOT_INTEGER); throw new InvalidArgumentException('Timeout must be a positive integer.', InvalidArgumentException::TIMEOUT_NOT_INTEGER);
} }
$this->Socket->Open($Address, $Port, $Timeout, $Engine); $this->socket->open($address, $port, $timeout, $engine);
$this->Connected = true; $this->connected = true;
} }
/** /**
* Forces GetChallenge to use old method for challenge retrieval because some games use outdated protocol (e.g Starbound) * Forces GetChallenge to use old method for challenge retrieval because some games use outdated protocol (e.g Starbound)
* *
* @param bool $Value Set to true to force old method * @param bool $value Set to true to force old method
* *
* @return bool Previous value * @return bool Previous value
*/ */
public function SetUseOldGetChallengeMethod(bool $Value): bool public function SetUseOldGetChallengeMethod(bool $value): bool
{ {
$Previous = $this->UseOldGetChallengeMethod; $previous = $this->useOldGetChallengeMethod;
$this->UseOldGetChallengeMethod = $Value === true; $this->useOldGetChallengeMethod = $value === true;
return $Previous; return $previous;
} }
/** /**
* Closes all open connections * Closes all open connections
*/ */
public function Disconnect(): void public function disconnect(): void
{ {
$this->Connected = false; $this->connected = false;
$this->Challenge = ''; $this->challenge = '';
$this->Socket->Close(); $this->socket->close();
if ($this->Rcon) { if ($this->rcon) {
$this->Rcon->Close(); $this->rcon->close();
$this->Rcon = null; $this->rcon = null;
} }
} }
@ -181,16 +180,16 @@ final class SourceQuery
* *
* @return bool True on success, false on failure * @return bool True on success, false on failure
*/ */
public function Ping(): bool public function ping(): bool
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
$this->Socket->Write(self::A2A_PING); $this->socket->write(self::A2A_PING);
$Buffer = $this->Socket->Read(); $buffer = $this->socket->read();
return $Buffer->GetByte() === self::A2A_ACK; return $buffer->getByte() === self::A2A_ACK;
} }
/** /**
@ -201,154 +200,154 @@ final class SourceQuery
* *
* @return array Returns an array with information on success * @return array Returns an array with information on success
*/ */
public function GetInfo(): array public function getInfo(): array
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
if ($this->Challenge) { if ($this->challenge) {
$this->Socket->Write(self::A2S_INFO, "Source Engine Query\0" . $this->Challenge); $this->socket->write(self::A2S_INFO, "Source Engine Query\0" . $this->challenge);
} else { } else {
$this->Socket->Write(self::A2S_INFO, "Source Engine Query\0"); $this->socket->write(self::A2S_INFO, "Source Engine Query\0");
} }
$Buffer = $this->Socket->Read(); $buffer = $this->socket->read();
$Type = $Buffer->GetByte(); $type = $buffer->getByte();
$Server = []; $server = [];
if ($Type === self::S2C_CHALLENGE) { if ($type === self::S2C_CHALLENGE) {
$this->Challenge = $Buffer->Get(4); $this->challenge = $buffer->get(4);
$this->Socket->Write(self::A2S_INFO, "Source Engine Query\0" . $this->Challenge); $this->socket->write(self::A2S_INFO, "Source Engine Query\0" . $this->challenge);
$Buffer = $this->Socket->Read(); $buffer = $this->socket->read();
$Type = $Buffer->GetByte(); $type = $buffer->getByte();
} }
// Old GoldSource protocol, HLTV still uses it // Old GoldSource protocol, HLTV still uses it
if ($Type === self::S2A_INFO_OLD && $this->Socket->Engine === self::GOLDSOURCE) { if ($type === self::S2A_INFO_OLD && $this->socket->getType() === SocketType::GOLDSOURCE) {
/** /**
* If we try to read data again, and we get the result with type S2A_INFO (0x49) * If we try to read data again, and we get the result with type S2A_INFO (0x49)
* That means this server is running dproto, * That means this server is running dproto,
* Because it sends answer for both protocols * Because it sends answer for both protocols
*/ */
$Server[ 'Address' ] = $Buffer->GetString(); $server[ 'Address' ] = $buffer->getString();
$Server[ 'HostName' ] = $Buffer->GetString(); $server[ 'HostName' ] = $buffer->getString();
$Server[ 'Map' ] = $Buffer->GetString(); $server[ 'Map' ] = $buffer->getString();
$Server[ 'ModDir' ] = $Buffer->GetString(); $server[ 'ModDir' ] = $buffer->getString();
$Server[ 'ModDesc' ] = $Buffer->GetString(); $server[ 'ModDesc' ] = $buffer->getString();
$Server[ 'Players' ] = $Buffer->GetByte(); $server[ 'Players' ] = $buffer->getByte();
$Server[ 'MaxPlayers' ] = $Buffer->GetByte(); $server[ 'MaxPlayers' ] = $buffer->getByte();
$Server[ 'Protocol' ] = $Buffer->GetByte(); $server[ 'Protocol' ] = $buffer->getByte();
$Server[ 'Dedicated' ] = chr($Buffer->GetByte()); $server[ 'Dedicated' ] = chr($buffer->getByte());
$Server[ 'Os' ] = chr($Buffer->GetByte()); $server[ 'Os' ] = chr($buffer->getByte());
$Server[ 'Password' ] = $Buffer->GetByte() === 1; $server[ 'Password' ] = $buffer->getByte() === 1;
$Server[ 'IsMod' ] = $Buffer->GetByte() === 1; $server[ 'IsMod' ] = $buffer->getByte() === 1;
if ($Server[ 'IsMod' ]) { if ($server[ 'IsMod' ]) {
$Mod = []; $Mod = [];
$Mod[ 'Url' ] = $Buffer->GetString(); $Mod[ 'Url' ] = $buffer->getString();
$Mod[ 'Download' ] = $Buffer->GetString(); $Mod[ 'Download' ] = $buffer->getString();
$Buffer->Get(1); // NULL byte $buffer->get(1); // NULL byte
$Mod[ 'Version' ] = $Buffer->GetLong(); $Mod[ 'Version' ] = $buffer->getLong();
$Mod[ 'Size' ] = $Buffer->GetLong(); $Mod[ 'Size' ] = $buffer->getLong();
$Mod[ 'ServerSide' ] = $Buffer->GetByte() === 1; $Mod[ 'ServerSide' ] = $buffer->getByte() === 1;
$Mod[ 'CustomDLL' ] = $Buffer->GetByte() === 1; $Mod[ 'CustomDLL' ] = $buffer->getByte() === 1;
$Server[ 'Mod' ] = $Mod; $server[ 'Mod' ] = $Mod;
} }
$Server[ 'Secure' ] = $Buffer->GetByte() === 1; $server[ 'Secure' ] = $buffer->getByte() === 1;
$Server[ 'Bots' ] = $Buffer->GetByte(); $server[ 'Bots' ] = $buffer->getByte();
return $Server; return $server;
} }
if ($Type !== self::S2A_INFO_SRC) { if ($type !== self::S2A_INFO_SRC) {
throw new InvalidPacketException('GetInfo: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); throw new InvalidPacketException('GetInfo: Packet header mismatch. (0x' . dechex($type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
} }
$Server[ 'Protocol' ] = $Buffer->GetByte(); $server[ 'Protocol' ] = $buffer->getByte();
$Server[ 'HostName' ] = $Buffer->GetString(); $server[ 'HostName' ] = $buffer->getString();
$Server[ 'Map' ] = $Buffer->GetString(); $server[ 'Map' ] = $buffer->getString();
$Server[ 'ModDir' ] = $Buffer->GetString(); $server[ 'ModDir' ] = $buffer->getString();
$Server[ 'ModDesc' ] = $Buffer->GetString(); $server[ 'ModDesc' ] = $buffer->getString();
$Server[ 'AppID' ] = $Buffer->GetShort(); $server[ 'AppID' ] = $buffer->getShort();
$Server[ 'Players' ] = $Buffer->GetByte(); $server[ 'Players' ] = $buffer->getByte();
$Server[ 'MaxPlayers' ] = $Buffer->GetByte(); $server[ 'MaxPlayers' ] = $buffer->getByte();
$Server[ 'Bots' ] = $Buffer->GetByte(); $server[ 'Bots' ] = $buffer->getByte();
$Server[ 'Dedicated' ] = chr($Buffer->GetByte()); $server[ 'Dedicated' ] = chr($buffer->getByte());
$Server[ 'Os' ] = chr($Buffer->GetByte()); $server[ 'Os' ] = chr($buffer->getByte());
$Server[ 'Password' ] = $Buffer->GetByte() === 1; $server[ 'Password' ] = $buffer->getByte() === 1;
$Server[ 'Secure' ] = $Buffer->GetByte() === 1; $server[ 'Secure' ] = $buffer->getByte() === 1;
// The Ship (they violate query protocol spec by modifying the response) // The Ship (they violate query protocol spec by modifying the response)
if ($Server[ 'AppID' ] === 2400) { if ($server[ 'AppID' ] === 2400) {
$Server[ 'GameMode' ] = $Buffer->GetByte(); $server[ 'GameMode' ] = $buffer->getByte();
$Server[ 'WitnessCount' ] = $Buffer->GetByte(); $server[ 'WitnessCount' ] = $buffer->getByte();
$Server[ 'WitnessTime' ] = $Buffer->GetByte(); $server[ 'WitnessTime' ] = $buffer->getByte();
} }
$Server[ 'Version' ] = $Buffer->GetString(); $server[ 'Version' ] = $buffer->getString();
// Extra Data Flags // Extra Data Flags
if ($Buffer->Remaining() > 0) { if ($buffer->remaining() > 0) {
$Server[ 'ExtraDataFlags' ] = $Flags = $Buffer->GetByte(); $server[ 'ExtraDataFlags' ] = $Flags = $buffer->getByte();
// S2A_EXTRA_DATA_HAS_GAME_PORT - Next 2 bytes include the game port. // S2A_EXTRA_DATA_HAS_GAME_PORT - Next 2 bytes include the game port.
if ($Flags & 0x80) { if ($Flags & 0x80) {
$Server[ 'GamePort' ] = $Buffer->GetShort(); $server[ 'GamePort' ] = $buffer->getShort();
} }
// S2A_EXTRA_DATA_HAS_STEAMID - Next 8 bytes are the steamID // S2A_EXTRA_DATA_HAS_STEAMID - Next 8 bytes are the steamID
// Want to play around with this? // Want to play around with this?
// You can use https://github.com/xPaw/SteamID.php // You can use https://github.com/xPaw/SteamID.php
if ($Flags & 0x10) { if ($Flags & 0x10) {
$SteamIDLower = $Buffer->GetUnsignedLong(); $steamIdLower = $buffer->getUnsignedLong();
$SteamIDInstance = $Buffer->GetUnsignedLong(); // This gets shifted by 32 bits, which should be steamid instance $steamIdInstance = $buffer->getUnsignedLong(); // This gets shifted by 32 bits, which should be steamid instance
if (PHP_INT_SIZE === 4) { if (PHP_INT_SIZE === 4) {
if (extension_loaded('gmp')) { if (extension_loaded('gmp')) {
$SteamIDLower = gmp_abs($SteamIDLower); $steamIdLower = gmp_abs($steamIdLower);
$SteamIDInstance = gmp_abs($SteamIDInstance); $steamIdInstance = gmp_abs($steamIdInstance);
$SteamID = gmp_strval(gmp_or($SteamIDLower, gmp_mul($SteamIDInstance, gmp_pow(2, 32)))); $steamId = gmp_strval(gmp_or($steamIdLower, gmp_mul($steamIdInstance, gmp_pow(2, 32))));
} else { } else {
throw new RuntimeException('Either 64-bit PHP installation or "gmp" module is required to correctly parse server\'s steamid.'); throw new RuntimeException('Either 64-bit PHP installation or "gmp" module is required to correctly parse server\'s steamid.');
} }
} else { } else {
$SteamID = $SteamIDLower | ($SteamIDInstance << 32); $steamId = $steamIdLower | ($steamIdInstance << 32);
} }
$Server[ 'SteamID' ] = $SteamID; $server[ 'SteamID' ] = $steamId;
unset($SteamIDLower, $SteamIDInstance, $SteamID); unset($steamIdLower, $steamIdInstance, $steamId);
} }
// S2A_EXTRA_DATA_HAS_SPECTATOR_DATA - Next 2 bytes include the spectator port, then the spectator server name. // S2A_EXTRA_DATA_HAS_SPECTATOR_DATA - Next 2 bytes include the spectator port, then the spectator server name.
if ($Flags & 0x40) { if ($Flags & 0x40) {
$Server[ 'SpecPort' ] = $Buffer->GetShort(); $server[ 'SpecPort' ] = $buffer->getShort();
$Server[ 'SpecName' ] = $Buffer->GetString(); $server[ 'SpecName' ] = $buffer->getString();
} }
// S2A_EXTRA_DATA_HAS_GAMETAG_DATA - Next bytes are the game tag string // S2A_EXTRA_DATA_HAS_GAMETAG_DATA - Next bytes are the game tag string
if ($Flags & 0x20) { if ($Flags & 0x20) {
$Server[ 'GameTags' ] = $Buffer->GetString(); $server[ 'GameTags' ] = $buffer->getString();
} }
// S2A_EXTRA_DATA_GAMEID - Next 8 bytes are the gameID of the server // S2A_EXTRA_DATA_GAMEID - Next 8 bytes are the gameID of the server
if ($Flags & 0x01) { if ($Flags & 0x01) {
$Server[ 'GameID' ] = $Buffer->GetUnsignedLong() | ($Buffer->GetUnsignedLong() << 32); $server[ 'GameID' ] = $buffer->getUnsignedLong() | ($buffer->getUnsignedLong() << 32);
} }
if (!$Buffer->isEmpty()) { if (!$buffer->isEmpty()) {
throw new InvalidPacketException( throw new InvalidPacketException(
'GetInfo: unread data? ' . $Buffer->Remaining() . ' bytes remaining in the buffer. Please report it to the library developer.', 'GetInfo: unread data? ' . $buffer->remaining() . ' bytes remaining in the buffer. Please report it to the library developer.',
InvalidPacketException::BUFFER_NOT_EMPTY InvalidPacketException::BUFFER_NOT_EMPTY
); );
} }
} }
return $Server; return $server;
} }
/** /**
@ -359,39 +358,39 @@ final class SourceQuery
* *
* @return array Returns an array with players on success * @return array Returns an array with players on success
*/ */
public function GetPlayers(): array public function getPlayers(): array
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
$this->GetChallenge(self::A2S_PLAYER, self::S2A_PLAYER); $this->getChallenge(self::A2S_PLAYER, self::S2A_PLAYER);
$this->Socket->Write(self::A2S_PLAYER, $this->Challenge); $this->socket->write(self::A2S_PLAYER, $this->challenge);
$Buffer = $this->Socket->Read(14000); // Moronic Arma 3 developers do not split their packets, so we have to read more data $buffer = $this->socket->read(14000); // Moronic Arma 3 developers do not split their packets, so we have to read more data
// This violates the protocol spec, and they probably should fix it: https://developer.valvesoftware.com/wiki/Server_queries#Protocol // This violates the protocol spec, and they probably should fix it: https://developer.valvesoftware.com/wiki/Server_queries#Protocol
$Type = $Buffer->GetByte(); $type = $buffer->getByte();
if ($Type !== self::S2A_PLAYER) { if ($type !== self::S2A_PLAYER) {
throw new InvalidPacketException('GetPlayers: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); throw new InvalidPacketException('GetPlayers: Packet header mismatch. (0x' . dechex($type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
} }
$Players = []; $players = [];
$Count = $Buffer->GetByte(); $count = $buffer->getByte();
while ($Count-- > 0 && $Buffer->Remaining() > 0) { while ($count-- > 0 && $buffer->remaining() > 0) {
$Player = []; $player = [];
$Player[ 'Id' ] = $Buffer->GetByte(); // PlayerID, is it just always 0? $player[ 'Id' ] = $buffer->getByte(); // PlayerID, is it just always 0?
$Player[ 'Name' ] = $Buffer->GetString(); $player[ 'Name' ] = $buffer->getString();
$Player[ 'Frags' ] = $Buffer->GetLong(); $player[ 'Frags' ] = $buffer->getLong();
$Player[ 'Time' ] = (int)$Buffer->GetFloat(); $player[ 'Time' ] = (int)$buffer->getFloat();
$Player[ 'TimeF' ] = gmdate(($Player[ 'Time' ] > 3600 ? 'H:i:s' : 'i:s'), $Player[ 'Time' ]); $player[ 'TimeF' ] = gmdate(($player[ 'Time' ] > 3600 ? 'H:i:s' : 'i:s'), $player[ 'Time' ]);
$Players[ ] = $Player; $players[] = $player;
} }
return $Players; return $players;
} }
/** /**
@ -402,70 +401,70 @@ final class SourceQuery
* *
* @return array Returns an array with rules on success * @return array Returns an array with rules on success
*/ */
public function GetRules(): array public function getRules(): array
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
$this->GetChallenge(self::A2S_RULES, self::S2A_RULES); $this->getChallenge(self::A2S_RULES, self::S2A_RULES);
$this->Socket->Write(self::A2S_RULES, $this->Challenge); $this->socket->write(self::A2S_RULES, $this->challenge);
$Buffer = $this->Socket->Read(); $buffer = $this->socket->read();
$Type = $Buffer->GetByte(); $type = $buffer->getByte();
if ($Type !== self::S2A_RULES) { if ($type !== self::S2A_RULES) {
throw new InvalidPacketException('GetRules: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); throw new InvalidPacketException('GetRules: Packet header mismatch. (0x' . dechex($type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
} }
$Rules = []; $rules = [];
$Count = $Buffer->GetShort(); $count = $buffer->getShort();
while ($Count-- > 0 && $Buffer->Remaining() > 0) { while ($count-- > 0 && $buffer->remaining() > 0) {
$Rule = $Buffer->GetString(); $rule = $buffer->getString();
$Value = $Buffer->GetString(); $value = $buffer->getString();
if (!empty($Rule)) { if (!empty($rule)) {
$Rules[ $Rule ] = $Value; $rules[$rule] = $value;
} }
} }
return $Rules; return $rules;
} }
/** /**
* Get challenge (used for players/rules packets) * Get challenge (used for players/rules packets)
* *
* @param int $Header * @param int $header
* @param int $ExpectedResult * @param int $expectedResult
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
*/ */
private function GetChallenge(int $Header, int $ExpectedResult): void private function getChallenge(int $header, int $expectedResult): void
{ {
if ($this->Challenge) { if ($this->challenge) {
return; return;
} }
if ($this->UseOldGetChallengeMethod) { if ($this->useOldGetChallengeMethod) {
$Header = self::A2S_SERVERQUERY_GETCHALLENGE; $header = self::A2S_SERVERQUERY_GETCHALLENGE;
} }
$this->Socket->Write($Header, "\xFF\xFF\xFF\xFF"); $this->socket->write($header, "\xFF\xFF\xFF\xFF");
$Buffer = $this->Socket->Read(); $buffer = $this->socket->read();
$Type = $Buffer->GetByte(); $type = $buffer->getByte();
switch ($Type) { switch ($type) {
case self::S2C_CHALLENGE: case self::S2C_CHALLENGE:
{ {
$this->Challenge = $Buffer->Get(4); $this->challenge = $buffer->get(4);
return; return;
} }
case $ExpectedResult: case $expectedResult:
{ {
// Goldsource (HLTV) // Goldsource (HLTV)
@ -477,7 +476,7 @@ final class SourceQuery
} }
default: default:
{ {
throw new InvalidPacketException('GetChallenge: Packet header mismatch. (0x' . dechex($Type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH); throw new InvalidPacketException('GetChallenge: Packet header mismatch. (0x' . dechex($type) . ')', InvalidPacketException::PACKET_HEADER_MISMATCH);
} }
} }
} }
@ -485,28 +484,28 @@ final class SourceQuery
/** /**
* Sets rcon password, for future use in Rcon() * Sets rcon password, for future use in Rcon()
* *
* @param string $Password Rcon Password * @param string $password Rcon Password
* *
* @throws AuthenticationException * @throws AuthenticationException
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
*/ */
public function SetRconPassword(string $Password): void public function setRconPassword(string $password): void
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
switch ($this->Socket->Engine) { switch ($this->socket->getType()) {
case self::GOLDSOURCE: case SocketType::GOLDSOURCE:
{ {
$this->Rcon = new GoldSourceRcon($this->Socket); $this->rcon = new GoldSourceRcon($this->socket);
break; break;
} }
case self::SOURCE: case SocketType::SOURCE:
{ {
$this->Rcon = new SourceRcon($this->Socket); $this->rcon = new SourceRcon($this->socket);
break; break;
} }
@ -516,31 +515,31 @@ final class SourceQuery
} }
} }
$this->Rcon->Open(); $this->rcon->open();
$this->Rcon->Authorize($Password); $this->rcon->authorize($password);
} }
/** /**
* Sends a command to the server for execution. * Sends a command to the server for execution.
* *
* @param string $Command Command to execute * @param string $command Command to execute
* *
* @throws AuthenticationException * @return string Answer from server in string
* @throws InvalidPacketException *@throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @return string Answer from server in string * @throws AuthenticationException
*/ */
public function Rcon(string $Command): string public function rcon(string $command): string
{ {
if (!$this->Connected) { if (!$this->connected) {
throw new SocketException('Not connected.', SocketException::NOT_CONNECTED); throw new SocketException('Not connected.', SocketException::NOT_CONNECTED);
} }
if ($this->Rcon === null) { if ($this->rcon === null) {
throw new SocketException('You must set a RCON password before trying to execute a RCON command.', SocketException::NOT_CONNECTED); throw new SocketException('You must set a RCON password before trying to execute a RCON command.', SocketException::NOT_CONNECTED);
} }
return $this->Rcon->Command($Command); return $this->rcon->command($command);
} }
} }

@ -1,226 +0,0 @@
<?php
declare(strict_types=1);
/**
* @author Pavel Djundik
*
* @link https://xpaw.me
* @link https://github.com/xPaw/PHP-Source-Query
*
* @license GNU Lesser General Public License, version 2.1
*
* @internal
*/
namespace xPaw\SourceQuery;
use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException;
/**
* Class SourceRcon
*
* @package xPaw\SourceQuery
*
* @uses AuthenticationException
* @uses InvalidPacketException
* @uses SocketException
*/
final class SourceRcon
{
/**
* Points to socket class
*/
private BaseSocket $Socket;
/**
* @var ?resource
*/
private $RconSocket;
/**
* @var int $RconRequestId
*/
private int $RconRequestId = 0;
/**
* @param BaseSocket $Socket
*/
public function __construct(BaseSocket $Socket)
{
$this->Socket = $Socket;
}
/**
* Close
*/
public function Close(): void
{
if ($this->RconSocket) {
fclose($this->RconSocket);
$this->RconSocket = null;
}
$this->RconRequestId = 0;
}
/**
* @throws SocketException
*/
public function Open(): void
{
if (!$this->RconSocket) {
$RconSocket = @fsockopen($this->Socket->Address, $this->Socket->Port, $ErrNo, $ErrStr, $this->Socket->Timeout);
if ($ErrNo || !$RconSocket) {
throw new SocketException('Can\'t connect to RCON server: ' . $ErrStr, SocketException::CONNECTION_FAILED);
}
$this->RconSocket = $RconSocket;
stream_set_timeout($this->RconSocket, $this->Socket->Timeout);
stream_set_blocking($this->RconSocket, true);
}
}
/**
* @param int $Header
* @param string $String
*
* @return bool
*/
public function Write(int $Header, string $String = ''): bool
{
// Pack the packet together
$Command = pack('VV', ++$this->RconRequestId, $Header) . $String . "\x00\x00";
// Prepend packet length
$Command = pack('V', strlen($Command)) . $Command;
$Length = strlen($Command);
return $Length === fwrite($this->RconSocket, $Command, $Length);
}
/**
* @return Buffer
*
* @throws InvalidPacketException
*/
public function Read(): Buffer
{
$Buffer = new Buffer();
$Buffer->Set(fread($this->RconSocket, 4));
if ($Buffer->Remaining() < 4) {
throw new InvalidPacketException('Rcon read: Failed to read any data from socket', InvalidPacketException::BUFFER_EMPTY);
}
$PacketSize = $Buffer->GetLong();
$Buffer->Set(fread($this->RconSocket, $PacketSize));
$Data = $Buffer->Get();
$Remaining = $PacketSize - strlen($Data);
while ($Remaining > 0) {
$Data2 = fread($this->RconSocket, $Remaining);
$PacketSize = strlen($Data2);
if ($PacketSize === 0) {
throw new InvalidPacketException('Read ' . strlen($Data) . ' bytes from socket, ' . $Remaining . ' remaining', InvalidPacketException::BUFFER_EMPTY);
}
$Data .= $Data2;
$Remaining -= $PacketSize;
}
$Buffer->Set($Data);
return $Buffer;
}
/**
* @param string $Command
*
* @return string
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function Command(string $Command): string
{
$this->Write(SourceQuery::SERVERDATA_EXECCOMMAND, $Command);
$Buffer = $this->Read();
$Buffer->GetLong(); // RequestID
$Type = $Buffer->GetLong();
if ($Type === SourceQuery::SERVERDATA_AUTH_RESPONSE) {
throw new AuthenticationException('Bad rcon_password.', AuthenticationException::BAD_PASSWORD);
} elseif ($Type !== SourceQuery::SERVERDATA_RESPONSE_VALUE) {
throw new InvalidPacketException('Invalid rcon response.', InvalidPacketException::PACKET_HEADER_MISMATCH);
}
$Data = $Buffer->Get();
// We do this stupid hack to handle split packets
// See https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Multiple-packet_Responses
if (strlen($Data) >= 4000) {
$this->Write(SourceQuery::SERVERDATA_REQUESTVALUE);
do {
$Buffer = $this->Read();
$Buffer->GetLong(); // RequestID
if ($Buffer->GetLong() !== SourceQuery::SERVERDATA_RESPONSE_VALUE) {
break;
}
$Data2 = $Buffer->Get();
if ($Data2 === "\x00\x01\x00\x00\x00\x00") {
break;
}
$Data .= $Data2;
} while (true);
}
return rtrim($Data, "\0");
}
/**
* @param string $Password
*
* @throws AuthenticationException
* @throws InvalidPacketException
*/
public function Authorize(string $Password): void
{
$this->Write(SourceQuery::SERVERDATA_AUTH, $Password);
$Buffer = $this->Read();
$RequestID = $Buffer->GetLong();
$Type = $Buffer->GetLong();
// If we receive SERVERDATA_RESPONSE_VALUE, then we need to read again
// More info: https://developer.valvesoftware.com/wiki/Source_RCON_Protocol#Additional_Comments
if ($Type === SourceQuery::SERVERDATA_RESPONSE_VALUE) {
$Buffer = $this->Read();
$RequestID = $Buffer->GetLong();
$Type = $Buffer->GetLong();
}
if ($RequestID === -1 || $Type !== SourceQuery::SERVERDATA_AUTH_RESPONSE) {
throw new AuthenticationException('RCON authorization failed.', AuthenticationException::BAD_PASSWORD);
}
}
}

@ -3,118 +3,25 @@
declare(strict_types=1); declare(strict_types=1);
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use xPaw\SourceQuery\BaseSocket;
use xPaw\SourceQuery\SourceQuery; use xPaw\SourceQuery\SourceQuery;
use xPaw\SourceQuery\Buffer;
use xPaw\SourceQuery\Exception\AuthenticationException; use xPaw\SourceQuery\Exception\AuthenticationException;
use xPaw\SourceQuery\Exception\InvalidArgumentException; use xPaw\SourceQuery\Exception\InvalidArgumentException;
use xPaw\SourceQuery\Exception\InvalidPacketException; use xPaw\SourceQuery\Exception\InvalidPacketException;
use xPaw\SourceQuery\Exception\SocketException; use xPaw\SourceQuery\Exception\SocketException;
use xPaw\SourceQuery\Socket\SocketType;
final class TestableSocket extends BaseSocket use xPaw\SourceQuery\Socket\TestableSocket;
{
/**
* @var SplQueue<string>
*/
private SplQueue $PacketQueue;
/**
* TestableSocket constructor.
*/
public function __construct()
{
$this->PacketQueue = new SplQueue();
$this->PacketQueue->setIteratorMode(SplDoublyLinkedList::IT_MODE_DELETE);
}
/**
* @param string $Data
*/
public function Queue(string $Data): void
{
$this->PacketQueue->push($Data);
}
/**
* Close.
*/
public function Close(): void
{
}
/**
* @param string $Address
* @param int $Port
* @param int $Timeout
* @param int $Engine
*/
public function Open(string $Address, int $Port, int $Timeout, int $Engine): void
{
$this->Timeout = $Timeout;
$this->Engine = $Engine;
$this->Port = $Port;
$this->Address = $Address;
}
/**
* @param int $Header
* @param string $String
*
* @return bool
*/
public function Write(int $Header, string $String = ''): bool
{
return true;
}
/**
* @param int $Length
*
* @return Buffer
*
* @throws InvalidPacketException
* @throws SocketException
*/
public function Read(int $Length = 1400): Buffer
{
$Buffer = new Buffer();
$Buffer->Set($this->PacketQueue->shift());
$this->ReadInternal($Buffer, $Length, [ $this, 'Sherlock' ]);
return $Buffer;
}
/**
* @param Buffer $Buffer
*
* @return bool
*
* @throws InvalidPacketException
*/
public function Sherlock(Buffer $Buffer): bool
{
if ($this->PacketQueue->isEmpty()) {
return false;
}
$Buffer->Set($this->PacketQueue->shift());
return $Buffer->GetLong() === -2;
}
}
final class Tests extends TestCase final class Tests extends TestCase
{ {
/** /**
* @var TestableSocket $Socket * @var TestableSocket
*/ */
private TestableSocket $Socket; private TestableSocket $socket;
/** /**
* @var SourceQuery $SourceQuery * @var SourceQuery
*/ */
private SourceQuery $SourceQuery; private SourceQuery $sourceQuery;
/** /**
* @throws SocketException * @throws SocketException
@ -122,9 +29,9 @@ final class Tests extends TestCase
*/ */
public function setUp(): void public function setUp(): void
{ {
$this->Socket = new TestableSocket(); $this->socket = new TestableSocket(SocketType::SOURCE);
$this->SourceQuery = new SourceQuery($this->Socket); $this->sourceQuery = new SourceQuery($this->socket);
$this->SourceQuery->Connect('', 2); $this->sourceQuery->connect('', 2);
} }
/** /**
@ -132,9 +39,9 @@ final class Tests extends TestCase
*/ */
public function tearDown(): void public function tearDown(): void
{ {
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
unset($this->Socket, $this->SourceQuery); unset($this->socket, $this->sourceQuery);
} }
/** /**
@ -144,8 +51,8 @@ final class Tests extends TestCase
public function testInvalidTimeout(): void public function testInvalidTimeout(): void
{ {
$this->expectException(InvalidArgumentException::class); $this->expectException(InvalidArgumentException::class);
$SourceQuery = new SourceQuery(); $SourceQuery = new SourceQuery($this->socket);
$SourceQuery->Connect('', 2, -1); $SourceQuery->connect('', 2, -1);
} }
/** /**
@ -155,8 +62,8 @@ final class Tests extends TestCase
public function testNotConnectedGetInfo(): void public function testNotConnectedGetInfo(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->GetInfo(); $this->sourceQuery->getInfo();
} }
/** /**
@ -166,8 +73,8 @@ final class Tests extends TestCase
public function testNotConnectedPing(): void public function testNotConnectedPing(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->Ping(); $this->sourceQuery->ping();
} }
/** /**
@ -177,8 +84,8 @@ final class Tests extends TestCase
public function testNotConnectedGetPlayers(): void public function testNotConnectedGetPlayers(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->GetPlayers(); $this->sourceQuery->getPlayers();
} }
/** /**
@ -188,8 +95,8 @@ final class Tests extends TestCase
public function testNotConnectedGetRules(): void public function testNotConnectedGetRules(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->GetRules(); $this->sourceQuery->getRules();
} }
/** /**
@ -200,8 +107,8 @@ final class Tests extends TestCase
public function testNotConnectedSetRconPassword(): void public function testNotConnectedSetRconPassword(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->SetRconPassword('a'); $this->sourceQuery->setRconPassword('a');
} }
/** /**
@ -212,8 +119,8 @@ final class Tests extends TestCase
public function testNotConnectedRcon(): void public function testNotConnectedRcon(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Disconnect(); $this->sourceQuery->disconnect();
$this->SourceQuery->Rcon('a'); $this->sourceQuery->rcon('a');
} }
/** /**
@ -224,29 +131,32 @@ final class Tests extends TestCase
public function testRconWithoutPassword(): void public function testRconWithoutPassword(): void
{ {
$this->expectException(SocketException::class); $this->expectException(SocketException::class);
$this->SourceQuery->Rcon('a'); $this->sourceQuery->rcon('a');
} }
/** /**
* @param string $RawInput * @param string $rawInput
* @param array $ExpectedOutput * @param array $expectedOutput
* *
* @throws InvalidArgumentException
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider InfoProvider * @dataProvider infoProvider
*/ */
public function testGetInfo(string $RawInput, array $ExpectedOutput): void public function testGetInfo(string $rawInput, array $expectedOutput): void
{ {
if (isset($ExpectedOutput[ 'IsMod' ])) { if (isset($expectedOutput[ 'IsMod' ])) {
$this->Socket->Engine = SourceQuery::GOLDSOURCE; $this->socket = new TestableSocket(SocketType::GOLDSOURCE);
$this->sourceQuery = new SourceQuery($this->socket);
$this->sourceQuery->connect('', 2);
} }
$this->Socket->Queue($RawInput); $this->socket->queue($rawInput);
$RealOutput = $this->SourceQuery->GetInfo(); $realOutput = $this->sourceQuery->getInfo();
self::assertEquals($ExpectedOutput, $RealOutput); self::assertEquals($expectedOutput, $realOutput);
} }
/** /**
@ -254,19 +164,19 @@ final class Tests extends TestCase
* *
* @throws JsonException * @throws JsonException
*/ */
public function InfoProvider(): array public function infoProvider(): array
{ {
$DataProvider = []; $dataProvider = [];
$Files = glob(__DIR__ . '/Info/*.raw', GLOB_ERR); $files = glob(__DIR__ . '/Info/*.raw', GLOB_ERR);
foreach ($Files as $File) { foreach ($files as $file) {
$DataProvider[] = $dataProvider[] =
[ [
hex2bin(trim(file_get_contents($File))), hex2bin(trim(file_get_contents($file))),
json_decode( json_decode(
file_get_contents( file_get_contents(
str_replace('.raw', '.json', $File) str_replace('.raw', '.json', $file)
), ),
true, true,
512, 512,
@ -275,79 +185,79 @@ final class Tests extends TestCase
]; ];
} }
return $DataProvider; return $dataProvider;
} }
/** /**
* @param string $Data * @param string $data
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider BadPacketProvider * @dataProvider badPacketProvider
*/ */
public function testBadGetInfo(string $Data): void public function testBadGetInfo(string $data): void
{ {
$this->expectException(InvalidPacketException::class); $this->expectException(InvalidPacketException::class);
$this->Socket->Queue($Data); $this->socket->queue($data);
$this->SourceQuery->GetInfo(); $this->sourceQuery->getInfo();
} }
/** /**
* @param string $Data * @param string $data
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider BadPacketProvider * @dataProvider badPacketProvider
*/ */
public function testBadGetChallengeViaPlayers(string $Data): void public function testBadGetChallengeViaPlayers(string $data): void
{ {
$this->expectException(InvalidPacketException::class); $this->expectException(InvalidPacketException::class);
$this->Socket->Queue($Data); $this->socket->queue($data);
$this->SourceQuery->GetPlayers(); $this->sourceQuery->getPlayers();
} }
/** /**
* @param string $Data * @param string $data
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider BadPacketProvider * @dataProvider badPacketProvider
*/ */
public function testBadGetPlayersAfterCorrectChallenge(string $Data): void public function testBadGetPlayersAfterCorrectChallenge(string $data): void
{ {
$this->expectException(InvalidPacketException::class); $this->expectException(InvalidPacketException::class);
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); $this->socket->queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11");
$this->Socket->Queue($Data); $this->socket->queue($data);
$this->SourceQuery->GetPlayers(); $this->sourceQuery->getPlayers();
} }
/** /**
* @param string $Data * @param string $data
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider BadPacketProvider * @dataProvider badPacketProvider
*/ */
public function testBadGetRulesAfterCorrectChallenge(string $Data): void public function testBadGetRulesAfterCorrectChallenge(string $data): void
{ {
$this->expectException(InvalidPacketException::class); $this->expectException(InvalidPacketException::class);
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); $this->socket->queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11");
$this->Socket->Queue($Data); $this->socket->queue($data);
$this->SourceQuery->GetRules(); $this->sourceQuery->getRules();
} }
/** /**
* @return string[][] * @return string[][]
*/ */
public function BadPacketProvider(): array public function badPacketProvider(): array
{ {
return return
[ [
@ -367,34 +277,34 @@ final class Tests extends TestCase
*/ */
public function testGetChallengeTwice(): void public function testGetChallengeTwice(): void
{ {
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11"); $this->socket->queue("\xFF\xFF\xFF\xFF\x41\x11\x11\x11\x11");
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x45\x01\x00ayy\x00lmao\x00"); $this->socket->queue("\xFF\xFF\xFF\xFF\x45\x01\x00ayy\x00lmao\x00");
self::assertEquals([ 'ayy' => 'lmao' ], $this->SourceQuery->GetRules()); self::assertEquals([ 'ayy' => 'lmao' ], $this->sourceQuery->getRules());
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x45\x01\x00wow\x00much\x00"); $this->socket->queue("\xFF\xFF\xFF\xFF\x45\x01\x00wow\x00much\x00");
self::assertEquals([ 'wow' => 'much' ], $this->SourceQuery->GetRules()); self::assertEquals([ 'wow' => 'much' ], $this->sourceQuery->getRules());
} }
/** /**
* @param array $RawInput * @param string[] $rawInput
* @param array $ExpectedOutput * @param array $expectedOutput
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider RulesProvider * @dataProvider rulesProvider
*/ */
public function testGetRules(array $RawInput, array $ExpectedOutput): void public function testGetRules(array $rawInput, array $expectedOutput): void
{ {
$this->Socket->Queue(hex2bin("ffffffff4104fce20e")); // Challenge $this->socket->queue(hex2bin("ffffffff4104fce20e")); // Challenge
foreach ($RawInput as $Packet) { foreach ($rawInput as $packet) {
$this->Socket->Queue(hex2bin($Packet)); $this->socket->queue(hex2bin($packet));
} }
$RealOutput = $this->SourceQuery->GetRules(); $realOutput = $this->sourceQuery->getRules();
self::assertEquals($ExpectedOutput, $RealOutput); self::assertEquals($expectedOutput, $realOutput);
} }
/** /**
@ -402,19 +312,19 @@ final class Tests extends TestCase
* *
* @throws JsonException * @throws JsonException
*/ */
public function RulesProvider(): array public function rulesProvider(): array
{ {
$DataProvider = []; $dataProvider = [];
$Files = glob(__DIR__ . '/Rules/*.raw', GLOB_ERR); $files = glob(__DIR__ . '/Rules/*.raw', GLOB_ERR);
foreach ($Files as $File) { foreach ($files as $file) {
$DataProvider[] = $dataProvider[] =
[ [
file($File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES), file($file, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES),
json_decode( json_decode(
file_get_contents( file_get_contents(
str_replace('.raw', '.json', $File) str_replace('.raw', '.json', $file)
), ),
true, true,
512, 512,
@ -423,29 +333,29 @@ final class Tests extends TestCase
]; ];
} }
return $DataProvider; return $dataProvider;
} }
/** /**
* @param string[] $RawInput * @param string[] $rawInput
* @param array $ExpectedOutput * @param array $expectedOutput
* *
* @throws InvalidPacketException * @throws InvalidPacketException
* @throws SocketException * @throws SocketException
* *
* @dataProvider PlayersProvider * @dataProvider playersProvider
*/ */
public function testGetPlayers(array $RawInput, array $ExpectedOutput): void public function testGetPlayers(array $rawInput, array $expectedOutput): void
{ {
$this->Socket->Queue(hex2bin("ffffffff4104fce20e")); // Challenge $this->socket->queue(hex2bin("ffffffff4104fce20e")); // Challenge
foreach ($RawInput as $Packet) { foreach ($rawInput as $packet) {
$this->Socket->Queue(hex2bin($Packet)); $this->socket->queue(hex2bin($packet));
} }
$RealOutput = $this->SourceQuery->GetPlayers(); $realOutput = $this->sourceQuery->getPlayers();
self::assertEquals($ExpectedOutput, $RealOutput); self::assertEquals($expectedOutput, $realOutput);
} }
/** /**
@ -453,19 +363,19 @@ final class Tests extends TestCase
* *
* @throws JsonException * @throws JsonException
*/ */
public function PlayersProvider(): array public function playersProvider(): array
{ {
$DataProvider = []; $dataProvider = [];
$Files = glob(__DIR__ . '/Players/*.raw', GLOB_ERR); $files = glob(__DIR__ . '/Players/*.raw', GLOB_ERR);
foreach ($Files as $File) { foreach ($files as $file) {
$DataProvider[] = $dataProvider[] =
[ [
file($File, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES), file($file, FILE_SKIP_EMPTY_LINES | FILE_IGNORE_NEW_LINES),
json_decode( json_decode(
file_get_contents( file_get_contents(
str_replace('.raw', '.json', $File) str_replace('.raw', '.json', $file)
), ),
true, true,
512, 512,
@ -474,7 +384,7 @@ final class Tests extends TestCase
]; ];
} }
return $DataProvider; return $dataProvider;
} }
/** /**
@ -483,10 +393,10 @@ final class Tests extends TestCase
*/ */
public function testPing(): void public function testPing(): void
{ {
$this->Socket->Queue("\xFF\xFF\xFF\xFF\x6A\x00"); $this->socket->queue("\xFF\xFF\xFF\xFF\x6A\x00");
self::assertTrue($this->SourceQuery->Ping()); self::assertTrue($this->sourceQuery->ping());
$this->Socket->Queue("\xFF\xFF\xFF\xFF\xEE"); $this->socket->queue("\xFF\xFF\xFF\xFF\xEE");
self::assertFalse($this->SourceQuery->Ping()); self::assertFalse($this->sourceQuery->ping());
} }
} }

Loading…
Cancel
Save